Skip to content

Client runtime

The generated client delegates to a generic runtime, available as client.client. Most applications use the generated model APIs; use this reference for SQL, connection control, recovery or a custom integration. TypeScript returns promises and Dart returns futures unless stated otherwise. Native validation failures reject the call; Dart reports them as StateError.

Opening and schema changes

TypeScript imports Client from packages/client-js/index.mts in a source checkout. Dart imports package:ahead/ahead.dart through the local package dependency described in the client setup guide.

const raw = await Client.open({ path: 'local.sqlite', schema });
final raw = await Client.open(
  path: 'local.sqlite', schema: schema,
  libraryPath: '/absolute/path/to/libahead_dart.dylib',
);

schema is the compiler's generated descriptor. clientId is a read-only, persistent identity for that database, used for retry deduplication. Use one active client per database and a separate file per signed-in user. Do not duplicate a database and then let both copies independently send mutations under the same client identity.

Both forms accept migration. For an explicitly changed descriptor:

const raw = await Client.open({
  path: 'local.sqlite',
  schema,
  migration: { defaults: { Entry: { addedField: null } }, replayPull: true },
});
final raw = await Client.open(
  path: 'local.sqlite', schema: schema,
  libraryPath: '/absolute/path/to/libahead_dart.dylib',
  migration: {
    'defaults': {'Entry': {'addedField': null}},
    'replayPull': true,
  },
);

Defaults must fit the new field's type. Migration is atomic and preserves client identity, queued work and frozen request bytes. replayPull requests cursor rewind when applying the changed descriptor. Arbitrary identity/type changes are not supported; see local storage.

Reads

All these methods read local SQLite through Rust. RecordValue in TypeScript is Record<string, unknown>; Dart uses Map<String, dynamic>.

Method Input Result
read(model, identity) Model name and all identity fields Complete record or null
query(model, where) Equality fields; default empty Matching records
querySpec(model, query) filter, orderBy, limit Matching records with requested order/limit
readSql(sql, parameters) Read-only SQL and bound parameters Result rows
related(model, identity, relation) Source identity and declared relation name Related record or null
referencing(model, identity, source, relation) Target identity, referencing model and its relation Referencing records
watch(model, where, listener, onError?) (TypeScript) Equality filter and callbacks Unsubscribe function
watch(model, where: ...) (Dart) Equality filter Stream of record lists

TypeScript's query and readSql take optional positional second arguments. Dart uses named where: and parameters:. querySpec takes the same positional descriptor in both languages:

const rows = await raw.querySpec('Entry', {
  filter: { note: null },
  orderBy: [{ field: 'text', direction: 'ascending' }],
  limit: 20,
});
const matches = await raw.readSql(
  'SELECT id, text FROM "Entry" WHERE text = ?', ['Draft'],
);
final rows = await raw.querySpec('Entry', {
  'filter': {'note': null},
  'orderBy': [{'field': 'text', 'direction': 'ascending'}],
  'limit': 20,
});
final matches = await raw.readSql(
  'SELECT id, text FROM "Entry" WHERE text = ?',
  parameters: ['Draft'],
);

querySpec calls its equality filter filter; the generated API calls it where. SQL rejects writes. Bind values instead of interpolating them into SQL. Watch results are distinct committed snapshots, with an initial query; they are not an event log. Cancel watchers when their owner is disposed.

Transactions and savepoints

transaction<T>(callback) commits the callback's result or rolls back on failure. Its Transaction exposes all the reads above except watch, plus:

Method Behavior
mutate(descriptor) Apply declared optimistic operations and enqueue one mutation; returns its local ordinal
direct(operation) Apply one local-only operation; returns void
savepoint(callback) Run a nested scope; roll back that scope on failure; return its callback result

raw.mutate(descriptor) is a convenience wrapper around its own transaction. Prefer generated mutation builders to constructing descriptors yourself.

import { Edit } from './generated/client.ts';

await raw.transaction(async tx => {
  await tx.mutate(Edit({
    entry: { identity: { id: 'entry-1' }, values: { text: 'Draft' } },
  }));
  try {
    await tx.savepoint(async () => {
      await tx.direct({
        model: 'Entry', op: 'update',
        identity: { id: 'entry-1' }, values: { note: 'Temporary' },
      });
      throw new Error('Discard this note');
    });
  } catch {
    // The note rolls back; the earlier edit can still commit.
  }
});
import 'generated/generated.dart';

await raw.transaction((tx) async {
  await tx.mutate(edit(
    entry: const EditEntryUpdate(
      identity: EntryIdentity(id: 'entry-1'), text: Present('Draft'),
    ),
  ));
  try {
    await tx.savepoint(() async {
      await tx.direct({
        'model': 'Entry', 'op': 'update',
        'identity': {'id': 'entry-1'}, 'values': {'note': 'Temporary'},
      });
      throw StateError('Discard this note');
    });
  } catch (_) {
    // The note rolls back; the earlier edit can still commit.
  }
});

Dart's savepoint also takes a zero-argument async callback and operates through the same tx. Await every call and nested callback. Savepoints must be properly nested, not run concurrently. An escaped transaction, unfinished operation or overlapping savepoint fails. Inside the transaction use tx reads; an outer raw read can wait behind the current transaction. TypeScript's Transaction.finish() is runtime-owned bookkeeping; applications should not call it.

Server connection

Pass server when opening the generated client, or call connect on the raw client after opening local storage. TypeScript accepts ServerOptions; Dart uses SyncServer. Only one connection may be active per client. Network I/O happens outside the local transaction queue.

const connection = await raw.connect(
  { url: backendUrl, token: () => accessToken },
  {
    onError: error => console.error(error),
    refreshAuth: async () => { accessToken = await renewAccessToken(); },
  },
);
final connection = await raw.connect(
  SyncServer(url: backendUrl, token: () => accessToken),
  onError: (error) => print(error),
  refreshAuth: () async { accessToken = await renewAccessToken(); },
);
Option TypeScript Dart
url HTTP or HTTPS backend base URL HTTP or HTTPS backend base URL
token String or function returning a string/promise Function returning a string/future
onError (error: unknown) => void, in connection options Named callback on connect / open
refreshAuth () => Promise<void>, in connection options Named async callback on connect / open

Here backendUrl, accessToken and renewAccessToken belong to your application. Credentials travel in authorization headers. Token functions run for new requests and connections, so they can read refreshed credentials. Authentication failures can invoke refreshAuth; background failures reach onError and retry with backoff.

Catch-up and live updates

Ahead manages these phases automatically:

  1. Connect to /sync/live and subscribe to the current channel set. The server installs listeners before acknowledging the subscription.
  2. Fetch missing records through POST /sync/pull, starting from each channel's saved cursor. Queue WebSocket pages arriving while catch-up runs.
  3. Continue receiving WebSocket updates. HTTP and WebSocket pages enter the same serialized Rust processing path, using each channel's saved cursor.

For either source, a page already covered by the cursor is discarded. A page spanning the current cursor applies only its unseen changes; for example, at cursor 100, a page covering 90 → 120 applies changes after 100 and advances to 120. Only a page starting beyond the current cursor has a gap and requires HTTP recovery. Pages update SQLite and watches through the same engine logic.

Mutation submission runs independently through POST /sync/mutations. A connection with no subscribed channels can still submit mutations without opening a socket.

Reconnection and subscription changes repeat catch-up from saved progress. The client checks that every HTTP response and queued WebSocket page belongs to the current session before applying it. Pause and close cancel requests and sockets; resume creates a new session. The runtime does not poll for remote changes.

Connection controls

TypeScript calls the returned object Connection; Dart calls it RuntimeConnection.

Method Behavior
pause() Stop background network work; local reads/writes remain available
resume() Resume a paused connection and schedule work
wake() Ask the driver to re-evaluate pending work
close() Permanently stop this connection; the client database stays open
closed (Dart) Future that completes when the connection closes

All controls return promise/future void. Pause/close cancel network activity and discard responses from the canceled session. Persisted frozen requests remain available for retry. After close, create a new connection through the raw client to resume sync. await raw.close() closes its connection and native database resources and is idempotent; subsequent client operations fail.

Pending work and recovery

status() returns { clientId, pending, beforeImages, cursors, channels, rejections }. pending counts queued mutations; beforeImages is a diagnostic count; cursors maps channels to received positions; channels lists desired subscriptions; rejections contains { ordinal, code } entries. It is a local snapshot, not a network status probe.

const status = await raw.recordStatus('Entry', { id: 'entry-1' });
for (const item of status.pending) console.log(item.ordinal, item.phase);
for (const rejection of (await raw.status()).rejections) {
  console.log(rejection.code);
  // After your UI has handled it:
  await raw.dismissRejection(rejection.ordinal);
}
final status = await raw.recordStatus('Entry', {'id': 'entry-1'});
for (final item in status['pending'] as List) {
  print('${item['ordinal']}: ${item['phase']}');
}
for (final rejection in (await raw.status())['rejections'] as List) {
  print(rejection['code']);
  // After your UI has handled it:
  await raw.dismissRejection(rejection['ordinal'] as int);
}
Method Result / effect
recordStatus(model, identity) { pending, rejections } for that record; pending entries include ordinal, mutation name, phase and prerequisite states
dismissRejection(ordinal) Remove a handled rejection from the durable local inbox; does not retry it
drop(ordinal) Remove eligible unsent work and recompute local state; frozen/sent work cannot be cancelled this way

Phases are queued (not frozen), frozen (request retained for sending/retry), and accepted (awaiting settlement). A mutation ordinal is local bookkeeping. To retry a rejected business operation, make a new edit after resolving the cause. See sync and recovery.

Prerequisites

A schema can require host I/O, such as an upload, before a mutation can be sent. The local change remains visible while this work is pending.

await raw.runPrerequisites({
  Uploaded: async args => { await uploadFile(args.key); },
});
await raw.runPrerequisites({
  'Uploaded': (args) async { await uploadFile(args['key']); },
});

uploadFile is application code. Dart accepts the equivalent map of async callbacks. Callbacks run one at a time and must tolerate retry after a crash or restart; starting a connection does not automatically supply or run your host callbacks.

Method Behavior
pendingTasks() Return unresolved tasks, including key, state and schema-derived name/arguments
runPrerequisites(handlers) Run pending tasks; success marks ready, callback failure marks failed; missing handler rejects
setReadiness(key, state) Set ready, pending or failed; use the task's opaque key, not a reconstructed key

Callback failures are recorded as failed tasks rather than rethrown by the runner. Inspect pendingTasks or recordStatus to display them. To retry, set the failed key to pending, then run callbacks again. Mark ready only when the prerequisite actually completed.

Protocol primitives

These low-level engine methods support protocol tests and tooling. Application synchronization is managed by connect; calling these methods alongside an active connection can interfere with its sequencing.

Method Input and return
freeze() Return frozen request JSON or null when no batch can be sent; retry preserves the same bytes
acknowledge(sequence, receipt) Apply a decoded push receipt to the matching batch; returns void
applyPull(page) Apply a decoded pull page and return its application result

Wire fields are defined in the protocol source and exercised by shared wire fixtures. They retain names such as scope and syncId. Do not manufacture receipts, advance cursors yourself or rewrite frozen requests to recover from a network failure.