@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
@@ -6,6 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { describe, expect, it } from 'vitest';
9
+ import { defineSingleton } from '../@types/index.js';
9
10
  import { filterReadableCollections } from './filter-readable-collections.js';
10
11
  const define = (path) => ({
11
12
  path,
@@ -15,10 +16,16 @@ const define = (path) => ({
15
16
  const news = define('news');
16
17
  const pages = define('pages');
17
18
  const media = define('media');
19
+ const settings = defineSingleton({
20
+ path: 'site-settings',
21
+ label: 'Site settings',
22
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
23
+ });
18
24
  const all = [news, pages, media];
19
25
  describe('filterReadableCollections', () => {
20
26
  it('returns every collection for a super admin, regardless of abilities', () => {
21
- expect(filterReadableCollections(all, { isSuperAdmin: true, abilities: [] })).toEqual(all);
27
+ const resources = [...all, settings];
28
+ expect(filterReadableCollections(resources, { isSuperAdmin: true, abilities: [] })).toEqual(resources);
22
29
  });
23
30
  it('returns nothing when the actor holds no abilities', () => {
24
31
  expect(filterReadableCollections(all, { isSuperAdmin: false, abilities: [] })).toEqual([]);
@@ -51,6 +58,24 @@ describe('filterReadableCollections', () => {
51
58
  });
52
59
  expect(result).toEqual([]);
53
60
  });
61
+ it('uses the singleton read namespace for singleton resources', () => {
62
+ expect(filterReadableCollections([settings], {
63
+ isSuperAdmin: false,
64
+ abilities: ['singletons.site-settings.read'],
65
+ })).toEqual([settings]);
66
+ });
67
+ it('does not let the collection namespace bleed into a singleton path', () => {
68
+ expect(filterReadableCollections([settings], {
69
+ isSuperAdmin: false,
70
+ abilities: ['collections.site-settings.read'],
71
+ })).toEqual([]);
72
+ });
73
+ it('does not advertise an update-only singleton without read access', () => {
74
+ expect(filterReadableCollections([settings], {
75
+ isSuperAdmin: false,
76
+ abilities: ['singletons.site-settings.update'],
77
+ })).toEqual([]);
78
+ });
54
79
  it('does not match on a prefix of a collection path', () => {
55
80
  const result = filterReadableCollections([define('news-categories')], {
56
81
  isSuperAdmin: false,
@@ -8,4 +8,4 @@
8
8
  export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
9
9
  export { assertActorCanPerform } from './assert-actor-can-perform.js';
10
10
  export { type ActorAbilitySnapshot, filterReadableCollections, } from './filter-readable-collections.js';
11
- export { COLLECTION_ABILITY_VERBS, type CollectionAbilityVerb, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
11
+ export { COLLECTION_ABILITY_VERBS, type CollectionAbilityResource, type CollectionAbilityResourceDescriptor, type CollectionAbilityVerb, collectionAbilityKey, type DocumentAbilityResource, type DocumentAbilityVerbFor, documentAbilityKey, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, type SingletonAbilityResource, type SingletonAbilityResourceDescriptor, type SingletonAbilityVerb, } from './register-collection-abilities.js';
@@ -8,4 +8,4 @@
8
8
  export { applyBeforeRead, bindReadContextAuthority, compileBeforeReadFilters, } from './apply-before-read.js';
9
9
  export { assertActorCanPerform } from './assert-actor-can-perform.js';
10
10
  export { filterReadableCollections, } from './filter-readable-collections.js';
11
- export { COLLECTION_ABILITY_VERBS, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
11
+ export { COLLECTION_ABILITY_VERBS, collectionAbilityKey, documentAbilityKey, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, } from './register-collection-abilities.js';
@@ -6,37 +6,41 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import type { AbilityRegistry } from '@byline/auth';
9
- import type { CollectionDefinition } from '../@types/index.js';
10
- /**
11
- * Auto-register the CRUD + workflow abilities contributed by a collection.
12
- *
13
- * Every registered collection contributes exactly seven abilities, all in
14
- * the `collections.<path>` group:
15
- *
16
- * - `collections.<path>.read` — enumerate / fetch documents
17
- * - `collections.<path>.create` — create new documents
18
- * - `collections.<path>.update` — modify existing documents
19
- * - `collections.<path>.delete` — delete documents (soft or hard)
20
- * - `collections.<path>.publish` — transition a document into the
21
- * `published` status
22
- * - `collections.<path>.changeStatus` — any other workflow transition
23
- * (draft → custom state, etc.)
24
- * - `collections.<path>.reindex` — rebuild the collection's search
25
- * index (admin maintenance task)
26
- *
27
- * Registration is unconditional: every collection in Byline has a workflow
28
- * (the default `draft → published → archived` one when not explicitly
29
- * configured), so `publish` and `changeStatus` always apply; `reindex` is
30
- * likewise uniform (a no-op for collections without a `search` config).
31
- * Keeping the seven-ability contract uniform makes the role editor UI
32
- * predictable and avoids hidden conditional logic downstream.
33
- *
34
- * Called from `initBylineCore()` for each declared collection. See
35
- * docs/07-auth-and-security/01-authn-authz.md.
36
- */
37
- export declare function registerCollectionAbilities(registry: AbilityRegistry, definition: CollectionDefinition): void;
9
+ import { type CollectionDefinition, type MultiCollectionDefinition, type SingletonDefinition } from '../@types/index.js';
38
10
  /** The ability suffixes that every collection contributes. Exposed for contract tests. */
39
11
  export declare const COLLECTION_ABILITY_VERBS: readonly ['read', 'create', 'update', 'delete', 'publish', 'changeStatus', 'reindex'];
12
+ /** The smaller ability family contributed by every singleton. */
13
+ export declare const SINGLETON_ABILITY_VERBS: readonly ['read', 'update', 'publish', 'changeStatus'];
40
14
  export type CollectionAbilityVerb = (typeof COLLECTION_ABILITY_VERBS)[number];
41
- /** Compute the full ability key for a collection path and verb. */
15
+ export type SingletonAbilityVerb = (typeof SINGLETON_ABILITY_VERBS)[number];
16
+ /** Minimal explicit descriptor for collection ability checks. */
17
+ export interface CollectionAbilityResourceDescriptor {
18
+ kind: 'collection';
19
+ path: string;
20
+ }
21
+ /** Minimal explicit descriptor for singleton ability checks. */
22
+ export interface SingletonAbilityResourceDescriptor {
23
+ kind: 'singleton';
24
+ path: string;
25
+ }
26
+ export type CollectionAbilityResource = MultiCollectionDefinition | CollectionAbilityResourceDescriptor;
27
+ export type SingletonAbilityResource = SingletonDefinition | SingletonAbilityResourceDescriptor;
28
+ export type DocumentAbilityResource = CollectionAbilityResource | SingletonAbilityResource;
29
+ /** Correlate the permitted verb family with the resource kind. */
30
+ export type DocumentAbilityVerbFor<Resource extends DocumentAbilityResource> = Resource extends SingletonAbilityResource ? SingletonAbilityVerb : CollectionAbilityVerb;
31
+ /**
32
+ * Compute a document-resource ability key from a kind-bearing descriptor.
33
+ *
34
+ * TypeScript callers cannot pair a known singleton with collection-only verbs.
35
+ * Runtime validation preserves the same boundary for untyped JavaScript callers
36
+ * and for union-typed definitions whose concrete kind is known only at runtime.
37
+ */
38
+ export declare function documentAbilityKey<Resource extends DocumentAbilityResource>(resource: Resource, verb: DocumentAbilityVerbFor<Resource>): string;
39
+ /** @deprecated Use {@link documentAbilityKey} with a kind-bearing resource descriptor. */
42
40
  export declare function collectionAbilityKey(path: string, verb: CollectionAbilityVerb): string;
41
+ /** Auto-register the seven abilities contributed by a multi-document collection. */
42
+ export declare function registerCollectionAbilities(registry: AbilityRegistry, definition: MultiCollectionDefinition): void;
43
+ /** Auto-register the four read, update, and workflow abilities for a singleton. */
44
+ export declare function registerSingletonAbilities(registry: AbilityRegistry, definition: SingletonDefinition): void;
45
+ /** Register the correct ability family for either document-resource kind. */
46
+ export declare function registerDocumentAbilities(registry: AbilityRegistry, definition: CollectionDefinition): void;
@@ -5,81 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- /**
9
- * Auto-register the CRUD + workflow abilities contributed by a collection.
10
- *
11
- * Every registered collection contributes exactly seven abilities, all in
12
- * the `collections.<path>` group:
13
- *
14
- * - `collections.<path>.read` — enumerate / fetch documents
15
- * - `collections.<path>.create` — create new documents
16
- * - `collections.<path>.update` — modify existing documents
17
- * - `collections.<path>.delete` — delete documents (soft or hard)
18
- * - `collections.<path>.publish` — transition a document into the
19
- * `published` status
20
- * - `collections.<path>.changeStatus` — any other workflow transition
21
- * (draft → custom state, etc.)
22
- * - `collections.<path>.reindex` — rebuild the collection's search
23
- * index (admin maintenance task)
24
- *
25
- * Registration is unconditional: every collection in Byline has a workflow
26
- * (the default `draft → published → archived` one when not explicitly
27
- * configured), so `publish` and `changeStatus` always apply; `reindex` is
28
- * likewise uniform (a no-op for collections without a `search` config).
29
- * Keeping the seven-ability contract uniform makes the role editor UI
30
- * predictable and avoids hidden conditional logic downstream.
31
- *
32
- * Called from `initBylineCore()` for each declared collection. See
33
- * docs/07-auth-and-security/01-authn-authz.md.
34
- */
35
- export function registerCollectionAbilities(registry, definition) {
36
- const path = definition.path;
37
- const group = `collections.${path}`;
38
- const base = `collections.${path}`;
39
- const { singular, plural } = definition.labels;
40
- registry.register({
41
- key: `${base}.read`,
42
- label: `Read ${plural}`,
43
- group,
44
- source: 'collection',
45
- });
46
- registry.register({
47
- key: `${base}.create`,
48
- label: `Create ${singular}`,
49
- group,
50
- source: 'collection',
51
- });
52
- registry.register({
53
- key: `${base}.update`,
54
- label: `Update ${singular}`,
55
- group,
56
- source: 'collection',
57
- });
58
- registry.register({
59
- key: `${base}.delete`,
60
- label: `Delete ${singular}`,
61
- group,
62
- source: 'collection',
63
- });
64
- registry.register({
65
- key: `${base}.publish`,
66
- label: `Publish ${plural}`,
67
- group,
68
- source: 'collection',
69
- });
70
- registry.register({
71
- key: `${base}.changeStatus`,
72
- label: `Change status of ${plural}`,
73
- group,
74
- source: 'collection',
75
- });
76
- registry.register({
77
- key: `${base}.reindex`,
78
- label: `Reindex ${plural} search`,
79
- group,
80
- source: 'collection',
81
- });
82
- }
8
+ import { isSingleton, } from '../@types/index.js';
83
9
  /** The ability suffixes that every collection contributes. Exposed for contract tests. */
84
10
  export const COLLECTION_ABILITY_VERBS = [
85
11
  'read',
@@ -90,7 +16,85 @@ export const COLLECTION_ABILITY_VERBS = [
90
16
  'changeStatus',
91
17
  'reindex',
92
18
  ];
93
- /** Compute the full ability key for a collection path and verb. */
19
+ /** The smaller ability family contributed by every singleton. */
20
+ export const SINGLETON_ABILITY_VERBS = ['read', 'update', 'publish', 'changeStatus'];
21
+ /**
22
+ * Compute a document-resource ability key from a kind-bearing descriptor.
23
+ *
24
+ * TypeScript callers cannot pair a known singleton with collection-only verbs.
25
+ * Runtime validation preserves the same boundary for untyped JavaScript callers
26
+ * and for union-typed definitions whose concrete kind is known only at runtime.
27
+ */
28
+ export function documentAbilityKey(resource, verb) {
29
+ const kind = resourceKind(resource);
30
+ const candidate = verb;
31
+ const allowed = kind === 'singleton' ? SINGLETON_ABILITY_VERBS : COLLECTION_ABILITY_VERBS;
32
+ if (!allowed.includes(candidate)) {
33
+ throw new TypeError(`ability verb '${candidate}' is not valid for resource kind '${kind}'`);
34
+ }
35
+ return `${resourceNamespace(kind)}.${resource.path}.${candidate}`;
36
+ }
37
+ /** @deprecated Use {@link documentAbilityKey} with a kind-bearing resource descriptor. */
94
38
  export function collectionAbilityKey(path, verb) {
95
- return `collections.${path}.${verb}`;
39
+ return documentAbilityKey({ kind: 'collection', path }, verb);
40
+ }
41
+ /** Auto-register the seven abilities contributed by a multi-document collection. */
42
+ export function registerCollectionAbilities(registry, definition) {
43
+ const group = documentAbilityGroup(definition);
44
+ const { singular, plural } = definition.labels;
45
+ const labels = {
46
+ read: `Read ${plural}`,
47
+ create: `Create ${singular}`,
48
+ update: `Update ${singular}`,
49
+ delete: `Delete ${singular}`,
50
+ publish: `Publish ${plural}`,
51
+ changeStatus: `Change status of ${plural}`,
52
+ reindex: `Reindex ${plural} search`,
53
+ };
54
+ for (const verb of COLLECTION_ABILITY_VERBS) {
55
+ registry.register({
56
+ key: documentAbilityKey(definition, verb),
57
+ label: labels[verb],
58
+ group,
59
+ source: 'collection',
60
+ });
61
+ }
62
+ }
63
+ /** Auto-register the four read, update, and workflow abilities for a singleton. */
64
+ export function registerSingletonAbilities(registry, definition) {
65
+ const group = documentAbilityGroup(definition);
66
+ const label = definition.label;
67
+ const labels = {
68
+ read: `Read ${label}`,
69
+ update: `Update ${label}`,
70
+ publish: `Publish ${label}`,
71
+ changeStatus: `Change status of ${label}`,
72
+ };
73
+ for (const verb of SINGLETON_ABILITY_VERBS) {
74
+ registry.register({
75
+ key: documentAbilityKey(definition, verb),
76
+ label: labels[verb],
77
+ group,
78
+ source: 'collection',
79
+ });
80
+ }
81
+ }
82
+ /** Register the correct ability family for either document-resource kind. */
83
+ export function registerDocumentAbilities(registry, definition) {
84
+ if (isSingleton(definition)) {
85
+ registerSingletonAbilities(registry, definition);
86
+ return;
87
+ }
88
+ registerCollectionAbilities(registry, definition);
89
+ }
90
+ function documentAbilityGroup(resource) {
91
+ return `${resourceNamespace(resourceKind(resource))}.${resource.path}`;
92
+ }
93
+ function resourceKind(resource) {
94
+ if ('kind' in resource)
95
+ return resource.kind;
96
+ return resource.singleton === true ? 'singleton' : 'collection';
97
+ }
98
+ function resourceNamespace(kind) {
99
+ return kind === 'singleton' ? 'singletons' : 'collections';
96
100
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { AbilityRegistry } from '@byline/auth';
9
9
  import { describe, expect, it } from 'vitest';
10
- import { COLLECTION_ABILITY_VERBS, collectionAbilityKey, registerCollectionAbilities, } from './register-collection-abilities.js';
10
+ import { COLLECTION_ABILITY_VERBS, collectionAbilityKey, documentAbilityKey, registerCollectionAbilities, registerDocumentAbilities, registerSingletonAbilities, SINGLETON_ABILITY_VERBS, } from './register-collection-abilities.js';
11
11
  function pageCollection() {
12
12
  return {
13
13
  path: 'pages',
@@ -30,8 +30,16 @@ function newsCollection() {
30
30
  },
31
31
  };
32
32
  }
33
+ function siteSettings() {
34
+ return {
35
+ singleton: true,
36
+ path: 'site-settings',
37
+ label: 'Site settings',
38
+ fields: [{ name: 'title', type: 'text' }],
39
+ };
40
+ }
33
41
  describe('registerCollectionAbilities', () => {
34
- it('registers exactly the six CRUD + workflow abilities for a collection', () => {
42
+ it('registers exactly the seven collection abilities', () => {
35
43
  const registry = new AbilityRegistry();
36
44
  registerCollectionAbilities(registry, pageCollection());
37
45
  const keys = registry.list().map((d) => d.key);
@@ -101,7 +109,47 @@ describe('registerCollectionAbilities', () => {
101
109
  expect(registry.size).toBe(14);
102
110
  });
103
111
  });
104
- describe('COLLECTION_ABILITY_VERBS / collectionAbilityKey', () => {
112
+ describe('registerSingletonAbilities', () => {
113
+ it('registers exactly the four singleton abilities', () => {
114
+ const registry = new AbilityRegistry();
115
+ registerSingletonAbilities(registry, siteSettings());
116
+ expect(registry.list().map((descriptor) => descriptor.key)).toEqual([
117
+ 'singletons.site-settings.read',
118
+ 'singletons.site-settings.update',
119
+ 'singletons.site-settings.publish',
120
+ 'singletons.site-settings.changeStatus',
121
+ ]);
122
+ });
123
+ it('uses the singleton label and one singleton group', () => {
124
+ const registry = new AbilityRegistry();
125
+ registerSingletonAbilities(registry, siteSettings());
126
+ expect(registry.list().map((descriptor) => descriptor.label)).toEqual([
127
+ 'Read Site settings',
128
+ 'Update Site settings',
129
+ 'Publish Site settings',
130
+ 'Change status of Site settings',
131
+ ]);
132
+ expect(registry.byGroup().get('singletons.site-settings')).toHaveLength(4);
133
+ });
134
+ it('does not register collection-only verbs', () => {
135
+ const registry = new AbilityRegistry();
136
+ registerSingletonAbilities(registry, siteSettings());
137
+ expect(registry.has('singletons.site-settings.create')).toBe(false);
138
+ expect(registry.has('singletons.site-settings.delete')).toBe(false);
139
+ expect(registry.has('singletons.site-settings.reindex')).toBe(false);
140
+ });
141
+ });
142
+ describe('registerDocumentAbilities', () => {
143
+ it('dispatches both definition kinds into isolated ability families', () => {
144
+ const registry = new AbilityRegistry();
145
+ registerDocumentAbilities(registry, pageCollection());
146
+ registerDocumentAbilities(registry, siteSettings());
147
+ expect(registry.size).toBe(11);
148
+ expect(registry.byGroup().get('collections.pages')).toHaveLength(7);
149
+ expect(registry.byGroup().get('singletons.site-settings')).toHaveLength(4);
150
+ });
151
+ });
152
+ describe('ability verb contracts / documentAbilityKey', () => {
105
153
  it('COLLECTION_ABILITY_VERBS exposes the canonical verb list in registration order', () => {
106
154
  expect(COLLECTION_ABILITY_VERBS).toEqual([
107
155
  'read',
@@ -113,15 +161,45 @@ describe('COLLECTION_ABILITY_VERBS / collectionAbilityKey', () => {
113
161
  'reindex',
114
162
  ]);
115
163
  });
116
- it('collectionAbilityKey composes a flat dotted key', () => {
117
- expect(collectionAbilityKey('pages', 'publish')).toBe('collections.pages.publish');
118
- expect(collectionAbilityKey('news', 'changeStatus')).toBe('collections.news.changeStatus');
164
+ it('SINGLETON_ABILITY_VERBS exposes the canonical verb list in registration order', () => {
165
+ expect(SINGLETON_ABILITY_VERBS).toEqual(['read', 'update', 'publish', 'changeStatus']);
166
+ });
167
+ it('composes a kind-aware flat dotted key from definitions and explicit descriptors', () => {
168
+ expect(documentAbilityKey(pageCollection(), 'publish')).toBe('collections.pages.publish');
169
+ expect(documentAbilityKey(siteSettings(), 'changeStatus')).toBe('singletons.site-settings.changeStatus');
170
+ expect(documentAbilityKey({ kind: 'collection', path: 'news' }, 'read')).toBe('collections.news.read');
171
+ expect(documentAbilityKey({ kind: 'singleton', path: 'navigation' }, 'update')).toBe('singletons.navigation.update');
172
+ });
173
+ it('preserves the deprecated collectionAbilityKey string-path contract', () => {
174
+ expect(collectionAbilityKey('news', 'read')).toBe('collections.news.read');
119
175
  });
120
176
  it('matches the keys produced by registerCollectionAbilities', () => {
121
177
  const registry = new AbilityRegistry();
122
- registerCollectionAbilities(registry, pageCollection());
178
+ const definition = pageCollection();
179
+ registerCollectionAbilities(registry, definition);
123
180
  for (const verb of COLLECTION_ABILITY_VERBS) {
124
- expect(registry.has(collectionAbilityKey('pages', verb))).toBe(true);
181
+ expect(registry.has(documentAbilityKey(definition, verb))).toBe(true);
125
182
  }
126
183
  });
184
+ it('matches the keys produced by registerSingletonAbilities', () => {
185
+ const registry = new AbilityRegistry();
186
+ const definition = siteSettings();
187
+ registerSingletonAbilities(registry, definition);
188
+ for (const verb of SINGLETON_ABILITY_VERBS) {
189
+ expect(registry.has(documentAbilityKey(definition, verb))).toBe(true);
190
+ }
191
+ });
192
+ it('rejects collection-only verbs for singleton descriptors at compile time and runtime', () => {
193
+ const singleton = siteSettings();
194
+ const compileOnlyInvalidCall = () => {
195
+ documentAbilityKey(singleton,
196
+ // @ts-expect-error create is not a singleton ability verb
197
+ 'create');
198
+ };
199
+ expect(compileOnlyInvalidCall).toBeTypeOf('function');
200
+ const untypedKey = documentAbilityKey;
201
+ expect(() => untypedKey(singleton, 'create')).toThrow(/verb 'create'.*singleton/);
202
+ expect(() => untypedKey(singleton, 'delete')).toThrow(/verb 'delete'.*singleton/);
203
+ expect(() => untypedKey(singleton, 'reindex')).toThrow(/verb 'reindex'.*singleton/);
204
+ });
127
205
  });
@@ -122,3 +122,6 @@ export type CollectionFieldsAllLocalesByPath = {
122
122
  minimal: MinimalFieldsAllLocales;
123
123
  };
124
124
  export type CollectionPath = keyof CollectionFieldsByPath;
125
+ export type SingletonFieldsByPath = Record<never, never>;
126
+ export type SingletonFieldsAllLocalesByPath = Record<never, never>;
127
+ export type SingletonPath = keyof SingletonFieldsByPath;
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { CollectionDefinition } from '../@types/index.js';
8
+ import { type CollectionDefinition } from '../@types/index.js';
9
9
  /**
10
10
  * Emit deterministic application collection types from evaluated runtime definitions.
11
11
  * The returned hash is SHA-256 over the versioned format domain and generated module body.
@@ -6,8 +6,9 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { createHash } from 'node:crypto';
9
- const FORMAT_VERSION = 2;
10
- const HASH_DOMAIN = '@byline/core/codegen:collection-types:v2\n';
9
+ import { isSingleton, } from '../@types/index.js';
10
+ const FORMAT_VERSION = 3;
11
+ const HASH_DOMAIN = '@byline/core/codegen:collection-types:v3\n';
11
12
  const IMPORT_ORDER = ['JsonObject', 'JsonValue', 'RelatedDocumentValue', 'StoredFileValue'];
12
13
  function compareStrings(a, b) {
13
14
  return a < b ? -1 : a > b ? 1 : 0;
@@ -285,6 +286,8 @@ function emitBody(analysis) {
285
286
  alias: collectionNames.get(definition) ?? 'UnnamedFields',
286
287
  definition,
287
288
  }));
289
+ const multiCollectionContracts = collectionContracts.filter((collection) => !isSingleton(collection.definition));
290
+ const singletonContracts = collectionContracts.filter((collection) => isSingleton(collection.definition));
288
291
  const blockAliasByKey = new Map(blocks.map((block) => [block.key, block.alias]));
289
292
  function fieldSetType(fields, mode) {
290
293
  return objectType(fields.map((field) => ({
@@ -400,15 +403,25 @@ function emitBody(analysis) {
400
403
  for (const collection of [...collectionContracts].sort((a, b) => compareStrings(a.alias, b.alias))) {
401
404
  declarations.push(`export type ${collection.alias} = ${fieldSetType(collection.definition.fields, 'single').join('\n')}`, '', `export type ${collection.alias}AllLocales = ${fieldSetType(collection.definition.fields, 'all').join('\n')}`, '');
402
405
  }
403
- declarations.push('export type CollectionFieldsByPath = {');
404
- for (const collection of collectionContracts) {
405
- declarations.push(` ${propertyKey(collection.definition.path)}: ${collection.alias}`);
406
- }
407
- declarations.push('}', '', 'export type CollectionFieldsAllLocalesByPath = {');
408
- for (const collection of collectionContracts) {
409
- declarations.push(` ${propertyKey(collection.definition.path)}: ${collection.alias}AllLocales`);
406
+ function emitRegistry(name, contracts, suffix) {
407
+ if (contracts.length === 0) {
408
+ declarations.push(`export type ${name} = {}`);
409
+ return;
410
+ }
411
+ declarations.push(`export type ${name} = {`);
412
+ for (const contract of contracts) {
413
+ declarations.push(` ${propertyKey(contract.definition.path)}: ${contract.alias}${suffix}`);
414
+ }
415
+ declarations.push('}');
410
416
  }
411
- declarations.push('}', '', 'export type CollectionPath = keyof CollectionFieldsByPath');
417
+ emitRegistry('CollectionFieldsByPath', multiCollectionContracts, '');
418
+ declarations.push('');
419
+ emitRegistry('CollectionFieldsAllLocalesByPath', multiCollectionContracts, 'AllLocales');
420
+ declarations.push('', 'export type CollectionPath = keyof CollectionFieldsByPath', '');
421
+ emitRegistry('SingletonFieldsByPath', singletonContracts, '');
422
+ declarations.push('');
423
+ emitRegistry('SingletonFieldsAllLocalesByPath', singletonContracts, 'AllLocales');
424
+ declarations.push('', 'export type SingletonPath = keyof SingletonFieldsByPath');
412
425
  const usedImports = IMPORT_ORDER.filter((name) => imports.has(name));
413
426
  const importLines = usedImports.length === 0
414
427
  ? []
@@ -424,16 +437,19 @@ function emitBody(analysis) {
424
437
  .replace(/\n+$/, '')
425
438
  .split('\n')
426
439
  .map((line) => (line.length === 0 ? line : ` ${line}`));
427
- // Register the app's collection registry with @byline/client (a second
428
- // declaration merge). Every bare `BylineClient` in the app's program —
429
- // including the `@byline/client/server` getters — then resolves to
430
- // `BylineClient<CollectionFieldsByPath>`. Both `@byline/generated-types`
431
- // and `@byline/client` must be resolvable in the consuming program; apps
432
- // that generate types depend on both.
440
+ // Register the app's collection and singleton registries with @byline/client
441
+ // through a second declaration merge. Every bare `BylineClient` in the app's
442
+ // program — including the `@byline/client/server` getters — then resolves its
443
+ // collection generic to `CollectionFieldsByPath`. The singleton member is
444
+ // emitted now so Plan 3's parallel `RegisteredSingletons` fallback can resolve
445
+ // `SingletonFieldsByPath` through the same merge without runtime definitions.
446
+ // Both `@byline/generated-types` and `@byline/client` must be resolvable in
447
+ // the consuming program; apps that generate types depend on both.
433
448
  const registerLines = [
434
449
  "declare module '@byline/client' {",
435
450
  ' interface Register {',
436
451
  " collections: import('@byline/generated-types').CollectionFieldsByPath",
452
+ " singletons: import('@byline/generated-types').SingletonFieldsByPath",
437
453
  ' }',
438
454
  '}',
439
455
  ];
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { describe, expect, expectTypeOf, it } from 'vitest';
6
+ import { defineCollection, defineSingleton, } from '../@types/collection-types.js';
6
7
  import { AllFieldsCollection, AllFieldsCollections, } from './fixtures/all-fields.js';
7
8
  import { emitCollectionTypes } from './index.js';
8
9
  const fixtureDirectory = resolve(dirname(fileURLToPath(import.meta.url)), 'fixtures');
@@ -24,16 +25,19 @@ describe('emitCollectionTypes', () => {
24
25
  expectTypeOf().toEqualTypeOf();
25
26
  expectTypeOf().toEqualTypeOf();
26
27
  expectTypeOf().toEqualTypeOf();
28
+ expectTypeOf().toEqualTypeOf();
27
29
  });
28
30
  it('emits a versioned header, verifiable body hash, and canonical formatting', () => {
29
31
  const { source, hash } = emitCollectionTypes([collection('plain', [])]);
30
32
  const body = source.split('\n').slice(4).join('\n');
31
- expect(source).toMatch(/^\/\/ Generated by @byline\/core\/codegen\n\/\/ Format version: 2\n\/\/ Hash: [a-f0-9]{64}\n\n/);
32
- expect(hash).toBe(createHash('sha256').update(`@byline/core/codegen:collection-types:v2\n${body}`).digest('hex'));
33
+ expect(source).toMatch(/^\/\/ Generated by @byline\/core\/codegen\n\/\/ Format version: 3\n\/\/ Hash: [a-f0-9]{64}\n\n/);
34
+ expect(hash).toBe(createHash('sha256').update(`@byline/core/codegen:collection-types:v3\n${body}`).digest('hex'));
33
35
  expect(source).toContain(`// Hash: ${hash}`);
34
36
  expect(source.endsWith('\n')).toBe(true);
35
37
  expect(source).not.toContain('\r');
36
38
  expect(source).not.toContain(';');
39
+ expect(source).toContain(' export type SingletonFieldsByPath = {}');
40
+ expect(source).toContain(' export type SingletonFieldsAllLocalesByPath = {}');
37
41
  });
38
42
  it('declares every type inside the @byline/generated-types augmentation block', () => {
39
43
  const { source } = emitCollectionTypes([collection('plain', [{ name: 'title', type: 'text' }])]);
@@ -49,10 +53,39 @@ describe('emitCollectionTypes', () => {
49
53
  "declare module '@byline/client' {",
50
54
  ' interface Register {',
51
55
  " collections: import('@byline/generated-types').CollectionFieldsByPath",
56
+ " singletons: import('@byline/generated-types').SingletonFieldsByPath",
52
57
  ' }',
53
58
  '}',
54
59
  ].join('\n'));
55
60
  });
61
+ it('partitions collection and singleton registries and registers both with the client', () => {
62
+ const definitions = [
63
+ defineCollection({
64
+ path: 'articles',
65
+ labels: { singular: 'Article', plural: 'Articles' },
66
+ fields: [{ name: 'title', type: 'text' }],
67
+ }),
68
+ defineSingleton({
69
+ path: 'site-settings',
70
+ label: 'Site settings',
71
+ fields: [{ name: 'siteName', type: 'text', localized: true }],
72
+ }),
73
+ ];
74
+ const { source } = emitCollectionTypes(definitions);
75
+ expect(source).toContain('export type SingletonPath = keyof SingletonFieldsByPath');
76
+ expect(source).toContain('export type CollectionPath = keyof CollectionFieldsByPath');
77
+ expect(source).toMatch(/export type SingletonFieldsByPath = \{[^}]*'site-settings'/);
78
+ expect(source).toMatch(/export type SingletonFieldsAllLocalesByPath = \{[^}]*'site-settings'/);
79
+ expect(source).not.toMatch(/export type CollectionFieldsByPath = \{[^}]*'site-settings'/);
80
+ expect(source).not.toMatch(/export type CollectionFieldsAllLocalesByPath = \{[^}]*'site-settings'/);
81
+ expect(source).toMatch(/export type CollectionFieldsByPath = \{[^}]*articles/);
82
+ expect(source).not.toMatch(/export type SingletonFieldsByPath = \{[^}]*articles/);
83
+ expect(source).toContain("singletons: import('@byline/generated-types').SingletonFieldsByPath");
84
+ expectTypeOf().toEqualTypeOf();
85
+ expectTypeOf().toEqualTypeOf();
86
+ expectTypeOf().toEqualTypeOf();
87
+ expectTypeOf().toEqualTypeOf();
88
+ });
56
89
  it('sorts collections and declarations without changing field, select, or block union order', () => {
57
90
  const alpha = collection('alpha', [
58
91
  {
@@ -1,11 +1,11 @@
1
- import type { CollectionDefinition, CollectionHooks, CollectionHooksLoader, FileField, ImageField, ServerHooksConfig, UploadHooks, UploadHooksLoader } from '../@types/index.js';
2
- type CollectionHookValue = CollectionHooks | CollectionHooksLoader;
1
+ import type { CollectionDefinition, CollectionHooks, CollectionHooksLoader, FileField, ImageField, ServerHooksConfig, SingletonHooks, SingletonHooksLoader, UploadHooks, UploadHooksLoader } from '../@types/index.js';
2
+ type DocumentHookValue = CollectionHooks | CollectionHooksLoader | SingletonHooks | SingletonHooksLoader;
3
3
  type UploadHookValue = UploadHooks | UploadHooksLoader;
4
4
  type UploadField = ImageField | FileField;
5
5
  export type HookAttachmentOperation = {
6
6
  kind: 'collection';
7
7
  target: CollectionDefinition;
8
- hooks: CollectionHookValue | undefined;
8
+ hooks: DocumentHookValue | undefined;
9
9
  } | {
10
10
  kind: 'upload';
11
11
  target: UploadField;
@@ -1,3 +1,4 @@
1
+ import { assertHooksMatchDefinition } from '../@types/collection-types.js';
1
2
  import { formatDeclarationPath, walkFieldDeclarations } from '../paths/index.js';
2
3
  const registryCollectionHooks = new WeakMap();
3
4
  const registryUploadHooks = new WeakMap();
@@ -61,6 +62,8 @@ export function commitHookAttachment(operations) {
61
62
  }
62
63
  function planCollectionAttachment(collection, requested, operations) {
63
64
  const owned = registryCollectionHooks.get(collection);
65
+ assertInlineHookFamily(collection, collection.hooks);
66
+ assertInlineHookFamily(collection, requested);
64
67
  assertOwnershipIntact(`collection "${collection.path}"`, collection.hooks, owned);
65
68
  if (requested === undefined) {
66
69
  if (owned !== undefined) {
@@ -75,6 +78,11 @@ function planCollectionAttachment(collection, requested, operations) {
75
78
  operations.push({ kind: 'collection', target: collection, hooks: requested });
76
79
  }
77
80
  }
81
+ function assertInlineHookFamily(definition, hooks) {
82
+ if (hooks !== undefined && typeof hooks !== 'function') {
83
+ assertHooksMatchDefinition(definition, hooks);
84
+ }
85
+ }
78
86
  function planUploadAttachment(path, field, requested, operations) {
79
87
  const owned = registryUploadHooks.get(field);
80
88
  assertOwnershipIntact(`upload field "${path}"`, field.upload?.hooks, owned);