@byline/core 3.21.0 → 4.1.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 (77) 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/codegen/index.js +34 -3
  15. package/dist/codegen/index.test.node.js +20 -2
  16. package/dist/config/attach-hooks.d.ts +25 -0
  17. package/dist/config/attach-hooks.js +130 -0
  18. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  19. package/dist/config/attach-hooks.test.node.js +173 -0
  20. package/dist/config/config-hooks.test.node.d.ts +1 -0
  21. package/dist/config/config-hooks.test.node.js +56 -0
  22. package/dist/config/config.d.ts +9 -5
  23. package/dist/config/config.js +20 -2
  24. package/dist/config/routes.d.ts +5 -5
  25. package/dist/config/routes.js +42 -9
  26. package/dist/config/routes.test.node.d.ts +1 -0
  27. package/dist/config/routes.test.node.js +152 -0
  28. package/dist/core.d.ts +2 -2
  29. package/dist/core.js +20 -14
  30. package/dist/core.test.node.d.ts +1 -0
  31. package/dist/core.test.node.js +28 -0
  32. package/dist/host/host-request-bridge.d.ts +62 -0
  33. package/dist/host/host-request-bridge.js +38 -0
  34. package/dist/index.d.ts +5 -3
  35. package/dist/index.js +5 -3
  36. package/dist/lib/errors.d.ts +13 -0
  37. package/dist/lib/errors.js +14 -0
  38. package/dist/query/parse-where.d.ts +9 -0
  39. package/dist/query/parse-where.js +146 -2
  40. package/dist/query/parse-where.test.node.js +60 -1
  41. package/dist/services/collection-bootstrap.test.node.js +30 -59
  42. package/dist/services/discover-counter-groups.test.node.js +23 -0
  43. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  44. package/dist/services/document-lifecycle/audit.js +32 -1
  45. package/dist/services/document-lifecycle/create.js +13 -6
  46. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  47. package/dist/services/document-lifecycle/delete.js +91 -26
  48. package/dist/services/document-lifecycle/index.d.ts +1 -1
  49. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  50. package/dist/services/document-lifecycle/internals.js +22 -46
  51. package/dist/services/document-lifecycle/status.d.ts +1 -1
  52. package/dist/services/document-lifecycle/status.js +20 -3
  53. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  54. package/dist/services/document-lifecycle/system-fields.js +112 -74
  55. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  56. package/dist/services/document-lifecycle/tree.js +244 -123
  57. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  58. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  59. package/dist/services/document-lifecycle/update.js +2 -1
  60. package/dist/services/document-lifecycle.test.node.js +360 -16
  61. package/dist/services/document-read.d.ts +14 -6
  62. package/dist/services/document-read.js +47 -9
  63. package/dist/services/field-upload.test.node.js +70 -0
  64. package/dist/services/index.d.ts +2 -2
  65. package/dist/services/index.js +2 -2
  66. package/dist/services/populate.d.ts +7 -3
  67. package/dist/services/populate.js +180 -28
  68. package/dist/services/populate.test.node.js +59 -0
  69. package/dist/services/richtext-embed.d.ts +39 -2
  70. package/dist/services/richtext-embed.js +4 -34
  71. package/dist/services/richtext-embed.test.node.js +15 -0
  72. package/dist/services/richtext-populate.d.ts +20 -2
  73. package/dist/services/richtext-populate.js +160 -19
  74. package/dist/services/richtext-populate.test.node.js +523 -2
  75. package/dist/utils/root-relative-redirect.d.ts +6 -0
  76. package/dist/utils/root-relative-redirect.js +37 -0
  77. package/package.json +2 -2
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { ERR_VALIDATION } from '../lib/errors.js';
8
9
  import { fieldTypeToStore } from '../storage/field-store-map.js';
9
10
  // ---------------------------------------------------------------------------
10
11
  // Document-level reserved keys
@@ -44,6 +45,144 @@ const DOCUMENT_SORT_COLUMNS = {
44
45
  export async function parseWhere(where, definition, ctx) {
45
46
  return parseWhereInternal(where, definition, ctx, { isNested: false, inCombinator: false });
46
47
  }
48
+ /**
49
+ * Compile a predicate wholly to adapter `DocumentFilter`s. Unlike
50
+ * `parseWhere`, top-level `status` and `path` are emitted as document-column
51
+ * filters rather than scalar list-query options. This is the form used by
52
+ * `beforeRead` on detail, populate, count, and tree reads.
53
+ */
54
+ export async function parsePredicateFilters(where, definition, ctx, options = {}) {
55
+ if (options.strict) {
56
+ await validateSecurityPredicate(where, definition, ctx);
57
+ }
58
+ const parsed = await parseWhereInternal(where, definition, ctx, {
59
+ isNested: false,
60
+ inCombinator: true,
61
+ });
62
+ return parsed.filters;
63
+ }
64
+ const FILTER_OPERATORS = new Set([
65
+ '$eq',
66
+ '$ne',
67
+ '$gt',
68
+ '$gte',
69
+ '$lt',
70
+ '$lte',
71
+ '$contains',
72
+ '$in',
73
+ '$nin',
74
+ ]);
75
+ const DOCUMENT_COLUMN_OPERATORS = {
76
+ status: new Set(['$eq', '$ne', '$in', '$nin']),
77
+ path: new Set(['$eq', '$ne', '$contains', '$in', '$nin']),
78
+ id: new Set(['$eq', '$ne', '$in', '$nin']),
79
+ };
80
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
81
+ /** Fail-closed validation used only for security predicates from hooks. */
82
+ async function validateSecurityPredicate(predicate, definition, ctx, allowEmpty = false) {
83
+ if (predicate == null || typeof predicate !== 'object' || Array.isArray(predicate)) {
84
+ throw invalidSecurityPredicate(definition.path, 'predicate must be an object');
85
+ }
86
+ const entries = Object.entries(predicate).filter(([, value]) => value !== undefined);
87
+ if (entries.length === 0 && !allowEmpty) {
88
+ throw invalidSecurityPredicate(definition.path, 'predicate must contain at least one clause');
89
+ }
90
+ for (const [key, value] of entries) {
91
+ if (key === '$and' || key === '$or') {
92
+ if (!Array.isArray(value) || value.length === 0) {
93
+ throw invalidSecurityPredicate(definition.path, `${key} must be a non-empty array`);
94
+ }
95
+ for (const child of value) {
96
+ await validateSecurityPredicate(child, definition, ctx);
97
+ }
98
+ continue;
99
+ }
100
+ if (key === 'query') {
101
+ throw invalidSecurityPredicate(definition.path, '`query` is not supported in beforeRead security predicates');
102
+ }
103
+ if (key === 'status' || key === 'path' || key === 'id') {
104
+ validateDocumentColumnPredicate(value, definition.path, key);
105
+ continue;
106
+ }
107
+ const field = definition.fields.find((candidate) => candidate.name === key);
108
+ if (field == null) {
109
+ throw invalidSecurityPredicate(definition.path, `unknown field '${key}'`);
110
+ }
111
+ if (field.type === 'relation' && (isQuantifierObject(value) || isPlainSubWhere(value))) {
112
+ const targetPath = field.targetCollection;
113
+ const target = targetPath
114
+ ? ctx?.collections.find((candidate) => candidate.path === targetPath)
115
+ : null;
116
+ if (target == null) {
117
+ throw invalidSecurityPredicate(definition.path, `cannot resolve target collection for relation field '${key}'`);
118
+ }
119
+ if (isQuantifierObject(value)) {
120
+ for (const [quantifier, nested] of Object.entries(value)) {
121
+ await validateSecurityPredicate(nested, target, ctx, quantifier === '$none');
122
+ }
123
+ }
124
+ else {
125
+ await validateSecurityPredicate(value, target, ctx);
126
+ }
127
+ continue;
128
+ }
129
+ if (fieldTypeToStore[field.type] == null) {
130
+ throw invalidSecurityPredicate(definition.path, `field '${key}' cannot be used as a predicate`);
131
+ }
132
+ validateSecurityOperator(value, definition.path, key);
133
+ }
134
+ }
135
+ function validateDocumentColumnPredicate(value, collectionPath, column) {
136
+ validateSecurityOperator(value, collectionPath, column);
137
+ const parsed = normaliseToOperator(value);
138
+ if (parsed == null || !DOCUMENT_COLUMN_OPERATORS[column].has(parsed.operator)) {
139
+ throw invalidSecurityPredicate(collectionPath, `operator '${parsed?.operator ?? 'unknown'}' is not supported for '${column}'`);
140
+ }
141
+ const values = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
142
+ if ((parsed.operator === '$in' || parsed.operator === '$nin') && !Array.isArray(parsed.value)) {
143
+ throw invalidSecurityPredicate(collectionPath, `${parsed.operator} for '${column}' requires an array`);
144
+ }
145
+ if (parsed.operator !== '$in' && parsed.operator !== '$nin' && Array.isArray(parsed.value)) {
146
+ throw invalidSecurityPredicate(collectionPath, `${parsed.operator} for '${column}' requires a string`);
147
+ }
148
+ if (values.some((item) => typeof item !== 'string')) {
149
+ throw invalidSecurityPredicate(collectionPath, `predicate for '${column}' requires string values`);
150
+ }
151
+ if (column === 'id' && values.some((item) => !UUID_RE.test(item))) {
152
+ throw invalidSecurityPredicate(collectionPath, `predicate for 'id' requires UUID values; use { id: { $in: [] } } to deny all rows`);
153
+ }
154
+ }
155
+ function validateSecurityOperator(value, collectionPath, fieldName) {
156
+ if (value === null ||
157
+ typeof value === 'string' ||
158
+ typeof value === 'number' ||
159
+ typeof value === 'boolean') {
160
+ return;
161
+ }
162
+ if (typeof value !== 'object' || Array.isArray(value)) {
163
+ throw invalidSecurityPredicate(collectionPath, `invalid predicate for '${fieldName}'`);
164
+ }
165
+ const entries = Object.entries(value);
166
+ if (entries.length !== 1 || !FILTER_OPERATORS.has(entries[0]?.[0] ?? '')) {
167
+ throw invalidSecurityPredicate(collectionPath, `predicate for '${fieldName}' must contain exactly one supported operator`);
168
+ }
169
+ const [operator, operand] = entries[0] ?? [];
170
+ if ((operator === '$in' || operator === '$nin') && !Array.isArray(operand)) {
171
+ throw invalidSecurityPredicate(collectionPath, `${operator} for '${fieldName}' requires an array`);
172
+ }
173
+ if (operator === '$contains' && typeof operand !== 'string') {
174
+ throw invalidSecurityPredicate(collectionPath, `$contains for '${fieldName}' requires a string`);
175
+ }
176
+ if (operand === undefined) {
177
+ throw invalidSecurityPredicate(collectionPath, `operator for '${fieldName}' requires a value`);
178
+ }
179
+ }
180
+ function invalidSecurityPredicate(collectionPath, reason) {
181
+ return ERR_VALIDATION({
182
+ message: `invalid beforeRead predicate for '${collectionPath}': ${reason}`,
183
+ details: { collectionPath, reason },
184
+ });
185
+ }
47
186
  /**
48
187
  * Combine a `beforeRead` hook predicate with a caller-supplied where
49
188
  * clause using implicit AND. Returns whichever side is non-empty, or
@@ -179,7 +318,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
179
318
  kind: 'docColumn',
180
319
  column: 'status',
181
320
  operator: parsed.operator,
182
- value: parsed.value === null ? null : String(parsed.value),
321
+ value: stringifyDocumentColumnValue(parsed.value),
183
322
  });
184
323
  }
185
324
  continue;
@@ -211,7 +350,7 @@ async function parseWhereInternal(where, definition, ctx, { isNested, inCombinat
211
350
  kind: 'docColumn',
212
351
  column: 'path',
213
352
  operator: parsed.operator,
214
- value: parsed.value === null ? null : String(parsed.value),
353
+ value: stringifyDocumentColumnValue(parsed.value),
215
354
  });
216
355
  }
217
356
  continue;
@@ -449,5 +588,10 @@ function normaliseToOperator(raw) {
449
588
  value: val,
450
589
  };
451
590
  }
591
+ function stringifyDocumentColumnValue(value) {
592
+ if (value === null)
593
+ return null;
594
+ return Array.isArray(value) ? value.map(String) : String(value);
595
+ }
452
596
  /** Exported for testing. */
453
597
  export { DOCUMENT_LEVEL_KEYS, DOCUMENT_SORT_COLUMNS };
@@ -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
  *