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 teardowncommit(stream, msgs, meta, expectedVersion?)โ append events atomically with optimistic concurrencyquery(callback, query?)โ stream events to a callback with filter, range, regex, andwith_snapssupportclaim(lagging, leading, by, millis, lane?)โ atomically discover and lease streams for reaction processing (the workhorse ofdrain); optionallanefilter for ACT-1103 drain lanessubscribe(streams)โ register streams so they become claimable; each row carries optionallanethat the adapter UPSERTs on every call (restart-driven re-laning)ack(leases)/block(leases)โ release a lease normally or after persistent failurereset(streams)/prioritize(filter, n)/truncate(targets)โ operator-facing primitives; theStreamFiltershape carries an optionallaneexact-matchquery_streams(callback, query?)โ read-only introspection (operational dashboards); positions carry theirlanenotify(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):
- The matching cases land in
libs/act-tck/src/store-tck.ts. - New optional methods are gated behind a
Capabilitiesflag so existing adapters keep passing until they opt in. - 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โ
- The contract itself:
libs/act/src/types/ports.ts - Existing adapters as reference implementations:
- TCK source:
libs/act-tck/src/store-tck.ts - Bootstrapping a new
/libspackage end-to-end: contributing-new-package.md