Byline CMS
  • Inicio
  • Docs
  • Acerca de
Byline CMS
Ver en GitHub
  • Getting Started
    • CLI
    • Development environment and example application
    • Upgrading from 3.21 to 4.x
  • Why Byline
    • Mission & Vision
    • Content Management in the Time of AI
  • Key Architectural Decisions
    • Core Document Storage
    • Core Composition
    • Transactions
    • Path Grammar
    • Deployment Topologies
  • Collections
    • Fields
    • Blocks
    • Relationships
    • Document Trees
    • Document Paths
    • File / Media Uploads
    • Rich Text Editor
    • Collection Versioning
  • Reading & Delivery
    • Client SDK (@byline/client)
    • Routing & API
    • Transports
    • Markdown Export
    • MCP Server
    • Caching
    • Search & Retrieval
    • Search & Document Extraction
  • Auth & Security
    • Authentication & Authorization
    • Auditability
  • Internationalization (i18n)
    • The host i18n system
    • Admin interface translations
    • Content locales
    • Administering content locales
  • Admin UI
    • UI Kit (@byline/ui)
    • Client-config registration
  • Testing
  • Inicio

Client SDK (@byline/client)

Companions:

  • Routing & API — broader transport-phase context: admin UI is the only client today, stable HTTP is deferred. The SDK is what fills the gap.
  • Document Storage — storage primitives the SDK sits above.
  • Relationships — populate / depth machinery the SDK exposes.
  • Authentication & Authorization — RequestContext threading and beforeRead / afterRead enforcement.
  • Collections — CollectionAdminConfig.preview.url builder used by the admin preview affordance.
  • packages/client/DESIGN.md  — implementation-detail design doc; phase-by-phase status snapshot.

Overview

@byline/client is an in-process, server-side SDK for querying and mutating Byline documents. It sits above the storage primitives (IDbAdapter) and the document-lifecycle services, and exposes a richer DSL than the adapter alone: field-level filters, sort, pagination, populate, status awareness, and automatic beforeRead / afterRead hook firing. It is not a browser-safe SDK, not a public HTTP client, and not a framework-agnostic network transport client.

Read this document when you are building a public frontend, a feed or sitemap, a seed or migration script, or any other server-side consumer that needs to read or write Byline content from outside the admin UI.

The distinction matters because Byline today is in an internal transport phase (see Routing & API). The admin UI is the only active client, TanStack Start server functions are the internal transport boundary, and stable/public HTTP transport is intentionally deferred until the first real non-admin client arrives. @byline/client fits that phase well — it lives in the same Node process as Byline Core, holds direct references to the configured DB and storage adapters, and does no network I/O of its own.

What this gives consumers in trusted runtimes:

  • A read DSL with field-level filters, sort, pagination, populate, and status awareness.
  • A write surface (create, update, delete, changeStatus, unpublish) that delegates to document-lifecycle services.
  • Response shaping into a public ClientDocument<F> envelope (camelCase, predictable, generic over the schema's field type).
  • Automatic beforeRead predicate application and afterRead hook firing.
  • Transparent published / any read-mode handling, including through populate.

What it does not do: speak HTTP, run in browsers, or hide the trust boundary. actor: null is allowed only for read with readMode: 'published'; everything else needs a real RequestContext.

Application type generation

The application owns two related collection surfaces:

  • byline/collections/index.ts exports collections, the evaluated runtime registry used by core and the client.
  • byline/generated/collection-types.ts is the committed, deterministic type projection emitted from that tuple by @byline/core/codegen.

Run pnpm byline:generate after changing a collection, field, or block schema. pnpm byline:generate:check performs no writes and fails when the artifact is missing or stale; CI runs this before lint and typecheck. The runner imports the collection tuple directly and deliberately does not import server.config.ts.

Typed clients use the generated CollectionFieldsByPath map as their registry provenance. A hand-authored compile contract maps CollectionFieldData and CollectionFieldDataAllLocales over typeof collections and requires exact key and value equality with both generated maps, so emitter or schema drift also fails application typecheck.

The generated file (format 2) is a pair of TypeScript declaration merges, not a module of local exports:

  • Every emitted type lives inside a declare module '@byline/generated-types' block, so the published stub package @byline/generated-types is the one canonical import path — import type { NewsFields } from '@byline/generated-types' — from anywhere in the app. The stub itself is empty; the app's tsconfig include carrying the generated file into the program is what populates it. Imports are type-only and erased at runtime.
  • A second block registers the registry with the SDK: declare module '@byline/client' { interface Register { collections: CollectionFieldsByPath } }. Once merged, every bare BylineClient in the app's program — including createBylineClient's default generic and the @byline/client/server getters below — resolves to BylineClient<CollectionFieldsByPath>: client.collection('news') autocompletes paths and returns generated field shapes with no explicit generics and no casts.

Exactly one application per TypeScript program can hold these augmentations (declaration merging is program-global); one app per tsconfig — the supported layout — is safe. Unaugmented programs (a package's own tests, a script compiled outside the app) fall back to the loose CollectionRegistry.

Server client getters (@byline/client/server)

Host applications rarely construct clients by hand. @byline/client/server exports four request-authority singletons plus the preview probe:

import {
getAdminBylineClient, // authenticated admin actor, resolved per request from session cookies
getPublicBylineClient, // anonymous, published-only; preview can never apply (feeds, sitemaps, CDN-cacheable)
getSystemBylineClient, // explicit super-admin context; background/maintenance work, no request needed
getViewerBylineClient, // public reads that honour an admin's preview-mode session
isPreviewActive, // cookie + admin session probe: `status: preview ? 'any' : 'published'`
} from '@byline/client/server'

The subpath is host-framework agnostic: everything request-scoped bottoms out in the HostRequestBridge seam (@byline/core) — request identity, cookie read, cookie write — which a host adapter implements and registers at server boot (registerTanstackStartHostBridge() from @byline/host-tanstack-start/integrations/host-bridge, wired into the scaffolded server.config.ts). Application modules that read documents therefore never import the host framework, which is what keeps them portable across a future host migration. The subpath is server-only: the package's browser export condition resolves it to a stub that throws.


Quick reference

Each entry is the minimal SDK shape for one task — plain client.collection(...) calls, no host-framework wrappers. The link at the end of each entry points at the deeper architecture section. Host-adapter helpers (TanStack Start server fns, viewer client, preview-cookie plumbing) are framework concerns and live under Preview mode.

1. Instantiate a client

The standalone shape — pass an IDbAdapter, the collection definitions, optional storage, and a requestContext. No initBylineCore() required; the SDK runs equally well from a script, a test, or a host adapter.

import { createBylineClient } from '@byline/client'
import { createSuperAdminContext } from '@byline/auth'
import { pgAdapter } from '@byline/db-postgres'
import { localStorageProvider } from '@byline/storage-local'
import { collections } from './byline/collections'
const client = createBylineClient({
db: pgAdapter({ connectionString: process.env.BYLINE_DB_URL! }),
collections,
storage: localStorageProvider({ uploadDir: './uploads', baseUrl: '/uploads' }),
requestContext: createSuperAdminContext({ id: 'my-script' }),
})

When the process has already called initBylineCore() (the usual case in a host application), the shorthand is just config: getServerConfig():

import { getServerConfig } from '@byline/core'
const client = createBylineClient({
config: getServerConfig(),
requestContext: () => resolveRequestContextFromSession(),
})

requestContext accepts either a static RequestContext (long-lived scripts) or a factory () => RequestContext | Promise<RequestContext> (per-request resolution). Omitting it makes every call fail closed with ERR_UNAUTHENTICATED.

→ Construction · Auth and the trust boundary

2. Simple reads

The five read entry points on a CollectionHandle. Each returns a camelCase-shaped ClientDocument<F> (or FindResult<F> / number).

// List — returns { docs, meta }
const list = await client.collection('news').find()
// By id (logical document id, not a version id)
const doc = await client.collection('news').findById(id)
// By path (locale-aware via byline_document_paths)
const home = await client.collection('pages').findByPath('home')
// First match (skips the count query)
const featured = await client.collection('news').findOne({ where: { featured: true } })
// Editorial count — current versions across workflow statuses; authenticated only
const total = await client.collection('news').count()

findById / findByPath / findOne return ClientDocument<F> | null. find returns { docs, meta }. count returns number, but unlike ordinary reads it is an editorial current-version status count: it authorizes as 'any' and rejects anonymous callers.

→ Read surface

3. Top-level where filters

Field-level filters compile to EXISTS subqueries against the typed store_* tables. Equality is shorthand; operators ($eq, $ne, $gt, $gte, $lt, $lte, $in, $contains, $startsWith, $endsWith) live under an object.

await client.collection('news').find({
where: {
title: { $contains: 'launch' },
views: { $gte: 100 },
publishedAt: { $lte: new Date().toISOString() },
},
})

Combinators wrap an array of sub-clauses and behave the same as at the top level:

where: {
$or: [
{ status: 'published' },
{ authorId: actor.id },
],
}

status and path are document metadata, not field filters — they resolve to direct outer-scope comparisons (document_versions.status and a byline_document_paths projection) and compose correctly inside combinators or nested relation hops. Caller where and strict beforeRead filters compile separately before their adapter filters are ANDed, so security-only metadata operators cannot be lost through the caller's top-level scalar channels.

→ Filtering

4. Relation where filters

where: { <relation>: { <field>: ... } } filters on a relation target's columns. The target collection's filter machinery runs at the inner depth; relation chains can nest.

// News whose category's path is 'press'
where: { category: { path: 'press' } }
// News whose category's `slug` field is 'press'
where: { category: { slug: 'press' } }
// 2-hop — news whose category's parent's path is 'editorial'
where: { category: { parent: { path: 'editorial' } } }

path is locale-resolved against the target's byline_document_paths row; status resolves to document_versions.status on the relation hop. A target collection that declares a path or status field won't see those clauses resolve as field filters — rename the field (e.g. to slug) if it ever bites.

Multi-target (hasMany) relations take the $some / $every / $none quantifiers over the target set (a plain sub-where is shorthand for $some; also valid on single relations):

// At least one author named Alan Turing
where: { authors: { $some: { name: 'Alan Turing' } } }
// Every (resolving) author is published; vacuously true with no authors
where: { authors: { $every: { status: 'published' } } }
// No authors at all
where: { authors: { $none: {} } }

→ Filtering · Relationships § Query quantifiers

5. Sort and pagination

await client.collection('news').find({
sort: { publishedAt: 'desc' }, // also: 'publishedAt' / '-publishedAt' / ['-publishedAt', 'title']
page: 2,
pageSize: 20,
locale: 'en',
})

Field sort compiles to LEFT JOIN LATERAL against the appropriate store table; document-level columns (status, createdAt, updatedAt) use direct outer-scope comparisons. Sorting by path is intentionally not supported.

→ Sorting

6. Populate and depth

populate replaces relation slots with their target documents. depth caps the traversal (default 1 when populate is set, 0 otherwise) and is clamped to the request's ReadContext.maxDepth.

// Every relation, default projection
await client.collection('news').find({ populate: true })
// Every relation, full doc, recursive
await client.collection('news').find({ populate: '*', depth: 3 })
// Selective — and a 2-hop populate on `author.department`
await client.collection('news').find({
populate: {
featureImage: true,
author: { populate: { department: true } },
},
depth: 2,
})

The default projection includes the target's useAsTitle field implicitly, so link labels keep working even if the caller didn't list it. Populate threads readMode through every hop — published-mode reads stay on current_published_documents all the way down.

→ Population · Relationships § Populate

7. Type a populated relation with WithPopulated

Generated collection field types treat relation slots as the unpopulated wire shape (RelatedDocumentValue). WithPopulated<Fields, 'name', TargetFields> overlays the populated envelope so result.fields.name?.document?.fields.<field> is fully typed.

import type { WithPopulated } from '@byline/client'
import type {
MediaFields,
NewsCategoriesFields,
NewsFields,
} from '@byline/generated-types'
type NewsListFields = WithPopulated<
WithPopulated<NewsFields, 'category', NewsCategoriesFields>,
'featureImage',
MediaFields
>
const result = await client.collection('news').find<NewsListFields>({
populate: { category: '*', featureImage: '*' },
})
result.docs[0]?.fields.category?.document?.fields.name // fully typed

The wrapper composes — wrap once per populated relation. Type-level only: you still need a matching populate at the call site for the runtime envelope to actually be populated.

→ Typing populated relations

8. Status-aware reads

status: 'published' (the SDK default) reads through current_published_documents; status: 'any' reads the latest version regardless of publish state. A draft over a previously-published version keeps returning the published content until the draft itself is published.

// Public read — default
await client.collection('news').find()
// Admin / system — see the latest version regardless of publish state
await client.collection('news').find({ status: 'any' })
// 'status' selects the source view; where.status is an orthogonal column filter
await client.collection('news').find({
status: 'any',
where: { status: 'draft' },
})

The mode threads through populate, so a published-mode read of news populating category reads both from current_published_documents.

→ Status awareness

9. Create / update / delete

Writes delegate to the corresponding document-lifecycle service. Collection hooks (beforeCreate, afterUpdate, etc.) fire the same way they do when the admin UI writes. update accepts whole-document data — patches are admin-UI internal.

const created = await client.collection('news').create(
{ title: 'New piece', summary: '…' },
{ locale: 'en' /* status?: optional; defaults to the workflow's first status */ }
)
await client.collection('news').update(
created.documentId,
{ title: 'Revised title' },
{ locale: 'en' }
)
await client.collection('news').changeStatus(created.documentId, 'published')
const deleted = await client.collection('news').delete(created.documentId)
if (deleted.outcome === 'committed-with-side-effect-failures') {
console.warn('Delete committed, but follow-up work needs reconciliation', deleted.sideEffectFailures)
}

Every write resolves the client's configured requestContext and runs assertActorCanPerform('collections.<path>.<verb>'). actor: null is rejected on writes.

delete returns a discriminated committed result rather than using rejection for post-commit failures. outcome: 'committed' carries an empty sideEffectFailures; outcome: 'committed-with-side-effect-failures' carries one or more failures from storageCleanup, afterTreeChange, or afterDelete. Each failure exposes only its phase and an allowlisted code (ERR_STORAGE or ERR_UNHANDLED); raw errors, hook messages, and storage paths remain in internal logs. Authorization, existence lookup, beforeDelete, and the transactional soft-delete + audit + tree reconciliation are pre-commit: any failure there rejects and no committed result is returned. Once that transaction commits, storage cleanup and both hook families get independent attempts; their failures cannot turn the committed delete into a rejected promise.

→ Write surface

10. Search

Ranked full-text search, when a SearchProvider is registered (ServerConfig.search). Two entry points sharing one finishing pipeline — collection-scoped (homogeneous) and zone-scoped (heterogeneous, ranked together across every collection indexed into the zone):

// Collection-scoped
const { hits } = await client.collection('docs').search({ query: 'installation' })
// Zone-scoped (cross-collection) + hydrate — each hit carries collectionPath,
// and hydrate attaches a shaped ClientDocument as hit.document
const results = await client.search({ zone: 'site', query: 'launch', hydrate: true })

Both assert the collection read ability (zone search excludes collections the actor can't read), honour beforeRead row scoping by re-resolving candidate ids through the normal read path, and default to status: 'published'. hydrate: true batch-reads hits into shaped documents (projected to admin.itemView columns when registered) and drops stale index entries. When authorization removes collections or rows, total is conservatively the authorized hit count on the returned page and facets are omitted rather than leaking provider-wide aggregates.

→ Search for the full surface (indexing, reindex, zones, the provider seam).

11. A standalone script

Build a client, then read and write — no host application or initBylineCore() required. A minimal end-to-end run (connect → read → create → update → publish → read back):

// scripts/demo.ts — run with: pnpm tsx scripts/demo.ts
import { createBylineClient } from '@byline/client'
import { createSuperAdminContext } from '@byline/auth'
import { pgAdapter } from '@byline/db-postgres'
import { collections } from '../byline/collections/index.js'
const client = createBylineClient({
db: pgAdapter({ connectionString: process.env.BYLINE_DB_URL! }),
collections,
requestContext: createSuperAdminContext({ id: 'demo-script' }),
})
const news = client.collection('news')
// READ — the latest published articles
const { docs, meta } = await news.find({ status: 'published', sort: '-publishedOn', pageSize: 5 })
console.log(`${meta.total} published; latest: ${docs[0]?.fields.title ?? '(none)'}`)
// CREATE — a new draft (defaults to the workflow's first status)
const draft = await news.create(
{ title: 'Hello from a script', summary: 'Written via @byline/client.' },
{ locale: 'en' },
)
// UPDATE — whole-document write (patches are admin-UI internal)
await news.update(draft.documentId, { title: 'Hello, world' }, { locale: 'en' })
// PUBLISH — walk the workflow forward
await news.changeStatus(draft.documentId, 'published')
// READ BACK — a published read now resolves the freshly published version
const fresh = await news.findById(draft.documentId, { status: 'published' })
console.log('published title:', fresh?.fields.title)
process.exit(0) // pgAdapter holds a connection pool; end the process when done

Every write resolves the configured requestContext and runs assertActorCanPerform; the super-admin context here passes every check (use a real scoped context to exercise authorization). The same shape fits seeds, content imports, and one-shot maintenance jobs.

The advanced example below is a real maintenance job that iterates a collection, regenerates the bytes behind every media document, and writes the new value back. The script:

  • side-effect imports server.config.ts so initBylineCore() registers config + collections;
  • builds a client from getServerConfig() and a super-admin context;
  • pages through media with status: 'any' + _bypassBeforeRead: true (admin-only escape hatches);
  • runs the core upload service to re-derive variants, then handle.update(...) to point the document at the new storedFile;
  • walks the workflow ladder forward via changeStatus to restore each doc's original status (since update always stamps a new version with the workflow's default status).

The full source — including orphan-file cleanup and the workflow-restore helper — lives at apps/webapp/byline/scripts/regenerate-media.ts . The shape, condensed:

import 'dotenv/config'
import '../server.config.js'
import { createSuperAdminContext } from '@byline/auth'
import { createBylineClient } from '@byline/client'
import { getServerConfig } from '@byline/core'
const client = createBylineClient({
config: getServerConfig(),
requestContext: createSuperAdminContext({ id: 'regenerate-media-script' }),
})
const handle = client.collection('media')
// Snapshot the full set up-front — every update bumps `updated_at` and
// would reorder a moving paged window.
const allDocs: { id: string; status: string; fields: Record<string, any> }[] = []
for (let page = 1; ; page++) {
const result = await handle.find({
page,
pageSize: 100,
status: 'any',
_bypassBeforeRead: true,
})
for (const d of result.docs) {
allDocs.push({ id: d.id, status: d.status, fields: d.fields as Record<string, any> })
}
if (result.docs.length < 100) break
}
for (const doc of allDocs) {
// ...regenerate variants via the core upload service, then:
await handle.update(doc.id, { ...doc.fields, image: newStoredFile })
// ...walk the workflow forward to restore doc.status (see the full source).
}

Run it with pnpm tsx byline/scripts/regenerate-media.ts (the script imports byline/load-env.ts, which loads .env.local + .env — no --env-file flag needed). The same pattern fits seeds, migrations, content imports, and one-shot maintenance jobs.

→ Construction · Write surface · Auth and the trust boundary


Architecture

Architectural position

┌──────────────────────────────────────────────────────────────────┐
│ Consumers (trusted runtime) │
│ - TanStack Start route loaders / server functions │
│ - server-side rendering paths inside the same deployment │
│ - migrations, seeds, import/export jobs │
│ - operational tooling, scheduled jobs │
└─────────────────────────┬────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ @byline/client │
│ - BylineClient + CollectionHandle │
│ - WhereClause / SortClause / PopulateMap parsing │
│ - shapeDocument() → ClientDocument<F> │
│ - status mode default ('published') + threading │
│ - calls beforeRead / afterRead at correct points │
└─────────────────────────┬────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ @byline/core services │
│ - document-lifecycle/ (create / update / delete / status) │
│ - document-read.ts (afterRead orchestration) │
│ - populate.ts (relation expansion) │
│ - apply-before-read.ts (security predicate application) │
└─────────────────────────┬────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────┐
│ Adapters │
│ IDbAdapter (Drizzle/Postgres today) │
│ IStorageProvider (local fs / S3) │
└──────────────────────────────────────────────────────────────────┘

The SDK does not sit at the same level as a future stable-HTTP client. Both can coexist — a future HTTP client would target the (yet-to-be-designed) public HTTP boundary; @byline/client continues to target adapters in-process.

Construction

import { createBylineClient } from '@byline/client'
import { pgAdapter } from '@byline/db-postgres'
import { localStorageProvider } from '@byline/storage-local'
import { collections } from './byline/collections'
const client = createBylineClient({
db: pgAdapter({ connectionString: process.env.BYLINE_DB_URL! }),
collections,
storage: localStorageProvider({ uploadDir: './uploads', baseUrl: '/uploads' }),
// logger?: BylineLogger
})

createBylineClient is the standalone constructor. In an initBylineCore() setup the SDK can resolve its logger automatically through the registry; in scripts and tests it falls back to a silent no-op so callers don't have to wire initBylineCore() just to seed data.

The host adapter (@byline/host-tanstack-start) ships three module-scoped clients over getServerConfig(): getPublicBylineClient(), getViewerBylineClient(), and getAdminBylineClient(). Each serves a fresh per-request RequestContext through the SDK's per-call factory pattern.

Read surface

Five basic read methods with camelCase-shaped document results:

client.collection('news').find({ where, sort, page, pageSize, select, populate, depth, status, locale })
client.collection('news').findOne(opts)
client.collection('news').findById(id, opts)
client.collection('news').findByPath(path, opts)
client.collection('news').count({ status })

Filtering

WhereClause parses through packages/core/src/query/parse-where.ts (relocated from @byline/client so populate can compile predicates in-process):

// Field-level filters
where: { title: { $contains: 'launch' } }
where: { views: { $gte: 100 }, status: 'published' }
where: { publishedAt: { $lte: new Date().toISOString() } }
// Combinators
where: { $or: [{ status: 'published' }, { authorId: actor.id }] }
where: { $and: [{ tags: { $in: ['featured'] } }, { archived: false }] }
// Cross-collection relation filters
where: { category: { slug: 'news' } } // target's `slug` field === 'news'
where: { category: { path: 'news' } } // target document's path (locale-resolved via `byline_document_paths`) === 'news'
where: { category: { status: 'draft' } } // target version's `document_versions.status` column === 'draft'
where: { category: { parent: { path: 'news' } } } // 2-hop, doc-column at depth 2

The compiler emits EXISTS subqueries against the typed store_* tables for field filters, and depth-scoped nested EXISTS joins through store_relation for relation sub-wheres. All filter predicates respect the read mode — published-mode reads use current_published_documents even at the inner side of a relation join. Caller filters use this ordinary parser; beforeRead predicates use a separate strict pass, and the resulting adapter filters are appended with implicit AND.

Document-level reserved keys (status, path) inside a nested sub-clause are document metadata, not field filters — same precedence as the top level, with no field-shadow exception (a target collection that declares a path or status field will not see those clauses resolve as field filters; rename the field, e.g. to slug). status resolves to document_versions.status on the relation hop's target row; path resolves through a byline_document_paths subquery against the hop's document_id (locale-resolved through the request's fallback chain, ending at that document's source locale). In strict beforeRead predicates, top-level status supports $eq / $ne / $in / $nin, while path additionally supports $contains; these compile as document-column filters on every read shape. query (text search) is not supported inside a nested caller sub-clause and is silently dropped with a debug log; strict security predicates reject it.

Sorting

sort: 'publishedAt' // ascending
sort: '-publishedAt' // descending
sort: ['-publishedAt', 'title'] // multi-key
sort: { publishedAt: 'desc' } // object form (used in the news example)

Field sort compiles to LEFT JOIN LATERAL against the appropriate store; document-level columns (status, created_at, updated_at) use direct outer-scope comparisons. Sorting by path is intentionally not supported (path lives in byline_document_paths and is locale-resolved per request); reintroduce via the pathProjection subquery if a real consumer surfaces.

Selective field loading

fields: ['title', 'publishedAt', 'heroImage']

Cuts the 7-way UNION ALL to just the stores those fields use, then trims the response to the requested keys. See Document Storage § Selective field loading for the full pipeline.

Population

populate: true // every relation, default projection
populate: '*' // every relation, full doc, recursive
populate: { heroImage: true, author: { populate: { dept: true } } }
depth: 2 // default 1 when populate present

The default projection includes the target's useAsTitle field implicitly, so widgets that render link labels keep working even if the caller's select didn't ask for it. Before each target fetch, populate asserts the target collection's read ability and applies its strict beforeRead predicate; row-hidden targets become unresolved relation values, while a missing target-collection ability rejects the read. See Relationships § Populate.

Typing populated relations

Generated collection field types treat relation slots as the unpopulated wire shape (RelatedDocumentValue). To get full type checking on doc.fields.<relation>?.document?.fields.<field>, overlay each populated relation with WithPopulated:

import type { WithPopulated } from '@byline/client'
import type {
MediaFields,
NewsCategoriesFields,
NewsFields,
} from '@byline/generated-types'
type NewsListFields = WithPopulated<
WithPopulated<NewsFields, 'category', NewsCategoriesFields>,
'featureImage',
MediaFields
>
// The operation-specific overlay is the generic; ordinary reads infer generated fields.
await client.collection('news').find<NewsListFields>({ populate: { category: '*', featureImage: '*' } })

The wrapper is purely at the type level — you still need a matching populate: { … } at the call site for the runtime envelope to actually be populated. WithPopulated makes the type match what populate gives you back.

Status awareness

status: 'published' // default in @byline/client
status: 'any' // admin / system code paths

In 'published' mode every ordinary read — including relation and richtext target population and findByPath resolution — hits current_published_documents. A document with a newer unpublished draft over a previously-published version keeps returning the published content; the new draft becomes visible only once it's itself published.

status selects the source view, not an exact-status filter. where.status is a literal column filter and composes orthogonally:

// "Show me draft rows under the latest version, regardless of publish state"
client.collection('news').find({ status: 'any', where: { status: 'draft' } })

Editorial metadata reads are intentionally stricter: count / countByStatus, history, findByVersion, and audit-log access gates authorize as 'any', so anonymous callers cannot use their aggregates or metadata as a side channel. History applies beforeRead to each immutable version before pagination and totals; findByVersion is collection-bound and applies the predicate to that exact version. Document-tree reads apply the predicate structurally: a hidden node breaks its subtree or ancestor edge rather than promoting descendants, and hidden parent ids are redacted.

Preview mode (admin draft viewing on the public host)

Editorial workflows usually want one extra capability: an admin should be able to navigate the public host pages and see their own in-progress drafts rendered exactly as the published version would be — without changing routes, without rebuilding markup, and without leaking drafts to ordinary visitors. @byline/client/server ships a "viewer client" that layers preview-aware behaviour over the SDK without changing it, implemented against the HostRequestBridge seam a host adapter registers at boot.

The plumbing splits into two layers — a transport layer (cookie + viewer client + server fns) that decides what each request sees, and a UX layer (admin shell affordances) that lets editors flip the cookie and discover the resulting state.

Transport layer:

Piece

Location

Role

byline_preview cookie

@byline/client/server (preview-cookies.ts)

Session-level "I want to see drafts" flag. httpOnly. Mere presence is the signal — no payload to verify.

getViewerBylineClient()

@byline/client/server

Singleton BylineClient whose per-call requestContext factory upgrades to the admin actor when both the cookie and a valid admin session resolve.

isPreviewActive()

same module

Async check that returns true only when the cookie is set and getAdminRequestContext() resolves an admin.

enablePreviewModeFn / disablePreviewModeFn / getPreviewStateFn

@byline/host-tanstack-start/server-fns/preview

Toggle the cookie / read its current state. Enable requires a valid admin context; disable and state-read are unauthenticated.

UX layer:

Surface

Location

Role

Drawer toggle (Preview ON / OFF)

@byline/host-tanstack-start/admin-shell/chrome/preview-toggle

Source-of-truth indicator above Account in the admin menu drawer. Always visible, always reversible. Reflects cookie state via getPreviewStateFn.

<PreviewLink>

@byline/host-tanstack-start/admin-shell/collections/preview-link

Per-document external-link icon on the edit page header. On click: enablePreviewModeFn() then window.open(url). Hides when preview.url(doc) returns null.

CollectionAdminConfig.preview

defineAdmin(...) in your collection's admin.tsx

{ url(doc, { locale }) } — see Collections § Preview URL for the full reference.

ContentAdminBar pill

apps/webapp/src/ui/components/content-admin-bar.tsx

Public-side "Preview" pill + "Exit Preview" button when the cookie is set. Threaded down from the public layout loader (getPreviewStateFn). Calls disablePreviewModeFn then router.invalidate() on exit.

Trust model. The cookie is a flag, not a credential. The actual safety check is layered:

  1. Source-view selection is per-call. The SDK's resolveReadMode defaults to 'published' regardless of RequestContext.readMode, so a server fn must pass status: 'any' to surface drafts. There is no way to flip the source view through RequestContext alone.
  2. status: 'any' requires an actor. assertActorCanPerform only permits actor: null on read when readMode === 'published'. So a stray query string or stale cookie that reaches status: 'any' without a valid admin throws ERR_UNAUTHENTICATED rather than leaking drafts.
  3. The viewer client elevates the actor only when the cookie and the session line up. A signed-out browser carrying an old preview cookie still falls through to the anonymous + 'published' context — worst case the cookie does nothing.

A stale cookie is therefore failure-mode-neutral: it never escalates a non-admin request, and it never breaks one either.

Editorial UX flow. The three UX surfaces compose into one flow: an editor clicks <PreviewLink> on a document's edit page (which enables the cookie and opens the public URL in a new tab); every other public page in that browser session now surfaces drafts; the drawer toggle makes the state glanceable and reversible; and the public-side ContentAdminBar pill offers "Exit Preview" from any draft-rendering page. The two-step "enable cookie, then navigate" deliberately avoids a /routes/draft?url=...&secret=... redirect handler — enablePreviewModeFn is itself the gate (it requires a valid admin session before setting the cookie), so no shared secret needs to ride in the URL.

Limits and notes:

  • Preview is per-server-fn opt-in. A fn that does not pass status: 'any' always serves published content, even with the cookie set. This is deliberate: opt-in keeps the trust boundary visible in code.
  • The double resolution cost (cookie check + JWT verify) only happens in active preview sessions — the no-cookie path is a single cookie read.
  • Preview elevates readMode for the request, but it does not bypass beforeRead hooks. A multi-tenant or owner-only-drafts hook will still scope the rows the admin can see — preview just changes which version of those rows is returned.
  • The cookie has a 24-hour maxAge — preview is meant to be a short-lived editorial mode, not a permanent state. Re-enabling is a one-click action.
  • The same pattern works for any host fn — not just collection reads. Any server fn that wants the "promote to admin actor when preview is on" behaviour can compose getViewerBylineClient + isPreviewActive the same way.
  • Front-end caching caveat. Byline doesn't ship a built-in cache layer, but anything in front of your host must bypass shared caches for authenticated editor requests and active preview reads. Do not use byline_preview alone as the bypass signal: a stale preview cookie without a valid session still receives published content and is safe to cache. In application code, use isPreviewActive(); at the CDN boundary, bypass on the admin session cookies. See Caching for the complete policy.

Write surface

client.collection('news').create(data, { locale?, path?, status? })
client.collection('news').update(id, data, { locale?, path? })
client.collection('news').delete(id)
client.collection('news').changeStatus(id, nextStatus)
client.collection('news').unpublish(id)

Each method delegates to the corresponding document-lifecycle service. The handle resolves the collection id once, builds a DocumentLifecycleContext, and invokes the service — collection hooks (beforeCreate, afterUpdate, etc.) fire the same way they do when the admin UI writes.

Delete has an explicit commit contract. delete(id) resolves to { deletedVersionCount, outcome, sideEffectFailures }. The transaction atomically soft-deletes all versions, appends audit data, and reconciles tree edges when applicable. Ability/existence checks, beforeDelete, and transaction failures reject before commit. After commit, file/variant cleanup (storageCleanup), tree invalidation (afterTreeChange), and delete consumers (afterDelete) each run independently. If any fail, the SDK still resolves with outcome: 'committed-with-side-effect-failures'; otherwise it resolves with outcome: 'committed'. Callers must not retry the delete merely because a post-commit side effect failed.

Patches stay admin-internal. The update method accepts whole-document data, plus an optional patches array for the admin form's reordering / block-insertion flow. Public consumers should use whole-document writes; the patch families (field.*, array.*, block.*) are tied to UI intent and not part of the supported public surface.

Logger resolution. BylineClient resolves a BylineLogger in priority order: explicit config.logger → getLogger() if initBylineCore() has registered one → silent no-op. Migration scripts and tests work without setup.

Auth, RequestContext, and the trust boundary

Every SDK read and write path runs assertActorCanPerform (for documents) or assertActorCanPerform plus the field-upload create gate (for uploads) before touching storage. This includes search, editorial counts and history, document-tree reads, relation targets, and richtext targets. The SDK resolves the RequestContext from the client's configured requestContext (static value or factory) on every call — there is no per-call context argument on the public methods. Standalone consumers configure it at construction:

import { createSuperAdminContext } from '@byline/auth'
const client = createBylineClient({
config: getServerConfig(),
requestContext: createSuperAdminContext({ id: 'migration-script' }),
})
await client.collection('news').create({ title: '…' })

Host adapters typically pass a factory that resolves a session-scoped context per call:

createBylineClient({
config: getServerConfig(),
requestContext: () => getAdminRequestContext(),
})

Policy:

  • No context → ERR_UNAUTHENTICATED on every method.
  • actor: null → permitted only on read with readMode: 'published'. Any write or non-published read with a null actor throws.
  • Otherwise → actor.assertAbility('collections.<path>.<verb>'). Super-admin (actor.isSuperAdmin === true) short-circuits.

The same _bypassBeforeRead: true escape hatch on read options is available for admin tooling that needs to see everything regardless of beforeRead scoping. Use sparingly; it's a deliberate exit from access control. See Authentication & Authorization for the full auth subsystem.

Read-time hooks

Two collection-level hooks fire automatically through the SDK:

  • beforeRead — contributes a QueryPredicate whose strict adapter filters are ANDed with, but compiled separately from, caller where. It applies to ordinary reads, search candidates, editorial metadata, tree structure, relation targets, and richtext targets. The strict result compiles once per logical ReadContext + client security domain + collection definition + effective mode in private authority-bound state; invalid or unsupported clauses fail closed instead of disappearing. The deprecated caller-owned beforeReadCache property is ignored, reuse under another request id, locale, or actor authority rejects, and cyclic hook reads fail with ERR_READ_RECURSION. See Authentication & Authorization § Read-side scoping (the Quick Reference there carries six worked recipes).
  • afterRead — runs on every returned materialisation, including historical versions, tree nodes, relation targets, and richtext targets, with the authenticated RequestContext. Mutations to doc.fields propagate into the shaped response; recursive access to a version still being processed fails closed.

Hooks share the operation's ReadContext with relation and richtext population. Custom hooks must thread _readContext through nested SDK reads; richtext adapters must use the framework-provided secure target reader rather than direct adapter access.


Use cases the SDK fits today

  • Server-side frontend rendering in an all-in-one Byline deployment (TanStack Start route loaders, server functions, server-rendered page composition).
  • Migrations and seeds — a script can construct a BylineClient plus a super-admin context and write through document-lifecycle like any other caller.
  • Import / export jobs — read or write at scale, with the same hook semantics as production.
  • Operational tooling and admin scripts — anything running in Node alongside the core.
  • Future write helpers in trusted runtimes — uploads from disk, scheduled republish jobs, automated content ops.

In all of these the trust boundary is the Node process itself; the SDK is a convenience layer over the same core services that admin server functions call.

What the SDK does not trigger

The presence (or growth) of @byline/client does not mean Byline now needs a stable/public HTTP API. Even adding write capability — including uploads from filesystem or stream sources in trusted Node code — keeps the SDK on the in-process side of the boundary.

The trigger for a stable HTTP API is the arrival of the first real client that cannot safely or practically consume adapters in-process. Examples: a mobile app, a desktop app, a separately-deployed frontend, an external integration, a hosted remote Byline service. When that happens, uploads are not the only concern — the same boundary has to cover reads, list/find, create/update/delete, status transitions, version history, and auth. That is why the stable HTTP boundary is designed as a broader phase of work, not an accidental side-effect of the SDK gaining methods.

See Routing & API § What triggers a stable HTTP boundary for the full discussion.

Two clients, eventually

When stable HTTP arrives, two distinct client shapes will coexist:

  • In-process SDK — @byline/client as it stands today. Trusted runtime, direct IDbAdapter / IStorageProvider access, richer server-side ergonomics, no network I/O.
  • Transport client — a future thin fetch-based client over the public HTTP boundary. Browser-safe or remote-runtime-safe, no direct adapters, identical query DSL where possible but a different construction surface.

These are not the same package and should not be conflated. In-process SDK evolution should not accidentally define the public API; the public API is designed when the first external client forces the question.

Working rule for the current phase

  • Continue evolving @byline/client as an in-process, server-side SDK.
  • Allow read-first, then write capabilities, inside trusted runtimes.
  • Use it freely for migrations, seeds, server-rendered pages, scheduled jobs, operational tooling.
  • Do not let SDK feature growth drag a public HTTP boundary forward — that boundary needs the broader transport-design pass.
  • Introduce stable HTTP only when a real external client makes the in-process model untenable.

Code map

Concern

Location

BylineClient + createBylineClient

packages/client/src/client.ts

CollectionHandle

packages/client/src/collection-handle.ts

Public types (ClientDocument, FindResult, WithPopulated, options)

packages/client/src/types.ts

Response shaping

packages/client/src/response.ts

WhereClause / sort / relation filters

packages/core/src/query/parse-where.ts

populateDocuments orchestration

packages/core/src/services/populate.ts

afterRead orchestration

packages/core/src/services/document-read.ts

beforeRead predicate application

packages/core/src/auth/apply-before-read.ts

Strict security-predicate compiler

packages/core/src/query/parse-where.ts (parsePredicateFilters)

Secure richtext target reader

packages/core/src/services/richtext-populate.ts

Search authorization finishing

packages/client/src/search.ts

Document write services

packages/core/src/services/document-lifecycle/ (per-operation modules)

current_published_documents view

packages/db-postgres/src/database/migrations/0000_*.sql

Public client (no preview)

packages/client/src/server/clients.ts (@byline/client/server)

Viewer client + isPreviewActive

packages/client/src/server/clients.ts (@byline/client/server)

Admin client

packages/client/src/server/clients.ts (@byline/client/server)

Preview cookie helpers

packages/client/src/server/preview-cookies.ts (@byline/client/server)

Preview enable/disable/state server fns

packages/host-tanstack-start/src/server-fns/preview/

Drawer toggle

packages/host-tanstack-start/src/admin-shell/chrome/preview-toggle.tsx

<PreviewLink> + resolvePreviewUrl

packages/host-tanstack-start/src/admin-shell/collections/preview-link.tsx

CollectionAdminConfig.preview type

packages/core/src/@types/admin-types.ts

ContentAdminBar pill

apps/webapp/src/ui/components/content-admin-bar.tsx

Reference news list server fn

apps/webapp/src/modules/news/list.ts

Reference news detail server fn

apps/webapp/src/modules/news/details.ts

Reference news categories server fn

apps/webapp/src/modules/news/categories.ts

Implementation-detail design notes

packages/client/DESIGN.md

Integration test suite

packages/client/tests/integration/

AnteriorReading & Delivery
SiguienteRouting & API

En esta página

  • Overview
  • Application type generation
  • Server client getters (@byline/client/server)
  • Quick reference
  • 1. Instantiate a client
  • 2. Simple reads
  • 3. Top-level where filters
  • 4. Relation where filters
  • 5. Sort and pagination
  • 6. Populate and depth
  • 7. Type a populated relation with WithPopulated
  • 8. Status-aware reads
  • 9. Create / update / delete
  • 10. Search
  • 11. A standalone script
  • Architecture
  • Architectural position
  • Construction
  • Read surface
  • Filtering
  • Sorting
  • Selective field loading
  • Population
  • Typing populated relations
  • Status awareness
  • Preview mode (admin draft viewing on the public host)
  • Write surface
  • Auth, RequestContext, and the trust boundary
  • Read-time hooks
  • Use cases the SDK fits today
  • What the SDK does not trigger
  • Two clients, eventually
  • Working rule for the current phase
  • Code map
Byline CMS

Construyendo el futuro de la gestión de contenidos, un commit a la vez.

Proyecto

  • Documentación
  • Hoja de ruta
  • Contribuir
  • Versiones

Comunidad

  • Discusiones en GitHub
  • Blog
  • Boletín

Avisos legales

  • Política de privacidad
  • Condiciones de uso
  • Cookies

© 2026 Infonomic Company Limited y colaboradores. Open source y hecho con ❤️ por la comunidad.