@byline/core 1.10.2 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -179,6 +179,23 @@ export interface CollectionAdminConfig<T = any> {
179
179
  slug: string;
180
180
  /** Group name for organising collections in the admin sidebar. */
181
181
  group?: string;
182
+ /**
183
+ * When true, documents in this collection carry a fractional-index
184
+ * `order_key` and the list view sorts by it ascending by default, with
185
+ * drag-to-reorder enabled in the admin UI.
186
+ *
187
+ * Storage: `byline_documents.order_key` — admin metadata, never per-version
188
+ * and never EAV. Reordering writes the single column and does NOT mint a
189
+ * new document version.
190
+ *
191
+ * Backfill: existing rows in newly-`orderable` collections start with
192
+ * `order_key = NULL`. They sort to the bottom (NULLS LAST) until the
193
+ * editor drags them into position.
194
+ *
195
+ * Orthogonal to `hasMany` array order. Use this for top-level order
196
+ * inside a collection (bios, team members, FAQ items, sections).
197
+ */
198
+ orderable?: boolean;
182
199
  /** Column definitions for the collection list view. */
183
200
  columns?: ColumnDefinition<T>[];
184
201
  /**
@@ -226,6 +226,14 @@ export interface IDocumentCommands {
226
226
  * into the new one so that per-locale content is not lost.
227
227
  */
228
228
  previousVersionId?: string;
229
+ /**
230
+ * Fractional-index order key written onto the new `byline_documents` row.
231
+ * Only set on the initial create (when `documentId` is undefined) for
232
+ * collections with `orderable: true`. Ignored on subsequent versions of
233
+ * an existing document — order is admin metadata on the logical document,
234
+ * not per-version content. See docs/ORDERABLE.md.
235
+ */
236
+ orderKey?: string;
229
237
  }): Promise<{
230
238
  document: any;
231
239
  fieldCount: number;
@@ -263,6 +271,18 @@ export interface IDocumentCommands {
263
271
  softDeleteDocument(params: {
264
272
  document_id: string;
265
273
  }): Promise<number>;
274
+ /**
275
+ * Write the fractional-index `order_key` on a single `byline_documents`
276
+ * row. Used by the reorder server fn for `orderable: true` collections.
277
+ *
278
+ * This is a single-column metadata update — it does NOT create a new
279
+ * document version and does NOT touch `documentVersions`. `updated_at`
280
+ * on the row is refreshed so list-view caches can invalidate cleanly.
281
+ */
282
+ setOrderKey(params: {
283
+ document_id: string;
284
+ order_key: string;
285
+ }): Promise<void>;
266
286
  }
267
287
  export interface ICollectionQueries {
268
288
  getAllCollections(): Promise<any[]>;
@@ -478,4 +498,57 @@ export interface IDocumentQueries {
478
498
  documents: any[];
479
499
  total: number;
480
500
  }>;
501
+ /**
502
+ * Return the largest `order_key` currently in use for the given collection,
503
+ * or `null` if there are no keyed rows yet. Used at create-time on
504
+ * `orderable: true` collections to append the new row to the end.
505
+ *
506
+ * Ignores `is_deleted` rows (soft-deleted documents) and rows with a
507
+ * NULL `order_key`. If every document in the collection is unkeyed,
508
+ * returns `null` and the caller seeds the first key from scratch.
509
+ */
510
+ getLastOrderKey(params: {
511
+ collection_id: string;
512
+ }): Promise<string | null>;
513
+ /**
514
+ * Resolve the `order_key` values immediately bracketing a target gap.
515
+ *
516
+ * Called by the reorder server fn. The caller passes the IDs of the
517
+ * documents the dragged row should land **between** (`before` is the doc
518
+ * that should come immediately before, `after` immediately after). Either
519
+ * can be `null` to mean "the end" (`after: null`) or "the start"
520
+ * (`before: null`); both null is "append to a collection with no rows."
521
+ *
522
+ * Resolving keys in one query (instead of two round-trips that read
523
+ * each neighbor separately) keeps the read consistent with the moment
524
+ * the next-key computation runs, so concurrent reorders don't race into
525
+ * a degenerate gap.
526
+ */
527
+ getNeighborOrderKeys(params: {
528
+ collection_id: string;
529
+ before_document_id: string | null;
530
+ after_document_id: string | null;
531
+ }): Promise<{
532
+ left: string | null;
533
+ right: string | null;
534
+ }>;
535
+ /**
536
+ * Return every document in the collection in its canonical list-view
537
+ * order: `order_key ASC NULLS LAST, created_at DESC`. Keyed rows come
538
+ * first (in key order), then any unkeyed rows fall through to newest-
539
+ * first by creation time — exactly what the editor sees in the list
540
+ * view today.
541
+ *
542
+ * Used by the reorder server fn to lazily backfill unkeyed rows AND to
543
+ * detect / recover from pathological key state (duplicates, descending
544
+ * runs) by re-keying the entire collection in displayed order. Collection
545
+ * sizes for `orderable` use cases are small by design (bios, FAQs,
546
+ * sections), so the full read is cheap.
547
+ */
548
+ getCanonicalDocumentOrder(params: {
549
+ collection_id: string;
550
+ }): Promise<Array<{
551
+ id: string;
552
+ order_key: string | null;
553
+ }>>;
481
554
  }
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
7
7
  export { type BylineCore, getBylineCore, initBylineCore } from './core.js';
8
8
  export * from './defaults/default-values.js';
9
9
  export { BylineError, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_STORAGE, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, type ErrorReport, } from './lib/errors.js';
10
+ export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
10
11
  export { type BylineLogger, getLogger } from './lib/logger.js';
11
12
  export { AsyncRegistry, type RegisteredServices, Registry } from './lib/registry.js';
12
13
  export * from './patches/index.js';
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ export { RESERVED_FIELD_NAMES } from './config/validate-collections.js';
23
23
  export { getBylineCore, initBylineCore } from './core.js';
24
24
  export * from './defaults/default-values.js';
25
25
  export { BylineError, ERR_DATABASE, ERR_NOT_FOUND, ERR_READ_BUDGET_EXCEEDED, ERR_STORAGE, ERR_UNHANDLED, ERR_VALIDATION, ErrorCodes, } from './lib/errors.js';
26
+ export { generateKeyBetween, generateNKeysBetween, validateOrderKey, } from './lib/fractional-index.js';
26
27
  export { getLogger } from './lib/logger.js';
27
28
  export { AsyncRegistry, Registry } from './lib/registry.js';
28
29
  export * from './patches/index.js';
@@ -0,0 +1,46 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ *
8
+ * Fractional-index keys for stable, drag-and-drop reordering without a
9
+ * rebalancing pass. Keys are base-62 strings that sort lexicographically;
10
+ * `generateKeyBetween(a, b)` produces a new key strictly between two
11
+ * neighbors (or before/after a single neighbor when the other is null).
12
+ *
13
+ * Implementation follows David Greenspan's algorithm
14
+ * (https://observablehq.com/@dgreensp/implementing-fractional-indexing).
15
+ * Each key has two parts: a head character that encodes the integer-part
16
+ * length, followed by the integer digits and an optional base-62 fraction.
17
+ * Heads 'A'..'Z' carry positive integer-part lengths 2..27; heads 'a'..'z'
18
+ * carry negative integer-part lengths 2..27. This allows unbounded
19
+ * prepend/append without ever needing a rebalance.
20
+ *
21
+ * Used by `orderable: true` collections to drive the
22
+ * `byline_documents.order_key` column.
23
+ */
24
+ /**
25
+ * Validate a complete order_key — head + integer + optional fraction.
26
+ * Returns true if the key is well-formed; false otherwise. Does not throw.
27
+ */
28
+ export declare function validateOrderKey(key: string): boolean;
29
+ /**
30
+ * Generate an order_key strictly between `a` and `b`.
31
+ *
32
+ * - `(null, null)` returns a midpoint near zero
33
+ * - `(a, null)` returns a key greater than `a`
34
+ * - `(null, b)` returns a key less than `b`
35
+ * - `(a, b)` returns a key strictly between (requires `a < b`)
36
+ *
37
+ * Throws if `a >= b`, if either key is malformed, or if the integer
38
+ * head is already at its extreme bound (effectively never under normal
39
+ * use — would require ~10^40 unbounded prepends or appends).
40
+ */
41
+ export declare function generateKeyBetween(a: string | null, b: string | null): string;
42
+ /**
43
+ * Generate `n` order_keys strictly between `a` and `b`, in ascending order.
44
+ * Used when inserting a contiguous run of rows (bulk import, multi-select drop).
45
+ */
46
+ export declare function generateNKeysBetween(a: string | null, b: string | null, n: number): string[];
@@ -0,0 +1,265 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ *
8
+ * Fractional-index keys for stable, drag-and-drop reordering without a
9
+ * rebalancing pass. Keys are base-62 strings that sort lexicographically;
10
+ * `generateKeyBetween(a, b)` produces a new key strictly between two
11
+ * neighbors (or before/after a single neighbor when the other is null).
12
+ *
13
+ * Implementation follows David Greenspan's algorithm
14
+ * (https://observablehq.com/@dgreensp/implementing-fractional-indexing).
15
+ * Each key has two parts: a head character that encodes the integer-part
16
+ * length, followed by the integer digits and an optional base-62 fraction.
17
+ * Heads 'A'..'Z' carry positive integer-part lengths 2..27; heads 'a'..'z'
18
+ * carry negative integer-part lengths 2..27. This allows unbounded
19
+ * prepend/append without ever needing a rebalance.
20
+ *
21
+ * Used by `orderable: true` collections to drive the
22
+ * `byline_documents.order_key` column.
23
+ */
24
+ const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
25
+ const SMALLEST_INTEGER = 'A00000000000000000000000000';
26
+ const INTEGER_ZERO = 'a0';
27
+ function getIntegerLength(head) {
28
+ if (head === undefined)
29
+ throw new Error('invalid order_key head: empty');
30
+ if (head >= 'a' && head <= 'z')
31
+ return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2;
32
+ if (head >= 'A' && head <= 'Z')
33
+ return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2;
34
+ throw new Error(`invalid order_key head: ${head}`);
35
+ }
36
+ function getIntegerPart(key) {
37
+ const len = getIntegerLength(key[0]);
38
+ if (len > key.length)
39
+ throw new Error(`invalid order_key (truncated integer): ${key}`);
40
+ return key.slice(0, len);
41
+ }
42
+ function validateInteger(int) {
43
+ if (int.length !== getIntegerLength(int[0])) {
44
+ throw new Error(`invalid order_key integer part: ${int}`);
45
+ }
46
+ }
47
+ function charAt(s, i) {
48
+ const c = s[i];
49
+ if (c === undefined)
50
+ throw new Error(`order_key out-of-bounds at ${i}: ${s}`);
51
+ return c;
52
+ }
53
+ /**
54
+ * Validate a complete order_key — head + integer + optional fraction.
55
+ * Returns true if the key is well-formed; false otherwise. Does not throw.
56
+ */
57
+ export function validateOrderKey(key) {
58
+ if (typeof key !== 'string' || key.length === 0)
59
+ return false;
60
+ try {
61
+ const int = getIntegerPart(key);
62
+ validateInteger(int);
63
+ // Every char must be in the alphabet (integer digits + fraction).
64
+ for (let i = 1; i < key.length; i++) {
65
+ if (ALPHABET.indexOf(charAt(key, i)) === -1)
66
+ return false;
67
+ }
68
+ // No trailing zero in the fraction part — keeps representations unique.
69
+ if (key.length > int.length && charAt(key, key.length - 1) === '0')
70
+ return false;
71
+ return true;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ function incrementInteger(x) {
78
+ validateInteger(x);
79
+ const head = charAt(x, 0);
80
+ const digs = x.slice(1).split('');
81
+ let carry = true;
82
+ for (let i = digs.length - 1; carry && i >= 0; i--) {
83
+ const d = ALPHABET.indexOf(digs[i]) + 1;
84
+ if (d === ALPHABET.length) {
85
+ digs[i] = '0';
86
+ }
87
+ else {
88
+ digs[i] = charAt(ALPHABET, d);
89
+ carry = false;
90
+ }
91
+ }
92
+ if (carry) {
93
+ if (head === 'Z') {
94
+ return `a${charAt(ALPHABET, 0)}`;
95
+ }
96
+ if (head === 'z')
97
+ return null;
98
+ const headCode = head.charCodeAt(0) + 1;
99
+ const newHead = String.fromCharCode(headCode);
100
+ if (newHead > 'a') {
101
+ digs.push('0');
102
+ }
103
+ else {
104
+ digs.pop();
105
+ }
106
+ return newHead + digs.join('');
107
+ }
108
+ return head + digs.join('');
109
+ }
110
+ function decrementInteger(x) {
111
+ validateInteger(x);
112
+ const head = charAt(x, 0);
113
+ const digs = x.slice(1).split('');
114
+ const lastAlpha = charAt(ALPHABET, ALPHABET.length - 1);
115
+ let borrow = true;
116
+ for (let i = digs.length - 1; borrow && i >= 0; i--) {
117
+ const d = ALPHABET.indexOf(digs[i]) - 1;
118
+ if (d === -1) {
119
+ digs[i] = lastAlpha;
120
+ }
121
+ else {
122
+ digs[i] = charAt(ALPHABET, d);
123
+ borrow = false;
124
+ }
125
+ }
126
+ if (borrow) {
127
+ if (head === 'a') {
128
+ return `Z${lastAlpha}`;
129
+ }
130
+ if (head === 'A')
131
+ return null;
132
+ const headCode = head.charCodeAt(0) - 1;
133
+ const newHead = String.fromCharCode(headCode);
134
+ if (newHead < 'a') {
135
+ digs.push(lastAlpha);
136
+ }
137
+ else {
138
+ digs.pop();
139
+ }
140
+ return newHead + digs.join('');
141
+ }
142
+ return head + digs.join('');
143
+ }
144
+ /**
145
+ * Find the "midpoint" fraction strictly between two fraction strings (a, b).
146
+ * Both inputs are the fraction portion only (everything after the integer
147
+ * part), and must not have a trailing '0'. Either can be empty / null.
148
+ */
149
+ function midpoint(a, b) {
150
+ if (b !== null && a >= b) {
151
+ throw new Error(`midpoint: a >= b (${a}, ${b})`);
152
+ }
153
+ if (a.slice(-1) === '0' || (b && b.slice(-1) === '0')) {
154
+ throw new Error('midpoint: trailing zero');
155
+ }
156
+ if (b !== null) {
157
+ let n = 0;
158
+ while ((a[n] ?? '0') === b[n])
159
+ n++;
160
+ if (n > 0) {
161
+ return b.slice(0, n) + midpoint(a.slice(n), b.slice(n));
162
+ }
163
+ }
164
+ const digitA = a ? ALPHABET.indexOf(charAt(a, 0)) : 0;
165
+ const digitB = b !== null && b.length > 0 ? ALPHABET.indexOf(charAt(b, 0)) : ALPHABET.length;
166
+ if (digitB - digitA > 1) {
167
+ const midDigit = Math.round(0.5 * (digitA + digitB));
168
+ return charAt(ALPHABET, midDigit);
169
+ }
170
+ if (b !== null && b.length > 1) {
171
+ return b.slice(0, 1);
172
+ }
173
+ return charAt(ALPHABET, digitA) + midpoint(a.slice(1), null);
174
+ }
175
+ /**
176
+ * Generate an order_key strictly between `a` and `b`.
177
+ *
178
+ * - `(null, null)` returns a midpoint near zero
179
+ * - `(a, null)` returns a key greater than `a`
180
+ * - `(null, b)` returns a key less than `b`
181
+ * - `(a, b)` returns a key strictly between (requires `a < b`)
182
+ *
183
+ * Throws if `a >= b`, if either key is malformed, or if the integer
184
+ * head is already at its extreme bound (effectively never under normal
185
+ * use — would require ~10^40 unbounded prepends or appends).
186
+ */
187
+ export function generateKeyBetween(a, b) {
188
+ if (a !== null && !validateOrderKey(a)) {
189
+ throw new Error(`generateKeyBetween: invalid 'a' (${a})`);
190
+ }
191
+ if (b !== null && !validateOrderKey(b)) {
192
+ throw new Error(`generateKeyBetween: invalid 'b' (${b})`);
193
+ }
194
+ if (a !== null && b !== null && a >= b) {
195
+ throw new Error(`generateKeyBetween: a >= b (${a} >= ${b})`);
196
+ }
197
+ if (a === null) {
198
+ if (b === null)
199
+ return INTEGER_ZERO;
200
+ const ib = getIntegerPart(b);
201
+ const fb = b.slice(ib.length);
202
+ if (ib === SMALLEST_INTEGER) {
203
+ return ib + midpoint('', fb);
204
+ }
205
+ if (ib < b)
206
+ return ib;
207
+ const res = decrementInteger(ib);
208
+ if (res === null) {
209
+ throw new Error('generateKeyBetween: cannot decrement past lower bound');
210
+ }
211
+ return res;
212
+ }
213
+ if (b === null) {
214
+ const ia = getIntegerPart(a);
215
+ const fa = a.slice(ia.length);
216
+ const i = incrementInteger(ia);
217
+ return i === null ? ia + midpoint(fa, null) : i;
218
+ }
219
+ const ia = getIntegerPart(a);
220
+ const fa = a.slice(ia.length);
221
+ const ib = getIntegerPart(b);
222
+ const fb = b.slice(ib.length);
223
+ if (ia === ib) {
224
+ return ia + midpoint(fa, fb);
225
+ }
226
+ const i = incrementInteger(ia);
227
+ if (i === null) {
228
+ throw new Error('generateKeyBetween: cannot increment past upper bound');
229
+ }
230
+ if (i < b)
231
+ return i;
232
+ return ia + midpoint(fa, null);
233
+ }
234
+ /**
235
+ * Generate `n` order_keys strictly between `a` and `b`, in ascending order.
236
+ * Used when inserting a contiguous run of rows (bulk import, multi-select drop).
237
+ */
238
+ export function generateNKeysBetween(a, b, n) {
239
+ if (n === 0)
240
+ return [];
241
+ if (n === 1)
242
+ return [generateKeyBetween(a, b)];
243
+ if (b === null) {
244
+ let c = generateKeyBetween(a, b);
245
+ const result = [c];
246
+ for (let i = 0; i < n - 1; i++) {
247
+ c = generateKeyBetween(c, b);
248
+ result.push(c);
249
+ }
250
+ return result;
251
+ }
252
+ if (a === null) {
253
+ let c = generateKeyBetween(a, b);
254
+ const result = [c];
255
+ for (let i = 0; i < n - 1; i++) {
256
+ c = generateKeyBetween(a, c);
257
+ result.push(c);
258
+ }
259
+ result.reverse();
260
+ return result;
261
+ }
262
+ const mid = Math.floor(n / 2);
263
+ const c = generateKeyBetween(a, b);
264
+ return [...generateNKeysBetween(a, c, mid), c, ...generateNKeysBetween(c, b, n - mid - 1)];
265
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,130 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { describe, expect, it } from 'vitest';
9
+ import { generateKeyBetween, generateNKeysBetween, validateOrderKey } from './fractional-index.js';
10
+ describe('fractional-index', () => {
11
+ describe('generateKeyBetween — bounded cases', () => {
12
+ it('returns the canonical zero key for (null, null)', () => {
13
+ const k = generateKeyBetween(null, null);
14
+ expect(k).toBe('a0');
15
+ expect(validateOrderKey(k)).toBe(true);
16
+ });
17
+ it('produces a key strictly greater than `a` for (a, null)', () => {
18
+ const a = generateKeyBetween(null, null);
19
+ const b = generateKeyBetween(a, null);
20
+ expect(b > a).toBe(true);
21
+ expect(validateOrderKey(b)).toBe(true);
22
+ });
23
+ it('produces a key strictly less than `b` for (null, b)', () => {
24
+ const b = generateKeyBetween(null, null);
25
+ const a = generateKeyBetween(null, b);
26
+ expect(a < b).toBe(true);
27
+ expect(validateOrderKey(a)).toBe(true);
28
+ });
29
+ it('produces a key strictly between `a` and `b`', () => {
30
+ const a = generateKeyBetween(null, null);
31
+ const c = generateKeyBetween(a, null);
32
+ const mid = generateKeyBetween(a, c);
33
+ expect(a < mid).toBe(true);
34
+ expect(mid < c).toBe(true);
35
+ expect(validateOrderKey(mid)).toBe(true);
36
+ });
37
+ it('keeps splitting the same gap arbitrarily deep', () => {
38
+ const lo = generateKeyBetween(null, null);
39
+ let hi = generateKeyBetween(lo, null);
40
+ // 200 in-the-middle inserts into the same gap.
41
+ for (let i = 0; i < 200; i++) {
42
+ const mid = generateKeyBetween(lo, hi);
43
+ expect(lo < mid).toBe(true);
44
+ expect(mid < hi).toBe(true);
45
+ hi = mid;
46
+ }
47
+ });
48
+ });
49
+ describe('generateKeyBetween — error cases', () => {
50
+ it('throws when a >= b', () => {
51
+ const a = generateKeyBetween(null, null);
52
+ const b = generateKeyBetween(a, null);
53
+ expect(() => generateKeyBetween(b, a)).toThrow();
54
+ expect(() => generateKeyBetween(a, a)).toThrow();
55
+ });
56
+ it('throws when given a malformed key', () => {
57
+ expect(() => generateKeyBetween('not-a-real-key!!!', null)).toThrow();
58
+ expect(() => generateKeyBetween(null, '?')).toThrow();
59
+ });
60
+ });
61
+ describe('generateNKeysBetween', () => {
62
+ it('returns an empty array for n=0', () => {
63
+ expect(generateNKeysBetween(null, null, 0)).toEqual([]);
64
+ });
65
+ it('returns one key for n=1', () => {
66
+ const keys = generateNKeysBetween(null, null, 1);
67
+ expect(keys.length).toBe(1);
68
+ expect(validateOrderKey(keys[0])).toBe(true);
69
+ });
70
+ it('returns n strictly ascending keys (open interval)', () => {
71
+ const keys = generateNKeysBetween(null, null, 10);
72
+ expect(keys.length).toBe(10);
73
+ for (let i = 1; i < keys.length; i++) {
74
+ expect(keys[i - 1] < keys[i]).toBe(true);
75
+ expect(validateOrderKey(keys[i])).toBe(true);
76
+ }
77
+ });
78
+ it('returns n strictly ascending keys between two neighbors', () => {
79
+ const a = generateKeyBetween(null, null);
80
+ const b = generateKeyBetween(a, null);
81
+ const keys = generateNKeysBetween(a, b, 20);
82
+ expect(keys.length).toBe(20);
83
+ expect(a < keys[0]).toBe(true);
84
+ expect(keys[keys.length - 1] < b).toBe(true);
85
+ for (let i = 1; i < keys.length; i++) {
86
+ expect(keys[i - 1] < keys[i]).toBe(true);
87
+ }
88
+ });
89
+ });
90
+ describe('validateOrderKey', () => {
91
+ it('accepts well-formed keys', () => {
92
+ expect(validateOrderKey('a0')).toBe(true);
93
+ expect(validateOrderKey(generateKeyBetween(null, null))).toBe(true);
94
+ });
95
+ it('rejects empty / non-string / malformed input', () => {
96
+ expect(validateOrderKey('')).toBe(false);
97
+ expect(validateOrderKey(null)).toBe(false);
98
+ expect(validateOrderKey(undefined)).toBe(false);
99
+ expect(validateOrderKey('!')).toBe(false);
100
+ // Trailing zero in fraction part is forbidden (non-canonical).
101
+ expect(validateOrderKey('a00')).toBe(false);
102
+ });
103
+ });
104
+ describe('fuzz — random insert / prepend / append mix', () => {
105
+ it('maintains a globally sorted list after 1000 random operations', () => {
106
+ const list = [generateKeyBetween(null, null)];
107
+ for (let i = 0; i < 1000; i++) {
108
+ const op = Math.floor(Math.random() * 3);
109
+ if (op === 0) {
110
+ const k = generateKeyBetween(null, list[0]);
111
+ list.unshift(k);
112
+ }
113
+ else if (op === 1) {
114
+ const k = generateKeyBetween(list[list.length - 1], null);
115
+ list.push(k);
116
+ }
117
+ else {
118
+ if (list.length < 2)
119
+ continue;
120
+ const idx = Math.floor(Math.random() * (list.length - 1));
121
+ const k = generateKeyBetween(list[idx], list[idx + 1]);
122
+ list.splice(idx + 1, 0, k);
123
+ }
124
+ }
125
+ for (let i = 1; i < list.length; i++) {
126
+ expect(list[i - 1] < list[i]).toBe(true);
127
+ }
128
+ });
129
+ });
130
+ });
@@ -20,6 +20,8 @@ const DOCUMENT_SORT_COLUMNS = {
20
20
  path: 'path',
21
21
  created_at: 'created_at',
22
22
  updated_at: 'updated_at',
23
+ orderKey: 'order_key',
24
+ order_key: 'order_key',
23
25
  };
24
26
  // ---------------------------------------------------------------------------
25
27
  // Parse functions
@@ -42,6 +42,7 @@ function createMockDb(options) {
42
42
  setDocumentStatus: vi.fn(fail),
43
43
  archivePublishedVersions: vi.fn(fail),
44
44
  softDeleteDocument: vi.fn(fail),
45
+ setOrderKey: vi.fn(fail),
45
46
  },
46
47
  },
47
48
  queries: {
@@ -62,6 +63,9 @@ function createMockDb(options) {
62
63
  getPublishedDocumentIds: vi.fn(fail),
63
64
  getDocumentCountsByStatus: vi.fn(fail),
64
65
  findDocuments: vi.fn(fail),
66
+ getLastOrderKey: vi.fn(fail),
67
+ getNeighborOrderKeys: vi.fn(fail),
68
+ getCanonicalDocumentOrder: vi.fn(fail),
65
69
  },
66
70
  },
67
71
  };
@@ -175,6 +179,7 @@ describe('ensureCollections', () => {
175
179
  setDocumentStatus: vi.fn(),
176
180
  archivePublishedVersions: vi.fn(),
177
181
  softDeleteDocument: vi.fn(),
182
+ setOrderKey: vi.fn(),
178
183
  },
179
184
  },
180
185
  queries: {
@@ -195,6 +200,9 @@ describe('ensureCollections', () => {
195
200
  getPublishedDocumentIds: vi.fn(),
196
201
  getDocumentCountsByStatus: vi.fn(),
197
202
  findDocuments: vi.fn(),
203
+ getLastOrderKey: vi.fn(),
204
+ getNeighborOrderKeys: vi.fn(),
205
+ getCanonicalDocumentOrder: vi.fn(),
198
206
  },
199
207
  },
200
208
  };
@@ -7,7 +7,9 @@
7
7
  */
8
8
  import { isArrayField, isBlocksField, isGroupField, normalizeCollectionHook, } from '../@types/index.js';
9
9
  import { assertActorCanPerform } from '../auth/assert-actor-can-perform.js';
10
+ import { getCollectionAdminConfig } from '../config/config.js';
10
11
  import { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_PATCH_FAILED, ERR_PATH_CONFLICT, ERR_VALIDATION, ErrorCodes, } from '../lib/errors.js';
12
+ import { generateKeyBetween } from '../lib/fractional-index.js';
11
13
  import { withLogContext } from '../lib/logger.js';
12
14
  import { applyPatches } from '../patches/index.js';
13
15
  import { normaliseDateFields } from '../utils/normalise-dates.js';
@@ -28,6 +30,22 @@ async function invokeHook(hook, ctx) {
28
30
  await fn(ctx);
29
31
  }
30
32
  }
33
+ /**
34
+ * For collections with `orderable: true` in their admin config, compute an
35
+ * append-at-end fractional-index key for a newly-inserted document.
36
+ * Returns `undefined` when the collection hasn't opted in (or has no admin
37
+ * config registered, e.g. in unit-test environments), so the storage row
38
+ * gets `order_key = NULL` and the existing "no ordering" behavior holds.
39
+ */
40
+ async function maybeAppendOrderKey(ctx, collectionPath) {
41
+ const adminConfig = getCollectionAdminConfig(collectionPath);
42
+ if (adminConfig?.orderable !== true)
43
+ return undefined;
44
+ const last = await ctx.db.queries.documents.getLastOrderKey({
45
+ collection_id: ctx.collectionId,
46
+ });
47
+ return generateKeyBetween(last, null);
48
+ }
31
49
  /** Extract `id` from the document object returned by `createDocumentVersion`. */
32
50
  function extractVersionId(document) {
33
51
  return document?.id ?? document?.document_version_id ?? '';
@@ -157,6 +175,11 @@ export async function createDocument(ctx, params) {
157
175
  await invokeHook(hooks?.beforeCreate, { data, collectionPath });
158
176
  const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
159
177
  const resolvedPath = explicitPath ?? derivePath(definition, data, defaultLocale, slugifier);
178
+ // Append-at-end order_key for `orderable: true` collections.
179
+ // Computed before the insert so the single createDocumentVersion call
180
+ // carries the key into the byline_documents row. No effect when the
181
+ // admin config opts out or isn't registered.
182
+ const orderKey = await maybeAppendOrderKey(ctx, collectionPath);
160
183
  const result = await db.commands.documents
161
184
  .createDocumentVersion({
162
185
  collectionId,
@@ -167,6 +190,7 @@ export async function createDocument(ctx, params) {
167
190
  path: resolvedPath,
168
191
  status: params.status ?? data.status,
169
192
  locale: params.locale ?? defaultLocale,
193
+ orderKey,
170
194
  })
171
195
  .catch((err) => rethrowPathConflict(err, resolvedPath, defaultLocale));
172
196
  const documentId = extractDocumentId(result.document);
@@ -860,6 +884,10 @@ export async function duplicateDocument(ctx, params) {
860
884
  let finalPath = candidatePath;
861
885
  let pathRetried = false;
862
886
  let result;
887
+ // Append-at-end order_key for `orderable: true` collections. Computed
888
+ // before the insert; the source row's order is intentionally not
889
+ // copied — duplicates land at the end of the list.
890
+ const orderKey = await maybeAppendOrderKey(ctx, collectionPath);
863
891
  try {
864
892
  result = await db.commands.documents
865
893
  .createDocumentVersion({
@@ -871,6 +899,7 @@ export async function duplicateDocument(ctx, params) {
871
899
  path: finalPath,
872
900
  status: defaultStatus,
873
901
  locale: 'all',
902
+ orderKey,
874
903
  })
875
904
  .catch((err) => rethrowPathConflict(err, finalPath, defaultLocale));
876
905
  }
@@ -894,6 +923,7 @@ export async function duplicateDocument(ctx, params) {
894
923
  path: finalPath,
895
924
  status: defaultStatus,
896
925
  locale: 'all',
926
+ orderKey,
897
927
  })
898
928
  .catch((retryErr) => rethrowPathConflict(retryErr, finalPath, defaultLocale));
899
929
  }
@@ -47,6 +47,7 @@ function createMockDb() {
47
47
  setDocumentStatus,
48
48
  archivePublishedVersions,
49
49
  softDeleteDocument,
50
+ setOrderKey: vi.fn(),
50
51
  },
51
52
  },
52
53
  queries: {
@@ -67,6 +68,9 @@ function createMockDb() {
67
68
  getPublishedDocumentIds: vi.fn(),
68
69
  getDocumentCountsByStatus: vi.fn(),
69
70
  findDocuments: vi.fn(),
71
+ getLastOrderKey: vi.fn(),
72
+ getNeighborOrderKeys: vi.fn(),
73
+ getCanonicalDocumentOrder: vi.fn(),
70
74
  },
71
75
  },
72
76
  };
@@ -56,6 +56,7 @@ function createMockDb() {
56
56
  setDocumentStatus: vi.fn(),
57
57
  archivePublishedVersions: vi.fn(),
58
58
  softDeleteDocument: vi.fn(),
59
+ setOrderKey: vi.fn(),
59
60
  },
60
61
  },
61
62
  queries: {
@@ -76,6 +77,9 @@ function createMockDb() {
76
77
  getPublishedDocumentIds: vi.fn(),
77
78
  getDocumentCountsByStatus: vi.fn(),
78
79
  findDocuments: vi.fn(),
80
+ getLastOrderKey: vi.fn(),
81
+ getNeighborOrderKeys: vi.fn(),
82
+ getCanonicalDocumentOrder: vi.fn(),
79
83
  },
80
84
  },
81
85
  };
@@ -144,6 +144,7 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
144
144
  setDocumentStatus: vi.fn(),
145
145
  archivePublishedVersions: vi.fn(),
146
146
  softDeleteDocument: vi.fn(),
147
+ setOrderKey: vi.fn(),
147
148
  },
148
149
  },
149
150
  queries: {
@@ -164,6 +165,9 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
164
165
  getPublishedDocumentIds: vi.fn(),
165
166
  getDocumentCountsByStatus: vi.fn(),
166
167
  findDocuments: vi.fn(),
168
+ getLastOrderKey: vi.fn(),
169
+ getNeighborOrderKeys: vi.fn(),
170
+ getCanonicalDocumentOrder: vi.fn(),
167
171
  },
168
172
  },
169
173
  };
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/core",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "1.10.2",
5
+ "version": "1.11.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -79,7 +79,7 @@
79
79
  "sharp": "^0.34.5",
80
80
  "uuid": "^14.0.0",
81
81
  "zod": "^4.4.3",
82
- "@byline/auth": "1.10.2"
82
+ "@byline/auth": "1.11.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",