UNPKG

fugue

Version:

Fractional indexing without conflicts.

400 lines (361 loc) 12.3 kB
const FIRST = ""; const LAST = "~"; export type FuguePosition<TClientID extends string = string> = | `${string}${TClientID}.${string}` | typeof FIRST | typeof LAST; export type FugueOptions<TClientID extends string = string> = { /** * The unique ID for this client. */ clientID: TClientID; }; export class Fugue<TClientID extends string = string> { /** * A string that is less than all positions. */ static readonly FIRST = FIRST; /** * A string that is greater than all positions. */ static readonly LAST = LAST; /** * The unique ID for this client. */ readonly clientID: TClientID; /** * The waypoints' long name: `,${clientID}.`. */ private readonly longName: `,${TClientID}.`; /** * Variant of longName used for a position's first ID: `${clientID}.`. * (Otherwise every position would start with a redundant ','.) */ private readonly firstName: `${TClientID}.`; /** * For each waypoint that we created, maps a prefix (see getPrefix) * for that waypoint to its last (most recent) valueSeq. * We always store the right-side version (odd valueSeq). */ private lastValueSeqs = new Map<string, number>(); private readonly maxCachedPrefixes = 1000; constructor(clientID: TClientID) { const clientIDSanitized = sanitizeClientID(clientID); this.longName = `,${clientIDSanitized}.` as const; this.firstName = `${clientIDSanitized}.` as const; this.clientID = clientIDSanitized; } /** * Creates a new position between two existing positions. * The new position will be greater than `a` and less than `b`. * * @param a - An existing position to insert after, or null to insert at the beginning * @param b - An existing position to insert before, or null to insert at the end * @returns A new position that satisfies `a < new < b` * * @example * ```typescript * const fugue = new Fugue("client1"); * const pos1 = fugue.between(null, null); // First position * const pos2 = fugue.between(pos1, null); // Insert after pos1 * const pos3 = fugue.between(pos1, pos2); // Insert between pos1 and pos2 * // pos1 < pos3 < pos2 * ``` * * @throws Will warn and adjust inputs if: * - `a >= b` (when both are non-null) * - `b > Fugue.LAST` */ between(a: string | null, b: string | null) { let left = a; let right = b; if (left !== null && right !== null && left >= right) { console.warn( `left must be less than right: ${left} < ${right} - using ${Fugue.FIRST} instead`, ); left = Fugue.FIRST; } if (right !== null && right > Fugue.LAST) { console.warn( `right must be less than or equal to LAST: ${right} > ${Fugue.LAST} - using ${Fugue.LAST} instead`, ); right = Fugue.LAST; } let ans: string; if (right !== null && (left === null || right.startsWith(left))) { // Left child of right. This always appends a waypoint. const ancestor = leftVersion(right); ans = this.appendWaypoint(ancestor); } else { // Right child of left. if (left === null) { // ancestor is FIRST. ans = this.appendWaypoint(""); } else { // Check if we can reuse left's prefix. // It needs to be one of ours, and right can't use the same // prefix (otherwise we would get ans > right by comparing right's // older valueIndex to our new valueIndex). const prefix = getPrefix(left); const lastValueSeq = prefix ? (this.lastValueSeqs.get(prefix) ?? null) : null; if ( prefix !== null && lastValueSeq !== null && !(right !== null && right.startsWith(prefix)) ) { // Reuse. const valueSeq = nextOddValueSeq(lastValueSeq); ans = prefix + stringifyBase52(valueSeq); this.lastValueSeqs.set(prefix, valueSeq); } else { // Append waypoint. ans = this.appendWaypoint(left); } } } return ans as FuguePosition<TClientID> & {}; } /** * Creates a new position immediately after the given position. * This is equivalent to calling `between(position, null)`. * * @param position - The existing position to insert after * @returns A new position that is greater than the given position * * @example * ```typescript * const fugue = new Fugue("client1"); * const pos1 = fugue.between(null, null); // First position * const pos2 = fugue.after(pos1); // Insert after pos1 * // pos1 < pos2 * ``` */ after(position: string) { return this.between(position, null); } /** * Creates a new position immediately before the given position. * This is equivalent to calling `between(null, position)`. * * @param position - The existing position to insert before * @returns A new position that is less than the given position * * @example * ```typescript * const fugue = new Fugue("client1"); * const pos1 = fugue.between(null, null); // First position * const pos2 = fugue.before(pos1); // Insert before pos1 * // pos2 < pos1 * ``` */ before(position: string) { return this.between(null, position); } /** * Creates the first position in a sequence. * This is equivalent to calling `between(null, null)`. * * @returns A new position that is greater than all existing positions * * @example * ```typescript * const fugue = new Fugue("client1"); * const pos1 = fugue.first(); // First position * const pos2 = fugue.after(pos1); // Insert after pos1 * // pos1 < pos2 * ``` */ first() { return this.between(null, null); } /** * Appends a waypoint to the ancestor. */ private appendWaypoint(ancestor: string) { let waypointName: string = ancestor === "" ? this.firstName : this.longName; // If our ID already appears in ancestor, instead use a short // name for the waypoint. // Here we use the uniqueness of ',' and '.' to // claim that if this.longName (= `,${ID}.`) appears in ancestor, then it // must actually be from a waypoint that we created. let existing = ancestor.lastIndexOf(this.longName); if (ancestor.startsWith(this.firstName)) existing = 0; if (existing !== -1) { // Find the index of existing among the long-name // waypoints, in backwards order. Here we use the fact that // each longName ends with '.' and that '.' does not appear otherwise. let index = -1; for (let i = existing; i < ancestor.length; i++) { if (ancestor[i] === ".") index++; } waypointName = stringifyShortName(index); } const prefix = ancestor + waypointName; const lastValueSeq = this.lastValueSeqs.get(prefix); // Use next odd (right-side) valueSeq (1 if it's a new waypoint). const valueSeq = lastValueSeq === undefined ? 1 : nextOddValueSeq(lastValueSeq); this.lastValueSeqs.set(prefix, valueSeq); this.cleanupLastValueSeqs(); // Add cleanup check after setting new values return prefix + stringifyBase52(valueSeq); } /** * The number of prefixes in the cache. */ get cacheSize() { return this.lastValueSeqs.size; } /** * Cleans up the cache of last value sequences. */ private cleanupLastValueSeqs() { if (this.lastValueSeqs.size > this.maxCachedPrefixes) { // Convert to array, sort by values (most recent first), and take only the most recent entries const entries = Array.from(this.lastValueSeqs.entries()) .sort(([, a], [, b]) => b - a) .slice(0, this.maxCachedPrefixes); this.lastValueSeqs = new Map(entries); } } } /** * Returns position's *prefix*: the string through the last waypoint * name, or equivalently, without the final valueSeq. */ export function getPrefix(position: string) { // Last waypoint char is the last '.' (for long names) or // digit (for short names). Note that neither appear in valueSeq, // which is all letters. for (let i = position.length - 2; i >= 0; i--) { const char = position[i]; if (char !== undefined && (char === "." || ("0" <= char && char <= "9"))) { // i is the last waypoint char, i.e., the end of the prefix. return position.slice(0, i + 1); } } return null; } /** * Returns the variant of position ending with a "left" marker * instead of the default "right" marker. * * I.e., the ancestor for position's left descendants. */ export function leftVersion(position: string) { const lastWaypointChar = position[position.length - 1]; if (lastWaypointChar === undefined) { return ""; } // We need to subtract one from the (odd) valueSeq, equivalently, from // its last base52 digit. const last = parseBase52(lastWaypointChar); return position.slice(0, -1) + stringifyBase52(last - 1); } /** * Base 52, except for last digit, which is base 10 using * digits. If less than 0, "A". */ export function stringifyShortName(n: number) { if (n < 0) { return "A"; } else if (n < 10) { return String.fromCharCode(48 + n); } else { return ( stringifyBase52(Math.floor(n / 10)) + String.fromCharCode(48 + (n % 10)) ); } } /** * Base 52 encoding using letters (with "digits" in order by code point). */ export function stringifyBase52(n: number) { if (n === 0) { return "A"; } const codes: number[] = []; while (n > 0) { const digit = n % 52; codes.unshift((digit >= 26 ? 71 : 65) + digit); n = Math.floor(n / 52); } return String.fromCharCode(...codes); } /** * Parses a base52 string into a number. */ export function parseBase52(s: string) { let n = 0; for (let i = 0; i < s.length; i++) { const code = s.charCodeAt(i); const digit = code - (code >= 97 ? 71 : 65); n = 52 * n + digit; } return n; } const log52 = Math.log(52); /** * Returns the next odd valueSeq in the special sequence. * This is equivalent to mapping n to its valueIndex, adding 2, * then mapping back. * * The sequence has the following properties: * 1. Each number is a nonnegative integer (however, not all * nonnegative integers are enumerated). * 2. The numbers' base-52 representations are enumerated in * lexicographic order, with no prefixes (i.e., no string * representation is a prefix of another). * 3. The n-th enumerated number has O(log(n)) base-52 digits. * * Properties (2) and (3) are analogous to normal counting, except * that we order by the (base-52) lexicographic order instead of the * usual order by magnitude. It is also the case that * the numbers are in order by magnitude, although we do not * use this property. * * The specific sequence is as follows: * - Start with 0. * - Enumerate 26^1 numbers (A, B, ..., Z). * - Add 1, multiply by 52, then enumerate 26^2 numbers * (aA, aB, ..., mz). * - Add 1, multiply by 52, then enumerate 26^3 numbers * (nAA, nAB, ..., tZz). * - Repeat this pattern indefinitely, enumerating * 26^d d-digit numbers for each d >= 1. Imagining a decimal place * in front of each number, each d consumes 2^(-d) of the unit interval, * so we never "reach 1" (overflow to d+1 digits when * we meant to use d digits). */ export function nextOddValueSeq(n: number) { const d = n === 0 ? 1 : Math.floor(Math.log(n) / log52) + 1; // You can calculate that the last d-digit number is 52^d - 26^d - 1. if (n === Math.pow(52, d) - Math.pow(26, d) - 1) { // First step is a new length: n -> (n + 1) * 52. // Second step is n -> n + 1. return (n + 1) * 52 + 1; } else { // n -> n + 1 twice. return n + 2; } } /** * Sanitizes a client ID by removing invalid characters. */ export function sanitizeClientID<TClientID extends string>( clientID: TClientID, ): TClientID { let sanitized = clientID.replace(/[.,]/g, ""); if (sanitized.length !== clientID.length) { console.warn("clientID contains invalid characters"); } while (sanitized >= Fugue.LAST) { console.warn(`clientID must be less than ${Fugue.LAST}: ${sanitized}`); sanitized = sanitized.slice(0, -1); } if (sanitized.length === 0) { throw new Error("clientID cannot be empty"); } return sanitized as TClientID; }