@byline/core 3.21.0 → 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 (73) hide show
  1. package/dist/@types/collection-types.d.ts +66 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-types.d.ts +25 -1
  4. package/dist/@types/query-predicate.d.ts +3 -3
  5. package/dist/@types/search-types.d.ts +3 -4
  6. package/dist/@types/site-config.d.ts +51 -12
  7. package/dist/auth/apply-before-read.d.ts +23 -11
  8. package/dist/auth/apply-before-read.js +139 -33
  9. package/dist/auth/apply-before-read.test.node.js +241 -3
  10. package/dist/auth/index.d.ts +1 -1
  11. package/dist/auth/index.js +1 -1
  12. package/dist/auth/read-context-scope.d.ts +20 -0
  13. package/dist/auth/read-context-scope.js +48 -0
  14. package/dist/config/attach-hooks.d.ts +25 -0
  15. package/dist/config/attach-hooks.js +130 -0
  16. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  17. package/dist/config/attach-hooks.test.node.js +173 -0
  18. package/dist/config/config-hooks.test.node.d.ts +1 -0
  19. package/dist/config/config-hooks.test.node.js +56 -0
  20. package/dist/config/config.d.ts +9 -5
  21. package/dist/config/config.js +20 -2
  22. package/dist/config/routes.d.ts +5 -5
  23. package/dist/config/routes.js +42 -9
  24. package/dist/config/routes.test.node.d.ts +1 -0
  25. package/dist/config/routes.test.node.js +152 -0
  26. package/dist/core.d.ts +2 -2
  27. package/dist/core.js +20 -14
  28. package/dist/core.test.node.d.ts +1 -0
  29. package/dist/core.test.node.js +28 -0
  30. package/dist/index.d.ts +4 -3
  31. package/dist/index.js +4 -3
  32. package/dist/lib/errors.d.ts +13 -0
  33. package/dist/lib/errors.js +14 -0
  34. package/dist/query/parse-where.d.ts +9 -0
  35. package/dist/query/parse-where.js +146 -2
  36. package/dist/query/parse-where.test.node.js +60 -1
  37. package/dist/services/collection-bootstrap.test.node.js +30 -59
  38. package/dist/services/discover-counter-groups.test.node.js +23 -0
  39. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  40. package/dist/services/document-lifecycle/audit.js +32 -1
  41. package/dist/services/document-lifecycle/create.js +13 -6
  42. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  43. package/dist/services/document-lifecycle/delete.js +91 -26
  44. package/dist/services/document-lifecycle/index.d.ts +1 -1
  45. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  46. package/dist/services/document-lifecycle/internals.js +22 -46
  47. package/dist/services/document-lifecycle/status.d.ts +1 -1
  48. package/dist/services/document-lifecycle/status.js +20 -3
  49. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  50. package/dist/services/document-lifecycle/system-fields.js +112 -74
  51. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  52. package/dist/services/document-lifecycle/tree.js +244 -123
  53. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  54. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  55. package/dist/services/document-lifecycle/update.js +2 -1
  56. package/dist/services/document-lifecycle.test.node.js +360 -16
  57. package/dist/services/document-read.d.ts +14 -6
  58. package/dist/services/document-read.js +47 -9
  59. package/dist/services/field-upload.test.node.js +70 -0
  60. package/dist/services/index.d.ts +2 -2
  61. package/dist/services/index.js +2 -2
  62. package/dist/services/populate.d.ts +7 -3
  63. package/dist/services/populate.js +180 -28
  64. package/dist/services/populate.test.node.js +59 -0
  65. package/dist/services/richtext-embed.d.ts +39 -2
  66. package/dist/services/richtext-embed.js +4 -34
  67. package/dist/services/richtext-embed.test.node.js +15 -0
  68. package/dist/services/richtext-populate.d.ts +20 -2
  69. package/dist/services/richtext-populate.js +160 -19
  70. package/dist/services/richtext-populate.test.node.js +523 -2
  71. package/dist/utils/root-relative-redirect.d.ts +6 -0
  72. package/dist/utils/root-relative-redirect.js +37 -0
  73. package/package.json +2 -2
@@ -17,6 +17,7 @@ import { ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
17
17
  import { generateKeyBetween } from '../../lib/fractional-index.js';
18
18
  import { createReadContext } from '../populate.js';
19
19
  import { embedRichTextFields } from '../richtext-embed.js';
20
+ import { createRichTextDocumentReader } from '../richtext-populate.js';
20
21
  /**
21
22
  * Matches a canonical UUID (any version). Real admin / end-user actors carry
22
23
  * UUID ids (`uuidv7`); synthetic actors do not.
@@ -70,20 +71,38 @@ export async function applyRichTextEmbed(ctx, data) {
70
71
  // `initBylineCore()` (unit tests, isolated tooling) — they have no
71
72
  // adapter to invoke, so this is a soft no-op.
72
73
  let embed;
74
+ let richTextPopulate;
75
+ let collections;
73
76
  try {
74
- embed = getServerConfig().fields?.richText?.embed;
77
+ const config = getServerConfig();
78
+ embed = config.fields?.richText?.embed;
79
+ richTextPopulate = config.fields?.richText?.populate;
80
+ collections = config.collections;
75
81
  }
76
82
  catch {
77
83
  return;
78
84
  }
79
- if (embed == null)
85
+ if (embed == null || ctx.requestContext == null)
80
86
  return;
87
+ const readContext = createReadContext();
88
+ const requestContext = { ...ctx.requestContext, readMode: 'published' };
81
89
  await embedRichTextFields({
82
90
  fields: ctx.definition.fields,
83
91
  collectionPath: ctx.collectionPath,
84
92
  data,
85
93
  embed,
86
- readContext: createReadContext(),
94
+ readContext,
95
+ requestContext,
96
+ readMode: 'published',
97
+ readDocuments: createRichTextDocumentReader({
98
+ db: ctx.db,
99
+ collections,
100
+ requestContext,
101
+ readContext,
102
+ readMode: 'published',
103
+ locale: ctx.defaultLocale,
104
+ richTextPopulate,
105
+ }),
87
106
  logger: ctx.logger,
88
107
  });
89
108
  }
@@ -103,49 +122,6 @@ export async function maybeAppendOrderKey(ctx, collectionPath) {
103
122
  });
104
123
  return generateKeyBetween(last, null);
105
124
  }
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/04-collections/03-document-trees.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
- }
149
125
  /** Extract `id` from the document object returned by `createDocumentVersion`. */
150
126
  export function extractVersionId(document) {
151
127
  return document?.id ?? document?.document_version_id ?? '';
@@ -33,7 +33,7 @@ export declare function changeDocumentStatus(ctx: DocumentLifecycleContext, para
33
33
  *
34
34
  * Flow:
35
35
  * 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
36
- * 2. `db.commands.documents.archivePublishedVersions(...)`
36
+ * 2. Archive published versions and append the status audit atomically
37
37
  * 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
38
38
  */
39
39
  export declare function unpublishDocument(ctx: DocumentLifecycleContext, params: {
@@ -124,7 +124,7 @@ export async function changeDocumentStatus(ctx, params) {
124
124
  *
125
125
  * Flow:
126
126
  * 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
127
- * 2. `db.commands.documents.archivePublishedVersions(...)`
127
+ * 2. Archive published versions and append the status audit atomically
128
128
  * 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
129
129
  */
130
130
  export async function unpublishDocument(ctx, params) {
@@ -153,8 +153,25 @@ export async function unpublishDocument(ctx, params) {
153
153
  collectionPath,
154
154
  path,
155
155
  });
156
- const archivedCount = await db.commands.documents.archivePublishedVersions({
157
- document_id: params.documentId,
156
+ const audit = requireAuditCapability(db);
157
+ const actor = auditActor(ctx);
158
+ const archivedCount = await audit.withTransaction(async () => {
159
+ const count = await db.commands.documents.archivePublishedVersions({
160
+ document_id: params.documentId,
161
+ });
162
+ if (count > 0) {
163
+ await audit.append({
164
+ documentId: params.documentId,
165
+ collectionId,
166
+ actorId: actor.actorId,
167
+ actorRealm: actor.actorRealm,
168
+ action: AUDIT_ACTIONS.statusChanged,
169
+ field: 'status',
170
+ before: 'published',
171
+ after: 'archived',
172
+ });
173
+ }
174
+ return count;
158
175
  });
159
176
  await invokeHook(hooks?.afterUnpublish, {
160
177
  documentId: params.documentId,
@@ -10,7 +10,13 @@ export interface UpdateDocumentSystemFieldsResult {
10
10
  documentId: string;
11
11
  /** The path actually written, or `undefined` when no path write occurred. */
12
12
  path?: string;
13
- /** Whether the advertised-locale set was rewritten this call. */
13
+ /** Whether either system field actually changed. */
14
+ changed: boolean;
15
+ /** Whether a no-op request emitted the reconciliation hook. */
16
+ reconciliation: boolean;
17
+ pathChanged: boolean;
18
+ availableLocalesChanged: boolean;
19
+ /** Whether the advertised-locale set was actually rewritten this call. */
14
20
  availableLocalesWritten: boolean;
15
21
  }
16
22
  /**
@@ -29,7 +35,8 @@ export interface UpdateDocumentSystemFieldsResult {
29
35
  *
30
36
  * Flow:
31
37
  * 1. `assertActorCanPerform('update')` — same auth gate as content writes.
32
- * 2. Fetch the document to resolve its `source_locale` anchor + current path.
38
+ * 2. Inside the audit transaction, lock the logical document and read its
39
+ * authoritative source locale, path, and advertised locales.
33
40
  * 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
34
41
  * rule (translation-locale path edits are dropped with a warn); a real
35
42
  * change is written via `updateDocumentPath`, mapping the unique-constraint
@@ -37,10 +44,14 @@ export interface UpdateDocumentSystemFieldsResult {
37
44
  * 4. `availableLocales` (when supplied): rewritten wholesale via
38
45
  * `setDocumentAvailableLocales`.
39
46
  *
40
- * No content hooks fire these are not content writes. Accountability for
41
- * these mutations is the document-grain audit log: each field that actually
42
- * changes records a `document.path.changed` / `document.locales.changed` row
43
- * atomically with the write (docs/06-auth-and-security/02-auditability.md Workstream 2).
47
+ * Content hooks do not fire because these are not content writes. Actual
48
+ * changes emit `afterSystemFieldsChange` after the audited write commits. A
49
+ * caller can pass `reconcile: true` to emit the same hook for a no-op retry
50
+ * after an earlier post-commit hook failure. Hook failures reject the call but
51
+ * never roll back the already-committed write/audit.
52
+ * Accountability for these mutations is the document-grain audit log: each
53
+ * changed field records a `document.path.changed` /
54
+ * `document.locales.changed` row atomically with the write.
44
55
  *
45
56
  * @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
46
57
  * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
@@ -61,4 +72,6 @@ export declare function updateDocumentSystemFields(ctx: DocumentLifecycleContext
61
72
  * included — replaces the set wholesale.
62
73
  */
63
74
  availableLocales?: string[];
75
+ /** Re-run `afterSystemFieldsChange` when requested values are already current. */
76
+ reconcile?: boolean;
64
77
  }): Promise<UpdateDocumentSystemFieldsResult>;
@@ -5,11 +5,12 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { resolveHooks } from '../../@types/index.js';
8
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
9
- import { ERR_NOT_FOUND } from '../../lib/errors.js';
10
+ import { ERR_AUDIT_UNSUPPORTED, ERR_NOT_FOUND } from '../../lib/errors.js';
10
11
  import { withLogContext } from '../../lib/logger.js';
11
12
  import { AUDIT_ACTIONS, auditActor, requireAuditCapability, sameLocaleSet } from './audit.js';
12
- import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
13
+ import { invokeHook, resolvePathForUpdate, rethrowPathConflict } from './internals.js';
13
14
  /**
14
15
  * Write a document's system-managed, document-grain fields — `path` and the
15
16
  * editorial `availableLocales` set — **without** minting a new version or
@@ -26,7 +27,8 @@ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
26
27
  *
27
28
  * Flow:
28
29
  * 1. `assertActorCanPerform('update')` — same auth gate as content writes.
29
- * 2. Fetch the document to resolve its `source_locale` anchor + current path.
30
+ * 2. Inside the audit transaction, lock the logical document and read its
31
+ * authoritative source locale, path, and advertised locales.
30
32
  * 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
31
33
  * rule (translation-locale path edits are dropped with a warn); a real
32
34
  * change is written via `updateDocumentPath`, mapping the unique-constraint
@@ -34,58 +36,67 @@ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
34
36
  * 4. `availableLocales` (when supplied): rewritten wholesale via
35
37
  * `setDocumentAvailableLocales`.
36
38
  *
37
- * No content hooks fire these are not content writes. Accountability for
38
- * these mutations is the document-grain audit log: each field that actually
39
- * changes records a `document.path.changed` / `document.locales.changed` row
40
- * atomically with the write (docs/06-auth-and-security/02-auditability.md Workstream 2).
39
+ * Content hooks do not fire because these are not content writes. Actual
40
+ * changes emit `afterSystemFieldsChange` after the audited write commits. A
41
+ * caller can pass `reconcile: true` to emit the same hook for a no-op retry
42
+ * after an earlier post-commit hook failure. Hook failures reject the call but
43
+ * never roll back the already-committed write/audit.
44
+ * Accountability for these mutations is the document-grain audit log: each
45
+ * changed field records a `document.path.changed` /
46
+ * `document.locales.changed` row atomically with the write.
41
47
  *
42
48
  * @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
43
49
  * @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
44
50
  */
45
51
  export async function updateDocumentSystemFields(ctx, params) {
46
52
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentSystemFields' }, async () => {
47
- const { db, collectionId, collectionPath, defaultLocale } = ctx;
53
+ const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
48
54
  assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
49
55
  const requestLocale = params.locale ?? defaultLocale;
50
- // Resolve the document's source-locale anchor + current path. Both feed
51
- // the path source-locale guard below; the fetch also asserts existence.
52
- const latest = await db.queries.documents.getDocumentById({
53
- collection_id: collectionId,
54
- document_id: params.documentId,
55
- locale: requestLocale,
56
- reconstruct: true,
57
- });
58
- if (latest == null) {
59
- throw ERR_NOT_FOUND({
60
- message: 'document not found',
61
- details: { documentId: params.documentId },
62
- }).log(ctx.logger);
63
- }
64
- const originalData = latest;
65
- const sourceLocale = originalData.source_locale ?? defaultLocale;
66
- // Path: honour the same source-locale-only rule the versioned write
67
- // uses. `resolvePathForUpdate` returns `undefined` to mean "skip the
68
- // write" (null/empty override, or a translation-locale save).
69
56
  const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
70
- const pathForCommand = resolvePathForUpdate({
71
- explicitPath,
72
- currentPath: originalData.path,
73
- requestLocale,
74
- sourceLocale,
75
- documentId: params.documentId,
76
- logger: ctx.logger,
77
- });
78
- // Both document-grain writes and their audit rows commit atomically.
79
- // These fields are non-versioned, so the version stream never records
80
- // them — the audit log is their only accountability home. One audit row
81
- // per field that actually changed (docs/06-auth-and-security/02-auditability.md).
82
- const currentPath = originalData.path;
83
- const currentLocales = originalData.availableLocales ?? [];
84
- const availableLocalesWritten = params.availableLocales !== undefined;
57
+ const requested = {
58
+ path: explicitPath !== null,
59
+ availableLocales: params.availableLocales !== undefined,
60
+ };
61
+ const requestedLocales = params.availableLocales === undefined ? undefined : [...new Set(params.availableLocales)];
85
62
  const audit = requireAuditCapability(db);
63
+ const getDocumentSystemFieldsForUpdate = db.queries.documents.getDocumentSystemFieldsForUpdate;
64
+ if (typeof getDocumentSystemFieldsForUpdate !== 'function') {
65
+ throw ERR_AUDIT_UNSUPPORTED({
66
+ message: 'audited system-field writes require a transaction-scoped lock/read capability',
67
+ });
68
+ }
69
+ const lockSystemFields = getDocumentSystemFieldsForUpdate.bind(db.queries.documents);
86
70
  const actor = auditActor(ctx);
87
- await audit.withTransaction(async () => {
88
- if (pathForCommand !== undefined) {
71
+ // The logical document row is the mutex for both document-grain fields.
72
+ // Reading after that lock prevents concurrent writers from auditing a
73
+ // stale before value or omitting an intermediate old path from the event.
74
+ const outcome = await audit.withTransaction(async () => {
75
+ const snapshot = await lockSystemFields({
76
+ collection_id: collectionId,
77
+ document_id: params.documentId,
78
+ });
79
+ if (snapshot == null) {
80
+ throw ERR_NOT_FOUND({
81
+ message: 'document not found',
82
+ details: { documentId: params.documentId },
83
+ }).log(ctx.logger);
84
+ }
85
+ const sourceLocale = snapshot.source_locale ?? defaultLocale;
86
+ const currentPath = snapshot.path ?? undefined;
87
+ const currentLocales = snapshot.availableLocales;
88
+ const nextLocales = requestedLocales ?? currentLocales;
89
+ const pathForCommand = resolvePathForUpdate({
90
+ explicitPath,
91
+ currentPath,
92
+ requestLocale,
93
+ sourceLocale,
94
+ documentId: params.documentId,
95
+ logger: ctx.logger,
96
+ });
97
+ const pathChanged = pathForCommand !== undefined && pathForCommand !== currentPath;
98
+ const availableLocalesChanged = requestedLocales !== undefined && !sameLocaleSet(currentLocales, nextLocales);
99
+ if (pathChanged) {
89
100
  await db.commands.documents
90
101
  .updateDocumentPath({
91
102
  documentId: params.documentId,
@@ -93,45 +104,72 @@ export async function updateDocumentSystemFields(ctx, params) {
93
104
  locale: sourceLocale,
94
105
  path: pathForCommand,
95
106
  })
96
- .catch((err) => rethrowPathConflict(err, pathForCommand, defaultLocale));
97
- if (pathForCommand !== currentPath) {
98
- await audit.append({
99
- documentId: params.documentId,
100
- collectionId,
101
- actorId: actor.actorId,
102
- actorRealm: actor.actorRealm,
103
- action: AUDIT_ACTIONS.pathChanged,
104
- field: 'path',
105
- before: currentPath ?? null,
106
- after: pathForCommand,
107
- });
108
- }
107
+ .catch((err) => rethrowPathConflict(err, pathForCommand, sourceLocale));
108
+ await audit.append({
109
+ documentId: params.documentId,
110
+ collectionId,
111
+ actorId: actor.actorId,
112
+ actorRealm: actor.actorRealm,
113
+ action: AUDIT_ACTIONS.pathChanged,
114
+ field: 'path',
115
+ before: currentPath ?? null,
116
+ after: pathForCommand,
117
+ });
109
118
  }
110
- // Advertised locales: rewrite the document-grain set wholesale.
111
- if (params.availableLocales !== undefined) {
119
+ if (availableLocalesChanged) {
112
120
  await db.commands.documents.setDocumentAvailableLocales({
113
121
  documentId: params.documentId,
114
122
  collectionId,
115
- availableLocales: params.availableLocales,
123
+ availableLocales: nextLocales,
124
+ });
125
+ await audit.append({
126
+ documentId: params.documentId,
127
+ collectionId,
128
+ actorId: actor.actorId,
129
+ actorRealm: actor.actorRealm,
130
+ action: AUDIT_ACTIONS.localesChanged,
131
+ field: 'availableLocales',
132
+ before: currentLocales,
133
+ after: nextLocales,
116
134
  });
117
- if (!sameLocaleSet(currentLocales, params.availableLocales)) {
118
- await audit.append({
119
- documentId: params.documentId,
120
- collectionId,
121
- actorId: actor.actorId,
122
- actorRealm: actor.actorRealm,
123
- action: AUDIT_ACTIONS.localesChanged,
124
- field: 'availableLocales',
125
- before: currentLocales,
126
- after: params.availableLocales,
127
- });
128
- }
129
135
  }
136
+ return {
137
+ pathForCommand,
138
+ pathChanged,
139
+ availableLocalesChanged,
140
+ previousPath: currentPath,
141
+ currentPath: pathChanged ? pathForCommand : currentPath,
142
+ previousAvailableLocales: [...currentLocales],
143
+ currentAvailableLocales: [...nextLocales],
144
+ };
130
145
  });
146
+ const changed = outcome.pathChanged || outcome.availableLocalesChanged;
147
+ const reconciliation = !changed && params.reconcile === true;
148
+ if (changed || reconciliation) {
149
+ const hooks = await resolveHooks(definition);
150
+ await invokeHook(hooks?.afterSystemFieldsChange, {
151
+ documentId: params.documentId,
152
+ collectionPath,
153
+ requested,
154
+ changed: {
155
+ path: outcome.pathChanged,
156
+ availableLocales: outcome.availableLocalesChanged,
157
+ },
158
+ reconciliation,
159
+ previousPath: outcome.previousPath,
160
+ currentPath: outcome.currentPath,
161
+ previousAvailableLocales: outcome.previousAvailableLocales,
162
+ currentAvailableLocales: outcome.currentAvailableLocales,
163
+ });
164
+ }
131
165
  return {
132
166
  documentId: params.documentId,
133
- path: pathForCommand,
134
- availableLocalesWritten,
167
+ path: outcome.pathChanged ? outcome.pathForCommand : undefined,
168
+ changed,
169
+ reconciliation,
170
+ pathChanged: outcome.pathChanged,
171
+ availableLocalesChanged: outcome.availableLocalesChanged,
172
+ availableLocalesWritten: outcome.availableLocalesChanged,
135
173
  };
136
174
  });
137
175
  }
@@ -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 {};