@byline/core 3.20.4 → 4.0.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.
Files changed (103) hide show
  1. package/dist/@types/collection-types.d.ts +72 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-data-types.d.ts +8 -10
  4. package/dist/@types/field-data-types.js +1 -1
  5. package/dist/@types/field-data-types.test.node.d.ts +1 -0
  6. package/dist/@types/field-data-types.test.node.js +70 -0
  7. package/dist/@types/field-types.d.ts +25 -1
  8. package/dist/@types/index.d.ts +1 -0
  9. package/dist/@types/index.js +1 -0
  10. package/dist/@types/query-predicate.d.ts +3 -3
  11. package/dist/@types/relation-types.d.ts +38 -0
  12. package/dist/@types/relation-types.js +8 -0
  13. package/dist/@types/search-types.d.ts +3 -4
  14. package/dist/@types/site-config.d.ts +52 -13
  15. package/dist/auth/apply-before-read.d.ts +23 -11
  16. package/dist/auth/apply-before-read.js +139 -33
  17. package/dist/auth/apply-before-read.test.node.js +241 -3
  18. package/dist/auth/index.d.ts +1 -1
  19. package/dist/auth/index.js +1 -1
  20. package/dist/auth/read-context-scope.d.ts +20 -0
  21. package/dist/auth/read-context-scope.js +48 -0
  22. package/dist/codegen/fixtures/all-fields.d.ts +342 -0
  23. package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
  24. package/dist/codegen/fixtures/all-fields.expected.js +1 -0
  25. package/dist/codegen/fixtures/all-fields.js +94 -0
  26. package/dist/codegen/index.d.ts +16 -0
  27. package/dist/codegen/index.js +431 -0
  28. package/dist/codegen/index.test.node.d.ts +1 -0
  29. package/dist/codegen/index.test.node.js +230 -0
  30. package/dist/config/attach-hooks.d.ts +25 -0
  31. package/dist/config/attach-hooks.js +130 -0
  32. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  33. package/dist/config/attach-hooks.test.node.js +173 -0
  34. package/dist/config/config-hooks.test.node.d.ts +1 -0
  35. package/dist/config/config-hooks.test.node.js +56 -0
  36. package/dist/config/config.d.ts +9 -5
  37. package/dist/config/config.js +20 -2
  38. package/dist/config/routes.d.ts +5 -5
  39. package/dist/config/routes.js +42 -9
  40. package/dist/config/routes.test.node.d.ts +1 -0
  41. package/dist/config/routes.test.node.js +152 -0
  42. package/dist/core.d.ts +3 -3
  43. package/dist/core.js +20 -14
  44. package/dist/core.test.node.d.ts +1 -0
  45. package/dist/core.test.node.js +28 -0
  46. package/dist/index.d.ts +4 -3
  47. package/dist/index.js +4 -3
  48. package/dist/lib/errors.d.ts +13 -0
  49. package/dist/lib/errors.js +14 -0
  50. package/dist/query/parse-where.d.ts +10 -1
  51. package/dist/query/parse-where.js +146 -2
  52. package/dist/query/parse-where.test.node.js +60 -1
  53. package/dist/schemas/zod/builder.js +7 -3
  54. package/dist/schemas/zod/builder.test.node.d.ts +1 -0
  55. package/dist/schemas/zod/builder.test.node.js +49 -0
  56. package/dist/services/collection-bootstrap.d.ts +1 -1
  57. package/dist/services/collection-bootstrap.test.node.js +30 -59
  58. package/dist/services/discover-counter-groups.d.ts +1 -1
  59. package/dist/services/discover-counter-groups.test.node.js +23 -0
  60. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  61. package/dist/services/document-lifecycle/audit.js +32 -1
  62. package/dist/services/document-lifecycle/create.d.ts +2 -2
  63. package/dist/services/document-lifecycle/create.js +18 -8
  64. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  65. package/dist/services/document-lifecycle/delete.js +91 -26
  66. package/dist/services/document-lifecycle/index.d.ts +1 -1
  67. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  68. package/dist/services/document-lifecycle/internals.js +22 -46
  69. package/dist/services/document-lifecycle/status.d.ts +1 -1
  70. package/dist/services/document-lifecycle/status.js +20 -3
  71. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  72. package/dist/services/document-lifecycle/system-fields.js +112 -74
  73. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  74. package/dist/services/document-lifecycle/tree.js +244 -123
  75. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  76. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  77. package/dist/services/document-lifecycle/update.d.ts +4 -4
  78. package/dist/services/document-lifecycle/update.js +11 -5
  79. package/dist/services/document-lifecycle.test.node.js +426 -16
  80. package/dist/services/document-read.d.ts +14 -6
  81. package/dist/services/document-read.js +47 -9
  82. package/dist/services/field-upload.test.node.js +70 -0
  83. package/dist/services/index.d.ts +3 -2
  84. package/dist/services/index.js +3 -2
  85. package/dist/services/normalize-numeric-fields.d.ts +23 -0
  86. package/dist/services/normalize-numeric-fields.js +89 -0
  87. package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
  88. package/dist/services/normalize-numeric-fields.test.node.js +99 -0
  89. package/dist/services/populate.d.ts +9 -23
  90. package/dist/services/populate.js +180 -28
  91. package/dist/services/populate.test.node.js +59 -0
  92. package/dist/services/richtext-embed.d.ts +39 -2
  93. package/dist/services/richtext-embed.js +4 -34
  94. package/dist/services/richtext-embed.test.node.js +15 -0
  95. package/dist/services/richtext-populate.d.ts +21 -3
  96. package/dist/services/richtext-populate.js +160 -19
  97. package/dist/services/richtext-populate.test.node.js +523 -2
  98. package/dist/services/validate-search-config.d.ts +1 -1
  99. package/dist/storage/collection-fingerprint.js +6 -0
  100. package/dist/storage/collection-fingerprint.test.node.js +21 -0
  101. package/dist/utils/root-relative-redirect.d.ts +6 -0
  102. package/dist/utils/root-relative-redirect.js +37 -0
  103. package/package.json +7 -2
@@ -5,42 +5,40 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { type TreeAuditCapability } from './audit.js';
9
+ import type { TreeDeleteMutationResult } from '../../@types/index.js';
8
10
  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: {
11
+ interface PlaceParams {
18
12
  documentId: string;
19
13
  parentDocumentId: string | null;
20
14
  beforeDocumentId?: string | null;
21
15
  afterDocumentId?: string | null;
22
- }): Promise<{
16
+ ifUnplaced?: boolean;
17
+ /** Re-fire the post-commit hook when the requested placement is already current. */
18
+ reconcile?: boolean;
19
+ }
20
+ /** Place, re-parent, or reorder a node; explicit no-op retries may reconcile hooks. */
21
+ export declare function placeTreeNode(ctx: DocumentLifecycleContext, params: PlaceParams): Promise<{
23
22
  orderKey: string;
24
23
  }>;
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
- */
24
+ /** Best-effort create/self-heal primitive that never moves an already-placed node. */
25
+ export declare function appendTreeRoot(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
26
+ /** Best-effort post-version repair; locked `ifUnplaced` closes the check/write race. */
27
+ export declare function selfHealTreePlacement(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
28
+ /** Remove a node to the unplaced state; an already-unplaced node is a true no-op. */
30
29
  export declare function removeFromTree(ctx: DocumentLifecycleContext, params: {
31
30
  documentId: string;
31
+ reconcile?: boolean;
32
32
  }): Promise<void>;
33
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.
34
+ * Reconcile delete-time edges and append parent plus child-specific audit rows.
35
+ * The caller owns the transaction; delete uses this beside soft-delete/audit.
43
36
  */
37
+ export declare function reconcileTreeOnDeleteInTransaction(ctx: DocumentLifecycleContext, documentId: string, capability: TreeAuditCapability): Promise<TreeDeleteMutationResult>;
38
+ /** Fire the promotion invalidation after its transaction commits. */
39
+ export declare function firePromoteTreeChange(ctx: DocumentLifecycleContext, documentId: string, result: TreeDeleteMutationResult): Promise<void>;
40
+ /** Standalone audited reconciliation used by internal tooling and tests. */
44
41
  export declare function promoteChildrenAndRemove(ctx: DocumentLifecycleContext, params: {
45
42
  documentId: string;
46
43
  }): Promise<void>;
44
+ export {};
@@ -5,47 +5,40 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- /**
9
- * Document-tree lifecycle service — the unversioned structural mutations for
10
- * `tree: true` collections (docs/04-collections/03-document-trees.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
- */
8
+ /** Atomic, audited lifecycle orchestration for document-tree mutations. */
21
9
  import { resolveHooks } from '../../@types/index.js';
22
10
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
11
+ import { ERR_TREE_HOOK_COMMITTED, TREE_HOOK_COMMITTED_MARKER } from '../../lib/errors.js';
23
12
  import { withLogContext } from '../../lib/logger.js';
13
+ import { AUDIT_ACTIONS, auditActor, requireTreeAuditCapability, } from './audit.js';
24
14
  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). */
15
+ /** Document ids of a node and descendants, read only for registered hooks. */
32
16
  async function subtreeIds(ctx, rootDocumentId) {
33
17
  const rows = await ctx.db.queries.documents.getTreeSubtree({
34
18
  collectionId: ctx.collectionId,
35
19
  rootDocumentId,
36
20
  readMode: 'any',
37
21
  });
38
- return rows.map((r) => r.document_id);
22
+ return rows.map((row) => row.document_id);
23
+ }
24
+ /** Coarse collection-wide affected set for explicit no-op reconciliation. */
25
+ async function wholeTreeIds(ctx) {
26
+ const rows = await ctx.db.queries.documents.getTreeSubtree({
27
+ collectionId: ctx.collectionId,
28
+ rootDocumentId: null,
29
+ readMode: 'any',
30
+ });
31
+ return rows.map((row) => row.document_id);
39
32
  }
40
- /** Document ids of a parent's immediate children (`null` = the collection roots). */
33
+ /** Document ids of one sibling group, read only for registered hooks. */
41
34
  async function childIds(ctx, parentDocumentId) {
42
35
  const rows = await ctx.db.queries.documents.getTreeChildren({
43
36
  collectionId: ctx.collectionId,
44
37
  parentDocumentId,
45
38
  });
46
- return rows.map((r) => r.document_id);
39
+ return rows.map((row) => row.document_id);
47
40
  }
48
- /** Resolve and fire the `afterTreeChange` hook with a de-duplicated affected set. */
41
+ /** Invoke `afterTreeChange` after the transaction has committed. */
49
42
  async function fireTreeChange(ctx, definition, event) {
50
43
  const hooks = await resolveHooks(definition);
51
44
  if (hooks?.afterTreeChange == null)
@@ -57,128 +50,256 @@ async function fireTreeChange(ctx, definition, event) {
57
50
  affectedDocumentIds: [...new Set(event.affectedDocumentIds)],
58
51
  });
59
52
  }
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({
53
+ /** Preserve rejection semantics while exposing that tree data already committed. */
54
+ async function runCommittedTreeHook(event, callback) {
55
+ try {
56
+ await callback();
57
+ }
58
+ catch (cause) {
59
+ const message = cause instanceof Error ? cause.message : String(cause);
60
+ throw ERR_TREE_HOOK_COMMITTED({
61
+ message: `${TREE_HOOK_COMMITTED_MARKER} tree mutation committed but afterTreeChange failed: ${message}`,
62
+ cause,
63
+ details: event,
64
+ });
65
+ }
66
+ }
67
+ function placementAction(before, parentDocumentId) {
68
+ if (!before.placed)
69
+ return AUDIT_ACTIONS.treePlaced;
70
+ if (before.parentDocumentId !== parentDocumentId)
71
+ return AUDIT_ACTIONS.treeReparented;
72
+ return AUDIT_ACTIONS.treeReordered;
73
+ }
74
+ /** Append one locked place/reparent/reorder result to the audit log. */
75
+ async function appendPlacementAudit(ctx, capability, params, mutation) {
76
+ const actor = auditActor(ctx);
77
+ await capability.append({
78
+ documentId: params.documentId,
79
+ collectionId: ctx.collectionId,
80
+ actorId: actor.actorId,
81
+ actorRealm: actor.actorRealm,
82
+ action: placementAction(mutation.before, params.parentDocumentId),
83
+ field: 'tree',
84
+ before: mutation.before,
85
+ after: {
86
+ ...mutation.after,
87
+ beforeDocumentId: params.beforeDocumentId ?? null,
88
+ afterDocumentId: params.afterDocumentId ?? null,
89
+ },
90
+ });
91
+ }
92
+ /** Locked storage inspection/mutation and audit append in one transaction. */
93
+ async function auditedPlace(ctx, params) {
94
+ const capability = requireTreeAuditCapability(ctx.db);
95
+ let mutation;
96
+ await capability.withTransaction(async () => {
97
+ mutation = await capability.place({
84
98
  collectionId: ctx.collectionId,
85
99
  documentId: params.documentId,
86
100
  parentDocumentId: params.parentDocumentId,
87
101
  beforeDocumentId: params.beforeDocumentId ?? null,
88
102
  afterDocumentId: params.afterDocumentId ?? null,
103
+ ifUnplaced: params.ifUnplaced,
89
104
  });
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
- ],
105
+ if (mutation.changed)
106
+ await appendPlacementAudit(ctx, capability, params, mutation);
107
+ });
108
+ return mutation;
109
+ }
110
+ /** Place, re-parent, or reorder a node; explicit no-op retries may reconcile hooks. */
111
+ export async function placeTreeNode(ctx, params) {
112
+ return withLogContext({ domain: 'services', module: 'tree', function: 'placeTreeNode' }, async () => {
113
+ assertActorCanPerform(ctx.requestContext, ctx.collectionPath, 'update');
114
+ const hooks = await resolveHooks(ctx.definition);
115
+ const wantsEvent = hooks?.afterTreeChange != null;
116
+ const mutation = await auditedPlace(ctx, params);
117
+ const orderKey = mutation.after.orderKey;
118
+ if (orderKey == null)
119
+ throw new Error('placed tree mutation did not return an order key');
120
+ if (!mutation.changed) {
121
+ if (wantsEvent && params.reconcile === true) {
122
+ await runCommittedTreeHook({ change: 'place', documentId: params.documentId }, async () => {
123
+ await fireTreeChange(ctx, ctx.definition, {
124
+ change: 'place',
125
+ documentId: params.documentId,
126
+ affectedDocumentIds: await wholeTreeIds(ctx),
127
+ });
128
+ });
129
+ }
130
+ return { orderKey };
131
+ }
132
+ if (wantsEvent) {
133
+ await runCommittedTreeHook({ change: 'place', documentId: params.documentId }, async () => {
134
+ const [subtree, newSiblings] = await Promise.all([
135
+ subtreeIds(ctx, params.documentId),
136
+ childIds(ctx, params.parentDocumentId),
137
+ ]);
138
+ await fireTreeChange(ctx, ctx.definition, {
139
+ change: 'place',
140
+ documentId: params.documentId,
141
+ affectedDocumentIds: [
142
+ ...subtree,
143
+ ...(mutation.before.parentDocumentId ? [mutation.before.parentDocumentId] : []),
144
+ ...(params.parentDocumentId ? [params.parentDocumentId] : []),
145
+ ...mutation.beforeSiblingDocumentIds,
146
+ ...newSiblings,
147
+ ],
148
+ });
103
149
  });
104
150
  }
105
- return result;
151
+ return { orderKey };
106
152
  });
107
153
  }
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
- */
154
+ /** Best-effort create/self-heal primitive that never moves an already-placed node. */
155
+ export async function appendTreeRoot(ctx, documentId) {
156
+ await auditedPlace(ctx, { documentId, parentDocumentId: null, ifUnplaced: true });
157
+ }
158
+ /** Best-effort post-version repair; locked `ifUnplaced` closes the check/write race. */
159
+ export async function selfHealTreePlacement(ctx, documentId) {
160
+ if (ctx.definition.tree !== true)
161
+ return;
162
+ try {
163
+ await appendTreeRoot(ctx, documentId);
164
+ }
165
+ catch (err) {
166
+ ctx.logger.error({ err, documentId }, 'failed to self-heal tree placement on update');
167
+ }
168
+ }
169
+ /** Remove a node to the unplaced state; an already-unplaced node is a true no-op. */
113
170
  export async function removeFromTree(ctx, params) {
114
171
  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);
172
+ assertActorCanPerform(ctx.requestContext, ctx.collectionPath, 'update');
173
+ const hooks = await resolveHooks(ctx.definition);
118
174
  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, {
175
+ const capability = requireTreeAuditCapability(ctx.db);
176
+ const actor = auditActor(ctx);
177
+ let mutation;
178
+ await capability.withTransaction(async () => {
179
+ mutation = await capability.remove({
180
+ collectionId: ctx.collectionId,
181
+ documentId: params.documentId,
182
+ includeSubtree: wantsEvent,
183
+ });
184
+ if (!mutation.changed)
185
+ return;
186
+ await capability.append({
187
+ documentId: params.documentId,
188
+ collectionId: ctx.collectionId,
189
+ actorId: actor.actorId,
190
+ actorRealm: actor.actorRealm,
191
+ action: AUDIT_ACTIONS.treeRemoved,
192
+ field: 'tree',
193
+ before: { ...mutation.before, mode: 'remove' },
194
+ after: { ...mutation.after, mode: 'remove' },
195
+ });
196
+ });
197
+ if (!mutation || !wantsEvent)
198
+ return;
199
+ const committedMutation = mutation;
200
+ if (!committedMutation.changed) {
201
+ if (params.reconcile === true) {
202
+ await runCommittedTreeHook({ change: 'remove', documentId: params.documentId }, async () => {
203
+ await fireTreeChange(ctx, ctx.definition, {
204
+ change: 'remove',
205
+ documentId: params.documentId,
206
+ affectedDocumentIds: [
207
+ ...(await wholeTreeIds(ctx)),
208
+ ...committedMutation.beforeSubtreeDocumentIds,
209
+ ],
210
+ });
211
+ });
212
+ }
213
+ return;
214
+ }
215
+ await runCommittedTreeHook({ change: 'remove', documentId: params.documentId }, async () => {
216
+ await fireTreeChange(ctx, ctx.definition, {
125
217
  change: 'remove',
126
218
  documentId: params.documentId,
127
219
  affectedDocumentIds: [
128
- params.documentId,
129
- ...subtree,
130
- ...(oldParentId ? [oldParentId] : []),
131
- ...oldSiblings,
220
+ ...committedMutation.beforeSubtreeDocumentIds,
221
+ ...(committedMutation.before.parentDocumentId
222
+ ? [committedMutation.before.parentDocumentId]
223
+ : []),
224
+ ...committedMutation.beforeSiblingDocumentIds,
132
225
  ],
133
226
  });
134
- }
227
+ });
135
228
  });
136
229
  }
137
230
  /**
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.
231
+ * Reconcile delete-time edges and append parent plus child-specific audit rows.
232
+ * The caller owns the transaction; delete uses this beside soft-delete/audit.
147
233
  */
234
+ export async function reconcileTreeOnDeleteInTransaction(ctx, documentId, capability) {
235
+ const result = await capability.promoteAndRemove({
236
+ collectionId: ctx.collectionId,
237
+ documentId,
238
+ });
239
+ const actor = auditActor(ctx);
240
+ for (const child of result.promoted) {
241
+ await capability.append({
242
+ documentId: child.documentId,
243
+ collectionId: ctx.collectionId,
244
+ actorId: actor.actorId,
245
+ actorRealm: actor.actorRealm,
246
+ action: AUDIT_ACTIONS.treeReparented,
247
+ field: 'tree',
248
+ before: { ...child.before, mode: 'promoteOnDelete', removedParentDocumentId: documentId },
249
+ after: { ...child.after, mode: 'promoteOnDelete', removedParentDocumentId: documentId },
250
+ });
251
+ }
252
+ if (result.removed.changed || result.promoted.length > 0) {
253
+ await capability.append({
254
+ documentId,
255
+ collectionId: ctx.collectionId,
256
+ actorId: actor.actorId,
257
+ actorRealm: actor.actorRealm,
258
+ action: AUDIT_ACTIONS.treeRemoved,
259
+ field: 'tree',
260
+ before: {
261
+ ...result.removed.before,
262
+ mode: 'promoteChildren',
263
+ children: result.promoted.length,
264
+ },
265
+ after: {
266
+ ...result.removed.after,
267
+ mode: 'promoteChildren',
268
+ promotedDocumentIds: result.promoted.map((child) => child.documentId),
269
+ },
270
+ });
271
+ }
272
+ return result;
273
+ }
274
+ /** Fire the promotion invalidation after its transaction commits. */
275
+ export async function firePromoteTreeChange(ctx, documentId, result) {
276
+ const hooks = await resolveHooks(ctx.definition);
277
+ if (hooks?.afterTreeChange == null || (!result.removed.changed && result.promoted.length === 0)) {
278
+ return;
279
+ }
280
+ const affected = new Set([
281
+ documentId,
282
+ ...result.removed.beforeSiblingDocumentIds,
283
+ ...(result.removed.before.parentDocumentId ? [result.removed.before.parentDocumentId] : []),
284
+ ]);
285
+ for (const child of result.promoted) {
286
+ for (const id of await subtreeIds(ctx, child.documentId))
287
+ affected.add(id);
288
+ }
289
+ await fireTreeChange(ctx, ctx.definition, {
290
+ change: 'promote-on-delete',
291
+ documentId,
292
+ affectedDocumentIds: affected,
293
+ });
294
+ }
295
+ /** Standalone audited reconciliation used by internal tooling and tests. */
148
296
  export async function promoteChildrenAndRemove(ctx, params) {
149
297
  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
- }
298
+ const capability = requireTreeAuditCapability(ctx.db);
299
+ let result;
300
+ await capability.withTransaction(async () => {
301
+ result = await reconcileTreeOnDeleteInTransaction(ctx, params.documentId, capability);
302
+ });
303
+ await firePromoteTreeChange(ctx, params.documentId, result);
183
304
  });
184
305
  }
@@ -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 {};