Custom stores
flaky-tests ships four store adapters — sqlite, turso, supabase, postgres — but none of them are hardcoded into the CLI or the plugin. A store adapter is just any package that:
- Implements
IStore(the interface exported from@flaky-tests/core). - Calls
definePlugin({ name: 'store-<type>', create })at module import time.
The dispatcher in @flaky-tests/core (createStoreFromConfig) looks up
store-<type> in a runtime registry. Adding a new backend means shipping a
new package; nothing in flaky-tests itself needs to change.
This guide walks through authoring and using a custom adapter.
Write the adapter
Section titled “Write the adapter”// @acme/store-dynamodb/src/index.tsimport type { Config, FlakyPattern, GetNewPatternsOptions, GetRecentRunsOptions, InsertFailureInput, InsertRunInput, IStore, RecentRun, UpdateRunInput,} from '@flaky-tests/core'import { definePlugin, extractMessage, parse, insertRunInputSchema, insertFailureInputSchema, updateRunInputSchema, StoreError,} from '@flaky-tests/core'
const PACKAGE = '@acme/store-dynamodb'
export class DynamoStore implements IStore { constructor(private readonly options: { tableName: string }) {}
private async wrap<T>(method: string, fn: () => Promise<T>): Promise<T> { try { return await fn() } catch (error) { throw new StoreError({ package: PACKAGE, method, message: extractMessage(error), cause: error, }) } }
async migrate(): Promise<void> { await this.wrap('migrate', async () => { /* create-if-not-exists */ }) }
async insertRun(input: InsertRunInput): Promise<void> { parse(insertRunInputSchema, input) await this.wrap('insertRun', async () => { /* put item */ }) }
async updateRun(runId: string, input: UpdateRunInput): Promise<void> { parse(updateRunInputSchema, input) await this.wrap('updateRun', async () => { /* update item */ }) }
async insertFailure(input: InsertFailureInput): Promise<void> { parse(insertFailureInputSchema, input) await this.wrap('insertFailure', async () => { /* put item */ }) }
async insertFailures(inputs: readonly InsertFailureInput[]): Promise<void> { if (inputs.length === 0) return await this.wrap('insertFailures', async () => { /* batch write */ }) }
async getNewPatterns( _options: GetNewPatternsOptions = {}, ): Promise<FlakyPattern[]> { // `_options.signal` is an AbortSignal — honour it in long queries so // callers (e.g. the CLI under a CI timeout) can cancel cleanly. return this.wrap('getNewPatterns', async () => []) }
async getRecentRuns(_options: GetRecentRunsOptions): Promise<RecentRun[]> { // Ordered by startedAt DESC, capped at `_options.limit`. Respect // `_options.project` (string | null | undefined) and `_options.signal`. return this.wrap('getRecentRuns', async () => []) }
async close(): Promise<void> { // release any pooled clients }}
// Register at module import time — this is what makes the dispatcher find it.export const dynamoStorePlugin = definePlugin({ name: 'store-dynamodb', create(config: Config): IStore { if (config.store.type !== 'dynamodb') { throw new Error( `store-dynamodb invoked with config.store.type="${config.store.type}"`, ) } return new DynamoStore({ tableName: 'flaky_tests' }) },})Test it against the contract suite
Section titled “Test it against the contract suite”Every first-party adapter runs the shared runContractTests suite from
@flaky-tests/core/test-helpers. Authoring your own adapter is no different:
// @acme/store-dynamodb/src/index.test.tsimport { runContractTests } from '@flaky-tests/core/test-helpers'import { DynamoStore } from './index'
runContractTests('dynamodb', () => new DynamoStore({ tableName: 'test' }))This asserts the IStore scenarios every built-in adapter satisfies — insert/update semantics, pattern detection, project isolation, recent-runs ordering, and AbortSignal cancellation for getNewPatterns and getRecentRuns.
Schema migrations
Section titled “Schema migrations”migrate() must be idempotent — the CLI and both plugins call it before the first query on every startup. SQLite-dialect adapters (sqlite, turso) use the shared versioned-migration runner in @flaky-tests/core: a monotonic { version, up, probe } array recorded in a schema_version bookkeeping table, with probe callbacks that let pre-existing databases adopt a baseline without re-running DDL. Postgres/Supabase adapters stick with CREATE TABLE IF NOT EXISTS and manual dashboard setup. Your adapter is free to pick whichever style fits the backend — the only hard rule is that migrate() can be called repeatedly without side effects.
Consume the adapter
Section titled “Consume the adapter”With @flaky-tests/plugin-bun (recommended)
Section titled “With @flaky-tests/plugin-bun (recommended)”Write a short preload file that imports your adapter (so it registers) and then delegates to flaky-tests’s generic preload wiring:
import '@acme/store-dynamodb' // triggers definePlugin at import timeimport { createStoreFromConfig, resolveConfig,} from '@flaky-tests/core'import { createPreload } from '@flaky-tests/plugin-bun'
const config = resolveConfig()if (!config.plugin.disabled) { // Pass YOUR file's `import()` closure so the dispatcher resolves // specifiers against your node_modules — not core's. const store = await createStoreFromConfig(config, (spec) => import(spec)) createPreload(store)}[test]preload = ["./my-preload.ts"]With FLAKY_TESTS_STORE_MODULE (no custom preload)
Section titled “With FLAKY_TESTS_STORE_MODULE (no custom preload)”If you don’t want to write a preload wrapper, tell the dispatcher to import your module directly:
FLAKY_TESTS_STORE=dynamodb \FLAKY_TESTS_STORE_MODULE=@acme/store-dynamodb \bunx flaky-testsThe dispatcher tries FLAKY_TESTS_STORE_MODULE first, falls through to the
@flaky-tests/store-<type> convention second, and throws
MissingStorePackageError if neither resolves.
How dispatch works
Section titled “How dispatch works”FLAKY_TESTS_STORE=dynamodb ↓resolveConfig() # Config { store: { type: 'dynamodb', module?: ... } } ↓createStoreFromConfig(config) ├─ 1. listRegisteredPlugins().find(d => d.name === 'store-dynamodb') │ └─ hit? → use it. (user imported @acme/store-dynamodb in their preload) ├─ 2. miss → import(config.store.module) ?? import('@flaky-tests/store-dynamodb') │ └─ each success triggers the adapter's own definePlugin call └─ 3. miss → throw MissingStorePackageError('dynamodb', '@flaky-tests/store-dynamodb')Zero hardcoded adapter names anywhere in the dispatcher. Adding a store means shipping a package; nothing upstream changes.
Checklist for adapter authors
Section titled “Checklist for adapter authors”- Implements every
IStoremethod — includinggetRecentRuns— (migrateis required even if it’s a no-op, and must be idempotent). - Honours
options.signalongetNewPatternsandgetRecentRunsby throwing anAbortErrorwhen the caller aborts. - Wraps every driver error in
StoreErrorwithpackage,method,message,cause. - Calls
parse(insertRunInputSchema, input)etc. at the top of each write method so bad input surfaces asValidationErrorbefore hitting the DB. - Validates any user-provided identifier via
validateTablePrefixif the adapter interpolates table names. - Calls
definePlugin({ name: 'store-<name>', create })at module top-level (not inside a function). - Runs
runContractTests('<name>', makeStore)and passes every scenario.
That’s the contract. The rest is up to the adapter.