@byline/core 4.15.0 → 4.17.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/admin-types.d.ts +86 -31
- package/dist/@types/admin-types.js +12 -0
- package/dist/@types/collection-types.d.ts +211 -29
- package/dist/@types/collection-types.js +68 -8
- package/dist/@types/db-types.d.ts +50 -0
- package/dist/@types/form-admin-config.test.node.d.ts +6 -0
- package/dist/@types/form-admin-config.test.node.js +26 -0
- package/dist/@types/singleton-admin-config.test.node.js +66 -0
- package/dist/@types/singleton-definition.test.node.d.ts +8 -0
- package/dist/@types/singleton-definition.test.node.js +75 -0
- package/dist/@types/site-config.d.ts +9 -8
- package/dist/auth/apply-before-read.d.ts +2 -1
- package/dist/auth/apply-before-read.js +13 -3
- package/dist/auth/apply-before-read.test.node.js +44 -0
- package/dist/auth/assert-actor-can-perform.d.ts +4 -3
- package/dist/auth/assert-actor-can-perform.js +9 -7
- package/dist/auth/assert-actor-can-perform.test.node.js +32 -11
- package/dist/auth/filter-readable-collections.d.ts +6 -6
- package/dist/auth/filter-readable-collections.js +8 -8
- package/dist/auth/filter-readable-collections.test.node.js +26 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/register-collection-abilities.d.ts +34 -30
- package/dist/auth/register-collection-abilities.js +81 -77
- package/dist/auth/register-collection-abilities.test.node.js +86 -8
- package/dist/codegen/fixtures/all-fields.expected.d.ts +3 -0
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/index.js +32 -16
- package/dist/codegen/index.test.node.js +35 -2
- package/dist/config/attach-hooks.d.ts +3 -3
- package/dist/config/attach-hooks.js +8 -0
- package/dist/config/attach-hooks.test.node.js +59 -0
- package/dist/config/config-hooks.test.node.js +34 -1
- package/dist/config/config.d.ts +2 -1
- package/dist/config/config.js +9 -1
- package/dist/config/group-collections.d.ts +10 -9
- package/dist/config/group-collections.js +4 -4
- package/dist/config/group-collections.test.node.js +14 -0
- package/dist/config/validate-admin-configs.d.ts +4 -4
- package/dist/config/validate-admin-configs.js +40 -7
- package/dist/config/validate-admin-configs.test.node.js +44 -0
- package/dist/config/validate-collections.js +43 -0
- package/dist/config/validate-collections.test.node.js +75 -0
- package/dist/core.d.ts +2 -3
- package/dist/core.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/lib/errors.d.ts +5 -3
- package/dist/lib/errors.js +1 -0
- package/dist/services/collection-bootstrap.js +12 -0
- package/dist/services/collection-bootstrap.test.node.js +63 -6
- package/dist/services/discover-counter-groups.test.node.js +6 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +9 -17
- package/dist/services/document-lifecycle/create.js +6 -11
- package/dist/services/document-lifecycle/delete-locale.js +1 -1
- package/dist/services/document-lifecycle/delete.js +1 -1
- package/dist/services/document-lifecycle/duplicate.js +7 -18
- package/dist/services/document-lifecycle/persistence.d.ts +29 -0
- package/dist/services/document-lifecycle/persistence.js +42 -0
- package/dist/services/document-lifecycle/restore.js +9 -17
- package/dist/services/document-lifecycle/scheduled-publish.js +5 -5
- package/dist/services/document-lifecycle/status.js +3 -3
- package/dist/services/document-lifecycle/system-fields.js +1 -1
- package/dist/services/document-lifecycle/tree.js +2 -2
- package/dist/services/document-lifecycle/tree.test.node.js +6 -1
- package/dist/services/document-lifecycle/update.js +20 -36
- package/dist/services/document-lifecycle.test.node.js +35 -0
- package/dist/services/field-upload.d.ts +1 -1
- package/dist/services/field-upload.js +20 -6
- package/dist/services/field-upload.test.node.js +125 -9
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/populate.js +1 -1
- package/dist/services/populate.test.node.js +9 -0
- package/dist/services/richtext-populate.js +1 -1
- package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +16 -0
- package/dist/services/singleton-lifecycle/copy-to-locale.js +100 -0
- package/dist/services/singleton-lifecycle/index.d.ts +14 -0
- package/dist/services/singleton-lifecycle/index.js +11 -0
- package/dist/services/singleton-lifecycle/internals.d.ts +49 -0
- package/dist/services/singleton-lifecycle/internals.js +118 -0
- package/dist/services/singleton-lifecycle/restore.d.ts +13 -0
- package/dist/services/singleton-lifecycle/restore.js +93 -0
- package/dist/services/singleton-lifecycle/update.d.ts +16 -0
- package/dist/services/singleton-lifecycle/update.js +107 -0
- package/dist/services/singleton-lifecycle.test.node.d.ts +8 -0
- package/dist/services/singleton-lifecycle.test.node.js +396 -0
- package/dist/storage/collection-fingerprint.js +18 -6
- package/dist/storage/collection-fingerprint.test.node.js +15 -0
- package/package.json +5 -5
- package/dist/config/resolve-item-view-columns.test.node.js +0 -30
- /package/dist/{config/resolve-item-view-columns.test.node.d.ts → @types/singleton-admin-config.test.node.d.ts} +0 -0
package/dist/core.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { AbilityRegistry } from '@byline/auth';
|
|
9
|
-
import {
|
|
9
|
+
import { registerDocumentAbilities } from './auth/register-collection-abilities.js';
|
|
10
10
|
import { defineBylineCore, getBylineCoreUnsafe, registerServerConfig, resolveServerConfig, } from './config/config.js';
|
|
11
11
|
import { createBylineLogger, defineLogger } from './lib/logger.js';
|
|
12
12
|
import { Registry } from './lib/registry.js';
|
|
@@ -157,12 +157,12 @@ export const initBylineCore = async (config, pinoLogger) => {
|
|
|
157
157
|
}
|
|
158
158
|
return record;
|
|
159
159
|
};
|
|
160
|
-
// Ability registry — populated with each
|
|
160
|
+
// Ability registry — populated with each document resource's kind-aware
|
|
161
161
|
// abilities. Plugins and subsystems add their own via
|
|
162
162
|
// `core.registerAbility()` or `core.abilities.register()`.
|
|
163
163
|
const abilities = new AbilityRegistry();
|
|
164
164
|
for (const definition of composed.collections) {
|
|
165
|
-
|
|
165
|
+
registerDocumentAbilities(abilities, definition);
|
|
166
166
|
}
|
|
167
167
|
const core = {
|
|
168
168
|
config: composed.config,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export * from './@types/index.js';
|
|
2
|
-
export { type ActorAbilitySnapshot, applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, compileBeforeReadFilters, filterReadableCollections, registerCollectionAbilities, } from './auth/index.js';
|
|
3
|
-
export { defineAdminConfig, defineServerConfig, getAdminConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, } from './config/config.js';
|
|
2
|
+
export { type ActorAbilitySnapshot, applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, type CollectionAbilityResource, type CollectionAbilityResourceDescriptor, type CollectionAbilityVerb, collectionAbilityKey, compileBeforeReadFilters, type DocumentAbilityResource, type DocumentAbilityVerbFor, documentAbilityKey, filterReadableCollections, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, type SingletonAbilityResource, type SingletonAbilityResourceDescriptor, type SingletonAbilityVerb, } from './auth/index.js';
|
|
3
|
+
export { defineAdminConfig, defineServerConfig, getAdminConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, getSingletonAdminConfig, orderByContentLocale, } from './config/config.js';
|
|
4
4
|
export { type CollectionGroupBucket, groupCollectionsForAdmin, } from './config/group-collections.js';
|
|
5
5
|
export { resolveRoutes } from './config/routes.js';
|
|
6
6
|
export { validateAdminConfigs, validateBlockAdminConfigs, validateCollectionGroups, } from './config/validate-admin-configs.js';
|
package/dist/index.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// breaking package-surface change.
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
export * from './@types/index.js';
|
|
14
|
-
export { applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, collectionAbilityKey, compileBeforeReadFilters, filterReadableCollections, registerCollectionAbilities, } from './auth/index.js';
|
|
15
|
-
export { defineAdminConfig, defineServerConfig, getAdminConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, orderByContentLocale, } from './config/config.js';
|
|
14
|
+
export { applyBeforeRead, assertActorCanPerform, bindReadContextAuthority, COLLECTION_ABILITY_VERBS, collectionAbilityKey, compileBeforeReadFilters, documentAbilityKey, filterReadableCollections, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, } from './auth/index.js';
|
|
15
|
+
export { defineAdminConfig, defineServerConfig, getAdminConfig, getCollectionAdminConfig, getCollectionDefinition, getServerConfig, getSingletonAdminConfig, orderByContentLocale, } from './config/config.js';
|
|
16
16
|
export { groupCollectionsForAdmin, } from './config/group-collections.js';
|
|
17
17
|
export { resolveRoutes } from './config/routes.js';
|
|
18
18
|
export { validateAdminConfigs, validateBlockAdminConfigs, validateCollectionGroups, } from './config/validate-admin-configs.js';
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -141,15 +141,17 @@ export declare const ERR_TREE_HOOK_COMMITTED: (opts: BylineErrorOptions, errorCo
|
|
|
141
141
|
*/
|
|
142
142
|
export declare const DbErrorCodes: {
|
|
143
143
|
readonly UNIQUE_VIOLATION: 'DB_UNIQUE_VIOLATION';
|
|
144
|
+
readonly FOREIGN_KEY_VIOLATION: 'DB_FOREIGN_KEY_VIOLATION';
|
|
144
145
|
readonly UNKNOWN: 'DB_UNKNOWN';
|
|
145
146
|
};
|
|
146
147
|
export type DbErrorCode = (typeof DbErrorCodes)[keyof typeof DbErrorCodes];
|
|
147
148
|
export interface DbErrorClassification {
|
|
148
149
|
code: DbErrorCode;
|
|
149
150
|
/**
|
|
150
|
-
* For `DB_UNIQUE_VIOLATION`: the violated
|
|
151
|
-
* driver exposes it (Postgres carries it
|
|
152
|
-
* the error message). Absent when the
|
|
151
|
+
* For `DB_UNIQUE_VIOLATION` and `DB_FOREIGN_KEY_VIOLATION`: the violated
|
|
152
|
+
* constraint / index name when the driver exposes it (Postgres carries it
|
|
153
|
+
* structurally; MySQL parses it from the error message). Absent when the
|
|
154
|
+
* driver surfaces no name.
|
|
153
155
|
*/
|
|
154
156
|
constraint?: string;
|
|
155
157
|
}
|
package/dist/lib/errors.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
+
import { isSingleton } from '../@types/collection-types.js';
|
|
8
9
|
import { fingerprintCollection } from '../storage/collection-fingerprint.js';
|
|
9
10
|
/**
|
|
10
11
|
* Reconcile every registered `CollectionDefinition` with its row in the
|
|
@@ -57,6 +58,17 @@ async function reconcileCollection(definition, db, logger) {
|
|
|
57
58
|
record: { collectionId, version: initialVersion, schemaHash: fingerprint },
|
|
58
59
|
};
|
|
59
60
|
}
|
|
61
|
+
const storedConfig = (existing.config ?? {});
|
|
62
|
+
const storedIsSingleton = storedConfig.singleton === true;
|
|
63
|
+
const incomingIsSingleton = isSingleton(definition);
|
|
64
|
+
if (storedIsSingleton !== incomingIsSingleton) {
|
|
65
|
+
throw new Error(`Registered schema "${definition.path}" changed kind from ` +
|
|
66
|
+
`${storedIsSingleton ? 'singleton' : 'collection'} to ` +
|
|
67
|
+
`${incomingIsSingleton ? 'singleton' : 'collection'}. Cardinality and supported ` +
|
|
68
|
+
'operations differ between the two, so this needs an explicit data migration — ' +
|
|
69
|
+
'not a schema-version bump. Register the new kind under a different path, migrate ' +
|
|
70
|
+
'the documents, then retire the old one.');
|
|
71
|
+
}
|
|
60
72
|
const collectionId = existing.id;
|
|
61
73
|
const storedVersion = (existing.version ?? 1) | 0;
|
|
62
74
|
const storedHash = existing.schema_hash ?? null;
|
|
@@ -23,15 +23,19 @@ function baseCollection() {
|
|
|
23
23
|
}
|
|
24
24
|
function createMockDb(options) {
|
|
25
25
|
const getCollectionByPath = vi.fn(options.getCollectionByPath ?? (async () => options.existingRow ?? null));
|
|
26
|
-
const create = vi
|
|
27
|
-
.fn()
|
|
28
|
-
.mockImplementation(async (_path, _config, _opts) => [
|
|
29
|
-
{ id: 'col-new' },
|
|
30
|
-
]);
|
|
31
|
-
const update = vi.fn().mockResolvedValue([{ id: options.existingRow?.id ?? 'col-new' }]);
|
|
32
26
|
const fail = () => {
|
|
33
27
|
throw new Error('not expected to be called');
|
|
34
28
|
};
|
|
29
|
+
const create = options.failWrites
|
|
30
|
+
? vi.fn(fail)
|
|
31
|
+
: vi
|
|
32
|
+
.fn()
|
|
33
|
+
.mockImplementation(async (_path, _config, _opts) => [
|
|
34
|
+
{ id: 'col-new' },
|
|
35
|
+
]);
|
|
36
|
+
const update = options.failWrites
|
|
37
|
+
? vi.fn(fail)
|
|
38
|
+
: vi.fn().mockResolvedValue([{ id: options.existingRow?.id ?? 'col-new' }]);
|
|
35
39
|
const db = {
|
|
36
40
|
commands: {
|
|
37
41
|
collections: { create, update, delete: vi.fn(fail) },
|
|
@@ -65,6 +69,11 @@ function createMockDb(options) {
|
|
|
65
69
|
nextScopedCounterValue: vi.fn(fail),
|
|
66
70
|
},
|
|
67
71
|
audit: { append: vi.fn(async () => ({ id: 'audit-1' })) },
|
|
72
|
+
singletons: {
|
|
73
|
+
lockSlot: vi.fn(fail),
|
|
74
|
+
setMapping: vi.fn(fail),
|
|
75
|
+
clearMapping: vi.fn(fail),
|
|
76
|
+
},
|
|
68
77
|
},
|
|
69
78
|
queries: {
|
|
70
79
|
collections: {
|
|
@@ -105,6 +114,7 @@ function createMockDb(options) {
|
|
|
105
114
|
meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
|
|
106
115
|
})),
|
|
107
116
|
},
|
|
117
|
+
singletons: { getMappedDocumentId: vi.fn(fail) },
|
|
108
118
|
},
|
|
109
119
|
withTransaction: async (fn) => fn(),
|
|
110
120
|
};
|
|
@@ -216,4 +226,51 @@ describe('ensureCollections', () => {
|
|
|
216
226
|
expect(create).toHaveBeenCalledTimes(1);
|
|
217
227
|
expect(update).not.toHaveBeenCalled();
|
|
218
228
|
});
|
|
229
|
+
it('rejects changing a registered path from collection to singleton before writing', async () => {
|
|
230
|
+
const { db, create, update } = createMockDb({
|
|
231
|
+
existingRow: {
|
|
232
|
+
id: 'col-1',
|
|
233
|
+
version: 1,
|
|
234
|
+
schema_hash: 'stale',
|
|
235
|
+
config: { path: 'site-settings', fields: [] },
|
|
236
|
+
},
|
|
237
|
+
failWrites: true,
|
|
238
|
+
});
|
|
239
|
+
await expect(ensureCollections({
|
|
240
|
+
definitions: [
|
|
241
|
+
{
|
|
242
|
+
path: 'site-settings',
|
|
243
|
+
label: 'Site settings',
|
|
244
|
+
singleton: true,
|
|
245
|
+
fields: [{ name: 'name', label: 'Site name', type: 'text' }],
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
db,
|
|
249
|
+
})).rejects.toThrow(/kind/i);
|
|
250
|
+
expect(create).not.toHaveBeenCalled();
|
|
251
|
+
expect(update).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
it('rejects changing a registered path from singleton to collection before writing', async () => {
|
|
254
|
+
const { db, create, update } = createMockDb({
|
|
255
|
+
existingRow: {
|
|
256
|
+
id: 'col-1',
|
|
257
|
+
version: 1,
|
|
258
|
+
schema_hash: 'stale',
|
|
259
|
+
config: { path: 'site-settings', singleton: true, fields: [] },
|
|
260
|
+
},
|
|
261
|
+
failWrites: true,
|
|
262
|
+
});
|
|
263
|
+
await expect(ensureCollections({
|
|
264
|
+
definitions: [
|
|
265
|
+
{
|
|
266
|
+
path: 'site-settings',
|
|
267
|
+
labels: { singular: 'Setting', plural: 'Settings' },
|
|
268
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
db,
|
|
272
|
+
})).rejects.toThrow(/kind/i);
|
|
273
|
+
expect(create).not.toHaveBeenCalled();
|
|
274
|
+
expect(update).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
219
276
|
});
|
|
@@ -52,6 +52,11 @@ function makeAdapter(options) {
|
|
|
52
52
|
nextScopedCounterValue: vi.fn(fail),
|
|
53
53
|
},
|
|
54
54
|
audit: { append: vi.fn(async () => ({ id: 'audit-1' })) },
|
|
55
|
+
singletons: {
|
|
56
|
+
lockSlot: vi.fn(fail),
|
|
57
|
+
setMapping: vi.fn(fail),
|
|
58
|
+
clearMapping: vi.fn(fail),
|
|
59
|
+
},
|
|
55
60
|
},
|
|
56
61
|
queries: {
|
|
57
62
|
collections: {
|
|
@@ -92,6 +97,7 @@ function makeAdapter(options) {
|
|
|
92
97
|
meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
|
|
93
98
|
})),
|
|
94
99
|
},
|
|
100
|
+
singletons: { getMappedDocumentId: vi.fn(fail) },
|
|
95
101
|
},
|
|
96
102
|
withTransaction: async (fn) => fn(),
|
|
97
103
|
};
|
|
@@ -10,9 +10,9 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
|
-
import {
|
|
13
|
+
import { applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
|
|
14
14
|
import { mergeLocaleData } from './merge-locale-data.js';
|
|
15
|
-
import {
|
|
15
|
+
import { persistExistingDocumentVersion } from './persistence.js';
|
|
16
16
|
/**
|
|
17
17
|
* Copy a document's content from one locale into another, in place on
|
|
18
18
|
* the same document.
|
|
@@ -48,7 +48,7 @@ import { commitContentVersionWithScheduleSuspension } from './publish-schedule-c
|
|
|
48
48
|
export async function copyToLocale(ctx, params) {
|
|
49
49
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'copyToLocale' }, async () => {
|
|
50
50
|
const { db, definition, collectionId, collectionPath } = ctx;
|
|
51
|
-
assertActorCanPerform(ctx.requestContext,
|
|
51
|
+
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
52
52
|
if (params.sourceLocale === params.targetLocale) {
|
|
53
53
|
throw ERR_VALIDATION({
|
|
54
54
|
message: 'sourceLocale and targetLocale must differ',
|
|
@@ -124,21 +124,13 @@ export async function copyToLocale(ctx, params) {
|
|
|
124
124
|
// rewritten by this call).
|
|
125
125
|
const previousVersionId = targetRecord.document_version_id ?? undefined;
|
|
126
126
|
await applyRichTextEmbed(ctx, merged.data);
|
|
127
|
-
const writeResult = await
|
|
128
|
-
ctx,
|
|
127
|
+
const writeResult = await persistExistingDocumentVersion(ctx, {
|
|
129
128
|
documentId: params.documentId,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
action: 'copy_to_locale',
|
|
136
|
-
documentData: merged.data,
|
|
137
|
-
status: getDefaultStatus(definition),
|
|
138
|
-
locale: params.targetLocale,
|
|
139
|
-
previousVersionId,
|
|
140
|
-
createdBy: actorId(ctx),
|
|
141
|
-
}),
|
|
129
|
+
action: 'copy_to_locale',
|
|
130
|
+
documentData: merged.data,
|
|
131
|
+
status: getDefaultStatus(definition),
|
|
132
|
+
locale: params.targetLocale,
|
|
133
|
+
previousVersionId,
|
|
142
134
|
});
|
|
143
135
|
const documentVersionId = extractVersionId(writeResult.document);
|
|
144
136
|
await invokeHook(hooks?.afterUpdate, {
|
|
@@ -15,7 +15,8 @@ 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
17
|
import { requireTreeAuditCapability } from './audit.js';
|
|
18
|
-
import {
|
|
18
|
+
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
|
|
19
|
+
import { persistInitialDocumentVersion } from './persistence.js';
|
|
19
20
|
import { appendTreeRoot } from './tree.js';
|
|
20
21
|
/**
|
|
21
22
|
* Create a new document.
|
|
@@ -33,8 +34,8 @@ import { appendTreeRoot } from './tree.js';
|
|
|
33
34
|
*/
|
|
34
35
|
export async function createDocument(ctx, params) {
|
|
35
36
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'createDocument' }, async () => {
|
|
36
|
-
const { db, definition,
|
|
37
|
-
assertActorCanPerform(ctx.requestContext,
|
|
37
|
+
const { db, definition, collectionPath, defaultLocale } = ctx;
|
|
38
|
+
assertActorCanPerform(ctx.requestContext, definition, 'create');
|
|
38
39
|
// Reject unsupported tree adapters before hooks, counters, or persistence
|
|
39
40
|
// can create a document that cannot be placed and audited safely.
|
|
40
41
|
if (definition.tree === true)
|
|
@@ -72,11 +73,7 @@ export async function createDocument(ctx, params) {
|
|
|
72
73
|
// Refresh embedded relation envelopes inside rich-text fields
|
|
73
74
|
// (internal-link / inline-image nodes) before flatten-and-persist.
|
|
74
75
|
await applyRichTextEmbed(ctx, data);
|
|
75
|
-
const result = await
|
|
76
|
-
.createDocumentVersion({
|
|
77
|
-
collectionId,
|
|
78
|
-
collectionVersion: ctx.collectionVersion,
|
|
79
|
-
collectionConfig: definition,
|
|
76
|
+
const result = await persistInitialDocumentVersion(ctx, {
|
|
80
77
|
action: 'create',
|
|
81
78
|
documentData: data,
|
|
82
79
|
path: resolvedPath,
|
|
@@ -84,9 +81,7 @@ export async function createDocument(ctx, params) {
|
|
|
84
81
|
status: params.status ?? data.status ?? getDefaultStatus(definition),
|
|
85
82
|
locale: params.locale ?? defaultLocale,
|
|
86
83
|
orderKey,
|
|
87
|
-
|
|
88
|
-
})
|
|
89
|
-
.catch((err) => rethrowPathConflict(db, err, resolvedPath, defaultLocale, 'create'));
|
|
84
|
+
}).catch((err) => rethrowPathConflict(db, err, resolvedPath, defaultLocale, 'create'));
|
|
90
85
|
const documentId = extractDocumentId(result.document);
|
|
91
86
|
const documentVersionId = extractVersionId(result.document);
|
|
92
87
|
// `tree: true` collections place every document in the tree by default:
|
|
@@ -40,7 +40,7 @@ import { commitContentVersionWithScheduleSuspension } from './publish-schedule-c
|
|
|
40
40
|
export async function deleteLocale(ctx, params) {
|
|
41
41
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteLocale' }, async () => {
|
|
42
42
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
43
|
-
assertActorCanPerform(ctx.requestContext,
|
|
43
|
+
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
44
44
|
// The default locale anchors the document's path and source_locale —
|
|
45
45
|
// it cannot be deleted (the other locales fall back to it).
|
|
46
46
|
if (params.locale === defaultLocale) {
|
|
@@ -49,7 +49,7 @@ function serializeSideEffectFailure(phase, error) {
|
|
|
49
49
|
export async function deleteDocument(ctx, params) {
|
|
50
50
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'deleteDocument' }, async () => {
|
|
51
51
|
const { db, collectionPath, definition, logger } = ctx;
|
|
52
|
-
assertActorCanPerform(ctx.requestContext,
|
|
52
|
+
assertActorCanPerform(ctx.requestContext, definition, 'delete');
|
|
53
53
|
const hooks = await resolveHooks(definition);
|
|
54
54
|
// 1. Verify the document exists. Soft delete retains field rows and
|
|
55
55
|
// uploaded objects, so only the envelope and original path projection
|
|
@@ -12,7 +12,8 @@ import { withLogContext } from '../../lib/logger.js';
|
|
|
12
12
|
import { slugify } from '../../utils/slugify.js';
|
|
13
13
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
14
14
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
15
|
-
import {
|
|
15
|
+
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, isPathConflictError, maybeAppendOrderKey, rethrowPathConflict, stripMetaIdsInPlace, } from './internals.js';
|
|
16
|
+
import { persistInitialDocumentVersion } from './persistence.js';
|
|
16
17
|
/**
|
|
17
18
|
* Apply the `" (copy)"` suffix to the configured `useAsTitle` field on a
|
|
18
19
|
* duplicate's data tree. Handles both shapes:
|
|
@@ -100,7 +101,7 @@ function deriveDuplicateCandidatePath(definition, fields, defaultLocale, slugifi
|
|
|
100
101
|
export async function duplicateDocument(ctx, params) {
|
|
101
102
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'duplicateDocument' }, async () => {
|
|
102
103
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
103
|
-
assertActorCanPerform(ctx.requestContext,
|
|
104
|
+
assertActorCanPerform(ctx.requestContext, definition, 'create');
|
|
104
105
|
const slugifier = ctx.slugifier ?? slugify;
|
|
105
106
|
const hooks = await resolveHooks(definition);
|
|
106
107
|
// 1. Read source with locale='all' — single read, full multi-locale tree.
|
|
@@ -159,20 +160,14 @@ export async function duplicateDocument(ctx, params) {
|
|
|
159
160
|
// restoreDocumentVersion for the same caveat).
|
|
160
161
|
await applyRichTextEmbed(ctx, clonedFields);
|
|
161
162
|
try {
|
|
162
|
-
result = await
|
|
163
|
-
.createDocumentVersion({
|
|
164
|
-
collectionId,
|
|
165
|
-
collectionVersion: ctx.collectionVersion,
|
|
166
|
-
collectionConfig: definition,
|
|
163
|
+
result = await persistInitialDocumentVersion(ctx, {
|
|
167
164
|
action: 'create',
|
|
168
165
|
documentData: clonedFields,
|
|
169
166
|
path: finalPath,
|
|
170
167
|
status: defaultStatus,
|
|
171
168
|
locale: 'all',
|
|
172
169
|
orderKey,
|
|
173
|
-
|
|
174
|
-
})
|
|
175
|
-
.catch((err) => rethrowPathConflict(db, err, finalPath, defaultLocale, 'duplicate'));
|
|
170
|
+
}).catch((err) => rethrowPathConflict(db, err, finalPath, defaultLocale, 'duplicate'));
|
|
176
171
|
}
|
|
177
172
|
catch (err) {
|
|
178
173
|
if (!isPathConflictError(err)) {
|
|
@@ -184,20 +179,14 @@ export async function duplicateDocument(ctx, params) {
|
|
|
184
179
|
finalPath = `${candidatePath}-${shortDisambiguator}`;
|
|
185
180
|
pathRetried = true;
|
|
186
181
|
ctx.logger?.info({ candidatePath, retryPath: finalPath, sourceDocumentId: params.sourceDocumentId }, 'duplicateDocument: candidate path collided, retrying with short-UUID suffix');
|
|
187
|
-
result = await
|
|
188
|
-
.createDocumentVersion({
|
|
189
|
-
collectionId,
|
|
190
|
-
collectionVersion: ctx.collectionVersion,
|
|
191
|
-
collectionConfig: definition,
|
|
182
|
+
result = await persistInitialDocumentVersion(ctx, {
|
|
192
183
|
action: 'create',
|
|
193
184
|
documentData: clonedFields,
|
|
194
185
|
path: finalPath,
|
|
195
186
|
status: defaultStatus,
|
|
196
187
|
locale: 'all',
|
|
197
188
|
orderKey,
|
|
198
|
-
|
|
199
|
-
})
|
|
200
|
-
.catch((retryErr) => rethrowPathConflict(db, retryErr, finalPath, defaultLocale, 'duplicate'));
|
|
189
|
+
}).catch((retryErr) => rethrowPathConflict(db, retryErr, finalPath, defaultLocale, 'duplicate'));
|
|
201
190
|
}
|
|
202
191
|
const newDocumentId = extractDocumentId(result.document);
|
|
203
192
|
const newDocumentVersionId = extractVersionId(result.document);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { IDocumentCommands } from '../../@types/index.js';
|
|
9
|
+
import type { DocumentLifecycleContext } from './context.js';
|
|
10
|
+
type AdapterVersionWrite = Parameters<IDocumentCommands['createDocumentVersion']>[0];
|
|
11
|
+
type LifecycleOwnedWriteKeys = 'collectionId' | 'collectionVersion' | 'collectionConfig' | 'createdBy';
|
|
12
|
+
type InitialDocumentVersionWrite = Omit<AdapterVersionWrite, LifecycleOwnedWriteKeys | 'documentId' | 'previousVersionId'>;
|
|
13
|
+
type ExistingDocumentVersionWrite = Omit<AdapterVersionWrite, LifecycleOwnedWriteKeys | 'documentId'> & {
|
|
14
|
+
documentId: string;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Persist the first version of a logical document with lifecycle-owned
|
|
18
|
+
* registration and actor metadata. Authentication, hooks, normalization,
|
|
19
|
+
* counters, path derivation, ordering, and rich-text work stay in the caller
|
|
20
|
+
* so their sequencing remains explicit.
|
|
21
|
+
*/
|
|
22
|
+
export declare function persistInitialDocumentVersion(ctx: DocumentLifecycleContext, write: InitialDocumentVersionWrite): ReturnType<IDocumentCommands['createDocumentVersion']>;
|
|
23
|
+
/**
|
|
24
|
+
* Persist a new version of an existing logical document and suspend any armed
|
|
25
|
+
* publication schedule atomically. All operation-specific preparation and
|
|
26
|
+
* hook sequencing remains in the caller.
|
|
27
|
+
*/
|
|
28
|
+
export declare function persistExistingDocumentVersion(ctx: DocumentLifecycleContext, write: ExistingDocumentVersionWrite): ReturnType<IDocumentCommands['createDocumentVersion']>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { actorId } from './internals.js';
|
|
9
|
+
import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
|
|
10
|
+
/**
|
|
11
|
+
* Persist the first version of a logical document with lifecycle-owned
|
|
12
|
+
* registration and actor metadata. Authentication, hooks, normalization,
|
|
13
|
+
* counters, path derivation, ordering, and rich-text work stay in the caller
|
|
14
|
+
* so their sequencing remains explicit.
|
|
15
|
+
*/
|
|
16
|
+
export function persistInitialDocumentVersion(ctx, write) {
|
|
17
|
+
return ctx.db.commands.documents.createDocumentVersion({
|
|
18
|
+
...write,
|
|
19
|
+
collectionId: ctx.collectionId,
|
|
20
|
+
collectionVersion: ctx.collectionVersion,
|
|
21
|
+
collectionConfig: ctx.definition,
|
|
22
|
+
createdBy: actorId(ctx),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Persist a new version of an existing logical document and suspend any armed
|
|
27
|
+
* publication schedule atomically. All operation-specific preparation and
|
|
28
|
+
* hook sequencing remains in the caller.
|
|
29
|
+
*/
|
|
30
|
+
export function persistExistingDocumentVersion(ctx, write) {
|
|
31
|
+
return commitContentVersionWithScheduleSuspension({
|
|
32
|
+
ctx,
|
|
33
|
+
documentId: write.documentId,
|
|
34
|
+
write: () => ctx.db.commands.documents.createDocumentVersion({
|
|
35
|
+
...write,
|
|
36
|
+
collectionId: ctx.collectionId,
|
|
37
|
+
collectionVersion: ctx.collectionVersion,
|
|
38
|
+
collectionConfig: ctx.definition,
|
|
39
|
+
createdBy: actorId(ctx),
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -10,8 +10,8 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook } from './internals.js';
|
|
14
|
+
import { persistExistingDocumentVersion } from './persistence.js';
|
|
15
15
|
/**
|
|
16
16
|
* Restore a historical document version as the new current version.
|
|
17
17
|
*
|
|
@@ -55,7 +55,7 @@ import { commitContentVersionWithScheduleSuspension } from './publish-schedule-c
|
|
|
55
55
|
export async function restoreDocumentVersion(ctx, params) {
|
|
56
56
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'restoreDocumentVersion' }, async () => {
|
|
57
57
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
58
|
-
assertActorCanPerform(ctx.requestContext,
|
|
58
|
+
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
59
59
|
const hooks = await resolveHooks(definition);
|
|
60
60
|
// 1. Read source version (full multi-locale tree).
|
|
61
61
|
const source = await db.queries.documents.getDocumentByVersion({
|
|
@@ -130,21 +130,13 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
130
130
|
//
|
|
131
131
|
// No `path` is passed: restore does not change the document's path
|
|
132
132
|
// (the existing byline_document_paths row stays as-is — sticky).
|
|
133
|
-
const result = await
|
|
134
|
-
ctx,
|
|
133
|
+
const result = await persistExistingDocumentVersion(ctx, {
|
|
135
134
|
documentId: params.documentId,
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
action: 'restore',
|
|
142
|
-
documentData: sourceFields,
|
|
143
|
-
status: getDefaultStatus(definition),
|
|
144
|
-
locale: 'all',
|
|
145
|
-
previousVersionId: currentMeta.document_version_id,
|
|
146
|
-
createdBy: actorId(ctx),
|
|
147
|
-
}),
|
|
135
|
+
action: 'restore',
|
|
136
|
+
documentData: sourceFields,
|
|
137
|
+
status: getDefaultStatus(definition),
|
|
138
|
+
locale: 'all',
|
|
139
|
+
previousVersionId: currentMeta.document_version_id,
|
|
148
140
|
});
|
|
149
141
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
150
142
|
const documentVersionId = extractVersionId(result.document);
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { ERR_UNAUTHENTICATED } from '@byline/auth';
|
|
9
9
|
import { resolveHooks } from '../../@types/index.js';
|
|
10
10
|
import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
11
|
-
import {
|
|
11
|
+
import { documentAbilityKey } from '../../auth/register-collection-abilities.js';
|
|
12
12
|
import { ERR_CONFLICT, ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION, } from '../../lib/errors.js';
|
|
13
13
|
import { withLogContext } from '../../lib/logger.js';
|
|
14
14
|
import { getWorkflow, validateStatusTransition } from '../../workflow/workflow.js';
|
|
@@ -24,8 +24,8 @@ class ClaimedPublicationOutcome extends Error {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
function assertScheduleAbilities(ctx) {
|
|
27
|
-
assertActorCanPerform(ctx.requestContext, ctx.
|
|
28
|
-
assertActorCanPerform(ctx.requestContext, ctx.
|
|
27
|
+
assertActorCanPerform(ctx.requestContext, ctx.definition, 'changeStatus');
|
|
28
|
+
assertActorCanPerform(ctx.requestContext, ctx.definition, 'publish');
|
|
29
29
|
}
|
|
30
30
|
async function assertCurrentVersionCanPublish(ctx, documentId, expectedVersionId) {
|
|
31
31
|
const current = await ctx.db.queries.documents.getCurrentVersionMetadata({
|
|
@@ -217,8 +217,8 @@ export async function listDocumentPublishSchedules(core, requestContext, params)
|
|
|
217
217
|
}
|
|
218
218
|
const collectionIds = [];
|
|
219
219
|
for (const definition of core.collections) {
|
|
220
|
-
if (actor.hasAbility(
|
|
221
|
-
actor.hasAbility(
|
|
220
|
+
if (actor.hasAbility(documentAbilityKey(definition, 'changeStatus')) &&
|
|
221
|
+
actor.hasAbility(documentAbilityKey(definition, 'publish'))) {
|
|
222
222
|
collectionIds.push(core.getCollectionRecord(definition.path).collectionId);
|
|
223
223
|
}
|
|
224
224
|
}
|
|
@@ -33,9 +33,9 @@ export async function changeDocumentStatus(ctx, params) {
|
|
|
33
33
|
// require the narrower `publish` ability — so installations can
|
|
34
34
|
// grant "move things through the workflow" without also granting
|
|
35
35
|
// "flip the final publish switch".
|
|
36
|
-
assertActorCanPerform(ctx.requestContext,
|
|
36
|
+
assertActorCanPerform(ctx.requestContext, definition, 'changeStatus');
|
|
37
37
|
if (params.nextStatus === 'published') {
|
|
38
|
-
assertActorCanPerform(ctx.requestContext,
|
|
38
|
+
assertActorCanPerform(ctx.requestContext, definition, 'publish');
|
|
39
39
|
}
|
|
40
40
|
// Single-status workflows (e.g. SINGLE_STATUS_WORKFLOW for lookups)
|
|
41
41
|
// have no transitions to perform. Reject early with a clear message
|
|
@@ -132,7 +132,7 @@ export async function unpublishDocument(ctx, params) {
|
|
|
132
132
|
const { db, collectionId, collectionPath, definition } = ctx;
|
|
133
133
|
// Unpublish is a workflow transition out of `published` — reuse the
|
|
134
134
|
// changeStatus gate rather than a separate ability.
|
|
135
|
-
assertActorCanPerform(ctx.requestContext,
|
|
135
|
+
assertActorCanPerform(ctx.requestContext, definition, 'changeStatus');
|
|
136
136
|
// Single-status workflows have nothing to unpublish to.
|
|
137
137
|
const workflow = getWorkflow(definition);
|
|
138
138
|
if (workflow.statuses.length <= 1) {
|
|
@@ -51,7 +51,7 @@ import { invokeHook, resolvePathForUpdate, rethrowPathConflict } from './interna
|
|
|
51
51
|
export async function updateDocumentSystemFields(ctx, params) {
|
|
52
52
|
return withLogContext({ domain: 'services', module: 'lifecycle', function: 'updateDocumentSystemFields' }, async () => {
|
|
53
53
|
const { db, definition, collectionId, collectionPath, defaultLocale } = ctx;
|
|
54
|
-
assertActorCanPerform(ctx.requestContext,
|
|
54
|
+
assertActorCanPerform(ctx.requestContext, definition, 'update');
|
|
55
55
|
const requestLocale = params.locale ?? defaultLocale;
|
|
56
56
|
const explicitPath = typeof params.path === 'string' && params.path.length > 0 ? params.path : null;
|
|
57
57
|
const requested = {
|
|
@@ -110,7 +110,7 @@ async function auditedPlace(ctx, params) {
|
|
|
110
110
|
/** Place, re-parent, or reorder a node; explicit no-op retries may reconcile hooks. */
|
|
111
111
|
export async function placeTreeNode(ctx, params) {
|
|
112
112
|
return withLogContext({ domain: 'services', module: 'tree', function: 'placeTreeNode' }, async () => {
|
|
113
|
-
assertActorCanPerform(ctx.requestContext, ctx.
|
|
113
|
+
assertActorCanPerform(ctx.requestContext, ctx.definition, 'update');
|
|
114
114
|
const hooks = await resolveHooks(ctx.definition);
|
|
115
115
|
const wantsEvent = hooks?.afterTreeChange != null;
|
|
116
116
|
const mutation = await auditedPlace(ctx, params);
|
|
@@ -169,7 +169,7 @@ export async function selfHealTreePlacement(ctx, documentId) {
|
|
|
169
169
|
/** Remove a node to the unplaced state; an already-unplaced node is a true no-op. */
|
|
170
170
|
export async function removeFromTree(ctx, params) {
|
|
171
171
|
return withLogContext({ domain: 'services', module: 'tree', function: 'removeFromTree' }, async () => {
|
|
172
|
-
assertActorCanPerform(ctx.requestContext, ctx.
|
|
172
|
+
assertActorCanPerform(ctx.requestContext, ctx.definition, 'update');
|
|
173
173
|
const hooks = await resolveHooks(ctx.definition);
|
|
174
174
|
const wantsEvent = hooks?.afterTreeChange != null;
|
|
175
175
|
const capability = requireTreeAuditCapability(ctx.db);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { createSuperAdminContext } from '@byline/auth';
|
|
9
9
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { isSingleton } from '../../@types/index.js';
|
|
10
11
|
import { TREE_HOOK_COMMITTED_MARKER } from '../../lib/errors.js';
|
|
11
12
|
import { validateTreeAuditCapability } from './audit.js';
|
|
12
13
|
import { createDocument } from './create.js';
|
|
@@ -275,7 +276,11 @@ describe('document-tree lifecycle audit contract', () => {
|
|
|
275
276
|
},
|
|
276
277
|
},
|
|
277
278
|
};
|
|
278
|
-
|
|
279
|
+
const definition = harness.ctx.definition;
|
|
280
|
+
if (isSingleton(definition)) {
|
|
281
|
+
throw new Error('tree lifecycle harness must use a multi-document collection');
|
|
282
|
+
}
|
|
283
|
+
expect(() => validateTreeAuditCapability([{ ...definition, tree: false }], unsupported)).not.toThrow();
|
|
279
284
|
expect(() => validateTreeAuditCapability([harness.ctx.definition], unsupported)).toThrow(/tree-enabled writes require/);
|
|
280
285
|
});
|
|
281
286
|
it('records place, reparent, and reorder actions with structural details', async () => {
|