blob: 7f32b695835934cd5b8e6066a2de6c45fce956eb [file]
import type { DatabaseSync } from 'node:sqlite';
export const SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION = 3;
const SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS = 5_000;
const SQLITE_INITIALIZATION_RETRY_DELAY_MS = 10;
const initializationRetryGate = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
export type SqliteLongTermMemoryMigrationFailpoint = 'after_schema_sql';
export interface SqliteLongTermMemoryMigrationOptions {
readonly failpoint?: (point: SqliteLongTermMemoryMigrationFailpoint) => void;
}
const MIGRATIONS: ReadonlyMap<number, string> = new Map([
[
1,
`
CREATE TABLE memory_items (
item_id TEXT PRIMARY KEY,
version INTEGER NOT NULL CHECK (version >= 1),
content TEXT NOT NULL CHECK (length(content) > 0),
kind TEXT NOT NULL CHECK (
kind IN ('preference', 'identity', 'context', 'knowledge', 'failure', 'note')
),
statement_type TEXT NOT NULL CHECK (statement_type IN ('fact', 'plan', 'prediction')),
temporal_type TEXT NOT NULL CHECK (
temporal_type IN ('undated', 'point', 'interval', 'open_ended')
),
scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'workspace')),
scope_key TEXT,
event_started_at INTEGER,
event_ended_at INTEGER,
observed_at INTEGER NOT NULL CHECK (observed_at >= 0),
lifecycle_state TEXT NOT NULL CHECK (lifecycle_state IN ('active', 'archived')),
origin TEXT NOT NULL CHECK (origin IN ('agent_extracted', 'user_requested')),
content_hash TEXT NOT NULL CHECK (length(content_hash) = 64),
created_at INTEGER NOT NULL CHECK (created_at >= 0),
updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
CHECK (
(scope_type = 'global' AND scope_key IS NULL)
OR
(scope_type = 'workspace' AND scope_key IS NOT NULL AND length(scope_key) > 0)
),
CHECK (
(temporal_type = 'undated'
AND event_started_at IS NULL
AND event_ended_at IS NULL)
OR
(temporal_type = 'point'
AND event_started_at IS NOT NULL
AND event_started_at >= 0
AND (event_ended_at IS NULL OR event_ended_at > event_started_at))
OR
(temporal_type = 'interval'
AND event_started_at IS NOT NULL
AND event_started_at >= 0
AND event_ended_at IS NOT NULL
AND event_ended_at > event_started_at)
OR
(temporal_type = 'open_ended'
AND event_started_at IS NOT NULL
AND event_started_at >= 0
AND event_ended_at IS NULL)
),
CHECK (created_at <= updated_at),
CHECK (observed_at <= updated_at)
);
CREATE INDEX memory_items_by_scope_and_lifecycle
ON memory_items(scope_type, scope_key, lifecycle_state, updated_at DESC, item_id);
CREATE TABLE memory_item_keys (
item_id TEXT NOT NULL,
key_text TEXT NOT NULL CHECK (length(key_text) > 0),
normalized_key TEXT NOT NULL CHECK (length(normalized_key) > 0),
key_type TEXT NOT NULL CHECK (key_type IN ('exact', 'entity', 'concept', 'alias', 'code')),
key_origin TEXT NOT NULL CHECK (key_origin IN ('deterministic', 'llm', 'user')),
PRIMARY KEY(item_id, normalized_key),
FOREIGN KEY(item_id) REFERENCES memory_items(item_id) ON DELETE CASCADE
) WITHOUT ROWID;
CREATE INDEX memory_item_keys_by_normalized_key
ON memory_item_keys(normalized_key, item_id);
CREATE TABLE memory_item_sources (
item_id TEXT NOT NULL,
session_id TEXT NOT NULL CHECK (length(session_id) > 0),
run_id TEXT NOT NULL CHECK (length(run_id) > 0),
turn_id TEXT NOT NULL CHECK (length(turn_id) > 0),
event_id TEXT NOT NULL CHECK (length(event_id) > 0),
PRIMARY KEY(item_id, event_id),
FOREIGN KEY(item_id) REFERENCES memory_items(item_id) ON DELETE CASCADE
) WITHOUT ROWID;
CREATE INDEX memory_item_sources_by_event
ON memory_item_sources(event_id, item_id);
CREATE INDEX memory_item_sources_by_turn
ON memory_item_sources(session_id, turn_id, item_id);
CREATE TABLE memory_write_operations (
operation_id TEXT PRIMARY KEY,
operation_type TEXT NOT NULL CHECK (
operation_type IN ('create', 'update', 'archive', 'restore', 'batch')
),
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
result_json TEXT NOT NULL,
committed_at INTEGER NOT NULL CHECK (committed_at >= 0)
);
`,
],
[
2,
`
CREATE TABLE memory_extraction_cursors (
session_id TEXT PRIMARY KEY CHECK (length(session_id) > 0),
processed_ordinal INTEGER NOT NULL CHECK (processed_ordinal > 0),
updated_at INTEGER NOT NULL CHECK (updated_at >= 0)
);
CREATE TABLE memory_extraction_receipts (
operation_id TEXT PRIMARY KEY CHECK (length(operation_id) > 0),
session_id TEXT NOT NULL CHECK (length(session_id) > 0),
request_hash TEXT NOT NULL CHECK (length(request_hash) = 64),
result_json TEXT NOT NULL,
committed_at INTEGER NOT NULL CHECK (committed_at >= 0),
FOREIGN KEY (operation_id) REFERENCES memory_write_operations(operation_id) ON DELETE CASCADE
);
`,
],
[
3,
`
CREATE TABLE memory_extraction_failures (
session_id TEXT PRIMARY KEY CHECK (length(session_id) > 0),
from_ordinal INTEGER NOT NULL CHECK (from_ordinal > 0),
through_ordinal INTEGER NOT NULL CHECK (through_ordinal >= from_ordinal),
coverage_hash TEXT NOT NULL CHECK (length(coverage_hash) = 64),
first_operation_id TEXT NOT NULL UNIQUE CHECK (length(first_operation_id) > 0),
first_trigger TEXT NOT NULL CHECK (first_trigger IN ('remember', 'extract')),
first_failure_class TEXT NOT NULL CHECK (
first_failure_class IN (
'provider', 'schema', 'evidence', 'localization', 'requested_admission'
)
),
failed_at INTEGER NOT NULL CHECK (failed_at >= 0)
);
`,
],
]);
interface MinimumTableShape {
readonly name: string;
readonly requiredColumns: readonly string[];
}
interface MinimumIndexShape {
readonly name: string;
readonly tableName: string;
readonly requiredColumnPrefix: readonly string[];
}
interface MinimumSchemaShape {
readonly tables: readonly MinimumTableShape[];
readonly indexes: readonly MinimumIndexShape[];
}
// Each entry describes the complete minimum shape required by that schema version. Extra
// columns and indexes are allowed so additive migrations do not fail exact-DDL validation.
const VERSION_1_MINIMUM_SCHEMA_SHAPE: MinimumSchemaShape = {
tables: [
{
name: 'memory_items',
requiredColumns: [
'item_id',
'version',
'content',
'kind',
'statement_type',
'temporal_type',
'scope_type',
'scope_key',
'event_started_at',
'event_ended_at',
'observed_at',
'lifecycle_state',
'origin',
'content_hash',
'created_at',
'updated_at',
],
},
{
name: 'memory_item_keys',
requiredColumns: ['item_id', 'key_text', 'normalized_key', 'key_type', 'key_origin'],
},
{
name: 'memory_item_sources',
requiredColumns: ['item_id', 'session_id', 'run_id', 'turn_id', 'event_id'],
},
{
name: 'memory_write_operations',
requiredColumns: [
'operation_id',
'operation_type',
'request_hash',
'result_json',
'committed_at',
],
},
],
indexes: [
{
name: 'memory_item_keys_by_normalized_key',
tableName: 'memory_item_keys',
requiredColumnPrefix: ['normalized_key', 'item_id'],
},
],
};
const MINIMUM_SCHEMA_SHAPES: ReadonlyMap<number, MinimumSchemaShape> = new Map([
[1, VERSION_1_MINIMUM_SCHEMA_SHAPE],
[
2,
{
tables: [
...VERSION_1_MINIMUM_SCHEMA_SHAPE.tables,
{
name: 'memory_extraction_cursors',
requiredColumns: ['session_id', 'processed_ordinal', 'updated_at'],
},
{
name: 'memory_extraction_receipts',
requiredColumns: [
'operation_id',
'session_id',
'request_hash',
'result_json',
'committed_at',
],
},
],
indexes: VERSION_1_MINIMUM_SCHEMA_SHAPE.indexes,
},
],
[
3,
{
tables: [
...VERSION_1_MINIMUM_SCHEMA_SHAPE.tables,
{
name: 'memory_extraction_cursors',
requiredColumns: ['session_id', 'processed_ordinal', 'updated_at'],
},
{
name: 'memory_extraction_receipts',
requiredColumns: [
'operation_id',
'session_id',
'request_hash',
'result_json',
'committed_at',
],
},
{
name: 'memory_extraction_failures',
requiredColumns: [
'session_id',
'from_ordinal',
'through_ordinal',
'coverage_hash',
'first_operation_id',
'first_trigger',
'first_failure_class',
'failed_at',
],
},
],
indexes: VERSION_1_MINIMUM_SCHEMA_SHAPE.indexes,
},
],
]);
for (let version = 1; version <= SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION; version += 1) {
if (!MIGRATIONS.has(version)) {
throw new Error(`Missing long-term memory SQLite migration ${version}`);
}
if (!MINIMUM_SCHEMA_SHAPES.has(version)) {
throw new Error(`Missing long-term memory SQLite minimum schema shape ${version}`);
}
}
export function configureSqliteLongTermMemoryDatabase(db: DatabaseSync): void {
db.exec(`PRAGMA busy_timeout = ${SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS}`);
ensureWalJournalMode(db);
db.exec('PRAGMA synchronous = FULL');
db.exec('PRAGMA foreign_keys = ON');
}
export function migrateSqliteLongTermMemoryDatabase(
db: DatabaseSync,
options: SqliteLongTermMemoryMigrationOptions = {},
): void {
const observedVersion = readSqliteLongTermMemorySchemaVersion(db);
if (observedVersion > SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION) {
throw newerSchemaError(observedVersion);
}
if (observedVersion === SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION) {
validateMinimumSchemaShape(db, observedVersion);
return;
}
db.exec('BEGIN IMMEDIATE');
try {
const current = readSqliteLongTermMemorySchemaVersion(db);
if (current > SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION) throw newerSchemaError(current);
for (
let version = current + 1;
version <= SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION;
version += 1
) {
const sql = MIGRATIONS.get(version);
if (!sql) throw new Error(`Missing long-term memory SQLite migration ${version}`);
db.exec(sql);
options.failpoint?.('after_schema_sql');
db.exec(`PRAGMA user_version = ${version}`);
}
validateMinimumSchemaShape(db, SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION);
db.exec('COMMIT');
} catch (error) {
rollback(db);
throw error;
}
}
export function assertSupportedSqliteLongTermMemorySchemaVersion(db: DatabaseSync): void {
const observedVersion = readSqliteLongTermMemorySchemaVersion(db);
if (observedVersion > SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION) {
throw newerSchemaError(observedVersion);
}
}
export function readSqliteLongTermMemorySchemaVersion(db: DatabaseSync): number {
return retryWhileSqliteBusy(() => {
const row = db.prepare('PRAGMA user_version').get() as { user_version?: unknown } | undefined;
const value = row?.user_version;
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new Error('Invalid long-term memory SQLite schema version');
}
return value;
});
}
function readJournalMode(db: DatabaseSync): string {
return retryWhileSqliteBusy(() => {
const row = db.prepare('PRAGMA journal_mode').get() as { journal_mode?: unknown } | undefined;
if (typeof row?.journal_mode !== 'string') {
throw new Error('Invalid long-term memory SQLite journal mode');
}
return row.journal_mode.toLowerCase();
});
}
function ensureWalJournalMode(db: DatabaseSync): void {
const deadline = Date.now() + SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS;
while (true) {
const journalMode = readJournalMode(db);
if (journalMode === 'wal' || journalMode === 'memory') return;
try {
db.exec('PRAGMA journal_mode = WAL');
const configuredMode = readJournalMode(db);
if (configuredMode !== 'wal') {
throw new Error(
`Long-term memory SQLite requires WAL journal mode, received ${configuredMode}`,
);
}
return;
} catch (error) {
if (!isSqliteBusy(error) || Date.now() >= deadline) throw error;
Atomics.wait(
initializationRetryGate,
0,
0,
Math.min(SQLITE_INITIALIZATION_RETRY_DELAY_MS, Math.max(1, deadline - Date.now())),
);
}
}
}
function isSqliteBusy(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const code = 'code' in error ? String(error.code) : '';
return code === 'SQLITE_BUSY' || /database is locked/i.test(error.message);
}
function retryWhileSqliteBusy<T>(operation: () => T): T {
const deadline = Date.now() + SQLITE_INITIALIZATION_BUSY_TIMEOUT_MS;
while (true) {
try {
return operation();
} catch (error) {
if (!isSqliteBusy(error) || Date.now() >= deadline) throw error;
Atomics.wait(
initializationRetryGate,
0,
0,
Math.min(SQLITE_INITIALIZATION_RETRY_DELAY_MS, Math.max(1, deadline - Date.now())),
);
}
}
}
function newerSchemaError(version: number): Error {
return new Error(
`Long-term memory SQLite schema ${version} is newer than supported version ${SQLITE_LONG_TERM_MEMORY_SCHEMA_VERSION}`,
);
}
function validateMinimumSchemaShape(db: DatabaseSync, version: number): void {
const shape = MINIMUM_SCHEMA_SHAPES.get(version);
if (!shape) {
throw new Error(`Missing long-term memory SQLite minimum schema shape ${version}`);
}
const readSchemaObject = db.prepare('SELECT type, tbl_name FROM sqlite_schema WHERE name = ?');
for (const table of shape.tables) {
const object = readSchemaObject.get(table.name) as
| { type?: unknown; tbl_name?: unknown }
| undefined;
if (object?.type !== 'table' || object.tbl_name !== table.name) {
throw incompleteSchemaError(`missing required table ${table.name}`);
}
const columns = new Set(
(
db.prepare(`PRAGMA table_info(${quoteSqliteIdentifier(table.name)})`).all() as Array<{
name?: unknown;
}>
)
.map((row) => row.name)
.filter((name): name is string => typeof name === 'string'),
);
for (const column of table.requiredColumns) {
if (!columns.has(column)) {
throw incompleteSchemaError(`table ${table.name} is missing required column ${column}`);
}
}
}
for (const index of shape.indexes) {
const object = readSchemaObject.get(index.name) as
| { type?: unknown; tbl_name?: unknown }
| undefined;
if (object?.type !== 'index' || object.tbl_name !== index.tableName) {
throw incompleteSchemaError(`missing required index ${index.name}`);
}
const columns = (
db.prepare(`PRAGMA index_info(${quoteSqliteIdentifier(index.name)})`).all() as Array<{
seqno?: unknown;
name?: unknown;
}>
)
.filter(
(row): row is { seqno: number; name: string } =>
typeof row.seqno === 'number' && typeof row.name === 'string',
)
.sort((left, right) => left.seqno - right.seqno)
.map((row) => row.name);
if (index.requiredColumnPrefix.some((column, position) => columns[position] !== column)) {
throw incompleteSchemaError(`required index ${index.name} has incompatible columns`);
}
}
}
function quoteSqliteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
function incompleteSchemaError(detail: string): Error {
return new Error(`Incomplete long-term memory SQLite schema: ${detail}`);
}
function rollback(db: DatabaseSync): void {
try {
db.exec('ROLLBACK');
} catch {
// Preserve the migration failure that triggered rollback.
}
}