@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
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
- import type { BlockFieldData, CollectionDefinition, WorkflowStatus } from './collection-types.js';
8
+ import type { BlockFieldData, MultiCollectionDefinition, SingletonDefinition, WorkflowStatus } from './collection-types.js';
9
9
  import type { Block, FieldComponentSlots, RichTextEditorComponent } from './field-types.js';
10
10
  /**
11
11
  * Props passed to a custom list-view component registered via
@@ -168,22 +168,23 @@ export interface FieldAdminConfig {
168
168
  editor?: RichTextEditorComponent;
169
169
  }
170
170
  /**
171
- * One labelled group of collections on the admin dashboard.
171
+ * One labelled group of document resources on the admin dashboard.
172
172
  *
173
173
  * Declared in display order on `AdminConfig.collectionGroups` and referenced by
174
- * `name` from `CollectionAdminConfig.group`. Array order is the order headings
175
- * appear on the dashboard; a group with no member collections is not rendered
174
+ * `name` from a collection or singleton admin config. The registry keeps its
175
+ * established `collectionGroups` name for compatibility. Array order is the order headings
176
+ * appear on the dashboard; a group with no member resources is not rendered
176
177
  * at all, so no heading ever sits above an empty section.
177
178
  */
178
179
  export interface CollectionGroupDefinition {
179
180
  /**
180
- * Stable key referenced by `CollectionAdminConfig.group`. Boot-validated —
181
+ * Stable key referenced by an admin resource's `group`. Boot-validated —
181
182
  * a reference to an undeclared name throws at startup rather than silently
182
183
  * producing an extra heading.
183
184
  */
184
185
  name: string;
185
186
  /**
186
- * Heading text rendered above this group's collections.
187
+ * Heading text rendered above this group's document resources.
187
188
  *
188
189
  * A plain string, deliberately not translated: `CollectionDefinition.labels`
189
190
  * are themselves rendered untranslated on the dashboard, so translating group
@@ -208,6 +209,44 @@ export interface PreviewDocument<F = any> {
208
209
  status: string;
209
210
  fields: F;
210
211
  }
212
+ /** Minimal pathless document shape passed to `SingletonAdminConfig.preview.url`. */
213
+ export interface SingletonPreviewDocument<F = any> {
214
+ id: string;
215
+ status: string;
216
+ fields: F;
217
+ }
218
+ /**
219
+ * Admin UI configuration consumed directly by Byline's form renderer.
220
+ *
221
+ * Collection-specific list, dashboard, and preview configuration belongs on
222
+ * {@link CollectionAdminConfig}; this base contains only the reusable form
223
+ * composition contract.
224
+ */
225
+ export interface FormAdminConfig {
226
+ /** Named tab components. Each set is one tab bar. */
227
+ tabSets?: TabSetDefinition[];
228
+ /** Named horizontal-row layouts. */
229
+ rows?: RowDefinition[];
230
+ /** Named labelled-fieldset clusters. */
231
+ groups?: GroupDefinition[];
232
+ /**
233
+ * Composition: how the primitives above (and any raw schema fields) flow
234
+ * into the form's `main` and `sidebar` regions. When omitted, the
235
+ * renderer synthesises a default that places every schema field in
236
+ * `main` in declaration order.
237
+ */
238
+ layout?: LayoutDefinition;
239
+ /**
240
+ * Per-field rendering overrides, keyed by schema path: a top-level field
241
+ * name (`title`) or a dotted, index-free path through `group` / `array`
242
+ * structure fields (`files.filesGroup.publicationFile`). Paths address
243
+ * field *declarations*, never item instances (no `[0]` indices), and never
244
+ * traverse a `blocks` field — blocks take their overrides from the
245
+ * blockType-keyed `AdminConfig.blockAdmin` registry instead.
246
+ * Placement is no longer expressed here — see the layout primitives above.
247
+ */
248
+ fields?: Record<string, FieldAdminConfig>;
249
+ }
211
250
  /**
212
251
  * Admin UI configuration for a collection.
213
252
  * This is the presentation/UI layer — separate from the data schema.
@@ -215,7 +254,9 @@ export interface PreviewDocument<F = any> {
215
254
  * Linked to a `CollectionDefinition` by `slug` matching the collection's
216
255
  * `path`.
217
256
  */
218
- export interface CollectionAdminConfig<T = any> {
257
+ export interface CollectionAdminConfig<T = any> extends FormAdminConfig {
258
+ /** Explicit resource discriminator. Stamped by `defineAdmin`. */
259
+ singleton?: false;
219
260
  /** Must match the `path` of the corresponding `CollectionDefinition`. */
220
261
  slug: string;
221
262
  /**
@@ -281,29 +322,6 @@ export interface CollectionAdminConfig<T = any> {
281
322
  itemViewSort?: ListDefaultSort<T>;
282
323
  /** Default columns to show when no explicit column config is provided. */
283
324
  defaultColumns?: string[];
284
- /** Named tab components. Each set is one tab bar. */
285
- tabSets?: TabSetDefinition[];
286
- /** Named horizontal-row layouts. */
287
- rows?: RowDefinition[];
288
- /** Named labelled-fieldset clusters. */
289
- groups?: GroupDefinition[];
290
- /**
291
- * Composition: how the primitives above (and any raw schema fields) flow
292
- * into the form's `main` and `sidebar` regions. When omitted, the
293
- * renderer synthesises a default that places every schema field in
294
- * `main` in declaration order.
295
- */
296
- layout?: LayoutDefinition;
297
- /**
298
- * Per-field rendering overrides, keyed by schema path: a top-level field
299
- * name (`title`) or a dotted, index-free path through `group` / `array`
300
- * structure fields (`files.filesGroup.publicationFile`). Paths address
301
- * field *declarations*, never item instances (no `[0]` indices), and never
302
- * traverse a `blocks` field — blocks take their overrides from the
303
- * blockType-keyed `AdminConfig.blockAdmin` registry instead.
304
- * Placement is no longer expressed here — see the layout primitives above.
305
- */
306
- fields?: Record<string, FieldAdminConfig>;
307
325
  /**
308
326
  * Preview URL configuration for the admin's live-preview affordance
309
327
  * (`<PreviewLink>` icon on the document edit page header). When omitted,
@@ -411,6 +429,38 @@ export interface CollectionAdminConfig<T = any> {
411
429
  */
412
430
  listActions?: Array<(props: ListActionComponentProps) => any>;
413
431
  }
432
+ /** Admin UI configuration for a singleton document resource. */
433
+ export interface SingletonAdminConfig<T = any> extends FormAdminConfig {
434
+ /** Explicit resource discriminator. Stamped by `defineSingletonAdmin`. */
435
+ singleton: true;
436
+ /** Must match the `path` of the corresponding `SingletonDefinition`. */
437
+ slug: string;
438
+ /**
439
+ * Dashboard group this resource belongs to. The registry remains named
440
+ * `AdminConfig.collectionGroups` for backwards compatibility.
441
+ */
442
+ group?: string;
443
+ /**
444
+ * Explicit public preview URL for the singleton's site-wide effect.
445
+ * Unlike collection previews, no internal document path is available or
446
+ * used as a fallback.
447
+ */
448
+ preview?: {
449
+ url: (doc: SingletonPreviewDocument<T>, ctx: {
450
+ locale?: string;
451
+ }) => string | null;
452
+ };
453
+ /** Collection-list options are invalid for a cardinality-one resource. */
454
+ columns?: never;
455
+ defaultSort?: never;
456
+ defaultColumns?: never;
457
+ itemView?: never;
458
+ itemViewSort?: never;
459
+ listView?: never;
460
+ listActions?: never;
461
+ }
462
+ /** Admin presentation config for either registered document-resource kind. */
463
+ export type AdminResourceConfig<T = any> = CollectionAdminConfig<T> | SingletonAdminConfig<T>;
414
464
  /**
415
465
  * Props passed to each `CollectionAdminConfig.listActions` component. Kept
416
466
  * minimal and framework-agnostic (`@byline/core` is React-free); components
@@ -424,7 +474,12 @@ export interface ListActionComponentProps {
424
474
  * Type-safe factory for creating a `CollectionAdminConfig` linked to a schema.
425
475
  * Sets `slug` from the schema's `path`.
426
476
  */
427
- export declare function defineAdmin<T = any>(schema: CollectionDefinition, config: Omit<CollectionAdminConfig<T>, 'slug'>): CollectionAdminConfig<T>;
477
+ export declare function defineAdmin<T = any>(schema: MultiCollectionDefinition, config: Omit<CollectionAdminConfig<T>, 'slug' | 'singleton'>): CollectionAdminConfig<T>;
478
+ /**
479
+ * Type-safe factory for a `SingletonAdminConfig` linked to a singleton schema.
480
+ * Sets both the schema path and resource discriminator for authors.
481
+ */
482
+ export declare function defineSingletonAdmin<T = any>(schema: SingletonDefinition, config: Omit<SingletonAdminConfig<T>, 'slug' | 'singleton'>): SingletonAdminConfig<T>;
428
483
  /**
429
484
  * Admin UI configuration for a block — the block-scoped analogue of
430
485
  * `CollectionAdminConfig`, carrying **rendering overrides only**.
@@ -11,8 +11,20 @@
11
11
  */
12
12
  export function defineAdmin(schema, config) {
13
13
  return {
14
+ ...config,
15
+ singleton: false,
14
16
  slug: schema.path,
17
+ };
18
+ }
19
+ /**
20
+ * Type-safe factory for a `SingletonAdminConfig` linked to a singleton schema.
21
+ * Sets both the schema path and resource discriminator for authors.
22
+ */
23
+ export function defineSingletonAdmin(schema, config) {
24
+ return {
15
25
  ...config,
26
+ singleton: true,
27
+ slug: schema.path,
16
28
  };
17
29
  }
18
30
  /**
@@ -483,6 +483,60 @@ export interface AfterUpdateContext {
483
483
  locale: string;
484
484
  };
485
485
  }
486
+ /** The public singleton operation represented by a save-hook invocation. */
487
+ export type SingletonSaveOperation = {
488
+ type: 'save';
489
+ } | {
490
+ type: 'restore';
491
+ sourceVersionId: string;
492
+ } | {
493
+ type: 'copyToLocale';
494
+ sourceLocale: string;
495
+ targetLocale: string;
496
+ overwrite: boolean;
497
+ };
498
+ /**
499
+ * Context passed to a singleton's `beforeSave` hooks.
500
+ *
501
+ * The hook runs inside the singleton transaction after the slot lock and
502
+ * current-value read. It may mutate `data` before the next immutable version
503
+ * is written. `documentId` and `originalData` are `null` only for the first
504
+ * materialising save.
505
+ *
506
+ * Restore carries the complete all-locale field tree with `locale: 'all'`.
507
+ * Copy-to-locale carries the merged target payload and the target locale's
508
+ * prior values; it never exposes the raw source-locale payload as `data`.
509
+ */
510
+ export interface BeforeSingletonSaveContext {
511
+ data: Record<string, any>;
512
+ originalData: Record<string, any> | null;
513
+ singletonPath: string;
514
+ locale: string;
515
+ requestContext: RequestContext;
516
+ isInitialSave: boolean;
517
+ operation: SingletonSaveOperation;
518
+ documentId: string | null;
519
+ }
520
+ /**
521
+ * Context passed to a singleton's `afterSave` hooks after the save transaction
522
+ * commits. A hook failure rejects the public call but cannot roll back the
523
+ * persisted document version or initial slot mapping.
524
+ *
525
+ * Public-cache invalidation is deliberately host-owned rather than automatic.
526
+ * Hosts commonly invalidate preview caches after every save and public caches
527
+ * only when publication changes, using the status hooks for the latter.
528
+ */
529
+ export interface AfterSingletonSaveContext {
530
+ data: Record<string, any>;
531
+ originalData: Record<string, any> | null;
532
+ singletonPath: string;
533
+ locale: string;
534
+ requestContext: RequestContext;
535
+ isInitialSave: boolean;
536
+ operation: SingletonSaveOperation;
537
+ documentId: string;
538
+ documentVersionId: string;
539
+ }
486
540
  /**
487
541
  * Context passed to `afterSystemFieldsChange` after a non-versioned document
488
542
  * path and/or advertised-locale change commits successfully.
@@ -829,11 +883,11 @@ export interface AfterReadContext {
829
883
  * `LEFT JOIN LATERAL` SQL machinery the client's existing `where` parser
830
884
  * emits, then ANDs onto whatever the caller passed in `where`.
831
885
  *
832
- * Returning `undefined` (or simply `void`) means "no scoping" — typically
833
- * the superuser / unconditional-read branch. Return `{ id: { $in: [] } }`
834
- * when the actor cannot read anything; it compiles to an always-false SQL
835
- * predicate without passing an invalid UUID to Postgres. Do not throw, because
836
- * callers expect empty list results rather than collapsed endpoints.
886
+ * Returning `undefined` (or `void`) means "no scoping" — typically the
887
+ * superuser / unconditional-read branch. Return `false` when the actor cannot
888
+ * read anything; it normalises to `{ id: { $in: [] } }` and compiles to an
889
+ * always-false SQL predicate without passing an invalid UUID to the database.
890
+ * Do not throw, because callers expect empty results rather than collapsed endpoints.
837
891
  *
838
892
  * The hook receives:
839
893
  * - `requestContext` — the authenticated request, including `actor`. The
@@ -845,7 +899,7 @@ export interface AfterReadContext {
845
899
  * same hook function is reused across collections).
846
900
  *
847
901
  * See `docs/07-auth-and-security/01-authn-authz.md` for the strategic rationale; the Quick
848
- * Reference there carries six worked recipes.
902
+ * Reference there carries seven worked recipes.
849
903
  */
850
904
  export interface BeforeReadContext {
851
905
  collectionPath: string;
@@ -854,17 +908,19 @@ export interface BeforeReadContext {
854
908
  }
855
909
  /**
856
910
  * A `beforeRead` hook function. Returns a `QueryPredicate` to scope the
857
- * query, or `void`/`undefined` to apply no scoping. May be async — actors
858
- * needing tenant lookups or role-metadata fetches commonly are.
911
+ * query, `false` to deny every row, or `void`/`undefined` to apply no
912
+ * scoping. May be async — actors needing tenant lookups or role-metadata
913
+ * fetches commonly are.
859
914
  */
860
- export type BeforeReadHookFn = (ctx: BeforeReadContext) => QueryPredicate | void | Promise<QueryPredicate | void>;
915
+ export type BeforeReadHookFn = (ctx: BeforeReadContext) => QueryPredicate | false | void | Promise<QueryPredicate | false | void>;
861
916
  /**
862
917
  * Slot type for `beforeRead`.
863
918
  *
864
919
  * Distinct from the generic `CollectionHookSlot` because `beforeRead`
865
920
  * returns a value (a predicate). When multiple hook functions are
866
921
  * configured, their predicates are combined with implicit AND in
867
- * declaration order; functions that return `void` are skipped.
922
+ * declaration order; `false` remains restrictive in any position, and
923
+ * functions that return `void` are skipped.
868
924
  */
869
925
  export type BeforeReadHookSlot = BeforeReadHookFn | BeforeReadHookFn[];
870
926
  /**
@@ -952,6 +1008,31 @@ export interface CollectionHooks {
952
1008
  */
953
1009
  afterRead?: CollectionHookSlot<AfterReadContext>;
954
1010
  }
1011
+ /** A singleton-hook function signature, parameterised by context type. */
1012
+ export type SingletonHookFn<Ctx> = (ctx: Ctx) => void | Promise<void>;
1013
+ /** A singleton hook slot: one function or an ordered array of functions. */
1014
+ export type SingletonHookSlot<Ctx> = SingletonHookFn<Ctx> | SingletonHookFn<Ctx>[];
1015
+ /**
1016
+ * Lifecycle hooks for a singleton document slot.
1017
+ *
1018
+ * The family describes public singleton operations rather than the internal
1019
+ * create/update branch used to persist them. `beforeSave` runs inside the
1020
+ * locked transaction and may mutate `data`; `afterSave` runs only after that
1021
+ * transaction commits. Read, status, and unpublish hooks retain the shared
1022
+ * document contracts.
1023
+ */
1024
+ export interface SingletonHooks {
1025
+ /** Runs for every singleton save, including initial materialisation. */
1026
+ beforeSave?: SingletonHookSlot<BeforeSingletonSaveContext>;
1027
+ /** Runs after every singleton save commits. See its cache-invalidation JSDoc. */
1028
+ afterSave?: SingletonHookSlot<AfterSingletonSaveContext>;
1029
+ beforeStatusChange?: SingletonHookSlot<StatusChangeContext>;
1030
+ afterStatusChange?: SingletonHookSlot<StatusChangeContext>;
1031
+ beforeUnpublish?: SingletonHookSlot<BeforeUnpublishContext>;
1032
+ afterUnpublish?: SingletonHookSlot<AfterUnpublishContext>;
1033
+ beforeRead?: BeforeReadHookSlot;
1034
+ afterRead?: SingletonHookSlot<AfterReadContext>;
1035
+ }
955
1036
  /**
956
1037
  * A lazy loader for a collection's hooks — the function form of
957
1038
  * `CollectionDefinition.hooks`. Returns the `CollectionHooks` object, or a
@@ -965,6 +1046,11 @@ export interface CollectionHooks {
965
1046
  * server-only code through `ServerConfig.hooks.collections` from a server-only
966
1047
  * entry instead.
967
1048
  *
1049
+ * The loader remains lazy, so its returned family cannot be inspected during
1050
+ * startup. A mismatched family fails on first lifecycle use instead. The
1051
+ * import result is cached, but {@link resolveHooks} repeats family validation
1052
+ * for the current definition on every call, including cache hits.
1053
+ *
968
1054
  * @example
969
1055
  * // docs.schema.ts — only for isomorphic-safe hooks
970
1056
  * export const Docs = defineCollection({
@@ -982,7 +1068,26 @@ export type CollectionHooksLoader = () => Promise<CollectionHooks | {
982
1068
  default: CollectionHooks;
983
1069
  }>;
984
1070
  /**
985
- * Resolve a collection's `hooks` to a concrete `CollectionHooks` object.
1071
+ * A lazy loader for a singleton's hooks.
1072
+ *
1073
+ * Loaders remain lazy: their returned family is validated on first lifecycle
1074
+ * use, not at startup, because eagerly importing server hook modules would
1075
+ * defeat the loader boundary. The import result is cached by loader identity,
1076
+ * but family validation still runs against the current definition on every
1077
+ * {@link resolveHooks} call so one reused loader cannot bypass the kind check.
1078
+ */
1079
+ export type SingletonHooksLoader = () => Promise<SingletonHooks | {
1080
+ default: SingletonHooks;
1081
+ }>;
1082
+ type ResolvedDocumentHooks = CollectionHooks & SingletonHooks;
1083
+ /**
1084
+ * Assert that an inspected hook object belongs to the definition's resource
1085
+ * family. Inline objects are checked during startup attachment; lazily loaded
1086
+ * objects are checked by {@link resolveHooks} on every resolution.
1087
+ */
1088
+ export declare function assertHooksMatchDefinition(definition: CollectionDefinition, hooks: CollectionHooks | SingletonHooks): void;
1089
+ /**
1090
+ * Resolve a document definition's `hooks` to a concrete, validated hook object.
986
1091
  *
987
1092
  * - The inline-object form (`hooks: { … }`) is returned as-is.
988
1093
  * - The loader form (`hooks: () => import('./docs.hooks.js')`) is invoked
@@ -991,9 +1096,14 @@ export type CollectionHooksLoader = () => Promise<CollectionHooks | {
991
1096
  * through here, so a loader's dynamic `import()` runs at most once per
992
1097
  * process regardless of how many documents flow through it.
993
1098
  *
1099
+ * Inline families are also validated during startup attachment. A loader
1100
+ * cannot be inspected without importing it, so a mismatched loader fails on
1101
+ * first lifecycle use rather than at boot. Validation runs on every call,
1102
+ * including cache hits, against the current definition.
1103
+ *
994
1104
  * Returns `undefined` when no hooks are declared.
995
1105
  */
996
- export declare function resolveHooks(definition: CollectionDefinition): Promise<CollectionHooks | undefined>;
1106
+ export declare function resolveHooks(definition: CollectionDefinition): Promise<ResolvedDocumentHooks | undefined>;
997
1107
  /**
998
1108
  * Type-safe factory for authoring a collection's hooks in a separate
999
1109
  * module (the loader form: `hooks: () => import('./docs.hooks.js')`).
@@ -1014,15 +1124,34 @@ export declare function resolveHooks(definition: CollectionDefinition): Promise<
1014
1124
  * })
1015
1125
  */
1016
1126
  export declare function defineHooks(hooks: CollectionHooks): CollectionHooks;
1017
- export interface CollectionDefinition {
1018
- labels: {
1019
- singular: string;
1020
- plural: string;
1021
- };
1127
+ /** Fields and lifecycle configuration shared by every document resource kind. */
1128
+ export interface DocumentDefinitionBase {
1022
1129
  path: string;
1023
1130
  fields: Field[];
1024
1131
  /** Sequential workflow configuration. Falls back to DEFAULT_WORKFLOW if omitted. */
1025
1132
  workflow?: WorkflowConfig;
1133
+ /**
1134
+ * Optional explicit version pin. When omitted, the startup bootstrap
1135
+ * auto-increments the collection's stored version any time the schema
1136
+ * fingerprint changes. When set, the value is used verbatim as long as it
1137
+ * is >= the currently-stored version; pinning backwards throws at startup.
1138
+ *
1139
+ * The stamped version is written onto every `documentVersions` row so that
1140
+ * a document can later be resolved against the schema shape it was
1141
+ * authored under.
1142
+ */
1143
+ version?: number;
1144
+ }
1145
+ /** A collection of many documents — Byline's original and default resource kind. */
1146
+ export interface MultiCollectionDefinition extends DocumentDefinitionBase {
1147
+ /** Absent or `false` on a multi-document collection. */
1148
+ singleton?: false;
1149
+ labels: {
1150
+ singular: string;
1151
+ plural: string;
1152
+ };
1153
+ /** A multi-document collection uses `labels`, never the singleton's `label`. */
1154
+ label?: never;
1026
1155
  /**
1027
1156
  * Lifecycle hooks for server-side document operations.
1028
1157
  *
@@ -1231,30 +1360,82 @@ export interface CollectionDefinition {
1231
1360
  * docs/04-collections/04-document-trees.md.
1232
1361
  */
1233
1362
  tree?: boolean;
1363
+ }
1364
+ /**
1365
+ * A single named document slot for an installation. Cardinality is
1366
+ * zero-or-one: the slot exists as soon as the definition is registered, and
1367
+ * its document is materialised by the first save.
1368
+ *
1369
+ * Collection-only options are absent by construction: there is no list to sort,
1370
+ * search, or paginate, no sibling documents to order or arrange in a tree, and
1371
+ * no public slug (`path` is internal metadata; see the design spec).
1372
+ */
1373
+ export interface SingletonDefinition extends DocumentDefinitionBase {
1374
+ singleton: true;
1375
+ /** Singular display label. A singleton has no plural form. */
1376
+ label: string;
1234
1377
  /**
1235
- * Optional explicit version pin. When omitted, the startup bootstrap
1236
- * auto-increments the collection's stored version any time the schema
1237
- * fingerprint changes. When set, the value is used verbatim as long as it
1238
- * is >= the currently-stored version; pinning backwards throws at startup.
1239
- *
1240
- * The stamped version is written onto every `documentVersions` row so that
1241
- * a document can later be resolved against the schema shape it was
1242
- * authored under.
1378
+ * Singleton lifecycle hooks, expressed as save/read/workflow operations.
1379
+ * Inline objects are family-validated at startup. A loader remains lazy and
1380
+ * is therefore family-validated on first lifecycle use and every subsequent
1381
+ * resolution, including cache hits shared with another definition.
1243
1382
  */
1244
- version?: number;
1383
+ hooks?: SingletonHooks | SingletonHooksLoader;
1384
+ /**
1385
+ * Collection-only members remain explicit `never` properties rather than
1386
+ * being omitted. Without them, `defineSingleton`'s generic parameter can
1387
+ * absorb excess properties and collection-only configuration would pass at
1388
+ * the definition site.
1389
+ */
1390
+ labels?: never;
1391
+ useAsTitle?: never;
1392
+ useAsPath?: never;
1393
+ orderable?: never;
1394
+ tree?: never;
1395
+ search?: never;
1396
+ listSearch?: never;
1397
+ advertiseLocales?: never;
1398
+ showStats?: never;
1399
+ linksInEditor?: never;
1400
+ buildDocumentPath?: never;
1245
1401
  }
1402
+ /** A registered multi-document collection or singleton document slot. */
1403
+ export type CollectionDefinition = MultiCollectionDefinition | SingletonDefinition;
1246
1404
  /**
1247
1405
  * Type-safe factory for creating a CollectionDefinition.
1248
1406
  * Returns the definition as-is but provides type checking.
1249
1407
  */
1250
- export declare function defineCollection<const C extends CollectionDefinition>(definition: C & CollectionDefinition): C;
1408
+ export declare function defineCollection<const C extends MultiCollectionDefinition>(definition: C & MultiCollectionDefinition): C;
1409
+ /**
1410
+ * Type-safe factory for a singleton definition. Adds the `singleton: true`
1411
+ * discriminant so authors never write it themselves, and locks in literal
1412
+ * types for `path` and field names so the generated path registries resolve
1413
+ * precisely. Mirrors `defineCollection`.
1414
+ */
1415
+ export declare function defineSingleton<const S extends Omit<SingletonDefinition, 'singleton'>>(definition: S & Omit<SingletonDefinition, 'singleton'>): S & {
1416
+ singleton: true;
1417
+ };
1418
+ /** Narrows a definition to the singleton variant. */
1419
+ export declare function isSingleton(definition: CollectionDefinition): definition is SingletonDefinition;
1251
1420
  export type CollectionFieldData<C extends CollectionDefinition> = FieldSetData<C['fields']>;
1252
1421
  export type CollectionFieldDataAllLocales<C extends CollectionDefinition> = FieldSetDataAllLocales<C['fields']>;
1253
1422
  /** Broad collection registry used when an application has not supplied its schema types. */
1254
1423
  export type CollectionRegistry = Record<string, Record<string, any>>;
1255
- /** Infer a path-to-fields registry from a shared readonly collection-definition tuple. */
1424
+ /** Infer the multi-collection path-to-fields registry from a shared definition tuple. */
1256
1425
  export type InferCollectionRegistry<TCollections extends readonly CollectionDefinition[]> = {
1257
- [TCollection in TCollections[number] as TCollection['path']]: CollectionFieldData<TCollection>;
1426
+ [TCollection in TCollections[number] as TCollection extends SingletonDefinition ? never : TCollection['path']]: CollectionFieldData<TCollection>;
1427
+ };
1428
+ /** Infer the all-locale multi-collection registry from a shared definition tuple. */
1429
+ export type InferCollectionRegistryAllLocales<TCollections extends readonly CollectionDefinition[]> = {
1430
+ [TCollection in TCollections[number] as TCollection extends SingletonDefinition ? never : TCollection['path']]: CollectionFieldDataAllLocales<TCollection>;
1431
+ };
1432
+ /** Infer the singleton path-to-fields registry from a shared definition tuple. */
1433
+ export type InferSingletonRegistry<TCollections extends readonly CollectionDefinition[]> = {
1434
+ [TCollection in TCollections[number] as TCollection extends SingletonDefinition ? TCollection['path'] : never]: CollectionFieldData<TCollection>;
1435
+ };
1436
+ /** Infer the all-locale singleton registry from a shared definition tuple. */
1437
+ export type InferSingletonRegistryAllLocales<TCollections extends readonly CollectionDefinition[]> = {
1438
+ [TCollection in TCollections[number] as TCollection extends SingletonDefinition ? TCollection['path'] : never]: CollectionFieldDataAllLocales<TCollection>;
1258
1439
  };
1259
1440
  /**
1260
1441
  * Type-safe factory for creating a Block. Returns the definition as-is,
@@ -1340,3 +1521,4 @@ export type CollectionDataAllLocales<C extends CollectionDefinition> = Prettify<
1340
1521
  created_at: Date;
1341
1522
  updated_at: Date;
1342
1523
  } & CollectionFieldDataAllLocales<C>>;
1524
+ export {};
@@ -175,8 +175,46 @@ export function normalizeCollectionHook(hook) {
175
175
  return Array.isArray(hook) ? hook : [hook];
176
176
  }
177
177
  const resolvedHooksCache = new WeakMap();
178
+ function completeHookNames() {
179
+ return (names) => names;
180
+ }
181
+ const COLLECTION_ONLY_HOOK_NAMES = completeHookNames()([
182
+ 'beforeCreate',
183
+ 'afterCreate',
184
+ 'beforeUpdate',
185
+ 'afterUpdate',
186
+ 'afterSystemFieldsChange',
187
+ 'beforeDelete',
188
+ 'afterDelete',
189
+ 'afterTreeChange',
190
+ ]);
191
+ const SINGLETON_ONLY_HOOK_NAMES = completeHookNames()([
192
+ 'beforeSave',
193
+ 'afterSave',
194
+ ]);
178
195
  /**
179
- * Resolve a collection's `hooks` to a concrete `CollectionHooks` object.
196
+ * Assert that an inspected hook object belongs to the definition's resource
197
+ * family. Inline objects are checked during startup attachment; lazily loaded
198
+ * objects are checked by {@link resolveHooks} on every resolution.
199
+ */
200
+ export function assertHooksMatchDefinition(definition, hooks) {
201
+ const record = hooks;
202
+ if (isSingleton(definition)) {
203
+ for (const name of COLLECTION_ONLY_HOOK_NAMES) {
204
+ if (record[name] !== undefined) {
205
+ throw new Error(`Singleton "${definition.path}" cannot use collection hook "${name}".`);
206
+ }
207
+ }
208
+ return;
209
+ }
210
+ for (const name of SINGLETON_ONLY_HOOK_NAMES) {
211
+ if (record[name] !== undefined) {
212
+ throw new Error(`Collection "${definition.path}" cannot use singleton hook "${name}".`);
213
+ }
214
+ }
215
+ }
216
+ /**
217
+ * Resolve a document definition's `hooks` to a concrete, validated hook object.
180
218
  *
181
219
  * - The inline-object form (`hooks: { … }`) is returned as-is.
182
220
  * - The loader form (`hooks: () => import('./docs.hooks.js')`) is invoked
@@ -185,18 +223,27 @@ const resolvedHooksCache = new WeakMap();
185
223
  * through here, so a loader's dynamic `import()` runs at most once per
186
224
  * process regardless of how many documents flow through it.
187
225
  *
226
+ * Inline families are also validated during startup attachment. A loader
227
+ * cannot be inspected without importing it, so a mismatched loader fails on
228
+ * first lifecycle use rather than at boot. Validation runs on every call,
229
+ * including cache hits, against the current definition.
230
+ *
188
231
  * Returns `undefined` when no hooks are declared.
189
232
  */
190
233
  export async function resolveHooks(definition) {
191
234
  const hooks = definition.hooks;
192
- if (typeof hooks !== 'function')
235
+ if (typeof hooks !== 'function') {
236
+ if (hooks !== undefined)
237
+ assertHooksMatchDefinition(definition, hooks);
193
238
  return hooks;
194
- const cached = resolvedHooksCache.get(hooks);
195
- if (cached)
196
- return cached;
197
- const loaded = await hooks();
198
- const resolved = 'default' in loaded ? loaded.default : loaded;
199
- resolvedHooksCache.set(hooks, resolved);
239
+ }
240
+ let resolved = resolvedHooksCache.get(hooks);
241
+ if (resolved === undefined) {
242
+ const loaded = await hooks();
243
+ resolved = ('default' in loaded ? loaded.default : loaded);
244
+ resolvedHooksCache.set(hooks, resolved);
245
+ }
246
+ assertHooksMatchDefinition(definition, resolved);
200
247
  return resolved;
201
248
  }
202
249
  /**
@@ -228,6 +275,19 @@ export function defineHooks(hooks) {
228
275
  export function defineCollection(definition) {
229
276
  return definition;
230
277
  }
278
+ /**
279
+ * Type-safe factory for a singleton definition. Adds the `singleton: true`
280
+ * discriminant so authors never write it themselves, and locks in literal
281
+ * types for `path` and field names so the generated path registries resolve
282
+ * precisely. Mirrors `defineCollection`.
283
+ */
284
+ export function defineSingleton(definition) {
285
+ return { ...definition, singleton: true };
286
+ }
287
+ /** Narrows a definition to the singleton variant. */
288
+ export function isSingleton(definition) {
289
+ return definition.singleton === true;
290
+ }
231
291
  // ---------------------------------------------------------------------------
232
292
  // Block helpers — mirrors of defineCollection / CollectionFieldData / etc.
233
293
  // ---------------------------------------------------------------------------