Path Grammar
Companions:
- Document Storage — how
field_pathand thestore_metapath appear in stored rows. - Fields — which path format each collection option accepts.
- Collections — upload hooks, whose registry keys are declaration paths.
- Blocks — why field overrides inside blocks use the
blockAdminregistry rather than a path.
Overview
Byline addresses individual fields with dotted paths, and you will meet them in configuration keys, validation errors, patches, form state, upload hooks, and stored rows. They all look alike. They are not all the same thing, and passing one where another is expected is the most common way to get a confusing error.
Read this document when you are writing a configuration key that names a nested field, debugging a validation message that points at a path you did not expect, or implementing anything that produces or consumes paths.
The two notations
Nearly every path in Byline answers one of two questions:
- Which value in this document do you mean? That is an instance path. It selects array and block items with a bracket selector, and it carries no block type, because the selected item already records its own
_type.content[id=block-id].gallery[id=image-id].alt - Which field in this schema do you mean? That is a declaration path. It has no selectors, and it must include block types, because two blocks may declare the same field name.content.photoBlock.gallery.alt
A third notation is persisted: storage paths use dotted positions and block types (content.0.photoBlock.gallery.1.alt). Strip the positions from a storage path and you have its declaration path — the two are one grammar with two serialisations.
The distinction is load-bearing. An instance path without selectors is still an instance path (title is valid), and a declaration path that omits a block type is ambiguous rather than merely terse.
One field, every notation
Take a single upload-capable field and follow it through the system. The schema:
collection: pagesblocks field: contentblock type: photoBlockarray field: galleryfield: heroImageThat one field is addressed eight different ways, depending on who is asking:
Context | Path | What it identifies |
Stored |
| One stored value, using dotted positions and a block type |
Upload hook registry |
| One upload field declaration, prefixed by collection |
Collection validation |
| One field declaration |
Collection admin | Not reachable through blocks | Block traversal is rejected — use |
Block admin |
| One declaration, relative to the block root |
Field patch |
| One document value |
Structural patch |
| A repeating container |
Form state |
| One document value, with positional fallback when needed |
Reading down that column tells you the rule: anything addressing a schema drops selectors and keeps block types; anything addressing a document keeps selectors and drops block types; storage keeps both.
Patch paths and form paths share the bracket notation — both parseInstancePath and the form path helpers accept [id=…] and [n]. Structural array.move and array.remove patches work slightly differently: the patch path names the array or blocks field, and a separate itemId names the item, which may be a numeric position when stable identity is unavailable.
Why Byline needs field paths at all
A document can nest groups, arrays, blocks, and relations. Rather than storing it as one opaque JSON value, Byline flattens its values into typed tables such as store_text, store_numeric, and store_file, which keeps them available to relational indexes, constraints, queries, and transactions.
Each row then needs an address so the document can be rebuilt — enough to say "the alt text of the first image in the gallery of the second block on this page". Arrays and blocks also produce store_meta rows for item identity. Groups produce no rows of their own, and absent, null, and virtual values are not stored at all. Document Storage covers the full mapping.
Paths address values within a version. Saving still produces one immutable document version: the admin interface sends patches, the server applies them to the reconstructed document, and the complete result for the edited locale is flattened into a new version while other locales carry forward. Paths do not create separate versions or audit histories for individual fields.
Instance paths
An instance path identifies a value in one document, selecting array and block items by stable id or by position.
Selectors are only needed inside repeating fields, so all of these are valid instance paths:
titlemetadata.captioncontentcontent[id=block-id].gallery[id=image-id].altThe first two identify values; content can be a structural patch target.
Prefer [id=…]. It continues to identify the same item after a reorder, where [0] identifies whichever item happens to be first when the path is evaluated. Stable ids keep form state, hooks, deferred uploads, and patches attached to the item you meant. They do not resolve concurrent saves — the document-version conflict check handles that separately.
In forms
The admin interface uses instance paths for form state, hooks, conditions, deferred uploads, and patches. For canonical array and block items it emits stable id selectors. New items receive _id values in the browser before the first save, and reordering updates both the rendered list and the form store.
A stable form write fails if its id no longer exists. That is deliberate: it stops late asynchronous work from recreating a removed item or writing to a sibling. Removing an item clears its pending uploads, and during upload execution the form is inert so structural edits cannot invalidate the save's upload snapshot.
Positional fallback. Form paths retain [n] for legacy items and for created defaults with no usable id. An [id=…] value must be a non-empty string containing no ., [, or ]. Structural edits keep positional form state synchronised immediately, but a position is not stable across deferred work and reorders — so when identity must survive them, use canonical _id data.
In patches
parsePatchPath uses parseInstancePath but preserves the existing patch segment property key rather than name. A malformed path produces no segments, so its patch is rejected outright rather than applied against a partial path.
That is syntax validation, not schema validation. Field resolution stays best-effort and searches block variants by child name rather than by the selected item's _type. Server-side field.set can create an { _id } item when an id is absent, while admin form writes deliberately fail when an id target has disappeared.
A positional patch uses the array order at that point in the patch stream. The optimistic concurrency check rejects a stale documentVersionId rather than rebasing it across a reorder saved by another request.
Declaration paths
A declaration path identifies a field definition in a collection schema. It carries no selectors and includes every block type needed to identify the field:
content.photoBlock.gallery.heroImageYou will write these mostly in configuration and read them in error messages:
export const FAQBlockAdmin = defineBlockAdmin(FAQBlock, { fields: { 'faq.answer': { editor: lexicalEditor(/* a smaller extension set */) }, },})That key configures every answer declared at that location, not one array item. The example is real — apps/webapp/byline/blocks/faq-block.admin.ts.
Because plain text cannot distinguish a field name from a block type (both are bare dotted segments), the resolver checks the path against the schema rather than trusting its shape.
Admin field overrides
Collection admin fields{} keys cannot enter a blocks field, even with the correct block type. Fields inside blocks take their overrides from the block-type-keyed blockAdmin registry instead, so one registration applies wherever that block renders. Startup validation rejects an override that enters a block and points you at blockAdmin.
A collection admin config may still name the blocks field itself — content — since the blocks field has its own label and description and naming it does not enter a block.
A block admin fields{} map is relative to the block root:
faq.answerIt may enter groups and arrays, but a nested blocks field uses its own blockAdmin.
The core API expresses the rule as a resolver policy rather than a separate grammar:
resolveDeclarationPath(fields, key, { blocks: 'forbidden' })Persisted storage paths
Storage paths use dotted positions and include block types:
content.0.photoBlock.gallery.1.heroImageThe Postgres adapter builds these as string arrays, joins them with . for storage, and splits them during reconstruction. It does not use parseInstancePath.
The block type is present because a value row has no _type column — block metadata lives separately in store_meta. Without photoBlock, Byline would need a metadata join to determine which schema owns the value. The format also permits a storage-level block-type filter without that join, though the public query API does not expose one.
The block metadata row for this example is stored at content.0.photoBlock, where the path records the type and item_id stores the block's _id. Array metadata uses paths such as gallery.0. Ordered has-many relations instead write one relation row per target at authors.0, authors.1, and so on, needing no store_meta identity because the target document id identifies the item.
How storage paths relate to declaration paths
Removing positions from a storage path produces its collection-relative declaration path:
content.0.photoBlock.gallery.1.heroImage ← storagecontent.photoBlock.gallery.heroImage ← declaration (positions removed)pages.content.photoBlock.gallery.heroImage ← upload registry key (collection prefixed)This relationship does not mean one parser accepts all three. Storage handles dotted strings directly; the shared core module parses declaration paths and bracket-form instance paths. The block type must stay in storage: removing positions from content.0.gallery.1.heroImage would yield the invalid declaration path content.gallery.heroImage.
packages/db-postgres/src/modules/storage/storage-paths.test.node.ts verifies the relationship against real flattener output.
Upload paths and context
Uploads use three addresses at once:
Address | Example | Purpose |
Registry declaration key |
| Selects the server upload hook and configuration |
Request |
| Selects an upload field by leaf name |
Request and hook |
| Identifies the form value and its key in the hook fields bag |
Because field carries only the leaf name, upload-capable leaf names must be unique within a collection.
UploadConfig.context uses a separate relative addressing language:
../caption/titleByline resolves these from the upload field's containing instance scope, the way filesystem paths resolve. Every dotted instance-path segment is currently navigational; if a future grammar adds metadata segments, context resolution must ignore them when counting parent scopes.
Missing values and paths above the document root are omitted. Each value enters the multipart request under its context path's leaf name, and for duplicate leaves the later value wins. If the block item is unavailable, upload resolution falls back to a declaration only when exactly one block variant matches — an ambiguous match produces no context.
The shared path module
packages/core/src/paths/ implements declaration and bracket-form instance paths. The Postgres adapter owns dotted storage paths. One segment type underpins both notations:
type PathSegment = | { kind: 'field'; name: string } | { kind: 'blockType'; blockType: string } | { kind: 'index'; index: number } | { kind: 'id'; id: string }Function | Purpose |
| Parses a dotted path with no selectors |
| Parses bracket selectors in |
| Formats a segment list as a declaration path |
| Formats a segment list as a bracket-form instance path |
| Removes selectors from a typed, schema-aware segment list |
| Resolves a declaration path against fields, with qualified or forbidden block traversal |
| Walks a schema and emits canonical declaration segments |
What parsing does and does not validate
Parsers return a result rather than throwing — { ok: true, segments } or { ok: false, reason }, where reason is one of empty, emptySegment, index (a declaration path carrying an item index), or malformed (unparseable bracket syntax).
They validate syntax only, not your schema or your document. parseInstancePath accepts selector-free paths and consecutive selectors; it does not prove that a selector follows an array or blocks field, that a field exists, or that an id exists. There is no general schema-aware resolveInstancePath — consumers apply the semantics they need.
parseDeclarationPath initially classifies every bare segment as a field, because text alone cannot distinguish photoBlock from a field name. resolveDeclarationPath reclassifies them against the schema and reports one of three outcomes: ok with the resolved field, blocks when traversal hit a blocks field under blocks: 'forbidden', or unresolved. The blocks case is distinct on purpose — it means your path is correct but used somewhere block traversal is barred, so the guidance is "use blockAdmin", not "your path is wrong".
A schema walker already knows each segment's kind, so walkFieldDeclarations emits blockType segments directly. Its onBlock callback visits empty blocks too, which is what stops validation from skipping them.
Convert typed segments, not raw text
toDeclarationSegments removes selectors from typed segments. Do not split text and discard the numeric components: a field can legitimately be named 0, and the typed form preserves it as a field.
The conversion cannot add a block type that an instance path omitted. Start from schema-aware segments, or inspect the selected item's _type.
Character and length limits
Path segments are not escaped or quoted. Collection paths, field names, and block types used by upload indexing must be non-empty and cannot contain ., [, or ].
The form path helper does not support quoted keys, negative indices, or paths supplied as arrays.
Database columns impose these limits:
field_path: 500 charactersparent_path: 500 charactersfield_name: 255 characters
Compatibility boundaries
Persisted and client/server path formats cannot be changed as internal refactors.
Storage paths require a migration. Changing the stored field_path format means a data migration. The instance parser can parse a dotted storage string as text, but it will treat positions and block types as field names — so do not pass storage paths to parseInstancePath.
Patch paths are a wire format. They cross the client/server boundary, so a change must account for mismatched client and server versions, saved payloads, and every consumer. Treat patch syntax as a protocol.
Rejected design — block-qualified runtime paths
Form and patch paths carry no block type; persisted storage paths do, because a stored value row has no _type column while an in-memory block item does. Carrying the block type through the runtime notations as well — so that one visually consistent notation appeared in logs, patch payloads, and storage rows — was implemented in full on 2026-07-19 and then abandoned rather than merged. This section records why, so the design is not proposed a second time.
The added segment addresses nothing in the data. Block items are stored flat, as { _id, _type, ...fields }, so every consumer had to recognise the segment and skip it. Three consumers did: the patch walkers, the admin form store, and upload resolution. Three defects followed.
- A reorder combined with a heterogeneous block wrote a phantom object into a stale item.
- The block type was carried but never enforced, so a mismatched segment silently edited the real field. The cost of an assertion was paid without gaining the integrity of one.
UploadConfig.contextregressed.resolveContextPathcounts every dotted segment as one scope level, so../stopped inside the block instead of reaching the document root — a public API regression, confirmed by direct test.
The general lesson is the third one. Each non-navigating segment creates another place that must know to erase it, and nothing enforces that obligation. Two of the three consumers were missed on the first implementation pass, and the third was found only in review. The cost kept growing while the value stayed fixed at visual consistency alone.
Both of the real benefits — resolving the exact block declaration, and resolving an upload without form data — are obtainable from a schema-and-data-aware resolver that reads _type, with no change to any payload.
Revisit this only for a genuinely cold consumer: a persisted operation log, peer synchronisation, or collaborative editing, where a path is read without the document in hand. At that point the block type becomes load-bearing rather than decorative, and it should be a validated assertion that is rejected on mismatch, parsed centrally, and explicitly ignored by relative-scope arithmetic — not a pseudo-navigation segment that every data walker strips. The regression in (3) is pinned by packages/admin/src/forms/upload-executor.test.node.ts ("climbs out of the block to the document root").
Other path-like APIs
These have narrower contracts and must not be passed to the shared resolvers:
API | Example | Contract |
|
| Runtime positions without block types; logs and errors only |
Rich-text startup diagnostics |
| Diagnostic notation with block types in angle brackets |
Search configuration |
| Top-level field names; a named body container is traversed recursively |
Relation |
| Nested query objects resolved within each relation target |
Populate map |
| Relation leaf names matched anywhere in the field tree; same-named leaves share a selector |
Upload context |
| Relative or root addressing resolved from an instance scope |
validateCollections uses block-qualified declaration paths, but other validators may use their own diagnostic format — "startup error path" does not identify one grammar.
The counter allocator uses dotted keys to read previous values and stops at arrays. Counters cannot appear inside arrays or blocks, so it is not a field path API.
Implementation reference
Location | Responsibility |
| Produces and serializes dotted storage paths |
| Reads dotted storage paths |
| Produces upload registry keys |
| Produces collection validation paths |
| Resolves admin |
| Reads patch paths |
| Reads and writes form state using bracket instance paths |
| Produces stable-id form paths with positional fallback |
| Resolves upload fields and context |
Contract tests
The central contracts are covered by:
packages/core/src/paths/path-dialects.test.node.ts— configuration and patch formats, including two block types that declare the same field name.packages/db-postgres/src/modules/storage/storage-paths.test.node.ts— real flattener output and the relationship between stored and declaration paths.
Form behaviour is covered by nested-path.test.node.ts, repeating-items.test.node.ts, upload-executor.test.node.ts, and pending-uploads.test.node.ts, all in packages/admin/src/forms/.
Search, relation, populate, and diagnostic APIs have separate tests. A new path-like notation does not automatically enter the central contract tests.
Collection fingerprints are separate
The runtime collection fingerprint includes the collection path, field names, selected field properties, structure, and block types. With an unchanged collection path, changing an included component normally increments the collection version. Changing the collection path registers a new collection unless a migration handles the rename.
Generated collection types have a separate output hash. Neither hash validates path grammar, and neither includes admin overrides, upload hooks, search configuration, or upload context.