Skip to Content
Offline Actions

Offline Actions

Use a custom action when KalamDB is not the only write path. The table stays replicaOnly, the backend remains authoritative, and kalam_sync owns the optimistic overlay plus a durable outbox.

kalam_sync_generator writes the JSON codec, namespaced action definition, and typed queue. Drift still owns table row types. Direct create/update/delete uses one generic DML envelope — the generator does not emit three extra models per table.

Generate a queue

Add the generator as a dev dependency, then annotate an immutable payload and executor module:

pubspec.yamlYAML
dev_dependencies:build_runner: ^2.15.1kalam_sync_generator: ^0.6.0-rc.0
DART
@KalamActionPayload()class SendMessageArgs {  const SendMessageArgs({    required this.messageId,    required this.conversationId,    required this.text,    required this.createdAt,    required this.author,  });   final String messageId;  final String conversationId;  final String text;  final DateTime createdAt;  final String author;} @KalamActionModule(namespace: 'chat')class ChatActions {  ChatActions(this.api);  final ChatActionApi api;   @KalamAction(name: 'sendMessage')  Future<void> sendMessage(    KalamActionContext context,    SendMessageArgs args,  ) async {    await context.step<bool>(      'persist',      run: (key) async {        await api.persistMessage(args, idempotencyKey: key);        return true;      },      encode: (value) => value,      decode: (value) => value == true,    );    await context.step<bool>(      'deliver',      run: (key) async {        await api.markDelivered(args, idempotencyKey: key);        return true;      },      encode: (value) => value,      decode: (value) => value == true,    );  }}
BASH
dart run build_runner build --delete-conflicting-outputs

That generates chat.sendMessage, the codec, and ChatActionsQueue.sendMessage().

Open with action definitions

DART
final api = ChatHttpApi(baseUrl: Uri.parse(chatApiUrl));final kalam = await Kalam.open(  url: serverUrl,  subject: userId,  namespace: 'chat',  authProvider: () async => Auth.jwt(await tokens.freshAccessToken()),  actionDefinitions: chatActionsDefinitions(ChatActions(api)),);

Enqueue with an optimistic row

DART
final messages = kalam.table(chatMessagesSpec('chat.messages'));final actions = ChatActionsQueue(kalam.actions); final message = ChatMessage(  id: Kalam.id(),  conversationId: conversationId,  role: ChatMessageRole.user,  author: userId,  text: text,  status: ChatDeliveryStatus.pending,  createdAt: DateTime.now().toUtc(),); await actions.sendMessage(  SendMessageArgs(    messageId: message.id,    conversationId: conversationId,    text: message.text,    createdAt: message.createdAt,    author: message.author,  ),  orderingKey: conversationId,  optimistic: messages.optimisticInsert(message),);

The generated queue method commits the optimistic row, sidecar sync state, and serialized action together. On connectivity, the executor calls the backend with the stable action UUID. The row becomes synced only after the backend’s KalamDB write arrives and is committed locally.

orderingKey keeps related actions FIFO (one conversation, one queue). Different keys can flush in parallel.

Calling messages.insert(...) on a replicaOnly table throws. Send and read receipts go through the generated queue.

Named steps

context.step(...) runs a sub-operation at most once across retries and process restarts. A completed named step is persisted and reused. Every remote endpoint must still honor the supplied idempotency key, because a response can be lost after the server commits.

context.idempotencyKey is the action UUID. Step callbacks receive '$actionId/$stepName'.

FILE uploads

Multipart FILE("placeholder") uploads use the same step machinery. Persist the step result, not the file bytes:

DART
await context.queryWithFiles(  'upload',  sql: r"INSERT INTO app.messages (id, attachment) VALUES ($1, FILE('file'))",  files: [    KalamFileUpload(      placeholder: 'file',      filename: 'photo.jpg',      data: bytes,      mime: 'image/jpeg',    ),  ],  params: [messageId],);

Retrying the action reuses a completed upload instead of sending the multipart body again.

Generation boundary

OwnerGenerates
kalam schema gen --languages dartRow classes and KalamTableSpec values
kalam_sync_generator / build_runnerAction payload codecs, definitions, and queues
DriftTable row / companion types when you own a custom database
kalam_sync runtimeOne generic DML envelope for bidirectional insert / update / delete

Do not hand-write duplicate row models. Edit schema.sql, regenerate, then bind KalamTables.*.

The complete offline-first chat app, REST engine, schema, and live-server use-case tests live in the kalam_sync example. Conversations use bidirectional DML; messages stay replicaOnly and sync through generated sendMessage / markMsgRead actions.

Next

Last updated on