@byline/core 4.15.0 → 4.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -9,6 +9,14 @@ function collection(path = 'documents') {
9
9
  fields: [{ name: 'title', type: 'text' }],
10
10
  };
11
11
  }
12
+ function singleton(path = 'site-settings') {
13
+ return {
14
+ singleton: true,
15
+ path,
16
+ label: 'Site settings',
17
+ fields: [{ name: 'title', type: 'text' }],
18
+ };
19
+ }
12
20
  function nestedUploadCollection() {
13
21
  const nested = { name: 'asset', type: 'file', upload: {} };
14
22
  const arrayFile = { name: 'publicationFile', type: 'file', upload: {} };
@@ -37,6 +45,57 @@ function nestedUploadCollection() {
37
45
  };
38
46
  }
39
47
  describe('server hook attachment', () => {
48
+ it('rejects inline hooks from the wrong resource family at startup', () => {
49
+ const singletonDefinition = singleton();
50
+ singletonDefinition.hooks = { beforeCreate: () => { } };
51
+ expect(() => prepareHookAttachment({ collections: [singletonDefinition] })).toThrow(/singleton.*beforeCreate/i);
52
+ const collectionDefinition = collection();
53
+ const singletonHooks = { beforeSave: () => { } };
54
+ expect(() => prepareHookAttachment({
55
+ collections: [collectionDefinition],
56
+ hooks: { collections: { documents: singletonHooks } },
57
+ })).toThrow(/collection.*beforeSave/i);
58
+ });
59
+ it('accepts well-formed inline singleton hooks and resolves them unchanged', async () => {
60
+ const definition = singleton();
61
+ const hooks = {
62
+ beforeSave: vi.fn((_context) => { }),
63
+ };
64
+ definition.hooks = hooks;
65
+ expect(prepareHookAttachment({ collections: [definition] })).toEqual([]);
66
+ expect(await resolveHooks(definition)).toBe(hooks);
67
+ });
68
+ it('defers loader-family validation until first resolution', async () => {
69
+ const definition = singleton();
70
+ const loader = vi.fn(() => Promise.resolve({ beforeCreate: () => { } }));
71
+ definition.hooks = loader;
72
+ expect(() => prepareHookAttachment({ collections: [definition] })).not.toThrow();
73
+ await expect(resolveHooks(definition)).rejects.toThrow(/singleton.*beforeCreate/i);
74
+ });
75
+ it('resolves and caches a well-formed singleton hook loader', async () => {
76
+ const definition = singleton();
77
+ const hooks = {
78
+ beforeSave: vi.fn((_context) => { }),
79
+ };
80
+ const loader = vi.fn(() => Promise.resolve(hooks));
81
+ definition.hooks = loader;
82
+ expect(await resolveHooks(definition)).toBe(hooks);
83
+ expect(await resolveHooks(definition)).toBe(hooks);
84
+ expect(loader).toHaveBeenCalledOnce();
85
+ });
86
+ it('validates a reused loader against the current definition on every resolution', async () => {
87
+ const hooks = {
88
+ beforeSave: vi.fn((_context) => { }),
89
+ };
90
+ const loader = vi.fn(() => Promise.resolve(hooks));
91
+ const singletonDefinition = singleton();
92
+ singletonDefinition.hooks = loader;
93
+ const collectionDefinition = collection();
94
+ collectionDefinition.hooks = loader;
95
+ expect(await resolveHooks(singletonDefinition)).toBe(hooks);
96
+ await expect(resolveHooks(collectionDefinition)).rejects.toThrow(/collection.*beforeSave/i);
97
+ expect(loader).toHaveBeenCalledOnce();
98
+ });
40
99
  it('prepares without mutation, then attaches collection and canonical nested upload hooks', async () => {
41
100
  const { definition, nested, arrayFile, blockFile } = nestedUploadCollection();
42
101
  const collectionHooks = {};
@@ -1,8 +1,12 @@
1
1
  import { afterEach, describe, expect, it } from 'vitest';
2
- import { defineAdminConfig, defineServerConfig, getCollectionDefinition } from './config.js';
2
+ import { defineAdmin, defineSingletonAdmin } from '../@types/admin-types.js';
3
+ import { defineCollection, defineSingleton } from '../@types/collection-types.js';
4
+ import { defineAdminConfig, defineServerConfig, getCollectionAdminConfig, getCollectionDefinition, getSingletonAdminConfig, } from './config.js';
3
5
  const SERVER_CONFIG = Symbol.for('__byline_server_config__');
6
+ const ADMIN_CONFIG = Symbol.for('__byline_admin_config__');
4
7
  const CLIENT_CONFIG = Symbol.for('__byline_client_config__');
5
8
  const previousServer = globalThis[SERVER_CONFIG];
9
+ const previousAdmin = globalThis[ADMIN_CONFIG];
6
10
  const previousClient = globalThis[CLIENT_CONFIG];
7
11
  afterEach(() => {
8
12
  const globals = globalThis;
@@ -10,6 +14,10 @@ afterEach(() => {
10
14
  delete globals[SERVER_CONFIG];
11
15
  else
12
16
  globals[SERVER_CONFIG] = previousServer;
17
+ if (previousAdmin === undefined)
18
+ delete globals[ADMIN_CONFIG];
19
+ else
20
+ globals[ADMIN_CONFIG] = previousAdmin;
13
21
  if (previousClient === undefined)
14
22
  delete globals[CLIENT_CONFIG];
15
23
  else
@@ -53,3 +61,28 @@ describe('collection definition config preference', () => {
53
61
  expect(getCollectionDefinition('docs')?.hooks).toBe(hooks);
54
62
  });
55
63
  });
64
+ describe('admin resource config lookup', () => {
65
+ it('returns only the admin config matching the requested resource kind', () => {
66
+ const pages = defineCollection({
67
+ path: 'pages',
68
+ labels: { singular: 'Page', plural: 'Pages' },
69
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
70
+ });
71
+ const settings = defineSingleton({
72
+ path: 'site-settings',
73
+ label: 'Site settings',
74
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
75
+ });
76
+ const pagesAdmin = defineAdmin(pages, {});
77
+ const settingsAdmin = defineSingletonAdmin(settings, {});
78
+ defineAdminConfig({
79
+ collections: [pages, settings],
80
+ admin: [pagesAdmin, settingsAdmin],
81
+ i18n,
82
+ });
83
+ expect(getCollectionAdminConfig(pages.path)).toBe(pagesAdmin);
84
+ expect(getCollectionAdminConfig(settings.path)).toBeNull();
85
+ expect(getSingletonAdminConfig(settings.path)).toBe(settingsAdmin);
86
+ expect(getSingletonAdminConfig(pages.path)).toBeNull();
87
+ });
88
+ });
@@ -1,4 +1,4 @@
1
- import type { AdminConfig, CollectionAdminConfig, CollectionDefinition, ResolvedAdminConfig, ResolvedServerConfig, ServerConfig } from '../@types/index.js';
1
+ import type { AdminConfig, CollectionAdminConfig, CollectionDefinition, ResolvedAdminConfig, ResolvedServerConfig, ServerConfig, SingletonAdminConfig } from '../@types/index.js';
2
2
  /**
3
3
  * Resolve a collection definition by `path`. Returns `null` either when
4
4
  * no config has been registered (e.g. unit tests, isolated tooling) or
@@ -12,6 +12,7 @@ import type { AdminConfig, CollectionAdminConfig, CollectionDefinition, Resolved
12
12
  */
13
13
  export declare const getCollectionDefinition: (path: string) => CollectionDefinition | null;
14
14
  export declare const getCollectionAdminConfig: (slug: string) => CollectionAdminConfig | null;
15
+ export declare const getSingletonAdminConfig: (slug: string) => SingletonAdminConfig | null;
15
16
  export declare function defineAdminConfig(config: AdminConfig): ResolvedAdminConfig;
16
17
  export declare function defineServerConfig<TAdminStore = unknown>(config: ServerConfig<TAdminStore>): ResolvedServerConfig<TAdminStore>;
17
18
  /** Resolve and validate server config without replacing the registered singleton. */
@@ -51,12 +51,20 @@ export const getCollectionDefinition = (path) => {
51
51
  return null;
52
52
  return config.collections.find((collection) => collection.path === path) ?? null;
53
53
  };
54
- export const getCollectionAdminConfig = (slug) => {
54
+ const getAdminResourceConfig = (slug) => {
55
55
  const adminConfig = getAdminConfigInstance();
56
56
  if (adminConfig == null)
57
57
  return null;
58
58
  return adminConfig.admin?.find((admin) => admin.slug === slug) ?? null;
59
59
  };
60
+ export const getCollectionAdminConfig = (slug) => {
61
+ const admin = getAdminResourceConfig(slug);
62
+ return admin?.singleton === true ? null : admin;
63
+ };
64
+ export const getSingletonAdminConfig = (slug) => {
65
+ const admin = getAdminResourceConfig(slug);
66
+ return admin?.singleton === true ? admin : null;
67
+ };
60
68
  export function defineAdminConfig(config) {
61
69
  validateCollections(config.collections);
62
70
  validateAdminConfigs(config.admin, config.collections, config.collectionGroups);
@@ -5,30 +5,31 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { CollectionAdminConfig, CollectionGroupDefinition } from '../@types/admin-types.js';
8
+ import type { AdminResourceConfig, CollectionGroupDefinition } from '../@types/admin-types.js';
9
9
  import type { CollectionDefinition } from '../@types/collection-types.js';
10
10
  /**
11
11
  * One renderable section of the admin dashboard: a heading (or none) and the
12
- * collections beneath it.
12
+ * document resources beneath it. The public `collections` member name remains
13
+ * for backwards compatibility and may contain both resource kinds.
13
14
  */
14
- export interface CollectionGroupBucket {
15
+ export interface CollectionGroupBucket<TDefinition extends CollectionDefinition = CollectionDefinition> {
15
16
  /** Registry key, or `null` for the leading ungrouped band. */
16
17
  name: string | null;
17
18
  /** Heading text, or `null` when the band renders without a heading. */
18
19
  label: string | null;
19
- collections: CollectionDefinition[];
20
+ collections: TDefinition[];
20
21
  }
21
22
  /**
22
- * Bucket collections into ordered dashboard sections.
23
+ * Bucket collections and singletons into ordered dashboard sections.
23
24
  *
24
25
  * Rules:
25
26
  * - The ungrouped band is emitted first, and omitted entirely when empty.
26
27
  * - Declared groups follow in `collectionGroups` order.
27
28
  * - A declared group with no members is skipped, so no heading ever appears
28
29
  * above an empty section.
29
- * - Collection declaration order is preserved within each bucket.
30
+ * - Resource declaration order is preserved within each bucket.
30
31
  * - An absent or empty registry yields a single ungrouped bucket holding every
31
- * collection — the flat grid Byline rendered before groups existed.
32
+ * resource — the flat grid Byline rendered before groups existed.
32
33
  *
33
34
  * This function is deliberately total: a `group` naming no declared entry is
34
35
  * treated as ungrouped rather than throwing. `validateCollectionGroups` rejects
@@ -37,9 +38,9 @@ export interface CollectionGroupBucket {
37
38
  * outcome.
38
39
  *
39
40
  * It takes no actor and knows nothing about abilities. Callers that need to
40
- * hide collections filter the `collections` argument first — see
41
+ * hide resources filter the `collections` argument first — see
41
42
  * `filterReadableCollections`. That ordering is what makes a group whose
42
43
  * members are all hidden disappear along with its heading: it arrives here with
43
44
  * no members and is skipped by the rule above.
44
45
  */
45
- export declare function groupCollectionsForAdmin(collections: readonly CollectionDefinition[], admin: readonly CollectionAdminConfig[] | undefined, collectionGroups: readonly CollectionGroupDefinition[] | undefined): CollectionGroupBucket[];
46
+ export declare function groupCollectionsForAdmin<TDefinition extends CollectionDefinition>(collections: readonly TDefinition[], admin: readonly AdminResourceConfig[] | undefined, collectionGroups: readonly CollectionGroupDefinition[] | undefined): CollectionGroupBucket<TDefinition>[];
@@ -6,16 +6,16 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  /**
9
- * Bucket collections into ordered dashboard sections.
9
+ * Bucket collections and singletons into ordered dashboard sections.
10
10
  *
11
11
  * Rules:
12
12
  * - The ungrouped band is emitted first, and omitted entirely when empty.
13
13
  * - Declared groups follow in `collectionGroups` order.
14
14
  * - A declared group with no members is skipped, so no heading ever appears
15
15
  * above an empty section.
16
- * - Collection declaration order is preserved within each bucket.
16
+ * - Resource declaration order is preserved within each bucket.
17
17
  * - An absent or empty registry yields a single ungrouped bucket holding every
18
- * collection — the flat grid Byline rendered before groups existed.
18
+ * resource — the flat grid Byline rendered before groups existed.
19
19
  *
20
20
  * This function is deliberately total: a `group` naming no declared entry is
21
21
  * treated as ungrouped rather than throwing. `validateCollectionGroups` rejects
@@ -24,7 +24,7 @@
24
24
  * outcome.
25
25
  *
26
26
  * It takes no actor and knows nothing about abilities. Callers that need to
27
- * hide collections filter the `collections` argument first — see
27
+ * hide resources filter the `collections` argument first — see
28
28
  * `filterReadableCollections`. That ordering is what makes a group whose
29
29
  * members are all hidden disappear along with its heading: it arrives here with
30
30
  * no members and is skipped by the rule above.
@@ -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 { groupCollectionsForAdmin } from './group-collections.js';
10
11
  const define = (path) => ({
11
12
  path,
@@ -17,6 +18,11 @@ const news = define('news');
17
18
  const images = define('images');
18
19
  const authors = define('authors');
19
20
  const categories = define('categories');
21
+ const settings = defineSingleton({
22
+ path: 'site-settings',
23
+ label: 'Site settings',
24
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
25
+ });
20
26
  const groups = [
21
27
  { name: 'media', label: 'Media' },
22
28
  { name: 'authorities', label: 'People & Organisations' },
@@ -64,6 +70,14 @@ describe('groupCollectionsForAdmin', () => {
64
70
  const result = groupCollectionsForAdmin([more, images], [...admin, { slug: 'videos', group: 'media' }], groups);
65
71
  expect(result[0]?.collections).toEqual([more, images]);
66
72
  });
73
+ it('groups singleton and multi-collection resources together in declaration order', () => {
74
+ const resourceAdmin = [
75
+ ...admin,
76
+ { singleton: true, slug: settings.path, group: 'media' },
77
+ ];
78
+ const result = groupCollectionsForAdmin([images, settings], resourceAdmin, groups);
79
+ expect(result[0]?.collections).toEqual([images, settings]);
80
+ });
67
81
  it('places a collection with no admin config in the ungrouped band', () => {
68
82
  const result = groupCollectionsForAdmin([pages, images], admin, groups);
69
83
  expect(result[0]).toEqual({ name: null, label: null, collections: [pages] });
@@ -5,14 +5,14 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { BlockAdminConfig, CollectionAdminConfig, CollectionDefinition, CollectionGroupDefinition } from '../@types/index.js';
8
+ import type { AdminResourceConfig, BlockAdminConfig, CollectionDefinition, CollectionGroupDefinition } from '../@types/index.js';
9
9
  /**
10
10
  * Validate the dashboard collection-group registry and every reference to it.
11
11
  *
12
12
  * Enforced rules:
13
13
  * 1. Each `collectionGroups` entry has a non-blank `name` and `label`.
14
14
  * 2. No two entries share a `name`.
15
- * 3. Every `CollectionAdminConfig.group` names a declared entry. This single
15
+ * 3. Every admin resource's `group` names a declared entry. This single
16
16
  * rule covers both a typographical error and the case where `group` was set
17
17
  * but the registry was never declared.
18
18
  *
@@ -20,7 +20,7 @@ import type { BlockAdminConfig, CollectionAdminConfig, CollectionDefinition, Col
20
20
  * configuration validation runs at startup, before the logger and error
21
21
  * registry are necessarily wired up.
22
22
  */
23
- export declare function validateCollectionGroups(collectionGroups: readonly CollectionGroupDefinition[] | undefined, admins: readonly CollectionAdminConfig[] | undefined): void;
23
+ export declare function validateCollectionGroups(collectionGroups: readonly CollectionGroupDefinition[] | undefined, admins: readonly AdminResourceConfig[] | undefined): void;
24
24
  /**
25
25
  * Validate every admin config in a configuration.
26
26
  *
@@ -53,7 +53,7 @@ export declare function validateCollectionGroups(collectionGroups: readonly Coll
53
53
  * validation runs at startup, before the logger and error registry are
54
54
  * necessarily wired up.
55
55
  */
56
- export declare function validateAdminConfigs(admins: readonly CollectionAdminConfig[] | undefined, collections: readonly CollectionDefinition[], collectionGroups?: readonly CollectionGroupDefinition[]): void;
56
+ export declare function validateAdminConfigs(admins: readonly AdminResourceConfig[] | undefined, collections: readonly CollectionDefinition[], collectionGroups?: readonly CollectionGroupDefinition[]): void;
57
57
  /**
58
58
  * Validate every block admin config in a configuration.
59
59
  *
@@ -5,7 +5,21 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
+ import { isSingleton } from '../@types/collection-types.js';
8
9
  import { resolveDeclarationPath, walkFieldDeclarations, } from '../paths/index.js';
10
+ function completeCollectionOnlyAdminKeys(keys) {
11
+ return keys;
12
+ }
13
+ /** Runtime counterpart to `SingletonAdminConfig`'s collection-only `never` members. */
14
+ const COLLECTION_ONLY_ADMIN_KEYS = completeCollectionOnlyAdminKeys([
15
+ 'columns',
16
+ 'defaultSort',
17
+ 'defaultColumns',
18
+ 'itemView',
19
+ 'itemViewSort',
20
+ 'listView',
21
+ 'listActions',
22
+ ]);
9
23
  /**
10
24
  * Resolve a `fields{}` override key against a field set.
11
25
  *
@@ -44,7 +58,7 @@ function validateFieldAdminKeys(keys, resolve, fail) {
44
58
  * Enforced rules:
45
59
  * 1. Each `collectionGroups` entry has a non-blank `name` and `label`.
46
60
  * 2. No two entries share a `name`.
47
- * 3. Every `CollectionAdminConfig.group` names a declared entry. This single
61
+ * 3. Every admin resource's `group` names a declared entry. This single
48
62
  * rule covers both a typographical error and the case where `group` was set
49
63
  * but the registry was never declared.
50
64
  *
@@ -58,7 +72,7 @@ export function validateCollectionGroups(collectionGroups, admins) {
58
72
  const name = typeof group.name === 'string' ? group.name.trim() : '';
59
73
  const label = typeof group.label === 'string' ? group.label.trim() : '';
60
74
  if (name === '') {
61
- throw new Error('A `collectionGroups` entry has a blank `name`. Each entry needs a non-empty key for `CollectionAdminConfig.group` to reference.');
75
+ throw new Error('A `collectionGroups` entry has a blank `name`. Each entry needs a non-empty key for an admin resource `group` to reference.');
62
76
  }
63
77
  if (label === '') {
64
78
  throw new Error(`Collection group "${name}" has a blank \`label\`. The label is the heading rendered above the group on the dashboard.`);
@@ -76,7 +90,7 @@ export function validateCollectionGroups(collectionGroups, admins) {
76
90
  const known = declared.size === 0
77
91
  ? '`collectionGroups` was not declared, or is empty'
78
92
  : `declared groups: ${[...declared].map((name) => `"${name}"`).join(', ')}`;
79
- throw new Error(`Collection "${admin.slug}": \`group: '${admin.group}'\` does not name a declared collection group (${known}). Add it to \`AdminConfig.collectionGroups\`, or remove the \`group\` property.`);
93
+ throw new Error(`Admin resource "${admin.slug}": \`group: '${admin.group}'\` does not name a declared collection group (${known}). Add it to \`AdminConfig.collectionGroups\`, or remove the \`group\` property.`);
80
94
  }
81
95
  }
82
96
  /**
@@ -198,12 +212,29 @@ function validateOne(admin, collectionsByPath) {
198
212
  // Rule 1 — slug pairing.
199
213
  const collection = collectionsByPath.get(admin.slug);
200
214
  if (collection == null) {
215
+ if (admin.singleton === true) {
216
+ throw new Error(`Singleton admin config "${admin.slug}" has no matching singleton definition (no singleton with \`path: '${admin.slug}'\` was registered).`);
217
+ }
201
218
  throw new Error(`Admin config "${admin.slug}" has no matching collection (no collection with \`path: '${admin.slug}'\` was registered).`);
202
219
  }
220
+ if (admin.singleton === true && !isSingleton(collection)) {
221
+ throw new Error(`Singleton admin config "${admin.slug}" targets a collection definition. Register it with \`defineAdmin\`, or point it at a singleton definition.`);
222
+ }
223
+ if (admin.singleton !== true && isSingleton(collection)) {
224
+ throw new Error(`Collection admin config "${admin.slug}" targets a singleton definition. Register it with \`defineSingletonAdmin\`, or point it at a multi-collection definition.`);
225
+ }
203
226
  const slug = admin.slug;
204
227
  const fail = (msg) => {
205
- throw new Error(`Collection "${slug}": ${msg}`);
228
+ const resource = admin.singleton === true ? 'Singleton' : 'Collection';
229
+ throw new Error(`${resource} "${slug}": ${msg}`);
206
230
  };
231
+ if (admin.singleton === true) {
232
+ for (const key of COLLECTION_ONLY_ADMIN_KEYS) {
233
+ if (Object.hasOwn(admin, key)) {
234
+ fail(`\`${key}\` is not allowed on a singleton admin config.`);
235
+ }
236
+ }
237
+ }
207
238
  // Top-level schema field names — the population subject to placement
208
239
  // bookkeeping. Fields nested inside group/array/blocks are rendered by
209
240
  // their parent widget and are not addressable from layout.
@@ -221,7 +252,7 @@ function validateOne(admin, collectionsByPath) {
221
252
  return;
222
253
  const { field, direction } = spec;
223
254
  const documentColumns = new Set(['createdAt', 'updatedAt', 'path']);
224
- if (collection.orderable === true) {
255
+ if (!isSingleton(collection) && collection.orderable === true) {
225
256
  fail(`${optionName} is not allowed on an orderable collection — manual ordering owns the sort (order_key asc).`);
226
257
  }
227
258
  if (typeof field !== 'string' || field.length === 0) {
@@ -234,8 +265,10 @@ function validateOne(admin, collectionsByPath) {
234
265
  fail(`${optionName}.direction must be 'asc' or 'desc' (got "${String(direction)}").`);
235
266
  }
236
267
  };
237
- validateSortSpec(admin.defaultSort, 'defaultSort');
238
- validateSortSpec(admin.itemViewSort, 'itemViewSort');
268
+ if (admin.singleton !== true) {
269
+ validateSortSpec(admin.defaultSort, 'defaultSort');
270
+ validateSortSpec(admin.itemViewSort, 'itemViewSort');
271
+ }
239
272
  // Build primitive lookup tables.
240
273
  const tabSets = admin.tabSets ?? [];
241
274
  const rows = admin.rows ?? [];
@@ -6,6 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { describe, expect, it } from 'vitest';
9
+ import { defineSingleton, defineSingletonAdmin, } from '../@types/index.js';
9
10
  import { validateAdminConfigs, validateBlockAdminConfigs } from './validate-admin-configs.js';
10
11
  const collection = {
11
12
  path: 'news',
@@ -33,6 +34,19 @@ const baseAdmin = {
33
34
  rows: [{ name: 'titles', fields: ['text1', 'text2'] }],
34
35
  layout: { main: ['main'] },
35
36
  };
37
+ const singleton = defineSingleton({
38
+ path: 'site-settings',
39
+ label: 'Site settings',
40
+ fields: [
41
+ { name: 'title', label: 'Title', type: 'text' },
42
+ {
43
+ name: 'seo',
44
+ label: 'SEO',
45
+ type: 'group',
46
+ fields: [{ name: 'description', label: 'Description', type: 'textArea' }],
47
+ },
48
+ ],
49
+ });
36
50
  describe('validateAdminConfigs', () => {
37
51
  it('accepts a valid admin config (baseline)', () => {
38
52
  expect(() => validateAdminConfigs([baseAdmin], [collection])).not.toThrow();
@@ -40,6 +54,36 @@ describe('validateAdminConfigs', () => {
40
54
  it('is a no-op when admins is undefined', () => {
41
55
  expect(() => validateAdminConfigs(undefined, [collection])).not.toThrow();
42
56
  });
57
+ it('accepts a singleton admin config and dotted field override path', () => {
58
+ const admin = defineSingletonAdmin(singleton, {
59
+ fields: { 'seo.description': {} },
60
+ layout: { main: ['title', 'seo'] },
61
+ });
62
+ expect(() => validateAdminConfigs([admin], [collection, singleton])).not.toThrow();
63
+ });
64
+ it('rejects an unknown singleton field override path at startup', () => {
65
+ const admin = defineSingletonAdmin(singleton, { fields: { 'seo.missing': {} } });
66
+ expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/does not resolve to a field declaration/);
67
+ });
68
+ it('rejects list-only singleton options from untyped callers', () => {
69
+ const admin = {
70
+ ...defineSingletonAdmin(singleton, {}),
71
+ columns: [{ fieldName: 'title', label: 'Title' }],
72
+ };
73
+ expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/columns.*not allowed on a singleton admin config/);
74
+ });
75
+ it('rejects a singleton admin config targeting a multi-collection', () => {
76
+ const admin = { ...defineSingletonAdmin(singleton, {}), slug: collection.path };
77
+ expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/targets a collection definition/);
78
+ });
79
+ it('rejects a singleton admin config with no matching definition', () => {
80
+ const admin = { ...defineSingletonAdmin(singleton, {}), slug: 'missing-settings' };
81
+ expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/no matching singleton definition/);
82
+ });
83
+ it('rejects a multi-collection admin config targeting a singleton', () => {
84
+ const admin = { slug: singleton.path };
85
+ expect(() => validateAdminConfigs([admin], [collection, singleton])).toThrow(/targets a singleton definition/);
86
+ });
43
87
  // Rule 1 — slug pairing.
44
88
  it('rejects an admin config whose slug has no matching collection', () => {
45
89
  const orphan = { slug: 'nonexistent', layout: { main: [] } };
@@ -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 { formatDeclarationPath, walkFieldDeclarations } from '../paths/index.js';
9
10
  import { fieldTypeToStore } from '../storage/field-store-map.js';
10
11
  /**
@@ -48,6 +49,23 @@ const USE_AS_PATH_SOURCE_TYPES = new Set([
48
49
  'integer',
49
50
  'counter',
50
51
  ]);
52
+ function completeCollectionOnlyOptions(keys) {
53
+ return keys;
54
+ }
55
+ /** Runtime counterpart to the singleton definition's collection-only `never` members. */
56
+ const COLLECTION_ONLY_OPTIONS = completeCollectionOnlyOptions([
57
+ 'labels',
58
+ 'orderable',
59
+ 'tree',
60
+ 'useAsPath',
61
+ 'useAsTitle',
62
+ 'search',
63
+ 'listSearch',
64
+ 'advertiseLocales',
65
+ 'showStats',
66
+ 'linksInEditor',
67
+ 'buildDocumentPath',
68
+ ]);
51
69
  /**
52
70
  * True when any field in the tree (at any nesting depth) is `localized`.
53
71
  * Used to gate `advertiseLocales` — advertising content locales is only
@@ -239,13 +257,38 @@ function validateVirtualFields(collection) {
239
257
  * necessarily wired up.
240
258
  */
241
259
  export function validateCollections(collections) {
260
+ const definitionsByPath = new Map();
261
+ for (const collection of collections) {
262
+ if (definitionsByPath.has(collection.path)) {
263
+ throw new Error(`Two definitions are registered on path "${collection.path}". Collections and singletons share one path namespace, so a collection and a singleton cannot both be called "${collection.path}".`);
264
+ }
265
+ definitionsByPath.set(collection.path, collection);
266
+ }
242
267
  for (const collection of collections) {
268
+ if (isSingleton(collection)) {
269
+ for (const option of COLLECTION_ONLY_OPTIONS) {
270
+ if (collection[option] !== undefined) {
271
+ throw new Error(`Singleton "${collection.path}" sets \`${option}\`, which is a multi-document collection option. A singleton holds at most one document, so there is no list to sort, search, or paginate and no public slug. Remove \`${option}\`.`);
272
+ }
273
+ }
274
+ if (typeof collection.label !== 'string') {
275
+ throw new Error(`Singleton "${collection.path}" must declare a \`label\`. Singletons have no plural form, so they use \`label\` rather than \`labels\`.`);
276
+ }
277
+ }
243
278
  walkFields(collection.fields, (field) => {
244
279
  if ('name' in field && RESERVED_FIELD_NAMES.has(field.name)) {
245
280
  const hint = RESERVED_FIELD_HINTS[field.name] ?? '';
246
281
  throw new Error(`Collection "${collection.path}" declares a field named "${field.name}", which is a reserved system attribute.${hint ? ` ${hint}` : ''}`);
247
282
  }
248
283
  });
284
+ walkFieldsWithPath(collection.fields, (field, fieldPath) => {
285
+ if (field.type !== 'relation')
286
+ return;
287
+ const target = definitionsByPath.get(field.targetCollection);
288
+ if (target == null || !isSingleton(target))
289
+ return;
290
+ throw new Error(`Field "${fieldPath}" on "${collection.path}" targets "${target.path}", which is a singleton. A singleton holds at most one document and is not a relation target in this release — reference its values directly instead.`);
291
+ });
249
292
  if (collection.useAsPath != null) {
250
293
  const source = collection.fields.find((f) => 'name' in f && f.name === collection.useAsPath);
251
294
  if (source == null) {
@@ -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 CRUD + workflow
34
- * abilities contributed by each declared collection
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