Collections API
Companions:
- Collections — recipes and explanation for modeling and presenting a collection.
- Fields API — every field definition accepted by
CollectionDefinition.fields. - Configuration API — how collection, admin, block-admin, and server-hook registries enter the runtime.
- Collection versioning — fingerprints, automatic version bumps, and explicit version pins.
- Authentication and authorization — the
beforeReadrecipes include separating public and private singleton values.
This reference lists the complete application-facing document-resource and presentation contracts exported by @byline/core. Schemas are isomorphic; collection, singleton, and block admin configs are client-side presentation.
defineCollection(definition)
function defineCollection<const C extends MultiCollectionDefinition>( definition: C & MultiCollectionDefinition): CReturns the definition unchanged while preserving literal paths, field names, select values, and block types for inference.
import { defineCollection } from '@byline/core'
export const Articles = defineCollection({ path: 'articles', labels: { singular: 'Article', plural: 'Articles' }, useAsTitle: 'title', useAsPath: 'title', fields: [{ name: 'title', type: 'text', localized: true }],})CollectionDefinition
type CollectionDefinition = MultiCollectionDefinition | SingletonDefinitionThe singleton property discriminates the two branches. defineCollection() accepts only MultiCollectionDefinition; defineSingleton() accepts only the singleton branch.
MultiCollectionDefinition
Property | Required or default | Description |
| Omitted or | Multi-document resource discriminant. |
| Required | Unique collection key used by storage, clients, abilities, routes, and admin registration. |
| Required |
|
|
| Singleton-only singular label; rejected on a multi-document definition. |
| Required | Ordered |
|
| Sequential workflow. Use |
| None | Isomorphic-safe |
| None | Provider-search projection: |
| Identity field | Top-level |
| First suitable text field where a fallback exists | Field used as the document's single-line identity in headings, relations, projections, and logs. |
| UUID path | Top-level path-compatible field whose value initializes the sticky document path at create time. |
|
| Enables the document-grain available-locales control. Requires at least one localized field. |
| Generic collection/path composition | Host function that builds a locale-agnostic root-relative public path for rich-text links and preview fallback. |
|
| Includes this collection in the rich-text internal-link picker. Requires |
|
| Shows per-status counts on the admin collection card. Adds one count read per enabled collection on landing. |
|
| Enables document-grain fractional ordering and drag-to-reorder. Mutually exclusive with |
|
| Enables a single-parent, per-parent ordered document hierarchy. Mutually exclusive with |
| Automatic | Explicit collection schema-version pin. It cannot be lower than the stored version. |
search
search?: { body?: SearchFieldDecl[] facets?: SearchFieldDecl[] filters?: string[] zones?: string[]}
type SearchFieldDecl = string | { field: string; boost?: number }Property | Description |
| Text-bearing fields included in full-text content. Rich-text entries require |
| Relation fields whose targets contribute their counter and title values to the projection. |
| Scalar fields projected for provider filtering or sorting. Built-in SQL providers currently advertise their supported capabilities separately. |
| Named cross-collection search scopes. When omitted, an implicit zone matching the collection path supports collection search. |
Search configuration documents valid field roles, weights, provider capabilities, and boot validation.
buildDocumentPath
buildDocumentPath?: ( doc: { id: string path: string status: string fields: Record<string, any> }, ctx: { collectionPath: string }) => string | nullReturn a root-relative path beginning with /, or null when the collection cannot build one. Do not include an origin or locale prefix. The rich-text path composer falls back to /${collectionPath}/${path} when this function returns null; the admin preview resolver treats null as no meaningful preview URL.
buildDocumentPath: (doc) => (doc.path ? `/articles/${doc.path}` : null)defineSingleton(definition)
function defineSingleton<const S extends Omit<SingletonDefinition, 'singleton'>>( definition: S & Omit<SingletonDefinition, 'singleton'>): S & { singleton: true }The parameter supplies the singleton schema without its discriminator. The return value preserves literal path and field types and stamps singleton: true after the input spread.
import { defineSingleton } from '@byline/core'
export const SiteSettings = defineSingleton({ path: 'site-settings', label: 'Site settings', fields: [{ name: 'siteName', label: 'Site name', type: 'text' }],})SingletonDefinition
Property | Required or default | Description |
| Required | Resource-family discriminator. |
| Required | Unique slot key shared by configuration, generated registries, abilities, admin routing, client lookup, and storage mapping. |
| Required | Singular display label. |
| Required | Ordered |
|
| Sequential workflow. |
| Automatic | Explicit schema-version pin; it cannot be lower than the stored version. |
| None |
|
|
| Multi-document plural labels are invalid. |
|
| There is no list item that needs a derived title. |
|
| The backing document path is generated internal metadata, not a public identifier. |
|
| There are no sibling documents to order. |
|
| A document tree requires several independently addressed documents. |
|
| Singleton search indexing is not shipped. |
|
| There is no admin list to search. |
|
| The collection-level advertised-locale control is not part of the singleton surface. |
|
| There is no dashboard document-count query. |
|
| Singletons are not rich-text link targets. |
|
| A singleton does not expose a per-document public path. |
The explicit ?: never members prevent the factory's generic parameter from absorbing collection-only excess properties. Runtime validation checks the same list for untyped JavaScript and rejects a collection and singleton that share one path.
Deferred relation-target, search-indexing, embedded-tree, and multi-tenant-instance surfaces are fenced in Singletons — Not yet shipped.
defineAdmin(schema, config)
function defineAdmin<T = any>( schema: MultiCollectionDefinition, config: Omit<CollectionAdminConfig<T>, 'slug' | 'singleton'>): CollectionAdminConfig<T>Returns the admin config with slug set from schema.path.
import { defineAdmin } from '@byline/core'
import { Articles } from './schema.js'
export const ArticlesAdmin = defineAdmin(Articles, { columns: [ { fieldName: 'title', label: 'Title', sortable: true }, { fieldName: 'status', label: 'Status' }, ], layout: { main: ['title', 'content'] },})CollectionAdminConfig
Property | Required or default | Description |
|
| Admin-resource discriminator. |
| Required; set by | Must equal the corresponding collection |
| None | Dashboard group this collection belongs to. Names an entry in |
| Synthesized defaults | Columns for the collection's default list view. |
|
| Initial list sort when the URL has no explicit order. Invalid on |
|
| Compact row/tile projection and presentation used by relation pickers and relation summaries. |
|
| Sort used by item-view list surfaces. Invalid on |
| None | Default field-name list used when no explicit column configuration is supplied. |
|
| Named tab bars referenced from |
|
| Named horizontal field rows referenced from layouts, tabs, or groups. |
|
| Named labelled fieldsets referenced from layouts or tabs. |
| All schema fields in | Composes raw fields and named primitives into |
|
| Per-field presentation overrides keyed by index-free schema path. Block field overrides belong in |
| Collection path fallback | Custom preview URL function. It falls back through |
| Default table | Component that completely replaces the default collection list view. |
|
| Components rendered in the default list header. Ignored when |
ListDefaultSort
interface ListDefaultSort<T = any> { field: keyof T | 'createdAt' | 'updatedAt' | 'path' direction?: 'asc' | 'desc'}The direction defaults to asc. The field must be a top-level schema field or one of the listed document columns. Explicit URL sort parameters take precedence.
ColumnDefinition
interface ColumnDefinition<T = any> { fieldName: keyof T label: string sortable?: boolean align?: 'left' | 'center' | 'right' className?: string formatter?: | ((value: any, record: T) => any) | { component: (props: { value: any; record: T }) => any }}Property | Default | Description |
| Required | Schema field or supported top-level document column. |
| Required | Column heading. |
|
| Enables the list's sort control when the storage/query surface supports the field. |
| Renderer default | Cell alignment. |
| None | CSS class applied by the table renderer. |
| Default field rendering | Plain function or component wrapper. Use the component form when the formatter needs React hooks or context. |
Layout definitions
interface TabDefinition { name: string label: string fields: string[] condition?: (data: Record<string, any>) => boolean}
interface TabSetDefinition { name: string tabs: TabDefinition[]}
interface RowDefinition { name: string fields: string[]}
interface GroupDefinition { name: string label?: string fields: string[]}
interface LayoutDefinition { main: string[] sidebar?: string[]}Primitive | Membership | Placement |
Tab set | Tabs; each tab accepts schema fields, row names, and group names |
|
Row | Schema field names only | Main, sidebar, tab, or group |
Group | Schema fields and row names | Main, sidebar, or tab |
Raw field | One schema field name | Main, sidebar, tab, row, or group according to the container rules |
Names must be unique within their registry. Admin validation rejects unknown references, duplicate placement, tab sets in the sidebar, nested groups, and other invalid combinations.
preview
preview?: { url: ( doc: { id: string; path: string; status: string; fields: T }, ctx: { locale?: string } ) => string | null}Return a relative or absolute URL, or null to hide the preview action. Direct relation targets are populated to depth one using their item-view projection on the admin edit read.
listView and listActions
interface ListViewComponentProps<TData = any> { data: TData workflowStatuses?: WorkflowStatus[]}
interface ListActionComponentProps { collectionPath: string}A custom listView owns search, ordering, results, pagination, and header actions. listActions extend only the default list view and must perform their own permission gating.
defineSingletonAdmin(schema, config)
function defineSingletonAdmin<T = any>( schema: SingletonDefinition, config: Omit<SingletonAdminConfig<T>, 'slug' | 'singleton'>): SingletonAdminConfig<T>The function returns the form config with singleton: true and slug set from schema.path.
import { defineSingletonAdmin } from '@byline/core'
export const SiteSettingsAdmin = defineSingletonAdmin(SiteSettings, { group: 'settings', layout: { main: ['siteName', 'siteDescription'] }, preview: { url: () => '/' },})SingletonAdminConfig
Property | Required or default | Description |
| Required | Admin-resource discriminator. |
| Required; stamped by | Must equal the corresponding singleton definition path. |
| None | Dashboard resource group name from |
|
| Named tab bars used by |
|
| Named horizontal field rows used by |
|
| Named labelled fieldsets used by |
| All schema fields in | Shared form composition for the singleton editor. |
|
| Per-field presentation overrides keyed by index-free schema path. |
| None |
|
|
| Collection list option. |
|
| Collection list option. |
|
| Collection list option. |
|
| Collection item-list presentation option. |
|
| Collection item-list sort option. |
|
| Collection list replacement. |
|
| Collection list-header extension. |
The explicit ?: never list prevents generic excess-property absorption. Startup validation applies the same kind and option checks to untyped configuration.
Blocks
defineBlock(definition)
interface Block { blockType: string fields: Field[] label?: string helpText?: string hooks?: FieldHooks validate?: (value: any, data: Record<string, any>) => string | undefined}
function defineBlock<const B extends Block>(definition: B & Block): BblockType is the stable discriminator stored on every block value. Keep block schema files isomorphic.
export const QuoteBlock = defineBlock({ blockType: 'quote', label: 'Quote', fields: [ { name: 'quoteText', type: 'richText' }, { name: 'attribution', type: 'text', optional: true }, ],})defineBlockAdmin(block, config)
interface BlockAdminConfig { blockType: string fields?: Record<string, FieldAdminConfig>}
function defineBlockAdmin<B extends Block>( block: B, config: { fields?: Record<string, FieldAdminConfig> }): BlockAdminConfigdefineBlockAdmin() sets blockType from the block. Field keys are index-free schema paths relative to the block root, such as quoteText or faq.answer. A block admin config applies wherever that block type renders.
Blocks covers block storage, type generation, nested arrays, uploads, and editor overrides.
Workflow
defineWorkflow(input?)
interface DefineWorkflowInput { draft?: { label?: string; verb?: string } published?: { label?: string; verb?: string } archived?: { label?: string; verb?: string } customStatuses?: Array<{ name: string; label?: string; verb?: string }> defaultStatus?: string}
function defineWorkflow(input?: DefineWorkflowInput): WorkflowConfigThe result always orders statuses as draft, every custom status, published, then archived. Custom statuses cannot reuse a required name. The default status is draft unless overridden.
workflow: defineWorkflow({ customStatuses: [ { name: 'needs_review', label: 'Needs review', verb: 'Request review' }, ],})DEFAULT_WORKFLOW is the built-in draft/published/archived workflow. SINGLE_STATUS_WORKFLOW contains only published, publishes new documents immediately, and removes irrelevant workflow controls from the admin.
Collection lifecycle hooks
Each hook accepts one function or an array executed sequentially. Use defineHooks(hooks) to type an object without changing it.
Hook | Timing and contract |
| Before persistence; may mutate the outgoing |
| After a create commits; suitable for side effects. |
| Before a new immutable version is written; may mutate |
| After an update or restore commits. |
| After an actual path/available-locales change commits, or an explicit reconciliation retry. |
| Before an in-place workflow transition. |
| After the workflow transition commits. |
| Before published versions are archived by unpublish. |
| After unpublish commits. |
| Before soft deletion. |
| After deletion commits. |
| After place, reorder, re-parent, removal, or delete-time tree reconciliation commits. |
| Before database work; returns a strict |
| After populate for each materialized document; may mutate |
After hooks run outside the storage transaction. A post-commit failure cannot roll back the committed content or structural change. Callers must follow the lifecycle result's committed/failure contract rather than blindly retrying writes. Every version-creating collection operation rejects an afterCreate or afterUpdate failure with ERR_DOCUMENT_HOOK_COMMITTED, including create, update, patch, duplicate, restore, copy-to-locale, and delete-locale. Singleton saves use the same error for afterSave. Its details include the committed document/version IDs, the failed phase, and an allowlisted ERR_STORAGE or ERR_UNHANDLED side-effect code. This is a reconciliation signal, not permission to repeat the write.
Hooks attached directly to a schema must be safe in every graph that imports that schema. Register server-only hook loaders through ServerConfig.hooks.collections.
Upload hooks are separate field-scoped beforeStore and afterStore hooks. Their complete contract is in File and media uploads.
SingletonHooks
interface SingletonHooks { beforeSave?: SingletonHookSlot<BeforeSingletonSaveContext> afterSave?: SingletonHookSlot<AfterSingletonSaveContext> beforeStatusChange?: SingletonHookSlot<StatusChangeContext> afterStatusChange?: SingletonHookSlot<StatusChangeContext> beforeUnpublish?: SingletonHookSlot<BeforeUnpublishContext> afterUnpublish?: SingletonHookSlot<AfterUnpublishContext> beforeRead?: BeforeReadHookSlot afterRead?: SingletonHookSlot<AfterReadContext>}Each slot accepts one function or an ordered array. beforeSave runs inside the locked write transaction and may mutate data; afterSave runs after commit and cannot roll back a persisted version or initial mapping.
Save contexts
interface BeforeSingletonSaveContext { data: Record<string, any> originalData: Record<string, any> | null singletonPath: string locale: string requestContext: RequestContext isInitialSave: boolean operation: SingletonSaveOperation documentId: string | null}
interface AfterSingletonSaveContext { data: Record<string, any> originalData: Record<string, any> | null singletonPath: string locale: string requestContext: RequestContext isInitialSave: boolean operation: SingletonSaveOperation documentId: string documentVersionId: string}On the first save, originalData and BeforeSingletonSaveContext.documentId are null. AfterSingletonSaveContext always identifies the committed document and version.
SingletonSaveOperation
type SingletonSaveOperation = | { type: 'save' } | { type: 'restore'; sourceVersionId: string } | { type: 'copyToLocale' sourceLocale: string targetLocale: string overwrite: boolean }Branch |
|
|
|
| Submitted locale field tree after normalisation; mutable in | Current values in the same locale, or | Submitted locale, defaulting to the installation content locale. |
| Complete historical all-locale field tree; localised fields remain locale maps. | Complete current all-locale field tree. | Literal |
| Source values merged into the target payload according to | Previous target-locale values, or |
|
import type { SingletonHooks } from '@byline/core'
const hooks = { afterSave: ({ operation, documentVersionId }) => { if (operation.type === 'restore') console.info('restored', documentVersionId) },} satisfies SingletonHooksShared read and workflow contexts
Hook | Context | Fields and return |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const hooks = { beforeRead: ({ requestContext }) => requestContext.actor == null ? false : undefined,} satisfies SingletonHooksInline hook objects are family-validated at startup. SingletonHooksLoader is lazy, so a mismatched family fails on first lifecycle resolution; the imported object is cached, but its family is revalidated on every resolution.