@byline/core 3.12.2 → 3.13.1
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/collection-types.d.ts +69 -0
- package/dist/@types/db-types.d.ts +137 -0
- package/dist/config/validate-collections.d.ts +3 -0
- package/dist/config/validate-collections.js +6 -0
- package/dist/config/validate-collections.test.node.js +24 -0
- package/dist/services/collection-bootstrap.test.node.js +12 -0
- package/dist/services/discover-counter-groups.test.node.js +6 -0
- package/dist/services/document-lifecycle/create.js +17 -1
- package/dist/services/document-lifecycle/delete.js +18 -1
- package/dist/services/document-lifecycle/index.d.ts +1 -0
- package/dist/services/document-lifecycle/index.js +1 -0
- package/dist/services/document-lifecycle/internals.d.ts +20 -0
- package/dist/services/document-lifecycle/internals.js +43 -0
- package/dist/services/document-lifecycle/tree.d.ts +46 -0
- package/dist/services/document-lifecycle/tree.js +184 -0
- package/dist/services/document-lifecycle/update.js +7 -1
- package/dist/services/document-lifecycle.test.node.js +6 -0
- package/dist/services/field-upload.test.node.js +6 -0
- package/dist/services/populate.test.node.js +6 -0
- package/package.json +2 -2
|
@@ -471,6 +471,39 @@ export interface DeleteContext {
|
|
|
471
471
|
/** The document's canonical (source-locale) routing path. */
|
|
472
472
|
path: string;
|
|
473
473
|
}
|
|
474
|
+
/**
|
|
475
|
+
* Context passed to the `afterTreeChange` hook — the structural-change
|
|
476
|
+
* invalidation event for `tree: true` collections (docs/DOCUMENT-TREE.md).
|
|
477
|
+
*
|
|
478
|
+
* Tree mutations are document-grain and **unversioned**, so the normal
|
|
479
|
+
* version-write invalidation (`afterCreate` / `afterUpdate` / `afterStatusChange`)
|
|
480
|
+
* never fires for them. A single structural change ripples — the moved node, its
|
|
481
|
+
* descendants (their breadcrumb trails changed), and the old/new parents and
|
|
482
|
+
* sibling lists — so the hook fires **once per write** carrying the whole
|
|
483
|
+
* `affectedDocumentIds` set as a batched event, rather than one event per edge.
|
|
484
|
+
* Consumers (cache / ISR invalidation, markdown-export refresh, search reindex)
|
|
485
|
+
* refresh exactly that set.
|
|
486
|
+
*/
|
|
487
|
+
export interface TreeChangeContext {
|
|
488
|
+
collectionPath: string;
|
|
489
|
+
/**
|
|
490
|
+
* The kind of structural change:
|
|
491
|
+
* - `'place'` — a node was placed, reordered, or re-parented.
|
|
492
|
+
* - `'remove'` — a node was removed from the tree (back to *unplaced*).
|
|
493
|
+
* - `'promote-on-delete'` — a document was deleted, so its children were
|
|
494
|
+
* promoted to root and it left the tree.
|
|
495
|
+
*/
|
|
496
|
+
change: 'place' | 'remove' | 'promote-on-delete';
|
|
497
|
+
/** The primary node the change acted on. */
|
|
498
|
+
documentId: string;
|
|
499
|
+
/**
|
|
500
|
+
* Every document whose tree position, breadcrumb trail, or sibling context
|
|
501
|
+
* changed and which downstream caches / indexes should refresh: the acted-on
|
|
502
|
+
* node, its descendants, the old and new parents, and the affected sibling
|
|
503
|
+
* lists. De-duplicated; order is not significant.
|
|
504
|
+
*/
|
|
505
|
+
affectedDocumentIds: string[];
|
|
506
|
+
}
|
|
474
507
|
/**
|
|
475
508
|
* Context passed to `beforeStore` hooks (configured on
|
|
476
509
|
* `field.upload.hooks`).
|
|
@@ -746,6 +779,15 @@ export interface CollectionHooks {
|
|
|
746
779
|
beforeDelete?: CollectionHookSlot<DeleteContext>;
|
|
747
780
|
/** Runs after a document is deleted. */
|
|
748
781
|
afterDelete?: CollectionHookSlot<DeleteContext>;
|
|
782
|
+
/**
|
|
783
|
+
* Runs after a structural change to a `tree: true` collection's hierarchy —
|
|
784
|
+
* a place / reorder / re-parent (`placeTreeNode`), a removal
|
|
785
|
+
* (`removeFromTree`), or the promote-children-to-root that accompanies a
|
|
786
|
+
* delete. Tree writes mint no document version, so this is the only
|
|
787
|
+
* invalidation signal for them. Fires once per write with the full affected
|
|
788
|
+
* set ({@link TreeChangeContext}). See docs/DOCUMENT-TREE.md.
|
|
789
|
+
*/
|
|
790
|
+
afterTreeChange?: CollectionHookSlot<TreeChangeContext>;
|
|
749
791
|
/**
|
|
750
792
|
* Runs once per `findDocuments` call (and once per populate batch, per
|
|
751
793
|
* target collection), **before** any DB work. Returns a `QueryPredicate`
|
|
@@ -967,6 +1009,33 @@ export interface CollectionDefinition {
|
|
|
967
1009
|
* inside a collection (bios, team members, FAQ items, sections).
|
|
968
1010
|
*/
|
|
969
1011
|
orderable?: boolean;
|
|
1012
|
+
/**
|
|
1013
|
+
* When `true`, this collection is a **document tree** — a self-referential,
|
|
1014
|
+
* single-parent ordered hierarchy (the structural backbone for documentation
|
|
1015
|
+
* / book sites). Each node's parent and its per-parent sibling order are
|
|
1016
|
+
* stored document-grain and unversioned in `byline_document_relationships`
|
|
1017
|
+
* and mutated via the dedicated tree commands (`placeTreeNode` /
|
|
1018
|
+
* `removeFromTree`), which mint no document version — exactly like `path`,
|
|
1019
|
+
* `availableLocales`, and `order_key`.
|
|
1020
|
+
*
|
|
1021
|
+
* Lives on the schema (not `defineAdmin`) because it changes **storage
|
|
1022
|
+
* authority and the read path**, not just presentation: it turns on the
|
|
1023
|
+
* edge-table storage + commands, the recursive tree read path
|
|
1024
|
+
* (`getTreeSubtree` / `getTreeAncestors` / `getTreeChildren`), the authoring
|
|
1025
|
+
* tree widget, and the structural-change invalidation event.
|
|
1026
|
+
*
|
|
1027
|
+
* Mutually exclusive with `orderable`: the tree owns ordering (per-parent, on
|
|
1028
|
+
* the edge row), so `byline_documents.order_key` is inert for a tree
|
|
1029
|
+
* collection. Setting both throws at startup.
|
|
1030
|
+
*
|
|
1031
|
+
* The hierarchy is *meta* — it describes where a document sits in a table of
|
|
1032
|
+
* contents and says nothing about its content; re-parenting and reordering
|
|
1033
|
+
* touch no user fields. Do **not** also declare a `parent` relation field; the
|
|
1034
|
+
* tree owns structure (a topic that genuinely belongs in two places is a
|
|
1035
|
+
* cross-link relation field, never a second tree edge). See
|
|
1036
|
+
* docs/DOCUMENT-TREE.md.
|
|
1037
|
+
*/
|
|
1038
|
+
tree?: boolean;
|
|
970
1039
|
/**
|
|
971
1040
|
* Optional explicit version pin. When omitted, the startup bootstrap
|
|
972
1041
|
* auto-increments the collection's stored version any time the schema
|
|
@@ -573,6 +573,51 @@ export interface IDocumentCommands {
|
|
|
573
573
|
document_id: string;
|
|
574
574
|
order_key: string;
|
|
575
575
|
}): Promise<void>;
|
|
576
|
+
/**
|
|
577
|
+
* Place or move a document within its collection's single-parent ordered
|
|
578
|
+
* tree (the `tree: true` document-tree primitive). Upserts the node's edge
|
|
579
|
+
* row in `byline_document_relationships`: sets `parentDocumentId` (or NULL
|
|
580
|
+
* for a root node) and mints a per-parent fractional `order_key` between the
|
|
581
|
+
* resolved in-group neighbours. Covers place, reorder, and re-parent — they
|
|
582
|
+
* differ only in whether `parentDocumentId` changes.
|
|
583
|
+
*
|
|
584
|
+
* Document-grain and unversioned: writes the edge table only — no new
|
|
585
|
+
* document version, no status change, no user-field write (exactly like
|
|
586
|
+
* `setOrderKey` / `updateDocumentPath`).
|
|
587
|
+
*
|
|
588
|
+
* Two invariants the DB cannot hold are enforced in the same transaction as
|
|
589
|
+
* the write, so concurrent cross-moves cannot race them:
|
|
590
|
+
* - **cycle guard** — rejects a move that would make the node its own
|
|
591
|
+
* ancestor (walks the new parent's ancestor chain).
|
|
592
|
+
* - **same-collection guard** — both endpoints must belong to
|
|
593
|
+
* `collectionId` (the tree is self-referential within one collection).
|
|
594
|
+
*
|
|
595
|
+
* Neighbour semantics match `reorderCollectionDocument`: `beforeDocumentId`
|
|
596
|
+
* is the sibling the placed node should land immediately **after** (its left
|
|
597
|
+
* neighbour) and `afterDocumentId` is the sibling it should land immediately
|
|
598
|
+
* **before** (its right neighbour). Either may be null — `{}` appends as the
|
|
599
|
+
* only/last child, `beforeDocumentId` alone appends after it, `afterDocumentId`
|
|
600
|
+
* alone prepends before it. Both are resolved within the *target* parent
|
|
601
|
+
* group. See docs/DOCUMENT-TREE.md.
|
|
602
|
+
*/
|
|
603
|
+
placeTreeNode(params: {
|
|
604
|
+
collectionId: string;
|
|
605
|
+
documentId: string;
|
|
606
|
+
parentDocumentId: string | null;
|
|
607
|
+
beforeDocumentId?: string | null;
|
|
608
|
+
afterDocumentId?: string | null;
|
|
609
|
+
}): Promise<{
|
|
610
|
+
orderKey: string;
|
|
611
|
+
}>;
|
|
612
|
+
/**
|
|
613
|
+
* Remove a document's edge row, returning it to the *unplaced* state (in the
|
|
614
|
+
* collection, but not in the tree). Distinct from document deletion — the
|
|
615
|
+
* document and its content are untouched. No-op when the node is already
|
|
616
|
+
* unplaced. See docs/DOCUMENT-TREE.md.
|
|
617
|
+
*/
|
|
618
|
+
removeFromTree(params: {
|
|
619
|
+
documentId: string;
|
|
620
|
+
}): Promise<void>;
|
|
576
621
|
}
|
|
577
622
|
export interface ICollectionQueries {
|
|
578
623
|
getAllCollections(): Promise<any[]>;
|
|
@@ -873,4 +918,96 @@ export interface IDocumentQueries {
|
|
|
873
918
|
id: string;
|
|
874
919
|
order_key: string | null;
|
|
875
920
|
}>>;
|
|
921
|
+
/**
|
|
922
|
+
* Walk a document's ancestor chain upward through the document tree
|
|
923
|
+
* (`byline_document_relationships`). Returns the ancestors ordered
|
|
924
|
+
* **root-first** with a 1-based `depth` (1 = the document's immediate
|
|
925
|
+
* parent, increasing toward the root). Empty for a root or unplaced node.
|
|
926
|
+
*
|
|
927
|
+
* Backs breadcrumbs and the read-time hierarchical-URL canonicalization
|
|
928
|
+
* (docs/DOCUMENT-TREE.md). Depth-bounded as a backstop against pathological
|
|
929
|
+
* key state even though the write-path cycle guard prevents true cycles.
|
|
930
|
+
*
|
|
931
|
+
* **Status-at-edge.** `readMode: 'published'` joins
|
|
932
|
+
* `byline_current_published_documents` at each hop and **stops at the first
|
|
933
|
+
* unpublished ancestor** — it does *not* skip past it. So an unpublished
|
|
934
|
+
* mid-spine node truncates the returned chain, which the hierarchical-URL
|
|
935
|
+
* splat handler turns into a 404 (the "unpublished node hides its subtree"
|
|
936
|
+
* decision, enforced at the URL layer). `readMode: 'any'` (the default) walks
|
|
937
|
+
* the raw edges, matching the breadcrumb/lifecycle use that wants every
|
|
938
|
+
* ancestor regardless of status.
|
|
939
|
+
*/
|
|
940
|
+
getTreeAncestors(params: {
|
|
941
|
+
document_id: string;
|
|
942
|
+
maxDepth?: number;
|
|
943
|
+
readMode?: ReadMode;
|
|
944
|
+
}): Promise<Array<{
|
|
945
|
+
document_id: string;
|
|
946
|
+
depth: number;
|
|
947
|
+
}>>;
|
|
948
|
+
/**
|
|
949
|
+
* List the immediate children of a tree node, ordered by the per-parent
|
|
950
|
+
* `order_key`. `parentDocumentId: null` returns the collection's root nodes.
|
|
951
|
+
* Scoped to `collectionId` so root reads (which have no parent to scope by)
|
|
952
|
+
* stay within the collection. One level only — the recursive subtree read is
|
|
953
|
+
* a separate query. See docs/DOCUMENT-TREE.md.
|
|
954
|
+
*/
|
|
955
|
+
getTreeChildren(params: {
|
|
956
|
+
collectionId: string;
|
|
957
|
+
parentDocumentId: string | null;
|
|
958
|
+
}): Promise<Array<{
|
|
959
|
+
document_id: string;
|
|
960
|
+
order_key: string;
|
|
961
|
+
}>>;
|
|
962
|
+
/**
|
|
963
|
+
* Resolve a single node's placement state in the tree, distinguishing the
|
|
964
|
+
* three tri-states (docs/DOCUMENT-TREE.md → "Node placement"):
|
|
965
|
+
*
|
|
966
|
+
* - **Unplaced** (no edge row) → `{ placed: false, parentDocumentId: null }`
|
|
967
|
+
* - **Root** (edge row, null parent) → `{ placed: true, parentDocumentId: null }`
|
|
968
|
+
* - **Child** (edge row, parent set) → `{ placed: true, parentDocumentId: <id> }`
|
|
969
|
+
*
|
|
970
|
+
* `getTreeAncestors` returns `[]` for *both* a root and an unplaced node, so it
|
|
971
|
+
* cannot tell them apart; this primitive can. Backs the update-time self-heal
|
|
972
|
+
* (re-root a stray doc) and the authoring widget's "Add to tree" vs "Top level"
|
|
973
|
+
* distinction.
|
|
974
|
+
*/
|
|
975
|
+
getTreeParent(params: {
|
|
976
|
+
document_id: string;
|
|
977
|
+
}): Promise<{
|
|
978
|
+
placed: boolean;
|
|
979
|
+
parentDocumentId: string | null;
|
|
980
|
+
}>;
|
|
981
|
+
/**
|
|
982
|
+
* Read a node's subtree as a flat, **pre-order (depth-first)** list — each
|
|
983
|
+
* node immediately followed by its descendants, siblings in `order_key`
|
|
984
|
+
* order, with a 0-based `depth` (the requested root / the collection roots
|
|
985
|
+
* are depth 0). Drives the authoring tree, server-rendered navigation, and
|
|
986
|
+
* the prev/next spine flatten.
|
|
987
|
+
*
|
|
988
|
+
* `rootDocumentId: null` (the default) reads the whole tree from the
|
|
989
|
+
* collection's roots; a value reads the subtree rooted at (and including)
|
|
990
|
+
* that node.
|
|
991
|
+
*
|
|
992
|
+
* **Status-at-edge.** `readMode: 'published'` joins
|
|
993
|
+
* `byline_current_published_documents` and drops any node without a current
|
|
994
|
+
* published version; because the walk only recurses through *included*
|
|
995
|
+
* nodes, an unpublished node's entire subtree is omitted — the spine is
|
|
996
|
+
* broken and descendants are not promoted (see docs/DOCUMENT-TREE.md).
|
|
997
|
+
* `readMode: 'any'` (the default) includes every current, non-deleted node.
|
|
998
|
+
* Depth-bounded by `maxDepth` as a backstop.
|
|
999
|
+
*
|
|
1000
|
+
* Returns structure only; hydrate content via `getDocumentsByDocumentIds`.
|
|
1001
|
+
*/
|
|
1002
|
+
getTreeSubtree(params: {
|
|
1003
|
+
collectionId: string;
|
|
1004
|
+
rootDocumentId?: string | null;
|
|
1005
|
+
maxDepth?: number;
|
|
1006
|
+
readMode?: ReadMode;
|
|
1007
|
+
}): Promise<Array<{
|
|
1008
|
+
document_id: string;
|
|
1009
|
+
parent_document_id: string | null;
|
|
1010
|
+
depth: number;
|
|
1011
|
+
order_key: string;
|
|
1012
|
+
}>>;
|
|
876
1013
|
}
|
|
@@ -29,6 +29,9 @@ export declare const RESERVED_FIELD_NAMES: ReadonlySet<string>;
|
|
|
29
29
|
* - When `advertiseLocales` is `true`, the collection must have at least
|
|
30
30
|
* one `localized` field — advertising content locales is meaningless
|
|
31
31
|
* otherwise.
|
|
32
|
+
* - A collection may not set both `tree: true` and `orderable: true`. A
|
|
33
|
+
* document-tree owns ordering per-parent on the tree edge, so
|
|
34
|
+
* `byline_documents.order_key` is inert for it.
|
|
32
35
|
*
|
|
33
36
|
* Throws a plain `Error` (not a `BylineError`) because configuration
|
|
34
37
|
* validation runs at startup, before the logger and error registry are
|
|
@@ -71,6 +71,9 @@ function walkFields(fields, visit) {
|
|
|
71
71
|
* - When `advertiseLocales` is `true`, the collection must have at least
|
|
72
72
|
* one `localized` field — advertising content locales is meaningless
|
|
73
73
|
* otherwise.
|
|
74
|
+
* - A collection may not set both `tree: true` and `orderable: true`. A
|
|
75
|
+
* document-tree owns ordering per-parent on the tree edge, so
|
|
76
|
+
* `byline_documents.order_key` is inert for it.
|
|
74
77
|
*
|
|
75
78
|
* Throws a plain `Error` (not a `BylineError`) because configuration
|
|
76
79
|
* validation runs at startup, before the logger and error registry are
|
|
@@ -96,5 +99,8 @@ export function validateCollections(collections) {
|
|
|
96
99
|
if (collection.advertiseLocales === true && !hasLocalizedField(collection.fields)) {
|
|
97
100
|
throw new Error(`Collection "${collection.path}" sets \`advertiseLocales: true\` but has no localized fields. The available-locales control advertises content locales, which is only meaningful when at least one field is \`localized\`.`);
|
|
98
101
|
}
|
|
102
|
+
if (collection.tree === true && collection.orderable === true) {
|
|
103
|
+
throw new Error(`Collection "${collection.path}" sets both \`tree: true\` and \`orderable: true\`. A document-tree collection owns ordering on the tree edge (per-parent), so \`byline_documents.order_key\` is inert — set only \`tree: true\`.`);
|
|
104
|
+
}
|
|
99
105
|
}
|
|
100
106
|
}
|
|
@@ -214,4 +214,28 @@ describe('validateCollections', () => {
|
|
|
214
214
|
it('accepts advertiseLocales omitted regardless of localized fields', () => {
|
|
215
215
|
expect(() => validateCollections([baseCollection])).not.toThrow();
|
|
216
216
|
});
|
|
217
|
+
it('accepts tree: true on its own', () => {
|
|
218
|
+
const collection = {
|
|
219
|
+
...baseCollection,
|
|
220
|
+
useAsTitle: 'title',
|
|
221
|
+
useAsPath: 'title',
|
|
222
|
+
tree: true,
|
|
223
|
+
};
|
|
224
|
+
expect(() => validateCollections([collection])).not.toThrow();
|
|
225
|
+
});
|
|
226
|
+
it('rejects tree: true together with orderable: true', () => {
|
|
227
|
+
const collection = {
|
|
228
|
+
...baseCollection,
|
|
229
|
+
tree: true,
|
|
230
|
+
orderable: true,
|
|
231
|
+
};
|
|
232
|
+
expect(() => validateCollections([collection])).toThrow(/tree: true.*orderable: true|orderable/);
|
|
233
|
+
});
|
|
234
|
+
it('accepts orderable: true without tree', () => {
|
|
235
|
+
const collection = {
|
|
236
|
+
...baseCollection,
|
|
237
|
+
orderable: true,
|
|
238
|
+
};
|
|
239
|
+
expect(() => validateCollections([collection])).not.toThrow();
|
|
240
|
+
});
|
|
217
241
|
});
|
|
@@ -46,6 +46,8 @@ function createMockDb(options) {
|
|
|
46
46
|
softDeleteDocument: vi.fn(fail),
|
|
47
47
|
deleteDocumentLocale: vi.fn(fail),
|
|
48
48
|
setOrderKey: vi.fn(fail),
|
|
49
|
+
placeTreeNode: vi.fn(fail),
|
|
50
|
+
removeFromTree: vi.fn(fail),
|
|
49
51
|
},
|
|
50
52
|
counters: {
|
|
51
53
|
ensureCounterGroup: vi.fn(fail),
|
|
@@ -74,6 +76,10 @@ function createMockDb(options) {
|
|
|
74
76
|
getLastOrderKey: vi.fn(fail),
|
|
75
77
|
getNeighborOrderKeys: vi.fn(fail),
|
|
76
78
|
getCanonicalDocumentOrder: vi.fn(fail),
|
|
79
|
+
getTreeAncestors: vi.fn(fail),
|
|
80
|
+
getTreeChildren: vi.fn(fail),
|
|
81
|
+
getTreeParent: vi.fn(fail),
|
|
82
|
+
getTreeSubtree: vi.fn(fail),
|
|
77
83
|
},
|
|
78
84
|
},
|
|
79
85
|
};
|
|
@@ -191,6 +197,8 @@ describe('ensureCollections', () => {
|
|
|
191
197
|
softDeleteDocument: vi.fn(),
|
|
192
198
|
deleteDocumentLocale: vi.fn(),
|
|
193
199
|
setOrderKey: vi.fn(),
|
|
200
|
+
placeTreeNode: vi.fn(),
|
|
201
|
+
removeFromTree: vi.fn(),
|
|
194
202
|
},
|
|
195
203
|
counters: {
|
|
196
204
|
ensureCounterGroup: vi.fn(),
|
|
@@ -219,6 +227,10 @@ describe('ensureCollections', () => {
|
|
|
219
227
|
getLastOrderKey: vi.fn(),
|
|
220
228
|
getNeighborOrderKeys: vi.fn(),
|
|
221
229
|
getCanonicalDocumentOrder: vi.fn(),
|
|
230
|
+
getTreeAncestors: vi.fn(),
|
|
231
|
+
getTreeChildren: vi.fn(),
|
|
232
|
+
getTreeParent: vi.fn(),
|
|
233
|
+
getTreeSubtree: vi.fn(),
|
|
222
234
|
},
|
|
223
235
|
},
|
|
224
236
|
};
|
|
@@ -31,6 +31,8 @@ function makeAdapter(options) {
|
|
|
31
31
|
softDeleteDocument: vi.fn(fail),
|
|
32
32
|
deleteDocumentLocale: vi.fn(fail),
|
|
33
33
|
setOrderKey: vi.fn(fail),
|
|
34
|
+
placeTreeNode: vi.fn(fail),
|
|
35
|
+
removeFromTree: vi.fn(fail),
|
|
34
36
|
},
|
|
35
37
|
counters: {
|
|
36
38
|
ensureCounterGroup,
|
|
@@ -59,6 +61,10 @@ function makeAdapter(options) {
|
|
|
59
61
|
getLastOrderKey: vi.fn(fail),
|
|
60
62
|
getNeighborOrderKeys: vi.fn(fail),
|
|
61
63
|
getCanonicalDocumentOrder: vi.fn(fail),
|
|
64
|
+
getTreeAncestors: vi.fn(fail),
|
|
65
|
+
getTreeChildren: vi.fn(fail),
|
|
66
|
+
getTreeParent: vi.fn(fail),
|
|
67
|
+
getTreeSubtree: vi.fn(fail),
|
|
62
68
|
},
|
|
63
69
|
},
|
|
64
70
|
};
|
|
@@ -13,7 +13,7 @@ import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
|
13
13
|
import { slugify } from '../../utils/slugify.js';
|
|
14
14
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
|
-
import { actorId, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
|
|
16
|
+
import { actorId, appendTreeRoot, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
|
|
17
17
|
/**
|
|
18
18
|
* Create a new document.
|
|
19
19
|
*
|
|
@@ -80,6 +80,22 @@ export async function createDocument(ctx, params) {
|
|
|
80
80
|
.catch((err) => rethrowPathConflict(err, resolvedPath, defaultLocale));
|
|
81
81
|
const documentId = extractDocumentId(result.document);
|
|
82
82
|
const documentVersionId = extractVersionId(result.document);
|
|
83
|
+
// `tree: true` collections place every document in the tree by default:
|
|
84
|
+
// a new document is appended as a root (a top-level nav entry) so it is
|
|
85
|
+
// never stranded in the "unplaced" limbo. This is a system step of create
|
|
86
|
+
// (the actor already passed the `create` ability), so it calls the storage
|
|
87
|
+
// command directly — no `update` re-assertion, no separate tree event
|
|
88
|
+
// (afterCreate covers invalidation). Post-version and best-effort: a
|
|
89
|
+
// failure leaves the document created-but-unplaced and is logged, not
|
|
90
|
+
// thrown. See docs/DOCUMENT-TREE.md.
|
|
91
|
+
if (definition.tree === true) {
|
|
92
|
+
try {
|
|
93
|
+
await appendTreeRoot(ctx, documentId);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
ctx.logger.error({ err, documentId }, 'failed to auto-place new document in tree');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
83
99
|
await invokeHook(hooks?.afterCreate, {
|
|
84
100
|
data,
|
|
85
101
|
collectionPath,
|
|
@@ -12,6 +12,7 @@ import { withLogContext } from '../../lib/logger.js';
|
|
|
12
12
|
import { getUploadFields } from '../../utils/storage-utils.js';
|
|
13
13
|
import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
|
|
14
14
|
import { invokeHook } from './internals.js';
|
|
15
|
+
import { promoteChildrenAndRemove } from './tree.js';
|
|
15
16
|
/**
|
|
16
17
|
* Soft-delete a document.
|
|
17
18
|
*
|
|
@@ -124,7 +125,23 @@ export async function deleteDocument(ctx, params) {
|
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
}
|
|
127
|
-
// 5.
|
|
128
|
+
// 5. Reconcile the document tree for `tree: true` collections. Byline
|
|
129
|
+
// deletes are soft (the document row survives), so the table's
|
|
130
|
+
// promote/cascade foreign keys never fire — promote the node's
|
|
131
|
+
// children to root and remove its edge here instead, firing the
|
|
132
|
+
// structural-change invalidation event. Post-commit and best-effort,
|
|
133
|
+
// like file cleanup: a failure here leaves the soft-delete intact
|
|
134
|
+
// (status-at-edge already hides the deleted node's subtree from
|
|
135
|
+
// reads) and is logged rather than thrown. See docs/DOCUMENT-TREE.md.
|
|
136
|
+
if (definition.tree === true) {
|
|
137
|
+
try {
|
|
138
|
+
await promoteChildrenAndRemove(ctx, { documentId: params.documentId });
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
logger.error({ err, documentId: params.documentId }, 'failed to reconcile document tree on delete');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// 6. afterDelete hook.
|
|
128
145
|
await invokeHook(hooks?.afterDelete, hookCtx);
|
|
129
146
|
return { deletedVersionCount };
|
|
130
147
|
});
|
|
@@ -30,6 +30,7 @@ export { duplicateDocument } from './duplicate.js';
|
|
|
30
30
|
export { restoreDocumentVersion } from './restore.js';
|
|
31
31
|
export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
32
32
|
export { updateDocumentSystemFields } from './system-fields.js';
|
|
33
|
+
export { placeTreeNode, promoteChildrenAndRemove, removeFromTree } from './tree.js';
|
|
33
34
|
export { updateDocument, updateDocumentWithPatches } from './update.js';
|
|
34
35
|
export type { DocumentLifecycleContext } from './context.js';
|
|
35
36
|
export type { CopyToLocaleResult } from './copy-to-locale.js';
|
|
@@ -30,4 +30,5 @@ export { duplicateDocument } from './duplicate.js';
|
|
|
30
30
|
export { restoreDocumentVersion } from './restore.js';
|
|
31
31
|
export { changeDocumentStatus, unpublishDocument } from './status.js';
|
|
32
32
|
export { updateDocumentSystemFields } from './system-fields.js';
|
|
33
|
+
export { placeTreeNode, promoteChildrenAndRemove, removeFromTree } from './tree.js';
|
|
33
34
|
export { updateDocument, updateDocumentWithPatches } from './update.js';
|
|
@@ -59,6 +59,26 @@ export declare function applyRichTextEmbed(ctx: DocumentLifecycleContext, data:
|
|
|
59
59
|
* gets `order_key = NULL` and the existing "no ordering" behavior holds.
|
|
60
60
|
*/
|
|
61
61
|
export declare function maybeAppendOrderKey(ctx: DocumentLifecycleContext, collectionPath: string): Promise<string | undefined>;
|
|
62
|
+
/**
|
|
63
|
+
* Append a document as the **last root** of its `tree: true` collection's tree.
|
|
64
|
+
* Mints a fresh root-group `order_key` after the current trailing root. Used by
|
|
65
|
+
* create's auto-place and update's self-heal so a tree collection never strands a
|
|
66
|
+
* document in the "unplaced" limbo. Issues the storage command directly — the
|
|
67
|
+
* caller has already asserted the relevant ability and this is a system step.
|
|
68
|
+
*/
|
|
69
|
+
export declare function appendTreeRoot(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Self-heal a genuinely-*unplaced* document on update: if the collection is a
|
|
72
|
+
* tree and the document has no edge row, append it as a root (mirroring create's
|
|
73
|
+
* auto-place). `getTreeParent` distinguishes unplaced from root, so an existing
|
|
74
|
+
* root or child is left exactly where it is — only strays (e.g. docs created
|
|
75
|
+
* before the flag, or whose create-time auto-place failed) are re-treed.
|
|
76
|
+
*
|
|
77
|
+
* No-op for non-tree collections. Best-effort and post-version: a failure leaves
|
|
78
|
+
* the document saved-but-unplaced and is logged, never thrown. See
|
|
79
|
+
* docs/DOCUMENT-TREE.md.
|
|
80
|
+
*/
|
|
81
|
+
export declare function selfHealTreePlacement(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
|
|
62
82
|
/** Extract `id` from the document object returned by `createDocumentVersion`. */
|
|
63
83
|
export declare function extractVersionId(document: any): string;
|
|
64
84
|
/** Extract the logical document id from the document object returned by `createDocumentVersion`. */
|
|
@@ -103,6 +103,49 @@ export async function maybeAppendOrderKey(ctx, collectionPath) {
|
|
|
103
103
|
});
|
|
104
104
|
return generateKeyBetween(last, null);
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Append a document as the **last root** of its `tree: true` collection's tree.
|
|
108
|
+
* Mints a fresh root-group `order_key` after the current trailing root. Used by
|
|
109
|
+
* create's auto-place and update's self-heal so a tree collection never strands a
|
|
110
|
+
* document in the "unplaced" limbo. Issues the storage command directly — the
|
|
111
|
+
* caller has already asserted the relevant ability and this is a system step.
|
|
112
|
+
*/
|
|
113
|
+
export async function appendTreeRoot(ctx, documentId) {
|
|
114
|
+
const roots = await ctx.db.queries.documents.getTreeChildren({
|
|
115
|
+
collectionId: ctx.collectionId,
|
|
116
|
+
parentDocumentId: null,
|
|
117
|
+
});
|
|
118
|
+
await ctx.db.commands.documents.placeTreeNode({
|
|
119
|
+
collectionId: ctx.collectionId,
|
|
120
|
+
documentId,
|
|
121
|
+
parentDocumentId: null,
|
|
122
|
+
beforeDocumentId: roots.at(-1)?.document_id ?? null,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Self-heal a genuinely-*unplaced* document on update: if the collection is a
|
|
127
|
+
* tree and the document has no edge row, append it as a root (mirroring create's
|
|
128
|
+
* auto-place). `getTreeParent` distinguishes unplaced from root, so an existing
|
|
129
|
+
* root or child is left exactly where it is — only strays (e.g. docs created
|
|
130
|
+
* before the flag, or whose create-time auto-place failed) are re-treed.
|
|
131
|
+
*
|
|
132
|
+
* No-op for non-tree collections. Best-effort and post-version: a failure leaves
|
|
133
|
+
* the document saved-but-unplaced and is logged, never thrown. See
|
|
134
|
+
* docs/DOCUMENT-TREE.md.
|
|
135
|
+
*/
|
|
136
|
+
export async function selfHealTreePlacement(ctx, documentId) {
|
|
137
|
+
if (ctx.definition.tree !== true)
|
|
138
|
+
return;
|
|
139
|
+
try {
|
|
140
|
+
const { placed } = await ctx.db.queries.documents.getTreeParent({ document_id: documentId });
|
|
141
|
+
if (placed)
|
|
142
|
+
return;
|
|
143
|
+
await appendTreeRoot(ctx, documentId);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
ctx.logger.error({ err, documentId }, 'failed to self-heal tree placement on update');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
106
149
|
/** Extract `id` from the document object returned by `createDocumentVersion`. */
|
|
107
150
|
export function extractVersionId(document) {
|
|
108
151
|
return document?.id ?? document?.document_version_id ?? '';
|
|
@@ -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
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
9
|
+
/**
|
|
10
|
+
* Place or move a node within the collection's tree (place / reorder /
|
|
11
|
+
* re-parent — one upsert). Asserts the `update` ability, performs the storage
|
|
12
|
+
* write, then fires `afterTreeChange` with the affected set. The affected-set
|
|
13
|
+
* reads are skipped entirely when the collection declares no `afterTreeChange`
|
|
14
|
+
* hook, so the event machinery adds no overhead to collections that don't
|
|
15
|
+
* consume it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function placeTreeNode(ctx: DocumentLifecycleContext, params: {
|
|
18
|
+
documentId: string;
|
|
19
|
+
parentDocumentId: string | null;
|
|
20
|
+
beforeDocumentId?: string | null;
|
|
21
|
+
afterDocumentId?: string | null;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
orderKey: string;
|
|
24
|
+
}>;
|
|
25
|
+
/**
|
|
26
|
+
* Remove a node from the tree (back to the *unplaced* state). Asserts `update`,
|
|
27
|
+
* performs the storage delete, then fires `afterTreeChange`. Distinct from
|
|
28
|
+
* deleting the document.
|
|
29
|
+
*/
|
|
30
|
+
export declare function removeFromTree(ctx: DocumentLifecycleContext, params: {
|
|
31
|
+
documentId: string;
|
|
32
|
+
}): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Promote a soft-deleted node's children to root and remove the node from the
|
|
35
|
+
* tree — the application-level equivalent of the table's `parent → set null`
|
|
36
|
+
* (promote) and `child → cascade` (leave) foreign keys, which only fire on a
|
|
37
|
+
* *hard* row delete. Byline deletes are soft (the document row survives), so the
|
|
38
|
+
* tree must be reconciled here. Fires one `afterTreeChange` (`promote-on-delete`)
|
|
39
|
+
* covering the deleted node, the promoted children, and their subtrees.
|
|
40
|
+
*
|
|
41
|
+
* Best-effort and idempotent: a node with no children or no edge row is a no-op.
|
|
42
|
+
* Called by the document delete lifecycle for `tree: true` collections.
|
|
43
|
+
*/
|
|
44
|
+
export declare function promoteChildrenAndRemove(ctx: DocumentLifecycleContext, params: {
|
|
45
|
+
documentId: string;
|
|
46
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Document-tree lifecycle service — the unversioned structural mutations for
|
|
10
|
+
* `tree: true` collections (docs/DOCUMENT-TREE.md). Wraps the storage adapter's
|
|
11
|
+
* tree commands so that, like the versioned lifecycle services, they enforce the
|
|
12
|
+
* actor ability and fire a collection hook. Tree writes mint no document version
|
|
13
|
+
* and touch no user fields, so the `afterTreeChange` hook is the *only*
|
|
14
|
+
* invalidation signal for them.
|
|
15
|
+
*
|
|
16
|
+
* The hook payload is the **affected set** — not a single node — because one
|
|
17
|
+
* structural change ripples: the moved node, its descendants (breadcrumb trails
|
|
18
|
+
* changed), the old and new parents, and both sibling lists. Computed with a few
|
|
19
|
+
* cheap tree reads around the write and emitted as one batched event.
|
|
20
|
+
*/
|
|
21
|
+
import { resolveHooks } from '../../@types/index.js';
|
|
22
|
+
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
23
|
+
import { withLogContext } from '../../lib/logger.js';
|
|
24
|
+
import { invokeHook } from './internals.js';
|
|
25
|
+
/** Immediate parent of a node, or `null` when it is a root or unplaced. */
|
|
26
|
+
async function immediateParentId(ctx, documentId) {
|
|
27
|
+
const ancestors = await ctx.db.queries.documents.getTreeAncestors({ document_id: documentId });
|
|
28
|
+
// `getTreeAncestors` is root-first, so the last entry (depth 1) is the parent.
|
|
29
|
+
return ancestors.length > 0 ? (ancestors[ancestors.length - 1]?.document_id ?? null) : null;
|
|
30
|
+
}
|
|
31
|
+
/** Document ids of a node and all its descendants (status-agnostic). */
|
|
32
|
+
async function subtreeIds(ctx, rootDocumentId) {
|
|
33
|
+
const rows = await ctx.db.queries.documents.getTreeSubtree({
|
|
34
|
+
collectionId: ctx.collectionId,
|
|
35
|
+
rootDocumentId,
|
|
36
|
+
readMode: 'any',
|
|
37
|
+
});
|
|
38
|
+
return rows.map((r) => r.document_id);
|
|
39
|
+
}
|
|
40
|
+
/** Document ids of a parent's immediate children (`null` = the collection roots). */
|
|
41
|
+
async function childIds(ctx, parentDocumentId) {
|
|
42
|
+
const rows = await ctx.db.queries.documents.getTreeChildren({
|
|
43
|
+
collectionId: ctx.collectionId,
|
|
44
|
+
parentDocumentId,
|
|
45
|
+
});
|
|
46
|
+
return rows.map((r) => r.document_id);
|
|
47
|
+
}
|
|
48
|
+
/** Resolve and fire the `afterTreeChange` hook with a de-duplicated affected set. */
|
|
49
|
+
async function fireTreeChange(ctx, definition, event) {
|
|
50
|
+
const hooks = await resolveHooks(definition);
|
|
51
|
+
if (hooks?.afterTreeChange == null)
|
|
52
|
+
return;
|
|
53
|
+
await invokeHook(hooks.afterTreeChange, {
|
|
54
|
+
collectionPath: ctx.collectionPath,
|
|
55
|
+
change: event.change,
|
|
56
|
+
documentId: event.documentId,
|
|
57
|
+
affectedDocumentIds: [...new Set(event.affectedDocumentIds)],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Place or move a node within the collection's tree (place / reorder /
|
|
62
|
+
* re-parent — one upsert). Asserts the `update` ability, performs the storage
|
|
63
|
+
* write, then fires `afterTreeChange` with the affected set. The affected-set
|
|
64
|
+
* reads are skipped entirely when the collection declares no `afterTreeChange`
|
|
65
|
+
* hook, so the event machinery adds no overhead to collections that don't
|
|
66
|
+
* consume it.
|
|
67
|
+
*/
|
|
68
|
+
export async function placeTreeNode(ctx, params) {
|
|
69
|
+
return withLogContext({ domain: 'services', module: 'tree', function: 'placeTreeNode' }, async () => {
|
|
70
|
+
const { db, definition, collectionPath } = ctx;
|
|
71
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
72
|
+
const hooks = await resolveHooks(definition);
|
|
73
|
+
const wantsEvent = hooks?.afterTreeChange != null;
|
|
74
|
+
// Affected-before: the moved node's old parent, its subtree (descendants
|
|
75
|
+
// travel with it), and its old sibling group.
|
|
76
|
+
const before = wantsEvent
|
|
77
|
+
? {
|
|
78
|
+
oldParentId: await immediateParentId(ctx, params.documentId),
|
|
79
|
+
subtree: await subtreeIds(ctx, params.documentId),
|
|
80
|
+
}
|
|
81
|
+
: null;
|
|
82
|
+
const oldSiblings = before ? await childIds(ctx, before.oldParentId) : [];
|
|
83
|
+
const result = await db.commands.documents.placeTreeNode({
|
|
84
|
+
collectionId: ctx.collectionId,
|
|
85
|
+
documentId: params.documentId,
|
|
86
|
+
parentDocumentId: params.parentDocumentId,
|
|
87
|
+
beforeDocumentId: params.beforeDocumentId ?? null,
|
|
88
|
+
afterDocumentId: params.afterDocumentId ?? null,
|
|
89
|
+
});
|
|
90
|
+
if (before) {
|
|
91
|
+
const newSiblings = await childIds(ctx, params.parentDocumentId);
|
|
92
|
+
await fireTreeChange(ctx, definition, {
|
|
93
|
+
change: 'place',
|
|
94
|
+
documentId: params.documentId,
|
|
95
|
+
affectedDocumentIds: [
|
|
96
|
+
params.documentId,
|
|
97
|
+
...before.subtree,
|
|
98
|
+
...(before.oldParentId ? [before.oldParentId] : []),
|
|
99
|
+
...(params.parentDocumentId ? [params.parentDocumentId] : []),
|
|
100
|
+
...oldSiblings,
|
|
101
|
+
...newSiblings,
|
|
102
|
+
],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Remove a node from the tree (back to the *unplaced* state). Asserts `update`,
|
|
110
|
+
* performs the storage delete, then fires `afterTreeChange`. Distinct from
|
|
111
|
+
* deleting the document.
|
|
112
|
+
*/
|
|
113
|
+
export async function removeFromTree(ctx, params) {
|
|
114
|
+
return withLogContext({ domain: 'services', module: 'tree', function: 'removeFromTree' }, async () => {
|
|
115
|
+
const { db, definition, collectionPath } = ctx;
|
|
116
|
+
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
117
|
+
const hooks = await resolveHooks(definition);
|
|
118
|
+
const wantsEvent = hooks?.afterTreeChange != null;
|
|
119
|
+
const oldParentId = wantsEvent ? await immediateParentId(ctx, params.documentId) : null;
|
|
120
|
+
const subtree = wantsEvent ? await subtreeIds(ctx, params.documentId) : [];
|
|
121
|
+
const oldSiblings = wantsEvent ? await childIds(ctx, oldParentId) : [];
|
|
122
|
+
await db.commands.documents.removeFromTree({ documentId: params.documentId });
|
|
123
|
+
if (wantsEvent) {
|
|
124
|
+
await fireTreeChange(ctx, definition, {
|
|
125
|
+
change: 'remove',
|
|
126
|
+
documentId: params.documentId,
|
|
127
|
+
affectedDocumentIds: [
|
|
128
|
+
params.documentId,
|
|
129
|
+
...subtree,
|
|
130
|
+
...(oldParentId ? [oldParentId] : []),
|
|
131
|
+
...oldSiblings,
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Promote a soft-deleted node's children to root and remove the node from the
|
|
139
|
+
* tree — the application-level equivalent of the table's `parent → set null`
|
|
140
|
+
* (promote) and `child → cascade` (leave) foreign keys, which only fire on a
|
|
141
|
+
* *hard* row delete. Byline deletes are soft (the document row survives), so the
|
|
142
|
+
* tree must be reconciled here. Fires one `afterTreeChange` (`promote-on-delete`)
|
|
143
|
+
* covering the deleted node, the promoted children, and their subtrees.
|
|
144
|
+
*
|
|
145
|
+
* Best-effort and idempotent: a node with no children or no edge row is a no-op.
|
|
146
|
+
* Called by the document delete lifecycle for `tree: true` collections.
|
|
147
|
+
*/
|
|
148
|
+
export async function promoteChildrenAndRemove(ctx, params) {
|
|
149
|
+
return withLogContext({ domain: 'services', module: 'tree', function: 'promoteChildrenAndRemove' }, async () => {
|
|
150
|
+
const { db, definition } = ctx;
|
|
151
|
+
// Capture the affected set before mutating: the node, its (direct)
|
|
152
|
+
// children, and each child's subtree (their breadcrumbs all change as
|
|
153
|
+
// they promote to root).
|
|
154
|
+
const hooks = await resolveHooks(definition);
|
|
155
|
+
const wantsEvent = hooks?.afterTreeChange != null;
|
|
156
|
+
const children = await childIds(ctx, params.documentId);
|
|
157
|
+
const affected = new Set([params.documentId]);
|
|
158
|
+
if (wantsEvent) {
|
|
159
|
+
for (const childId of children) {
|
|
160
|
+
for (const id of await subtreeIds(ctx, childId))
|
|
161
|
+
affected.add(id);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Promote each child to root. `placeTreeNode` with no neighbours mints a
|
|
165
|
+
// fresh root-group key, so promoted orphans no longer carry a stale
|
|
166
|
+
// per-parent key.
|
|
167
|
+
for (const childId of children) {
|
|
168
|
+
await db.commands.documents.placeTreeNode({
|
|
169
|
+
collectionId: ctx.collectionId,
|
|
170
|
+
documentId: childId,
|
|
171
|
+
parentDocumentId: null,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
// The deleted node leaves the tree.
|
|
175
|
+
await db.commands.documents.removeFromTree({ documentId: params.documentId });
|
|
176
|
+
if (wantsEvent) {
|
|
177
|
+
await fireTreeChange(ctx, definition, {
|
|
178
|
+
change: 'promote-on-delete',
|
|
179
|
+
documentId: params.documentId,
|
|
180
|
+
affectedDocumentIds: affected,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
@@ -13,7 +13,7 @@ import { applyPatches } from '../../patches/index.js';
|
|
|
13
13
|
import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
14
14
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
|
-
import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
|
|
16
|
+
import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, selfHealTreePlacement, } from './internals.js';
|
|
17
17
|
/**
|
|
18
18
|
* Update a document via full replacement (PUT semantics).
|
|
19
19
|
*
|
|
@@ -90,6 +90,9 @@ export async function updateDocument(ctx, params) {
|
|
|
90
90
|
.catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
|
|
91
91
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
92
92
|
const documentVersionId = extractVersionId(result.document);
|
|
93
|
+
// Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
|
|
94
|
+
// save re-trees a stray (system step, best-effort, no-op when placed).
|
|
95
|
+
await selfHealTreePlacement(ctx, documentId);
|
|
93
96
|
await invokeHook(hooks?.afterUpdate, {
|
|
94
97
|
data,
|
|
95
98
|
originalData,
|
|
@@ -204,6 +207,9 @@ export async function updateDocumentWithPatches(ctx, params) {
|
|
|
204
207
|
.catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
|
|
205
208
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
206
209
|
const documentVersionId = extractVersionId(result.document);
|
|
210
|
+
// Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
|
|
211
|
+
// save re-trees a stray (system step, best-effort, no-op when placed).
|
|
212
|
+
await selfHealTreePlacement(ctx, documentId);
|
|
207
213
|
// 7. afterUpdate hook.
|
|
208
214
|
await invokeHook(hooks?.afterUpdate, {
|
|
209
215
|
data: nextData,
|
|
@@ -57,6 +57,8 @@ function createMockDb() {
|
|
|
57
57
|
softDeleteDocument,
|
|
58
58
|
deleteDocumentLocale: vi.fn(),
|
|
59
59
|
setOrderKey: vi.fn(),
|
|
60
|
+
placeTreeNode: vi.fn(),
|
|
61
|
+
removeFromTree: vi.fn(),
|
|
60
62
|
},
|
|
61
63
|
counters: {
|
|
62
64
|
ensureCounterGroup: vi.fn(),
|
|
@@ -87,6 +89,10 @@ function createMockDb() {
|
|
|
87
89
|
getLastOrderKey: vi.fn(),
|
|
88
90
|
getNeighborOrderKeys: vi.fn(),
|
|
89
91
|
getCanonicalDocumentOrder: vi.fn(),
|
|
92
|
+
getTreeAncestors: vi.fn(),
|
|
93
|
+
getTreeChildren: vi.fn(),
|
|
94
|
+
getTreeParent: vi.fn(),
|
|
95
|
+
getTreeSubtree: vi.fn(),
|
|
90
96
|
},
|
|
91
97
|
},
|
|
92
98
|
};
|
|
@@ -60,6 +60,8 @@ function createMockDb() {
|
|
|
60
60
|
softDeleteDocument: vi.fn(),
|
|
61
61
|
deleteDocumentLocale: vi.fn(),
|
|
62
62
|
setOrderKey: vi.fn(),
|
|
63
|
+
placeTreeNode: vi.fn(),
|
|
64
|
+
removeFromTree: vi.fn(),
|
|
63
65
|
},
|
|
64
66
|
counters: {
|
|
65
67
|
ensureCounterGroup: vi.fn(),
|
|
@@ -88,6 +90,10 @@ function createMockDb() {
|
|
|
88
90
|
getLastOrderKey: vi.fn(),
|
|
89
91
|
getNeighborOrderKeys: vi.fn(),
|
|
90
92
|
getCanonicalDocumentOrder: vi.fn(),
|
|
93
|
+
getTreeAncestors: vi.fn(),
|
|
94
|
+
getTreeChildren: vi.fn(),
|
|
95
|
+
getTreeParent: vi.fn(),
|
|
96
|
+
getTreeSubtree: vi.fn(),
|
|
91
97
|
},
|
|
92
98
|
},
|
|
93
99
|
};
|
|
@@ -148,6 +148,8 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
|
|
|
148
148
|
softDeleteDocument: vi.fn(),
|
|
149
149
|
deleteDocumentLocale: vi.fn(),
|
|
150
150
|
setOrderKey: vi.fn(),
|
|
151
|
+
placeTreeNode: vi.fn(),
|
|
152
|
+
removeFromTree: vi.fn(),
|
|
151
153
|
},
|
|
152
154
|
counters: {
|
|
153
155
|
ensureCounterGroup: vi.fn(),
|
|
@@ -176,6 +178,10 @@ function makeMockAdapter(store = {}, pathByCollectionId = {}) {
|
|
|
176
178
|
getLastOrderKey: vi.fn(),
|
|
177
179
|
getNeighborOrderKeys: vi.fn(),
|
|
178
180
|
getCanonicalDocumentOrder: vi.fn(),
|
|
181
|
+
getTreeAncestors: vi.fn(),
|
|
182
|
+
getTreeChildren: vi.fn(),
|
|
183
|
+
getTreeParent: vi.fn(),
|
|
184
|
+
getTreeSubtree: vi.fn(),
|
|
179
185
|
},
|
|
180
186
|
},
|
|
181
187
|
};
|
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": "3.
|
|
5
|
+
"version": "3.13.1",
|
|
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": "3.
|
|
82
|
+
"@byline/auth": "3.13.1"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|