@byline/core 4.16.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.
Files changed (92) hide show
  1. package/dist/@types/admin-types.d.ts +86 -31
  2. package/dist/@types/admin-types.js +12 -0
  3. package/dist/@types/collection-types.d.ts +211 -29
  4. package/dist/@types/collection-types.js +68 -8
  5. package/dist/@types/db-types.d.ts +50 -0
  6. package/dist/@types/form-admin-config.test.node.d.ts +6 -0
  7. package/dist/@types/form-admin-config.test.node.js +26 -0
  8. package/dist/@types/singleton-admin-config.test.node.js +66 -0
  9. package/dist/@types/singleton-definition.test.node.d.ts +8 -0
  10. package/dist/@types/singleton-definition.test.node.js +75 -0
  11. package/dist/@types/site-config.d.ts +9 -8
  12. package/dist/auth/apply-before-read.d.ts +2 -1
  13. package/dist/auth/apply-before-read.js +13 -3
  14. package/dist/auth/apply-before-read.test.node.js +44 -0
  15. package/dist/auth/assert-actor-can-perform.d.ts +4 -3
  16. package/dist/auth/assert-actor-can-perform.js +9 -7
  17. package/dist/auth/assert-actor-can-perform.test.node.js +32 -11
  18. package/dist/auth/filter-readable-collections.d.ts +6 -6
  19. package/dist/auth/filter-readable-collections.js +8 -8
  20. package/dist/auth/filter-readable-collections.test.node.js +26 -1
  21. package/dist/auth/index.d.ts +1 -1
  22. package/dist/auth/index.js +1 -1
  23. package/dist/auth/register-collection-abilities.d.ts +34 -30
  24. package/dist/auth/register-collection-abilities.js +81 -77
  25. package/dist/auth/register-collection-abilities.test.node.js +86 -8
  26. package/dist/codegen/fixtures/all-fields.expected.d.ts +3 -0
  27. package/dist/codegen/index.d.ts +1 -1
  28. package/dist/codegen/index.js +32 -16
  29. package/dist/codegen/index.test.node.js +35 -2
  30. package/dist/config/attach-hooks.d.ts +3 -3
  31. package/dist/config/attach-hooks.js +8 -0
  32. package/dist/config/attach-hooks.test.node.js +59 -0
  33. package/dist/config/config-hooks.test.node.js +34 -1
  34. package/dist/config/config.d.ts +2 -1
  35. package/dist/config/config.js +9 -1
  36. package/dist/config/group-collections.d.ts +10 -9
  37. package/dist/config/group-collections.js +4 -4
  38. package/dist/config/group-collections.test.node.js +14 -0
  39. package/dist/config/validate-admin-configs.d.ts +4 -4
  40. package/dist/config/validate-admin-configs.js +40 -7
  41. package/dist/config/validate-admin-configs.test.node.js +44 -0
  42. package/dist/config/validate-collections.js +43 -0
  43. package/dist/config/validate-collections.test.node.js +75 -0
  44. package/dist/core.d.ts +2 -3
  45. package/dist/core.js +3 -3
  46. package/dist/index.d.ts +2 -2
  47. package/dist/index.js +2 -2
  48. package/dist/lib/errors.d.ts +5 -3
  49. package/dist/lib/errors.js +1 -0
  50. package/dist/services/collection-bootstrap.js +12 -0
  51. package/dist/services/collection-bootstrap.test.node.js +63 -6
  52. package/dist/services/discover-counter-groups.test.node.js +6 -0
  53. package/dist/services/document-lifecycle/copy-to-locale.js +9 -17
  54. package/dist/services/document-lifecycle/create.js +6 -11
  55. package/dist/services/document-lifecycle/delete-locale.js +1 -1
  56. package/dist/services/document-lifecycle/delete.js +1 -1
  57. package/dist/services/document-lifecycle/duplicate.js +7 -18
  58. package/dist/services/document-lifecycle/persistence.d.ts +29 -0
  59. package/dist/services/document-lifecycle/persistence.js +42 -0
  60. package/dist/services/document-lifecycle/restore.js +9 -17
  61. package/dist/services/document-lifecycle/scheduled-publish.js +5 -5
  62. package/dist/services/document-lifecycle/status.js +3 -3
  63. package/dist/services/document-lifecycle/system-fields.js +1 -1
  64. package/dist/services/document-lifecycle/tree.js +2 -2
  65. package/dist/services/document-lifecycle/tree.test.node.js +6 -1
  66. package/dist/services/document-lifecycle/update.js +20 -36
  67. package/dist/services/document-lifecycle.test.node.js +35 -0
  68. package/dist/services/field-upload.d.ts +1 -1
  69. package/dist/services/field-upload.js +20 -6
  70. package/dist/services/field-upload.test.node.js +125 -9
  71. package/dist/services/index.d.ts +1 -0
  72. package/dist/services/index.js +1 -0
  73. package/dist/services/populate.js +1 -1
  74. package/dist/services/populate.test.node.js +9 -0
  75. package/dist/services/richtext-populate.js +1 -1
  76. package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +16 -0
  77. package/dist/services/singleton-lifecycle/copy-to-locale.js +100 -0
  78. package/dist/services/singleton-lifecycle/index.d.ts +14 -0
  79. package/dist/services/singleton-lifecycle/index.js +11 -0
  80. package/dist/services/singleton-lifecycle/internals.d.ts +49 -0
  81. package/dist/services/singleton-lifecycle/internals.js +118 -0
  82. package/dist/services/singleton-lifecycle/restore.d.ts +13 -0
  83. package/dist/services/singleton-lifecycle/restore.js +93 -0
  84. package/dist/services/singleton-lifecycle/update.d.ts +16 -0
  85. package/dist/services/singleton-lifecycle/update.js +107 -0
  86. package/dist/services/singleton-lifecycle.test.node.d.ts +8 -0
  87. package/dist/services/singleton-lifecycle.test.node.js +396 -0
  88. package/dist/storage/collection-fingerprint.js +18 -6
  89. package/dist/storage/collection-fingerprint.test.node.js +15 -0
  90. package/package.json +5 -5
  91. package/dist/config/resolve-item-view-columns.test.node.js +0 -30
  92. /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 { registerCollectionAbilities } from './auth/register-collection-abilities.js';
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 collection's CRUD + workflow
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
- registerCollectionAbilities(abilities, definition);
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';
@@ -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 constraint / index name when the
151
- * driver exposes it (Postgres carries it structurally; MySQL parses it from
152
- * the error message). Absent when the driver surfaces no name.
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
  }
@@ -181,5 +181,6 @@ export const ERR_TREE_HOOK_COMMITTED = createErrorType(ErrorCodes.TREE_HOOK_COMM
181
181
  */
182
182
  export const DbErrorCodes = {
183
183
  UNIQUE_VIOLATION: 'DB_UNIQUE_VIOLATION',
184
+ FOREIGN_KEY_VIOLATION: 'DB_FOREIGN_KEY_VIOLATION',
184
185
  UNKNOWN: 'DB_UNKNOWN',
185
186
  };
@@ -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 { actorId, applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
13
+ import { applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
14
14
  import { mergeLocaleData } from './merge-locale-data.js';
15
- import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
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, collectionPath, 'update');
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 commitContentVersionWithScheduleSuspension({
128
- ctx,
127
+ const writeResult = await persistExistingDocumentVersion(ctx, {
129
128
  documentId: params.documentId,
130
- write: () => db.commands.documents.createDocumentVersion({
131
- documentId: params.documentId,
132
- collectionId,
133
- collectionVersion: ctx.collectionVersion,
134
- collectionConfig: definition,
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 { actorId, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
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, collectionId, collectionPath, defaultLocale } = ctx;
37
- assertActorCanPerform(ctx.requestContext, collectionPath, 'create');
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 db.commands.documents
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
- createdBy: actorId(ctx),
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, collectionPath, 'update');
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, collectionPath, 'delete');
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 { actorId, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, isPathConflictError, maybeAppendOrderKey, rethrowPathConflict, stripMetaIdsInPlace, } from './internals.js';
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, collectionPath, 'create');
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 db.commands.documents
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
- createdBy: actorId(ctx),
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 db.commands.documents
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
- createdBy: actorId(ctx),
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 { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, } from './internals.js';
14
- import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
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, collectionPath, 'update');
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 commitContentVersionWithScheduleSuspension({
134
- ctx,
133
+ const result = await persistExistingDocumentVersion(ctx, {
135
134
  documentId: params.documentId,
136
- write: () => db.commands.documents.createDocumentVersion({
137
- documentId: params.documentId,
138
- collectionId,
139
- collectionVersion: ctx.collectionVersion,
140
- collectionConfig: definition,
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 { collectionAbilityKey } from '../../auth/register-collection-abilities.js';
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.collectionPath, 'changeStatus');
28
- assertActorCanPerform(ctx.requestContext, ctx.collectionPath, 'publish');
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(collectionAbilityKey(definition.path, 'changeStatus')) &&
221
- actor.hasAbility(collectionAbilityKey(definition.path, 'publish'))) {
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, collectionPath, 'changeStatus');
36
+ assertActorCanPerform(ctx.requestContext, definition, 'changeStatus');
37
37
  if (params.nextStatus === 'published') {
38
- assertActorCanPerform(ctx.requestContext, collectionPath, 'publish');
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, collectionPath, 'changeStatus');
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, collectionPath, 'update');
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.collectionPath, 'update');
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.collectionPath, 'update');
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
- expect(() => validateTreeAuditCapability([{ ...harness.ctx.definition, tree: false }], unsupported)).not.toThrow();
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 () => {