@byline/core 3.21.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/collection-types.d.ts +66 -31
- package/dist/@types/db-types.d.ts +93 -47
- package/dist/@types/field-types.d.ts +25 -1
- package/dist/@types/query-predicate.d.ts +3 -3
- package/dist/@types/search-types.d.ts +3 -4
- package/dist/@types/site-config.d.ts +51 -12
- 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/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 +2 -2
- 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 +9 -0
- package/dist/query/parse-where.js +146 -2
- package/dist/query/parse-where.test.node.js +60 -1
- package/dist/services/collection-bootstrap.test.node.js +30 -59
- 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.js +13 -6
- 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.js +2 -1
- package/dist/services/document-lifecycle.test.node.js +360 -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 +2 -2
- package/dist/services/index.js +2 -2
- package/dist/services/populate.d.ts +7 -3
- 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 +20 -2
- package/dist/services/richtext-populate.js +160 -19
- package/dist/services/richtext-populate.test.node.js +523 -2
- package/dist/utils/root-relative-redirect.d.ts +6 -0
- package/dist/utils/root-relative-redirect.js +37 -0
- package/package.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from 'vitest';
|
|
9
9
|
import { defineCollection, defineWorkflow } from '../@types/collection-types.js';
|
|
10
|
-
import { mergePredicates, parseSort, parseWhere } from './parse-where.js';
|
|
10
|
+
import { mergePredicates, parsePredicateFilters, parseSort, parseWhere } from './parse-where.js';
|
|
11
11
|
const testCollection = defineCollection({
|
|
12
12
|
path: 'test-articles',
|
|
13
13
|
labels: { singular: 'Article', plural: 'Articles' },
|
|
@@ -691,6 +691,65 @@ describe('parseWhere — `id` reserved key', () => {
|
|
|
691
691
|
expect(result.filters).toHaveLength(1);
|
|
692
692
|
});
|
|
693
693
|
});
|
|
694
|
+
describe('parsePredicateFilters', () => {
|
|
695
|
+
const idA = '018f4f7c-6a2b-7d91-8f21-111111111111';
|
|
696
|
+
const idB = '018f4f7c-6a2b-7d91-8f21-222222222222';
|
|
697
|
+
it('keeps top-level status and path inside the adapter filter list', async () => {
|
|
698
|
+
const filters = await parsePredicateFilters({ status: 'published', path: { $contains: 'docs' }, featured: true }, testCollection);
|
|
699
|
+
expect(filters).toEqual([
|
|
700
|
+
{ kind: 'docColumn', column: 'status', operator: '$eq', value: 'published' },
|
|
701
|
+
{ kind: 'docColumn', column: 'path', operator: '$contains', value: 'docs' },
|
|
702
|
+
expect.objectContaining({ kind: 'field', fieldName: 'featured' }),
|
|
703
|
+
]);
|
|
704
|
+
});
|
|
705
|
+
it.each([
|
|
706
|
+
[{ typoedTenant: 't-1' }, 'unknown field'],
|
|
707
|
+
[{ query: 'secret' }, 'not supported'],
|
|
708
|
+
[{ $or: [] }, 'non-empty array'],
|
|
709
|
+
[{ $or: [{ typoedTenant: 't-1' }] }, 'unknown field'],
|
|
710
|
+
[{ title: { $wat: 'x' } }, 'supported operator'],
|
|
711
|
+
[{ status: { $contains: 'pub' } }, 'not supported'],
|
|
712
|
+
[{ status: { $in: 'published' } }, 'requires an array'],
|
|
713
|
+
[{ status: { $in: ['published', 1] } }, 'requires string values'],
|
|
714
|
+
[{ path: { $gt: '/private' } }, 'not supported'],
|
|
715
|
+
[{ path: { $in: ['/a', 1] } }, 'requires string values'],
|
|
716
|
+
[{ id: '__none__' }, 'requires UUID values'],
|
|
717
|
+
[{ id: { $eq: [idA] } }, 'requires a string'],
|
|
718
|
+
[{ id: { $in: ['not-a-uuid'] } }, 'requires UUID values'],
|
|
719
|
+
])('rejects invalid security predicate %o', async (predicate, message) => {
|
|
720
|
+
await expect(parsePredicateFilters(predicate, testCollection, ctx, { strict: true })).rejects.toThrow(message);
|
|
721
|
+
});
|
|
722
|
+
it('preserves status, path, and id arrays in strict predicates', async () => {
|
|
723
|
+
const filters = await parsePredicateFilters({
|
|
724
|
+
status: { $in: ['draft', 'published'] },
|
|
725
|
+
path: { $nin: ['/private', '/internal'] },
|
|
726
|
+
id: { $in: [idA, idB] },
|
|
727
|
+
}, testCollection, ctx, { strict: true });
|
|
728
|
+
expect(filters).toEqual([
|
|
729
|
+
{
|
|
730
|
+
kind: 'docColumn',
|
|
731
|
+
column: 'status',
|
|
732
|
+
operator: '$in',
|
|
733
|
+
value: ['draft', 'published'],
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
kind: 'docColumn',
|
|
737
|
+
column: 'path',
|
|
738
|
+
operator: '$nin',
|
|
739
|
+
value: ['/private', '/internal'],
|
|
740
|
+
},
|
|
741
|
+
{ kind: 'docColumn', column: 'id', operator: '$in', value: [idA, idB] },
|
|
742
|
+
]);
|
|
743
|
+
});
|
|
744
|
+
it('compiles an empty id set as a safe always-false document filter', async () => {
|
|
745
|
+
await expect(parsePredicateFilters({ id: { $in: [] } }, testCollection, ctx, { strict: true })).resolves.toEqual([{ kind: 'docColumn', column: 'id', operator: '$in', value: [] }]);
|
|
746
|
+
});
|
|
747
|
+
it('keeps caller-query compilation permissive for unknown fields', async () => {
|
|
748
|
+
await expect(parseWhere({ typoedTenant: 't-1' }, testCollection)).resolves.toEqual({
|
|
749
|
+
filters: [],
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
});
|
|
694
753
|
// ---------------------------------------------------------------------------
|
|
695
754
|
// mergePredicates
|
|
696
755
|
// ---------------------------------------------------------------------------
|
|
@@ -21,10 +21,8 @@ function baseCollection() {
|
|
|
21
21
|
},
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
|
-
// Build a minimal IDbAdapter. We only wire the methods `ensureCollections`
|
|
25
|
-
// actually calls; the others throw if touched so we catch accidental usage.
|
|
26
24
|
function createMockDb(options) {
|
|
27
|
-
const getCollectionByPath = vi.fn(
|
|
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);
|
|
@@ -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)
|
|
@@ -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 {
|
|
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
|
|
90
|
-
//
|
|
91
|
-
// (afterCreate covers invalidation).
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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
|
|
10
|
+
export type DeleteDocumentOutcome = 'committed' | 'committed-with-side-effect-failures';
|
|
11
|
+
export type DeleteDocumentSideEffectPhase = 'storageCleanup' | 'afterTreeChange' | 'afterDelete';
|
|
12
|
+
export type DeleteDocumentSideEffectCode = typeof ErrorCodes.STORAGE | typeof ErrorCodes.UNHANDLED;
|
|
13
|
+
export interface DeleteDocumentSideEffectFailure {
|
|
14
|
+
phase: DeleteDocumentSideEffectPhase;
|
|
15
|
+
code: DeleteDocumentSideEffectCode;
|
|
16
|
+
}
|
|
17
|
+
export interface DeleteDocumentCommittedResult {
|
|
18
|
+
deletedVersionCount: number;
|
|
19
|
+
outcome: 'committed';
|
|
20
|
+
sideEffectFailures: [];
|
|
21
|
+
}
|
|
22
|
+
export interface DeleteDocumentCommittedWithSideEffectFailuresResult {
|
|
10
23
|
deletedVersionCount: number;
|
|
24
|
+
outcome: 'committed-with-side-effect-failures';
|
|
25
|
+
sideEffectFailures: [DeleteDocumentSideEffectFailure, ...DeleteDocumentSideEffectFailure[]];
|
|
11
26
|
}
|
|
27
|
+
export type DeleteDocumentResult = DeleteDocumentCommittedResult | DeleteDocumentCommittedWithSideEffectFailuresResult;
|
|
12
28
|
/**
|
|
13
29
|
* Soft-delete a document.
|
|
14
30
|
*
|
|
@@ -7,13 +7,31 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { resolveHooks } from '../../@types/index.js';
|
|
9
9
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
10
|
-
import { ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
10
|
+
import { ERR_NOT_FOUND, ErrorCodes } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { hasUploadField, isUploadField } from '../../utils/storage-utils.js';
|
|
13
13
|
import { walkFieldTree } from '../walk-field-tree.js';
|
|
14
|
-
import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
|
|
14
|
+
import { AUDIT_ACTIONS, auditActor, requireAuditCapability, requireTreeAuditCapability, } from './audit.js';
|
|
15
15
|
import { invokeHook } from './internals.js';
|
|
16
|
-
import {
|
|
16
|
+
import { firePromoteTreeChange, reconcileTreeOnDeleteInTransaction } from './tree.js';
|
|
17
|
+
function readErrorCode(error) {
|
|
18
|
+
try {
|
|
19
|
+
if ((typeof error !== 'object' || error === null) && typeof error !== 'function') {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const value = Reflect.get(error, 'code');
|
|
23
|
+
return typeof value === 'string' ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function serializeSideEffectFailure(phase, error) {
|
|
30
|
+
return {
|
|
31
|
+
phase,
|
|
32
|
+
code: readErrorCode(error) === ErrorCodes.STORAGE ? ErrorCodes.STORAGE : ErrorCodes.UNHANDLED,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
17
35
|
/**
|
|
18
36
|
* Soft-delete a document.
|
|
19
37
|
*
|
|
@@ -47,7 +65,8 @@ export async function deleteDocument(ctx, params) {
|
|
|
47
65
|
// AND a storage provider, fetch with reconstruct: true so we
|
|
48
66
|
// can read the stored file paths (and persisted variant paths)
|
|
49
67
|
// from the field values before the DB rows are deleted.
|
|
50
|
-
const
|
|
68
|
+
const storage = ctx.storage;
|
|
69
|
+
const isUploadCollection = hasUploadField(definition) && storage != null;
|
|
51
70
|
const latest = await db.queries.documents.getDocumentById({
|
|
52
71
|
collection_id: ctx.collectionId,
|
|
53
72
|
document_id: params.documentId,
|
|
@@ -99,15 +118,20 @@ export async function deleteDocument(ctx, params) {
|
|
|
99
118
|
};
|
|
100
119
|
// 2. beforeDelete hook.
|
|
101
120
|
await invokeHook(hooks?.beforeDelete, hookCtx);
|
|
102
|
-
// 3. Soft-delete all versions
|
|
121
|
+
// 3. Soft-delete all versions atomically with the document audit and,
|
|
122
|
+
// for tree collections, locked child promotion/removal plus every
|
|
123
|
+
// parent/child tree audit row. Any failure rolls the entire delete
|
|
124
|
+
// back, so soft-deleted documents cannot leak live edges.
|
|
103
125
|
// whole-document delete mints no new version, so the version stream
|
|
104
126
|
// never records it — the audit log is the only place a deletion is
|
|
105
127
|
// accountable (docs/06-auth-and-security/02-auditability.md). Storage-file cleanup (step 4) is a
|
|
106
128
|
// DB↔external side-effect and stays OUTSIDE the transaction — it is
|
|
107
129
|
// post-commit, best-effort compensation (docs/03-architecture/03-transactions.md).
|
|
108
|
-
const
|
|
130
|
+
const treeAudit = definition.tree === true ? requireTreeAuditCapability(db) : undefined;
|
|
131
|
+
const audit = treeAudit ?? requireAuditCapability(db);
|
|
109
132
|
const actor = auditActor(ctx);
|
|
110
133
|
let deletedVersionCount = 0;
|
|
134
|
+
let treeResult;
|
|
111
135
|
await audit.withTransaction(async () => {
|
|
112
136
|
deletedVersionCount = await db.commands.documents.softDeleteDocument({
|
|
113
137
|
document_id: params.documentId,
|
|
@@ -119,36 +143,77 @@ export async function deleteDocument(ctx, params) {
|
|
|
119
143
|
actorRealm: actor.actorRealm,
|
|
120
144
|
action: AUDIT_ACTIONS.deleted,
|
|
121
145
|
});
|
|
146
|
+
if (treeAudit != null) {
|
|
147
|
+
treeResult = await reconcileTreeOnDeleteInTransaction(ctx, params.documentId, treeAudit);
|
|
148
|
+
}
|
|
122
149
|
});
|
|
123
|
-
//
|
|
124
|
-
|
|
150
|
+
// Everything below is post-commit. Each operation and the logger get an
|
|
151
|
+
// independent attempt; none can turn the committed delete into a rejection.
|
|
152
|
+
const sideEffectFailures = [];
|
|
153
|
+
// 4. Clean up every storage file. Returned failures omit paths, while
|
|
154
|
+
// internal logs retain the target needed for operational reconciliation.
|
|
155
|
+
if (storage && storagePathsToDelete.length > 0) {
|
|
125
156
|
for (const storagePath of storagePathsToDelete) {
|
|
126
157
|
try {
|
|
127
|
-
await
|
|
158
|
+
await storage.delete(storagePath);
|
|
128
159
|
}
|
|
129
|
-
catch (
|
|
130
|
-
|
|
160
|
+
catch (error) {
|
|
161
|
+
sideEffectFailures.push(serializeSideEffectFailure('storageCleanup', error));
|
|
162
|
+
try {
|
|
163
|
+
logger.error({ err: error, documentId: params.documentId, storagePath }, 'failed to delete storage file');
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Diagnostic logging must not interrupt the remaining cleanup attempts.
|
|
167
|
+
}
|
|
131
168
|
}
|
|
132
169
|
}
|
|
133
170
|
}
|
|
134
|
-
// 5.
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
171
|
+
// 5-6. Both post-commit hook families get an independent attempt. A tree
|
|
172
|
+
// invalidation failure must not prevent afterDelete consumers (search,
|
|
173
|
+
// cache removal) from running, or vice versa.
|
|
174
|
+
try {
|
|
175
|
+
if (treeResult != null) {
|
|
176
|
+
await firePromoteTreeChange(ctx, params.documentId, treeResult);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
sideEffectFailures.push(serializeSideEffectFailure('afterTreeChange', error));
|
|
181
|
+
try {
|
|
182
|
+
logger.error({ err: error, documentId: params.documentId }, 'afterTreeChange hook failed after document delete');
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Diagnostic logging must not affect the committed result.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
await invokeHook(hooks?.afterDelete, hookCtx);
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
sideEffectFailures.push(serializeSideEffectFailure('afterDelete', error));
|
|
193
|
+
try {
|
|
194
|
+
logger.error({ err: error, documentId: params.documentId }, 'afterDelete hook failed after document delete');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Diagnostic logging must not affect the committed result.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const [firstFailure, ...remainingFailures] = sideEffectFailures;
|
|
201
|
+
if (firstFailure != null) {
|
|
143
202
|
try {
|
|
144
|
-
|
|
203
|
+
logger.error({
|
|
204
|
+
documentId: params.documentId,
|
|
205
|
+
sideEffectFailures: [firstFailure, ...remainingFailures],
|
|
206
|
+
}, 'post-commit delete side effects failed');
|
|
145
207
|
}
|
|
146
|
-
catch
|
|
147
|
-
|
|
208
|
+
catch {
|
|
209
|
+
// A reporting failure cannot change the already-committed outcome.
|
|
148
210
|
}
|
|
211
|
+
return {
|
|
212
|
+
deletedVersionCount,
|
|
213
|
+
outcome: 'committed-with-side-effect-failures',
|
|
214
|
+
sideEffectFailures: [firstFailure, ...remainingFailures],
|
|
215
|
+
};
|
|
149
216
|
}
|
|
150
|
-
|
|
151
|
-
await invokeHook(hooks?.afterDelete, hookCtx);
|
|
152
|
-
return { deletedVersionCount };
|
|
217
|
+
return { deletedVersionCount, outcome: 'committed', sideEffectFailures: [] };
|
|
153
218
|
});
|
|
154
219
|
}
|
|
@@ -35,7 +35,7 @@ export { updateDocument, updateDocumentWithPatches } from './update.js';
|
|
|
35
35
|
export type { DocumentLifecycleContext } from './context.js';
|
|
36
36
|
export type { CopyToLocaleResult } from './copy-to-locale.js';
|
|
37
37
|
export type { CreateDocumentResult } from './create.js';
|
|
38
|
-
export type { DeleteDocumentResult } from './delete.js';
|
|
38
|
+
export type { DeleteDocumentCommittedResult, DeleteDocumentCommittedWithSideEffectFailuresResult, DeleteDocumentOutcome, DeleteDocumentResult, DeleteDocumentSideEffectCode, DeleteDocumentSideEffectFailure, DeleteDocumentSideEffectPhase, } from './delete.js';
|
|
39
39
|
export type { DeleteLocaleResult } from './delete-locale.js';
|
|
40
40
|
export type { DuplicateDocumentResult } from './duplicate.js';
|
|
41
41
|
export type { RestoreVersionResult } from './restore.js';
|
|
@@ -59,26 +59,6 @@ export declare function applyRichTextEmbed(ctx: DocumentLifecycleContext, data:
|
|
|
59
59
|
* gets `order_key = NULL` and the existing "no ordering" behavior holds.
|
|
60
60
|
*/
|
|
61
61
|
export declare function maybeAppendOrderKey(ctx: DocumentLifecycleContext, collectionPath: string): Promise<string | undefined>;
|
|
62
|
-
/**
|
|
63
|
-
* Append a document as the **last root** of its `tree: true` collection's tree.
|
|
64
|
-
* Mints a fresh root-group `order_key` after the current trailing root. Used by
|
|
65
|
-
* create's auto-place and update's self-heal so a tree collection never strands a
|
|
66
|
-
* document in the "unplaced" limbo. Issues the storage command directly — the
|
|
67
|
-
* caller has already asserted the relevant ability and this is a system step.
|
|
68
|
-
*/
|
|
69
|
-
export declare function appendTreeRoot(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
|
|
70
|
-
/**
|
|
71
|
-
* Self-heal a genuinely-*unplaced* document on update: if the collection is a
|
|
72
|
-
* tree and the document has no edge row, append it as a root (mirroring create's
|
|
73
|
-
* auto-place). `getTreeParent` distinguishes unplaced from root, so an existing
|
|
74
|
-
* root or child is left exactly where it is — only strays (e.g. docs created
|
|
75
|
-
* before the flag, or whose create-time auto-place failed) are re-treed.
|
|
76
|
-
*
|
|
77
|
-
* No-op for non-tree collections. Best-effort and post-version: a failure leaves
|
|
78
|
-
* the document saved-but-unplaced and is logged, never thrown. See
|
|
79
|
-
* docs/04-collections/03-document-trees.md.
|
|
80
|
-
*/
|
|
81
|
-
export declare function selfHealTreePlacement(ctx: DocumentLifecycleContext, documentId: string): Promise<void>;
|
|
82
62
|
/** Extract `id` from the document object returned by `createDocumentVersion`. */
|
|
83
63
|
export declare function extractVersionId(document: any): string;
|
|
84
64
|
/** Extract the logical document id from the document object returned by `createDocumentVersion`. */
|