Document Paths
Companions:
- Document Storage —
pathwas the first system attribute promoted out of the EAV layer; it now lives in a dedicatedbyline_document_pathstable keyed by(document_id, locale), separate fromdocumentVersions. - Relationships —
pathis the routing identifier used by relation filters (where: { category: { path: 'news' } }) and resolved viafindByPathunder the samereadModerule populate honours, with locale fallback applied per request. - Collections —
useAsPathparticipates in the collection schema fingerprint (see the Fingerprint section).
Overview
A document's path is its stable, human-readable address — the slug you put in a URL, such as launch-2026. It is a reserved system attribute and the cheapest path-resolution lookup in the system, used by findByPath and by relation filters. Storage lives in a dedicated byline_document_paths table keyed by (document_id, locale) with a unique constraint on (collection_id, locale, path). See Path uniqueness below for the full schema and lifecycle behaviour.
Read this document when you are choosing which field drives a collection's URLs, overriding a slug from a seed or import, or handling a path collision.
Three rules anchor the model:
- path is reserved. No collection field may be named
path, at any nesting depth (group, array, blocks). Validation runs at config load and throws. - useAsPath?: string on
CollectionDefinitionnames the source field whose slugified (URL safe) value initialises a document'spathrow. Parallel touseAsTitle. The named field must exist at the top level and be of a path-compatible type (text,textArea,select,date,datetime,time). - One canonical resource identifier per document. It is stored under the document's
sourceLocale(the default content locale on create); localised slugs (/en/aboutvs/de/ueber-uns) are deferred. Frontends can still prefix/{locale}/{path}over the single canonical path.
This work was the first time a system attribute was promoted out of the user-defined field tree. It establishes a pattern for any future "system metadata that needs editing in the admin form": reserve the name, expose it via a directive, render it through a non-field widget, and persist it via a top-level lifecycle parameter — not a field.set patch.
Quick reference
Each entry is the minimal shape for one task. The "Edit" line tells you which file you actually change; the link at the end points at the deeper section.
1. Set useAsPath on a collection
Name the field whose slugified value initialises a document's path on first create. Must be a top-level field of a path-compatible type (text, textArea, select, date, datetime, time).
Edit: apps/webapp/byline/collections/<name>/schema.ts
export const News = defineCollection({ path: 'news', useAsTitle: 'title', useAsPath: 'title', // ← slugified from `title` on first create fields: [ { name: 'title', type: 'text', localized: true }, /* … */ ],})path is sticky after creation — subsequent saves don't re-derive. Editors can re-anchor explicitly via the path widget's "Regenerate from {source}" action.
2. Override path explicitly on create or update
Both CollectionHandle.create and CollectionHandle.update accept a top-level path parameter (separate from data). Useful for seeds, imports, and any caller that needs a specific URL slug.
Edit: any write call site — typically a seed under apps/webapp/byline/seeds/ or a one-off script.
await client.collection('news').create({ data: { title: 'Launch announcement' }, path: 'launch-2026', // ← overrides the useAsPath derivation locale: 'en',})
await client.collection('news').update(id, { data: { title: 'Revised title' }, path: 'new-canonical-slug', // ← only honoured on source-locale writes})On a non-source-locale (translation) update, path is dropped silently with a logger.warn — the canonical path belongs to the document's source locale.
3. Install a custom slugifier
The default slugifier is pure, sync, Unicode-aware (NFC), CJK-preserving, and recognises ISO 8601 date prefixes. Override site-wide if you need stricter URL policies, a different transliteration, or a domain-specific format. The contract is sync + pure because the same function runs server-side at write time and client-side in the path widget's live preview — the two must agree.
Edit: apps/webapp/byline/server.config.ts
import type { SlugifierFn } from '@byline/core'
const myStrictSlugifier: SlugifierFn = (value, _ctx) => { return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}
await initBylineCore({ // …db, collections, storage, sessionProvider, adminStore, … slugifier: myStrictSlugifier,})4. Handle ERR_PATH_CONFLICT
Per-collection path uniqueness is enforced for live documents at the database level. Collisions with another live document surface as ERR_PATH_CONFLICT from the lifecycle layer; a deleted document can retain the same path without occupying the live namespace, and re-saving the same path for the same live document is idempotent.
Edit: any write call site that surfaces user-supplied paths.
import { BylineError, ErrorCodes } from '@byline/core'
try { await client.collection('news').update(id, { data: { title }, path: requestedPath, })} catch (err) { if (err instanceof BylineError && err.code === ErrorCodes.PATH_CONFLICT) { return { error: `The slug "${requestedPath}" is already in use.` } } throw err}Create and update do not auto-suffix because a silent rename would change the requested address. The explicit duplicate operation is different: it retries one live collision with a short-UUID suffix. Seeders and bulk imports can pre-resolve uniqueness in caller code when they need another policy.
Derivation cascade
createDocument runs three derivation steps in order:
- Explicit params.path from the caller → used verbatim.
- definition.useAsPath set → slugify (make URL safe) the source field's value in the default content locale using the installation slugifier.
- Otherwise →
crypto.randomUUID().
The cascade applies only on first create. After that, path is sticky — updateDocument and updateDocumentWithPatches never re-derive. The previous version's path carries forward unchanged unless the caller supplies an explicit params.path. This protects inbound links and SEO from a title-edit accidentally invalidating a URL. The path widget surfaces a "Regenerate from {sourceField}" action for explicit re-anchoring.
Default-locale enforcement on first create
A brand-new document MUST be created in the configured default content locale (ServerConfig.i18n.content.defaultLocale). Creating in any other locale throws ERR_VALIDATION. Subsequent localised versions of the same document inherit the existing path automatically — it never re-derives, regardless of locale.
The slugifier
packages/core/src/utils/slugify.ts. Pure, synchronous, Unicode-aware (NFC), Thai-script and CJK preserving, HTML-stripping. Recognises ISO 8601 date prefixes and returns yyyy-mm-dd rather than slugifying the time portion. Exported as slugify, formatTextValue, looksLikeISODate plus the SlugifierFn / SlugifyContext types.
The slugifier is intentionally trivial to swap. Installations with strict URL or security policies can supply their own (value, ctx) => string on ServerConfig.slugifier. The contract is sync + pure because the same function runs server-side at write time and client-side in the path widget for live preview — the two must agree.
Lifecycle wiring
packages/core/src/services/document-lifecycle/internals.ts carries the derivePath helper (shared by the create / duplicate operation modules, not exported from the package) and context.ts threads defaultLocale + optional slugifier through DocumentLifecycleContext. All callers — admin server fns, the client SDK's CollectionHandle.create/update, and the upload service — populate these fields explicitly from ServerConfig.
createDocument enforces the default-locale rule and runs the derivation cascade, then the storage primitive's createDocumentVersion upserts the corresponding row in byline_document_paths. updateDocument and updateDocumentWithPatches are sticky: when no params.path is supplied the path row is left untouched (no DB write), and when a path is supplied on a non-source-locale (translation) save it is dropped silently with a logger.warn rather than overwriting the source-locale row. All three accept an optional params.path for explicit override on source-locale operations.
The admin's dedicated direct-write service is stricter about change detection. Inside the audit transaction it locks the logical document and snapshots its authoritative source locale, path, and advertised locales. An unchanged path causes no storage write or audit row; a real change and its document.path.changed row commit together. Only after commit does afterSystemFieldsChange run, carrying previousPath and currentPath plus requested, changed, and reconciliation flags. A hook failure rejects the call but cannot roll back the committed path/audit.
Two helpers in the lifecycle module own this policy:
resolvePathForUpdate— decides whether the storage primitive should receive apathargument (source-locale write) or be called without one (translation save), and emits the warn log when it drops a translation-locale change.rethrowPathConflict— catches the underlying Postgres unique-violation onidx_document_paths_collection_locale_path(SQLSTATE23505) and translates it toERR_PATH_CONFLICT. Walks the Drizzle / pg cause chain so wrapped errors are still detected.
Validation
packages/core/src/config/validate-collections.ts runs at config load time. It walks every collection's field tree (recursively into group, array, and blocks fields) and rejects any field named path. If useAsPath is set, it asserts the named field exists and is of a slugifier-compatible type.
The reserved name set is exported as RESERVED_FIELD_NAMES so the storage layer can consume it.
The path widget
packages/admin/src/forms/path-widget.tsx. Rendered in the form sidebar, conceptually grouped with status and timestamps — path is identity metadata, not per-locale content.
- Reads
useFieldValue(useAsPath)to track the source field live. - Computes
livePreview = slugify(sourceValue)using the same slugifier the server will apply. - Subscribes to a new
systemPathslot on form context (useSystemPath()).
Behaviour:
- Edit mode — input shows the persisted
byline_document_pathsrow for the editing locale (resolved via the same[requested, source]fallback chain reads use). Editing writes a string override into the slot; clearing reverts tonull(sticky from the previous version on save). When editing a translation, the input renders read-only — the canonical path belongs to the source locale, and the read-only state prevents the lifecycle's translation-locale warn from being hit through the admin form. - Create mode — input is empty by default; placeholder shows the live-derived preview (
Will be saved as "..."). - "Regenerate from {source}" action — small text-style link rendered right-aligned to the label when
livePreview !== systemPath. Clicking writes the live preview into the override slot. Used to re-anchor a stale path against an updated title. - Live validation hint — typed values are slugified for comparison; if the typed value differs from its slugified form, an inline
Suggested: "..."hint surfaces without blocking input.
The widget bypasses the patch system. The systemPath slot on form context (getSystemPath, setSystemPath, subscribeSystemPath) is initialised from initialData.path on mount, tracked in dirty state, reset on form save, and threaded into the onSubmit(...) payload that FormRenderer emits.
On an existing document, a path edit in the admin is a document-level, non-versioned write: path lives in byline_document_paths keyed by logical document (sticky across versions), so editing it does not mint a new version or reset workflow status. FormRenderer partitions its dirty state (getDirtyBreakdown() → none / content / direct-write / both), confirms the immediate write with a modal, and persists path through the dedicated non-versioned write path below. On create, path is still part of the initial version write. See Internationalization for the shared design (the available-locales widget works the same way).
Server transport
The create server fn (.../collections/create.ts) accepts an optional top-level path on the request payload (separate from data) and forwards it as params.path to the lifecycle — on create, path is part of the initial version write. Editing an existing document's path in the admin no longer rides the versioned update: it routes through a dedicated updateCollectionDocumentSystemFields server fn → updateDocumentSystemFields lifecycle service → updateDocumentPath storage command — an immediate write that mints no version and leaves status untouched. (updateCollectionDocumentWithPatches no longer carries path.) This still mirrors the setDocumentStatus precedent: system metadata is addressed via dedicated parameters, not field patches.
The admin server fn passes reconcile: true. If an earlier afterSystemFieldsChange failed after commit, repeating the same save therefore re-emits the hook with reconciliation: true but performs no second path write or audit append. Reconciliation is explicit lifecycle behavior, not an automatic retry loop.
The @byline/client SDK exposes CreateOptions.path and UpdateOptions.path on CollectionHandle.create/update. Note the SDK's whole-document update still writes path as part of its version (path is one parameter of a deliberate version write); it fires afterUpdate, not afterSystemFieldsChange. The non-versioned event belongs to the interactive admin/direct-write affordance; create continues to fire afterCreate.
Patches stay admin-internal
path is not addressable via field.* / array.* / block.* patches — it is system metadata, parallel to status. The widget writes to the separate systemPath slot; the submit payload sends it as a top-level field. This keeps the patch system aligned with UI intent (reordering, block insertion, field-level changes) and keeps system metadata out of the patch grammar.
Path uniqueness
Per-collection path uniqueness is enforced at the database level via a dedicated byline_document_paths table. The version-level documentVersions.path column has been retired. The new model:
byline_document_paths document_id uuid } unique with locale locale varchar } collection_id uuid path varchar(255) deleted_at timestamp | null alive boolean GENERATED ALWAYS AS ( CASE WHEN deleted_at IS NULL THEN true ELSE NULL END ) STORED UNIQUE (collection_id, locale, path, alive)There is one row per logical document per content locale. alive = true marks a live path; deleted_at being non-null makes generated alive null. PostgreSQL and MySQL unique constraints allow several null values, so UNIQUE (collection_id, locale, path, alive) prevents two live documents from sharing a path while allowing any number of deleted documents to retain it. Callers never write alive directly.
UNIQUE (document_id, locale) remains unchanged. Soft deletion changes the existing row's liveness rather than creating path-history rows. Locale is modelled from day one even though the lifecycle writes only the document's source-locale row — the column is present so localized paths remain an additive future capability.
Lifecycle behaviour
- Create —
createDocumentenforces "first create must be in the default content locale" (existing rule), then writes the path row keyed by(document_id, defaultContentLocale). Collisions surface asERR_PATH_CONFLICT. - Update in the source locale — a versioned
updateDocumentupserts the path row when an explicitparams.pathis supplied. The admin direct-write path first compares the locked current value and skips an equal value. Sticky: if no non-emptypathis supplied, the existing row carries forward unchanged. Collisions surface asERR_PATH_CONFLICT. - Update in a non-source (translation) locale — path changes are dropped silently with a
logger.warn; the source-locale row is left untouched and the path widget is read-only. A future per-locale paths UI can lift this restriction. - Soft delete — one transaction sets
deleted_aton every path row andis_deleted = trueon every version with one operation timestamp. Path values, versions, field rows, uploaded sources, and generated variants remain stored, but path lookup ignores the tombstoned rows and another document can claim the path. - Whole-document storage un-delete —
restoreSoftDeletedDocument({ document_id })clears every path tombstone and every version tombstone in one transaction. The live unique constraint revalidates the retained path; if another live document has claimed it, the whole restoration rolls back. Missing, versionless, already-live, and legacy partially-live documents return0. This storage primitive does not reconstruct tree placement or search/cache projections, so it is reserved for trusted tooling until a complete lifecycle restore exists. - Historical-version restore —
restoreDocumentVersioncreates a new version of an already-live document. It never changes path liveness and cannot un-delete a whole document.
Collision policy
Reject live collisions by default, surfaced as ERR_PATH_CONFLICT from the lifecycle layer. The message identifies the attempted operation and states that a live document already owns the path without exposing that document's id. The storage adapter's createDocumentVersion performs an upsert keyed by (document_id, locale), so re-saving the same path for the same document is idempotent. Deleted rows do not collide. Ordinary create and update do not auto-suffix; the duplicateDocument lifecycle is the exception and retries one live collision with a short-UUID suffix.
Read-side locale resolution
Once a document row is known, reads compose a fallback chain [requested, source], deduplicated when both values match. The initial findByPath lookup cannot know that source locale yet, so it resolves (collection_id, path) against [requested, configuredDefault] via one ordered subquery — never a double round-trip — and requires alive = true. A deleted higher-priority locale is skipped before a live fallback is selected. Projection helpers (pathProjection, viewProjection, documentVersionsProjection) remain unfiltered because they attach retained paths after a document id is already known, including for trusted history tooling. The current_* views deliberately do not project path — locale is request-scoped and lives in the storage adapter's read functions, not in static view DDL.
Where the source-locale value comes from
pgAdapter() and mysqlAdapter() take a defaultContentLocale: string parameter, threaded from ServerConfig.i18n.content.defaultLocale. New documents use this as their initial sourceLocale; existing documents resolve path writes and fallback against their own source locale (falling back to the configured default for legacy rows). @byline/client resolves the same value (from explicit config, the supplied ServerConfig, or 'en' as a last-resort fallback for tests and migration scripts) and applies it as the implicit default for locale on every read method.
Existing-installation migration
Fresh installations receive the current schema from each adapter's squashed 0000 Drizzle baseline. That baseline is not an upgrade stream. Existing installations must apply the numbered, idempotent native script for their provider:
# PostgreSQLpsql "$DATABASE_URL" -f packages/db-postgres/sql/0006_soft_delete_path_liveness.sql
# MySQLmysql -u byline -p byline_dev < packages/db-mysql/sql/0001_soft_delete_path_liveness.sqlApply the scripts from the target release in filename order. The PostgreSQL script runs the backfill and constraint replacement transactionally. MySQL DDL auto-commits, so its script guards each step, validates both directions of path/version liveness afterwards, and is safe to rerun. Do not apply the feature's development Drizzle migration or a future fresh-install baseline to an existing database.
Current limitations
- One path per document, under its source locale. Translated paths (a different slug per locale) are not yet a CMS concern. This is a deliberate simplification, not a structural limit —
byline_document_pathsis already locale-keyed and known-document projections resolve through a[requested, source]chain, so a frontend can route/{locale}/{path}today and per-locale slugs can be added additively later. Most sites need nothing more. - No per-collection slugifier override. The slugifier is configured once on
ServerConfig; a collection cannot yet supply its own (for example to preserve filename extensions on a media collection). - No supported editorial un-delete or purge. The adapter-level
restoreSoftDeletedDocumentprimitive preserves identity but does not rebuild tree, search, or cache projections. Irreversible field and object-storage cleanup belongs to a future reference-safe purge workflow.
Code map
Concern | Location |
Default slugifier + types |
|
|
|
|
|
Reserved-name + |
|
Lifecycle derivation + sticky update + locale rules |
|
|
|
|
|
Storage adapters — locale-aware path resolution | each adapter's |
Storage adapters — path write/delete/un-delete | each adapter's |
Existing-installation path-liveness upgrades |
|
Adapter |
|
Client SDK options + locale defaults |
|
Admin server fns accept |
|
Form context |
|
Path widget (sidebar) |
|
Form rendering integration |
|
Integration tests (collision, upsert, fallback) |
|
Lifecycle tests (warn, conflict translation) |
|
Reference collections using |
|