@byline/core 3.20.4 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/@types/collection-types.d.ts +72 -31
  2. package/dist/@types/db-types.d.ts +93 -47
  3. package/dist/@types/field-data-types.d.ts +8 -10
  4. package/dist/@types/field-data-types.js +1 -1
  5. package/dist/@types/field-data-types.test.node.d.ts +1 -0
  6. package/dist/@types/field-data-types.test.node.js +70 -0
  7. package/dist/@types/field-types.d.ts +25 -1
  8. package/dist/@types/index.d.ts +1 -0
  9. package/dist/@types/index.js +1 -0
  10. package/dist/@types/query-predicate.d.ts +3 -3
  11. package/dist/@types/relation-types.d.ts +38 -0
  12. package/dist/@types/relation-types.js +8 -0
  13. package/dist/@types/search-types.d.ts +3 -4
  14. package/dist/@types/site-config.d.ts +52 -13
  15. package/dist/auth/apply-before-read.d.ts +23 -11
  16. package/dist/auth/apply-before-read.js +139 -33
  17. package/dist/auth/apply-before-read.test.node.js +241 -3
  18. package/dist/auth/index.d.ts +1 -1
  19. package/dist/auth/index.js +1 -1
  20. package/dist/auth/read-context-scope.d.ts +20 -0
  21. package/dist/auth/read-context-scope.js +48 -0
  22. package/dist/codegen/fixtures/all-fields.d.ts +342 -0
  23. package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
  24. package/dist/codegen/fixtures/all-fields.expected.js +1 -0
  25. package/dist/codegen/fixtures/all-fields.js +94 -0
  26. package/dist/codegen/index.d.ts +16 -0
  27. package/dist/codegen/index.js +431 -0
  28. package/dist/codegen/index.test.node.d.ts +1 -0
  29. package/dist/codegen/index.test.node.js +230 -0
  30. package/dist/config/attach-hooks.d.ts +25 -0
  31. package/dist/config/attach-hooks.js +130 -0
  32. package/dist/config/attach-hooks.test.node.d.ts +1 -0
  33. package/dist/config/attach-hooks.test.node.js +173 -0
  34. package/dist/config/config-hooks.test.node.d.ts +1 -0
  35. package/dist/config/config-hooks.test.node.js +56 -0
  36. package/dist/config/config.d.ts +9 -5
  37. package/dist/config/config.js +20 -2
  38. package/dist/config/routes.d.ts +5 -5
  39. package/dist/config/routes.js +42 -9
  40. package/dist/config/routes.test.node.d.ts +1 -0
  41. package/dist/config/routes.test.node.js +152 -0
  42. package/dist/core.d.ts +3 -3
  43. package/dist/core.js +20 -14
  44. package/dist/core.test.node.d.ts +1 -0
  45. package/dist/core.test.node.js +28 -0
  46. package/dist/index.d.ts +4 -3
  47. package/dist/index.js +4 -3
  48. package/dist/lib/errors.d.ts +13 -0
  49. package/dist/lib/errors.js +14 -0
  50. package/dist/query/parse-where.d.ts +10 -1
  51. package/dist/query/parse-where.js +146 -2
  52. package/dist/query/parse-where.test.node.js +60 -1
  53. package/dist/schemas/zod/builder.js +7 -3
  54. package/dist/schemas/zod/builder.test.node.d.ts +1 -0
  55. package/dist/schemas/zod/builder.test.node.js +49 -0
  56. package/dist/services/collection-bootstrap.d.ts +1 -1
  57. package/dist/services/collection-bootstrap.test.node.js +30 -59
  58. package/dist/services/discover-counter-groups.d.ts +1 -1
  59. package/dist/services/discover-counter-groups.test.node.js +23 -0
  60. package/dist/services/document-lifecycle/audit.d.ts +22 -1
  61. package/dist/services/document-lifecycle/audit.js +32 -1
  62. package/dist/services/document-lifecycle/create.d.ts +2 -2
  63. package/dist/services/document-lifecycle/create.js +18 -8
  64. package/dist/services/document-lifecycle/delete.d.ts +17 -1
  65. package/dist/services/document-lifecycle/delete.js +91 -26
  66. package/dist/services/document-lifecycle/index.d.ts +1 -1
  67. package/dist/services/document-lifecycle/internals.d.ts +0 -20
  68. package/dist/services/document-lifecycle/internals.js +22 -46
  69. package/dist/services/document-lifecycle/status.d.ts +1 -1
  70. package/dist/services/document-lifecycle/status.js +20 -3
  71. package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
  72. package/dist/services/document-lifecycle/system-fields.js +112 -74
  73. package/dist/services/document-lifecycle/tree.d.ts +22 -24
  74. package/dist/services/document-lifecycle/tree.js +244 -123
  75. package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
  76. package/dist/services/document-lifecycle/tree.test.node.js +663 -0
  77. package/dist/services/document-lifecycle/update.d.ts +4 -4
  78. package/dist/services/document-lifecycle/update.js +11 -5
  79. package/dist/services/document-lifecycle.test.node.js +426 -16
  80. package/dist/services/document-read.d.ts +14 -6
  81. package/dist/services/document-read.js +47 -9
  82. package/dist/services/field-upload.test.node.js +70 -0
  83. package/dist/services/index.d.ts +3 -2
  84. package/dist/services/index.js +3 -2
  85. package/dist/services/normalize-numeric-fields.d.ts +23 -0
  86. package/dist/services/normalize-numeric-fields.js +89 -0
  87. package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
  88. package/dist/services/normalize-numeric-fields.test.node.js +99 -0
  89. package/dist/services/populate.d.ts +9 -23
  90. package/dist/services/populate.js +180 -28
  91. package/dist/services/populate.test.node.js +59 -0
  92. package/dist/services/richtext-embed.d.ts +39 -2
  93. package/dist/services/richtext-embed.js +4 -34
  94. package/dist/services/richtext-embed.test.node.js +15 -0
  95. package/dist/services/richtext-populate.d.ts +21 -3
  96. package/dist/services/richtext-populate.js +160 -19
  97. package/dist/services/richtext-populate.test.node.js +523 -2
  98. package/dist/services/validate-search-config.d.ts +1 -1
  99. package/dist/storage/collection-fingerprint.js +6 -0
  100. package/dist/storage/collection-fingerprint.test.node.js +21 -0
  101. package/dist/utils/root-relative-redirect.d.ts +6 -0
  102. package/dist/utils/root-relative-redirect.js +37 -0
  103. package/package.json +7 -2
@@ -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
  // ---------------------------------------------------------------------------
@@ -118,9 +118,12 @@ export const fieldToZodSchema = (field, strict = true) => {
118
118
  schema = z.number().int();
119
119
  break;
120
120
  case 'float':
121
- case 'decimal':
122
121
  schema = z.number();
123
122
  break;
123
+ case 'decimal':
124
+ // Postgres numeric values are restored as strings to preserve precision.
125
+ schema = z.string();
126
+ break;
124
127
  case 'image':
125
128
  case 'file': {
126
129
  // StoredFileValue — the object written by the upload endpoint and
@@ -132,7 +135,7 @@ export const fieldToZodSchema = (field, strict = true) => {
132
135
  filename: z.string(),
133
136
  originalFilename: z.string(),
134
137
  mimeType: z.string(),
135
- fileSize: z.string(),
138
+ fileSize: z.number(),
136
139
  storageProvider: z.string(),
137
140
  storagePath: z.string(),
138
141
  storageUrl: z.string().nullable().optional(),
@@ -181,7 +184,8 @@ export const fieldToZodSchema = (field, strict = true) => {
181
184
  schema = arr;
182
185
  }
183
186
  else {
184
- schema = relationValue.nullable();
187
+ // Storage omits cleared values; it never restores a relation as null.
188
+ schema = relationValue;
185
189
  }
186
190
  break;
187
191
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { defineCollection } from '../../@types/collection-types.js';
3
+ import { createCollectionSchemas } from './builder.js';
4
+ const Assets = defineCollection({
5
+ path: 'assets',
6
+ labels: { singular: 'Asset', plural: 'Assets' },
7
+ fields: [
8
+ { name: 'price', type: 'decimal' },
9
+ { name: 'download', type: 'file' },
10
+ { name: 'owner', type: 'relation', targetCollection: 'people' },
11
+ ],
12
+ });
13
+ const fieldsFixture = {
14
+ price: '1234567890.123456789',
15
+ download: {
16
+ fileId: 'file-1',
17
+ filename: 'report.pdf',
18
+ originalFilename: 'Report.pdf',
19
+ mimeType: 'application/pdf',
20
+ fileSize: 4096,
21
+ storageProvider: 'local',
22
+ storagePath: 'assets/report.pdf',
23
+ processingStatus: 'complete',
24
+ },
25
+ owner: {
26
+ targetDocumentId: 'person-1',
27
+ targetCollectionId: 'people',
28
+ },
29
+ };
30
+ describe('collection Zod schemas', () => {
31
+ it('parses the canonical decimal and file storage read shapes', () => {
32
+ const parsed = createCollectionSchemas(Assets).get.parse({
33
+ id: '550e8400-e29b-41d4-a716-446655440000',
34
+ status: 'published',
35
+ createdAt: '2026-07-15T12:00:00.000Z',
36
+ updatedAt: '2026-07-15T12:00:00.000Z',
37
+ fields: fieldsFixture,
38
+ });
39
+ expect(parsed.fields.price).toBe(fieldsFixture.price);
40
+ expect(parsed.fields.download?.fileSize).toBe(4096);
41
+ });
42
+ it('rejects null for a required single relation in the strict fields schema', () => {
43
+ const result = createCollectionSchemas(Assets).fields.safeParse({
44
+ ...fieldsFixture,
45
+ owner: null,
46
+ });
47
+ expect(result.success).toBe(false);
48
+ });
49
+ });
@@ -19,7 +19,7 @@ export interface CollectionRecord {
19
19
  schemaHash: string;
20
20
  }
21
21
  export interface EnsureCollectionsInput {
22
- definitions: CollectionDefinition[];
22
+ definitions: readonly CollectionDefinition[];
23
23
  db: IDbAdapter;
24
24
  logger?: BylineLogger;
25
25
  }
@@ -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);
@@ -9,7 +9,7 @@ import { type CollectionDefinition } from '../@types/index.js';
9
9
  import type { IDbAdapter } from '../@types/db-types.js';
10
10
  import type { BylineLogger } from '../lib/logger.js';
11
11
  export interface DiscoverCounterGroupsInput {
12
- definitions: CollectionDefinition[];
12
+ definitions: readonly CollectionDefinition[];
13
13
  db: IDbAdapter;
14
14
  logger?: BylineLogger;
15
15
  }
@@ -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)
@@ -17,8 +17,8 @@ export interface CreateDocumentResult {
17
17
  * 1. Default-locale enforcement: reject if `params.locale` is anything
18
18
  * other than the configured default content locale (a brand-new
19
19
  * document's canonical `path` lives in the default locale).
20
- * 2. `normaliseDateFields(data)`
21
- * 3. `hooks.beforeCreate({ data, collectionPath })`
20
+ * 2. Normalize date and numeric fields
21
+ * 3. `hooks.beforeCreate({ data, collectionPath })`, then normalize numerics again
22
22
  * 4. Resolve `path` — explicit `params.path` → derive via `useAsPath`
23
23
  * → UUID fallback.
24
24
  * 5. `db.commands.documents.createDocumentVersion(...)` (action = 'create')
@@ -13,7 +13,10 @@ import { normaliseDateFields } from '../../utils/normalise-dates.js';
13
13
  import { slugify } from '../../utils/slugify.js';
14
14
  import { getDefaultStatus } from '../../workflow/workflow.js';
15
15
  import { assignCounterValues } from '../assign-counter-values.js';
16
- import { actorId, appendTreeRoot, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
16
+ import { normalizeNumericFields } from '../normalize-numeric-fields.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';
17
20
  /**
18
21
  * Create a new document.
19
22
  *
@@ -21,8 +24,8 @@ import { actorId, appendTreeRoot, applyRichTextEmbed, derivePath, extractDocumen
21
24
  * 1. Default-locale enforcement: reject if `params.locale` is anything
22
25
  * other than the configured default content locale (a brand-new
23
26
  * document's canonical `path` lives in the default locale).
24
- * 2. `normaliseDateFields(data)`
25
- * 3. `hooks.beforeCreate({ data, collectionPath })`
27
+ * 2. Normalize date and numeric fields
28
+ * 3. `hooks.beforeCreate({ data, collectionPath })`, then normalize numerics again
26
29
  * 4. Resolve `path` — explicit `params.path` → derive via `useAsPath`
27
30
  * → UUID fallback.
28
31
  * 5. `db.commands.documents.createDocumentVersion(...)` (action = 'create')
@@ -32,6 +35,10 @@ export async function createDocument(ctx, params) {
32
35
  return withLogContext({ domain: 'services', module: 'lifecycle', function: 'createDocument' }, async () => {
33
36
  const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
34
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);
35
42
  const slugifier = ctx.slugifier ?? slugify;
36
43
  const hooks = await resolveHooks(definition);
37
44
  const data = params.data;
@@ -42,7 +49,9 @@ export async function createDocument(ctx, params) {
42
49
  }).log(ctx.logger);
43
50
  }
44
51
  normaliseDateFields(data);
52
+ normalizeNumericFields(definition.fields, data);
45
53
  await invokeHook(hooks?.beforeCreate, { data, collectionPath });
54
+ normalizeNumericFields(definition.fields, data);
46
55
  // Allocate counter-field values after beforeCreate so user-land hooks
47
56
  // can run their own logic on the raw payload, but before the flatten/
48
57
  // insert pass so the assigned values are persisted on the same write.
@@ -83,11 +92,12 @@ export async function createDocument(ctx, params) {
83
92
  // `tree: true` collections place every document in the tree by default:
84
93
  // a new document is appended as a root (a top-level nav entry) so it is
85
94
  // never stranded in the "unplaced" limbo. This is a system step of create
86
- // (the actor already passed the `create` ability), so it calls the storage
87
- // command directly no `update` re-assertion, no separate tree event
88
- // (afterCreate covers invalidation). Post-version and best-effort: a
89
- // failure leaves the document created-but-unplaced and is logged, not
90
- // thrown. See docs/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.
91
101
  if (definition.tree === true) {
92
102
  try {
93
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
  *