diff --git a/packages/storage/src/__tests__/session-metadata-transfer.test.ts b/packages/storage/src/__tests__/session-metadata-transfer.test.ts new file mode 100644 index 0000000000..ba2bef2895 --- /dev/null +++ b/packages/storage/src/__tests__/session-metadata-transfer.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import type { CreateSessionInput } from '@maka/core'; +import { importLegacySessionMetadataTree } from '../session-metadata-transfer.js'; +import { createSessionStore } from '../session-store.js'; +import { createSqliteSessionMetadataStore } from '../sqlite-session-metadata-store.js'; + +describe('legacy session metadata transfer', () => { + test('imports every legacy line-1 header without reading transcript payloads as metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-transfer-')); + const legacy = createSessionStore(root); + const sqlite = createSqliteSessionMetadataStore(join(root, 'state.sqlite')); + try { + const first = await legacy.create(makeInput({ name: 'First', labels: ['alpha'] })); + const second = await legacy.create(makeInput({ name: 'Second', labels: ['beta'] })); + await legacy.appendMessage(first.id, { + type: 'user', + id: 'user-1', + turnId: 'turn-1', + ts: 10, + text: 'This transcript row is not session metadata.', + }); + await legacy.updateHeader(second.id, { + status: 'blocked', + blockedReason: 'permission_required', + hasUnread: true, + }); + + const report = await importLegacySessionMetadataTree({ + workspaceRoot: root, + destination: sqlite, + }); + assert.deepEqual(report, { + filesScanned: 2, + headersRead: 2, + headersImported: 2, + headersExisting: 0, + sourcesAlreadyImported: 0, + }); + assert.deepEqual((await sqlite.list()).map((record) => record.header.name).sort(), [ + 'First', + 'Second', + ]); + assert.deepEqual( + (await sqlite.read(first.id)).header, + await legacy.readHeaderSnapshot(first.id), + ); + assert.deepEqual( + (await sqlite.read(second.id)).header, + await legacy.readHeaderSnapshot(second.id), + ); + + await legacy.appendMessage(second.id, { + type: 'assistant', + id: 'assistant-1', + turnId: 'turn-1', + ts: 11, + text: 'Appending transcript bytes must not invalidate the imported header.', + modelId: 'fake-model', + }); + await sqlite.update(first.id, { name: 'SQLite is canonical now' }); + const repeated = await importLegacySessionMetadataTree({ + workspaceRoot: root, + destination: sqlite, + }); + assert.deepEqual(repeated, { + filesScanned: 2, + headersRead: 2, + headersImported: 0, + headersExisting: 0, + sourcesAlreadyImported: 2, + }); + assert.equal((await sqlite.read(first.id)).header.name, 'SQLite is canonical now'); + } finally { + sqlite.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('decodes legacy compatibility defaults through the FileSessionStore codec', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-transfer-legacy-')); + const sqlite = createSqliteSessionMetadataStore(join(root, 'state.sqlite')); + const sessionId = 'legacy-session'; + const path = join(root, 'sessions', sessionId, 'session.jsonl'); + try { + const legacy = { + id: sessionId, + workspaceRoot: root, + cwd: '/workspace', + createdAt: 1, + lastUsedAt: 2, + name: 'New Session', + isFlagged: false, + labels: [], + isArchived: false, + hasUnread: false, + backend: 'pi', + llmConnectionSlug: 'legacy', + connectionLocked: false, + schemaVersion: 1, + }; + await mkdir(join(root, 'sessions', sessionId), { recursive: true }); + await writeFile(path, `${JSON.stringify(legacy)}\n`, 'utf8'); + + await importLegacySessionMetadataTree({ workspaceRoot: root, destination: sqlite }); + const header = (await sqlite.read(sessionId)).header; + assert.equal(header.backend, 'pi-agent'); + assert.equal(header.model, 'default'); + assert.equal(header.permissionMode, 'ask'); + assert.equal(header.collaborationMode, 'agent'); + assert.equal(header.orchestrationMode, 'default'); + assert.equal(header.status, 'active'); + assert.equal(header.titleIsManual, false); + assert.equal(header.name, 'New Chat'); + } finally { + sqlite.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('rejects one malformed header before importing any session', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-transfer-invalid-')); + const legacy = createSessionStore(root); + const sqlite = createSqliteSessionMetadataStore(join(root, 'state.sqlite')); + try { + const valid = await legacy.create(makeInput({ name: 'Valid' })); + const invalid = await legacy.create(makeInput({ name: 'Invalid' })); + const invalidPath = join(root, 'sessions', invalid.id, 'session.jsonl'); + const lines = (await readFile(invalidPath, 'utf8')).split('\n'); + lines[0] = JSON.stringify({ ...JSON.parse(lines[0]!), labels: 'not-an-array' }); + await writeFile(invalidPath, lines.join('\n'), 'utf8'); + + await assert.rejects( + () => importLegacySessionMetadataTree({ workspaceRoot: root, destination: sqlite }), + /Invalid session header/, + ); + await assert.rejects(() => sqlite.read(valid.id), /not found/); + assert.deepEqual(await sqlite.list(), []); + } finally { + sqlite.close(); + await rm(root, { recursive: true, force: true }); + } + }); +}); + +function makeInput(overrides: Partial = {}): CreateSessionInput { + return { + cwd: '/tmp/cwd', + backend: 'fake', + llmConnectionSlug: 'fake', + model: 'fake-model', + permissionMode: 'ask', + name: 'Session', + labels: [], + ...overrides, + }; +} diff --git a/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts new file mode 100644 index 0000000000..8eecf5cbac --- /dev/null +++ b/packages/storage/src/__tests__/sqlite-session-metadata-store.test.ts @@ -0,0 +1,315 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import type { SessionHeader } from '@maka/core'; +import { + createSqliteSessionMetadataStore, + SessionMetadataConflictError, + type SqliteSessionMetadataStoreFailpoint, +} from '../sqlite-session-metadata-store.js'; +import { createSqliteRuntimeStore } from '../sqlite-runtime-store.js'; + +describe('SqliteSessionMetadataStore', () => { + test('round-trips every SessionHeader field and reopens the same schema', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-metadata-')); + const path = join(root, 'state.sqlite'); + try { + const store = createSqliteSessionMetadataStore(path, { now: () => 100 }); + const header = fullHeader(); + assert.equal(store.schemaVersion(), 1); + assert.equal(store.journalMode(), 'wal'); + assert.deepEqual(await store.create(header), { + header, + metadataVersion: 1, + committedAt: 100, + }); + store.close(); + + const reopened = createSqliteSessionMetadataStore(path, { now: () => 200 }); + try { + assert.equal(reopened.schemaVersion(), 1); + assert.deepEqual(await reopened.read(header.id), { + header, + metadataVersion: 1, + committedAt: 100, + }); + } finally { + reopened.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test('coexists with the RuntimeEvent schema in one workspace database', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-runtime-database-')); + const path = join(root, 'runtime.sqlite'); + const runtime = createSqliteRuntimeStore(path); + const metadata = createSqliteSessionMetadataStore(path); + try { + assert.equal(runtime.schemaVersion(), 4); + assert.equal(metadata.schemaVersion(), 1); + await metadata.create(fullHeader()); + await runtime.appendRuntimeEvent('session-1', 'run-1', { + id: 'event-1', + invocationId: 'invocation-1', + runId: 'run-1', + sessionId: 'session-1', + turnId: 'turn-1', + ts: 1, + partial: false, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'hello' }, + }); + assert.equal((await metadata.read('session-1')).header.name, 'Session'); + assert.equal((await runtime.readRuntimeEvents('session-1', 'run-1')).length, 1); + } finally { + metadata.close(); + runtime.close(); + await rm(root, { recursive: true, force: true }); + } + }); + + test('filters indexed flags, archive state, and normalized labels in recency order', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create( + fullHeader({ + id: 'older', + name: 'Older', + lastUsedAt: 10, + lastMessageAt: 20, + labels: ['alpha', 'shared'], + isFlagged: true, + }), + ); + await store.create( + fullHeader({ + id: 'newer', + name: 'Newer', + lastUsedAt: 30, + lastMessageAt: 40, + labels: ['shared'], + isFlagged: true, + }), + ); + await store.create( + fullHeader({ + id: 'archived', + name: 'Archived', + isArchived: true, + archivedAt: 50, + status: 'archived', + blockedReason: undefined, + lastMessageAt: 50, + labels: ['shared'], + }), + ); + + assert.deepEqual( + (await store.list({ isArchived: false })).map((record) => record.header.id), + ['newer', 'older'], + ); + assert.deepEqual( + (await store.list({ isArchived: false, isFlagged: true, labelSlug: 'shared' })).map( + (record) => record.header.id, + ), + ['newer', 'older'], + ); + assert.deepEqual( + (await store.list({ labelSlug: 'alpha' })).map((record) => record.header.id), + ['older'], + ); + } finally { + store.close(); + } + }); + + test('updates metadata and labels with a compare-and-set version', async () => { + const store = createSqliteSessionMetadataStore(':memory:', { now: nextNow(10) }); + try { + await store.create(fullHeader()); + const updated = await store.update( + 'session-1', + { + name: 'Renamed', + labels: ['replacement'], + hasUnread: false, + pendingCwdReminder: undefined, + }, + { expectedVersion: 1 }, + ); + assert.equal(updated.metadataVersion, 2); + assert.equal(updated.header.name, 'Renamed'); + assert.deepEqual(updated.header.labels, ['replacement']); + assert.equal(updated.header.pendingCwdReminder, undefined); + assert.deepEqual( + (await store.list({ labelSlug: 'replacement' })).map((record) => record.header.id), + ['session-1'], + ); + assert.deepEqual(await store.list({ labelSlug: 'alpha' }), []); + + await assert.rejects( + () => store.update('session-1', { name: 'Stale' }, { expectedVersion: 1 }), + SessionMetadataConflictError, + ); + assert.equal((await store.read('session-1')).header.name, 'Renamed'); + } finally { + store.close(); + } + }); + + test('rolls back row and label changes at every injected transaction failure', async () => { + for (const failpoint of [ + 'after_session_row_write', + 'after_session_labels_write', + ] satisfies SqliteSessionMetadataStoreFailpoint[]) { + let armed = true; + const store = createSqliteSessionMetadataStore(':memory:', { + failpoint: (point) => { + if (armed && point === failpoint) throw new Error(`failpoint: ${point}`); + }, + }); + try { + await assert.rejects(() => store.create(fullHeader()), /failpoint/); + await assert.rejects(() => store.read('session-1'), /not found/); + + armed = false; + await store.create(fullHeader()); + armed = true; + await assert.rejects( + () => store.update('session-1', { name: 'Not committed', labels: ['lost'] }), + /failpoint/, + ); + const current = await store.read('session-1'); + assert.equal(current.metadataVersion, 1); + assert.equal(current.header.name, 'Session'); + assert.deepEqual(current.header.labels, ['alpha', 'beta']); + assert.deepEqual(await store.list({ labelSlug: 'lost' }), []); + } finally { + store.close(); + } + } + }); + + test('imports source-marked metadata idempotently and rejects identity drift', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + const entry = { + header: fullHeader(), + source: { path: '/workspace/sessions/session-1/session.jsonl', fingerprint: '1:1' }, + }; + try { + assert.deepEqual(await store.importEntries([entry]), { + created: [true], + sourcesAlreadyImported: 0, + }); + assert.deepEqual(await store.importEntries([entry]), { + created: [], + sourcesAlreadyImported: 1, + }); + await assert.rejects( + () => + store.importEntries([ + { + ...entry, + header: fullHeader({ name: 'Changed outside SQLite' }), + source: { ...entry.source, fingerprint: '2:2' }, + }, + ]), + SessionMetadataConflictError, + ); + assert.equal((await store.read('session-1')).header.name, 'Session'); + } finally { + store.close(); + } + }); + + test('rolls back the whole import batch when a later source marker fails', async () => { + let markers = 0; + const store = createSqliteSessionMetadataStore(':memory:', { + failpoint: (point) => { + if (point === 'after_session_import_marker_write' && ++markers === 2) { + throw new Error('second marker failed'); + } + }, + }); + try { + await assert.rejects( + () => + store.importEntries([ + { + header: fullHeader({ id: 'session-1' }), + source: { path: '/session-1.jsonl', fingerprint: '1:1' }, + }, + { + header: fullHeader({ id: 'session-2' }), + source: { path: '/session-2.jsonl', fingerprint: '2:2' }, + }, + ]), + /second marker failed/, + ); + assert.deepEqual(await store.list(), []); + } finally { + store.close(); + } + }); + + test('deletes metadata and its label projection atomically', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + try { + await store.create(fullHeader()); + assert.equal(await store.remove('session-1'), true); + assert.equal(await store.remove('session-1'), false); + assert.deepEqual(await store.list({ labelSlug: 'alpha' }), []); + } finally { + store.close(); + } + }); +}); + +function fullHeader(overrides: Partial = {}): SessionHeader { + return { + id: 'session-1', + workspaceRoot: '/workspace', + cwd: '/workspace/repo', + pendingCwdReminder: { from: '/workspace/old', to: '/workspace/repo' }, + createdAt: 1, + lastUsedAt: 2, + lastMessageAt: 3, + name: 'Session', + titleIsManual: true, + isFlagged: false, + labels: ['alpha', 'beta'], + isArchived: false, + status: 'blocked', + blockedReason: 'permission_required', + statusUpdatedAt: 4, + parentSessionId: 'parent-session', + branchOfTurnId: 'branch-turn', + revisionRootSessionId: 'root-session', + revisionParentSessionId: 'previous-session', + revisionOfTurnId: 'revised-turn', + revisionIndex: 2, + revisionState: 'committed', + lastReadMessageId: 'message-1', + hasUnread: true, + backend: 'ai-sdk', + llmConnectionSlug: 'openai', + connectionLocked: true, + model: 'gpt-5', + thinkingLevel: 'high', + permissionMode: 'ask', + collaborationMode: 'agent', + orchestrationMode: 'swarm', + schemaVersion: 1, + ...overrides, + }; +} + +function nextNow(start: number): () => number { + let current = start; + return () => current++; +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index a53213224f..d8514aca4b 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -1,4 +1,6 @@ export * from './session-store.js'; +export * from './sqlite-session-metadata-store.js'; +export * from './session-metadata-transfer.js'; export * from './agent-run-store.js'; export * from './shell-run-store.js'; export * from './connection-store.js'; diff --git a/packages/storage/src/session-metadata-transfer.ts b/packages/storage/src/session-metadata-transfer.ts new file mode 100644 index 0000000000..4c8f543d57 --- /dev/null +++ b/packages/storage/src/session-metadata-transfer.ts @@ -0,0 +1,100 @@ +import { createHash } from 'node:crypto'; +import { open, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { decodeSessionHeader, isSafeSessionId } from './session-store.js'; +import type { + SessionMetadataImportEntry, + SqliteSessionMetadataStore, +} from './sqlite-session-metadata-store.js'; + +const LEGACY_SESSION_HEADER_MAX_BYTES = 1024 * 1024; +const LEGACY_SESSION_HEADER_READ_BYTES = 8192; + +export interface LegacySessionMetadataImportReport { + filesScanned: number; + headersRead: number; + headersImported: number; + headersExisting: number; + sourcesAlreadyImported: number; +} + +/** + * Import every legacy line-1 SessionHeader in one SQLite transaction. + * + * The scan and decode phase completes before the transaction begins, so a + * malformed header cannot leave a partially imported session catalog. + */ +export async function importLegacySessionMetadataTree(input: { + workspaceRoot: string; + destination: SqliteSessionMetadataStore; +}): Promise { + const sessionsRoot = join(input.workspaceRoot, 'sessions'); + const entries: SessionMetadataImportEntry[] = []; + for (const directory of await sessionDirectoryNames(sessionsRoot)) { + const sourcePath = join(sessionsRoot, directory, 'session.jsonl'); + let value: unknown; + let headerLine: string; + try { + headerLine = await readFirstJsonlRecord(sourcePath); + value = JSON.parse(headerLine) as unknown; + } catch (error) { + throw new Error(`Invalid legacy session header at ${sourcePath}`, { cause: error }); + } + const fingerprint = createHash('sha256').update(headerLine).digest('hex'); + entries.push({ + header: decodeSessionHeader(value, directory), + source: { path: sourcePath, fingerprint }, + }); + } + const result = await input.destination.importEntries(entries); + const headersImported = result.created.filter(Boolean).length; + return { + filesScanned: entries.length, + headersRead: entries.length, + headersImported, + headersExisting: result.created.length - headersImported, + sourcesAlreadyImported: result.sourcesAlreadyImported, + }; +} + +async function sessionDirectoryNames(root: string): Promise { + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + const names: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!isSafeSessionId(entry.name)) { + throw new Error(`Invalid Session entry: ${entry.name}`); + } + names.push(entry.name); + } + return names.sort(); +} + +async function readFirstJsonlRecord(path: string): Promise { + const handle = await open(path, 'r'); + try { + const chunks: Buffer[] = []; + let offset = 0; + while (offset < LEGACY_SESSION_HEADER_MAX_BYTES) { + const buffer = Buffer.alloc( + Math.min(LEGACY_SESSION_HEADER_READ_BYTES, LEGACY_SESSION_HEADER_MAX_BYTES - offset), + ); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset); + if (bytesRead === 0) break; + chunks.push(buffer.subarray(0, bytesRead)); + const text = Buffer.concat(chunks).toString('utf8'); + const newline = text.indexOf('\n'); + if (newline >= 0) return text.slice(0, newline); + offset += bytesRead; + } + throw new Error(`Cannot read legacy session header from ${path}`); + } finally { + await handle.close(); + } +} diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 8d9d4c8bd2..25639a6714 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -419,10 +419,7 @@ class FileSessionStore implements SessionStore { const region = Buffer.concat(chunks).toString('utf8'); const firstNl = region.indexOf('\n'); if (firstNl !== -1) { - return migrateHeader( - JSON.parse(region.slice(0, firstNl)) as StoredSessionHeader, - sessionId, - ); + return decodeSessionHeader(JSON.parse(region.slice(0, firstNl)), sessionId); } offset += bytesRead; } @@ -479,7 +476,7 @@ class FileSessionStore implements SessionStore { .map((line, index) => ({ line, lineNumber: index + 1 })) .filter((entry) => entry.line.trim().length > 0); if (lines.length === 0 || !lines[0]) throw new Error(`Session ${sessionId} is empty`); - const header = migrateHeader(JSON.parse(lines[0].line) as StoredSessionHeader, sessionId); + const header = decodeSessionHeader(JSON.parse(lines[0].line), sessionId); const messages: StoredMessage[] = []; const lastLineNumber = lines.at(-1)?.lineNumber; for (const entry of lines.slice(1)) { @@ -601,7 +598,17 @@ function createJsonlCorruptionNote( }; } -function migrateHeader(header: StoredSessionHeader, sessionId: string): SessionHeader { +/** + * Decode the legacy line-1 JSONL header into the current canonical shape. + * + * Kept public for one-way importers so file and SQLite storage apply exactly + * the same compatibility defaults and validation rules. + */ +export function decodeSessionHeader(value: unknown, sessionId: string): SessionHeader { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Invalid session header for session ${sessionId}: expected an object`); + } + const header = value as StoredSessionHeader; const permissionMode = isPermissionMode(header.permissionMode) ? header.permissionMode : 'ask'; const collaborationMode = isCollaborationMode(header.collaborationMode) ? header.collaborationMode @@ -631,7 +638,7 @@ function migrateHeader(header: StoredSessionHeader, sessionId: string): SessionH ? header.titleIsManual : normalizeSessionName(header.name) !== DEFAULT_SESSION_NAME; if (header.backend === 'claude') { - return normalizeMigratedHeader( + return normalizeSessionHeader( { ...header, ...statusFields, @@ -646,7 +653,7 @@ function migrateHeader(header: StoredSessionHeader, sessionId: string): SessionH ); } if (header.backend === 'pi-agent') { - return normalizeMigratedHeader( + return normalizeSessionHeader( { ...header, ...statusFields, @@ -661,7 +668,7 @@ function migrateHeader(header: StoredSessionHeader, sessionId: string): SessionH ); } if (header.backend === 'pi') { - return normalizeMigratedHeader( + return normalizeSessionHeader( { ...header, ...statusFields, @@ -675,7 +682,7 @@ function migrateHeader(header: StoredSessionHeader, sessionId: string): SessionH sessionId, ); } - return normalizeMigratedHeader( + return normalizeSessionHeader( { ...header, ...statusFields, @@ -696,7 +703,11 @@ function resolveMigratedStatus(header: StoredSessionHeader): SessionHeader['stat return 'active'; } -function normalizeMigratedHeader(header: SessionHeader, sessionId: string): SessionHeader { +/** Validate and normalize a current SessionHeader before canonical persistence. */ +export function normalizeSessionHeader( + header: SessionHeader, + sessionId: string = header.id, +): SessionHeader { const valid = header.id === sessionId && typeof header.workspaceRoot === 'string' && @@ -731,7 +742,12 @@ function normalizeMigratedHeader(header: SessionHeader, sessionId: string): Sess if (!valid) { throw new Error(`Invalid session header for session ${sessionId}: malformed fields`); } - return { ...header, name: normalizeSessionName(header.name) }; + const normalizedName = normalizeSessionName(header.name); + if (header.blockedReason === undefined) { + const { blockedReason: _blockedReason, ...withoutBlockedReason } = header; + return { ...withoutBlockedReason, name: normalizedName }; + } + return { ...header, name: normalizedName }; } function isValidRevisionLineage(header: SessionHeader): boolean { diff --git a/packages/storage/src/sqlite-session-metadata-schema.ts b/packages/storage/src/sqlite-session-metadata-schema.ts new file mode 100644 index 0000000000..2d08d07f34 --- /dev/null +++ b/packages/storage/src/sqlite-session-metadata-schema.ts @@ -0,0 +1,133 @@ +import type { DatabaseSync } from 'node:sqlite'; + +export const SQLITE_SESSION_METADATA_SCHEMA_VERSION = 1; + +const MIGRATIONS: ReadonlyMap = new Map([ + [ + 1, + ` + CREATE TABLE session_metadata ( + session_id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER NOT NULL, + last_message_at INTEGER, + name TEXT NOT NULL, + is_flagged INTEGER NOT NULL CHECK (is_flagged IN (0, 1)), + is_archived INTEGER NOT NULL CHECK (is_archived IN (0, 1)), + status TEXT NOT NULL, + status_updated_at INTEGER, + parent_session_id TEXT, + revision_root_session_id TEXT, + revision_index INTEGER, + has_unread INTEGER NOT NULL CHECK (has_unread IN (0, 1)), + backend TEXT NOT NULL, + llm_connection_slug TEXT NOT NULL, + model TEXT NOT NULL, + metadata_version INTEGER NOT NULL CHECK (metadata_version > 0), + committed_at INTEGER NOT NULL + ); + + CREATE INDEX session_metadata_by_recency + ON session_metadata(is_archived, last_message_at DESC, last_used_at DESC, session_id); + + CREATE INDEX session_metadata_by_flag + ON session_metadata(is_flagged, is_archived, session_id); + + CREATE INDEX session_metadata_by_status + ON session_metadata(status, status_updated_at DESC, session_id); + + CREATE INDEX session_metadata_by_parent + ON session_metadata(parent_session_id, session_id); + + CREATE INDEX session_metadata_by_revision + ON session_metadata(revision_root_session_id, revision_index, session_id); + + CREATE TABLE session_metadata_labels ( + session_id TEXT NOT NULL, + label_index INTEGER NOT NULL CHECK (label_index >= 0), + label TEXT NOT NULL, + PRIMARY KEY(session_id, label_index), + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + + CREATE INDEX session_metadata_labels_by_label + ON session_metadata_labels(label, session_id); + + CREATE TABLE session_metadata_import_sources ( + source_path TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + session_id TEXT NOT NULL, + imported_at INTEGER NOT NULL, + FOREIGN KEY(session_id) REFERENCES session_metadata(session_id) ON DELETE CASCADE + ); + `, + ], +]); + +export function configureSqliteSessionMetadataDatabase(db: DatabaseSync): void { + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA synchronous = FULL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); +} + +export function migrateSqliteSessionMetadataDatabase(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS session_metadata_schema ( + scope TEXT PRIMARY KEY, + version INTEGER NOT NULL CHECK (version >= 0) + ) + `); + db.exec('BEGIN IMMEDIATE'); + try { + const current = readSqliteSessionMetadataSchemaVersion(db); + if (current > SQLITE_SESSION_METADATA_SCHEMA_VERSION) { + throw new Error( + `SQLite session metadata schema ${current} is newer than supported version ${SQLITE_SESSION_METADATA_SCHEMA_VERSION}`, + ); + } + for ( + let version = current + 1; + version <= SQLITE_SESSION_METADATA_SCHEMA_VERSION; + version += 1 + ) { + const sql = MIGRATIONS.get(version); + if (!sql) throw new Error(`Missing SQLite session metadata migration ${version}`); + db.exec(sql); + db.prepare(` + INSERT INTO session_metadata_schema(scope, version) + VALUES ('session_metadata', ?) + ON CONFLICT(scope) DO UPDATE SET version = excluded.version + `).run(version); + } + db.exec('COMMIT'); + } catch (error) { + rollback(db); + throw error; + } +} + +export function readSqliteSessionMetadataSchemaVersion(db: DatabaseSync): number { + const row = db + .prepare(` + SELECT version + FROM session_metadata_schema + WHERE scope = 'session_metadata' + `) + .get() as { version?: unknown } | undefined; + if (!row) return 0; + const value = row.version; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new Error('Invalid SQLite session metadata schema version'); + } + return value; +} + +function rollback(db: DatabaseSync): void { + try { + db.exec('ROLLBACK'); + } catch { + // Preserve the migration failure that triggered rollback. + } +} diff --git a/packages/storage/src/sqlite-session-metadata-store.ts b/packages/storage/src/sqlite-session-metadata-store.ts new file mode 100644 index 0000000000..ac135485ac --- /dev/null +++ b/packages/storage/src/sqlite-session-metadata-store.ts @@ -0,0 +1,437 @@ +import { createRequire } from 'node:module'; +import { dirname } from 'node:path'; +import { mkdirSync } from 'node:fs'; +import { isDeepStrictEqual } from 'node:util'; +import type { DatabaseSync } from 'node:sqlite'; +import type { SessionHeader, SessionListFilter } from '@maka/core'; +import { assertSafeSessionId, normalizeSessionHeader } from './session-store.js'; +import { + configureSqliteSessionMetadataDatabase, + migrateSqliteSessionMetadataDatabase, + readSqliteSessionMetadataSchemaVersion, +} from './sqlite-session-metadata-schema.js'; + +export { SQLITE_SESSION_METADATA_SCHEMA_VERSION } from './sqlite-session-metadata-schema.js'; + +const require = createRequire(import.meta.url); + +function loadDatabaseSync(): typeof import('node:sqlite').DatabaseSync { + return (require('node:sqlite') as typeof import('node:sqlite')).DatabaseSync; +} + +export type SqliteSessionMetadataStoreFailpoint = + | 'after_session_row_write' + | 'after_session_labels_write' + | 'after_session_import_marker_write'; + +export interface SqliteSessionMetadataStoreOptions { + now?: () => number; + failpoint?: (point: SqliteSessionMetadataStoreFailpoint) => void; +} + +export interface SessionMetadataRecord { + header: SessionHeader; + metadataVersion: number; + committedAt: number; +} + +export interface SessionMetadataImportEntry { + header: SessionHeader; + source: { + path: string; + fingerprint: string; + }; +} + +export interface SessionMetadataImportResult { + created: boolean[]; + sourcesAlreadyImported: number; +} + +export class SessionMetadataConflictError extends Error { + readonly name = 'SessionMetadataConflictError'; +} + +export function createSqliteSessionMetadataStore( + path: string, + options: SqliteSessionMetadataStoreOptions = {}, +): SqliteSessionMetadataStore { + return new SqliteSessionMetadataStore(path, options); +} + +export class SqliteSessionMetadataStore { + private readonly db: DatabaseSync; + private readonly now: () => number; + private closed = false; + + constructor( + path: string, + private readonly options: SqliteSessionMetadataStoreOptions = {}, + ) { + if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true }); + const DatabaseSync = loadDatabaseSync(); + this.db = new DatabaseSync(path); + configureSqliteSessionMetadataDatabase(this.db); + migrateSqliteSessionMetadataDatabase(this.db); + this.now = options.now ?? Date.now; + } + + schemaVersion(): number { + this.assertOpen(); + return readSqliteSessionMetadataSchemaVersion(this.db); + } + + journalMode(): string { + this.assertOpen(); + const row = this.db.prepare('PRAGMA journal_mode').get() as + | { journal_mode?: unknown } + | undefined; + return typeof row?.journal_mode === 'string' ? row.journal_mode.toLowerCase() : ''; + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.db.close(); + } + + async create(header: SessionHeader): Promise { + this.assertOpen(); + const normalized = normalizeSessionHeader(header); + assertSafeSessionId(normalized.id); + return this.transaction(() => { + if (this.readRecordSync(normalized.id)) { + throw new SessionMetadataConflictError(`Session metadata already exists: ${normalized.id}`); + } + return this.insertHeader(normalized, 1, this.now()); + }); + } + + async read(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + const record = this.readRecordSync(sessionId); + if (!record) throw new Error(`Session metadata not found: ${sessionId}`); + return record; + } + + async list(filter: SessionListFilter = {}): Promise { + this.assertOpen(); + const where: string[] = []; + const parameters: Array = []; + if (filter.isArchived !== undefined) { + where.push('metadata.is_archived = ?'); + parameters.push(filter.isArchived ? 1 : 0); + } + if (filter.isFlagged !== undefined) { + where.push('metadata.is_flagged = ?'); + parameters.push(filter.isFlagged ? 1 : 0); + } + if (filter.labelSlug !== undefined) { + where.push(` + EXISTS ( + SELECT 1 + FROM session_metadata_labels labels + WHERE labels.session_id = metadata.session_id + AND labels.label = ? + ) + `); + parameters.push(filter.labelSlug); + } + const rows = this.db + .prepare(` + SELECT session_id, payload_json, metadata_version, committed_at + FROM session_metadata metadata + ${where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY + COALESCE(last_message_at, last_used_at, created_at) DESC, + session_id ASC + `) + .all(...parameters) as unknown as SessionMetadataRow[]; + return rows.map(decodeRecord); + } + + async update( + sessionId: string, + patch: Partial, + options: { expectedVersion?: number } = {}, + ): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.transaction(() => { + const current = this.readRecordSync(sessionId); + if (!current) throw new Error(`Session metadata not found: ${sessionId}`); + if ( + options.expectedVersion !== undefined && + options.expectedVersion !== current.metadataVersion + ) { + throw new SessionMetadataConflictError( + `Session metadata version conflict for ${sessionId}: expected ${options.expectedVersion}, found ${current.metadataVersion}`, + ); + } + const next = normalizeSessionHeader({ ...current.header, ...patch }, sessionId); + if (next.id !== sessionId) { + throw new SessionMetadataConflictError('Session metadata identity cannot be changed'); + } + const metadataVersion = current.metadataVersion + 1; + const committedAt = this.now(); + const updated = this.db + .prepare(` + UPDATE session_metadata + SET + payload_json = ?, + created_at = ?, + last_used_at = ?, + last_message_at = ?, + name = ?, + is_flagged = ?, + is_archived = ?, + status = ?, + status_updated_at = ?, + parent_session_id = ?, + revision_root_session_id = ?, + revision_index = ?, + has_unread = ?, + backend = ?, + llm_connection_slug = ?, + model = ?, + metadata_version = ?, + committed_at = ? + WHERE session_id = ? AND metadata_version = ? + `) + .run( + JSON.stringify(next), + next.createdAt, + next.lastUsedAt, + next.lastMessageAt ?? null, + next.name, + booleanInteger(next.isFlagged), + booleanInteger(next.isArchived), + next.status, + next.statusUpdatedAt ?? null, + next.parentSessionId ?? null, + next.revisionRootSessionId ?? null, + next.revisionIndex ?? null, + booleanInteger(next.hasUnread), + next.backend, + next.llmConnectionSlug, + next.model, + metadataVersion, + committedAt, + sessionId, + current.metadataVersion, + ); + if (updated.changes !== 1) { + throw new SessionMetadataConflictError( + `Session metadata compare-and-set failed: ${sessionId}`, + ); + } + this.options.failpoint?.('after_session_row_write'); + this.replaceLabels(next); + this.options.failpoint?.('after_session_labels_write'); + return { header: next, metadataVersion, committedAt }; + }); + } + + async remove(sessionId: string): Promise { + this.assertOpen(); + assertSafeSessionId(sessionId); + return this.transaction( + () => + this.db.prepare('DELETE FROM session_metadata WHERE session_id = ?').run(sessionId) + .changes === 1, + ); + } + + async importEntries( + entries: readonly SessionMetadataImportEntry[], + ): Promise { + this.assertOpen(); + const sourcePaths = new Set(); + const normalized = entries.map((entry) => { + const header = normalizeSessionHeader(entry.header); + assertSafeSessionId(header.id); + if (!entry.source.path || !entry.source.fingerprint) { + throw new Error(`Invalid session metadata import source for ${header.id}`); + } + if (sourcePaths.has(entry.source.path)) { + throw new Error(`Duplicate session metadata import source: ${entry.source.path}`); + } + sourcePaths.add(entry.source.path); + return { header, source: entry.source }; + }); + return this.transaction(() => { + const created: boolean[] = []; + let sourcesAlreadyImported = 0; + for (const entry of normalized) { + const source = this.db + .prepare(` + SELECT fingerprint + FROM session_metadata_import_sources + WHERE source_path = ? + `) + .get(entry.source.path) as { fingerprint: string } | undefined; + if (source?.fingerprint === entry.source.fingerprint) { + const existing = this.readRecordSync(entry.header.id); + if (!existing) { + throw new SessionMetadataConflictError( + `Imported session metadata is missing: ${entry.header.id}`, + ); + } + sourcesAlreadyImported += 1; + continue; + } + const existing = this.readRecordSync(entry.header.id); + if (existing) { + if (!isDeepStrictEqual(existing.header, entry.header)) { + throw new SessionMetadataConflictError( + `Session metadata import conflict for ${entry.header.id}`, + ); + } + created.push(false); + } else { + this.insertHeader(entry.header, 1, this.now()); + created.push(true); + } + this.db + .prepare(` + INSERT INTO session_metadata_import_sources( + source_path, fingerprint, session_id, imported_at + ) VALUES (?, ?, ?, ?) + ON CONFLICT(source_path) DO UPDATE SET + fingerprint = excluded.fingerprint, + session_id = excluded.session_id, + imported_at = excluded.imported_at + `) + .run(entry.source.path, entry.source.fingerprint, entry.header.id, this.now()); + this.options.failpoint?.('after_session_import_marker_write'); + } + return { created, sourcesAlreadyImported }; + }); + } + + private insertHeader( + header: SessionHeader, + metadataVersion: number, + committedAt: number, + ): SessionMetadataRecord { + this.db + .prepare(` + INSERT INTO session_metadata( + session_id, + payload_json, + created_at, + last_used_at, + last_message_at, + name, + is_flagged, + is_archived, + status, + status_updated_at, + parent_session_id, + revision_root_session_id, + revision_index, + has_unread, + backend, + llm_connection_slug, + model, + metadata_version, + committed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + .run( + header.id, + JSON.stringify(header), + header.createdAt, + header.lastUsedAt, + header.lastMessageAt ?? null, + header.name, + booleanInteger(header.isFlagged), + booleanInteger(header.isArchived), + header.status, + header.statusUpdatedAt ?? null, + header.parentSessionId ?? null, + header.revisionRootSessionId ?? null, + header.revisionIndex ?? null, + booleanInteger(header.hasUnread), + header.backend, + header.llmConnectionSlug, + header.model, + metadataVersion, + committedAt, + ); + this.options.failpoint?.('after_session_row_write'); + this.replaceLabels(header); + this.options.failpoint?.('after_session_labels_write'); + return { header, metadataVersion, committedAt }; + } + + private replaceLabels(header: SessionHeader): void { + this.db.prepare('DELETE FROM session_metadata_labels WHERE session_id = ?').run(header.id); + const insert = this.db.prepare(` + INSERT INTO session_metadata_labels(session_id, label_index, label) + VALUES (?, ?, ?) + `); + for (let index = 0; index < header.labels.length; index += 1) { + insert.run(header.id, index, header.labels[index]!); + } + } + + private readRecordSync(sessionId: string): SessionMetadataRecord | undefined { + const row = this.db + .prepare(` + SELECT session_id, payload_json, metadata_version, committed_at + FROM session_metadata + WHERE session_id = ? + `) + .get(sessionId) as SessionMetadataRow | undefined; + return row ? decodeRecord(row) : undefined; + } + + private transaction(operation: () => T): T { + this.db.exec('BEGIN IMMEDIATE'); + try { + const result = operation(); + this.db.exec('COMMIT'); + return result; + } catch (error) { + try { + this.db.exec('ROLLBACK'); + } catch { + // Preserve the original storage or protocol failure. + } + throw error; + } + } + + private assertOpen(): void { + if (this.closed) throw new Error('SQLite session metadata store is closed'); + } +} + +interface SessionMetadataRow { + session_id: string; + payload_json: string; + metadata_version: number; + committed_at: number; +} + +function decodeRecord(row: SessionMetadataRow): SessionMetadataRecord { + const parsed = JSON.parse(row.payload_json) as SessionHeader; + if ( + !Number.isSafeInteger(row.metadata_version) || + row.metadata_version < 1 || + !Number.isFinite(row.committed_at) + ) { + throw new Error(`Invalid SQLite session metadata record for ${row.session_id}`); + } + return { + header: normalizeSessionHeader(parsed, row.session_id), + metadataVersion: row.metadata_version, + committedAt: row.committed_at, + }; +} + +function booleanInteger(value: boolean): 0 | 1 { + return value ? 1 : 0; +}