@byline/core 4.16.0 → 4.18.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/search-types.d.ts +12 -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
|
@@ -575,3 +575,78 @@ describe('validateCollections — search config fields', () => {
|
|
|
575
575
|
expect(() => validateCollections([searchable({ filters: ['nope'] })])).toThrow(/no top-level field with that name exists/);
|
|
576
576
|
});
|
|
577
577
|
});
|
|
578
|
+
describe('validateCollections — singletons', () => {
|
|
579
|
+
const singleton = (extra) => ({
|
|
580
|
+
path: 'site-settings',
|
|
581
|
+
label: 'Site settings',
|
|
582
|
+
singleton: true,
|
|
583
|
+
fields: [{ name: 'name', label: 'Site name', type: 'text' }],
|
|
584
|
+
...extra,
|
|
585
|
+
});
|
|
586
|
+
it('rejects orderable on a singleton', () => {
|
|
587
|
+
expect(() => validateCollections([singleton({ orderable: true })])).toThrow(/orderable/);
|
|
588
|
+
});
|
|
589
|
+
it('rejects tree on a singleton', () => {
|
|
590
|
+
expect(() => validateCollections([singleton({ tree: true })])).toThrow(/tree/);
|
|
591
|
+
});
|
|
592
|
+
it('rejects useAsPath on a singleton', () => {
|
|
593
|
+
expect(() => validateCollections([singleton({ useAsPath: 'name' })])).toThrow(/useAsPath/);
|
|
594
|
+
});
|
|
595
|
+
it('rejects search configuration on a singleton', () => {
|
|
596
|
+
expect(() => validateCollections([singleton({ search: { body: ['name'] } })])).toThrow(/search/);
|
|
597
|
+
});
|
|
598
|
+
it('rejects advertiseLocales on a singleton', () => {
|
|
599
|
+
expect(() => validateCollections([singleton({ advertiseLocales: true })])).toThrow(/advertiseLocales/);
|
|
600
|
+
});
|
|
601
|
+
it('accepts a well-formed singleton', () => {
|
|
602
|
+
expect(() => validateCollections([singleton({})])).not.toThrow();
|
|
603
|
+
});
|
|
604
|
+
it('rejects a relation whose target is a singleton', () => {
|
|
605
|
+
const singletonTarget = singleton({});
|
|
606
|
+
const referrer = {
|
|
607
|
+
path: 'news',
|
|
608
|
+
labels: { singular: 'Article', plural: 'News' },
|
|
609
|
+
fields: [
|
|
610
|
+
{
|
|
611
|
+
name: 'settings',
|
|
612
|
+
label: 'Settings',
|
|
613
|
+
type: 'relation',
|
|
614
|
+
targetCollection: 'site-settings',
|
|
615
|
+
},
|
|
616
|
+
],
|
|
617
|
+
};
|
|
618
|
+
expect(() => validateCollections([singletonTarget, referrer])).toThrow(/singleton/i);
|
|
619
|
+
});
|
|
620
|
+
it('rejects labels on a singleton at runtime', () => {
|
|
621
|
+
expect(() => validateCollections([singleton({ labels: { singular: 'S', plural: 'P' } })])).toThrow(/labels|label/i);
|
|
622
|
+
});
|
|
623
|
+
it('accepts the singleton hook family', () => {
|
|
624
|
+
expect(() => validateCollections([
|
|
625
|
+
singleton({
|
|
626
|
+
hooks: { beforeRead: () => false },
|
|
627
|
+
}),
|
|
628
|
+
])).not.toThrow();
|
|
629
|
+
});
|
|
630
|
+
it('still applies field-level validation to a singleton', () => {
|
|
631
|
+
expect(() => validateCollections([
|
|
632
|
+
singleton({
|
|
633
|
+
fields: [
|
|
634
|
+
{
|
|
635
|
+
name: 'heroImage',
|
|
636
|
+
label: 'Hero image',
|
|
637
|
+
type: 'image',
|
|
638
|
+
upload: { location: '/leading-slash-is-invalid' },
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
}),
|
|
642
|
+
])).toThrow(/location/);
|
|
643
|
+
});
|
|
644
|
+
it('rejects a singleton whose path collides with a collection', () => {
|
|
645
|
+
const collection = {
|
|
646
|
+
path: 'site-settings',
|
|
647
|
+
labels: { singular: 'Setting', plural: 'Settings' },
|
|
648
|
+
fields: [{ name: 'title', label: 'Title', type: 'text' }],
|
|
649
|
+
};
|
|
650
|
+
expect(() => validateCollections([collection, singleton({})])).toThrow(/site-settings/);
|
|
651
|
+
});
|
|
652
|
+
});
|
package/dist/core.d.ts
CHANGED
|
@@ -30,9 +30,8 @@ export interface BylineCore<TAdminStore = unknown> {
|
|
|
30
30
|
*/
|
|
31
31
|
getCollectionRecord: (path: string) => CollectionRecord;
|
|
32
32
|
/**
|
|
33
|
-
* Ability registry. Populated at init time with the
|
|
34
|
-
*
|
|
35
|
-
* (`collections.<path>.{read,create,update,delete,publish,changeStatus}`).
|
|
33
|
+
* Ability registry. Populated at init time with the kind-aware ability
|
|
34
|
+
* family contributed by every declared document resource.
|
|
36
35
|
*
|
|
37
36
|
* Plugins and future subsystems contribute their own abilities via
|
|
38
37
|
* `registerAbility()` — or directly against `core.abilities` — typically
|
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);
|