payload
Version:
Node, React, Headless CMS and Application Framework built on Next.js
363 lines (362 loc) • 12.1 kB
JavaScript
// @ts-no-check
/**
* THIS FILE IS BASED ON:
* https://github.com/rocicorp/fractional-indexing/blob/main/src/index.js
*
* MODIFIED FOR PAYLOAD CMS:
* - Changed the integer part encoding to use only digits for "small" keys and
* only lowercase letters for "large" keys, ensuring consistent ordering
* across databases with different collations.
*
* - Original algorithm used A-Z (uppercase) for "smaller" integers and a-z (lowercase)
* for "larger" integers, relying on ASCII ordering where 'Z' < 'a'.
*
* - Some databases (e.g., PostgreSQL with default collation) use case-insensitive
* comparison, treating 'Z' as 'z', which breaks the ordering.
*
* - New encoding:
* - Uses digits '0'-'9' for "small" integers (10 values, lengths 11 down to 2)
* - Uses lowercase 'a'-'z' for "large" integers (26 values, lengths 2 up to 27)
* - Digits ALWAYS sort before letters in both ASCII and case-insensitive orderings.
*
* - Ordering: '0...' < '1...' < ... < '9..' < 'a.' < 'b..' < ... < 'z...'
*
* BACKWARD COMPATIBILITY:
* - Existing keys starting with lowercase 'a'-'z' remain valid and work correctly.
* - Keys starting with uppercase 'A'-'Z' (from the old algorithm) will still be
* parsed for backward compatibility, but they may sort incorrectly in
* case-insensitive databases. Consider running a migration to convert them.
*/ // License: CC0 (no rights reserved).
// This is based on https://observablehq.com/@dgreensp/implementing-fractional-indexing
export const BASE_36_DIGITS = '0123456789abcdefghijklmnopqrstuvwxyz';
// `a` may be empty string, `b` is null or non-empty string.
// `a < b` lexicographically if `b` is non-null.
// no trailing zeros allowed.
// digits is a string such as '0123456789' for base 10. Digits must be in
// ascending character code order!
/**
* @param {string} a
* @param {string | null | undefined} b
* @param {string} digits
* @returns {string}
*/ function midpoint(a, b, digits) {
const zero = digits[0];
if (b != null && a >= b) {
throw new Error(a + ' >= ' + b);
}
if (a.slice(-1) === zero || b && b.slice(-1) === zero) {
throw new Error('trailing zero');
}
if (b) {
// remove longest common prefix. pad `a` with 0s as we
// go. note that we don't need to pad `b`, because it can't
// end before `a` while traversing the common prefix.
let n = 0;
while((a[n] || zero) === b[n]){
n++;
}
if (n > 0) {
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits);
}
}
// first digits (or lack of digit) are different
const digitA = a ? digits.indexOf(a[0]) : 0;
const digitB = b != null ? digits.indexOf(b[0]) : digits.length;
if (digitB - digitA > 1) {
const midDigit = Math.round(0.5 * (digitA + digitB));
return digits[midDigit];
} else {
// first digits are consecutive
if (b && b.length > 1) {
return b.slice(0, 1);
} else {
// `b` is null or has length 1 (a single digit).
// the first digit of `a` is the previous digit to `b`,
// or 9 if `b` is null.
// given, for example, midpoint('49', '5'), return
// '4' + midpoint('9', null), which will become
// '4' + '9' + midpoint('', null), which is '495'
return digits[digitA] + midpoint(a.slice(1), null, digits);
}
}
}
/**
* @param {string} int
* @return {void}
*/ function validateInteger(int) {
if (int.length !== getIntegerLength(int[0])) {
throw new Error('invalid integer part of order key: ' + int);
}
}
/**
* Returns the length of the integer part based on the head character.
*
* New encoding (case-insensitive safe):
* - SMALL range (digits): '0' = 11 chars, '1' = 10 chars, ..., '9' = 2 chars
* - LARGE range (lowercase): 'a' = 2 chars, 'b' = 3 chars, ..., 'z' = 27 chars
*
* Legacy encoding (for backward compatibility with existing keys):
* - 'A'-'Z' uppercase: 'A' = 27 chars, 'B' = 26 chars, ..., 'Z' = 2 chars
*
* @param {string} head
* @return {number}
*/ function getIntegerLength(head) {
if (head >= '0' && head <= '9') {
return 11 - (head.charCodeAt(0) - '0'.charCodeAt(0));
} else if (head >= 'a' && head <= 'z') {
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2;
} else if (head >= 'A' && head <= 'Z') {
// Legacy encoding
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2;
} else {
throw new Error('invalid order key head: ' + head);
}
}
/**
* @param {string} key
* @return {string}
*/ function getIntegerPart(key) {
const integerPartLength = getIntegerLength(key[0]);
if (integerPartLength > key.length) {
throw new Error('invalid order key: ' + key);
}
return key.slice(0, integerPartLength);
}
/**
* Smallest possible key (for validation)
* '0' + 10 zeros = smallest valid key in new format
*/ const SMALLEST_KEY = '0' + BASE_36_DIGITS[0].repeat(10);
/**
* @param {string} key
* @param {string} digits
* @return {void}
*/ function validateOrderKey(key, digits) {
if (key === SMALLEST_KEY) {
throw new Error('invalid order key: ' + key);
}
// Legacy check for old format
if (key === 'A' + digits[0].repeat(26)) {
throw new Error('invalid order key: ' + key);
}
// getIntegerPart will throw if the first character is bad,
// or the key is too short. we'd call it to check these things
// even if we didn't need the result
const i = getIntegerPart(key);
const f = key.slice(i.length);
if (f.slice(-1) === digits[0]) {
throw new Error('invalid order key: ' + key);
}
}
// note that this may return null, as there is a largest integer
/**
* @param {string} x
* @param {string} digits
* @return {string | null}
*/ function incrementInteger(x, digits) {
validateInteger(x);
const [head, ...digs] = x.split('');
let carry = true;
for(let i = digs.length - 1; carry && i >= 0; i--){
const d = digits.indexOf(digs[i]) + 1;
if (d === digits.length) {
digs[i] = digits[0];
} else {
digs[i] = digits[d];
carry = false;
}
}
if (carry) {
if (head === '9') {
return 'a' + digits[0];
}
// Handle legacy uppercase transition
if (head === 'Z') {
return 'a' + digits[0];
}
if (head === 'z') {
return null;
}
let h;
if (head >= '0' && head <= '8') {
h = String.fromCharCode(head.charCodeAt(0) + 1);
digs.pop();
} else if (head >= 'a' && head <= 'y') {
h = String.fromCharCode(head.charCodeAt(0) + 1);
digs.push(digits[0]);
} else if (head >= 'A' && head <= 'Y') {
// Legacy uppercase
h = String.fromCharCode(head.charCodeAt(0) + 1);
digs.pop();
} else {
throw new Error('invalid head: ' + head);
}
return h + digs.join('');
} else {
return head + digs.join('');
}
}
// note that this may return null, as there is a smallest integer
/**
* @param {string} x
* @param {string} digits
* @return {string | null}
*/ function decrementInteger(x, digits) {
validateInteger(x);
const [head, ...digs] = x.split('');
let borrow = true;
for(let i = digs.length - 1; borrow && i >= 0; i--){
const d = digits.indexOf(digs[i]) - 1;
if (d === -1) {
digs[i] = digits.slice(-1);
} else {
digs[i] = digits[d];
borrow = false;
}
}
if (borrow) {
if (head === 'a') {
return '9' + digits.slice(-1);
}
if (head === '0') {
return null;
}
let h;
if (head >= '1' && head <= '9') {
h = String.fromCharCode(head.charCodeAt(0) - 1);
digs.push(digits.slice(-1));
} else if (head >= 'b' && head <= 'z') {
h = String.fromCharCode(head.charCodeAt(0) - 1);
digs.pop();
} else if (head >= 'B' && head <= 'Z') {
// Legacy uppercase
h = String.fromCharCode(head.charCodeAt(0) - 1);
digs.push(digits.slice(-1));
} else if (head === 'A') {
// Legacy uppercase
return null;
} else {
throw new Error('invalid head: ' + head);
}
return h + digs.join('');
} else {
return head + digs.join('');
}
}
// `a` is an order key or null (START).
// `b` is an order key or null (END).
// `a < b` lexicographically if both are non-null.
// digits is a string such as '0123456789' for base 10. Digits must be in
// ascending character code order!
/**
* @param {string | null | undefined} a
* @param {string | null | undefined} b
* @param {string=} digits
* @return {string}
*/ export function generateKeyBetween(a, b, digits = BASE_36_DIGITS) {
if (a != null) {
validateOrderKey(a, digits);
}
if (b != null) {
validateOrderKey(b, digits);
}
if (a != null && b != null && a >= b) {
throw new Error(a + ' >= ' + b);
}
if (a == null) {
if (b == null) {
return 'a' + digits[0];
}
const ib = getIntegerPart(b);
const fb = b.slice(ib.length);
if (ib === SMALLEST_KEY) {
return ib + midpoint('', fb, digits);
}
// Legacy check
if (ib === 'A' + digits[0].repeat(26)) {
return ib + midpoint('', fb, digits);
}
if (ib < b) {
return ib;
}
const res = decrementInteger(ib, digits);
if (res == null) {
throw new Error('cannot decrement any more');
}
return res;
}
if (b == null) {
const ia = getIntegerPart(a);
const fa = a.slice(ia.length);
const i = incrementInteger(ia, digits);
return i == null ? ia + midpoint(fa, null, digits) : i;
}
const ia = getIntegerPart(a);
const fa = a.slice(ia.length);
const ib = getIntegerPart(b);
const fb = b.slice(ib.length);
if (ia === ib) {
return ia + midpoint(fa, fb, digits);
}
const i = incrementInteger(ia, digits);
if (i == null) {
throw new Error('cannot increment any more');
}
if (i < b) {
return i;
}
return ia + midpoint(fa, null, digits);
}
/**
* same preconditions as generateKeysBetween.
* n >= 0.
* Returns an array of n distinct keys in sorted order.
* If a and b are both null, returns [a0, a1, ...]
* If one or the other is null, returns consecutive "integer"
* keys. Otherwise, returns relatively short keys between
* a and b.
* @param {string | null | undefined} a
* @param {string | null | undefined} b
* @param {number} n
* @param {string} digits
* @return {string[]}
*/ export function generateNKeysBetween(a, b, n, digits = BASE_36_DIGITS) {
if (n === 0) {
return [];
}
if (n === 1) {
return [
generateKeyBetween(a, b, digits)
];
}
if (b == null) {
let c = generateKeyBetween(a, b, digits);
const result = [
c
];
for(let i = 0; i < n - 1; i++){
c = generateKeyBetween(c, b, digits);
result.push(c);
}
return result;
}
if (a == null) {
let c = generateKeyBetween(a, b, digits);
const result = [
c
];
for(let i = 0; i < n - 1; i++){
c = generateKeyBetween(a, c, digits);
result.push(c);
}
result.reverse();
return result;
}
const mid = Math.floor(n / 2);
const c = generateKeyBetween(a, b, digits);
return [
...generateNKeysBetween(a, c, mid, digits),
c,
...generateNKeysBetween(c, b, n - mid - 1, digits)
];
}
//# sourceMappingURL=fractional-indexing.js.map