Skip to main content

Writing a custom Store adapter

Store is the persistence port of the framework โ€” every event log, lease table, projection watermark, and stream subscription lives behind it. The shipped adapters are InMemoryStore, @rotorsoft/act-pg (Postgres), and @rotorsoft/act-sqlite (libSQL). If you need another backend (MySQL, MongoDB, DynamoDB, EventStoreDB-as-Act-store, etc.), this guide walks through scaffolding one against the executable contract defined by @rotorsoft/act-tck.

The contractโ€‹

The interface lives in libs/act/src/types/ports.ts:

  • seed() / drop() โ€” initialization and teardown
  • commit(stream, msgs, meta, expectedVersion?) โ€” append events atomically with optimistic concurrency
  • query(callback, query?) โ€” stream events to a callback with filter, range, regex, and with_snaps support
  • claim(lagging, leading, by, millis, lane?) โ€” atomically discover and lease streams for reaction processing (the workhorse of drain); optional lane filter for ACT-1103 drain lanes
  • subscribe(streams) โ€” register streams so they become claimable; each row carries optional lane that the adapter UPSERTs on every call (restart-driven re-laning)
  • ack(leases) / block(leases) โ€” release a lease normally or after persistent failure
  • reset(streams) / prioritize(filter, n) / truncate(targets) โ€” operator-facing primitives; the StreamFilter shape carries an optional lane exact-match
  • query_streams(callback, query?) โ€” read-only introspection (operational dashboards); positions carry their lane
  • notify(handler) โ€” optional cross-process commit notifications

Reading the JSDoc on each method is the first step. The TCK is the second.

The TCK is the specโ€‹

@rotorsoft/act-tck exports runStoreTck, a function you drop into your adapter's vitest suite:

// libs/act-mysql/test/store-tck.spec.ts
import { runStoreTck } from "@rotorsoft/act-tck";
import { MysqlStore } from "../src/index.js";

runStoreTck({
name: "MysqlStore",
factory: () =>
new MysqlStore({
host: "localhost",
database: "act_tck",
// โ€ฆ adapter-specific config
}),
capabilities: {
notify: false, // turn on once you implement Store.notify
},
});

That single call runs 29+ contract cases against your adapter โ€” every method on Store, every documented behavior, every error mode. If it passes, your adapter honors the contract every other piece of the framework relies on.

Adapter-specific tests (e.g., dialect-specific error paths, transaction edge cases, performance smoke tests) stay in their own files. The TCK only asserts what every Store must do.

Capabilities flagsโ€‹

Some methods are optional. Store.notify is the only one today โ€” it's a cross-process wakeup hook implemented by Postgres' LISTEN/NOTIFY and skipped by single-node adapters like SQLite.

runStoreTck({
name: "MysqlStore",
factory: () => new MysqlStore({ /* โ€ฆ */ }),
capabilities: { notify: true }, // your adapter implements notify
});

When notify: true, the TCK runs a structural smoke test (subscribe โ†’ dispose) to confirm the optional API is present and well-shaped. Cross-process LISTEN/NOTIFY semantics need two processes and stay in your adapter's own tests.

Scaffolding @rotorsoft/act-mysql (worked example)โ€‹

libs/act-mysql/
โ”œโ”€โ”€ package.json # peerDeps: @rotorsoft/act, zod; devDeps: @rotorsoft/act-tck
โ”œโ”€โ”€ tsconfig.json
โ”œโ”€โ”€ tsconfig.build.json
โ”œโ”€โ”€ tsup.config.ts
โ”œโ”€โ”€ src/
โ”‚ โ”œโ”€โ”€ index.ts # export { MysqlStore }
โ”‚ โ””โ”€โ”€ mysql-store.ts # implements Store
โ”œโ”€โ”€ test/
โ”‚ โ”œโ”€โ”€ store-tck.spec.ts # runStoreTck({ factory: () => new MysqlStore(โ€ฆ) })
โ”‚ โ””โ”€โ”€ store.error.spec.ts # MySQL-specific error paths
โ””โ”€โ”€ README.md

The package.json mirrors @rotorsoft/act-pg:

{
"name": "@rotorsoft/act-mysql",
"type": "module",
"peerDependencies": {
"@rotorsoft/act": ">=0.39.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@rotorsoft/act-tck": "workspace:^"
// mysql client lib of your choice
}
}

The README's testing section shows the TCK invocation so users can verify the adapter still passes the contract after upgrading:

## Testing

The Postgres store is validated against `@rotorsoft/act-tck`:

```ts
import { runStoreTck } from "@rotorsoft/act-tck";
import { MysqlStore } from "@rotorsoft/act-mysql";

runStoreTck({
name: "MysqlStore",
factory: () => new MysqlStore({ host: "localhost", database: "act_tck" }),
});
```

When the Store port changesโ€‹

The TCK and the interface evolve together. When the framework adds, removes, or changes a method on Store (e.g., the Store.query_stats(input, options) primitive added in #639 / #752):

  1. The matching cases land in libs/act-tck/src/store-tck.ts.
  2. New optional methods are gated behind a Capabilities flag so existing adapters keep passing until they opt in.
  3. Each shipped adapter updates its own implementation; this guide is updated alongside.

Watching the TCK changelog for breaking changes is the simplest way to keep a third-party adapter in lockstep with the framework.

Cross-referencesโ€‹