Backend interfaces
Protocol 5 configuration
Set protocol5 on backend options with authorizeStream(principal, stream, tx). Mutation admission/replay and finite authority planning authorize inside their fenced Serializable transaction. Ordinary Query/Fetch validate the immutable Store binding and authorize without holding Store progress or the global publication fence across arbitrary Handler/Loader awaits. Explicit Query tracking acquires the fence in its Serializable read transaction. projectionGeneration defaults to "1"; configure the same generation on client and backend. A materialization identifies normalized Model read contracts, independently of credentials.
materializations maps supported prior materialization IDs to {schema, projectionGeneration} using the retained descriptor and Bootstrap selection. Preserve retained operation schemas while serving frozen Mutation retries.
The optional bootstrap({ctx}) preparation and Query contexts expose explicit tracking. Returning Model identities does not track them. The initiating ctx.stream is supplied by authenticated engine context; the caller cannot choose it as a business argument. Track additional recipients explicitly using the generated multi-Stream declaration API.
Bootstrap and Sync freeze finite plans. Each required unit commits whole; transport fragments cannot advance partial coverage. Mutation execution acknowledgment and committed local settlement are separate boundaries. Protocol 5 requires fresh local files; it does not migrate old pending work automatically.
createBackend
import { createBackend, devAuth } from './generated/backend.ts';
import { database } from './database.ts';
import { mutations, queries } from './handlers.ts';
import { loaders } from './loaders.ts';
const backend = createBackend<Tx>({
database,
authenticate: devAuth(),
mutations,
queries,
loaders,
protocol5: { authorizeStream: (principal, stream, tx) => stream === `User:${principal}` },
onError: error => console.error(error),
});
const server = await backend.listen({ port: 4242 });
console.log(server.url);
database.ts exports a PostgreSQL shim such as prisma(db), and Tx is its transaction type; see Database for the shims and AXTON's metadata migration, which must be applied first. handlers.ts and loaders.ts contain the implementations below.
The generated Options<Tx> requires:
| Option | Responsibility |
|---|---|
database: Database<Tx> |
A PostgreSQL shim, pg(pool), prisma(client) or drizzle(db), or persistence(driver) over your own driver (Database) |
authenticate: Authenticate |
Resolve the caller's user identity or reject the request |
mutations: Mutations<Tx> |
Implement each retained Mutation version |
queries: Queries<Tx> |
Implement each retained Query version |
loaders: Loaders<Tx> |
Implement the read function for each supported model version; leave out a device-only Model |
protocol5 |
Projection generation, retained materializations and transaction-bound Stream authorization |
bootstrap({ctx}) |
Optional initial-data preparation with typed track-only handles |
mutations or queries is required when the schema retains a contract of that kind, and can be omitted otherwise; the To-do example has no Queries and passes only mutations. Optional options are admit, translateRejection, onError, loaderHooks and native, described below. The generated function binds the schema and returns the backend synchronously. The generic function in packages/backend/server/index.mts additionally requires config; normal generated integrations do not pass it.
What your backend owns
The Rust runtime processes the sync protocol and nothing else. Application code supplies Stream and business permission policies. Rust enforces the Store binding and calls the required Stream policy; schema declarations do not replace business authorization.
| Rule | Who owns it | What the runtime does |
|---|---|---|
| Authorization | Handlers decide what userId may write; loaders decide what userId may see and return null for the rest, whatever stream asked. |
Authenticates the request and passes userId through. There is a trusted authorizeStream policy. |
| Unique constraints and identities | Your database schema. @@unique and @@id are enforced on the client only; the client's local database refuses a violating write, but nothing checks the server. |
Decodes identities and patches by shape. A duplicate that your database allows is stored. |
| Child deletion | Your handler. onTargetDelete: delete is a client-side cascade: the client deletes the children locally, and those deletes never reach the server. A handler that deletes a parent must delete its children itself and touch them (ctx.invalidate.todo(identity)), leaving them in the Streams that delivered them so those Streams receive the deletion. |
Reads the parent back as deleted and delivers it; a child the handler did not touch stays on other clients until a Stream delivers it. |
| Client identity | Each signed-in user gets their own local client database. Admission binds a Store to its authenticated principal and Stream; a mismatch is refused, including on saved replay. | Persists the binding in the Store row. |
| Backend language | TypeScript on Node, through the generated createBackend. The Dart package is a client SDK; there is no Dart or Rust-hosted backend. |
Runs the same Rust engine inside the Node addon. |
| Prerequisite expressions | @requires(Name(field: self)) is the only supported form: every argument is self, the value of the annotated field. The runner that satisfies prerequisites is client code. |
Never sees prerequisites; they gate when the client sends a durable call, not what the backend receives. |
These are accepted limits of the current runtime, not planned features. See deployment for the process and network boundaries.
Handlers
A handler receives { ctx, args }: ctx holds trusted framework context and args holds decoded caller inputs. An optional Model operand the caller left out arrives as null, exactly as if it passed null; a DateTime arrives as a Date, at the millisecond precision every client sends. A create operand is always a complete record: fields the caller omitted were filled from their creation defaults by the client, and the server never fills a missing value. In the snippets, Tx stands for the transaction type supplied by your database adapter. A Mutation handler writes to your database and returns explicit output values; a Query handler reads and returns them. AXTON resolves Model outputs through the corresponding versioned Loader in the same transaction. For a durable Mutation, it also freezes the same-transaction private snapshot and required Stream target for each server-visible Model input, independently of each invocation's result snapshot. Database work and framework metadata share the transaction, which runs at Serializable isolation: a handler needs no row locks to stay correct, because PostgreSQL aborts a transaction whose outcome no serial order would produce and the database shim runs it again. A handler and its Loaders can therefore run more than once for one call, and nothing inside them may have an external effect such as sending email, a push notification or an HTTP call. Record the effect in the transaction, an application outbox, and perform it after commit.
import { CallRejected, type Mutations } from './generated/backend.ts';
// saveTodo is application code that writes to the business database.
const handleAddTodoV2: Mutations<Tx>['addTodo']['v2'] =
async ({ ctx, args }) => {
if (!args.todo.title.trim()) throw new CallRejected('todo.title_empty');
await saveTodo(ctx.tx, args.todo);
ctx.invalidate.todo(args.todo);
// The new Todo joins the Stream once; its later changes reach it with no enrollment.
ctx.streams(['todos']).track.todo(args.todo);
return { relatedTodo: null, matches: [], count: 1, state: null };
};
The todo input describes local optimism. The handler explicitly invalidates every canonical identity it changes. Its durable receipt identifies the authority required for settlement, separately from the invocation result. The result holds only the declared outputs: relatedTodo and matches are identity-selected Model outputs that the Loader resolves at this invocation, and count and state are ordinary outputs. Other clients learn of the change through the todos Stream.
An output is independent of the inputs even when the names match. The fixture's EditAndRead(todo Todo.update) { todo Todo } may edit one Todo and return another:
import { type Mutations } from './generated/backend.ts';
const handleEditAndRead: Mutations<Tx>['editAndRead'] =
async ({ ctx, args }) => {
await saveTodo(ctx.tx, args.todo);
ctx.invalidate.todo(args.todo);
// The result names the Todo to show; it need not be the one edited.
return { todo: { id: 'todo-summary' } };
};
A missing output field fails the call; AXTON never fills it from an input. An operation without outputs, such as Edit(todo Todo.update), returns nothing, and its caller inspects its local Model once the call completes.
import { type Queries } from './generated/backend.ts';
const handleFindTodos: Queries<Tx>['findTodos'] =
async ({ ctx, args }) => {
// searchTodos is an application read; it checks what ctx.userId may see.
const page = await searchTodos(ctx.tx, ctx.userId, args.text, args.cursor);
return { todos: page.ids.map(id => ({ id })), nextCursor: page.next };
};
Returning identity objects lets the Loader resolve the visible records in order, including repeated identities. nextCursor is an ordinary value your query computes; the framework does not paginate. Authorization remains the application's responsibility.
| Field | MutationContext<Tx> |
QueryContext<Tx> |
Meaning |
|---|---|---|---|
tx |
Yes | Yes | Your database transaction object |
userId |
Yes | Yes | Authenticated caller; use it for business authorization |
callId |
Yes | Yes | Stable identity of this invocation, including retries |
invalidate |
Yes | No | invalidate.todo(identity) declares a canonical identity this Mutation changed; see Streams |
stream, streams(names) |
Yes | Yes | A handle for tracking records and selected invalidation; see Streams |
A handler returns the generated explicit output shape, or no value when it has no outputs. Explicit invalidation publishes changed canonical records to existing holders; tracking enrolls selected identities. Model inputs do not imply publication. A durable Mutation freezes private input snapshots and required Stream targets in its receipt, independently of its result snapshot.
A Query has track-only ctx.stream and ctx.streams([...]), with no invalidation. It may establish Stream membership while reading. Business side effects remain forbidden: ctx.tx is your application transaction, not a SQL sandbox. Request retries retain the exact invocation identity; a new invocation reads again.
Loaders vary by authenticated viewer and declared Model version, never by which Stream asked. Cache snapshots use the active materialization's Model contracts; retained invocation output contracts may differ. Ordinary Query/Fetch records have null cursors, while Stream delivery records carry authoritative positions.
One Mutation can have several operands and perform several business writes in one savepoint. The schema's Model operands describe local optimism; the backend can normalize values or use different tables.
mutations.addTodo holds every retained version of AddTodo. The fixture retains v1 and v2, so register both. Registration follows each retained version's kind: the fixture's GetTodos retains v1 as a Mutation and v2 as a Query, so each version is registered under its own kind.
mutations.addTodo = {
v1: handleOriginalAddTodo, // receives AddTodoV1Input
v2: handleAddTodoV2, // receives AddTodoInput
};
mutations.getTodos = handleGetTodosV1; // GetTodos v1 is a Mutation
queries.getTodos = { v2: handleGetTodosV2 }; // GetTodos v2 is a Query
A bare function means v1 only. A missing retained version, unknown version key, non-function value, or a registration under the wrong kind is refused at startup. Dispatch uses the requested version and never falls back to another. An unsupported version is a per-call rejection.
An error that is neither CallRejected nor translated to a business code rejects that call with handler.failed and reaches onError. Other named calls retain their independent outcomes. A retryable database transaction error instead retries the transaction; it is not saved as a permanent business rejection.
Bootstrap preparation
bootstrap?: ({ctx}) => Promise<void> is optional application preparation. Its typed context contains the current tx, userId, callId, initiating ctx.stream, and explicit ctx.streams([...]). Both Stream handles only track; neither provides invalidation. AXTON acquires its publication fence before preparation and freezes the finite Bootstrap plan in the same transaction.
The schema's @@bootstrap marks historical Model types selected for initial materialization. Queries cover named history outside that baseline. Manifest paging, coverage, replay and tail capture belong to the native runtime; applications do not return a Load continuation or manage a Load job. Receipt-target recovery does not call this preparation callback.
Preparation tracks the complete initial scope, including scopes larger than one Query page's enrollment limit. Applications may scan identities in batches; the framework deduplicates them before publication. Native delivery applies its finite-plan capacity and transport paging separately.
Loaders
import type { Loaders } from './generated/backend.ts';
const loadTodoV2: NonNullable<Loaders<Tx>['todo']>['v2'] =
async ({ ids, tx, userId }) =>
Promise.all(ids.map(id => loadVisibleTodo(tx, userId, id)));
LoaderCall<Tx, Identity> contains:
| Field | Meaning |
|---|---|
ids |
Read-only list of typed record identities |
tx |
Your transaction, shared with sync persistence for this request |
userId |
Caller whose visibility must be checked |
A Loader is not told which stream, if any, asked: it serves Mutation and Query Model outputs, durable authority readback, catch-up pages, the live stream and a client's client.fetch of one record, which needs no handler of its own. It sees the same application transaction during a call.
A loader returns Promise<readonly (Record | null)[]>. Return exactly one item per identity, in the same order. Do not filter out missing rows or return a differently ordered database result directly.
loaders.todo holds every retained version of the Todo read contract. The operation fixture retains v1 and v2, so register both while older clients or call results use v1:
loaders.todo = {
v1: loadTodoV1, // returns TodoV1 rows
v2: loadTodoV2, // returns Todo rows
};
The generated TodoV1 type is the record shape published for v1, so a v1 Loader maps current rows into it; AXTON does not convert between versions. Registration is checked at startup like handlers: a bare function means v1 only, and missing/unknown versions or non-function values are refused. A load reaches only the version it names.
What each item may be:
| Item | Meaning | Result |
|---|---|---|
| A row object | The record's current state for this user | Stream/manifest delivery carries its authoritative position. Query/Fetch delivery is an ordinary null-cursor snapshot. |
null |
The record does not exist, or this user must not see it | Stream/manifest null installs canonical absence and retains deletion protection, preserving pending overlays. Ordinary Query/Fetch null returns absence without deleting an authoritative cached row. |
a thrown CallRejected (or a translated rejection) |
A refused read | A call fails with the rejection. Delivery cannot commit the required authority unit or its progress. |
| any other thrown error | A failure | Reported to onError; it cannot become absence or skip required progress. Independent earlier units can commit when the server proves their prefix; bounded adaptive requests may recover that prefix. |
undefined, a missing entry, a non-array result, a nonfinite number |
A defect | The affected read fails. It is never interpreted as null. |
A row object must match the generated model type exactly. Include every non-identity field: a nullable field that is absent reads as null, but an absent non-nullable field is a defect. The identity fields may be present. Any other property, such as an extra database column or a relation object, is a defect. Map your rows to the model type rather than returning a wider database row.
Loaders run during synchronization and calls, not when the app calls local get, query or watch. A malformed result never discharges delivery progress. The call or required delivery unit fails and onError receives the diagnostic.
Every member of Loaders<Tx> is optional, so a standalone Loader typed from it uses NonNullable<…>, as above. A Model that registers a Loader registers every retained version; a key that names no Model is refused at startup.
Device-only Models
A Model whose Loader you leave out is device-only: a composer's working copy or a cache of signed URLs, written and read only on the client. Whether a Model syncs follows from where it is written; the schema declares nothing extra.
- On the client the Model works like any other for local
create,update,delete,get,queryandwatch, and inside transactions and local companions. Those writes stay in local SQLite and are never sent. - At startup
createBackendthrows when a retained Mutation would carry the Model on the wire, or a Mutation or Query would return it, because each needs its Loader:Mutation SaveDraft v1 slot draft names Model Draft, which has no Loader; a Model without a Loader is device-only and never on the wire. - In a handler,
backend.transactionorbackend.publishthe Model is never published.invalidate.draft(…),streams(names).track.draft(…)and mixedstreams(names).track([...])naming it throw at the call:invalidate.draft: Model Draft has no Loader, so it is device-only and cannot be published. In a handler that is the call'shandler.failed. client.fetch.draft(…)fails withloader.unregistered.
A backend that registers a Loader for every Model, including one that always answers null for a device-only Model, keeps working unchanged.
Streams
A Stream is a resumable ordered notification sequence. track establishes durable interest in a record; invalidate requests current authority for existing interested streams. Neither grants permission. The viewer Loader decides current content or absence independently of the delivery source.
import { Todo, createBackend, devAuth } from './generated/backend.ts';
const backend = createBackend<Tx>({
database, authenticate: devAuth(), mutations, queries, loaders,
protocol5: { authorizeStream: (principal, stream, tx) => stream === `User:${principal}` },
});
await backend.transaction(async ctx => {
const records = [Todo({ id: 'A' }), Todo({ id: 'B' })];
ctx.streams(['User:alice', 'User:bob']).track(records);
ctx.invalidate.todo(['A', 'B']);
ctx.streams(['User:alice']).invalidate.todo('C');
});
| Interface | Return and behavior |
|---|---|
ctx.stream |
Callback-bound initiating Stream on Mutation, Query and Bootstrap contexts. |
ctx.streams(names) |
Explicit readonly list of Streams. Background contexts only expose this form. Names are nonblank and case-sensitive. |
stream.track.todo(identityOrList), stream.track(referenceOrList) |
void; establishes durable unique Stream/Model/Identity pairs. A first pair receives the transaction's affected-Stream cursor. Repeating a live pair retains its cursor. |
ctx.invalidate.todo(identityOrList), ctx.invalidate(referenceOrList) |
void; updates each identity's existing holders at the transaction's affected-Stream cursor. With no holders it enrolls nobody and advances no Stream. |
stream.invalidate.todo(identityOrList), stream.invalidate(referenceOrList) |
void; updates only selected existing holders at the transaction's affected-Stream cursor. Never enrolls. |
Generated Model methods accept scalar or complete object identities for a single-field identity, complete objects for composite identities, and one identity or a readonly list. Mixed calls require generated RecordRef constructors that identify the Model. Models without a viewer Loader cannot be declared.
Declarations are synchronous, copy and canonicalize operands before appending, and expire with the callback. Multiple names and records declare their Cartesian product; use separate declarations for different associations. Empty name or record lists do nothing. Ordinary invalid declarations throw before appending any effects; preparation collectors retain a sticky failure even if caught.
The enclosing settlement combines declarations regardless of order. Each identity advances at most once and each final pair receives at most one position. Targeted sets union; global invalidation dominates. Model inputs do not replace publication declarations: explicitly invalidate all changed identities. Tracking another Stream or reading an output does not change authority. There is no extra batch or execute call and no request per declaration. Separate backend.publish invocations remain separate settlements.
Content and authority
Shared content changes must invalidate all holders. Selected invalidation serves viewer-specific projection or permission changes when other viewers' answers remain valid. A changed row-to-null answer requires a newer canonical position even if only selected streams are notified. Loader errors retain local content and are never absence; a failed required Loader read cannot discharge delivery progress.
Business deletion and access revocation are authority changes: invalidate affected identities and let their viewer Loader return null. Tracking survives absence, allowing offline deletion delivery and later reinstatement. The public API has no withdrawal, tags or selectors and no automatic tracking retention policy. Membership Remove is delivery evidence: it retains the client Model and guards while releasing live-content protection. True Stream null supplies canonical absence and keeps deletion protection. The current client follows exactly one bound Stream.
| Context | Available declarations |
|---|---|
Mutation or legacy slot handler; backend.transaction or backend.publish |
Multi-stream tracking, global and selected invalidation. |
| Query or Bootstrap | Initiating and explicitly selected Stream tracking only. |
| Viewer Loader | No declarations; preparation hooks receive explicit typed streams and invalidate. |
| Client transaction or Mutation input callback | Device-only Model CRUD; no server declarations or Stream subscription management. |
See Bootstrap, cache authority, and settlement.
Authentication
Authenticate receives Node's IncomingMessage and returns a user ID string, null, undefined, or a promise of those values. Null/undefined or a blank user ID rejects authentication. The SDK calls it for HTTP requests and WebSocket connections. Verify your application's session/token here; enforce read permissions in loaders and write permissions in handlers.
devAuth(): Authenticate treats Authorization: Bearer <userId> as the identity without verification. It is provided for local development, not production authentication. See authentication and account changes.
Admission
admit?: Admit decides whether a client may use the listener at all, for example to turn away app builds below your supported floor. It receives Node's IncomingMessage, including the headers the client was configured with (server connection), and the user ID authenticate returned, or null when there is none. Return null or undefined to admit, or an AdmissionRefusal { status, body } to refuse:
const minimumBuild = 42; // your supported floor
const admit: Options<Prisma.TransactionClient>['admit'] = (request) => {
const build = Number(request.headers['x-app-build']);
return build < minimumBuild ? { status: 426, body: { minimumBuild } } : null;
};
It runs on every listener route and the WebSocket upgrade, after authenticate and before anything else, so a refusal wins over 401. The response has your status (400-599), your JSON body and the header axton-admission: refused; the client SDKs stop the connection and hand onError one AdmissionRefused instead of retrying. A hook that throws, or returns anything else, is a server error: 500 { code: "server" }, and the error goes to onError. admit is optional; without it every authenticated request is admitted.
Errors
| Interface | Use |
|---|---|
new CallRejected(code) |
Reject one Mutation or Query call with a stable machine-readable code |
translateRejection(error) |
Return a stable rejection code for a known application error; return null/undefined for other errors |
onError(error) |
Log server failures that are returned to the client as a generic server error. A handler or Loader failure is reported inside the transaction, so a transaction retried after a serialization failure can report the same failure once per attempt |
EngineError |
A failure from the native engine: code (stable), message (readable, may change), details (fields the code promises) |
Codes must match ^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$, such as todo.title_empty. A recognized business rejection rolls back that call's business writes, canonical publications, memberships and deliveries. For a queued call, wait() returns a CallError outcome and any optimistic Model change rolls back; the durable rejection remains inspectable until dismissed. For a direct call, the promise rejects with CallError. An unknown transport outcome can be retried with the same call identity; it is not evidence that the handler did nothing.
Business codes come from CallRejected or translateRejection; action_version_unsupported, handler.failed, loader.failed, model_version_unsupported and query.effects_forbidden identify framework failures attributable to one call. A durable receipt records each call's outcome. Distinct durable Calls retain independent outcomes. A direct response carries the same final outcome for that call.
Infrastructure errors that make the transaction unusable abort delivery for retry. A serialization conflict that outlasts the shim's retries is one of them: the call is not rejected, and a queued call stays queued and is sent again. onError receives diagnostic failures, including handler and Loader exceptions. Diagnostic callback exceptions after a committed outcome cannot replace its result, repeat its handler or become a transport error; SDKs report those callback exceptions through their runtime uncaught-error channel.
Protocol refusals use a status and JSON body chosen by the engine error's code; message text may change. Per-call content errors are outcomes, while a pull or live subscription with an unsupported Model declaration receives a whole-request 409.
| Code | HTTP status | Meaning |
|---|---|---|
(your admit refusal) |
Your status, with axton-admission: refused |
Your JSON body (Admission) |
protocol.unsupported |
426 | Unsupported numeric protocol discriminator; refused before handler or progress effects |
request.invalid |
400 | Malformed body, or a pull cursor ahead of the stream head |
store.binding |
403 | The Store belongs to another principal or Stream |
stream.forbidden |
403 | Required Stream authorization refused the principal |
batch.sequence, batch.conflict, batch.progress |
409 | Invalid Batch sequence, immutable intent or retained progress |
page.capacity, constraint_group_capacity |
413 | A required finite authority unit exceeds its capacity bound |
model_version_unsupported |
409 | Pull and live subscribe: a Model read contract this backend does not serve. During a call it is a per-call failure. |
handler.invalid |
500 { code: "server" } |
The handler's settlement could not be used: an invalid rejection code, or a change or membership naming a record without a model or an object identity |
| anything else | 500 { code: "server" } |
A server-side failure; the EngineError or thrown error goes to onError |
Listener
await backend.listen({ port, host? }) binds a Node HTTP and WebSocket server. Host defaults to 127.0.0.1; port zero selects an available port. The result is { url, close(): Promise<void> }.
| Route | Purpose |
|---|---|
POST /sync/handshake |
Bind the Store and capture its authenticated Stream head |
POST /sync/mutations |
Execute an immutable Batch of named Mutations with independent member outcomes |
POST /sync/actions |
Execute a fresh named Query and return null-cursor invocation snapshots |
POST /sync/fetch |
Read one record through its Model's Loader for client.fetch |
POST /sync/materialize |
Serve settlement-owned or schema-owned finite authority plans |
POST /sync/pull |
Serve finite Bootstrap and Sync plan fragments |
/sync/live (WebSocket) |
Acknowledge the one bound Stream and deliver proved authoritative units |
The listener has no TLS, CORS or proxy-header handling and binds to loopback by default; run it behind a reverse proxy as described in Deploy the backend.
Generated clients use all of these routes automatically from one bound connection configuration. The WebSocket subscription acknowledgement confirms that stream listeners are installed before HTTP catch-up starts, so changes during catch-up can be queued and reconciled. Listener errors reject. await server.close() releases the listener and its live connections; your application must drain admitted database transaction promises before separately closing its database pool. The supported listener owns its server; mounting into an application-owned HTTP server is not currently exposed.
Background writes
Writes outside handlers have no readback and no receipt; they reach clients only through Streams. Run them through backend.transaction: the framework opens the application transaction and hands the body the same invalidate and stream a Mutation handler receives. When the body returns, the framework reserves one cursor per affected Stream and settles explicit tracking and existing-holder invalidation inside that same transaction; once it commits, the live subscribers of the affected Streams are woken.
await backend.transaction(async ({ tx, streams, invalidate }) => {
await tx.entry.update({ where: { id: 'entry-1' }, data: { text: 'From a job' } });
invalidate.entry({ id: 'entry-1' });
streams(['book:demo']).track.entry({ id: 'entry-1' });
});
tx is the transaction of the shim passed as database. The body's return value is returned. If the body throws, the transaction rolls back and nobody is woken; the error propagates so the driver can retry serialization failures, which run the whole body again. Do not call backend.transaction from a handler: a handler already has a transaction.
TransactionCall<Tx> member |
Contract |
|---|---|
tx |
The application transaction; write business data through it |
invalidate |
invalidate.entry(identity) declares a changed record; publishes its current canonical state when the body returns |
streams(names) |
The same Stream handle as in a handler, for tracking records and selected invalidation |
Same rules as a Mutation handler's, with two differences: there are no Model inputs, because nothing was uploaded, and nothing is read back, because no client is waiting for a receipt. Tracking an unchanged record is idempotent; invalidation updates existing holders using the transaction's affected-Stream cursor. Wakeups are process-local; distributed wake delivery needs additional application infrastructure.
In a transaction you own
When your code has already opened the transaction, for example another framework's request handler, backend.publish(tx, body) settles the same declarations inside it. tx must be a transaction of the tool the database shim was built on.
const wake = await db.$transaction(async (tx) => {
await backend.acquirePublicationFence(tx);
await tx.entry.update({ where: { id: 'entry-1' }, data: { text: 'From my host' } });
return backend.publish(tx, ({ streams, invalidate }) => {
invalidate.entry({ id: 'entry-1' });
streams(['book:demo']).track.entry({ id: 'entry-1' });
});
}, { isolationLevel: "Serializable" });
wake();
| Behavior | Contract |
|---|---|
| Settlement | Runs before publish resolves: tracking and Stream positions are written through tx, so they commit or roll back with it, a savepoint included. Repeated publication calls reuse one cursor per affected Stream in that SQL transaction; records have no independent publication stamp |
| Wake | publish resolves to a function. Call it after tx commits; after a rollback, drop it. Until it is called, no live subscriber is told; they catch up on their next wake or reconnect |
| Errors | A refused declaration or a database error rejects publish with the original error, so your retry loop can recognize a serialization failure and run the whole transaction again. Wakes from failed attempts are simply never called |
| Isolation | AXTON does not choose the level of your transaction. Use Serializable isolation and acquire the persisted publication fence before relevant application work. Retry the entire transaction on a serialization conflict; a later publication call cannot repair an earlier stale snapshot |
| Refusal | A transaction AXTON is already serving, a handler's or backend.transaction's, is refused: declare through its own invalidate and stream |
Extension points
loaderHooks maps model names to { prepareForViewer(call): Promise<void> }. The hook runs before that model's loader in the same request context. Its failure fails the affected read. The typed call provides call.streams([...]) and call.invalidate for preparation publications; explicit preparation publications use the same transaction and cursor reservations. Use it only if viewer-specific preparation is needed; a loader already receives the user.
native?: Native injects the native bridge when packaging it elsewhere. Its current entrypoints include validateConfig, handshake05, validateMutationBatch, processBatchMember, encodeBatchAcknowledgement, processRead05, processDelivery05, processMaterialization05, processLive05 and settleExternal05; negotiateLive, liveEvent and liveClose manage the native socket session with the string/JSON callback contracts in the SDK source. The default binding comes from this repository's Node addon. This is a packaging seam; the generated Mutations, Queries and Loaders remain the application contract.
Backend methods marked @internal are used by the listener and tests. They are not the supported application-facing HTTP integration surface.