Client SDK (@byline/client)
Companions:
- ROUTING-API.md — broader transport-phase context: admin UI is the only client today, stable HTTP is deferred. The SDK is what fills the gap.
- CORE-DOCUMENT-STORAGE.md — storage primitives the SDK sits above.
- RELATIONSHIPS.md —
populate/depthmachinery the SDK exposes. - AUTHN-AUTHZ.md —
RequestContextthreading andbeforeRead/afterReadenforcement. - COLLECTIONS.md —
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.
The distinction matters because Byline today is in an internal transport phase (see ROUTING-API.md). 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.
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 } })
// Count — same status / locale / beforeRead machinery as find()const total = await client.collection('news').count()findById / findByPath / findOne return ClientDocument<F> | null. find returns { docs, meta }. count returns number.
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 column comparisons (document_versions.status and a byline_document_paths projection) and compose correctly inside combinators or nested relation hops.
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.
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.md § Populate
7. Type a populated relation with WithPopulated
Schema-derived 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 { NewsFields } from './collections/news/schema.js'import type { NewsCategoryFields } from './collections/news-categories/schema.js'import type { MediaFields } from './collections/media/schema.js'
type NewsListFields = WithPopulated< WithPopulated<NewsFields, 'category', NewsCategoryFields>, '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')
await client.collection('news').delete(created.documentId)Every write resolves the client's configured requestContext and runs assertActorCanPerform('collections.<path>.<verb>'). actor: null is rejected on writes.
10. A standalone script
Putting it all together — a one-shot Node script that iterates a collection, regenerates the bytes behind every media document, and writes the new value back through the SDK. 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.ts (create / update / delete / status) ││ - document-read.ts (afterRead orchestration) ││ - populate.ts (relation expansion) ││ - apply-before-read.ts (predicate compilation + cache) │└─────────────────────────┬────────────────────────────────────────┘ ▼┌──────────────────────────────────────────────────────────────────┐│ 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 singletons over getServerConfig(): getPublicBylineClient(), getViewerBylineClient(), getAdminBylineClient() — see Quick Reference recipe 11. Each holds its own collectionRecordCache (so the path → { id, version } lookup is amortised across the process lifetime) and serves fresh per-request RequestContext values via the SDK's per-call factory pattern.
Read surface
Five top-level read methods, each returning camelCase-shaped ClientDocument<F> results:
client.collection('news').find({ where, sort, page, pageSize, fields, 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({ where, status, locale })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.
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 via the request's [requested, default] priority chain). query (text search) is not supported inside a nested sub-clause and is silently dropped with a debug log.
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 CORE-DOCUMENT-STORAGE.md § 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. See RELATIONSHIPS.md § Populate.
Typing populated relations
Schema-derived 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'
type NewsListFields = WithPopulated< WithPopulated<NewsFields, 'category', NewsCategoryFields>, 'featureImage', MediaFields>
// Use as the generic: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 read — including populate of relation targets 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' } })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/host-tanstack-start ships a "viewer client" that layers preview-aware behaviour over the SDK without changing it.
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 (CDN, route-level cache headers, an in-process LRU) needs to either key off the
byline_previewcookie or skip caching entirely when it's set — otherwise a single admin's preview view can poison a public cache entry and leak drafts to the next visitor. The Payload analogue is Next.js's__prerender_bypasscookie that disables ISR for the session; the underlying constraint is the same.
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.
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 read and write path runs assertActorCanPerform (for documents) or assertActorCanPerform plus the field-upload create gate (for uploads) before touching storage. 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 AUTHN-AUTHZ.md for the full auth subsystem.
Read-time hooks
Two collection-level hooks fire automatically through the SDK:
- beforeRead — called once per
find*call (and once per populate batch per target collection), before any DB work. Returns aQueryPredicateAND-merged into the SQL. Per-ReadContextcache (beforeReadCache) ensures async hooks run once per collection per request. See AUTHN-AUTHZ.md § Read-side scoping (the Quick Reference there carries six worked recipes). - afterRead — called once per materialised document on every read path and once per populated relation target.
ReadContext.afterReadFiredenforces "at most once per logical request" (A→B→A foreclosure). Mutations todoc.fieldspropagate into the shaped response.
Hooks share ReadContext with populate: a relation field and a richtext document link pointing at the same target cost one materialisation, not two.
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.md § 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 |
|
|
|
|
|
|
|
|
|
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 |
|