Core Composition — Roadmap
Companions:
- AUTHN-AUTHZ.md — the ability-key design that the future
createCommand({ auth: { abilities } })slot will consume. - ROUTING-API.md — admin server fns are the current call sites that would benefit from a command-tree shape.
- RICHTEXT.md — the richtext adapter is the first server-side adapter slot on
ServerConfig.fields.*; pattern for future field-level server adapters.
Field-level server adapter slots — ServerConfig.fields.*
Adapter packages can register a server-side function alongside their client-side React component via mirrored slots on ClientConfig.fields.* and ServerConfig.fields.*. The richtext adapter is the first user of this pattern:
- Client side —
ClientConfig.fields.richText.editor: RichTextEditorComponent. Render-only React component, registered vialexicalEditor()from@byline/richtext-lexical. - Server side, write path —
ServerConfig.fields.richText.embed: RichTextEmbedFn. Pure function called once per rich-text leaf the document-lifecycle write path discovers in an outgoing document, registered vialexicalEditorEmbedServer()from@byline/richtext-lexical/server. - Server side, read path —
ServerConfig.fields.richText.populate: RichTextPopulateFn. Mirror of the embed function; called once per rich-text leaf the read pipeline discovers in a returned document, registered vialexicalEditorPopulateServer()from@byline/richtext-lexical/server.
Two consequences worth flagging for any future field-level adapter:
- Subpath split is the right shape. Adapter packages with both client and server pieces ship two entry points so consumers of one don't bundle the other.
@byline/richtext-lexical(UI) and@byline/richtext-lexical/serverare the reference example. - The framework owns the walker. Field-level server adapters receive context per-leaf (
{ value, fieldPath, collectionPath, readContext }) — they don't walk the document tree themselves.collectRichTextLeavesinpackages/core/src/services/richtext-populate.tsis the per-field-type walker today; future adapters get their own walker but slot into the same place in the read pipeline (between relation populate and user-land afterRead).
A user-land lifecycle hook surface on adapters (Phase 3b in RICHTEXT.md) is deliberately not part of this pattern. That layer waits for a second editor implementation to reveal what it should look like; today's slot is for framework-managed phases only.
Status: Phase 1 shipped.createCommandis live in@byline/adminand every admin module'scommands.tsis built on it. Phases 2–5 remain forward-looking. The phases below are ordered by leverage and by how independent each one is.
Where we are now
Byline already has the DI infrastructure it would need for richer composition — it just doesn't lean on it yet.
- Registry / AsyncRegistry — a typed DI container in
packages/core/src/lib/registry.tswith compile-time dependency-graph validation via TypeScript conditional types. - initBylineCore() — composes
config,collections,db,storage,logger, plus theAdminStoreaggregate, into aBylineCoreinstance. Server-side callers retrieve the resolved core viagetBylineCore<AdminStore>(). - Admin modules in @byline/admin — each module ships as
commands.ts+repository.ts+service.ts+dto.ts+schemas.ts+errors.ts+abilities.ts. Repositories are plugged intoAdminStorefrom@byline/db-postgres/admin. - Commands built on createCommand (Phase 1, shipped). Each command in
@byline/adminis acreateCommanddeclaration that folds the four standard steps (validate → authorise → invoke → shape) into one spec:The wrapper preserves the historicalexport const listAdminUsersCommand = createCommand({method: 'listAdminUsers',auth: { ability: ADMIN_USERS_ABILITIES.read },schemas: { input: listAdminUsersRequestSchema, output: adminUserListResponseSchema },handler: ({ input, deps }) =>new AdminUsersService({ repo: deps.store.adminUsers }).listUsers(input),})(context, input, deps) => Promise<Output>call signature, so server fns and tests need no change. Every server-fn call site still has to thread{ store }throughdepsby hand — that's what Phases 2/3 retire. - One request-context builder.
getAdminRequestContext()resolves the admin actor for admin server fns. There is no equivalent yet for a public-user realm or any future agent realm;RequestContext.actoris typed asActor = AdminAuth | UserAuth | nullso the slot is reserved, but onlyAdminAuth | nullis populated at runtime today. - Inline env parsing.
byline/server.config.tsand seed scripts readprocess.env.*directly. There is no centralloadConfig()boundary that turns environment into a typedConfig.
This shape is a deliberate baseline. It keeps the package boundaries decoupled — important when the product is a framework rather than a single app — and avoids speculative abstraction. The phases below add structure where the per-line repetition cost is high enough to justify it.
Future phases of work
Phase | Goal | Status |
1 |
| Shipped |
2 | Module-level registry factories inside | Builds on 1 |
3 | Compose module registries inside | Builds on 2 |
4 | Typed request-context builders per actor realm ( | Yes |
5 |
| Yes |
Phases 1 → 2 → 3 form one track (command/registry shape); Phases 4 and 5 are independent. The whole roadmap is reversible — none of these phases are load-bearing for shipped functionality.
Phase 1 — createCommand wrapper (shipped)
createCommand lives at packages/admin/src/lib/create-command.ts and is exported from @byline/admin. Every command in the four admin modules (admin-users, admin-roles, admin-permissions, admin-account) is built on it. Worked example:
export const listAdminUsersCommand = createCommand({ method: 'listAdminUsers', auth: { ability: ADMIN_USERS_ABILITIES.read }, schemas: { input: listAdminUsersRequestSchema, output: adminUserListResponseSchema }, handler: ({ input, deps }) => new AdminUsersService({ repo: deps.store.adminUsers }).listUsers(input),})The wrapper does the four steps in fixed order — Zod-parse input → resolve admin actor → invoke handler → Zod-parse output — and returns a function with today's (context, input, deps) => Promise<Output> signature so existing server-fn call sites and tests need no change.
auth slot — discriminated union. Two variants:
{ ability: 'admin.users.read' }— full admin gate. Delegates toassertAdminActor, which requires anAdminAuthactor holding the named ability. Inherits the super-admin bypass fromAdminAuth.assertAbility.{ authenticated: true }— identity gate only. Delegates torequireAdminActor. No ability check. Used byadmin-accountself-service commands where the security property is "you may only mutate your own row," enforced structurally by sourcing the target id fromactor.id.
The single-key form (ability) rather than abilities: [...] reflects that no command today asserts multiple abilities; an array variant remains additive if/when it surfaces. The ability key is opaque — the wrapper does not parse it or branch on its prefix.
handler slot — args-object form. The handler receives { context, input, deps, actor } so it can cherry-pick what it needs without positional ordering. actor is already narrowed to AdminAuth by the auth step; commands that perform self-checks (e.g. disableAdminUser, deleteAdminUser) read it directly.
Out of scope for Phase 1. Document collection operations (create / update / delete / status / upload) are gated by assertActorCanPerform inside the document-lifecycle service functions in @byline/core. They do not flow through this wrapper today. If the two enforcement paths converge later — most likely as a Phase 2/3 thing where a uniform createCommand shape wraps both admin and document collection commands — the auth discriminator can grow a collection variant without breaking existing call sites.
Three downstream benefits the wrapper unlocks:
- Halves the line count of each
commands.tsfile. - Makes the contract inspectable. A future revision can log every command call uniformly, emit OpenAPI / JSON-Schema descriptors from the typed
schemasslot, and power a "what's registered" admin inspector view. - No cross-package API change. The refactor was internal to
@byline/admin; server-fn call sites import the same exported names.
Phase 2 — Module-level registry factories
Each admin module currently exports loose commands; the integrating app wires { store } into every call. Phase 2 packages each module's wiring as a single factory that lives inside @byline/admin:
// packages/admin/src/modules/admin-users/index.tsexport const createAdminUsersRegistry = (store: AdminStore) => new Registry() .add('repo', store.adminUsers) .add('service', ({ repo }) => new AdminUsersService({ repo })) .add('commands', ({ service }) => ({ listAdminUsers: createCommand({ ..., handler: service.listAdminUsers }), // ... }))Same pattern for admin-roles, admin-permissions, admin-account, and any future module. The integrating app composes the registries at startup but no longer touches the wiring inside each module.
This phase depends on Phase 1 — without createCommand, every factory still has the four-step contract inline. With Phase 1 in place, the registry factories become a clean transcription.
Phase 3 — Compose module registries inside initBylineCore()
Once each module ships a registry factory, initBylineCore() can accept modules: [...] and expose a typed command tree on the resolved core:
const core = await initBylineCore({ config, collections, db, storage, modules: [ createAdminRegistry(adminStore), // composes admin-users + admin-roles + ... createWorkflowRegistry(...), // future ],})
const result = await core.admin.adminUsers.commands.listAdminUsers(ctx, input)Server-fn call sites stop importing individual command functions and stop threading deps — both are bound at composition time. The current adminStore? parameter on BylineCore<TAdminStore> retires; the store lives inside the registry tree.
Two design constraints to lock in:
- Module registries live in their feature package, not in @byline/core. Byline is a framework, not a monolith —
@byline/adminships its own composition factory; a future@byline/workflowships its own; the integrating app composes them.@byline/coreprovides theRegistryprimitive and theinitBylineCorecomposition point but does not import feature packages directly. - The adapter boundary stays.
IDbAdapterandIStorageProviderremain the contracts that adapter packages implement. Module registries consume those interfaces; they do not wire concrete connection pools or transaction managers themselves. Swapping the DB adapter must remain a single-file change inbyline/server.config.ts.
Phase 4 — Per-realm request-context builders
Today's getAdminRequestContext returns a RequestContext with actor: AdminAuth. When the public-user realm arrives (driven by the first feature that needs an authenticated reader — gated content, member-only articles, per-user drafts), the call sites need a parallel getUserRequestContext that resolves a UserAuth actor. The same applies to a future agent realm if one materialises.
The work is purely additive: each new builder is a sibling function, each returns a typed-discriminated RequestContext, and each is invoked at the top of its respective transport layer (admin server fns, public reader endpoints, agent endpoints). No changes to the service-layer enforcement helpers — assertActorCanPerform and assertAdminActor already dispatch on actor type.
Sequencing: this phase pairs naturally with whatever feature first introduces the second realm. Building it speculatively now would be over-fitting on a single use case.
Phase 5 — loadConfig()
A single boundary where environment variables turn into a typed Config object:
// packages/core/src/config/load-config.tsexport function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { return ConfigSchema.parse({ db: { connectionString: env.BYLINE_DB_URL }, storage: { /* ... */ }, auth: { /* ... */ }, // ... })}Pure cleanup. The current inline process.env.* reads work; they're just scattered. A single boundary catches misconfiguration at startup with a useful error message rather than at the first request that happens to need the missing variable.
Worth doing once one of two things happens:
- The env surface grows past ~10 values (currently smaller —
loadConfig()would be over-engineered today). - The first production misconfiguration bites and surfaces the cost of scattered reads.
Until then this is in the "nice to have, no rush" tier.
Architectural guard rails
Three things to hold the line on across all phases:
- Module registries live in feature packages, not in @byline/core. Byline composes; it does not own. A new feature package ships its own registry factory; the integrating app wires it in.
@byline/corestays unaware of feature-specific concerns. - Auth keys, not auth realms. The
createCommandwrapper takes an ability expression or a list of keys, not an enumeratedmode. TheAbilityRegistryis the source of truth — collections and plugins contribute their abilities at registration time, and the wrapper stays open to whatever they declare. - Adapter boundary is permanent.
IDbAdapter/IStorageProvider/SessionProviderare the contracts that adapter packages implement. Module registries consume them via interface; they do not wire concrete dependencies (Drizzle pools, argon2, S3 clients, etc.) directly. This is what keeps "swap the adapter" a single-file change inbyline/server.config.ts.
Code map
Concern | Location |
|
|
|
|
Admin module commands (current shape) |
|
|
|
|
|
|
|
|
|
Admin request-context resolver |
|
Server-fn call sites (would consume command tree) |
|