@byline/core 1.10.3 → 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.
- package/dist/@types/admin-types.d.ts +17 -0
- package/dist/@types/db-types.d.ts +73 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/fractional-index.d.ts +46 -0
- package/dist/lib/fractional-index.js +265 -0
- package/dist/lib/fractional-index.test.node.d.ts +8 -0
- package/dist/lib/fractional-index.test.node.js +130 -0
- package/dist/query/parse-where.js +2 -0
- package/dist/services/collection-bootstrap.test.node.js +8 -0
- package/dist/services/document-lifecycle.js +30 -0
- package/dist/services/document-lifecycle.test.node.js +4 -0
- package/dist/services/field-upload.test.node.js +4 -0
- package/dist/services/populate.test.node.js +4 -0
- package/package.json +2 -2
|
@@ -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.
|
|
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.
|
|
82
|
+
"@byline/auth": "1.11.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|