@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
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { describe, expect, it } from 'vitest';
9
9
  import { defineCollection, defineWorkflow } from '../@types/collection-types.js';
10
- import { mergePredicates, parseSort, parseWhere } from './parse-where.js';
10
+ import { mergePredicates, parsePredicateFilters, parseSort, parseWhere } from './parse-where.js';
11
11
  const testCollection = defineCollection({
12
12
  path: 'test-articles',
13
13
  labels: { singular: 'Article', plural: 'Articles' },
@@ -691,6 +691,65 @@ describe('parseWhere — `id` reserved key', () => {
691
691
  expect(result.filters).toHaveLength(1);
692
692
  });
693
693
  });
694
+ describe('parsePredicateFilters', () => {
695
+ const idA = '018f4f7c-6a2b-7d91-8f21-111111111111';
696
+ const idB = '018f4f7c-6a2b-7d91-8f21-222222222222';
697
+ it('keeps top-level status and path inside the adapter filter list', async () => {
698
+ const filters = await parsePredicateFilters({ status: 'published', path: { $contains: 'docs' }, featured: true }, testCollection);
699
+ expect(filters).toEqual([
700
+ { kind: 'docColumn', column: 'status', operator: '$eq', value: 'published' },
701
+ { kind: 'docColumn', column: 'path', operator: '$contains', value: 'docs' },
702
+ expect.objectContaining({ kind: 'field', fieldName: 'featured' }),
703
+ ]);
704
+ });
705
+ it.each([
706
+ [{ typoedTenant: 't-1' }, 'unknown field'],
707
+ [{ query: 'secret' }, 'not supported'],
708
+ [{ $or: [] }, 'non-empty array'],
709
+ [{ $or: [{ typoedTenant: 't-1' }] }, 'unknown field'],
710
+ [{ title: { $wat: 'x' } }, 'supported operator'],
711
+ [{ status: { $contains: 'pub' } }, 'not supported'],
712
+ [{ status: { $in: 'published' } }, 'requires an array'],
713
+ [{ status: { $in: ['published', 1] } }, 'requires string values'],
714
+ [{ path: { $gt: '/private' } }, 'not supported'],
715
+ [{ path: { $in: ['/a', 1] } }, 'requires string values'],
716
+ [{ id: '__none__' }, 'requires UUID values'],
717
+ [{ id: { $eq: [idA] } }, 'requires a string'],
718
+ [{ id: { $in: ['not-a-uuid'] } }, 'requires UUID values'],
719
+ ])('rejects invalid security predicate %o', async (predicate, message) => {
720
+ await expect(parsePredicateFilters(predicate, testCollection, ctx, { strict: true })).rejects.toThrow(message);
721
+ });
722
+ it('preserves status, path, and id arrays in strict predicates', async () => {
723
+ const filters = await parsePredicateFilters({
724
+ status: { $in: ['draft', 'published'] },
725
+ path: { $nin: ['/private', '/internal'] },
726
+ id: { $in: [idA, idB] },
727
+ }, testCollection, ctx, { strict: true });
728
+ expect(filters).toEqual([
729
+ {
730
+ kind: 'docColumn',
731
+ column: 'status',
732
+ operator: '$in',
733
+ value: ['draft', 'published'],
734
+ },
735
+ {
736
+ kind: 'docColumn',
737
+ column: 'path',
738
+ operator: '$nin',
739
+ value: ['/private', '/internal'],
740
+ },
741
+ { kind: 'docColumn', column: 'id', operator: '$in', value: [idA, idB] },
742
+ ]);
743
+ });
744
+ it('compiles an empty id set as a safe always-false document filter', async () => {
745
+ await expect(parsePredicateFilters({ id: { $in: [] } }, testCollection, ctx, { strict: true })).resolves.toEqual([{ kind: 'docColumn', column: 'id', operator: '$in', value: [] }]);
746
+ });
747
+ it('keeps caller-query compilation permissive for unknown fields', async () => {
748
+ await expect(parseWhere({ typoedTenant: 't-1' }, testCollection)).resolves.toEqual({
749
+ filters: [],
750
+ });
751
+ });
752
+ });
694
753
  // ---------------------------------------------------------------------------
695
754
  // mergePredicates
696
755
  // ---------------------------------------------------------------------------
@@ -21,10 +21,8 @@ function baseCollection() {
21
21
  },
22
22
  };
23
23
  }
24
- // Build a minimal IDbAdapter. We only wire the methods `ensureCollections`
25
- // actually calls; the others throw if touched so we catch accidental usage.
26
24
  function createMockDb(options) {
27
- const getCollectionByPath = vi.fn().mockResolvedValue(options.existingRow ?? null);
25
+ const getCollectionByPath = vi.fn(options.getCollectionByPath ?? (async () => options.existingRow ?? null));
28
26
  const create = vi
29
27
  .fn()
30
28
  .mockImplementation(async (_path, _config, _opts) => [
@@ -48,12 +46,23 @@ function createMockDb(options) {
48
46
  setOrderKey: vi.fn(fail),
49
47
  placeTreeNode: vi.fn(fail),
50
48
  removeFromTree: vi.fn(fail),
49
+ promoteChildrenAndRemoveFromTree: vi.fn(async () => ({
50
+ removed: {
51
+ changed: false,
52
+ before: { placed: false, parentDocumentId: null, orderKey: null, index: null },
53
+ after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
54
+ beforeSiblingDocumentIds: [],
55
+ beforeSubtreeDocumentIds: [],
56
+ },
57
+ promoted: [],
58
+ })),
51
59
  },
52
60
  counters: {
53
61
  ensureCounterGroup: vi.fn(fail),
54
62
  nextCounterValue: vi.fn(fail),
55
63
  nextScopedCounterValue: vi.fn(fail),
56
64
  },
65
+ audit: { append: vi.fn(async () => ({ id: 'audit-1' })) },
57
66
  },
58
67
  queries: {
59
68
  collections: {
@@ -62,6 +71,7 @@ function createMockDb(options) {
62
71
  getCollectionById: vi.fn(fail),
63
72
  },
64
73
  documents: {
74
+ getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
65
75
  getDocumentById: vi.fn(fail),
66
76
  getCurrentVersionMetadata: vi.fn(fail),
67
77
  getCurrentPath: vi.fn(fail),
@@ -82,7 +92,18 @@ function createMockDb(options) {
82
92
  getTreeParent: vi.fn(fail),
83
93
  getTreeSubtree: vi.fn(fail),
84
94
  },
95
+ audit: {
96
+ getDocumentAuditLog: vi.fn(async () => ({
97
+ entries: [],
98
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
99
+ })),
100
+ findAuditLog: vi.fn(async () => ({
101
+ entries: [],
102
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
103
+ })),
104
+ },
85
105
  },
106
+ withTransaction: async (fn) => fn(),
86
107
  };
87
108
  return { db, create, update, getCollectionByPath };
88
109
  }
@@ -179,63 +200,13 @@ describe('ensureCollections', () => {
179
200
  const b = { ...baseCollection(), path: 'pages' };
180
201
  const hashA = await fingerprintCollection(a);
181
202
  // For `a` the DB matches; for `b` it does not exist yet.
182
- const getCollectionByPath = vi.fn(async (path) => {
183
- if (path === 'news')
184
- return { id: 'col-news', version: 2, schema_hash: hashA };
185
- return null;
186
- });
187
- const create = vi.fn().mockResolvedValue([{ id: 'col-pages' }]);
188
- const update = vi.fn();
189
- const db = {
190
- commands: {
191
- collections: { create, update, delete: vi.fn() },
192
- documents: {
193
- createDocumentVersion: vi.fn(),
194
- updateDocumentPath: vi.fn(),
195
- setDocumentAvailableLocales: vi.fn(),
196
- setDocumentStatus: vi.fn(),
197
- archivePublishedVersions: vi.fn(),
198
- softDeleteDocument: vi.fn(),
199
- deleteDocumentLocale: vi.fn(),
200
- setOrderKey: vi.fn(),
201
- placeTreeNode: vi.fn(),
202
- removeFromTree: vi.fn(),
203
- },
204
- counters: {
205
- ensureCounterGroup: vi.fn(),
206
- nextCounterValue: vi.fn(),
207
- nextScopedCounterValue: vi.fn(),
208
- },
209
- },
210
- queries: {
211
- collections: {
212
- getAllCollections: vi.fn(),
213
- getCollectionByPath,
214
- getCollectionById: vi.fn(),
215
- },
216
- documents: {
217
- getDocumentById: vi.fn(),
218
- getCurrentVersionMetadata: vi.fn(),
219
- getCurrentPath: vi.fn(),
220
- getDocumentByPath: vi.fn(),
221
- getDocumentByVersion: vi.fn(),
222
- getDocumentsByVersionIds: vi.fn(),
223
- getDocumentsByDocumentIds: vi.fn(),
224
- getDocumentHistory: vi.fn(),
225
- getPublishedVersion: vi.fn(),
226
- getPublishedDocumentIds: vi.fn(),
227
- getDocumentCountsByStatus: vi.fn(),
228
- findDocuments: vi.fn(),
229
- getLastOrderKey: vi.fn(),
230
- getNeighborOrderKeys: vi.fn(),
231
- getCanonicalDocumentOrder: vi.fn(),
232
- getTreeAncestors: vi.fn(),
233
- getTreeChildren: vi.fn(),
234
- getTreeParent: vi.fn(),
235
- getTreeSubtree: vi.fn(),
236
- },
203
+ const { db, create, update } = createMockDb({
204
+ getCollectionByPath: async (path) => {
205
+ if (path === 'news')
206
+ return { id: 'col-news', version: 2, schema_hash: hashA };
207
+ return null;
237
208
  },
238
- };
209
+ });
239
210
  const records = await ensureCollections({ definitions: [a, b], db });
240
211
  expect(records.get('news')?.version).toBe(2);
241
212
  expect(records.get('pages')?.version).toBe(1);
@@ -33,12 +33,23 @@ function makeAdapter(options) {
33
33
  setOrderKey: vi.fn(fail),
34
34
  placeTreeNode: vi.fn(fail),
35
35
  removeFromTree: vi.fn(fail),
36
+ promoteChildrenAndRemoveFromTree: vi.fn(async () => ({
37
+ removed: {
38
+ changed: false,
39
+ before: { placed: false, parentDocumentId: null, orderKey: null, index: null },
40
+ after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
41
+ beforeSiblingDocumentIds: [],
42
+ beforeSubtreeDocumentIds: [],
43
+ },
44
+ promoted: [],
45
+ })),
36
46
  },
37
47
  counters: {
38
48
  ensureCounterGroup,
39
49
  nextCounterValue: vi.fn(fail),
40
50
  nextScopedCounterValue: vi.fn(fail),
41
51
  },
52
+ audit: { append: vi.fn(async () => ({ id: 'audit-1' })) },
42
53
  },
43
54
  queries: {
44
55
  collections: {
@@ -47,6 +58,7 @@ function makeAdapter(options) {
47
58
  getCollectionById: vi.fn(fail),
48
59
  },
49
60
  documents: {
61
+ getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
50
62
  getDocumentById: vi.fn(fail),
51
63
  getCurrentVersionMetadata: vi.fn(fail),
52
64
  getCurrentPath: vi.fn(fail),
@@ -67,7 +79,18 @@ function makeAdapter(options) {
67
79
  getTreeParent: vi.fn(fail),
68
80
  getTreeSubtree: vi.fn(fail),
69
81
  },
82
+ audit: {
83
+ getDocumentAuditLog: vi.fn(async () => ({
84
+ entries: [],
85
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
86
+ })),
87
+ findAuditLog: vi.fn(async () => ({
88
+ entries: [],
89
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
90
+ })),
91
+ },
70
92
  },
93
+ withTransaction: async (fn) => fn(),
71
94
  };
72
95
  return { db, ensureCounterGroup };
73
96
  }
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { AuditActorRealm, AuditLogAppendInput, IDbAdapter } from '../../@types/index.js';
8
+ import type { AuditActorRealm, AuditLogAppendInput, CollectionDefinition, IDbAdapter, TreeDeleteMutationResult, TreeMutationResult } from '../../@types/index.js';
9
9
  import type { DocumentLifecycleContext } from './context.js';
10
10
  /** Namespaced audit actions for document-grain changes. */
11
11
  export declare const AUDIT_ACTIONS: {
@@ -13,6 +13,10 @@ export declare const AUDIT_ACTIONS: {
13
13
  readonly localesChanged: 'document.locales.changed';
14
14
  readonly statusChanged: 'document.status.changed';
15
15
  readonly deleted: 'document.deleted';
16
+ readonly treePlaced: 'document.tree.placed';
17
+ readonly treeReparented: 'document.tree.reparented';
18
+ readonly treeReordered: 'document.tree.reordered';
19
+ readonly treeRemoved: 'document.tree.removed';
16
20
  };
17
21
  /**
18
22
  * The actor id + realm for an audit-log row. Mirrors `actorId()`: a real
@@ -33,6 +37,15 @@ export interface AuditCapability {
33
37
  id: string;
34
38
  }>;
35
39
  }
40
+ /** Auditing plus the locked mutation primitives required by document trees. */
41
+ export interface TreeAuditCapability extends AuditCapability {
42
+ place: (input: Parameters<IDbAdapter['commands']['documents']['placeTreeNode']>[0]) => Promise<TreeMutationResult>;
43
+ remove: (input: Parameters<IDbAdapter['commands']['documents']['removeFromTree']>[0]) => Promise<TreeMutationResult>;
44
+ promoteAndRemove: (input: {
45
+ collectionId: string;
46
+ documentId: string;
47
+ }) => Promise<TreeDeleteMutationResult>;
48
+ }
36
49
  /**
37
50
  * Assert the adapter can record an audited write atomically — it must provide
38
51
  * **both** `withTransaction` and `commands.audit`. Returns a non-null
@@ -41,5 +54,13 @@ export interface AuditCapability {
41
54
  * See docs/03-architecture/03-transactions.md and docs/06-auth-and-security/02-auditability.md.
42
55
  */
43
56
  export declare function requireAuditCapability(db: IDbAdapter): AuditCapability;
57
+ /**
58
+ * Require the complete audited-tree capability. Called at bootstrap and before
59
+ * create so a tree collection can never silently strand a document merely
60
+ * because its adapter lacks atomic audit/reconciliation support.
61
+ */
62
+ export declare function requireTreeAuditCapability(db: IDbAdapter): TreeAuditCapability;
63
+ /** Fail fast during server bootstrap when any tree collection lacks support. */
64
+ export declare function validateTreeAuditCapability(definitions: readonly CollectionDefinition[], db: IDbAdapter): void;
44
65
  /** Order-insensitive equality for the advertised-locale set. */
45
66
  export declare function sameLocaleSet(a: readonly string[], b: readonly string[]): boolean;
@@ -22,6 +22,10 @@ export const AUDIT_ACTIONS = {
22
22
  localesChanged: 'document.locales.changed',
23
23
  statusChanged: 'document.status.changed',
24
24
  deleted: 'document.deleted',
25
+ treePlaced: 'document.tree.placed',
26
+ treeReparented: 'document.tree.reparented',
27
+ treeReordered: 'document.tree.reordered',
28
+ treeRemoved: 'document.tree.removed',
25
29
  };
26
30
  /**
27
31
  * The actor id + realm for an audit-log row. Mirrors `actorId()`: a real
@@ -47,7 +51,7 @@ export function auditActor(ctx) {
47
51
  export function requireAuditCapability(db) {
48
52
  const withTransaction = db.withTransaction;
49
53
  const audit = db.commands.audit;
50
- if (withTransaction == null || audit == null) {
54
+ if (typeof withTransaction !== 'function' || typeof audit?.append !== 'function') {
51
55
  throw ERR_AUDIT_UNSUPPORTED({
52
56
  message: 'audited write requires a db adapter with withTransaction + commands.audit support',
53
57
  });
@@ -57,6 +61,33 @@ export function requireAuditCapability(db) {
57
61
  append: (input) => audit.append(input),
58
62
  };
59
63
  }
64
+ /**
65
+ * Require the complete audited-tree capability. Called at bootstrap and before
66
+ * create so a tree collection can never silently strand a document merely
67
+ * because its adapter lacks atomic audit/reconciliation support.
68
+ */
69
+ export function requireTreeAuditCapability(db) {
70
+ const audit = requireAuditCapability(db);
71
+ const documents = db.commands.documents;
72
+ const promoteChildrenAndRemove = documents.promoteChildrenAndRemoveFromTree;
73
+ if (typeof promoteChildrenAndRemove !== 'function') {
74
+ throw ERR_AUDIT_UNSUPPORTED({
75
+ message: 'tree-enabled writes require an adapter with locked tree mutation and delete-reconciliation support',
76
+ });
77
+ }
78
+ return {
79
+ ...audit,
80
+ place: (input) => documents.placeTreeNode(input),
81
+ remove: (input) => documents.removeFromTree(input),
82
+ promoteAndRemove: (input) => promoteChildrenAndRemove.call(documents, input),
83
+ };
84
+ }
85
+ /** Fail fast during server bootstrap when any tree collection lacks support. */
86
+ export function validateTreeAuditCapability(definitions, db) {
87
+ if (!definitions.some((definition) => definition.tree === true))
88
+ return;
89
+ requireTreeAuditCapability(db);
90
+ }
60
91
  /** Order-insensitive equality for the advertised-locale set. */
61
92
  export function sameLocaleSet(a, b) {
62
93
  if (a.length !== b.length)
@@ -14,7 +14,9 @@ import { slugify } from '../../utils/slugify.js';
14
14
  import { getDefaultStatus } from '../../workflow/workflow.js';
15
15
  import { assignCounterValues } from '../assign-counter-values.js';
16
16
  import { normalizeNumericFields } from '../normalize-numeric-fields.js';
17
- import { actorId, appendTreeRoot, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
17
+ import { requireTreeAuditCapability } from './audit.js';
18
+ import { actorId, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
19
+ import { appendTreeRoot } from './tree.js';
18
20
  /**
19
21
  * Create a new document.
20
22
  *
@@ -33,6 +35,10 @@ export async function createDocument(ctx, params) {
33
35
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'createDocument' }, async () => {
34
36
  const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
35
37
  assertActorCanPerform(ctx.requestContext, collectionPath, 'create');
38
+ // Reject unsupported tree adapters before hooks, counters, or persistence
39
+ // can create a document that cannot be placed and audited safely.
40
+ if (definition.tree === true)
41
+ requireTreeAuditCapability(db);
36
42
  const slugifier = ctx.slugifier ?? slugify;
37
43
  const hooks = await resolveHooks(definition);
38
44
  const data = params.data;
@@ -86,11 +92,12 @@ export async function createDocument(ctx, params) {
86
92
  // `tree: true` collections place every document in the tree by default:
87
93
  // a new document is appended as a root (a top-level nav entry) so it is
88
94
  // never stranded in the "unplaced" limbo. This is a system step of create
89
- // (the actor already passed the `create` ability), so it calls the storage
90
- // command directly no `update` re-assertion, no separate tree event
91
- // (afterCreate covers invalidation). Post-version and best-effort: a
92
- // failure leaves the document created-but-unplaced and is logged, not
93
- // thrown. See docs/04-collections/03-document-trees.md.
95
+ // (the actor already passed the `create` ability), so it uses the internal
96
+ // placement primitive without an `update` re-assertion or separate tree
97
+ // event (afterCreate covers invalidation). Placement + audit are atomic.
98
+ // Post-version and best-effort: a runtime audit/storage failure leaves the
99
+ // document created-but-unplaced and is logged. Missing capability was
100
+ // rejected before persistence above.
94
101
  if (definition.tree === true) {
95
102
  try {
96
103
  await appendTreeRoot(ctx, documentId);
@@ -5,10 +5,26 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { ErrorCodes } from '../../lib/errors.js';
8
9
  import type { DocumentLifecycleContext } from './context.js';
9
- export interface DeleteDocumentResult {
10
+ export type DeleteDocumentOutcome = 'committed' | 'committed-with-side-effect-failures';
11
+ export type DeleteDocumentSideEffectPhase = 'storageCleanup' | 'afterTreeChange' | 'afterDelete';
12
+ export type DeleteDocumentSideEffectCode = typeof ErrorCodes.STORAGE | typeof ErrorCodes.UNHANDLED;
13
+ export interface DeleteDocumentSideEffectFailure {
14
+ phase: DeleteDocumentSideEffectPhase;
15
+ code: DeleteDocumentSideEffectCode;
16
+ }
17
+ export interface DeleteDocumentCommittedResult {
18
+ deletedVersionCount: number;
19
+ outcome: 'committed';
20
+ sideEffectFailures: [];
21
+ }
22
+ export interface DeleteDocumentCommittedWithSideEffectFailuresResult {
10
23
  deletedVersionCount: number;
24
+ outcome: 'committed-with-side-effect-failures';
25
+ sideEffectFailures: [DeleteDocumentSideEffectFailure, ...DeleteDocumentSideEffectFailure[]];
11
26
  }
27
+ export type DeleteDocumentResult = DeleteDocumentCommittedResult | DeleteDocumentCommittedWithSideEffectFailuresResult;
12
28
  /**
13
29
  * Soft-delete a document.
14
30
  *
@@ -7,13 +7,31 @@
7
7
  */
8
8
  import { resolveHooks } from '../../@types/index.js';
9
9
  import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
10
- import { ERR_NOT_FOUND } from '../../lib/errors.js';
10
+ import { ERR_NOT_FOUND, ErrorCodes } from '../../lib/errors.js';
11
11
  import { withLogContext } from '../../lib/logger.js';
12
12
  import { hasUploadField, isUploadField } from '../../utils/storage-utils.js';
13
13
  import { walkFieldTree } from '../walk-field-tree.js';
14
- import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
14
+ import { AUDIT_ACTIONS, auditActor, requireAuditCapability, requireTreeAuditCapability, } from './audit.js';
15
15
  import { invokeHook } from './internals.js';
16
- import { promoteChildrenAndRemove } from './tree.js';
16
+ import { firePromoteTreeChange, reconcileTreeOnDeleteInTransaction } from './tree.js';
17
+ function readErrorCode(error) {
18
+ try {
19
+ if ((typeof error !== 'object' || error === null) && typeof error !== 'function') {
20
+ return undefined;
21
+ }
22
+ const value = Reflect.get(error, 'code');
23
+ return typeof value === 'string' ? value : undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ function serializeSideEffectFailure(phase, error) {
30
+ return {
31
+ phase,
32
+ code: readErrorCode(error) === ErrorCodes.STORAGE ? ErrorCodes.STORAGE : ErrorCodes.UNHANDLED,
33
+ };
34
+ }
17
35
  /**
18
36
  * Soft-delete a document.
19
37
  *
@@ -47,7 +65,8 @@ export async function deleteDocument(ctx, params) {
47
65
  // AND a storage provider, fetch with reconstruct: true so we
48
66
  // can read the stored file paths (and persisted variant paths)
49
67
  // from the field values before the DB rows are deleted.
50
- const isUploadCollection = hasUploadField(definition) && ctx.storage != null;
68
+ const storage = ctx.storage;
69
+ const isUploadCollection = hasUploadField(definition) && storage != null;
51
70
  const latest = await db.queries.documents.getDocumentById({
52
71
  collection_id: ctx.collectionId,
53
72
  document_id: params.documentId,
@@ -99,15 +118,20 @@ export async function deleteDocument(ctx, params) {
99
118
  };
100
119
  // 2. beforeDelete hook.
101
120
  await invokeHook(hooks?.beforeDelete, hookCtx);
102
- // 3. Soft-delete all versions, atomically with the audit record. A
121
+ // 3. Soft-delete all versions atomically with the document audit and,
122
+ // for tree collections, locked child promotion/removal plus every
123
+ // parent/child tree audit row. Any failure rolls the entire delete
124
+ // back, so soft-deleted documents cannot leak live edges.
103
125
  // whole-document delete mints no new version, so the version stream
104
126
  // never records it — the audit log is the only place a deletion is
105
127
  // accountable (docs/06-auth-and-security/02-auditability.md). Storage-file cleanup (step 4) is a
106
128
  // DB↔external side-effect and stays OUTSIDE the transaction — it is
107
129
  // post-commit, best-effort compensation (docs/03-architecture/03-transactions.md).
108
- const audit = requireAuditCapability(db);
130
+ const treeAudit = definition.tree === true ? requireTreeAuditCapability(db) : undefined;
131
+ const audit = treeAudit ?? requireAuditCapability(db);
109
132
  const actor = auditActor(ctx);
110
133
  let deletedVersionCount = 0;
134
+ let treeResult;
111
135
  await audit.withTransaction(async () => {
112
136
  deletedVersionCount = await db.commands.documents.softDeleteDocument({
113
137
  document_id: params.documentId,
@@ -119,36 +143,77 @@ export async function deleteDocument(ctx, params) {
119
143
  actorRealm: actor.actorRealm,
120
144
  action: AUDIT_ACTIONS.deleted,
121
145
  });
146
+ if (treeAudit != null) {
147
+ treeResult = await reconcileTreeOnDeleteInTransaction(ctx, params.documentId, treeAudit);
148
+ }
122
149
  });
123
- // 4. Clean up storage files. Non-fatal: logs errors but does not throw.
124
- if (ctx.storage && storagePathsToDelete.length > 0) {
150
+ // Everything below is post-commit. Each operation and the logger get an
151
+ // independent attempt; none can turn the committed delete into a rejection.
152
+ const sideEffectFailures = [];
153
+ // 4. Clean up every storage file. Returned failures omit paths, while
154
+ // internal logs retain the target needed for operational reconciliation.
155
+ if (storage && storagePathsToDelete.length > 0) {
125
156
  for (const storagePath of storagePathsToDelete) {
126
157
  try {
127
- await ctx.storage.delete(storagePath);
158
+ await storage.delete(storagePath);
128
159
  }
129
- catch (err) {
130
- logger.error({ err, storagePath }, 'failed to delete storage file');
160
+ catch (error) {
161
+ sideEffectFailures.push(serializeSideEffectFailure('storageCleanup', error));
162
+ try {
163
+ logger.error({ err: error, documentId: params.documentId, storagePath }, 'failed to delete storage file');
164
+ }
165
+ catch {
166
+ // Diagnostic logging must not interrupt the remaining cleanup attempts.
167
+ }
131
168
  }
132
169
  }
133
170
  }
134
- // 5. Reconcile the document tree for `tree: true` collections. Byline
135
- // deletes are soft (the document row survives), so the table's
136
- // promote/cascade foreign keys never fire promote the node's
137
- // children to root and remove its edge here instead, firing the
138
- // structural-change invalidation event. Post-commit and best-effort,
139
- // like file cleanup: a failure here leaves the soft-delete intact
140
- // (status-at-edge already hides the deleted node's subtree from
141
- // reads) and is logged rather than thrown. See docs/04-collections/03-document-trees.md.
142
- if (definition.tree === true) {
171
+ // 5-6. Both post-commit hook families get an independent attempt. A tree
172
+ // invalidation failure must not prevent afterDelete consumers (search,
173
+ // cache removal) from running, or vice versa.
174
+ try {
175
+ if (treeResult != null) {
176
+ await firePromoteTreeChange(ctx, params.documentId, treeResult);
177
+ }
178
+ }
179
+ catch (error) {
180
+ sideEffectFailures.push(serializeSideEffectFailure('afterTreeChange', error));
181
+ try {
182
+ logger.error({ err: error, documentId: params.documentId }, 'afterTreeChange hook failed after document delete');
183
+ }
184
+ catch {
185
+ // Diagnostic logging must not affect the committed result.
186
+ }
187
+ }
188
+ try {
189
+ await invokeHook(hooks?.afterDelete, hookCtx);
190
+ }
191
+ catch (error) {
192
+ sideEffectFailures.push(serializeSideEffectFailure('afterDelete', error));
193
+ try {
194
+ logger.error({ err: error, documentId: params.documentId }, 'afterDelete hook failed after document delete');
195
+ }
196
+ catch {
197
+ // Diagnostic logging must not affect the committed result.
198
+ }
199
+ }
200
+ const [firstFailure, ...remainingFailures] = sideEffectFailures;
201
+ if (firstFailure != null) {
143
202
  try {
144
- await promoteChildrenAndRemove(ctx, { documentId: params.documentId });
203
+ logger.error({
204
+ documentId: params.documentId,
205
+ sideEffectFailures: [firstFailure, ...remainingFailures],
206
+ }, 'post-commit delete side effects failed');
145
207
  }
146
- catch (err) {
147
- logger.error({ err, documentId: params.documentId }, 'failed to reconcile document tree on delete');
208
+ catch {
209
+ // A reporting failure cannot change the already-committed outcome.
148
210
  }
211
+ return {
212
+ deletedVersionCount,
213
+ outcome: 'committed-with-side-effect-failures',
214
+ sideEffectFailures: [firstFailure, ...remainingFailures],
215
+ };
149
216
  }
150
- // 6. afterDelete hook.
151
- await invokeHook(hooks?.afterDelete, hookCtx);
152
- return { deletedVersionCount };
217
+ return { deletedVersionCount, outcome: 'committed', sideEffectFailures: [] };
153
218
  });
154
219
  }
@@ -35,7 +35,7 @@ export { updateDocument, updateDocumentWithPatches } from './update.js';
35
35
  export type { DocumentLifecycleContext } from './context.js';
36
36
  export type { CopyToLocaleResult } from './copy-to-locale.js';
37
37
  export type { CreateDocumentResult } from './create.js';
38
- export type { DeleteDocumentResult } from './delete.js';
38
+ export type { DeleteDocumentCommittedResult, DeleteDocumentCommittedWithSideEffectFailuresResult, DeleteDocumentOutcome, DeleteDocumentResult, DeleteDocumentSideEffectCode, DeleteDocumentSideEffectFailure, DeleteDocumentSideEffectPhase, } from './delete.js';
39
39
  export type { DeleteLocaleResult } from './delete-locale.js';
40
40
  export type { DuplicateDocumentResult } from './duplicate.js';
41
41
  export type { RestoreVersionResult } from './restore.js';
@@ -59,26 +59,6 @@ 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/04-collections/03-document-trees.md.
80
- */
81
- export declare function selfHealTreePlacement(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
82
62
  /** Extract `id` from the document object returned by `createDocumentVersion`. */
83
63
  export declare function extractVersionId(document: any): string;
84
64
  /** Extract the logical document id from the document object returned by `createDocumentVersion`. */