@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.
- package/dist/@types/collection-types.d.ts +72 -31
- package/dist/@types/db-types.d.ts +93 -47
- package/dist/@types/field-data-types.d.ts +8 -10
- package/dist/@types/field-data-types.js +1 -1
- package/dist/@types/field-data-types.test.node.d.ts +1 -0
- package/dist/@types/field-data-types.test.node.js +70 -0
- package/dist/@types/field-types.d.ts +25 -1
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/@types/query-predicate.d.ts +3 -3
- package/dist/@types/relation-types.d.ts +38 -0
- package/dist/@types/relation-types.js +8 -0
- package/dist/@types/search-types.d.ts +3 -4
- package/dist/@types/site-config.d.ts +52 -13
- package/dist/auth/apply-before-read.d.ts +23 -11
- package/dist/auth/apply-before-read.js +139 -33
- package/dist/auth/apply-before-read.test.node.js +241 -3
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/read-context-scope.d.ts +20 -0
- package/dist/auth/read-context-scope.js +48 -0
- package/dist/codegen/fixtures/all-fields.d.ts +342 -0
- package/dist/codegen/fixtures/all-fields.expected.d.ts +120 -0
- package/dist/codegen/fixtures/all-fields.expected.js +1 -0
- package/dist/codegen/fixtures/all-fields.js +94 -0
- package/dist/codegen/index.d.ts +16 -0
- package/dist/codegen/index.js +431 -0
- package/dist/codegen/index.test.node.d.ts +1 -0
- package/dist/codegen/index.test.node.js +230 -0
- package/dist/config/attach-hooks.d.ts +25 -0
- package/dist/config/attach-hooks.js +130 -0
- package/dist/config/attach-hooks.test.node.d.ts +1 -0
- package/dist/config/attach-hooks.test.node.js +173 -0
- package/dist/config/config-hooks.test.node.d.ts +1 -0
- package/dist/config/config-hooks.test.node.js +56 -0
- package/dist/config/config.d.ts +9 -5
- package/dist/config/config.js +20 -2
- package/dist/config/routes.d.ts +5 -5
- package/dist/config/routes.js +42 -9
- package/dist/config/routes.test.node.d.ts +1 -0
- package/dist/config/routes.test.node.js +152 -0
- package/dist/core.d.ts +3 -3
- package/dist/core.js +20 -14
- package/dist/core.test.node.d.ts +1 -0
- package/dist/core.test.node.js +28 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/lib/errors.d.ts +13 -0
- package/dist/lib/errors.js +14 -0
- package/dist/query/parse-where.d.ts +10 -1
- package/dist/query/parse-where.js +146 -2
- package/dist/query/parse-where.test.node.js +60 -1
- package/dist/schemas/zod/builder.js +7 -3
- package/dist/schemas/zod/builder.test.node.d.ts +1 -0
- package/dist/schemas/zod/builder.test.node.js +49 -0
- package/dist/services/collection-bootstrap.d.ts +1 -1
- package/dist/services/collection-bootstrap.test.node.js +30 -59
- package/dist/services/discover-counter-groups.d.ts +1 -1
- package/dist/services/discover-counter-groups.test.node.js +23 -0
- package/dist/services/document-lifecycle/audit.d.ts +22 -1
- package/dist/services/document-lifecycle/audit.js +32 -1
- package/dist/services/document-lifecycle/create.d.ts +2 -2
- package/dist/services/document-lifecycle/create.js +18 -8
- package/dist/services/document-lifecycle/delete.d.ts +17 -1
- package/dist/services/document-lifecycle/delete.js +91 -26
- package/dist/services/document-lifecycle/index.d.ts +1 -1
- package/dist/services/document-lifecycle/internals.d.ts +0 -20
- package/dist/services/document-lifecycle/internals.js +22 -46
- package/dist/services/document-lifecycle/status.d.ts +1 -1
- package/dist/services/document-lifecycle/status.js +20 -3
- package/dist/services/document-lifecycle/system-fields.d.ts +19 -6
- package/dist/services/document-lifecycle/system-fields.js +112 -74
- package/dist/services/document-lifecycle/tree.d.ts +22 -24
- package/dist/services/document-lifecycle/tree.js +244 -123
- package/dist/services/document-lifecycle/tree.test.node.d.ts +8 -0
- package/dist/services/document-lifecycle/tree.test.node.js +663 -0
- package/dist/services/document-lifecycle/update.d.ts +4 -4
- package/dist/services/document-lifecycle/update.js +11 -5
- package/dist/services/document-lifecycle.test.node.js +426 -16
- package/dist/services/document-read.d.ts +14 -6
- package/dist/services/document-read.js +47 -9
- package/dist/services/field-upload.test.node.js +70 -0
- package/dist/services/index.d.ts +3 -2
- package/dist/services/index.js +3 -2
- package/dist/services/normalize-numeric-fields.d.ts +23 -0
- package/dist/services/normalize-numeric-fields.js +89 -0
- package/dist/services/normalize-numeric-fields.test.node.d.ts +8 -0
- package/dist/services/normalize-numeric-fields.test.node.js +99 -0
- package/dist/services/populate.d.ts +9 -23
- package/dist/services/populate.js +180 -28
- package/dist/services/populate.test.node.js +59 -0
- package/dist/services/richtext-embed.d.ts +39 -2
- package/dist/services/richtext-embed.js +4 -34
- package/dist/services/richtext-embed.test.node.js +15 -0
- package/dist/services/richtext-populate.d.ts +21 -3
- package/dist/services/richtext-populate.js +160 -19
- package/dist/services/richtext-populate.test.node.js +523 -2
- package/dist/services/validate-search-config.d.ts +1 -1
- package/dist/storage/collection-fingerprint.js +6 -0
- package/dist/storage/collection-fingerprint.test.node.js +21 -0
- package/dist/utils/root-relative-redirect.d.ts +6 -0
- package/dist/utils/root-relative-redirect.js +37 -0
- 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.
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -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(
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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.
|
|
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
|
|
87
|
-
//
|
|
88
|
-
// (afterCreate covers invalidation).
|
|
89
|
-
//
|
|
90
|
-
//
|
|
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
|
|
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
|
*
|