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/depthmachinery the SDK exposes. - Authentication & Authorization —
RequestContextthreading andbeforeRead/afterReadenforcement. - Collections —
CollectionAdminConfig.preview.urlbuilder 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 todocument-lifecycleservices. - Response shaping into a public
ClientDocument<F>envelope (camelCase, predictable, generic over the schema's field type). - Automatic
beforeReadpredicate application andafterReadhook firing. - Transparent
published/anyread-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.tsexportscollections, the evaluated runtime registry used by core and the client.byline/generated/collection-types.tsis 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-typesis 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 tsconfigincludecarrying 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 bareBylineClientin the app's program — includingcreateBylineClient's default generic and the@byline/client/servergetters below — resolves toBylineClient<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 onlyconst 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.
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.
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 Turingwhere: { authors: { $some: { name: 'Alan Turing' } } }
// Every (resolving) author is published; vacuously true with no authorswhere: { authors: { $every: { status: 'published' } } }
// No authors at allwhere: { 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 projectionawait client.collection('news').find({ populate: true })
// Every relation, full doc, recursiveawait 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 typedThe 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.
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 — defaultawait client.collection('news').find()
// Admin / system — see the latest version regardless of publish stateawait client.collection('news').find({ status: 'any' })
// 'status' selects the source view; where.status is an orthogonal column filterawait 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.
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.
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-scopedconst { 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.documentconst 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.tsimport { 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 articlesconst { 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 forwardawait news.changeStatus(draft.documentId, 'published')
// READ BACK — a published read now resolves the freshly published versionconst 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 doneEvery 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.tssoinitBylineCore()registers config + collections; - builds a client from
getServerConfig()and a super-admin context; - pages through
mediawithstatus: '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 newstoredFile; - walks the workflow ladder forward via
changeStatusto restore each doc's original status (sinceupdatealways 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 filterswhere: { title: { $contains: 'launch' } }where: { views: { $gte: 100 }, status: 'published' }where: { publishedAt: { $lte: new Date().toISOString() } }
// Combinatorswhere: { $or: [{ status: 'published' }, { authorId: actor.id }] }where: { $and: [{ tags: { $in: ['featured'] } }, { archived: false }] }
// Cross-collection relation filterswhere: { 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 2The 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' // ascendingsort: '-publishedAt' // descendingsort: ['-publishedAt', 'title'] // multi-keysort: { 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 projectionpopulate: '*' // every relation, full doc, recursivepopulate: { heroImage: true, author: { populate: { dept: true } } }depth: 2 // default 1 when populate presentThe 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/clientstatus: 'any' // admin / system code pathsIn '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 |
|
| Session-level "I want to see drafts" flag. httpOnly. Mere presence is the signal — no payload to verify. |
|
| Singleton |
| same module | Async check that returns |
|
| 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 ( |
| Source-of-truth indicator above Account in the admin menu drawer. Always visible, always reversible. Reflects cookie state via |
|
| Per-document external-link icon on the edit page header. On click: |
|
|
|
ContentAdminBar pill |
| Public-side "Preview" pill + "Exit Preview" button when the cookie is set. Threaded down from the public layout loader ( |
Trust model. The cookie is a flag, not a credential. The actual safety check is layered:
- Source-view selection is per-call. The SDK's
resolveReadModedefaults to'published'regardless ofRequestContext.readMode, so a server fn must passstatus: 'any'to surface drafts. There is no way to flip the source view throughRequestContextalone. - status: 'any' requires an actor.
assertActorCanPerformonly permitsactor: nullonreadwhenreadMode === 'published'. So a stray query string or stale cookie that reachesstatus: 'any'without a valid admin throwsERR_UNAUTHENTICATEDrather than leaking drafts. - 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
readModefor the request, but it does not bypassbeforeReadhooks. 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+isPreviewActivethe 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_previewalone as the bypass signal: a stale preview cookie without a valid session still receives published content and is safe to cache. In application code, useisPreviewActive(); 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_UNAUTHENTICATEDon every method. - actor: null → permitted only on
readwithreadMode: '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
QueryPredicatewhose strict adapter filters are ANDed with, but compiled separately from, callerwhere. It applies to ordinary reads, search candidates, editorial metadata, tree structure, relation targets, and richtext targets. The strict result compiles once per logicalReadContext+ client security domain + collection definition + effective mode in private authority-bound state; invalid or unsupported clauses fail closed instead of disappearing. The deprecated caller-ownedbeforeReadCacheproperty is ignored, reuse under another request id, locale, or actor authority rejects, and cyclic hook reads fail withERR_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 todoc.fieldspropagate 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
BylineClientplus a super-admin context and write throughdocument-lifecyclelike 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/clientas it stands today. Trusted runtime, directIDbAdapter/IStorageProvideraccess, 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/clientas 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 |
|
|
|
|
Public types ( |
|
Response shaping |
|
|
|
|
|
|
|
|
|
Strict security-predicate compiler |
|
Secure richtext target reader |
|
Search authorization finishing |
|
Document write services |
|
|
|
Public client (no preview) |
|
Viewer client + |
|
Admin client |
|
Preview cookie helpers |
|
Preview enable/disable/state server fns |
|
Drawer toggle |
|
|
|
|
|
ContentAdminBar pill |
|
Reference news list server fn |
|
Reference news detail server fn |
|
Reference news categories server fn |
|
Implementation-detail design notes |
|
Integration test suite |
|