@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.
@@ -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. afterDelete hook.
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.12.2",
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.12.2"
82
+ "@byline/auth": "3.13.1"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",