@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,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`. */
|
|
@@ -17,6 +17,7 @@ import { ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
|
|
|
17
17
|
import { generateKeyBetween } from '../../lib/fractional-index.js';
|
|
18
18
|
import { createReadContext } from '../populate.js';
|
|
19
19
|
import { embedRichTextFields } from '../richtext-embed.js';
|
|
20
|
+
import { createRichTextDocumentReader } from '../richtext-populate.js';
|
|
20
21
|
/**
|
|
21
22
|
* Matches a canonical UUID (any version). Real admin / end-user actors carry
|
|
22
23
|
* UUID ids (`uuidv7`); synthetic actors do not.
|
|
@@ -70,20 +71,38 @@ export async function applyRichTextEmbed(ctx, data) {
|
|
|
70
71
|
// `initBylineCore()` (unit tests, isolated tooling) — they have no
|
|
71
72
|
// adapter to invoke, so this is a soft no-op.
|
|
72
73
|
let embed;
|
|
74
|
+
let richTextPopulate;
|
|
75
|
+
let collections;
|
|
73
76
|
try {
|
|
74
|
-
|
|
77
|
+
const config = getServerConfig();
|
|
78
|
+
embed = config.fields?.richText?.embed;
|
|
79
|
+
richTextPopulate = config.fields?.richText?.populate;
|
|
80
|
+
collections = config.collections;
|
|
75
81
|
}
|
|
76
82
|
catch {
|
|
77
83
|
return;
|
|
78
84
|
}
|
|
79
|
-
if (embed == null)
|
|
85
|
+
if (embed == null || ctx.requestContext == null)
|
|
80
86
|
return;
|
|
87
|
+
const readContext = createReadContext();
|
|
88
|
+
const requestContext = { ...ctx.requestContext, readMode: 'published' };
|
|
81
89
|
await embedRichTextFields({
|
|
82
90
|
fields: ctx.definition.fields,
|
|
83
91
|
collectionPath: ctx.collectionPath,
|
|
84
92
|
data,
|
|
85
93
|
embed,
|
|
86
|
-
readContext
|
|
94
|
+
readContext,
|
|
95
|
+
requestContext,
|
|
96
|
+
readMode: 'published',
|
|
97
|
+
readDocuments: createRichTextDocumentReader({
|
|
98
|
+
db: ctx.db,
|
|
99
|
+
collections,
|
|
100
|
+
requestContext,
|
|
101
|
+
readContext,
|
|
102
|
+
readMode: 'published',
|
|
103
|
+
locale: ctx.defaultLocale,
|
|
104
|
+
richTextPopulate,
|
|
105
|
+
}),
|
|
87
106
|
logger: ctx.logger,
|
|
88
107
|
});
|
|
89
108
|
}
|
|
@@ -103,49 +122,6 @@ export async function maybeAppendOrderKey(ctx, collectionPath) {
|
|
|
103
122
|
});
|
|
104
123
|
return generateKeyBetween(last, null);
|
|
105
124
|
}
|
|
106
|
-
/**
|
|
107
|
-
* Append a document as the **last root** of its `tree: true` collection's tree.
|
|
108
|
-
* Mints a fresh root-group `order_key` after the current trailing root. Used by
|
|
109
|
-
* create's auto-place and update's self-heal so a tree collection never strands a
|
|
110
|
-
* document in the "unplaced" limbo. Issues the storage command directly — the
|
|
111
|
-
* caller has already asserted the relevant ability and this is a system step.
|
|
112
|
-
*/
|
|
113
|
-
export async function appendTreeRoot(ctx, documentId) {
|
|
114
|
-
const roots = await ctx.db.queries.documents.getTreeChildren({
|
|
115
|
-
collectionId: ctx.collectionId,
|
|
116
|
-
parentDocumentId: null,
|
|
117
|
-
});
|
|
118
|
-
await ctx.db.commands.documents.placeTreeNode({
|
|
119
|
-
collectionId: ctx.collectionId,
|
|
120
|
-
documentId,
|
|
121
|
-
parentDocumentId: null,
|
|
122
|
-
beforeDocumentId: roots.at(-1)?.document_id ?? null,
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Self-heal a genuinely-*unplaced* document on update: if the collection is a
|
|
127
|
-
* tree and the document has no edge row, append it as a root (mirroring create's
|
|
128
|
-
* auto-place). `getTreeParent` distinguishes unplaced from root, so an existing
|
|
129
|
-
* root or child is left exactly where it is — only strays (e.g. docs created
|
|
130
|
-
* before the flag, or whose create-time auto-place failed) are re-treed.
|
|
131
|
-
*
|
|
132
|
-
* No-op for non-tree collections. Best-effort and post-version: a failure leaves
|
|
133
|
-
* the document saved-but-unplaced and is logged, never thrown. See
|
|
134
|
-
* docs/04-collections/03-document-trees.md.
|
|
135
|
-
*/
|
|
136
|
-
export async function selfHealTreePlacement(ctx, documentId) {
|
|
137
|
-
if (ctx.definition.tree !== true)
|
|
138
|
-
return;
|
|
139
|
-
try {
|
|
140
|
-
const { placed } = await ctx.db.queries.documents.getTreeParent({ document_id: documentId });
|
|
141
|
-
if (placed)
|
|
142
|
-
return;
|
|
143
|
-
await appendTreeRoot(ctx, documentId);
|
|
144
|
-
}
|
|
145
|
-
catch (err) {
|
|
146
|
-
ctx.logger.error({ err, documentId }, 'failed to self-heal tree placement on update');
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
125
|
/** Extract `id` from the document object returned by `createDocumentVersion`. */
|
|
150
126
|
export function extractVersionId(document) {
|
|
151
127
|
return document?.id ?? document?.document_version_id ?? '';
|
|
@@ -33,7 +33,7 @@ export declare function changeDocumentStatus(ctx: DocumentLifecycleContext, para
|
|
|
33
33
|
*
|
|
34
34
|
* Flow:
|
|
35
35
|
* 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
|
|
36
|
-
* 2.
|
|
36
|
+
* 2. Archive published versions and append the status audit atomically
|
|
37
37
|
* 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
|
|
38
38
|
*/
|
|
39
39
|
export declare function unpublishDocument(ctx: DocumentLifecycleContext, params: {
|
|
@@ -124,7 +124,7 @@ export async function changeDocumentStatus(ctx, params) {
|
|
|
124
124
|
*
|
|
125
125
|
* Flow:
|
|
126
126
|
* 1. `hooks.beforeUnpublish({ documentId, collectionPath })`
|
|
127
|
-
* 2.
|
|
127
|
+
* 2. Archive published versions and append the status audit atomically
|
|
128
128
|
* 3. `hooks.afterUnpublish({ documentId, collectionPath, archivedCount })`
|
|
129
129
|
*/
|
|
130
130
|
export async function unpublishDocument(ctx, params) {
|
|
@@ -153,8 +153,25 @@ export async function unpublishDocument(ctx, params) {
|
|
|
153
153
|
collectionPath,
|
|
154
154
|
path,
|
|
155
155
|
});
|
|
156
|
-
const
|
|
157
|
-
|
|
156
|
+
const audit = requireAuditCapability(db);
|
|
157
|
+
const actor = auditActor(ctx);
|
|
158
|
+
const archivedCount = await audit.withTransaction(async () => {
|
|
159
|
+
const count = await db.commands.documents.archivePublishedVersions({
|
|
160
|
+
document_id: params.documentId,
|
|
161
|
+
});
|
|
162
|
+
if (count > 0) {
|
|
163
|
+
await audit.append({
|
|
164
|
+
documentId: params.documentId,
|
|
165
|
+
collectionId,
|
|
166
|
+
actorId: actor.actorId,
|
|
167
|
+
actorRealm: actor.actorRealm,
|
|
168
|
+
action: AUDIT_ACTIONS.statusChanged,
|
|
169
|
+
field: 'status',
|
|
170
|
+
before: 'published',
|
|
171
|
+
after: 'archived',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return count;
|
|
158
175
|
});
|
|
159
176
|
await invokeHook(hooks?.afterUnpublish, {
|
|
160
177
|
documentId: params.documentId,
|
|
@@ -10,7 +10,13 @@ export interface UpdateDocumentSystemFieldsResult {
|
|
|
10
10
|
documentId: string;
|
|
11
11
|
/** The path actually written, or `undefined` when no path write occurred. */
|
|
12
12
|
path?: string;
|
|
13
|
-
/** Whether
|
|
13
|
+
/** Whether either system field actually changed. */
|
|
14
|
+
changed: boolean;
|
|
15
|
+
/** Whether a no-op request emitted the reconciliation hook. */
|
|
16
|
+
reconciliation: boolean;
|
|
17
|
+
pathChanged: boolean;
|
|
18
|
+
availableLocalesChanged: boolean;
|
|
19
|
+
/** Whether the advertised-locale set was actually rewritten this call. */
|
|
14
20
|
availableLocalesWritten: boolean;
|
|
15
21
|
}
|
|
16
22
|
/**
|
|
@@ -29,7 +35,8 @@ export interface UpdateDocumentSystemFieldsResult {
|
|
|
29
35
|
*
|
|
30
36
|
* Flow:
|
|
31
37
|
* 1. `assertActorCanPerform('update')` — same auth gate as content writes.
|
|
32
|
-
* 2.
|
|
38
|
+
* 2. Inside the audit transaction, lock the logical document and read its
|
|
39
|
+
* authoritative source locale, path, and advertised locales.
|
|
33
40
|
* 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
|
|
34
41
|
* rule (translation-locale path edits are dropped with a warn); a real
|
|
35
42
|
* change is written via `updateDocumentPath`, mapping the unique-constraint
|
|
@@ -37,10 +44,14 @@ export interface UpdateDocumentSystemFieldsResult {
|
|
|
37
44
|
* 4. `availableLocales` (when supplied): rewritten wholesale via
|
|
38
45
|
* `setDocumentAvailableLocales`.
|
|
39
46
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
47
|
+
* Content hooks do not fire because these are not content writes. Actual
|
|
48
|
+
* changes emit `afterSystemFieldsChange` after the audited write commits. A
|
|
49
|
+
* caller can pass `reconcile: true` to emit the same hook for a no-op retry
|
|
50
|
+
* after an earlier post-commit hook failure. Hook failures reject the call but
|
|
51
|
+
* never roll back the already-committed write/audit.
|
|
52
|
+
* Accountability for these mutations is the document-grain audit log: each
|
|
53
|
+
* changed field records a `document.path.changed` /
|
|
54
|
+
* `document.locales.changed` row atomically with the write.
|
|
44
55
|
*
|
|
45
56
|
* @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
|
|
46
57
|
* @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
|
|
@@ -61,4 +72,6 @@ export declare function updateDocumentSystemFields(ctx: DocumentLifecycleContext
|
|
|
61
72
|
* included — replaces the set wholesale.
|
|
62
73
|
*/
|
|
63
74
|
availableLocales?: string[];
|
|
75
|
+
/** Re-run `afterSystemFieldsChange` when requested values are already current. */
|
|
76
|
+
reconcile?: boolean;
|
|
64
77
|
}): Promise<UpdateDocumentSystemFieldsResult>;
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { resolveHooks } from '../../@types/index.js';
|
|
8
9
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
9
|
-
import { ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
10
|
+
import { ERR_AUDIT_UNSUPPORTED, ERR_NOT_FOUND } from '../../lib/errors.js';
|
|
10
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
11
12
|
import { AUDIT_ACTIONS, auditActor, requireAuditCapability, sameLocaleSet } from './audit.js';
|
|
12
|
-
import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
|
|
13
|
+
import { invokeHook, resolvePathForUpdate, rethrowPathConflict } from './internals.js';
|
|
13
14
|
/**
|
|
14
15
|
* Write a document's system-managed, document-grain fields — `path` and the
|
|
15
16
|
* editorial `availableLocales` set — **without** minting a new version or
|
|
@@ -26,7 +27,8 @@ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
|
|
|
26
27
|
*
|
|
27
28
|
* Flow:
|
|
28
29
|
* 1. `assertActorCanPerform('update')` — same auth gate as content writes.
|
|
29
|
-
* 2.
|
|
30
|
+
* 2. Inside the audit transaction, lock the logical document and read its
|
|
31
|
+
* authoritative source locale, path, and advertised locales.
|
|
30
32
|
* 3. Path (when supplied): `resolvePathForUpdate` enforces the source-locale
|
|
31
33
|
* rule (translation-locale path edits are dropped with a warn); a real
|
|
32
34
|
* change is written via `updateDocumentPath`, mapping the unique-constraint
|
|
@@ -34,58 +36,67 @@ import { resolvePathForUpdate, rethrowPathConflict } from './internals.js';
|
|
|
34
36
|
* 4. `availableLocales` (when supplied): rewritten wholesale via
|
|
35
37
|
* `setDocumentAvailableLocales`.
|
|
36
38
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
39
|
+
* Content hooks do not fire because these are not content writes. Actual
|
|
40
|
+
* changes emit `afterSystemFieldsChange` after the audited write commits. A
|
|
41
|
+
* caller can pass `reconcile: true` to emit the same hook for a no-op retry
|
|
42
|
+
* after an earlier post-commit hook failure. Hook failures reject the call but
|
|
43
|
+
* never roll back the already-committed write/audit.
|
|
44
|
+
* Accountability for these mutations is the document-grain audit log: each
|
|
45
|
+
* changed field records a `document.path.changed` /
|
|
46
|
+
* `document.locales.changed` row atomically with the write.
|
|
41
47
|
*
|
|
42
48
|
* @throws {BylineError} ERR_NOT_FOUND if the document does not exist.
|
|
43
49
|
* @throws {BylineError} ERR_PATH_CONFLICT if the path is already in use.
|
|
44
50
|
*/
|
|
45
51
|
export async function updateDocumentSystemFields(ctx, params) {
|
|
46
52
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentSystemFields' }, async () => {
|
|
47
|
-
const { db, collectionId, collectionPath, defaultLocale } = ctx;
|
|
53
|
+
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
48
54
|
assertActorCanPerform(ctx.requestContext, collectionPath, 'update');
|
|
49
55
|
const requestLocale = params.locale ?? defaultLocale;
|
|
50
|
-
// Resolve the document's source-locale anchor + current path. Both feed
|
|
51
|
-
// the path source-locale guard below; the fetch also asserts existence.
|
|
52
|
-
const latest = await db.queries.documents.getDocumentById({
|
|
53
|
-
collection_id: collectionId,
|
|
54
|
-
document_id: params.documentId,
|
|
55
|
-
locale: requestLocale,
|
|
56
|
-
reconstruct: true,
|
|
57
|
-
});
|
|
58
|
-
if (latest == null) {
|
|
59
|
-
throw ERR_NOT_FOUND({
|
|
60
|
-
message: 'document not found',
|
|
61
|
-
details: { documentId: params.documentId },
|
|
62
|
-
}).log(ctx.logger);
|
|
63
|
-
}
|
|
64
|
-
const originalData = latest;
|
|
65
|
-
const sourceLocale = originalData.source_locale ?? defaultLocale;
|
|
66
|
-
// Path: honour the same source-locale-only rule the versioned write
|
|
67
|
-
// uses. `resolvePathForUpdate` returns `undefined` to mean "skip the
|
|
68
|
-
// write" (null/empty override, or a translation-locale save).
|
|
69
56
|
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
70
|
-
const
|
|
71
|
-
explicitPath,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
documentId: params.documentId,
|
|
76
|
-
logger: ctx.logger,
|
|
77
|
-
});
|
|
78
|
-
// Both document-grain writes and their audit rows commit atomically.
|
|
79
|
-
// These fields are non-versioned, so the version stream never records
|
|
80
|
-
// them — the audit log is their only accountability home. One audit row
|
|
81
|
-
// per field that actually changed (docs/06-auth-and-security/02-auditability.md).
|
|
82
|
-
const currentPath = originalData.path;
|
|
83
|
-
const currentLocales = originalData.availableLocales ?? [];
|
|
84
|
-
const availableLocalesWritten = params.availableLocales !== undefined;
|
|
57
|
+
const requested = {
|
|
58
|
+
path: explicitPath !== null,
|
|
59
|
+
availableLocales: params.availableLocales !== undefined,
|
|
60
|
+
};
|
|
61
|
+
const requestedLocales = params.availableLocales === undefined ? undefined : [...new Set(params.availableLocales)];
|
|
85
62
|
const audit = requireAuditCapability(db);
|
|
63
|
+
const getDocumentSystemFieldsForUpdate = db.queries.documents.getDocumentSystemFieldsForUpdate;
|
|
64
|
+
if (typeof getDocumentSystemFieldsForUpdate !== 'function') {
|
|
65
|
+
throw ERR_AUDIT_UNSUPPORTED({
|
|
66
|
+
message: 'audited system-field writes require a transaction-scoped lock/read capability',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const lockSystemFields = getDocumentSystemFieldsForUpdate.bind(db.queries.documents);
|
|
86
70
|
const actor = auditActor(ctx);
|
|
87
|
-
|
|
88
|
-
|
|
71
|
+
// The logical document row is the mutex for both document-grain fields.
|
|
72
|
+
// Reading after that lock prevents concurrent writers from auditing a
|
|
73
|
+
// stale before value or omitting an intermediate old path from the event.
|
|
74
|
+
const outcome = await audit.withTransaction(async () => {
|
|
75
|
+
const snapshot = await lockSystemFields({
|
|
76
|
+
collection_id: collectionId,
|
|
77
|
+
document_id: params.documentId,
|
|
78
|
+
});
|
|
79
|
+
if (snapshot == null) {
|
|
80
|
+
throw ERR_NOT_FOUND({
|
|
81
|
+
message: 'document not found',
|
|
82
|
+
details: { documentId: params.documentId },
|
|
83
|
+
}).log(ctx.logger);
|
|
84
|
+
}
|
|
85
|
+
const sourceLocale = snapshot.source_locale ?? defaultLocale;
|
|
86
|
+
const currentPath = snapshot.path ?? undefined;
|
|
87
|
+
const currentLocales = snapshot.availableLocales;
|
|
88
|
+
const nextLocales = requestedLocales ?? currentLocales;
|
|
89
|
+
const pathForCommand = resolvePathForUpdate({
|
|
90
|
+
explicitPath,
|
|
91
|
+
currentPath,
|
|
92
|
+
requestLocale,
|
|
93
|
+
sourceLocale,
|
|
94
|
+
documentId: params.documentId,
|
|
95
|
+
logger: ctx.logger,
|
|
96
|
+
});
|
|
97
|
+
const pathChanged = pathForCommand !== undefined && pathForCommand !== currentPath;
|
|
98
|
+
const availableLocalesChanged = requestedLocales !== undefined && !sameLocaleSet(currentLocales, nextLocales);
|
|
99
|
+
if (pathChanged) {
|
|
89
100
|
await db.commands.documents
|
|
90
101
|
.updateDocumentPath({
|
|
91
102
|
documentId: params.documentId,
|
|
@@ -93,45 +104,72 @@ export async function updateDocumentSystemFields(ctx, params) {
|
|
|
93
104
|
locale: sourceLocale,
|
|
94
105
|
path: pathForCommand,
|
|
95
106
|
})
|
|
96
|
-
.catch((err) => rethrowPathConflict(err, pathForCommand,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
});
|
|
108
|
-
}
|
|
107
|
+
.catch((err) => rethrowPathConflict(err, pathForCommand, sourceLocale));
|
|
108
|
+
await audit.append({
|
|
109
|
+
documentId: params.documentId,
|
|
110
|
+
collectionId,
|
|
111
|
+
actorId: actor.actorId,
|
|
112
|
+
actorRealm: actor.actorRealm,
|
|
113
|
+
action: AUDIT_ACTIONS.pathChanged,
|
|
114
|
+
field: 'path',
|
|
115
|
+
before: currentPath ?? null,
|
|
116
|
+
after: pathForCommand,
|
|
117
|
+
});
|
|
109
118
|
}
|
|
110
|
-
|
|
111
|
-
if (params.availableLocales !== undefined) {
|
|
119
|
+
if (availableLocalesChanged) {
|
|
112
120
|
await db.commands.documents.setDocumentAvailableLocales({
|
|
113
121
|
documentId: params.documentId,
|
|
114
122
|
collectionId,
|
|
115
|
-
availableLocales:
|
|
123
|
+
availableLocales: nextLocales,
|
|
124
|
+
});
|
|
125
|
+
await audit.append({
|
|
126
|
+
documentId: params.documentId,
|
|
127
|
+
collectionId,
|
|
128
|
+
actorId: actor.actorId,
|
|
129
|
+
actorRealm: actor.actorRealm,
|
|
130
|
+
action: AUDIT_ACTIONS.localesChanged,
|
|
131
|
+
field: 'availableLocales',
|
|
132
|
+
before: currentLocales,
|
|
133
|
+
after: nextLocales,
|
|
116
134
|
});
|
|
117
|
-
if (!sameLocaleSet(currentLocales, params.availableLocales)) {
|
|
118
|
-
await audit.append({
|
|
119
|
-
documentId: params.documentId,
|
|
120
|
-
collectionId,
|
|
121
|
-
actorId: actor.actorId,
|
|
122
|
-
actorRealm: actor.actorRealm,
|
|
123
|
-
action: AUDIT_ACTIONS.localesChanged,
|
|
124
|
-
field: 'availableLocales',
|
|
125
|
-
before: currentLocales,
|
|
126
|
-
after: params.availableLocales,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
135
|
}
|
|
136
|
+
return {
|
|
137
|
+
pathForCommand,
|
|
138
|
+
pathChanged,
|
|
139
|
+
availableLocalesChanged,
|
|
140
|
+
previousPath: currentPath,
|
|
141
|
+
currentPath: pathChanged ? pathForCommand : currentPath,
|
|
142
|
+
previousAvailableLocales: [...currentLocales],
|
|
143
|
+
currentAvailableLocales: [...nextLocales],
|
|
144
|
+
};
|
|
130
145
|
});
|
|
146
|
+
const changed = outcome.pathChanged || outcome.availableLocalesChanged;
|
|
147
|
+
const reconciliation = !changed && params.reconcile === true;
|
|
148
|
+
if (changed || reconciliation) {
|
|
149
|
+
const hooks = await resolveHooks(definition);
|
|
150
|
+
await invokeHook(hooks?.afterSystemFieldsChange, {
|
|
151
|
+
documentId: params.documentId,
|
|
152
|
+
collectionPath,
|
|
153
|
+
requested,
|
|
154
|
+
changed: {
|
|
155
|
+
path: outcome.pathChanged,
|
|
156
|
+
availableLocales: outcome.availableLocalesChanged,
|
|
157
|
+
},
|
|
158
|
+
reconciliation,
|
|
159
|
+
previousPath: outcome.previousPath,
|
|
160
|
+
currentPath: outcome.currentPath,
|
|
161
|
+
previousAvailableLocales: outcome.previousAvailableLocales,
|
|
162
|
+
currentAvailableLocales: outcome.currentAvailableLocales,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
131
165
|
return {
|
|
132
166
|
documentId: params.documentId,
|
|
133
|
-
path: pathForCommand,
|
|
134
|
-
|
|
167
|
+
path: outcome.pathChanged ? outcome.pathForCommand : undefined,
|
|
168
|
+
changed,
|
|
169
|
+
reconciliation,
|
|
170
|
+
pathChanged: outcome.pathChanged,
|
|
171
|
+
availableLocalesChanged: outcome.availableLocalesChanged,
|
|
172
|
+
availableLocalesWritten: outcome.availableLocalesChanged,
|
|
135
173
|
};
|
|
136
174
|
});
|
|
137
175
|
}
|