import type { DurableMigrations } from '@durability/storage';
import { z } from 'zod';
import { traceStorage } from './dev.js';
export const migrationTableName = 'durability_migrations';
/**
* Ordered SQLite migrations for the `durability_calls` table owned by
* {@link Durability}. Applied automatically on construction and tracked in the
* namespaced `durability_migrations` table alongside application migrations.
*/
export const durabilityOperationMigrations = [
{
name: 'durability_0001_create_calls',
up: `
CREATE TABLE IF NOT EXISTS durability_calls (
id TEXT PRIMARY KEY,
operation TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed')),
result TEXT,
attempt INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL,
last_error TEXT,
last_error_name TEXT,
completed_at INTEGER
);
`,
down: 'DROP TABLE IF EXISTS durability_calls;',
},
{
name: 'durability_0002_pending_index',
up: `
CREATE INDEX IF NOT EXISTS durability_calls_pending_idx
ON durability_calls (status, next_attempt_at);
`,
down: 'DROP INDEX IF EXISTS durability_calls_pending_idx;',
},
{
name: 'durability_0004_calls_generation_and_created_at',
up: `
DROP INDEX IF EXISTS durability_calls_pending_idx;
ALTER TABLE durability_calls RENAME TO durability_calls_v3;
CREATE TABLE durability_calls (
id TEXT PRIMARY KEY,
operation TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed')),
result TEXT,
attempt INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL,
last_error TEXT,
last_error_name TEXT,
completed_at INTEGER,
generation_id TEXT NOT NULL DEFAULT 'legacy',
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))
);
INSERT INTO durability_calls
SELECT id, operation, payload, status, result, attempt, next_attempt_at,
last_error, last_error_name, completed_at, 'legacy:' || id,
CAST(unixepoch('subsec') * 1000 AS INTEGER)
FROM durability_calls_v3;
DROP TABLE durability_calls_v3;
CREATE INDEX durability_calls_pending_idx
ON durability_calls (status, next_attempt_at);
CREATE INDEX durability_calls_created_idx
ON durability_calls (created_at);
`,
down: `
DROP INDEX IF EXISTS durability_calls_pending_idx;
DROP INDEX IF EXISTS durability_calls_created_idx;
ALTER TABLE durability_calls RENAME TO durability_calls_v4;
CREATE TABLE durability_calls (
id TEXT PRIMARY KEY,
operation TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed')),
result TEXT,
attempt INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL,
last_error TEXT,
last_error_name TEXT,
completed_at INTEGER
);
INSERT INTO durability_calls
SELECT id, operation, payload, status, result, attempt, next_attempt_at,
last_error, last_error_name, completed_at
FROM durability_calls_v4;
DROP TABLE durability_calls_v4;
CREATE INDEX durability_calls_pending_idx
ON durability_calls (status, next_attempt_at);
`,
},
] satisfies DurableMigrations;
/**
* Ordered SQLite migrations for the `durability_alarms` table owned by
* {@link DurabilityAlarms}. Applied automatically on construction.
*/
export const durabilityNamedAlarmMigrations = [
{
name: 'durability_0003_create_alarms',
up: `
CREATE TABLE IF NOT EXISTS durability_alarms (
name TEXT PRIMARY KEY,
generation_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'failed')),
scheduled_at INTEGER NOT NULL,
next_attempt_at INTEGER NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
last_error_name TEXT
);
CREATE INDEX IF NOT EXISTS durability_alarms_pending_idx
ON durability_alarms (status, next_attempt_at);
`,
down: `
DROP INDEX IF EXISTS durability_alarms_pending_idx;
DROP TABLE IF EXISTS durability_alarms;
`,
},
{
name: 'durability_0004_alarms_generation_and_created_at',
up: `
DROP INDEX IF EXISTS durability_alarms_pending_idx;
ALTER TABLE durability_alarms RENAME TO durability_alarms_v3;
CREATE TABLE durability_alarms (
name TEXT PRIMARY KEY,
generation_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'failed')),
scheduled_at INTEGER NOT NULL,
next_attempt_at INTEGER NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
last_error_name TEXT,
created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER))
);
INSERT INTO durability_alarms
SELECT name, generation_id, status, scheduled_at, next_attempt_at,
attempt, last_error, last_error_name,
CAST(unixepoch('subsec') * 1000 AS INTEGER)
FROM durability_alarms_v3;
DROP TABLE durability_alarms_v3;
CREATE INDEX durability_alarms_pending_idx
ON durability_alarms (status, next_attempt_at);
CREATE INDEX durability_alarms_created_idx
ON durability_alarms (created_at);
`,
down: `
DROP INDEX IF EXISTS durability_alarms_pending_idx;
DROP INDEX IF EXISTS durability_alarms_created_idx;
ALTER TABLE durability_alarms RENAME TO durability_alarms_v4;
CREATE TABLE durability_alarms (
name TEXT PRIMARY KEY,
generation_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'failed')),
scheduled_at INTEGER NOT NULL,
next_attempt_at INTEGER NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
last_error_name TEXT
);
INSERT INTO durability_alarms
SELECT name, generation_id, status, scheduled_at, next_attempt_at,
attempt, last_error, last_error_name
FROM durability_alarms_v4;
DROP TABLE durability_alarms_v4;
CREATE INDEX durability_alarms_pending_idx
ON durability_alarms (status, next_attempt_at);
`,
},
] satisfies DurableMigrations;
/**
* Ordered SQLite migrations for `durability_memos`, the memoized results that
* {@link Durability} and {@link DurabilityAlarms} share. Applied by whichever
* helper is constructed first.
*/
export const durabilityMemoMigrations = [
{
name: 'durability_0012_create_memos',
up: `
CREATE TABLE IF NOT EXISTS durability_memos (
owner TEXT NOT NULL,
key TEXT NOT NULL,
generation_id TEXT NOT NULL,
memo_key TEXT NOT NULL,
result TEXT NOT NULL,
PRIMARY KEY (owner, key, generation_id, memo_key)
);
`,
down: 'DROP TABLE IF EXISTS durability_memos;',
},
] satisfies DurableMigrations;
/**
* Ordered SQLite migrations for fanout manifests and deliveries. Applied
* automatically on construction.
*/
export const durabilityFanoutMigrations = [
{
name: 'durability_0005_create_fanout',
up: `
CREATE TABLE IF NOT EXISTS durability_fanout_messages (
key TEXT PRIMARY KEY,
id TEXT NOT NULL,
payload TEXT NOT NULL,
targets TEXT NOT NULL,
remaining INTEGER NOT NULL,
seq INTEGER NOT NULL,
created_at INTEGER NOT NULL,
generation_id TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS durability_fanout_deliveries (
id TEXT PRIMARY KEY,
message_key TEXT NOT NULL,
target_id TEXT NOT NULL,
seq INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending')),
phase TEXT NOT NULL CHECK (phase IN ('delivery', 'dead_letter')),
attempt INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL,
last_error TEXT,
last_error_name TEXT,
created_at INTEGER NOT NULL,
generation_id TEXT NOT NULL,
dead_lettered_at INTEGER,
dead_letter_reason TEXT CHECK (dead_letter_reason IN ('explicit', 'exhausted'))
);
CREATE INDEX IF NOT EXISTS durability_fanout_deliveries_due_idx
ON durability_fanout_deliveries
(target_id, phase, status, attempt, seq, next_attempt_at);
CREATE INDEX IF NOT EXISTS durability_fanout_deliveries_pending_idx
ON durability_fanout_deliveries (phase, status, next_attempt_at);
CREATE INDEX IF NOT EXISTS durability_fanout_deliveries_created_idx
ON durability_fanout_deliveries (created_at);
CREATE TABLE IF NOT EXISTS durability_fanout_seq (
id INTEGER PRIMARY KEY CHECK (id = 1),
next_seq INTEGER NOT NULL
);
INSERT OR IGNORE INTO durability_fanout_seq (id, next_seq) VALUES (1, 0);
`,
down: `
DROP INDEX IF EXISTS durability_fanout_deliveries_due_idx;
DROP INDEX IF EXISTS durability_fanout_deliveries_pending_idx;
DROP INDEX IF EXISTS durability_fanout_deliveries_created_idx;
DROP TABLE IF EXISTS durability_fanout_deliveries;
DROP TABLE IF EXISTS durability_fanout_messages;
DROP TABLE IF EXISTS durability_fanout_seq;
`,
},
] satisfies DurableMigrations;
/**
* Ordered SQLite migrations for the outbound event log owned by
* {@link DurabilitySocket}. Applied automatically on construction.
*/
export const durabilitySocketMigrations = [
{
name: 'durability_0006_create_socket_events',
up: `
CREATE TABLE IF NOT EXISTS durability_socket_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL
);
`,
down: 'DROP TABLE IF EXISTS durability_socket_events;',
},
] satisfies DurableMigrations;
/**
* Ordered SQLite migrations for the retained log and its consumer cursors.
* Applied automatically on construction.
*/
export const durabilityLogMigrations = [
{
name: 'durability_0007_create_log',
up: `
CREATE TABLE IF NOT EXISTS durability_log_records (
"offset" INTEGER PRIMARY KEY,
dedup_key TEXT NOT NULL UNIQUE,
payload TEXT NOT NULL,
bytes INTEGER NOT NULL,
appended_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS durability_log_records_appended_idx
ON durability_log_records (appended_at, "offset");
CREATE TABLE IF NOT EXISTS durability_log_cursors (
consumer TEXT PRIMARY KEY,
"offset" INTEGER NOT NULL,
committed_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS durability_log_segments (
first_offset INTEGER PRIMARY KEY,
last_offset INTEGER NOT NULL,
locator TEXT NOT NULL,
bytes INTEGER NOT NULL,
flushed_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS durability_log_seq (
id INTEGER PRIMARY KEY CHECK (id = 1),
next_offset INTEGER NOT NULL,
total_bytes INTEGER NOT NULL
);
INSERT OR IGNORE INTO durability_log_seq (id, next_offset, total_bytes)
VALUES (1, 0, 0);
`,
down: `
DROP INDEX IF EXISTS durability_log_records_appended_idx;
DROP TABLE IF EXISTS durability_log_records;
DROP TABLE IF EXISTS durability_log_cursors;
DROP TABLE IF EXISTS durability_log_segments;
DROP TABLE IF EXISTS durability_log_seq;
`,
},
] satisfies DurableMigrations;
/**
* Leases let several workers consume one log in parallel without handing the
* same offsets to two of them. Applied automatically when leases are enabled.
*/
export const durabilityLogLeaseMigrations = [
{
name: 'durability_0008_create_log_leases',
up: `
CREATE TABLE IF NOT EXISTS durability_log_leases (
batch_id TEXT PRIMARY KEY,
consumer TEXT NOT NULL,
first_offset INTEGER NOT NULL,
last_offset INTEGER NOT NULL,
state TEXT NOT NULL
CHECK (state IN ('active', 'pending', 'acked', 'skipped')),
attempt INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS durability_log_leases_claim_idx
ON durability_log_leases (consumer, state, expires_at, first_offset);
CREATE INDEX IF NOT EXISTS durability_log_leases_order_idx
ON durability_log_leases (consumer, first_offset);
CREATE TABLE IF NOT EXISTS durability_log_allocations (
consumer TEXT PRIMARY KEY,
allocated_offset INTEGER NOT NULL
);
`,
down: `
DROP INDEX IF EXISTS durability_log_leases_claim_idx;
DROP INDEX IF EXISTS durability_log_leases_order_idx;
DROP TABLE IF EXISTS durability_log_leases;
DROP TABLE IF EXISTS durability_log_allocations;
`,
},
] satisfies DurableMigrations;
/**
* Ordered SQLite migrations for outbox channels. Applied automatically on
* construction.
*/
export const durabilityOutboxMigrations = [
{
name: 'durability_0006_create_outbox',
up: `
CREATE TABLE IF NOT EXISTS durability_outbox (
id TEXT PRIMARY KEY,
channel TEXT NOT NULL,
entry_key TEXT NOT NULL,
version INTEGER NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending')),
attempt INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL,
last_error TEXT,
last_error_name TEXT,
created_at INTEGER NOT NULL,
generation_id TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS durability_outbox_due_idx
ON durability_outbox (channel, status, next_attempt_at, version);
CREATE INDEX IF NOT EXISTS durability_outbox_pending_idx
ON durability_outbox (status, next_attempt_at);
CREATE INDEX IF NOT EXISTS durability_outbox_created_idx
ON durability_outbox (created_at);
CREATE TABLE IF NOT EXISTS durability_outbox_seq (
id INTEGER PRIMARY KEY CHECK (id = 1),
next_version INTEGER NOT NULL
);
INSERT OR IGNORE INTO durability_outbox_seq (id, next_version) VALUES (1, 1);
`,
down: `
DROP INDEX IF EXISTS durability_outbox_due_idx;
DROP INDEX IF EXISTS durability_outbox_pending_idx;
DROP INDEX IF EXISTS durability_outbox_created_idx;
DROP TABLE IF EXISTS durability_outbox;
DROP TABLE IF EXISTS durability_outbox_seq;
`,
},
] satisfies DurableMigrations;
/**
* Ordered SQLite migrations for semaphore grants. Applied automatically on
* construction.
*/
export const durabilitySemaphoreMigrations = [
{
name: 'durability_0007_create_semaphore',
up: `
CREATE TABLE IF NOT EXISTS durability_semaphore_grants (
grant_id TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
grp TEXT NOT NULL,
permits INTEGER NOT NULL,
claimed INTEGER NOT NULL DEFAULT 0,
released INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS durability_semaphore_grants_expires_idx
ON durability_semaphore_grants (expires_at) WHERE claimed < permits;
`,
down: `
DROP INDEX IF EXISTS durability_semaphore_grants_expires_idx;
DROP TABLE IF EXISTS durability_semaphore_grants;
`,
},
{
name: 'durability_0009_create_semaphore_leases',
up: `
CREATE TABLE IF NOT EXISTS durability_semaphore_leases (
lease_id TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
grp TEXT NOT NULL,
permits INTEGER NOT NULL,
released INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
retired INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS durability_semaphore_leases_expires_idx
ON durability_semaphore_leases (expires_at);
`,
down: `
DROP INDEX IF EXISTS durability_semaphore_leases_expires_idx;
DROP TABLE IF EXISTS durability_semaphore_leases;
`,
},
] satisfies DurableMigrations;
/**
* Ordered SQLite migrations for a semaphore client's queue. Applied
* automatically on construction.
*/
export const durabilitySemaphoreQueueMigrations = [
{
name: 'durability_0008_create_semaphore_queue',
up: `
CREATE TABLE IF NOT EXISTS durability_semaphore_queue_items (
id TEXT PRIMARY KEY,
key TEXT NOT NULL,
grp TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('queued', 'claiming', 'admitted')),
grant_id TEXT,
created_at INTEGER NOT NULL,
admitted_at INTEGER,
notified INTEGER NOT NULL DEFAULT 0,
attempt INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS durability_semaphore_queue_items_key_idx
ON durability_semaphore_queue_items (key, status, created_at, id);
CREATE INDEX IF NOT EXISTS durability_semaphore_queue_items_grant_idx
ON durability_semaphore_queue_items (grant_id, created_at, id);
CREATE INDEX IF NOT EXISTS durability_semaphore_queue_items_notify_idx
ON durability_semaphore_queue_items (next_attempt_at)
WHERE status = 'admitted' AND notified = 0;
CREATE TABLE IF NOT EXISTS durability_semaphore_queue_grants (
grant_id TEXT PRIMARY KEY,
key TEXT NOT NULL,
grp TEXT NOT NULL,
permits INTEGER NOT NULL,
claimed INTEGER NOT NULL DEFAULT 0,
confirmed INTEGER NOT NULL DEFAULT 0,
admitted INTEGER NOT NULL DEFAULT 0,
released INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS durability_semaphore_queue_grants_key_idx
ON durability_semaphore_queue_grants (key, created_at);
CREATE TABLE IF NOT EXISTS durability_semaphore_queue_keys (
key TEXT PRIMARY KEY,
next_request_at INTEGER NOT NULL,
queued INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS durability_semaphore_queue_keys_due_idx
ON durability_semaphore_queue_keys (next_request_at) WHERE queued > 0;
`,
down: `
DROP INDEX IF EXISTS durability_semaphore_queue_keys_due_idx;
DROP TABLE IF EXISTS durability_semaphore_queue_keys;
DROP INDEX IF EXISTS durability_semaphore_queue_grants_key_idx;
DROP TABLE IF EXISTS durability_semaphore_queue_grants;
DROP INDEX IF EXISTS durability_semaphore_queue_items_notify_idx;
DROP INDEX IF EXISTS durability_semaphore_queue_items_grant_idx;
DROP INDEX IF EXISTS durability_semaphore_queue_items_key_idx;
DROP TABLE IF EXISTS durability_semaphore_queue_items;
`,
},
{
name: 'durability_0010_add_semaphore_queue_leases',
up: `
ALTER TABLE durability_semaphore_queue_items ADD COLUMN lease_id TEXT;
CREATE TABLE IF NOT EXISTS durability_semaphore_queue_leases (
lease_id TEXT PRIMARY KEY,
key TEXT NOT NULL,
grp TEXT NOT NULL,
permits INTEGER NOT NULL,
drawn INTEGER NOT NULL DEFAULT 0,
released INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS durability_semaphore_queue_leases_key_idx
ON durability_semaphore_queue_leases (key, created_at);
`,
down: `
DROP INDEX IF EXISTS durability_semaphore_queue_leases_key_idx;
DROP TABLE IF EXISTS durability_semaphore_queue_leases;
ALTER TABLE durability_semaphore_queue_items DROP COLUMN lease_id;
`,
},
{
name: 'durability_0011_renew_semaphore_queue_leases',
up: `
ALTER TABLE durability_semaphore_queue_leases ADD COLUMN renew_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE durability_semaphore_queue_leases ADD COLUMN renew_ms INTEGER NOT NULL DEFAULT 30000;
ALTER TABLE durability_semaphore_queue_leases ADD COLUMN confirmed INTEGER NOT NULL DEFAULT 0;
`,
down: `
ALTER TABLE durability_semaphore_queue_leases DROP COLUMN confirmed;
ALTER TABLE durability_semaphore_queue_leases DROP COLUMN renew_ms;
ALTER TABLE durability_semaphore_queue_leases DROP COLUMN renew_at;
`,
},
] satisfies DurableMigrations;
/**
* Ordered SQLite migrations for the experimental topology coordinator.
* Publication and sync ride on outbox channels. Applied automatically on
* construction.
*/
export const durabilityTopologyMigrations = [
{
name: 'durability_0011_create_topology',
up: `
CREATE TABLE IF NOT EXISTS durability_topology_groups (
group_id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
members TEXT NOT NULL,
deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)),
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS durability_topology_states (
group_id TEXT NOT NULL,
channel TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (group_id, channel)
);
CREATE TABLE IF NOT EXISTS durability_topology_writes (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL,
land_by INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS durability_topology_writes_group_idx
ON durability_topology_writes (group_id, land_by);
`,
down: `
DROP INDEX IF EXISTS durability_topology_writes_group_idx;
DROP TABLE IF EXISTS durability_topology_writes;
DROP TABLE IF EXISTS durability_topology_states;
DROP TABLE IF EXISTS durability_topology_groups;
`,
},
] satisfies DurableMigrations;
/**
* Ordered SQLite migrations for the experimental schedule. Applied
* automatically on construction.
*/
export const durabilityScheduleMigrations = [
{
name: 'durability_0013_create_schedule',
// INTEGER PRIMARY KEY aliases the rowid, so each table is a single b-tree
// with no secondary index for per-run writes to maintain.
up: `
CREATE TABLE IF NOT EXISTS durability_schedule (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL,
expression TEXT,
next_at INTEGER
);
CREATE TABLE IF NOT EXISTS durability_schedule_runs (
scheduled_time INTEGER PRIMARY KEY,
state TEXT NOT NULL
CHECK (state IN ('queued', 'running', 'complete', 'errored')),
next_at INTEGER NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
error TEXT
);
`,
down: `
DROP TABLE IF EXISTS durability_schedule_runs;
DROP TABLE IF EXISTS durability_schedule;
`,
},
{
name: 'durability_0014_add_schedule_jitter',
up: `
ALTER TABLE durability_schedule ADD COLUMN jitter_ms INTEGER NOT NULL DEFAULT 0;
ALTER TABLE durability_schedule_runs ADD COLUMN due_at INTEGER;
UPDATE durability_schedule_runs SET due_at = scheduled_time;
`,
down: `
ALTER TABLE durability_schedule_runs DROP COLUMN due_at;
ALTER TABLE durability_schedule DROP COLUMN jitter_ms;
`,
},
] satisfies DurableMigrations;
/** Migration names changed by one migrate call. */
export type DurabilityMigrationResult = {
/** Migrations applied in ascending order. */
applied: string[];
/** Migrations reverted in descending order. */
rolledBack: string[];
};
export type MigrationCapability =
| 'operations'
| 'namedAlarms'
| 'memos'
| 'fanout'
| 'socket'
| 'log'
| 'logLeases'
| 'outbox'
| 'semaphore'
| 'semaphoreQueue'
| 'topology'
| 'schedule';
/**
* Newer library versions may leave migrations this one does not know about.
* On downgrade their stored `down` scripts are run, but only this many, so an
* accidental deploy of an ancient version cannot silently unwind a schema.
*/
const maxFutureMigrations = 2;
type MigrationStorage = Pick<DurableObjectStorage, 'sql' | 'transactionSync'>;
const historyRowSchema = z.object({
id: z.number().int(),
name: z.string(),
capability: z.string().nullable(),
down: z.string().nullable(),
});
type HistoryRow = z.infer<typeof historyRowSchema>;
/**
* Versions before 3.0 tracked the v4 rewrite of both tables under one name.
* Splitting it lets each capability migrate independently. The combined row is
* kept and both halves are recorded next to it, so a 2.x deployment reading
* the same database still sees its history as complete and never re-runs the
* rewrite.
*/
const legacyCombinedV4 = 'durability_0004_generation_and_created_at';
const legacyHalves = [
'durability_0004_calls_generation_and_created_at',
'durability_0004_alarms_generation_and_created_at',
];
/** Where one list of migrations records its history, and how errors name it. */
type HistoryScope = {
table: string;
capability: string;
label: string;
};
const ensureHistoryTable = (sql: SqlStorage, table: string): void => {
// The base shape matches the table earlier versions created through workers-qb.
sql.exec(`
CREATE TABLE IF NOT EXISTS ${table} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
`);
const columns = new Set(
sql
.exec(`SELECT name FROM pragma_table_info('${table}')`)
.toArray()
.map((row) => row['name'])
);
for (const column of ['capability', 'down']) {
if (!columns.has(column)) {
sql.exec(`ALTER TABLE ${table} ADD COLUMN ${column} TEXT`);
}
}
};
const translateLegacyHistory = (sql: SqlStorage): void => {
const legacy = sql
.exec(
`SELECT name FROM ${migrationTableName} WHERE name = ?`,
legacyCombinedV4
)
.toArray();
if (legacy.length === 0) {
return;
}
for (const name of legacyHalves) {
sql.exec(
`INSERT OR IGNORE INTO ${migrationTableName} (name) VALUES (?)`,
name
);
}
};
const readHistory = (
sql: SqlStorage,
scope: HistoryScope,
migrations: DurableMigrations
): HistoryRow[] => {
// Rows written before capabilities were tracked are claimed by the list that names them.
for (const { name } of migrations) {
sql.exec(
`UPDATE ${scope.table} SET capability = ?
WHERE name = ? AND capability IS NULL`,
scope.capability,
name
);
}
return historyRowSchema.array().parse(
sql
.exec(
`SELECT id, name, capability, down FROM ${scope.table}
WHERE capability = ? ORDER BY id`,
scope.capability
)
.toArray()
);
};
const runDown = (
sql: SqlStorage,
table: string,
name: string,
down: string | null
): void => {
if (down === null) {
throw new Error(`Migration "${name}" has no stored down script`);
}
sql.exec(down);
sql.exec(`DELETE FROM ${table} WHERE name = ?`, name);
};
/**
* Reverts migrations recorded by a newer version, newest first, using the down
* scripts it stored. Refuses when the schema is further ahead than
* {@link maxFutureMigrations}.
*/
const revertFutureMigrations = (
storage: MigrationStorage,
scope: HistoryScope,
history: HistoryRow[],
known: ReadonlySet<string>
): string[] => {
const future = history.filter(({ name }) => !known.has(name)).reverse();
if (future.length === 0) {
return [];
}
if (future.length > maxFutureMigrations) {
throw new Error(
`${scope.label} schema is ${future.length} migrations ahead of this version; refusing to revert more than ${maxFutureMigrations}`
);
}
storage.transactionSync(() => {
for (const row of future) {
runDown(storage.sql, scope.table, row.name, row.down);
}
});
return future.map(({ name }) => name);
};
const currentRowSchema = z.object({
name: z.string(),
down: z.string().nullable(),
});
/**
* Whether a capability's history already lists exactly these migrations with
* these `down` scripts, so construction can skip {@link migrateCapability}. Every Durable
* Object wake constructs its helpers, and each statement there is one more way
* for the wake to fail, so the common case costs one read.
*/
export const isSchemaCurrent = (
sql: SqlStorage,
capability: MigrationCapability,
migrations: DurableMigrations
): boolean => {
let rows: z.infer<typeof currentRowSchema>[];
try {
rows = currentRowSchema
.array()
.parse(
sql
.exec(
`SELECT name, down FROM ${migrationTableName} WHERE capability = ?`,
capability
)
.toArray()
);
} catch {
// A first construction has no history table, and an older one lacks the
// capability column; migrateCapability() creates both, and retries any other failure.
return false;
}
const known = new Map(migrations.map(({ name, down }) => [name, down]));
return (
rows.length === known.size &&
rows.every((row) => known.get(row.name) === row.down)
);
};
const migrateScope = (
storage: MigrationStorage,
scope: HistoryScope,
migrations: DurableMigrations,
target: string | null | undefined
): DurabilityMigrationResult => {
storage = traceStorage(storage);
const resolvedTarget =
target === undefined
? (migrations[migrations.length - 1]?.name ?? null)
: target;
const targetIndex =
resolvedTarget === null
? -1
: migrations.findIndex((migration) => migration.name === resolvedTarget);
if (resolvedTarget !== null && targetIndex === -1) {
throw new Error(
`Unknown ${scope.label} migration target "${resolvedTarget}"`
);
}
ensureHistoryTable(storage.sql, scope.table);
if (scope.table === migrationTableName) {
translateLegacyHistory(storage.sql);
}
const known = new Set(migrations.map(({ name }) => name));
const history = readHistory(storage.sql, scope, migrations);
const rolledBack = revertFutureMigrations(storage, scope, history, known);
const applied = new Map(
history
.filter(({ name }) => known.has(name))
.map((row) => [row.name, row] as const)
);
const toRevert = migrations
.slice(targetIndex + 1)
.filter(({ name }) => applied.has(name))
.reverse();
const toApply = migrations
.slice(0, targetIndex + 1)
.filter(({ name }) => !applied.has(name));
const toRefresh = migrations
.slice(0, targetIndex + 1)
.filter(
({ name, down }) => applied.has(name) && applied.get(name)?.down !== down
);
storage.transactionSync(() => {
for (const migration of toRevert) {
runDown(storage.sql, scope.table, migration.name, migration.down);
}
for (const migration of toApply) {
storage.sql.exec(migration.up);
storage.sql.exec(
`INSERT INTO ${scope.table} (name, capability, down) VALUES (?, ?, ?)`,
migration.name,
scope.capability,
migration.down
);
}
// Keep stored down scripts current so a downgrade runs the latest revert logic.
for (const migration of toRefresh) {
storage.sql.exec(
`UPDATE ${scope.table} SET down = ? WHERE name = ?`,
migration.down,
migration.name
);
}
});
const remaining = z
.object({ count: z.number().int() })
.parse(
storage.sql
.exec(`SELECT COUNT(*) AS count FROM ${scope.table}`)
.toArray()[0]
).count;
if (remaining === 0) {
storage.sql.exec(`DROP TABLE IF EXISTS ${scope.table}`);
}
return {
applied: toApply.map(({ name }) => name),
rolledBack: [...rolledBack, ...toRevert.map(({ name }) => name)],
};
};
/**
* Moves one capability's schema to a target migration, applying or reverting as
* needed. `null` reverts every migration in the list; the shared migration
* table is dropped once no durability history remains.
*
* Each migration's `down` is stored alongside its history row so a later
* downgrade of the library can still revert it. Batches of migrations apply or
* revert atomically.
*/
export const migrateCapability = (
storage: MigrationStorage,
capability: MigrationCapability,
migrations: DurableMigrations,
target: string | null | undefined
): DurabilityMigrationResult =>
migrateScope(
storage,
{
table: migrationTableName,
capability,
label: `Durability ${capability}`,
},
migrations,
target
);
/** Options for {@link migrate}. */
export type MigrateOptions = {
/** History table for these migrations. Defaults to `schema_migrations`. */
table?: string;
/**
* Migration to move the schema to. Defaults to the last one; `null` reverts
* every migration.
*/
target?: string | null;
};
const sqlIdentifier = /^[A-Za-z_][A-Za-z0-9_]*$/;
/**
* Moves an application's own schema to a target migration, with the same
* guarantees durability gives its tables: each batch applies or reverts in one
* synchronous transaction, every history row stores its `down` script, and a
* Durable Object running older code reverts up to two migrations it does not
* know before refusing.
*
* The history lives in its own table, apart from `durability_migrations`, and
* is dropped once no migration remains applied.
*
* @example
* ```ts
* constructor(ctx: DurableObjectState, env: Env) {
* super(ctx, env);
* migrate(ctx.storage, [
* {
* name: '0001_create_jobs',
* up: 'CREATE TABLE jobs (id TEXT PRIMARY KEY);',
* down: 'DROP TABLE jobs;',
* },
* ]);
* }
* ```
*/
export const migrate = (
storage: MigrationStorage,
migrations: DurableMigrations,
options: MigrateOptions = {}
): DurabilityMigrationResult => {
const table = options.table ?? 'schema_migrations';
if (!sqlIdentifier.test(table) || table === migrationTableName) {
throw new Error(`Invalid schema migration table "${table}"`);
}
return migrateScope(
storage,
{
table,
capability: 'schema',
label: `"${table}"`,
},
migrations,
options.target
);
};
import type { StandardSchemaV1 } from '@standard-schema/spec';
import { trace, traceFunction, traceMethods, traceStorage } from './dev.js';
import { reportFailure, serializeError } from './errors.js';
import {
durabilitySocketMigrations,
migrateCapability,
type DurabilityMigrationResult,
} from './migrations.js';
import { deserialize, serialize } from './serialization.js';
import {
parseClientFrame,
socketMaxFrameLength,
socketProtocolErrorCode,
socketProtocolVersion,
type SocketCall,
type SocketClientFrame,
type SocketInboundHandler,
type SocketInboundMap,
type SocketOutboundMap,
type SocketOutboundPayload,
type SocketServerFrame,
type SocketInboundMetadata,
type SocketShape,
socketShape,
} from './socket-protocol.js';
/** The Durable Object state a socket needs: SQLite storage and hibernatable WebSockets. */
export type DurabilitySocketContext = Pick<
DurableObjectState,
'storage' | 'acceptWebSocket' | 'getWebSockets' | 'setWebSocketAutoResponse'
>;
export type DurabilitySocketConfig<
Inbound extends SocketInboundMap,
Outbound extends SocketOutboundMap,
Metadata extends SocketInboundMetadata<Inbound>,
> = {
context: DurabilitySocketContext;
/** Methods clients may call, keyed by the names they become on the client. */
inbound: Inbound;
/** Events the object may push, keyed by the methods they become on this instance. */
outbound: Outbound;
/**
* Captures trusted per-connection data from the upgrade request. Stored in
* the WebSocket attachment, so it survives hibernation without re-auth.
*/
metadata?: (request: Request) => Metadata | Promise<Metadata>;
/** Outbound events kept for replay to reconnecting clients. Defaults to 1000. */
retention?: number;
};
/** Thrown to the caller when an inbound payload fails its schema. */
export class SocketInputError extends Error {
readonly issues: readonly StandardSchemaV1.Issue[];
constructor(method: string, issues: readonly StandardSchemaV1.Issue[]) {
super(
`Invalid input for "${method}": ${issues.map((issue) => issue.message).join('; ')}`
);
this.name = 'SocketInputError';
this.issues = issues;
}
}
type Attachment<Metadata> = {
metadata: Metadata;
names: string[];
};
type EventRow = {
seq: number;
name: string;
payload: string;
};
declare const WebSocketPair: {
new (): { 0: WebSocket; 1: WebSocket };
};
declare const WebSocketRequestResponsePair: {
new (request: string, response: string): WebSocketRequestResponsePair;
};
const reservedOutboundNames = new Set(['status', 'resync']);
const sendFrame = (ws: WebSocket, frame: SocketServerFrame): void => {
trace('socket.send', { frame });
let text = JSON.stringify(frame);
if (text.length > socketMaxFrameLength) {
if (frame.type === 'event') {
reportFailure(
'durability.socket.event_too_large',
{ name: frame.name, seq: frame.seq },
new Error('Outbound event exceeds the frame limit')
);
return;
}
text = JSON.stringify({
v: socketProtocolVersion,
type: 'error',
id: frame.id,
error: {
name: 'SocketFrameTooLargeError',
message: `Response to "${frame.type}" exceeds ${socketMaxFrameLength} characters`,
},
} satisfies SocketServerFrame);
}
try {
ws.send(text);
} catch (error) {
reportFailure('durability.socket.send_failed', { type: frame.type }, error);
}
};
const readAttachment = <Metadata>(ws: WebSocket): Attachment<Metadata> => {
const stored: unknown = ws.deserializeAttachment();
if (typeof stored !== 'object' || stored === null) {
return { metadata: undefined as Metadata, names: [] };
}
const names = Reflect.get(stored, 'names');
return {
metadata: Reflect.get(stored, 'metadata') as Metadata,
names: Array.isArray(names)
? names.filter((name): name is string => typeof name === 'string')
: [],
};
};
class DurabilitySocketCore<
Inbound extends SocketInboundMap,
Outbound extends SocketOutboundMap,
Metadata extends SocketInboundMetadata<Inbound>,
> {
declare readonly [socketShape]: SocketShape<Inbound, Outbound>;
readonly #context: DurabilitySocketContext;
readonly #storage: DurableObjectStorage;
readonly #inbound: Map<string, SocketInboundHandler<never>>;
readonly #outboundNames: Set<string>;
readonly #metadata: DurabilitySocketConfig<
Inbound,
Outbound,
Metadata
>['metadata'];
readonly #retention: number;
constructor(config: DurabilitySocketConfig<Inbound, Outbound, Metadata>) {
const retention = config.retention ?? 1_000;
if (!Number.isSafeInteger(retention) || retention <= 0) {
throw new RangeError('retention must be a positive safe integer');
}
this.#context = config.context;
this.#storage = traceStorage(config.context.storage);
// Only own keys are callable; nothing on the prototype chain is reachable.
this.#inbound = new Map(Object.entries(config.inbound));
this.#metadata = config.metadata;
this.#retention = retention;
this.#outboundNames = new Set(Object.keys(config.outbound));
const reserved = new Set([
...Object.getOwnPropertyNames(DurabilitySocketCore.prototype),
...reservedOutboundNames,
]);
for (const name of this.#outboundNames) {
if (reserved.has(name)) {
throw new Error(`"${name}" is reserved by DurabilitySocket`);
}
Object.defineProperty(this, name, {
value: traceFunction(`DurabilitySocket.${name}`, (payload: unknown) =>
this.#publish(name, payload)
),
enumerable: true,
});
}
migrateCapability(
this.#storage,
'socket',
durabilitySocketMigrations,
undefined
);
// Keep-alive pings are answered by the runtime without waking a hibernated object.
this.#context.setWebSocketAutoResponse(
new WebSocketRequestResponsePair(
JSON.stringify({ v: socketProtocolVersion, type: 'ping' }),
JSON.stringify({ v: socketProtocolVersion, type: 'pong' })
)
);
}
/** Applies or reverts the `durability_socket_events` schema. */
static migrate(
context: Pick<DurableObjectState, 'storage'>,
target?: string | null
): DurabilityMigrationResult {
return migrateCapability(
context.storage,
'socket',
durabilitySocketMigrations,
target
);
}
/** Accepts a WebSocket upgrade; call from the Durable Object's `fetch`. */
async fetch(request: Request): Promise<Response> {
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
return new Response('Expected a WebSocket upgrade', { status: 426 });
}
const metadata = await this.#metadata?.(request);
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
this.#context.acceptWebSocket(server);
server.serializeAttachment({
metadata,
names: [],
} satisfies Attachment<Metadata | undefined>);
return new Response(null, { status: 101, webSocket: client });
}
/** Handles one inbound frame; call from the Durable Object's `webSocketMessage`. */
async webSocketMessage(
ws: WebSocket,
message: string | ArrayBuffer
): Promise<void> {
const text =
typeof message === 'string' ? message : new TextDecoder().decode(message);
const frame = parseClientFrame(text);
if (!frame) {
trace('socket.close', {
code: socketProtocolErrorCode,
reason: 'Invalid frame',
});
ws.close(socketProtocolErrorCode, 'Invalid frame');
return;
}
if (frame.type === 'subscribe') {
this.#subscribe(ws, frame);
return;
}
await this.#call(ws, frame);
}
/**
* Call from the Durable Object's `webSocketClose`. Subscriptions live in the
* socket's attachment, so nothing needs cleaning up; the runtime requires
* the method to exist for hibernatable WebSockets.
*/
webSocketClose(_ws: WebSocket): void {}
async #call(
ws: WebSocket,
frame: Extract<SocketClientFrame, { type: 'call' }>
): Promise<void> {
const handler = this.#inbound.get(frame.method);
if (!handler) {
sendFrame(ws, {
v: socketProtocolVersion,
type: 'error',
id: frame.id,
error: {
name: 'SocketUnknownMethodError',
message: `Unknown method "${frame.method}"`,
},
});
return;
}
const call: SocketCall<never> = {
metadata: readAttachment<never>(ws).metadata,
};
try {
const output =
typeof handler === 'function'
? await handler(frame.input, call)
: await handler.handle(
(await this.#validate(
frame.method,
handler.input,
frame.input
)) as never,
call
);
sendFrame(ws, {
v: socketProtocolVersion,
type: 'result',
id: frame.id,
output,
});
} catch (error) {
sendFrame(ws, {
v: socketProtocolVersion,
type: 'error',
id: frame.id,
error: serializeError(error),
});
}
}
async #validate(
method: string,
schema: StandardSchemaV1,
input: unknown
): Promise<unknown> {
const result = await schema['~standard'].validate(input);
if (result.issues) {
throw new SocketInputError(method, result.issues);
}
return result.value;
}
#subscribe(
ws: WebSocket,
frame: Extract<SocketClientFrame, { type: 'subscribe' }>
): void {
const unknown = frame.names.find((name) => !this.#outboundNames.has(name));
if (unknown !== undefined) {
sendFrame(ws, {
v: socketProtocolVersion,
type: 'error',
id: frame.id,
error: {
name: 'SocketUnknownEventError',
message: `Unknown event "${unknown}"`,
},
});
return;
}
const attachment = readAttachment<Metadata>(ws);
ws.serializeAttachment({
...attachment,
names: frame.names,
} satisfies Attachment<Metadata>);
const head = this.#headSeq();
const replayed = this.#replay(ws, frame.names, frame.since, head);
sendFrame(ws, {
v: socketProtocolVersion,
type: 'subscribed',
id: frame.id,
seq: head,
replayed,
});
}
/**
* Replays retained events after `since` and reports whether the client is
* now caught up. A client ahead of the log or behind its oldest row must
* rebuild its state from a fresh snapshot instead.
*/
#replay(
ws: WebSocket,
names: string[],
since: number | undefined,
head: number
): boolean {
if (since === undefined || since > head) {
return false;
}
if (since === head) {
return true;
}
const oldest = this.#storage.sql
.exec<{ seq: number }>(
'SELECT MIN(seq) AS seq FROM durability_socket_events'
)
.one().seq;
if (since + 1 < oldest) {
return false;
}
const rows = this.#storage.sql
.exec<EventRow>(
'SELECT seq, name, payload FROM durability_socket_events WHERE seq > ? ORDER BY seq',
since
)
.toArray();
const wanted = new Set(names);
for (const row of rows) {
if (!wanted.has(row.name)) {
continue;
}
sendFrame(ws, {
v: socketProtocolVersion,
type: 'event',
seq: row.seq,
name: row.name,
payload: deserialize(row.payload),
});
}
return true;
}
#headSeq(): number {
const row = this.#storage.sql
.exec<{ seq: number | null }>(
'SELECT MAX(seq) AS seq FROM durability_socket_events'
)
.one();
return row.seq ?? 0;
}
#publish(name: string, payload: unknown): void {
const serialized = serialize(payload);
const sql = this.#storage.sql;
const { seq } = sql
.exec<{ seq: number }>(
'INSERT INTO durability_socket_events (name, payload, created_at) VALUES (?, ?, ?) RETURNING seq',
name,
serialized,
Date.now()
)
.one();
sql.exec(
'DELETE FROM durability_socket_events WHERE seq <= ?',
seq - this.#retention
);
const frame: SocketServerFrame = {
v: socketProtocolVersion,
type: 'event',
seq,
name,
payload,
};
for (const ws of this.#context.getWebSockets()) {
if (ws.readyState !== WebSocket.OPEN) {
continue;
}
if (!readAttachment(ws).names.includes(name)) {
continue;
}
sendFrame(ws, frame);
}
}
}
type OutboundMethods<Outbound extends SocketOutboundMap> = {
[Name in keyof Outbound]: (
payload: SocketOutboundPayload<Outbound[Name]>
) => void;
};
/**
* Typed WebSocket RPC and event push for one Durable Object, over hibernatable
* WebSockets.
*
* Every `outbound` event becomes a method on the instance that persists the
* event for replay and pushes it to subscribed clients. Clients call `inbound`
* handlers through `durability/client`.
*
* @example
* ```ts
* class TodoList extends DurableObject<Env> {
* readonly socket = new DurabilitySocket({
* context: this.ctx,
* inbound: {
* list: () => this.repo.list(),
* create: {
* input: TodoSchema,
* handle: async (todo: Todo) => {
* await this.repo.insert(todo);
* this.socket.created(todo);
* },
* },
* },
* outbound: { created: event<Todo>() },
* });
*
* fetch(request: Request) {
* return this.socket.fetch(request);
* }
*
* webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
* return this.socket.webSocketMessage(ws, message);
* }
*
* webSocketClose(ws: WebSocket) {
* this.socket.webSocketClose(ws);
* }
* }
* ```
*/
export type DurabilitySocket<
Inbound extends SocketInboundMap,
Outbound extends SocketOutboundMap,
Metadata extends SocketInboundMetadata<Inbound> =
SocketInboundMetadata<Inbound>,
> = DurabilitySocketCore<Inbound, Outbound, Metadata> &
OutboundMethods<Outbound>;
export interface DurabilitySocketConstructor {
new <
Inbound extends SocketInboundMap,
Outbound extends SocketOutboundMap,
Metadata extends SocketInboundMetadata<Inbound> =
SocketInboundMetadata<Inbound>,
>(
config: DurabilitySocketConfig<Inbound, Outbound, Metadata>
): DurabilitySocket<Inbound, Outbound, Metadata>;
migrate: typeof DurabilitySocketCore.migrate;
}
traceMethods(DurabilitySocketCore.prototype, 'DurabilitySocket');
// TypeScript cannot declare class members derived from a generic event map,
// so the constructor is typed separately; the constructor installs and
// reserves every outbound method at runtime.
export const DurabilitySocket =
DurabilitySocketCore as unknown as DurabilitySocketConstructor;
export { durabilitySocketMigrations } from './migrations.js';
export {
event,
type SocketCall,
type SocketInboundHandler,
type SocketInboundMap,
type SocketOutboundEvent,
type SocketOutboundMap,
} from './socket-protocol.js';