Data, safety and extensions
Storage
Persist durable workflows, memory, budgets and jobs in SQLite or PostgreSQL, run migrations, and back the store up.
Agents and ephemeral runs need no database. You add storage when something has to survive a restart: durable workflows, native memory, durable budgets, scheduled jobs and the server's submission journal. One store object holds all of them. Mayura ships two adapters, SQLite and PostgreSQL, with the same API and the same on-disk format version, so you can develop on SQLite and deploy on PostgreSQL without changing application code.
import { createSqliteStore } from 'mayura/storage-sqlite';
const store = createSqliteStore({ filename: './data/app.sqlite' });
await store.initialize();
try {
// Hand `store` to workflow runtimes, memory, budgets and your server.
} finally {
await store.close();
}Creating a store is synchronous and opens nothing. initialize() connects, creates Mayura's tables if they are
missing and checks the schema version; call it once before anything uses the store. close() is yours to call when the
process shuts down. Runtimes and memory that receive the store never close it for you.
Choose a database
| You need | Import | Install alongside mayura |
|---|---|---|
| SQLite: local development, tests, a single host | mayura/storage-sqlite |
better-sqlite3 |
| PostgreSQL: several servers and workers sharing state | mayura/storage-postgres |
pg |
| Both factories from one import (existing apps) | mayura/storage |
better-sqlite3 and pg |
Your own adapter, types and StorageError only |
mayura/storage-contracts |
nothing |
The database drivers are optional peer dependencies: install only the one you use.
npm install mayura better-sqlite3SQLite
createSqliteStore({ filename }) takes one option, the database file path. The parent directory must already exist.
The store runs the database on its own worker thread, uses write-ahead logging with full synchronization, and queues up
to 256 pending requests; beyond that a call fails with QUEUE_FULL and you should retry with backoff.
Use filename: ':memory:' for tests and throwaway scripts. Everything disappears when the store closes, and an
in-memory store cannot be backed up.
PostgreSQL
import { createPostgresStore } from 'mayura/storage-postgres';
const store = createPostgresStore({ connectionString: process.env.DATABASE_URL ?? '', schema: 'mayura' });
await store.initialize();| Option | Default | Notes |
|---|---|---|
connectionString |
required | A standard PostgreSQL connection URL. Keep it in your secret configuration. |
schema |
mayura |
Lowercase identifier, at most 63 characters. All of Mayura's tables live in this schema. |
pool |
see below | { max, connectionTimeoutMs, idleTimeoutMs } for the connection pool. |
The pool keeps up to max connections (default 8, at most 100), waits up to connectionTimeoutMs for one (default
5,000) and closes one that has been idle for idleTimeoutMs (default 10,000). On serverless functions, where each
instance has its own pool, use pool: { max: 1 } or 2 and connect through your provider's pooler. Mayura keeps no
session state between transactions: each operation is one transaction, with SET LOCAL settings and
transaction-scoped locks only, which is what transaction-mode poolers need.
The adapter sets a 10 second statement timeout and a 5 second lock timeout on every transaction. initialize() takes an advisory lock, so several instances can start at the same time safely. A
schema keeps one application's data apart from another's; it is not an authorization boundary.
What uses the store
| Feature | How it uses the store | Extra setup |
|---|---|---|
| Durable workflows | createScheduledWorkflowRuntime({ store }) and the lifecycle hosts |
None: the runtime creates its tables on first use |
| Native memory | createNativeMemory({ store }) |
await store.memory.initialize() once |
| Durable budgets | store.durableBudgets |
await store.durableBudgets.initialize() once |
| Scheduled jobs | store.scheduler, a low-level leased job queue |
await store.scheduler.initialize() once |
| Server submission journal | createAggregateSubmissionJournal(store) from mayura/storage-contracts |
None |
| Server run records, for several server replicas | createAggregateRunRecords(store) from mayura/storage-contracts |
None |
A typical application opens one store and shares it:
import { createNativeMemory } from 'mayura/memory';
import { createSqliteStore } from 'mayura/storage-sqlite';
const store = createSqliteStore({ filename: './data/app.sqlite' });
await store.initialize();
// Native memory keeps its own tables; create them after the base schema.
await store.memory.initialize();
const memory = createNativeMemory({
store,
scope: { principalId: 'customer-42', projectId: 'support' },
permissions: { allow: ['memory:read', 'memory:write'] },
});Workflow runtimes and memory take a scope (a principal and a project), so one store can serve many users and projects.
Migrations
Every store records a schema version. Version 1 is the baseline for Mayura 1.x. Both adapters refuse to open a store at any other version, so an older release can never write into a newer layout, and upgrading the package never rewrites your data by itself. A release that changes the layout ships an explicit, one-way migration that you run as its own deployment step, before any new server or worker starts.
Your application module owns that step. mayura serve, mayura worker and mayura migrate all load the same module,
which default-exports its entry points:
import { defineMayuraApplication } from 'mayura/cli';
import { createPostgresStore } from 'mayura/storage-postgres';
const store = createPostgresStore({ connectionString: process.env.DATABASE_URL ?? '' });
let ready: Promise<void> | undefined;
const open = () => (ready ??= store.initialize());
export default defineMayuraApplication({
// Bring the store to the version this release needs and return a JSON report.
async migrate() { await open(); return { schemaVersion: 1 }; },
async shutdown() { await store.close(); },
});npx mayura migrate --app ./dist/app.jsThe CLI calls migrate() once, reports its result, then calls shutdown(). Nothing else starts. Take and verify a
backup before migrating: migrations cannot be reversed. See CLI: serve, worker and migrate for the
server and worker entry points.
Workflow runs are a separate concern from the storage layout. Changing a workflow definition needs its own migration policy; see workflow operations.
Back up and restore
SQLite
backupSqliteStore copies a live store through SQLite's online backup API from a separate read-only connection. The
copy is consistent while the application keeps writing, and it is checked (integrity and schema version) before the call
succeeds. The destination must not exist yet.
import { backupSqliteStore, restoreSqliteBackup } from 'mayura/storage-sqlite';
const report = await backupSqliteStore({ filename: '/data/app.sqlite', destination: '/backups/app-2026-09-28.sqlite' });
console.log(report.pages, report.schemaVersion);
// Later, with every process that uses the store stopped:
await restoreSqliteBackup({ backup: '/backups/app-2026-09-28.sqlite', filename: '/data/app.sqlite' });Stop every process that uses the store before you restore. The restore verifies the backup again, discards the target's write-ahead log so no newer pages can replay over the restored state, and atomically replaces the file.
PostgreSQL
Each store lives in one schema, so back it up with a custom-format dump of that schema and restore it with
pg_restore. pg_dump takes a consistent snapshot without stopping the application.
pg_dump -Fc -n mayura -f mayura.dump "$DATABASE_URL"
createdb mayura_restored
pg_restore --exit-on-error -d mayura_restored mayura.dumpPoint the application at the restored database, run mayura migrate, then start servers and workers.
After any restore, work that happened after the backup is missing from the store. If a run had external effects in that window (sent an email, charged a card), reconcile them from the external system rather than replaying the run. Test your restores on a schedule: a backup you have never restored is not a backup yet.
Errors
Stores throw StorageError, which is a MayuraError: one catch (error) { if (error instanceof MayuraError) ... }
handles storage and workflow failures alike. code is the general code every Mayura API uses, and storageCode names
the exact storage condition. Messages never contain driver text, SQL or credentials.
storageCode |
code |
What happened, and what to do |
|---|---|---|
CONFLICT |
CONFLICT |
The record changed after it was read, or an id or idempotency key already holds other content. Read it again and retry, or use a new key. |
NOT_FOUND |
NOT_FOUND |
No such record in this scope. |
INVALID_INPUT |
INVALID_INPUT |
The command was malformed or out of bounds. |
STORE_NOT_INITIALIZED |
INVALID_CONFIG |
Call await store.initialize() before using the store. |
STORE_CLOSED |
STORAGE_UNAVAILABLE |
The store was closed. Open a new one. |
QUEUE_FULL, LIMIT_EXCEEDED |
LIMIT_EXCEEDED |
Too much at once. Retry with backoff. |
STALE_CLAIM |
CONFLICT |
A worker's lease on a job expired or moved to another worker. |
SCHEDULED_WRITER_REQUIRED |
CONFLICT |
The run belongs to its scheduled workflow writer; change it through that runtime. |
STORAGE_UNAVAILABLE |
STORAGE_UNAVAILABLE |
The database could not confirm the operation. Check the run before retrying anything with effects. |
import { MayuraError } from 'mayura';
import { isStorageError } from 'mayura/storage-contracts';
try {
await store.initialize();
} catch (error) {
if (isStorageError(error, 'STORAGE_UNAVAILABLE')) {
// The database is unreachable.
} else if (error instanceof MayuraError) {
console.error(error.code, error.message);
}
throw error;
}Workflow runtimes keep these codes. They report a race they lost as CONFLICT (read the run and retry), pass a closed,
uninitialized or busy store through unchanged, and report any other storage failure as STORAGE_UNAVAILABLE, telling
you to inspect the run before retrying.
Custom adapters
mayura/storage-contracts holds the driver-free contracts: the AggregateStore interface (initialize, create,
read, update, events, close, optional migrate), the per-feature capability interfaces, their validators and
StorageError. Import types and StorageError from here when your code should not depend on a specific database. An
adapter throws new StorageError(storageCode, message) with one of the storage codes above.
A new adapter must implement every capability the features you use rely on (durable workflows need the scheduler and
workflow capabilities, native memory needs memory), with the same transactional guarantees. That is a substantial
piece of work. For another SQL database, start from the shared SQL engine in mayura/storage-sql/host, which both
built-in adapters use.
The both-adapter facade
mayura/storage re-exports createSqliteStore, createPostgresStore, the SQLite backup helpers and everything in
mayura/storage-contracts. It exists for applications written before the adapters were split and requires both
drivers to be installed. New applications should import the one adapter they use. Switching imports needs no data
migration: the same file or schema works with either import.
Good to know
- Storage methods are application APIs. Never expose them directly to a model or an untrusted client.
- If a SQLite worker stops unexpectedly, pending calls fail with
STORAGE_UNAVAILABLE. Reopen the store and check any writes whose result you did not see. - Calls after
close()fail withstorageCodeSTORE_CLOSED; calls beforeinitialize()fail withSTORE_NOT_INITIALIZED.