fix: prevent SQLite lock stalls and schema drift (#108663)

* fix: harden SQLite state storage

* build: allow strict SQLite migration SQL

* test: align SQLite and model-default assertions
This commit is contained in:
Peter Steinberger
2026-07-16 01:45:06 -07:00
committed by GitHub
parent 00ff4e8b84
commit 30ae21fca7
43 changed files with 2656 additions and 585 deletions
@@ -1,2 +1,2 @@
37477c8761123ab9b1dbc0b5923b77c0e6713d23e79d3cb7a4d3c349494fe2df plugin-sdk-api-baseline.json
67a0f46755da5f57de1d08882e1445b34dbf38af3bdf757149a1dfa3ee7763b1 plugin-sdk-api-baseline.jsonl
c4770214933b0ed42066f781c4f18a991af979bc87d2e0ca495a32439b524d1c plugin-sdk-api-baseline.json
31459c3d236e28a9474ada640a412796392d1eedf0f71b20c75513d61a6dba39 plugin-sdk-api-baseline.jsonl
@@ -1 +1 @@
b378fb02792c2147b8804006cc6efc7eb5409ccfa96f1ae727b641e03bf843b8 sqlite-session-transcript-schema-baseline.sql
1754707609cb8b5248307fc7082f3a213e0069883b6be5f392180f80bc0364c5 sqlite-session-transcript-schema-baseline.sql
+1 -1
View File
@@ -277,7 +277,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime, without database lifecycle controls |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types plus centralized connection pragma and WAL maintenance setup for plugin-owned databases |
| `plugin-sdk/plugin-state-runtime` | Plugin sidecar SQLite keyed-state types plus centralized connection pragma, verified WAL maintenance, and atomic STRICT-schema migration helpers for plugin-owned databases |
| `plugin-sdk/routing` | Route/session-key/account binding helpers such as `resolveAgentRoute`, `buildAgentSessionKey`, and `resolveDefaultAgentBoundAccountId` |
| `plugin-sdk/status-helpers` | Shared channel/account status summary helpers, runtime-state defaults, and issue metadata helpers |
| `plugin-sdk/target-resolver-runtime` | Shared target resolver helpers |
+236
View File
@@ -1,6 +1,7 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { LogbookStore, dayKeyFor } from "./store.js";
@@ -72,6 +73,111 @@ describe("LogbookStore", () => {
expect(store.batchFrames(batchId)).toHaveLength(2);
});
it("creates every owned table as STRICT with foreign keys enabled", () => {
const database = new DatabaseSync(path.join(dir, "logbook.sqlite"), { readOnly: true });
try {
const ordinaryTables = database
.prepare(
`SELECT name, strict FROM pragma_table_list
WHERE schema = 'main' AND type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`,
)
.all();
expect(ordinaryTables).toEqual([
{ name: "batches", strict: 1 },
{ name: "cards", strict: 1 },
{ name: "frames", strict: 1 },
{ name: "observations", strict: 1 },
{ name: "standups", strict: 1 },
]);
expect(database.prepare("PRAGMA user_version").get()).toEqual({ user_version: 1 });
expect(database.prepare("PRAGMA foreign_key_list(observations)").all()).toContainEqual(
expect.objectContaining({ table: "batches", on_delete: "CASCADE" }),
);
expect(database.prepare("PRAGMA foreign_key_list(cards)").all()).toContainEqual(
expect.objectContaining({ table: "frames", on_delete: "SET NULL" }),
);
} finally {
database.close();
}
});
it("rejects values that violate STRICT column types", () => {
const database = new DatabaseSync(path.join(dir, "logbook.sqlite"));
try {
expect(() =>
database
.prepare("INSERT INTO standups (day, text, updated_ms) VALUES (?, ?, ?)")
.run(DAY, "bad timestamp", "not-an-integer"),
).toThrow();
expect(database.prepare("SELECT COUNT(*) AS count FROM standups").get()).toEqual({
count: 0,
});
} finally {
database.close();
}
});
it("rolls back the whole batch when any frame is missing", () => {
const t0 = Date.now();
const frameId = insertFrame(t0);
expect(() =>
store.createBatch({
day: dayKeyFor(t0),
startMs: t0,
endMs: t0 + 1000,
frameIds: [frameId, 999_999],
}),
).toThrow("Logbook frame 999999 is missing or already batched");
expect(store.latestBatch()).toBeNull();
expect(store.unbatchedActiveFrames(10).map((frame) => frame.id)).toEqual([frameId]);
});
it("does not steal a frame from an existing batch", () => {
const t0 = Date.now();
const firstFrame = insertFrame(t0);
const secondFrame = insertFrame(t0 + 1000);
const firstBatch = store.createBatch({
day: dayKeyFor(t0),
startMs: t0,
endMs: t0 + 1000,
frameIds: [firstFrame],
});
expect(() =>
store.createBatch({
day: dayKeyFor(t0),
startMs: t0,
endMs: t0 + 2000,
frameIds: [firstFrame, secondFrame],
}),
).toThrow(`Logbook frame ${firstFrame} is missing or already batched`);
expect(store.latestBatch()?.id).toBe(firstBatch);
expect(store.batchFrames(firstBatch).map((frame) => frame.id)).toEqual([firstFrame]);
expect(store.unbatchedActiveFrames(10).map((frame) => frame.id)).toEqual([secondFrame]);
});
it("rejects empty and duplicate frame claims without leaving a batch", () => {
const t0 = Date.now();
expect(() =>
store.createBatch({ day: DAY, startMs: t0, endMs: t0 + 1000, frameIds: [] }),
).toThrow("Logbook batch requires at least one frame");
const frameId = insertFrame(t0);
expect(() =>
store.createBatch({
day: DAY,
startMs: t0,
endMs: t0 + 1000,
frameIds: [frameId, frameId],
}),
).toThrow(`Logbook frame ${frameId} is missing or already batched`);
expect(store.latestBatch()).toBeNull();
expect(store.countUnbatchedActiveFrames()).toBe(1);
});
it("resets running batches to pending on startup recovery", () => {
const t0 = Date.now();
insertFrame(t0);
@@ -129,6 +235,24 @@ describe("LogbookStore", () => {
expect(store.frameById(newId)).not.toBeNull();
});
it("keeps frame metadata when a retained file cannot be removed", () => {
const now = Date.now();
const firstId = insertFrame(now - 21 * 24 * 60 * 60_000);
const blockedId = insertFrame(now - 20 * 24 * 60 * 60_000);
const blockedPath = expectDefined(store.frameById(blockedId), "blocked frame").path;
rmSync(blockedPath);
mkdirSync(blockedPath);
expect(() => store.pruneFrames(now - 14 * 24 * 60 * 60_000)).toThrow();
expect(store.frameById(firstId)).not.toBeNull();
expect(store.frameById(blockedId)).not.toBeNull();
rmSync(blockedPath, { recursive: true });
expect(store.pruneFrames(now - 14 * 24 * 60 * 60_000)).toBe(2);
expect(store.frameById(firstId)).toBeNull();
expect(store.frameById(blockedId)).toBeNull();
});
it("detaches pruned keyframes from surviving cards", () => {
const now = Date.now();
const oldId = insertFrame(now - 20 * 24 * 60 * 60_000);
@@ -153,6 +277,27 @@ describe("LogbookStore", () => {
expect(expectDefined(observations[0], "retried observation").text).toBe("retry run");
});
it("rejects observations for a missing batch", () => {
expect(() =>
store.replaceObservations(999_999, DAY, [
{ startMs: 1, endMs: 2, text: "orphan observation" },
]),
).toThrow();
expect(store.observationsInRange(DAY, 0, Number.MAX_SAFE_INTEGER)).toEqual([]);
});
it("rolls back a card replacement with a missing keyframe", () => {
const base = new Date(`${DAY}T10:00:00`).getTime();
store.replaceCardsInWindow(DAY, base, base + 60_000, [draft({ title: "kept" })]);
expect(() =>
store.replaceCardsInWindow(DAY, base, base + 60_000, [
draft({ title: "invalid", keyframeId: 999_999 }),
]),
).toThrow();
expect(store.cardsForDay(DAY).map((card) => card.title)).toEqual(["kept"]);
});
it("requeues errored batches for explicit retry", () => {
const t0 = Date.now();
const frameId = insertFrame(t0);
@@ -182,4 +327,95 @@ describe("LogbookStore", () => {
store.saveStandup(DAY, "## Done\n- shipped more");
expect(store.getStandup(DAY)?.text).toContain("shipped more");
});
it("migrates legacy tables to STRICT without losing batch assignments", () => {
store.close();
const databasePath = path.join(dir, "logbook.sqlite");
rmSync(databasePath, { force: true });
const legacy = new DatabaseSync(databasePath);
legacy.exec(`
CREATE TABLE batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
frame_count INTEGER NOT NULL DEFAULT 0,
model TEXT,
created_ms INTEGER NOT NULL,
updated_ms INTEGER NOT NULL
);
CREATE TABLE frames (
id INTEGER PRIMARY KEY AUTOINCREMENT,
captured_at_ms INTEGER NOT NULL,
day TEXT NOT NULL,
path TEXT NOT NULL,
screen_index INTEGER NOT NULL DEFAULT 0,
width INTEGER,
height INTEGER,
byte_size INTEGER NOT NULL DEFAULT 0,
content_hash TEXT NOT NULL,
idle INTEGER NOT NULL DEFAULT 0,
batch_id INTEGER
);
INSERT INTO batches VALUES (7, '${DAY}', 10, 20, 'pending', NULL, 1, NULL, 10, 10);
INSERT INTO frames VALUES (11, 10, '${DAY}', '/tmp/frame.jpg', 0, NULL, NULL, 1, 'hash', 0, 7);
`);
legacy.close();
store = new LogbookStore(dir);
expect(store.batchFrames(7).map((frame) => frame.id)).toEqual([11]);
const migrated = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(
migrated
.prepare(
`SELECT name, strict FROM pragma_table_list
WHERE schema = 'main' AND type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`,
)
.all(),
).toEqual([
{ name: "batches", strict: 1 },
{ name: "cards", strict: 1 },
{ name: "frames", strict: 1 },
{ name: "observations", strict: 1 },
{ name: "standups", strict: 1 },
]);
} finally {
migrated.close();
}
});
it("rolls back a legacy STRICT migration when stored data has the wrong type", () => {
const legacyDir = path.join(dir, "invalid-legacy");
mkdirSync(legacyDir);
const databasePath = path.join(legacyDir, "logbook.sqlite");
const legacy = new DatabaseSync(databasePath);
legacy.exec(`
CREATE TABLE standups (day TEXT PRIMARY KEY, text TEXT NOT NULL, updated_ms TEXT NOT NULL);
INSERT INTO standups VALUES ('${DAY}', 'legacy', 'not-an-integer');
`);
legacy.close();
expect(() => new LogbookStore(legacyDir)).toThrow(
"Failed migrating SQLite table standups to STRICT",
);
const preserved = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(
preserved.prepare("SELECT strict FROM pragma_table_list WHERE name = 'standups'").get(),
).toEqual({ strict: 0 });
expect(preserved.prepare("SELECT * FROM standups").get()).toEqual({
day: DAY,
text: "legacy",
updated_ms: "not-an-integer",
});
} finally {
preserved.close();
}
});
});
+192 -96
View File
@@ -4,6 +4,11 @@
import { chmodSync, mkdirSync, rmdirSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import {
configureSqliteConnectionPragmas,
migrateSqliteSchemaToStrict,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-runtime";
import type {
LogbookBatch,
LogbookBatchStatus,
@@ -23,7 +28,22 @@ function loadNodeSqlite(): SqliteModule {
return req("node:sqlite") as SqliteModule;
}
const LOGBOOK_SCHEMA_VERSION = 1;
const LOGBOOK_SQLITE_BUSY_TIMEOUT_MS = 5_000;
const SCHEMA = `
CREATE TABLE IF NOT EXISTS batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'done', 'error')),
error TEXT,
frame_count INTEGER NOT NULL DEFAULT 0,
model TEXT,
created_ms INTEGER NOT NULL,
updated_ms INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_logbook_batches_day ON batches (day, start_ms);
CREATE TABLE IF NOT EXISTS frames (
id INTEGER PRIMARY KEY AUTOINCREMENT,
captured_at_ms INTEGER NOT NULL,
@@ -34,32 +54,19 @@ CREATE TABLE IF NOT EXISTS frames (
height INTEGER,
byte_size INTEGER NOT NULL DEFAULT 0,
content_hash TEXT NOT NULL,
idle INTEGER NOT NULL DEFAULT 0,
batch_id INTEGER
);
idle INTEGER NOT NULL DEFAULT 0 CHECK (idle IN (0, 1)),
batch_id INTEGER REFERENCES batches(id) ON DELETE SET NULL
) STRICT;
CREATE INDEX IF NOT EXISTS idx_logbook_frames_day ON frames (day, captured_at_ms);
CREATE INDEX IF NOT EXISTS idx_logbook_frames_unbatched ON frames (batch_id) WHERE batch_id IS NULL;
CREATE TABLE IF NOT EXISTS batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
frame_count INTEGER NOT NULL DEFAULT 0,
model TEXT,
created_ms INTEGER NOT NULL,
updated_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logbook_batches_day ON batches (day, start_ms);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL,
batch_id INTEGER NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
day TEXT NOT NULL,
start_ms INTEGER NOT NULL,
end_ms INTEGER NOT NULL,
text TEXT NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_logbook_observations_day ON observations (day, start_ms);
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -73,15 +80,15 @@ CREATE TABLE IF NOT EXISTS cards (
app_primary TEXT,
app_secondary TEXT,
distractions TEXT NOT NULL DEFAULT '[]',
keyframe_id INTEGER,
keyframe_id INTEGER REFERENCES frames(id) ON DELETE SET NULL,
updated_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_logbook_cards_day ON cards (day, start_ms);
CREATE TABLE IF NOT EXISTS standups (
day TEXT PRIMARY KEY,
text TEXT NOT NULL,
updated_ms INTEGER NOT NULL
);
) STRICT;
`;
type FrameRow = {
@@ -195,6 +202,7 @@ export function dayKeyFor(ms: number): string {
export class LogbookStore {
private readonly db: Database;
private readonly walMaintenance: ReturnType<typeof configureSqliteConnectionPragmas>;
readonly framesDir: string;
constructor(readonly dataDir: string) {
@@ -207,15 +215,47 @@ export class LogbookStore {
chmodSync(this.framesDir, 0o700);
const { DatabaseSync } = loadNodeSqlite();
const dbPath = path.join(dataDir, "logbook.sqlite");
this.db = new DatabaseSync(dbPath);
// WAL/SHM sidecars inherit the main DB file's permissions.
chmodSync(dbPath, 0o600);
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA busy_timeout = 1000");
this.db.exec(SCHEMA);
const db = new DatabaseSync(dbPath);
let walMaintenance: ReturnType<typeof configureSqliteConnectionPragmas> | undefined;
try {
// WAL/SHM sidecars inherit the main DB file's permissions.
chmodSync(dbPath, 0o600);
walMaintenance = configureSqliteConnectionPragmas(db, {
busyTimeoutMs: LOGBOOK_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: "logbook",
databasePath: dbPath,
foreignKeys: true,
synchronous: "NORMAL",
});
const versionRow = db.prepare("PRAGMA user_version").get() as
| { user_version?: unknown }
| undefined;
const schemaVersion = Number(versionRow?.user_version ?? 0);
if (schemaVersion > LOGBOOK_SCHEMA_VERSION) {
throw new Error(
`Logbook database uses newer schema version ${schemaVersion}; this build supports ${LOGBOOK_SCHEMA_VERSION}`,
);
}
db.exec(SCHEMA);
if (schemaVersion < LOGBOOK_SCHEMA_VERSION) {
migrateSqliteSchemaToStrict(db, SCHEMA, { databaseLabel: dbPath });
db.exec(`PRAGMA user_version = ${LOGBOOK_SCHEMA_VERSION};`);
}
} catch (error) {
walMaintenance?.close();
db.close();
throw error;
}
if (!walMaintenance) {
db.close();
throw new Error("Logbook SQLite maintenance failed to initialize");
}
this.db = db;
this.walMaintenance = walMaintenance;
}
close(): void {
this.walMaintenance.close();
this.db.close();
}
@@ -302,19 +342,43 @@ export class LogbookStore {
}
createBatch(params: { day: string; startMs: number; endMs: number; frameIds: number[] }): number {
const now = Date.now();
const result = this.db
.prepare(
`INSERT INTO batches (day, start_ms, end_ms, status, frame_count, created_ms, updated_ms)
VALUES (?, ?, ?, 'pending', ?, ?, ?)`,
)
.run(params.day, params.startMs, params.endMs, params.frameIds.length, now, now);
const batchId = Number(result.lastInsertRowid);
const assign = this.db.prepare(`UPDATE frames SET batch_id = ? WHERE id = ?`);
for (const frameId of params.frameIds) {
assign.run(batchId, frameId);
if (params.frameIds.length === 0) {
throw new Error("Logbook batch requires at least one frame");
}
return batchId;
const now = Date.now();
const insertBatch = this.db.prepare(
`INSERT INTO batches (day, start_ms, end_ms, status, frame_count, created_ms, updated_ms)
VALUES (?, ?, ?, 'pending', ?, ?, ?)`,
);
const assignFrame = this.db.prepare(
`UPDATE frames SET batch_id = ? WHERE id = ? AND batch_id IS NULL`,
);
return runSqliteImmediateTransactionSync(
this.db,
() => {
const result = insertBatch.run(
params.day,
params.startMs,
params.endMs,
params.frameIds.length,
now,
now,
);
const batchId = Number(result.lastInsertRowid);
for (const frameId of params.frameIds) {
const assignment = assignFrame.run(batchId, frameId);
if (assignment.changes !== 1) {
throw new Error(`Logbook frame ${frameId} is missing or already batched`);
}
}
return batchId;
},
{
busyTimeoutMs: LOGBOOK_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: "logbook",
operationLabel: "logbook.batch.create",
},
);
}
setBatchStatus(
@@ -387,20 +451,24 @@ export class LogbookStore {
day: string,
segments: Array<{ startMs: number; endMs: number; text: string }>,
): void {
this.db.exec("BEGIN");
try {
this.db.prepare(`DELETE FROM observations WHERE batch_id = ?`).run(batchId);
const insert = this.db.prepare(
`INSERT INTO observations (batch_id, day, start_ms, end_ms, text) VALUES (?, ?, ?, ?, ?)`,
);
for (const segment of segments) {
insert.run(batchId, day, segment.startMs, segment.endMs, segment.text);
}
this.db.exec("COMMIT");
} catch (err) {
this.db.exec("ROLLBACK");
throw err;
}
const deleteBatch = this.db.prepare(`DELETE FROM observations WHERE batch_id = ?`);
const insert = this.db.prepare(
`INSERT INTO observations (batch_id, day, start_ms, end_ms, text) VALUES (?, ?, ?, ?, ?)`,
);
runSqliteImmediateTransactionSync(
this.db,
() => {
deleteBatch.run(batchId);
for (const segment of segments) {
insert.run(batchId, day, segment.startMs, segment.endMs, segment.text);
}
},
{
busyTimeoutMs: LOGBOOK_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: "logbook",
operationLabel: "logbook.observations.replace",
},
);
}
observationsInRange(day: string, startMs: number, endMs: number): LogbookObservation[] {
@@ -459,36 +527,40 @@ export class LogbookStore {
drafts: LogbookCardDraft[],
): void {
const now = Date.now();
this.db.exec("BEGIN");
try {
this.db
.prepare(`DELETE FROM cards WHERE day = ? AND end_ms > ? AND start_ms < ?`)
.run(day, startMs, endMs);
const insert = this.db.prepare(
`INSERT INTO cards (day, start_ms, end_ms, title, summary, detail, category, app_primary, app_secondary, distractions, keyframe_id, updated_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const draft of drafts) {
insert.run(
draft.day,
draft.startMs,
draft.endMs,
draft.title,
draft.summary,
draft.detail,
draft.category,
draft.appPrimary ?? null,
draft.appSecondary ?? null,
JSON.stringify(draft.distractions),
draft.keyframeId ?? null,
now,
);
}
this.db.exec("COMMIT");
} catch (err) {
this.db.exec("ROLLBACK");
throw err;
}
const deleteWindow = this.db.prepare(
`DELETE FROM cards WHERE day = ? AND end_ms > ? AND start_ms < ?`,
);
const insert = this.db.prepare(
`INSERT INTO cards (day, start_ms, end_ms, title, summary, detail, category, app_primary, app_secondary, distractions, keyframe_id, updated_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
runSqliteImmediateTransactionSync(
this.db,
() => {
deleteWindow.run(day, startMs, endMs);
for (const draft of drafts) {
insert.run(
draft.day,
draft.startMs,
draft.endMs,
draft.title,
draft.summary,
draft.detail,
draft.category,
draft.appPrimary ?? null,
draft.appSecondary ?? null,
JSON.stringify(draft.distractions),
draft.keyframeId ?? null,
now,
);
}
},
{
busyTimeoutMs: LOGBOOK_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: "logbook",
operationLabel: "logbook.cards.replace",
},
);
}
listDays(): Array<{ day: string; cards: number; firstMs: number; lastMs: number }> {
@@ -552,32 +624,56 @@ export class LogbookStore {
/** Deletes frame rows and files older than the retention window. */
pruneFrames(olderThanMs: number): number {
const rows = this.db
.prepare(`SELECT id, path, day FROM frames WHERE captured_at_ms < ?`)
.all(olderThanMs) as Array<{ id: number; path: string; day: string }>;
const selectExpired = this.db.prepare(
`SELECT id, path, day FROM frames WHERE captured_at_ms < ?`,
);
const rows = selectExpired.all(olderThanMs) as Array<{
id: number;
path: string;
day: string;
}>;
if (rows.length === 0) {
return 0;
}
const days = new Set<string>();
for (const row of rows) {
// Keep metadata until every file operation succeeds. A later retry can
// then find rows whose earlier files were already removed with force.
rmSync(row.path, { force: true });
days.add(row.day);
}
// Cards outlive their frames; a dangling keyframe_id would make the UI
// retry a preview fetch forever, so detach it before the rows disappear.
this.db
.prepare(
`UPDATE cards SET keyframe_id = NULL
WHERE keyframe_id IN (SELECT id FROM frames WHERE captured_at_ms < ?)`,
)
.run(olderThanMs);
this.db.prepare(`DELETE FROM frames WHERE captured_at_ms < ?`).run(olderThanMs);
const selectCurrent = this.db.prepare(`SELECT path FROM frames WHERE id = ?`);
const deleteById = this.db.prepare(`DELETE FROM frames WHERE id = ?`);
const deleted = runSqliteImmediateTransactionSync(
this.db,
() => {
let count = 0;
for (const row of rows) {
const current = selectCurrent.get(row.id) as { path: string } | undefined;
if (!current) {
continue;
}
if (current.path !== row.path) {
throw new Error(`Logbook frame ${row.id} changed path while pruning`);
}
// keyframe_id uses ON DELETE SET NULL, so the same commit cannot
// leave surviving cards pointed at removed frame rows.
count += Number(deleteById.run(row.id).changes);
}
return count;
},
{
busyTimeoutMs: LOGBOOK_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: "logbook",
operationLabel: "logbook.frames.prune",
},
);
for (const day of days) {
// Best-effort: removes now-empty day directories, keeps non-empty ones.
try {
rmdirSync(path.join(this.framesDir, day));
} catch {}
}
return rows.length;
return deleted;
}
}
@@ -500,7 +500,7 @@ function copyLegacyMemoryIndexRows(
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
) STRICT;
INSERT OR IGNORE INTO main.${MEMORY_EMBEDDING_CACHE_TABLE} (
provider, model, provider_key, hash, embedding, dims, updated_at
)
@@ -85,7 +85,13 @@ describe("memory session sync state", () => {
{
absPath: "/tmp/sessions/unchanged.jsonl",
path: "sessions/unchanged.jsonl",
mtimeMs: 100,
mtimeMs: 100.75,
size: 10,
},
{
absPath: "/tmp/sessions/sub-ms-newer.jsonl",
path: "sessions/sub-ms-newer.jsonl",
mtimeMs: 100.75,
size: 10,
},
{
@@ -108,13 +114,15 @@ describe("memory session sync state", () => {
},
],
existingRows: [
{ path: "sessions/unchanged.jsonl", hash: "hash-unchanged", mtime: 100, size: 10 },
{ path: "sessions/unchanged.jsonl", hash: "hash-unchanged", mtime: 100.75, size: 10 },
{ path: "sessions/sub-ms-newer.jsonl", hash: "hash-sub-ms", mtime: 100.25, size: 10 },
{ path: "sessions/newer.jsonl", hash: "hash-newer", mtime: 200, size: 20 },
{ path: "sessions/resized.jsonl", hash: "hash-resized", mtime: 300, size: 30 },
],
});
expect(dirtyFiles).toEqual([
"/tmp/sessions/sub-ms-newer.jsonl",
"/tmp/sessions/newer.jsonl",
"/tmp/sessions/resized.jsonl",
"/tmp/sessions/missing.jsonl",
@@ -19,6 +19,7 @@ import {
type MemorySyncParams,
type MemorySyncProgressUpdate,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { runSqliteImmediateTransactionSync } from "openclaw/plugin-sdk/sqlite-runtime";
import type { MemoryCoreAcquireLocalService } from "./embedding-local-service.js";
import {
resolveEmbeddingProviderAdapterId,
@@ -94,6 +95,7 @@ const META_KEY = MEMORY_INDEX_META_KEY;
const VECTOR_TABLE = MEMORY_INDEX_VECTOR_TABLE;
const LEGACY_VECTOR_TABLE = "chunks_vec";
const EMBEDDING_CACHE_TABLE = MEMORY_EMBEDDING_CACHE_TABLE;
const EMBEDDING_CACHE_SEED_BATCH_SIZE = 1_000;
const VECTOR_LOAD_TIMEOUT_MS = 30_000;
const log = createSubsystemLogger("memory");
@@ -581,62 +583,63 @@ export abstract class MemoryManagerSyncBase {
if (!this.cache.enabled) {
return;
}
let transactionStarted = false;
try {
const rows = sourceDb
.prepare(
`SELECT provider, model, provider_key, hash, embedding, dims, updated_at FROM ${EMBEDDING_CACHE_TABLE}`,
)
.iterate() as IterableIterator<{
provider: string;
model: string;
provider_key: string;
hash: string;
embedding: string;
dims: number | null;
updated_at: number;
}>;
let rowCount = 0;
let insert: ReturnType<DatabaseSync["prepare"]> | null = null;
for (const row of rows) {
if (!insert) {
insert = this.db.prepare(
`INSERT INTO ${EMBEDDING_CACHE_TABLE} (provider, model, provider_key, hash, embedding, dims, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider, model, provider_key, hash) DO UPDATE SET
embedding=excluded.embedding,
dims=excluded.dims,
updated_at=excluded.updated_at`,
);
this.db.exec("BEGIN");
transactionStarted = true;
}
insert.run(
row.provider,
row.model,
row.provider_key,
row.hash,
row.embedding,
row.dims,
row.updated_at,
);
rowCount += 1;
if (rowCount % 1000 === 0) {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
type CacheRow = {
rowid: number;
provider: string;
model: string;
provider_key: string;
hash: string;
embedding: string;
dims: number | null;
updated_at: number;
};
const selectBatch = sourceDb.prepare(
`SELECT rowid, provider, model, provider_key, hash, embedding, dims, updated_at
FROM ${EMBEDDING_CACHE_TABLE}
WHERE rowid > ?
ORDER BY rowid
LIMIT ?`,
);
const insert = this.db.prepare(
`INSERT INTO ${EMBEDDING_CACHE_TABLE} (provider, model, provider_key, hash, embedding, dims, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider, model, provider_key, hash) DO UPDATE SET
embedding=excluded.embedding,
dims=excluded.dims,
updated_at=excluded.updated_at`,
);
let lastRowid = 0;
while (true) {
// Materialize each source page so neither a read cursor nor a write
// transaction remains open when control returns to the event loop.
const batch = selectBatch.all(lastRowid, EMBEDDING_CACHE_SEED_BATCH_SIZE) as CacheRow[];
if (batch.length === 0) {
return;
}
if (transactionStarted) {
this.db.exec("COMMIT");
runSqliteImmediateTransactionSync(
this.db,
() => {
for (const row of batch) {
insert.run(
row.provider,
row.model,
row.provider_key,
row.hash,
row.embedding,
row.dims,
row.updated_at,
);
}
},
{ operationLabel: "memory.embedding-cache.seed" },
);
lastRowid = batch[batch.length - 1]?.rowid ?? lastRowid;
if (batch.length < EMBEDDING_CACHE_SEED_BATCH_SIZE) {
return;
}
} catch (err) {
if (transactionStarted) {
try {
this.db.exec("ROLLBACK");
} catch {}
}
throw err;
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
}
@@ -7,7 +7,11 @@ import {
type OpenClawConfig,
type ResolvedMemorySearchConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import type { MemorySource } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import {
ensureMemoryIndexSchema,
requireNodeSqlite,
type MemorySource,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { buildSessionEntryMock } = vi.hoisted(() => ({
@@ -182,6 +186,20 @@ class SessionSyncYieldHarness extends MemoryManagerSyncOps {
}
}
class EmbeddingCacheSeedHarness extends SessionSyncYieldHarness {
protected override readonly cache = { enabled: true };
protected override db: DatabaseSync;
constructor(db: DatabaseSync) {
super(() => {});
this.db = db;
}
async seedCache(sourceDb: DatabaseSync): Promise<void> {
await this.seedEmbeddingCache(sourceDb);
}
}
describe("session sync responsiveness", () => {
beforeEach(() => {
setSyncYieldStateDir();
@@ -229,3 +247,75 @@ describe("session sync responsiveness", () => {
await immediate;
});
});
describe("embedding cache seed responsiveness", () => {
const { DatabaseSync: NodeDatabaseSync } = requireNodeSqlite();
function createCacheDb(): DatabaseSync {
const db = new NodeDatabaseSync(":memory:");
ensureMemoryIndexSchema({
db,
cacheEnabled: true,
ftsEnabled: false,
ftsTokenizer: "unicode61",
});
return db;
}
function countCacheRows(db: DatabaseSync): number {
const row = db.prepare("SELECT count(*) AS count FROM memory_embedding_cache").get() as {
count: number;
};
return row.count;
}
it("commits each materialized page before yielding", async () => {
const sourceDb = createCacheDb();
const targetDb = createCacheDb();
try {
const insert = sourceDb.prepare(
`INSERT INTO memory_embedding_cache
(provider, model, provider_key, hash, embedding, dims, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
);
sourceDb.exec("BEGIN");
for (let index = 0; index < 1_001; index += 1) {
insert.run("test", "model", "key", `hash-${index}`, "[0.5]", 1, index);
}
sourceDb.exec("COMMIT");
let duringYield: {
sourceInTransaction: boolean;
targetInTransaction: boolean;
rows: number;
} | null = null;
const observedYield = new Promise<void>((resolve, reject) => {
setImmediate(() => {
try {
duringYield = {
sourceInTransaction: sourceDb.isTransaction,
targetInTransaction: targetDb.isTransaction,
rows: countCacheRows(targetDb),
};
resolve();
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
}
});
});
await new EmbeddingCacheSeedHarness(targetDb).seedCache(sourceDb);
await observedYield;
expect(duringYield).toEqual({
sourceInTransaction: false,
targetInTransaction: false,
rows: 1_000,
});
expect(countCacheRows(targetDb)).toBe(1_001);
} finally {
sourceDb.close();
targetDb.close();
}
});
});
@@ -3,8 +3,23 @@ import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { requireNodeSqlite } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { migrateSqliteSchemaToStrict } from "openclaw/plugin-sdk/plugin-state-runtime";
const QMD_SESSION_ARTIFACT_TABLE = "openclaw_qmd_session_artifacts";
const QMD_SESSION_ARTIFACT_SCHEMA = `
CREATE TABLE IF NOT EXISTS ${QMD_SESSION_ARTIFACT_TABLE} (
collection TEXT NOT NULL,
artifact_path TEXT NOT NULL,
search_path TEXT NOT NULL,
docid TEXT,
memory_key TEXT NOT NULL,
agent_id TEXT NOT NULL,
session_id TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (collection, artifact_path)
) STRICT;
`;
const QMD_SESSION_ARTIFACT_HIT: unique symbol = Symbol("openclaw.qmdSessionArtifactHit");
@@ -49,26 +64,21 @@ type QmdSessionArtifactRow = {
};
function ensureQmdSessionArtifactSchema(db: DatabaseSync): void {
db.exec(
`CREATE TABLE IF NOT EXISTS ${QMD_SESSION_ARTIFACT_TABLE} (
collection TEXT NOT NULL,
artifact_path TEXT NOT NULL,
search_path TEXT NOT NULL,
docid TEXT,
memory_key TEXT NOT NULL,
agent_id TEXT NOT NULL,
session_id TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (collection, artifact_path)
)`,
);
db.exec(QMD_SESSION_ARTIFACT_SCHEMA);
try {
db.exec(
`ALTER TABLE ${QMD_SESSION_ARTIFACT_TABLE}
ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`,
);
} catch {}
const table = db
.prepare("SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = ?")
.get(QMD_SESSION_ARTIFACT_TABLE) as { strict?: unknown } | undefined;
if (Number(table?.strict ?? 0) !== 1) {
migrateSqliteSchemaToStrict(db, QMD_SESSION_ARTIFACT_SCHEMA, {
databaseLabel: "QMD session artifact mappings",
});
}
db.exec(
`CREATE INDEX IF NOT EXISTS idx_openclaw_qmd_session_artifacts_docid
ON ${QMD_SESSION_ARTIFACT_TABLE} (docid)`,
@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -97,6 +98,78 @@ describe("filterMemorySearchHitsBySessionVisibility", () => {
return attachQmdSessionArtifactHit(hit, identity);
}
it("migrates legacy QMD artifact mappings to STRICT without losing rows", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-qmd-session-artifact-"));
tempRoots.push(root);
const indexPath = path.join(root, "index.sqlite");
const legacy = new DatabaseSync(indexPath);
legacy.exec(`
CREATE TABLE openclaw_qmd_session_artifacts (
collection TEXT NOT NULL,
artifact_path TEXT NOT NULL,
search_path TEXT NOT NULL,
docid TEXT,
memory_key TEXT NOT NULL,
agent_id TEXT NOT NULL,
session_id TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (collection, artifact_path)
);
INSERT INTO openclaw_qmd_session_artifacts (
collection, artifact_path, search_path, docid, memory_key, agent_id, session_id, updated_at
) VALUES ('legacy', 'old.md', 'qmd/legacy/old.md', NULL, 'old-key', 'main', 'old', 1);
`);
legacy.close();
replaceQmdSessionArtifactMappings({
collection: "current",
indexPath,
mappings: [
{
agentId: "main",
archived: true,
artifactPath: "new.md",
collection: "current",
memoryKey: "new-key",
searchPath: "qmd/current/new.md",
sessionId: "new",
},
],
});
const migrated = new DatabaseSync(indexPath);
try {
expect(
migrated
.prepare(
"SELECT strict FROM pragma_table_list WHERE name = 'openclaw_qmd_session_artifacts'",
)
.get(),
).toEqual({ strict: 1 });
expect(
migrated
.prepare(
`SELECT collection, artifact_path, archived
FROM openclaw_qmd_session_artifacts
ORDER BY collection`,
)
.all(),
).toEqual([
{ collection: "current", artifact_path: "new.md", archived: 1 },
{ collection: "legacy", artifact_path: "old.md", archived: 0 },
]);
expect(() =>
migrated
.prepare(
"UPDATE openclaw_qmd_session_artifacts SET archived = ? WHERE collection = 'legacy'",
)
.run("not-an-integer"),
).toThrow();
} finally {
migrated.close();
}
});
it("drops sessions-sourced hits when requester key is missing (fail closed)", async () => {
const cfg = asOpenClawConfig({ tools: { sessions: { visibility: "all" } } });
const hits: MemorySearchResult[] = [
+39 -26
View File
@@ -16,7 +16,10 @@ import type {
WorkboardRunAttempt,
WorkboardWorkerLog,
} from "@openclaw/workboard-contract";
import { configureSqliteConnectionPragmas } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
configureSqliteConnectionPragmas,
migrateSqliteSchemaToStrict,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import type {
PersistedWorkboardAttachment,
@@ -26,7 +29,7 @@ import type {
WorkboardKeyedStore,
} from "./persistence-types.js";
const WORKBOARD_DB_RELATIVE_PATH = ["plugins", "workboard", "workboard.sqlite"] as const;
const SCHEMA_VERSION = 2;
const SCHEMA_VERSION = 3;
const WORKBOARD_SQLITE_BUSY_TIMEOUT_MS = 5000;
const WORKBOARD_SQLITE_DIR_MODE = 0o700;
const WORKBOARD_SQLITE_FILE_MODE = 0o600;
@@ -132,13 +135,11 @@ function ensureColumn(db: DatabaseSync, tableName: string, columnName: string, d
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${definition}`);
}
function ensureWorkboardSchema(db: DatabaseSync): void {
db.exec(`
PRAGMA foreign_keys = ON;
const WORKBOARD_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS workboard_schema_migrations (
id TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_boards (
id TEXT PRIMARY KEY,
@@ -151,7 +152,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived_at INTEGER
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_cards (
id TEXT PRIMARY KEY,
@@ -187,7 +188,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
stale_json TEXT,
lifecycle_status_source_updated_at INTEGER,
failure_count INTEGER
);
) STRICT;
CREATE INDEX IF NOT EXISTS workboard_cards_board_status_idx
ON workboard_cards(board_id, status, position);
CREATE INDEX IF NOT EXISTS workboard_cards_session_idx
@@ -198,7 +199,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
ordinal INTEGER NOT NULL,
label TEXT NOT NULL,
PRIMARY KEY(card_id, ordinal)
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_card_events (
id TEXT PRIMARY KEY,
@@ -210,7 +211,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
to_status TEXT,
session_key TEXT,
run_id TEXT
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_card_attempts (
id TEXT PRIMARY KEY,
@@ -225,7 +226,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
session_key TEXT,
run_id TEXT,
error TEXT
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_card_comments (
id TEXT PRIMARY KEY,
@@ -234,7 +235,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_card_links (
id TEXT PRIMARY KEY,
@@ -245,7 +246,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
title TEXT,
url TEXT,
created_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_card_proof (
id TEXT PRIMARY KEY,
@@ -257,7 +258,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
url TEXT,
note TEXT,
created_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_card_artifacts (
id TEXT PRIMARY KEY,
@@ -268,7 +269,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
path TEXT,
mime_type TEXT,
created_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_card_diagnostics (
card_id TEXT NOT NULL REFERENCES workboard_cards(id) ON DELETE CASCADE,
@@ -282,7 +283,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
count INTEGER NOT NULL,
actions_json TEXT NOT NULL,
PRIMARY KEY(card_id, ordinal)
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_card_notifications (
id TEXT PRIMARY KEY,
@@ -294,7 +295,7 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
sequence INTEGER,
session_key TEXT,
run_id TEXT
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_worker_logs (
id TEXT PRIMARY KEY,
@@ -305,14 +306,14 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
created_at INTEGER NOT NULL,
session_key TEXT,
run_id TEXT
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_worker_protocol (
card_id TEXT PRIMARY KEY REFERENCES workboard_cards(id) ON DELETE CASCADE,
state TEXT NOT NULL,
updated_at INTEGER NOT NULL,
detail TEXT
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_card_attachments (
id TEXT PRIMARY KEY,
@@ -323,14 +324,14 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
mime_type TEXT,
note TEXT,
created_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS workboard_card_attachments_card_idx
ON workboard_card_attachments(card_id, ordinal);
CREATE TABLE IF NOT EXISTS workboard_attachment_blobs (
attachment_id TEXT PRIMARY KEY,
content BLOB NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS workboard_notification_subscriptions (
id TEXT PRIMARY KEY,
@@ -346,17 +347,29 @@ function ensureWorkboardSchema(db: DatabaseSync): void {
delivered_event_ids_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
) STRICT;
`;
function ensureWorkboardSchema(db: DatabaseSync): void {
db.exec(WORKBOARD_SCHEMA_SQL);
ensureColumn(
db,
"workboard_cards",
"lifecycle_status_source_updated_at",
"lifecycle_status_source_updated_at INTEGER",
);
db.prepare(
"INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at) VALUES (?, ?)",
).run(`schema-${SCHEMA_VERSION}`, Date.now());
const migrationId = `schema-${SCHEMA_VERSION}`;
const current = db
.prepare("SELECT 1 AS found FROM workboard_schema_migrations WHERE id = ?")
.get(migrationId);
if (!current) {
migrateSqliteSchemaToStrict(db, WORKBOARD_SCHEMA_SQL, {
databaseLabel: "workboard database",
});
db.prepare(
"INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at) VALUES (?, ?)",
).run(migrationId, Date.now());
}
}
function chmodIfExists(targetPath: string, mode: number): void {
+80
View File
@@ -197,6 +197,22 @@ describe("WorkboardStore", () => {
expect(rawDb.prepare("PRAGMA journal_mode").get()).toMatchObject({
journal_mode: "wal",
});
expect(
rawDb
.prepare(
`SELECT name FROM pragma_table_list
WHERE schema = 'main'
AND type = 'table'
AND name NOT LIKE 'sqlite_%'
AND strict <> 1`,
)
.all(),
).toEqual([]);
expect(() =>
rawDb
.prepare("INSERT INTO workboard_attachment_blobs (attachment_id, content) VALUES (?, ?)")
.run("wrong-type", "text-not-blob"),
).toThrow();
rawDb.close();
const reopenedStores = createWorkboardSqliteStores({ dbPath });
@@ -239,6 +255,70 @@ describe("WorkboardStore", () => {
}
});
it("migrates a version 2 workboard table to STRICT without losing rows", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-strict-migration-"));
const dbPath = path.join(dir, "workboard.sqlite");
const initialized = createWorkboardSqliteStores({ dbPath });
initialized.close();
const legacy = new DatabaseSync(dbPath);
try {
legacy.exec(`
INSERT INTO workboard_boards (
id, name, description, icon, color, default_workspace_json, orchestration_json,
created_at, updated_at, archived_at
) VALUES ('legacy', 'Legacy board', NULL, NULL, NULL, NULL, NULL, 1, 2, NULL);
ALTER TABLE workboard_boards RENAME TO workboard_boards_strict;
CREATE TABLE workboard_boards (
id TEXT PRIMARY KEY,
name TEXT,
description TEXT,
icon TEXT,
color TEXT,
default_workspace_json TEXT,
orchestration_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
archived_at INTEGER
);
INSERT INTO workboard_boards SELECT * FROM workboard_boards_strict;
DROP TABLE workboard_boards_strict;
DELETE FROM workboard_schema_migrations WHERE id = 'schema-3';
INSERT OR IGNORE INTO workboard_schema_migrations (id, applied_at)
VALUES ('schema-2', 1);
`);
} finally {
legacy.close();
}
try {
const migratedStores = createWorkboardSqliteStores({ dbPath });
try {
await expect(migratedStores.boards.lookup("legacy")).resolves.toMatchObject({
board: { id: "legacy", name: "Legacy board" },
});
} finally {
migratedStores.close();
}
const migrated = new DatabaseSync(dbPath, { readOnly: true });
try {
expect(
migrated
.prepare("SELECT strict FROM pragma_table_list WHERE name = 'workboard_boards'")
.get(),
).toEqual({ strict: 1 });
expect(
migrated
.prepare("SELECT 1 AS found FROM workboard_schema_migrations WHERE id = 'schema-3'")
.get(),
).toEqual({ found: 1 });
} finally {
migrated.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("uses rollback journaling on network-backed volumes", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-sqlite-network-"));
const dbPath = path.join(dir, "workboard.sqlite");
+92 -1
View File
@@ -1,7 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_WORKSPACE } from "./default-workspace.js";
import { validateWorkspaceDoc, type WorkspaceDoc } from "./schema.js";
import { WorkspaceStore } from "./store.js";
@@ -38,6 +40,95 @@ describe("WorkspaceStore", () => {
});
});
it("uses STRICT tables and durable connection pragmas", async () => {
await withStore((store) => {
const database = new DatabaseSync(store.dbPath, { readOnly: true });
try {
expect(
database
.prepare(
`SELECT name, strict FROM pragma_table_list
WHERE schema = 'main' AND type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`,
)
.all(),
).toEqual([
{ name: "undo", strict: 1 },
{ name: "workspace", strict: 1 },
]);
expect(database.prepare("PRAGMA user_version").get()).toEqual({ user_version: 1 });
expect(database.prepare("PRAGMA journal_mode").get()).toEqual({ journal_mode: "wal" });
} finally {
database.close();
}
});
});
it("closes its WAL maintenance timer", async () => {
vi.useFakeTimers();
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-timer-"));
try {
const store = new WorkspaceStore({ stateDir });
expect(vi.getTimerCount()).toBe(1);
store.close();
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("migrates an existing workspace and undo history to STRICT", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-legacy-"));
const workspaceDir = path.join(stateDir, "workspaces");
const databasePath = path.join(workspaceDir, "workspaces.sqlite");
await fs.mkdir(workspaceDir, { recursive: true });
const legacy = new DatabaseSync(databasePath);
legacy.exec(`
CREATE TABLE workspace (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL,
doc TEXT NOT NULL,
updated_ms INTEGER NOT NULL
);
CREATE TABLE undo (
version INTEGER PRIMARY KEY,
doc TEXT NOT NULL,
created_ms INTEGER NOT NULL
);
`);
const serialized = JSON.stringify(DEFAULT_WORKSPACE);
legacy.prepare("INSERT INTO workspace VALUES (1, 1, ?, 10)").run(serialized);
legacy.prepare("INSERT INTO undo VALUES (1, ?, 10)").run(serialized);
legacy.close();
const store = new WorkspaceStore({ stateDir });
try {
expect(store.read()).toEqual(DEFAULT_WORKSPACE);
const migrated = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(
migrated
.prepare(
`SELECT name, strict FROM pragma_table_list
WHERE schema = 'main' AND type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`,
)
.all(),
).toEqual([
{ name: "undo", strict: 1 },
{ name: "workspace", strict: 1 },
]);
expect(migrated.prepare("SELECT COUNT(*) AS count FROM undo").get()).toEqual({ count: 1 });
} finally {
migrated.close();
}
} finally {
store.close();
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("keeps a 20-entry undo ring and restores the newest snapshot as a NEW version", async () => {
await withStore((store) => {
store.read();
+40 -7
View File
@@ -22,7 +22,10 @@
import { chmodSync, mkdirSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { configureSqliteConnectionPragmas } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
configureSqliteConnectionPragmas,
migrateSqliteSchemaToStrict,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { WidgetAssetTokens } from "./asset-tokens.js";
import { DEFAULT_WORKSPACE } from "./default-workspace.js";
@@ -41,6 +44,7 @@ const UNDO_RING_SIZE = 20;
const DIR_MODE = 0o700;
const FILE_MODE = 0o600;
const BUSY_TIMEOUT_MS = 5000;
const SCHEMA_VERSION = 1;
const SCHEMA = `
CREATE TABLE IF NOT EXISTS workspace (
@@ -48,12 +52,12 @@ CREATE TABLE IF NOT EXISTS workspace (
version INTEGER NOT NULL,
doc TEXT NOT NULL,
updated_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS undo (
version INTEGER PRIMARY KEY,
doc TEXT NOT NULL,
created_ms INTEGER NOT NULL
);
) STRICT;
`;
function serializeWorkspaceDoc(doc: WorkspaceDoc): string {
@@ -112,6 +116,7 @@ export class WorkspaceStore {
readonly workspaceDir: string;
readonly dbPath: string;
private readonly db: DatabaseSync;
private readonly walMaintenance: ReturnType<typeof configureSqliteConnectionPragmas>;
readonly assetTokens = new WidgetAssetTokens();
/**
* Single-slot cache of the parsed document. This process is the only writer
@@ -126,19 +131,47 @@ export class WorkspaceStore {
this.workspaceDir = path.join(this.stateDir, "workspaces");
this.dbPath = path.join(this.workspaceDir, "workspaces.sqlite");
mkdirSync(this.workspaceDir, { recursive: true, mode: DIR_MODE });
this.db = new DatabaseSync(this.dbPath);
const db = new DatabaseSync(this.dbPath);
let walMaintenance: ReturnType<typeof configureSqliteConnectionPragmas> | undefined;
try {
configureSqliteConnectionPragmas(this.db, { busyTimeoutMs: BUSY_TIMEOUT_MS });
walMaintenance = configureSqliteConnectionPragmas(db, {
busyTimeoutMs: BUSY_TIMEOUT_MS,
databaseLabel: "workspaces",
databasePath: this.dbPath,
foreignKeys: true,
synchronous: "NORMAL",
});
// WAL/SHM sidecars inherit the main DB file's permissions.
chmodSync(this.dbPath, FILE_MODE);
this.db.exec(SCHEMA);
const versionRow = db.prepare("PRAGMA user_version").get() as
| { user_version?: unknown }
| undefined;
const schemaVersion = Number(versionRow?.user_version ?? 0);
if (schemaVersion > SCHEMA_VERSION) {
throw new Error(
`Workspaces database uses newer schema version ${schemaVersion}; this build supports ${SCHEMA_VERSION}`,
);
}
db.exec(SCHEMA);
if (schemaVersion < SCHEMA_VERSION) {
migrateSqliteSchemaToStrict(db, SCHEMA, { databaseLabel: this.dbPath });
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
}
} catch (error) {
this.db.close();
walMaintenance?.close();
db.close();
throw error;
}
if (!walMaintenance) {
db.close();
throw new Error("Workspaces SQLite maintenance failed to initialize");
}
this.db = db;
this.walMaintenance = walMaintenance;
}
close(): void {
this.walMaintenance.close();
this.db.close();
}
@@ -46,7 +46,7 @@ describe("memory index schema", () => {
start_line UNINDEXED, end_line UNINDEXED
);
INSERT INTO meta VALUES ('memory_index_meta_v1', '{"vectorDims":3}');
INSERT INTO files VALUES ('MEMORY.md', 'memory', 'file-hash', 10, 20);
INSERT INTO files VALUES ('MEMORY.md', 'memory', 'file-hash', 10.75, 20);
INSERT INTO chunks VALUES (
'chunk-1', 'MEMORY.md', 'memory', 1, 2, 'chunk-hash', 'embed-model',
'remember this', '[1,0,0]', 30
@@ -67,7 +67,14 @@ describe("memory index schema", () => {
expect(result.ftsAvailable).toBe(true);
expect(db.prepare("SELECT * FROM memory_index_sources").all()).toEqual([
{ id: 1, path: "MEMORY.md", source: "memory", hash: "file-hash", mtime: 10, size: 20 },
{
id: 1,
path: "MEMORY.md",
source: "memory",
hash: "file-hash",
mtime: 10.75,
size: 20,
},
]);
expect(db.prepare("SELECT id, text FROM memory_index_chunks").all()).toEqual([
{ id: "chunk-1", text: "remember this" },
@@ -78,6 +85,17 @@ describe("memory index schema", () => {
expect(db.prepare("SELECT provider, hash FROM memory_embedding_cache").all()).toEqual([
{ provider: "openai", hash: "chunk-hash" },
]);
expect(
db
.prepare(
`SELECT name FROM pragma_table_list
WHERE schema = 'main'
AND type = 'table'
AND name LIKE 'memory_%'
AND strict <> 1`,
)
.all(),
).toEqual([]);
expect(
db
.prepare(
@@ -90,6 +108,81 @@ describe("memory index schema", () => {
}
});
it("upgrades already-canonical memory tables to STRICT and preserves precise mtimes", () => {
const db = new DatabaseSync(":memory:");
try {
db.exec(`
CREATE TABLE memory_index_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE memory_index_sources (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL,
UNIQUE (path, source)
);
CREATE TABLE memory_index_chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
hash TEXT NOT NULL,
model TEXT NOT NULL,
text TEXT NOT NULL,
embedding TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE memory_index_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL
);
INSERT INTO memory_index_meta VALUES ('memory_index_meta_v1', '{}');
INSERT INTO memory_index_sources
(path, source, hash, mtime, size)
VALUES ('MEMORY.md', 'memory', 'source-hash', 10.75, 20);
INSERT INTO memory_index_chunks VALUES (
'chunk-1', 'MEMORY.md', 'memory', 1, 1, 'chunk-hash', 'model', 'body', '[]', 30
);
INSERT INTO memory_index_state VALUES (1, 3);
`);
ensureMemoryIndexSchema({ db, cacheEnabled: false, ftsEnabled: false });
expect(
db
.prepare(
`SELECT name FROM pragma_table_list
WHERE schema = 'main'
AND type = 'table'
AND name LIKE 'memory_index_%'
AND strict <> 1`,
)
.all(),
).toEqual([]);
expect(
db.prepare("SELECT mtime, typeof(mtime) AS storage_type FROM memory_index_sources").get(),
).toEqual({ mtime: 10.75, storage_type: "real" });
expect(
db
.prepare(
"SELECT type FROM pragma_table_info('memory_index_sources') WHERE name = 'mtime'",
)
.get(),
).toEqual({ type: "REAL" });
expect(db.prepare("SELECT id, text FROM memory_index_chunks").get()).toEqual({
id: "chunk-1",
text: "body",
});
} finally {
db.close();
}
});
it("does not import a legacy sidecar memory database during schema startup", () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-memory-sidecar-"));
const legacyPath = path.join(rootDir, "memory", "main.sqlite");
@@ -406,7 +499,7 @@ describe("memory index schema", () => {
mtime INTEGER NOT NULL,
size INTEGER NOT NULL
);
INSERT INTO memory_index_sources VALUES ('shared.md', 'memory', 'memory-hash', 10, 20);
INSERT INTO memory_index_sources VALUES ('shared.md', 'memory', 'memory-hash', 10.75, 20);
`);
ensureMemoryIndexSchema({
@@ -420,10 +513,12 @@ describe("memory index schema", () => {
).run("shared.md", "sessions", "session-hash", 30, 40);
expect(
db.prepare("SELECT id, path, source, hash FROM memory_index_sources ORDER BY source").all(),
db
.prepare("SELECT id, path, source, hash, mtime FROM memory_index_sources ORDER BY source")
.all(),
).toEqual([
{ id: 1, path: "shared.md", source: "memory", hash: "memory-hash" },
{ id: 2, path: "shared.md", source: "sessions", hash: "session-hash" },
{ id: 1, path: "shared.md", source: "memory", hash: "memory-hash", mtime: 10.75 },
{ id: 2, path: "shared.md", source: "sessions", hash: "session-hash", mtime: 30 },
]);
ensureMemoryIndexSchema({ db, cacheEnabled: false, ftsEnabled: false });
expect(db.prepare("SELECT id FROM memory_index_sources ORDER BY id").all()).toEqual([
@@ -1,6 +1,7 @@
// Memory Host SDK module implements memory schema behavior.
import type { DatabaseSync } from "node:sqlite";
import { formatErrorMessage } from "./error-utils.js";
import { migrateSqliteSchemaToStrict } from "./openclaw-runtime-sqlite.js";
// SQLite schema setup for builtin memory index, embedding cache, and FTS.
@@ -68,7 +69,7 @@ const MEMORY_INDEX_SOURCE_COLUMN_TYPES = new Map<string, string>([
["path", "TEXT"],
["source", "TEXT"],
["hash", "TEXT"],
["mtime", "INTEGER"],
["mtime", "REAL"],
["size", "INTEGER"],
]);
@@ -191,7 +192,7 @@ function tableHasCanonicalSourceColumnTypes(db: DatabaseSync): boolean {
const expectedType = MEMORY_INDEX_SOURCE_COLUMN_TYPES.get(column.name);
const expectedDefault = column.name === "source" ? "'memory'" : null;
if (
column.type !== expectedType ||
(column.type !== expectedType && !(column.name === "mtime" && column.type === "INTEGER")) ||
column.defaultValue !== expectedDefault ||
column.hidden !== 0
) {
@@ -295,10 +296,10 @@ export function migrateMemoryIndexSourcesIdentity(db: DatabaseSync): void {
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
mtime REAL NOT NULL,
size INTEGER NOT NULL,
UNIQUE (path, source)
);
) STRICT;
INSERT INTO ${MEMORY_INDEX_SOURCES_TABLE} (id, path, source, hash, mtime, size)
SELECT rowid, path, source, hash, mtime, size
FROM memory_index_sources_identity_migration;
@@ -363,7 +364,8 @@ function copyLegacyMemoryIndexRows(
SELECT key, value FROM ${schema}.meta;
INSERT OR IGNORE INTO main.${MEMORY_INDEX_SOURCES_TABLE} (path, source, hash, mtime, size)
SELECT path, source, hash, mtime, size FROM ${schema}.files;
SELECT path, source, hash, mtime, size
FROM ${schema}.files;
INSERT OR IGNORE INTO main.${MEMORY_INDEX_CHUNKS_TABLE} (
id, path, source, start_line, end_line, hash, model, text, embedding, updated_at
@@ -428,7 +430,7 @@ function copyLegacyMemoryIndexRows(
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
) STRICT;
INSERT OR IGNORE INTO main.${MEMORY_EMBEDDING_CACHE_TABLE} (
provider, model, provider_key, hash, embedding, dims, updated_at
)
@@ -526,6 +528,58 @@ function ensureMemoryPathFtsSchema(params: { db: DatabaseSync; tokenizeClause: s
}
}
function buildMemoryIndexStrictSchema(params: {
embeddingCacheTable: string;
includeEmbeddingCache: boolean;
}): string {
const embeddingCacheSql = params.includeEmbeddingCache
? `
CREATE TABLE IF NOT EXISTS ${params.embeddingCacheTable} (
provider TEXT NOT NULL,
model TEXT NOT NULL,
provider_key TEXT NOT NULL,
hash TEXT NOT NULL,
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
) STRICT;
`
: "";
return `
CREATE TABLE IF NOT EXISTS ${MEMORY_INDEX_META_TABLE} (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS ${MEMORY_INDEX_SOURCES_TABLE} (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime REAL NOT NULL,
size INTEGER NOT NULL,
UNIQUE (path, source)
) STRICT;
CREATE TABLE IF NOT EXISTS ${MEMORY_INDEX_CHUNKS_TABLE} (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
hash TEXT NOT NULL,
model TEXT NOT NULL,
text TEXT NOT NULL,
embedding TEXT NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS ${MEMORY_INDEX_STATE_TABLE} (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL
) STRICT;
${embeddingCacheSql}
`;
}
/** Ensure canonical memory index tables and the optional FTS table exist. */
export function ensureMemoryIndexSchema(params: {
db: DatabaseSync;
@@ -539,36 +593,13 @@ export function ensureMemoryIndexSchema(params: {
}): { ftsAvailable: boolean; ftsError?: string } {
const embeddingCacheTable = params.embeddingCacheTable ?? MEMORY_EMBEDDING_CACHE_TABLE;
const ftsTable = params.ftsTable ?? MEMORY_INDEX_FTS_TABLE;
params.db.exec(
buildMemoryIndexStrictSchema({
embeddingCacheTable,
includeEmbeddingCache: params.cacheEnabled,
}),
);
params.db.exec(`
CREATE TABLE IF NOT EXISTS ${MEMORY_INDEX_META_TABLE} (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ${MEMORY_INDEX_SOURCES_TABLE} (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL,
UNIQUE (path, source)
);
CREATE TABLE IF NOT EXISTS ${MEMORY_INDEX_CHUNKS_TABLE} (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
hash TEXT NOT NULL,
model TEXT NOT NULL,
text TEXT NOT NULL,
embedding TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ${MEMORY_INDEX_STATE_TABLE} (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL
);
INSERT OR IGNORE INTO ${MEMORY_INDEX_STATE_TABLE} (id, revision) VALUES (1, 0);
`);
migrateMemoryIndexSourcesIdentity(params.db);
@@ -622,20 +653,18 @@ export function ensureMemoryIndexSchema(params: {
? "idx_memory_embedding_cache_updated_at"
: "idx_embedding_cache_updated_at";
params.db.exec(`
CREATE TABLE IF NOT EXISTS ${embeddingCacheTable} (
provider TEXT NOT NULL,
model TEXT NOT NULL,
provider_key TEXT NOT NULL,
hash TEXT NOT NULL,
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
CREATE INDEX IF NOT EXISTS ${updatedAtIndex}
ON ${embeddingCacheTable}(updated_at);
`);
}
migrateSqliteSchemaToStrict(
params.db,
buildMemoryIndexStrictSchema({
embeddingCacheTable,
includeEmbeddingCache: params.cacheEnabled || tableExists(params.db, embeddingCacheTable),
}),
{ databaseLabel: "memory index" },
);
let ftsAvailable = false;
let ftsError: string | undefined;
@@ -0,0 +1,3 @@
// Narrow core bridge for shared SQLite schema migration primitives.
export { migrateSqliteSchemaToStrict } from "../../../../src/infra/sqlite-strict.js";
+1
View File
@@ -34,6 +34,7 @@ const rawSqliteAllowPathGroups = {
"src/infra/sqlite-integrity.ts",
"src/infra/sqlite-pragma.test-support.ts",
"src/infra/sqlite-schema-contract.ts",
"src/infra/sqlite-strict.ts",
"src/infra/sqlite-transaction.ts",
"src/infra/sqlite-user-version.ts",
"src/infra/sqlite-wal.ts",
+4 -2
View File
@@ -222,6 +222,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: matchesNoProxy exposes canonical Undici-compatible bypass selection to plugins.
// +4: group scope encoder/key builder (channel-policy + compat mirror).
// +9: app-guided provider setup context/candidate/hook types and their public mirrors.
// +3: atomic SQLite STRICT migration function, options, and result for plugin stores.
// Harvest: channel-ingress -64; dead channel-message dispatch aliases -23.
// Harvest: retired qa-live-transport-scenarios subpath -6.
// +12: typed plan step/status and checklist formatter across channel barrels.
@@ -231,13 +232,14 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +6: app-guided provider setup types retained by plugin-entry and mirrors.
// Used-union narrowing: 31 wildcard barrels drop to explicit used exports;
// proxy stream API and codex marker/scaffold pins retained.
7942,
7945,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
// +2: materializeRequesterScopedMcpToolsForHarnessRun (agent-harness-runtime + compat mirror).
// +4: group scope encoder/key builder (channel-policy + compat mirror).
// +1: atomic SQLite STRICT migration for plugin stores.
// Harvest: channel-ingress -19; dead channel-message dispatch aliases -23.
// Harvest: retired qa-live-transport-scenarios subpath -3.
// +4: shared plan checklist formatter across channel barrels.
@@ -245,7 +247,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +4: dual-field plan payload builder for the steps deprecation window.
// +6: active plan-step helpers pinned through channel-outbound and mirrors.
// Used-union narrowing of the 31 wildcard barrels.
4433,
4434,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
@@ -73,7 +73,7 @@ export async function migrateLegacyCronRunLogsToSqlite(
entry_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (store_key, job_id, seq)
);
) STRICT;
`);
const insert = db.prepare(
`INSERT INTO cron_run_logs
+417
View File
@@ -0,0 +1,417 @@
import { afterEach, describe, expect, it } from "vitest";
import { requireNodeSqlite } from "./node-sqlite.js";
import { migrateSqliteSchemaToStrict } from "./sqlite-strict.js";
const openDatabases: Array<import("node:sqlite").DatabaseSync> = [];
function createDatabase(): import("node:sqlite").DatabaseSync {
const sqlite = requireNodeSqlite();
const database = new sqlite.DatabaseSync(":memory:");
openDatabases.push(database);
return database;
}
function readStrictFlag(database: import("node:sqlite").DatabaseSync, tableName: string): number {
const row = database
.prepare("SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = ?")
.get(tableName) as { strict?: unknown } | undefined;
return Number(row?.strict ?? -1);
}
afterEach(() => {
for (const database of openDatabases.splice(0)) {
if (database.isOpen) {
database.close();
}
}
});
describe("migrateSqliteSchemaToStrict", () => {
it("atomically rebuilds related tables and preserves their rows", () => {
const database = createDatabase();
database.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE parents (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE children (
id INTEGER PRIMARY KEY,
parent_id INTEGER NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
score INTEGER NOT NULL
);
INSERT INTO parents (id, name) VALUES (1, 'parent');
INSERT INTO children (id, parent_id, score) VALUES (2, 1, 3);
`);
const result = migrateSqliteSchemaToStrict(
database,
`
CREATE TABLE IF NOT EXISTS parents (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY,
parent_id INTEGER NOT NULL REFERENCES parents(id) ON DELETE CASCADE,
score INTEGER NOT NULL
) STRICT;
`,
{ databaseLabel: "test.sqlite" },
);
expect(result.migratedTables).toEqual(["children", "parents"]);
expect(readStrictFlag(database, "parents")).toBe(1);
expect(readStrictFlag(database, "children")).toBe(1);
expect(database.prepare("SELECT * FROM parents").all()).toEqual([{ id: 1, name: "parent" }]);
expect(database.prepare("SELECT * FROM children").all()).toEqual([
{ id: 2, parent_id: 1, score: 3 },
]);
expect(database.prepare("PRAGMA foreign_keys").get()).toEqual({ foreign_keys: 1 });
});
it("keeps database-local indexes and triggers", () => {
const database = createDatabase();
database.exec(`
CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE item_audit (item_id INTEGER NOT NULL, action TEXT NOT NULL);
CREATE UNIQUE INDEX items_name_custom ON items(name);
CREATE TRIGGER items_insert_custom AFTER INSERT ON items BEGIN
INSERT INTO item_audit (item_id, action) VALUES (NEW.id, 'insert');
END;
INSERT INTO items (id, name) VALUES (1, 'first');
`);
migrateSqliteSchemaToStrict(
database,
`CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT NOT NULL) STRICT;`,
);
database.prepare("INSERT INTO items (id, name) VALUES (?, ?)").run(2, "second");
expect(
database
.prepare(
"SELECT type, name FROM sqlite_schema WHERE name IN ('items_name_custom', 'items_insert_custom') ORDER BY type",
)
.all(),
).toEqual([
{ type: "index", name: "items_name_custom" },
{ type: "trigger", name: "items_insert_custom" },
]);
expect(database.prepare("SELECT * FROM item_audit ORDER BY item_id").all()).toEqual([
{ item_id: 1, action: "insert" },
{ item_id: 2, action: "insert" },
]);
});
it("preserves cross-table triggers and views while their target is rebuilt", () => {
const database = createDatabase();
database.exec(`
CREATE TABLE counters (id INTEGER PRIMARY KEY, value INTEGER NOT NULL);
CREATE TABLE events (id INTEGER PRIMARY KEY, amount INTEGER NOT NULL) STRICT;
INSERT INTO counters (id, value) VALUES (1, 0);
CREATE VIEW counter_totals AS SELECT id, value FROM counters;
CREATE TRIGGER events_update_counter AFTER INSERT ON events BEGIN
UPDATE counters SET value = value + NEW.amount WHERE id = 1;
END;
`);
migrateSqliteSchemaToStrict(
database,
`
CREATE TABLE IF NOT EXISTS counters (
id INTEGER PRIMARY KEY,
value INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
amount INTEGER NOT NULL
) STRICT;
`,
);
database.prepare("INSERT INTO events (id, amount) VALUES (?, ?)").run(1, 7);
expect(database.prepare("SELECT * FROM counter_totals").all()).toEqual([{ id: 1, value: 7 }]);
expect(
database
.prepare(
"SELECT type, name FROM sqlite_schema WHERE name IN ('counter_totals', 'events_update_counter') ORDER BY type",
)
.all(),
).toEqual([
{ type: "trigger", name: "events_update_counter" },
{ type: "view", name: "counter_totals" },
]);
});
it("preserves AUTOINCREMENT high-water marks", () => {
const database = createDatabase();
database.exec(`
CREATE TABLE entries (id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT NOT NULL);
INSERT INTO entries (value) VALUES ('first'), ('second'), ('third');
DELETE FROM entries WHERE id = 3;
UPDATE sqlite_sequence SET seq = 40.0 WHERE name = 'entries';
`);
migrateSqliteSchemaToStrict(
database,
`CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
value TEXT NOT NULL
) STRICT;`,
);
database.prepare("INSERT INTO entries (value) VALUES (?)").run("fourth");
expect(database.prepare("SELECT id, value FROM entries ORDER BY id").all()).toEqual([
{ id: 1, value: "first" },
{ id: 2, value: "second" },
{ id: 41, value: "fourth" },
]);
});
it("preserves implicit and declared row identities across table shapes", () => {
const database = createDatabase();
database.exec(`
CREATE TABLE implicit_items (value TEXT NOT NULL);
INSERT INTO implicit_items (rowid, value) VALUES (100, 'implicit');
CREATE TABLE composite_items (
left_key TEXT NOT NULL,
right_key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (left_key, right_key)
);
INSERT INTO composite_items (rowid, left_key, right_key, value)
VALUES (200, 'left', 'right', 'composite');
CREATE TABLE shadowed_items (rowid TEXT NOT NULL, value TEXT NOT NULL);
INSERT INTO shadowed_items (_rowid_, rowid, value) VALUES (300, 'named', 'shadowed');
CREATE TABLE integer_items (id INTEGER PRIMARY KEY, value TEXT NOT NULL);
INSERT INTO integer_items (id, value) VALUES (400, 'integer-primary-key');
CREATE TABLE without_items (
id TEXT PRIMARY KEY,
value TEXT NOT NULL
) WITHOUT ROWID;
INSERT INTO without_items (id, value) VALUES ('without', 'without-rowid');
`);
migrateSqliteSchemaToStrict(
database,
`
CREATE TABLE IF NOT EXISTS implicit_items (value TEXT NOT NULL) STRICT;
CREATE TABLE IF NOT EXISTS composite_items (
left_key TEXT NOT NULL,
right_key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (left_key, right_key)
) STRICT;
CREATE TABLE IF NOT EXISTS shadowed_items (
rowid TEXT NOT NULL,
value TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS integer_items (
id INTEGER PRIMARY KEY,
value TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS without_items (
id TEXT PRIMARY KEY,
value TEXT NOT NULL
) STRICT, WITHOUT ROWID;
`,
);
expect(database.prepare("SELECT rowid, value FROM implicit_items").get()).toEqual({
rowid: 100,
value: "implicit",
});
expect(database.prepare("SELECT rowid, value FROM composite_items").get()).toEqual({
rowid: 200,
value: "composite",
});
expect(
database.prepare("SELECT _rowid_ AS hidden_rowid, rowid, value FROM shadowed_items").get(),
).toEqual({ hidden_rowid: 300, rowid: "named", value: "shadowed" });
expect(
database.prepare("SELECT rowid AS stored_rowid, id, value FROM integer_items").get(),
).toEqual({
stored_rowid: 400,
id: 400,
value: "integer-primary-key",
});
expect(database.prepare("SELECT id, value FROM without_items").get()).toEqual({
id: "without",
value: "without-rowid",
});
});
it("rejects implicit-rowid tables that shadow every usable alias", () => {
const database = createDatabase();
database.exec("CREATE TABLE aliases (rowid TEXT, _rowid_ TEXT, oid TEXT);");
expect(() =>
migrateSqliteSchemaToStrict(
database,
"CREATE TABLE IF NOT EXISTS aliases (rowid TEXT, _rowid_ TEXT, oid TEXT) STRICT;",
),
).toThrow("shadows every rowid alias");
expect(readStrictFlag(database, "aliases")).toBe(0);
});
it("rejects migrations that change the table rowid model", () => {
const database = createDatabase();
database.exec("CREATE TABLE entries (id TEXT PRIMARY KEY, value TEXT NOT NULL);");
expect(() =>
migrateSqliteSchemaToStrict(
database,
`CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY,
value TEXT NOT NULL
) STRICT;`,
),
).toThrow("changes rowid storage from implicit to integer-primary-key");
expect(readStrictFlag(database, "entries")).toBe(0);
});
it("accepts values that SQLite can losslessly coerce", () => {
const database = createDatabase();
database.exec("CREATE TABLE counters (id TEXT PRIMARY KEY, value TEXT NOT NULL);");
database.prepare("INSERT INTO counters (id, value) VALUES (?, ?)").run("one", "42");
migrateSqliteSchemaToStrict(
database,
`CREATE TABLE IF NOT EXISTS counters (
id TEXT PRIMARY KEY,
value INTEGER NOT NULL
) STRICT;`,
);
expect(
database.prepare("SELECT id, value, typeof(value) AS value_type FROM counters").get(),
).toEqual({ id: "one", value: 42, value_type: "integer" });
});
it("rolls back when an existing value violates the canonical type", () => {
const database = createDatabase();
database.exec("PRAGMA foreign_keys = ON; CREATE TABLE counters (id TEXT, value TEXT);");
database.prepare("INSERT INTO counters (id, value) VALUES (?, ?)").run("one", "not-a-number");
expect(() =>
migrateSqliteSchemaToStrict(
database,
`CREATE TABLE IF NOT EXISTS counters (
id TEXT,
value INTEGER NOT NULL
) STRICT;`,
),
).toThrow("Failed migrating SQLite table counters to STRICT");
expect(readStrictFlag(database, "counters")).toBe(0);
expect(database.prepare("SELECT id, value FROM counters").get()).toEqual({
id: "one",
value: "not-a-number",
});
expect(database.prepare("PRAGMA foreign_keys").get()).toEqual({ foreign_keys: 1 });
});
it("rolls back when existing rows violate a foreign key", () => {
const database = createDatabase();
database.exec(`
PRAGMA foreign_keys = OFF;
CREATE TABLE parents (id INTEGER PRIMARY KEY);
CREATE TABLE children (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parents(id));
INSERT INTO children (id, parent_id) VALUES (1, 99);
PRAGMA foreign_keys = ON;
`);
expect(() =>
migrateSqliteSchemaToStrict(
database,
`
CREATE TABLE IF NOT EXISTS parents (id INTEGER PRIMARY KEY) STRICT;
CREATE TABLE IF NOT EXISTS children (
id INTEGER PRIMARY KEY,
parent_id INTEGER REFERENCES parents(id)
) STRICT;
`,
),
).toThrow("foreign_key_check failed");
expect(readStrictFlag(database, "parents")).toBe(0);
expect(readStrictFlag(database, "children")).toBe(0);
expect(database.prepare("SELECT * FROM children").get()).toEqual({ id: 1, parent_id: 99 });
});
it("rejects schema drift without changing the existing table", () => {
const database = createDatabase();
database.exec("CREATE TABLE entries (id TEXT PRIMARY KEY, obsolete TEXT);");
expect(() =>
migrateSqliteSchemaToStrict(
database,
`CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY,
current TEXT
) STRICT;`,
),
).toThrow("SQLite table entries does not match its canonical columns");
expect(readStrictFlag(database, "entries")).toBe(0);
expect(
database.prepare("SELECT name FROM pragma_table_info('entries') ORDER BY cid").all(),
).toEqual([{ name: "id" }, { name: "obsolete" }]);
});
it("rejects canonical schemas that still contain non-STRICT tables", () => {
const database = createDatabase();
expect(() =>
migrateSqliteSchemaToStrict(
database,
"CREATE TABLE IF NOT EXISTS entries (id TEXT PRIMARY KEY);",
),
).toThrow("Canonical SQLite schema contains non-STRICT tables: entries");
expect(database.prepare("PRAGMA table_list").all()).not.toContainEqual(
expect.objectContaining({ name: "entries" }),
);
});
it("ignores virtual tables while migrating ordinary tables", () => {
const database = createDatabase();
database.exec(`
CREATE TABLE documents (id INTEGER PRIMARY KEY, body TEXT NOT NULL);
CREATE VIRTUAL TABLE documents_fts USING fts5(body);
INSERT INTO documents (id, body) VALUES (1, 'hello');
INSERT INTO documents_fts (rowid, body) VALUES (1, 'hello');
`);
const result = migrateSqliteSchemaToStrict(
database,
`
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY,
body TEXT NOT NULL
) STRICT;
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(body);
`,
);
expect(result.migratedTables).toEqual(["documents"]);
expect(readStrictFlag(database, "documents")).toBe(1);
expect(database.prepare("SELECT rowid, body FROM documents_fts").all()).toEqual([
{ rowid: 1, body: "hello" },
]);
});
it("is idempotent once every canonical table is STRICT", () => {
const database = createDatabase();
const schema = "CREATE TABLE IF NOT EXISTS entries (id TEXT PRIMARY KEY, value BLOB) STRICT;";
database.exec(schema);
database.prepare("INSERT INTO entries (id, value) VALUES (?, ?)").run("one", null);
expect(migrateSqliteSchemaToStrict(database, schema)).toEqual({ migratedTables: [] });
expect(database.prepare("SELECT id, value FROM entries").get()).toEqual({
id: "one",
value: null,
});
});
});
+404
View File
@@ -0,0 +1,404 @@
// Migrates OpenClaw-owned SQLite tables to canonical STRICT schemas.
import type { DatabaseSync } from "node:sqlite";
import { requireNodeSqlite } from "./node-sqlite.js";
import { assertSqliteIntegrity } from "./sqlite-integrity.js";
import { runSqliteImmediateTransactionSync } from "./sqlite-transaction.js";
type TableListRow = {
name?: unknown;
schema?: unknown;
strict?: unknown;
type?: unknown;
wr?: unknown;
};
type TableColumnRow = {
hidden?: unknown;
name?: unknown;
pk?: unknown;
type?: unknown;
};
type SchemaObjectRow = {
name?: unknown;
sql?: unknown;
tbl_name?: unknown;
type?: unknown;
};
type PreservedSchemaObject = {
name: string;
sql: string;
type: "index" | "trigger" | "view";
};
type CanonicalStrictTable = {
columns: string[];
createSql: string;
name: string;
rowidAlias: string | null;
rowidStorage: TableRowidStorage;
usesAutoincrement: boolean;
};
type TableRowidStorage = "implicit" | "integer-primary-key" | "without-rowid";
type TableRowidModel = {
alias: string | null;
storage: TableRowidStorage;
};
export type SqliteStrictMigrationOptions = {
busyTimeoutMs?: number;
databaseLabel?: string;
};
export type SqliteStrictMigrationResult = {
migratedTables: string[];
};
const DEFAULT_STRICT_MIGRATION_BUSY_TIMEOUT_MS = 5_000;
const STRICT_MIGRATION_TABLE_PREFIX = "__openclaw_strict_migration_";
const SQLITE_ROWID_ALIASES = ["_rowid_", "rowid", "oid"] as const;
function quoteSqliteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
function readMainTableList(db: DatabaseSync): TableListRow[] {
return (db.prepare("PRAGMA table_list").all() as TableListRow[]).filter(
(row) =>
row.schema === "main" && typeof row.name === "string" && !row.name.startsWith("sqlite_"),
);
}
function readTableColumns(db: DatabaseSync, tableName: string): TableColumnRow[] {
return db
.prepare(`PRAGMA table_xinfo(${quoteSqliteIdentifier(tableName)})`)
.all() as TableColumnRow[];
}
function readVisibleColumns(db: DatabaseSync, tableName: string): string[] {
return readTableColumns(db, tableName)
.filter((row) => Number(row.hidden ?? 0) === 0)
.map((row) => {
if (typeof row.name !== "string" || row.name.length === 0) {
throw new Error(`SQLite table ${tableName} has an invalid column name`);
}
return row.name;
});
}
function readTableRowidModel(
db: DatabaseSync,
tableName: string,
tableRow: TableListRow,
): TableRowidModel {
if (Number(tableRow.wr ?? 0) === 1) {
return { alias: null, storage: "without-rowid" };
}
const columns = readTableColumns(db, tableName);
const primaryKeyColumns = columns.filter((column) => Number(column.pk ?? 0) > 0);
const primaryKeyIndex = db
.prepare(`SELECT 1 AS found FROM pragma_index_list(?) WHERE origin = 'pk' LIMIT 1`)
.get(tableName);
const primaryKeyType = primaryKeyColumns[0]?.type;
if (
primaryKeyColumns.length === 1 &&
typeof primaryKeyType === "string" &&
primaryKeyType.toUpperCase() === "INTEGER" &&
!primaryKeyIndex
) {
return { alias: null, storage: "integer-primary-key" };
}
const declaredNames = new Set(
columns.flatMap((column) =>
typeof column.name === "string" ? [column.name.toLowerCase()] : [],
),
);
const alias = SQLITE_ROWID_ALIASES.find((candidate) => !declaredNames.has(candidate)) ?? null;
if (!alias) {
throw new Error(
`SQLite table ${tableName} shadows every rowid alias; its implicit rowids cannot be migrated safely`,
);
}
return { alias, storage: "implicit" };
}
function readCanonicalStrictTables(schemaSql: string): CanonicalStrictTable[] {
const sqlite = requireNodeSqlite();
const canonical = new sqlite.DatabaseSync(":memory:");
try {
canonical.exec(schemaSql);
const tables = readMainTableList(canonical).filter((row) => row.type === "table");
const nonStrict = tables.flatMap((row) =>
Number(row.strict ?? 0) === 1 || typeof row.name !== "string" ? [] : [row.name],
);
if (nonStrict.length > 0) {
throw new Error(
`Canonical SQLite schema contains non-STRICT tables: ${nonStrict.toSorted().join(", ")}`,
);
}
return tables
.map((row) => {
if (typeof row.name !== "string") {
throw new Error("Canonical SQLite schema contains an unnamed table");
}
const schemaRow = canonical
.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?")
.get(row.name) as { sql?: unknown } | undefined;
if (typeof schemaRow?.sql !== "string") {
throw new Error(`Canonical SQLite table ${row.name} has no CREATE statement`);
}
const rowidModel = readTableRowidModel(canonical, row.name, row);
return {
columns: readVisibleColumns(canonical, row.name),
createSql: schemaRow.sql,
name: row.name,
rowidAlias: rowidModel.alias,
rowidStorage: rowidModel.storage,
usesAutoincrement: /\bAUTOINCREMENT\b/iu.test(schemaRow.sql),
};
})
.toSorted((left, right) => left.name.localeCompare(right.name));
} finally {
canonical.close();
}
}
function rewriteCreateTableName(createSql: string, replacementName: string): string {
const openingParen = createSql.indexOf("(");
if (openingParen === -1) {
throw new Error("Canonical SQLite table CREATE statement has no column list");
}
return `CREATE TABLE ${quoteSqliteIdentifier(replacementName)} ${createSql.slice(openingParen)}`;
}
function readPreservedSchemaObjects(
db: DatabaseSync,
tableNames: ReadonlySet<string>,
): PreservedSchemaObject[] {
return (
db
.prepare(
"SELECT type, name, tbl_name, sql FROM sqlite_schema WHERE type IN ('index', 'trigger', 'view')",
)
.all() as SchemaObjectRow[]
)
.flatMap<PreservedSchemaObject>((row) => {
if (
(row.type !== "index" && row.type !== "trigger" && row.type !== "view") ||
typeof row.name !== "string" ||
typeof row.tbl_name !== "string" ||
typeof row.sql !== "string" ||
(row.type === "index" && !tableNames.has(row.tbl_name))
) {
return [];
}
return [{ name: row.name, sql: row.sql, type: row.type }];
})
.toSorted((left, right) => {
const typeOrder = { view: 0, index: 1, trigger: 2 } as const;
return typeOrder[left.type] - typeOrder[right.type] || left.name.localeCompare(right.name);
});
}
function readAutoincrementHighWater(db: DatabaseSync, tableName: string): string | null {
const sequenceTable = db
.prepare(
"SELECT 1 AS found FROM sqlite_schema WHERE type = 'table' AND name = 'sqlite_sequence'",
)
.get();
if (!sequenceTable) {
return null;
}
const row = db
.prepare("SELECT CAST(seq AS TEXT) AS seq FROM sqlite_sequence WHERE name = ?")
.get(tableName) as { seq?: unknown } | undefined;
if (row === undefined) {
return null;
}
const normalized = typeof row.seq === "string" ? /^(\d+)(?:\.0+)?$/u.exec(row.seq)?.[1] : null;
if (!normalized) {
throw new Error(
`SQLite table ${tableName} has an invalid AUTOINCREMENT high-water mark (${typeof row.seq}: ${String(row.seq)})`,
);
}
return normalized;
}
function restoreAutoincrementHighWater(
db: DatabaseSync,
tableName: string,
previousHighWater: string | null,
): void {
if (previousHighWater === null) {
return;
}
const currentHighWater = readAutoincrementHighWater(db, tableName);
const restored =
currentHighWater === null || BigInt(previousHighWater) > BigInt(currentHighWater)
? previousHighWater
: currentHighWater;
db.prepare("DELETE FROM sqlite_sequence WHERE name = ?").run(tableName);
db.prepare("INSERT INTO sqlite_sequence (name, seq) VALUES (?, CAST(? AS INTEGER))").run(
tableName,
restored,
);
}
function assertMatchingColumns(
tableName: string,
currentColumns: readonly string[],
canonicalColumns: readonly string[],
): void {
const current = new Set(currentColumns);
const canonical = new Set(canonicalColumns);
const missing = canonicalColumns.filter((column) => !current.has(column));
const extra = currentColumns.filter((column) => !canonical.has(column));
if (missing.length === 0 && extra.length === 0) {
return;
}
const details = [
missing.length > 0 ? `missing ${missing.join(", ")}` : "",
extra.length > 0 ? `extra ${extra.join(", ")}` : "",
]
.filter(Boolean)
.join("; ");
throw new Error(`SQLite table ${tableName} does not match its canonical columns (${details})`);
}
function readForeignKeysEnabled(db: DatabaseSync): boolean {
const row = db.prepare("PRAGMA foreign_keys").get() as { foreign_keys?: unknown } | undefined;
return Number(row?.foreign_keys ?? 0) === 1;
}
/**
* Rebuild canonical non-STRICT tables inside the caller's transaction.
* Foreign-key enforcement must be disabled before BEGIN; integrity is checked
* before this function returns so any bad row or relationship rolls back.
*/
export function migrateSqliteSchemaToStrictInTransaction(
db: DatabaseSync,
schemaSql: string,
options: Pick<SqliteStrictMigrationOptions, "databaseLabel"> = {},
): SqliteStrictMigrationResult {
if (!db.isTransaction) {
throw new Error("SQLite STRICT schema migration requires an active transaction");
}
const canonicalTables = readCanonicalStrictTables(schemaSql);
db.exec(schemaSql);
const currentTableRows = new Map(
readMainTableList(db)
.filter((row) => row.type === "table" && typeof row.name === "string")
.map((row) => [row.name as string, row]),
);
const tablesToMigrate = canonicalTables.filter(
(table) => Number(currentTableRows.get(table.name)?.strict ?? 0) !== 1,
);
if (tablesToMigrate.length === 0) {
return { migratedTables: [] };
}
if (readForeignKeysEnabled(db)) {
throw new Error("SQLite STRICT schema migration requires foreign_keys=OFF before BEGIN");
}
const names = new Set(tablesToMigrate.map((table) => table.name));
const preservedObjects = readPreservedSchemaObjects(db, names);
// SQLite reparses every trigger and view during ALTER TABLE. Temporarily
// remove them so a referenced table can be absent between DROP and RENAME.
for (const object of preservedObjects) {
if (object.type === "trigger") {
db.exec(`DROP TRIGGER ${quoteSqliteIdentifier(object.name)};`);
}
}
for (const object of preservedObjects) {
if (object.type === "view") {
db.exec(`DROP VIEW ${quoteSqliteIdentifier(object.name)};`);
}
}
for (const [index, table] of tablesToMigrate.entries()) {
const migrationTable = `${STRICT_MIGRATION_TABLE_PREFIX}${index}_${table.name}`;
if (currentTableRows.has(migrationTable)) {
throw new Error(`SQLite STRICT migration table already exists: ${migrationTable}`);
}
const currentColumns = readVisibleColumns(db, table.name);
assertMatchingColumns(table.name, currentColumns, table.columns);
const currentTableRow = currentTableRows.get(table.name);
if (!currentTableRow) {
throw new Error(`SQLite table ${table.name} disappeared during STRICT migration`);
}
const currentRowidModel = readTableRowidModel(db, table.name, currentTableRow);
if (currentRowidModel.storage !== table.rowidStorage) {
throw new Error(
`SQLite table ${table.name} changes rowid storage from ${currentRowidModel.storage} to ${table.rowidStorage}; refusing an identity-changing STRICT migration`,
);
}
const previousHighWater = table.usesAutoincrement
? readAutoincrementHighWater(db, table.name)
: null;
db.exec(rewriteCreateTableName(table.createSql, migrationTable));
const columns = table.columns.map(quoteSqliteIdentifier);
if (table.rowidAlias) {
columns.unshift(quoteSqliteIdentifier(table.rowidAlias));
}
const copyColumns = columns.join(", ");
try {
db.exec(
`INSERT INTO ${quoteSqliteIdentifier(migrationTable)} (${copyColumns}) ` +
`SELECT ${copyColumns} FROM ${quoteSqliteIdentifier(table.name)};`,
);
} catch (error) {
throw new Error(`Failed migrating SQLite table ${table.name} to STRICT`, { cause: error });
}
db.exec(`DROP TABLE ${quoteSqliteIdentifier(table.name)};`);
db.exec(
`ALTER TABLE ${quoteSqliteIdentifier(migrationTable)} RENAME TO ${quoteSqliteIdentifier(table.name)};`,
);
restoreAutoincrementHighWater(db, table.name, previousHighWater);
}
// Recreate canonical objects first, then retain any database-local indexes or
// triggers that are not part of the checked-in schema.
db.exec(schemaSql);
const findObject = db.prepare(
"SELECT 1 AS found FROM sqlite_schema WHERE type = ? AND name = ? LIMIT 1",
);
for (const object of preservedObjects) {
if (!findObject.get(object.type, object.name)) {
db.exec(object.sql);
}
}
assertSqliteIntegrity(db, options.databaseLabel ?? "SQLite STRICT schema migration");
return { migratedTables: tablesToMigrate.map((table) => table.name) };
}
/** Atomically upgrade OpenClaw-owned tables described by a canonical STRICT schema. */
export function migrateSqliteSchemaToStrict(
db: DatabaseSync,
schemaSql: string,
options: SqliteStrictMigrationOptions = {},
): SqliteStrictMigrationResult {
if (db.isTransaction) {
throw new Error("SQLite STRICT schema migration cannot start inside a transaction");
}
const foreignKeysWereEnabled = readForeignKeysEnabled(db);
if (foreignKeysWereEnabled) {
db.exec("PRAGMA foreign_keys = OFF;");
}
try {
return runSqliteImmediateTransactionSync(
db,
() => migrateSqliteSchemaToStrictInTransaction(db, schemaSql, options),
{
busyTimeoutMs: options.busyTimeoutMs ?? DEFAULT_STRICT_MIGRATION_BUSY_TIMEOUT_MS,
databaseLabel: options.databaseLabel,
operationLabel: "sqlite.strict-schema-migration",
},
);
} finally {
if (foreignKeysWereEnabled) {
db.exec("PRAGMA foreign_keys = ON;");
}
}
}
+63 -4
View File
@@ -24,7 +24,7 @@ function createMockDb(): DatabaseSync {
get: vi.fn(() =>
sql.includes("wal_checkpoint")
? { busy: 0, log: 0, checkpointed: 0 }
: { journal_mode: "delete" },
: { journal_mode: sql === "PRAGMA journal_mode;" ? "wal" : "delete" },
),
})),
} as unknown as DatabaseSync;
@@ -142,7 +142,7 @@ describe("sqlite WAL maintenance", () => {
});
expect(realpath).toHaveBeenCalledWith(databasePath);
expect(db["prepare"]).not.toHaveBeenCalled();
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode;");
expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA journal_mode = WAL;");
});
@@ -184,6 +184,45 @@ describe("sqlite WAL maintenance", () => {
}
});
it("accepts SQLite's memory journal for an in-memory database", () => {
const sqlite = requireNodeSqlite();
const db = new sqlite.DatabaseSync(":memory:");
try {
const maintenance = configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databaseLabel: "in-memory-test-db",
});
expect(db.prepare("PRAGMA journal_mode;").get()).toEqual({ journal_mode: "memory" });
expect(maintenance.checkpoint()).toBe(true);
expect(maintenance.close()).toBe(true);
} finally {
db.close();
}
});
it("rejects a memory journal for a file-backed database", () => {
const db = createMockDb();
vi.mocked(db["prepare"]).mockImplementation(
(sql) =>
({
all: vi.fn(() =>
sql === "PRAGMA database_list;"
? [{ seq: 0, name: "main", file: "/tmp/file-backed.sqlite" }]
: [],
),
get: vi.fn(() => ({ journal_mode: "memory" })),
}) as unknown as ReturnType<DatabaseSync["prepare"]>,
);
expect(() =>
configureSqliteWalMaintenance(db, {
checkpointIntervalMs: 0,
databaseLabel: "file-backed-test-db",
}),
).toThrow("file-backed-test-db could not enable WAL; SQLite kept journal_mode=memory");
});
it("uses mountinfo filesystem names when statfs magic is not enough", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
try {
@@ -474,7 +513,7 @@ describe("sqlite WAL maintenance", () => {
get: vi.fn(() =>
sql.includes("wal_checkpoint")
? { busy: 1, log: 4, checkpointed: 3 }
: { journal_mode: "delete" },
: { journal_mode: sql === "PRAGMA journal_mode;" ? "wal" : "delete" },
),
}) as unknown as ReturnType<DatabaseSync["prepare"]>,
);
@@ -538,7 +577,7 @@ describe("sqlite WAL maintenance", () => {
throw error;
}
return {
get: vi.fn(() => ({ journal_mode: "delete" })),
get: vi.fn(() => ({ journal_mode: sql === "PRAGMA journal_mode;" ? "wal" : "delete" })),
} as unknown as ReturnType<DatabaseSync["prepare"]>;
});
@@ -579,6 +618,26 @@ describe("sqlite WAL maintenance", () => {
]);
});
it("rejects a WAL transition that SQLite silently declines", () => {
const db = createMockDb();
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
vi.mocked(db["prepare"]).mockImplementation(
(sql) =>
({
get: vi.fn(() => ({ journal_mode: sql === "PRAGMA journal_mode;" ? "delete" : "wal" })),
}) as unknown as ReturnType<DatabaseSync["prepare"]>,
);
expect(() =>
configureSqliteConnectionPragmas(db, {
busyTimeoutMs: 50,
checkpointIntervalMs: 0,
databaseLabel: "test-db",
}),
).toThrow("test-db could not enable WAL; SQLite kept journal_mode=delete");
expect(db["prepare"]).toHaveBeenCalledWith("PRAGMA journal_mode;");
});
it("configures lock retry before inspecting a fresh database header", () => {
const db = createMockDb();
vi.mocked(db["prepare"]).mockImplementation(
+34 -3
View File
@@ -300,6 +300,15 @@ function readJournalModeResult(row: unknown): string | null {
return typeof value === "string" ? value.toLowerCase() : null;
}
function hasInMemoryMainDatabase(db: DatabaseSync): boolean {
const rows = db.prepare("PRAGMA database_list;").all() as Array<{
file?: unknown;
name?: unknown;
}>;
const main = rows.find((row) => row.name === "main");
return main?.file === "";
}
function readCheckpointBusyResult(row: unknown): boolean {
if (!row || typeof row !== "object") {
return false;
@@ -322,14 +331,31 @@ function requireRollbackJournalMode(db: DatabaseSync, options: SqliteWalMaintena
}
}
function enableWalJournalMode(db: DatabaseSync, retryTimeoutMs: number): void {
function enableWalJournalMode(
db: DatabaseSync,
retryTimeoutMs: number,
options: SqliteWalMaintenanceOptions,
): boolean {
const deadline = Date.now() + retryTimeoutMs;
let restoreBusyTimeout = false;
try {
while (true) {
try {
db.exec("PRAGMA journal_mode = WAL;");
return;
const journalMode = readJournalModeResult(db.prepare("PRAGMA journal_mode;").get());
if (journalMode === "wal") {
return true;
}
// SQLite's in-memory databases cannot use WAL and correctly retain
// journal_mode=memory. They have no sidecars or checkpoint work.
if (journalMode === "memory" && hasInMemoryMainDatabase(db)) {
return false;
}
const label = options.databaseLabel ?? "sqlite database";
const location = options.databasePath ? ` at ${options.databasePath}` : "";
throw new Error(
`${label}${location} could not enable WAL; SQLite kept journal_mode=${journalMode ?? "unknown"}.`,
);
} catch (error) {
const remainingMs = deadline - Date.now();
if (!isSqliteLockError(error) || remainingMs <= 0) {
@@ -408,7 +434,12 @@ export function configureSqliteWalMaintenance(
close: () => true,
};
}
enableWalJournalMode(db, busyTimeoutMs);
if (!enableWalJournalMode(db, busyTimeoutMs, options)) {
return {
checkpoint: () => true,
close: () => true,
};
}
enableMacosCheckpointFullfsync(db);
db.exec(`PRAGMA wal_autocheckpoint = ${autoCheckpointPages};`);
@@ -798,6 +798,41 @@ describe("managed service update handoff", () => {
}
});
it("waits for a concurrent state writer before persisting the fallback failure", async () => {
let lockReleased: Promise<void> | undefined;
const { result, env } = await runHelperWithExistingSentinel({
handoffId: "handoff-locked",
metaHandoffId: "handoff-locked",
prepareStateDatabase: async (stateEnv) => {
openOpenClawStateDatabase({ env: stateEnv });
closeOpenClawStateDatabaseForTest();
const sqlite = await import("node:sqlite");
const lock = new sqlite.DatabaseSync(resolveOpenClawStateSqlitePath(stateEnv));
lock.exec("BEGIN IMMEDIATE;");
lockReleased = new Promise((resolve) => {
setTimeout(() => {
lock.exec("COMMIT;");
lock.close();
resolve();
}, 200);
});
},
});
await lockReleased;
expect(result).toEqual({ code: 1, signal: null });
expect(readRestartSentinelPayload(env)).toMatchObject({
version: 1,
payload: {
status: "error",
stats: {
handoffId: "handoff-locked",
reason: "managed-service-handoff-parent-timeout",
},
},
});
});
it("repairs legacy restart sentinel columns before writing fallback failures", async () => {
const { result, env } = await runHelperWithExistingSentinel({
handoffId: "handoff-123",
+3 -1
View File
@@ -25,6 +25,7 @@ import type { UpdateRestartSentinelMeta } from "./update-restart-sentinel-payloa
const PARENT_EXIT_SHUTDOWN_RESERVE_MS = 30_000;
const HANDOFF_READY_TIMEOUT_MS = 30_000;
const HANDOFF_READY_MARKER = "OPENCLAW_UPDATE_HANDOFF_READY\n";
const HANDOFF_STATE_DATABASE_BUSY_TIMEOUT_MS = 5_000;
const SYSTEMD_RUN_CANDIDATE_PATHS = ["/usr/bin/systemd-run", "/bin/systemd-run"] as const;
const SERVICE_IDENTITY_ENV_VARS = new Set<string>([
"OPENCLAW_LAUNCHD_LABEL",
@@ -123,6 +124,7 @@ function openStateDatabase() {
const sqlite = require("node:sqlite");
fs.mkdirSync(path.dirname(params.stateDatabasePath), { recursive: true, mode: 0o700 });
const db = new sqlite.DatabaseSync(params.stateDatabasePath);
db.exec("PRAGMA busy_timeout = ${HANDOFF_STATE_DATABASE_BUSY_TIMEOUT_MS};");
db.exec([
"CREATE TABLE IF NOT EXISTS gateway_restart_sentinel (",
"sentinel_key TEXT NOT NULL PRIMARY KEY,",
@@ -141,7 +143,7 @@ function openStateDatabase() {
"stats_json TEXT,",
"payload_json TEXT NOT NULL,",
"updated_at_ms INTEGER NOT NULL",
");",
") STRICT;",
"CREATE INDEX IF NOT EXISTS idx_gateway_restart_sentinel_ts",
"ON gateway_restart_sentinel(ts DESC, sentinel_key);",
].join(" "));
+5
View File
@@ -2,6 +2,11 @@
* Runtime SDK type surface for plugin-scoped keyed state stores.
*/
export { configureSqliteConnectionPragmas } from "../infra/sqlite-wal.js";
export {
migrateSqliteSchemaToStrict,
type SqliteStrictMigrationOptions,
type SqliteStrictMigrationResult,
} from "../infra/sqlite-strict.js";
export type {
OpenKeyedStoreOptions,
PluginStateEntry,
@@ -61,6 +61,7 @@ const MEMORY_HOST_SDK_EXPORTS = [
const MEMORY_HOST_SDK_ALLOWED_CORE_BRIDGE_FILES = [
"packages/memory-host-sdk/src/host/openclaw-runtime-auth.ts",
"packages/memory-host-sdk/src/host/openclaw-runtime-network.ts",
"packages/memory-host-sdk/src/host/openclaw-runtime-sqlite.ts",
"packages/memory-host-sdk/src/host/openclaw-runtime.ts",
] as const;
const MEMORY_HOST_SDK_RUNTIME_ADAPTER_FILES = [
+13
View File
@@ -137,6 +137,19 @@ describe("DebugProxyCaptureStore", () => {
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'capture_blobs'")
.get(),
).toBeUndefined();
expect(
lease.store.db
.prepare(
`SELECT name, strict FROM pragma_table_list
WHERE schema = 'main' AND type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`,
)
.all(),
).toEqual([
{ name: "capture_events", strict: 1 },
{ name: "capture_sessions", strict: 1 },
]);
expect(lease.store.db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 1 });
expect(lease.store.deleteSessions(["legacy-sdk-session"])).toEqual({
sessions: 1,
events: 1,
+55 -37
View File
@@ -10,6 +10,7 @@ import { sha256Hex } from "../infra/crypto-digest.js";
import { requireNodeSqlite } from "../infra/node-sqlite.js";
import { applyPrivateModeSync } from "../infra/private-mode.js";
import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js";
import { migrateSqliteSchemaToStrict } from "../infra/sqlite-strict.js";
import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js";
import {
configureSqliteConnectionPragmas,
@@ -41,6 +42,45 @@ type PathBasedDebugProxyCaptureStore = {
const DEBUG_PROXY_CAPTURE_DIR_MODE = 0o700;
const DEBUG_PROXY_CAPTURE_FILE_MODE = 0o600;
const DEBUG_PROXY_CAPTURE_LEGACY_SCHEMA_VERSION = 1;
const DEBUG_PROXY_CAPTURE_LEGACY_SCHEMA_SQL = `
CREATE TABLE IF NOT EXISTS capture_sessions (
id TEXT PRIMARY KEY,
started_at INTEGER NOT NULL,
ended_at INTEGER,
mode TEXT NOT NULL,
source_scope TEXT NOT NULL,
source_process TEXT NOT NULL,
proxy_url TEXT,
db_path TEXT NOT NULL,
blob_dir TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS capture_events (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
ts INTEGER NOT NULL,
source_scope TEXT NOT NULL,
source_process TEXT NOT NULL,
protocol TEXT NOT NULL,
direction TEXT NOT NULL,
kind TEXT NOT NULL,
flow_id TEXT NOT NULL,
method TEXT,
host TEXT,
path TEXT,
status INTEGER,
close_code INTEGER,
content_type TEXT,
headers_json TEXT,
data_text TEXT,
data_blob_id TEXT,
data_sha256 TEXT,
error_text TEXT,
meta_json TEXT
) STRICT;
CREATE INDEX IF NOT EXISTS capture_events_session_ts_idx ON capture_events(session_id, ts);
CREATE INDEX IF NOT EXISTS capture_events_flow_idx ON capture_events(flow_id, ts);
`;
function isInMemoryDatabasePath(dbPath: string): boolean {
if (dbPath === ":memory:") {
@@ -101,44 +141,22 @@ function openPathBasedDebugProxyCaptureStore(
...(fileBackedPath ? { databasePath: fileBackedPath } : {}),
foreignKeys: true,
});
db.exec(`
CREATE TABLE IF NOT EXISTS capture_sessions (
id TEXT PRIMARY KEY,
started_at INTEGER NOT NULL,
ended_at INTEGER,
mode TEXT NOT NULL,
source_scope TEXT NOT NULL,
source_process TEXT NOT NULL,
proxy_url TEXT,
db_path TEXT NOT NULL,
blob_dir TEXT NOT NULL
const versionRow = db.prepare("PRAGMA user_version").get() as
| { user_version?: unknown }
| undefined;
const schemaVersion = Number(versionRow?.user_version ?? 0);
if (schemaVersion > DEBUG_PROXY_CAPTURE_LEGACY_SCHEMA_VERSION) {
throw new Error(
`Legacy debug proxy capture database uses newer schema version ${schemaVersion}; this build supports ${DEBUG_PROXY_CAPTURE_LEGACY_SCHEMA_VERSION}`,
);
CREATE TABLE IF NOT EXISTS capture_events (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
ts INTEGER NOT NULL,
source_scope TEXT NOT NULL,
source_process TEXT NOT NULL,
protocol TEXT NOT NULL,
direction TEXT NOT NULL,
kind TEXT NOT NULL,
flow_id TEXT NOT NULL,
method TEXT,
host TEXT,
path TEXT,
status INTEGER,
close_code INTEGER,
content_type TEXT,
headers_json TEXT,
data_text TEXT,
data_blob_id TEXT,
data_sha256 TEXT,
error_text TEXT,
meta_json TEXT
);
CREATE INDEX IF NOT EXISTS capture_events_session_ts_idx ON capture_events(session_id, ts);
CREATE INDEX IF NOT EXISTS capture_events_flow_idx ON capture_events(flow_id, ts);
`);
}
db.exec(DEBUG_PROXY_CAPTURE_LEGACY_SCHEMA_SQL);
if (schemaVersion < DEBUG_PROXY_CAPTURE_LEGACY_SCHEMA_VERSION) {
migrateSqliteSchemaToStrict(db, DEBUG_PROXY_CAPTURE_LEGACY_SCHEMA_SQL, {
databaseLabel: fileBackedPath ?? dbPath,
});
db.exec(`PRAGMA user_version = ${DEBUG_PROXY_CAPTURE_LEGACY_SCHEMA_VERSION};`);
}
if (fileBackedPath) {
hardenLegacyDatabaseFiles(fileBackedPath);
}
+2 -2
View File
@@ -61,7 +61,7 @@ export interface MemoryIndexChunks {
embedding: string;
end_line: number;
hash: string;
id: string | null;
id: string;
model: string;
path: string;
source: Generated<string>;
@@ -71,7 +71,7 @@ export interface MemoryIndexChunks {
}
export interface MemoryIndexMeta {
key: string | null;
key: string;
value: string;
}
+88 -1
View File
@@ -458,6 +458,22 @@ describe("openclaw agent database", () => {
expect(collectSqliteSchemaShape(database.db)).toEqual(
createSqliteSchemaShapeFromSql(new URL("./openclaw-agent-schema.sql", import.meta.url)),
);
expect(
database.db
.prepare(
`SELECT name FROM pragma_table_list
WHERE schema = 'main'
AND type = 'table'
AND name NOT LIKE 'sqlite_%'
AND strict <> 1`,
)
.all(),
).toEqual([]);
expect(() =>
database.db
.prepare("UPDATE schema_meta SET schema_version = ? WHERE meta_key = 'primary'")
.run("not-an-integer"),
).toThrow();
expect(database.agentId).toBe("worker-1");
expect(database.path).toBe(
path.join(stateDir, "agents", "worker-1", "agent", "openclaw-agent.sqlite"),
@@ -475,6 +491,77 @@ describe("openclaw agent database", () => {
expect(registered?.sizeBytes).toBeGreaterThan(0);
});
it("migrates version 8 tables to STRICT without losing agent state", () => {
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const opened = openOpenClawAgentDatabase({ agentId: "worker-1", env });
const databasePath = opened.path;
opened.db
.prepare(
"INSERT INTO auth_profile_state (state_key, state_json, updated_at) VALUES (?, ?, ?)",
)
.run("last-good", '{"profile":"primary"}', 10);
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const legacy = new DatabaseSync(databasePath);
legacy.exec(`
ALTER TABLE auth_profile_state RENAME TO auth_profile_state_strict;
CREATE TABLE auth_profile_state (
state_key TEXT NOT NULL PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
INSERT INTO auth_profile_state SELECT * FROM auth_profile_state_strict;
DROP TABLE auth_profile_state_strict;
DROP TRIGGER memory_index_sources_revision_after_insert;
DROP TRIGGER memory_index_sources_revision_after_update;
DROP TRIGGER memory_index_sources_revision_after_delete;
ALTER TABLE memory_index_sources RENAME TO memory_index_sources_strict;
CREATE TABLE memory_index_sources (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL,
UNIQUE (path, source)
);
INSERT INTO memory_index_sources SELECT * FROM memory_index_sources_strict;
DROP TABLE memory_index_sources_strict;
INSERT INTO memory_index_sources (path, source, hash, mtime, size)
VALUES ('MEMORY.md', 'memory', 'legacy-hash', 10.75, 20);
PRAGMA user_version = 8;
UPDATE schema_meta SET schema_version = 8 WHERE meta_key = 'primary';
`);
legacy.close();
const migrated = openOpenClawAgentDatabase({ agentId: "worker-1", env });
expect(
migrated.db
.prepare("SELECT strict FROM pragma_table_list WHERE name = 'auth_profile_state'")
.get(),
).toEqual({ strict: 1 });
expect(migrated.db.prepare("SELECT * FROM auth_profile_state").get()).toEqual({
state_key: "last-good",
state_json: '{"profile":"primary"}',
updated_at: 10,
});
expect(
migrated.db
.prepare("SELECT mtime, typeof(mtime) AS storage_type FROM memory_index_sources")
.get(),
).toEqual({ mtime: 10.75, storage_type: "real" });
expect(readSqliteNumberPragma(migrated.db, "user_version")).toBe(OPENCLAW_AGENT_SCHEMA_VERSION);
expect(
migrated.db
.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary'")
.get(),
).toEqual({ schema_version: OPENCLAW_AGENT_SCHEMA_VERSION });
expect(migrated.db.prepare("PRAGMA foreign_keys").get()).toEqual({ foreign_keys: 1 });
});
it("generates stable typed memory source identities", () => {
const stateDir = createTempStateDir();
const database = openOpenClawAgentDatabase({
@@ -1470,7 +1557,7 @@ describe("openclaw agent database", () => {
acp_owned: 1,
plugin_owner_id: "history-owner",
});
expect(readSqliteNumberPragma(database.db, "user_version")).toBe(8);
expect(readSqliteNumberPragma(database.db, "user_version")).toBe(OPENCLAW_AGENT_SCHEMA_VERSION);
});
it("inspects registered database ownership without mutating the database", () => {
+10 -3
View File
@@ -23,6 +23,7 @@ import {
assertSqliteSchemaContains,
type SqliteSchemaCompatibility,
} from "../infra/sqlite-schema-contract.js";
import { migrateSqliteSchemaToStrictInTransaction } from "../infra/sqlite-strict.js";
import {
runSqliteImmediateTransactionSync,
type SqliteTransactionOptions,
@@ -69,13 +70,14 @@ export { resolveOpenClawAgentSqlitePath } from "./openclaw-agent-db.paths.js";
* per pathname, protected with private file modes, and registered in the shared
* OpenClaw state database for discovery and maintenance.
*/
// v8 = per-transcript session provenance. v7 added per-entry lifecycle status projection.
// v9 = SQLite STRICT tables. v8 added per-transcript session provenance.
// v7 added per-entry lifecycle status projection.
// v6 added session/transcript hot-path indexes.
// v5 added transcript mutation watermarks.
// The v4 session/transcript flip and main's v2 memory-identity
// change is folded in structure-gated (migrateMemoryIndexSourcesIdentity), so
// v2 main DBs and pre-merge v4 flip DBs both converge on this schema.
export const OPENCLAW_AGENT_SCHEMA_VERSION = 8;
export const OPENCLAW_AGENT_SCHEMA_VERSION = 9;
const OPENCLAW_AGENT_DB_DIR_MODE = 0o700;
const OPENCLAW_AGENT_DB_FILE_MODE = 0o600;
const OPENCLAW_AGENT_DB_SLOW_OPEN_MS = 1_000;
@@ -679,7 +681,7 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string):
// Ownership and version checks must share the write transaction with the
// schema update; concurrent openers must not overwrite another agent.
// Role/ownership gates before version: user_version is only meaningful
// within one schema role, and the global state DB now carries version 2.
// within one schema role, and the global state DB now carries version 3.
assertExistingSchemaOwner(readExistingSchemaMeta(db), agentId, pathname);
assertSupportedAgentSchemaVersion(db, pathname);
const previousVersion = readSqliteUserVersion(db);
@@ -693,6 +695,11 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string):
migrateMemoryIndexSourcesIdentity(db);
migrateOpenClawAgentSchema(db);
db.exec(OPENCLAW_AGENT_SCHEMA_SQL);
if (previousVersion < OPENCLAW_AGENT_SCHEMA_VERSION) {
migrateSqliteSchemaToStrictInTransaction(db, OPENCLAW_AGENT_SCHEMA_SQL, {
databaseLabel: pathname,
});
}
repairCanonicalSqliteUniqueIndexes(db, pathname, OPENCLAW_AGENT_CANONICAL_UNIQUE_INDEXES);
backfillOpenClawAgentSchema(db, previousVersion);
backfillSessionEntryProvenance(db, previousVersion);
+19 -19
View File
@@ -11,7 +11,7 @@ export const OPENCLAW_AGENT_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS schema_meta
app_version TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT NOT NULL PRIMARY KEY,
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS sessions (
spawned_by TEXT,
display_name TEXT,
FOREIGN KEY (primary_conversation_id) REFERENCES conversations(conversation_id) ON DELETE SET NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_sessions_updated_at
ON sessions(updated_at DESC, session_id);
@@ -56,7 +56,7 @@ CREATE TABLE IF NOT EXISTS session_routes (
session_id TEXT NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_session_routes_session_id
ON session_routes(session_id);
@@ -75,7 +75,7 @@ CREATE TABLE IF NOT EXISTS conversations (
metadata_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_conversations_lookup
ON conversations(channel, account_id, kind, peer_id, thread_id);
@@ -102,7 +102,7 @@ CREATE TABLE IF NOT EXISTS session_conversations (
PRIMARY KEY (session_id, conversation_id, role),
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_session_conversations_conversation
ON session_conversations(conversation_id, last_seen_at DESC, session_id);
@@ -118,7 +118,7 @@ CREATE TABLE IF NOT EXISTS session_entries (
updated_at INTEGER NOT NULL,
status TEXT CHECK (status IS NULL OR status IN ('running', 'done', 'failed', 'killed', 'timeout')),
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_session_entries_updated_at
ON session_entries(updated_at DESC, session_key);
@@ -137,7 +137,7 @@ CREATE TABLE IF NOT EXISTS transcript_events (
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, seq),
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);
) STRICT;
CREATE TABLE IF NOT EXISTS trajectory_runtime_events (
session_id TEXT NOT NULL,
@@ -147,7 +147,7 @@ CREATE TABLE IF NOT EXISTS trajectory_runtime_events (
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, seq),
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_trajectory_runtime_run
ON trajectory_runtime_events(session_id, run_id, seq)
@@ -163,7 +163,7 @@ CREATE TABLE IF NOT EXISTS transcript_event_identities (
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, event_id),
FOREIGN KEY (session_id, seq) REFERENCES transcript_events(session_id, seq) ON DELETE CASCADE
);
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_transcript_message_idempotency
ON transcript_event_identities(session_id, message_idempotency_key)
@@ -184,7 +184,7 @@ CREATE TABLE IF NOT EXISTS cache_entries (
expires_at INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (scope, key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_cache_expiry
ON cache_entries(scope, expires_at, key)
@@ -197,28 +197,28 @@ CREATE TABLE IF NOT EXISTS auth_profile_store (
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS auth_profile_state (
state_key TEXT NOT NULL PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS memory_index_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS memory_index_sources (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
mtime REAL NOT NULL,
size INTEGER NOT NULL,
UNIQUE (path, source)
);
) STRICT;
CREATE TABLE IF NOT EXISTS memory_index_chunks (
id TEXT PRIMARY KEY,
@@ -231,7 +231,7 @@ CREATE TABLE IF NOT EXISTS memory_index_chunks (
text TEXT NOT NULL,
embedding TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS memory_embedding_cache (
provider TEXT NOT NULL,
@@ -242,12 +242,12 @@ CREATE TABLE IF NOT EXISTS memory_embedding_cache (
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
) STRICT;
CREATE TABLE IF NOT EXISTS memory_index_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS session_transcript_index_state (
session_id TEXT NOT NULL PRIMARY KEY,
@@ -255,7 +255,7 @@ CREATE TABLE IF NOT EXISTS session_transcript_index_state (
leaf_event_id TEXT,
needs_rebuild INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE VIRTUAL TABLE IF NOT EXISTS session_transcript_fts USING fts5(
text,
+19 -19
View File
@@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS schema_meta (
app_version TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT NOT NULL PRIMARY KEY,
@@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS sessions (
spawned_by TEXT,
display_name TEXT,
FOREIGN KEY (primary_conversation_id) REFERENCES conversations(conversation_id) ON DELETE SET NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_sessions_updated_at
ON sessions(updated_at DESC, session_id);
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS session_routes (
session_id TEXT NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_session_routes_session_id
ON session_routes(session_id);
@@ -70,7 +70,7 @@ CREATE TABLE IF NOT EXISTS conversations (
metadata_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_conversations_lookup
ON conversations(channel, account_id, kind, peer_id, thread_id);
@@ -97,7 +97,7 @@ CREATE TABLE IF NOT EXISTS session_conversations (
PRIMARY KEY (session_id, conversation_id, role),
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE,
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_session_conversations_conversation
ON session_conversations(conversation_id, last_seen_at DESC, session_id);
@@ -113,7 +113,7 @@ CREATE TABLE IF NOT EXISTS session_entries (
updated_at INTEGER NOT NULL,
status TEXT CHECK (status IS NULL OR status IN ('running', 'done', 'failed', 'killed', 'timeout')),
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_session_entries_updated_at
ON session_entries(updated_at DESC, session_key);
@@ -132,7 +132,7 @@ CREATE TABLE IF NOT EXISTS transcript_events (
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, seq),
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);
) STRICT;
CREATE TABLE IF NOT EXISTS trajectory_runtime_events (
session_id TEXT NOT NULL,
@@ -142,7 +142,7 @@ CREATE TABLE IF NOT EXISTS trajectory_runtime_events (
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, seq),
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_trajectory_runtime_run
ON trajectory_runtime_events(session_id, run_id, seq)
@@ -158,7 +158,7 @@ CREATE TABLE IF NOT EXISTS transcript_event_identities (
created_at INTEGER NOT NULL,
PRIMARY KEY (session_id, event_id),
FOREIGN KEY (session_id, seq) REFERENCES transcript_events(session_id, seq) ON DELETE CASCADE
);
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_transcript_message_idempotency
ON transcript_event_identities(session_id, message_idempotency_key)
@@ -179,7 +179,7 @@ CREATE TABLE IF NOT EXISTS cache_entries (
expires_at INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (scope, key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_cache_expiry
ON cache_entries(scope, expires_at, key)
@@ -192,28 +192,28 @@ CREATE TABLE IF NOT EXISTS auth_profile_store (
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS auth_profile_state (
state_key TEXT NOT NULL PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS memory_index_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS memory_index_sources (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
mtime REAL NOT NULL,
size INTEGER NOT NULL,
UNIQUE (path, source)
);
) STRICT;
CREATE TABLE IF NOT EXISTS memory_index_chunks (
id TEXT PRIMARY KEY,
@@ -226,7 +226,7 @@ CREATE TABLE IF NOT EXISTS memory_index_chunks (
text TEXT NOT NULL,
embedding TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS memory_embedding_cache (
provider TEXT NOT NULL,
@@ -237,12 +237,12 @@ CREATE TABLE IF NOT EXISTS memory_embedding_cache (
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
) STRICT;
CREATE TABLE IF NOT EXISTS memory_index_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS session_transcript_index_state (
session_id TEXT NOT NULL PRIMARY KEY,
@@ -250,7 +250,7 @@ CREATE TABLE IF NOT EXISTS session_transcript_index_state (
leaf_event_id TEXT,
needs_rebuild INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE VIRTUAL TABLE IF NOT EXISTS session_transcript_fts USING fts5(
text,
@@ -9,19 +9,19 @@ import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.generated.js"
function canonicalOperatorApprovalCreateSql(): string {
const marker = "CREATE TABLE IF NOT EXISTS operator_approvals (";
const tableTerminator = "\n) STRICT;";
const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(marker);
const end = OPENCLAW_STATE_SCHEMA_SQL.indexOf(
"\n);\n\nCREATE INDEX IF NOT EXISTS idx_operator_approvals_status_expiry",
`${tableTerminator}\n\nCREATE INDEX IF NOT EXISTS idx_operator_approvals_status_expiry`,
start,
);
return OPENCLAW_STATE_SCHEMA_SQL.slice(start, end + 3);
return OPENCLAW_STATE_SCHEMA_SQL.slice(start, end + tableTerminator.length);
}
function legacyTwoKindCreateSql(): string {
return canonicalOperatorApprovalCreateSql().replace(
/'exec',\s*'plugin',\s*'system-agent'/,
"'exec', 'plugin'",
);
return canonicalOperatorApprovalCreateSql()
.replace(/\) STRICT;$/u, ");")
.replace(/'exec',\s*'plugin',\s*'system-agent'/, "'exec', 'plugin'");
}
function seedRow(db: DatabaseSync, kind: string): void {
@@ -53,6 +53,9 @@ describe("repairOperatorApprovalKinds", () => {
expect(() => assertCanonicalOperatorApprovalKinds(db, ":memory:")).not.toThrow();
const rows = db.prepare("SELECT approval_id, kind FROM operator_approvals").all();
expect(rows).toEqual([{ approval_id: "a1", kind: "exec" }]);
expect(
db.prepare("SELECT strict FROM pragma_table_list WHERE name = 'operator_approvals'").get(),
).toEqual({ strict: 1 });
db.close();
});
@@ -77,15 +77,16 @@ function normalizeDdl(sql: string): string {
function canonicalOperatorApprovalCreateSql(): string {
const marker = "CREATE TABLE IF NOT EXISTS operator_approvals (";
const tableTerminator = "\n) STRICT;";
const start = OPENCLAW_STATE_SCHEMA_SQL.indexOf(marker);
const end = OPENCLAW_STATE_SCHEMA_SQL.indexOf(
"\n);\n\nCREATE INDEX IF NOT EXISTS idx_operator_approvals_status_expiry",
`${tableTerminator}\n\nCREATE INDEX IF NOT EXISTS idx_operator_approvals_status_expiry`,
start,
);
if (start < 0 || end < 0) {
throw new Error("canonical operator approval schema is unavailable");
}
return OPENCLAW_STATE_SCHEMA_SQL.slice(start, end + 3);
return OPENCLAW_STATE_SCHEMA_SQL.slice(start, end + tableTerminator.length);
}
// The only legacy shape this repair may destructively replace is the exact
@@ -98,16 +99,14 @@ function hasExactLegacyOperatorApprovalSchema(db: DatabaseSync): boolean {
if (!live) {
return false;
}
const expectedLegacy = normalizeDdl(
canonicalOperatorApprovalCreateSql()
// sqlite_master stores the CREATE statement without "IF NOT EXISTS".
.replace(
"CREATE TABLE IF NOT EXISTS operator_approvals (",
"CREATE TABLE operator_approvals (",
)
.replace(/'exec',\s*'plugin',\s*'system-agent'/, "'exec', 'plugin'"),
);
return normalizeDdl(live) === expectedLegacy;
const exactStrictLegacy = canonicalOperatorApprovalCreateSql()
// sqlite_master stores the CREATE statement without "IF NOT EXISTS".
.replace("CREATE TABLE IF NOT EXISTS operator_approvals (", "CREATE TABLE operator_approvals (")
.replace(/'exec',\s*'plugin',\s*'system-agent'/, "'exec', 'plugin'");
const expectedStrictLegacy = normalizeDdl(exactStrictLegacy);
const expectedFlexibleLegacy = normalizeDdl(exactStrictLegacy.replace(/\) STRICT;$/u, ");"));
const normalizedLive = normalizeDdl(live);
return normalizedLive === expectedStrictLegacy || normalizedLive === expectedFlexibleLegacy;
}
function canonicalCreateSql(): string {
+77 -1
View File
@@ -719,6 +719,78 @@ describe("openclaw state database", () => {
createSqliteSchemaShapeFromSql(new URL("./openclaw-state-schema.sql", import.meta.url)),
);
expect(database.path).toBe(path.join(stateDir, "state", "openclaw.sqlite"));
expect(
database.db
.prepare(
`SELECT name FROM pragma_table_list
WHERE schema = 'main'
AND type = 'table'
AND name NOT LIKE 'sqlite_%'
AND strict <> 1`,
)
.all(),
).toEqual([]);
expect(() =>
database.db
.prepare("UPDATE schema_meta SET schema_version = ? WHERE meta_key = 'primary'")
.run("not-an-integer"),
).toThrow();
});
it("doctor migrates version 2 tables to STRICT without losing rows", () => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const opened = openOpenClawStateDatabase(options);
const databasePath = opened.path;
opened.db
.prepare(
`INSERT INTO skill_curator_state (
id, last_attempt_at_ms, last_success_at_ms, last_error, last_result_json
) VALUES (1, 10, 20, NULL, '{}')`,
)
.run();
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const legacy = new DatabaseSync(databasePath);
legacy.exec(`
ALTER TABLE skill_curator_state RENAME TO skill_curator_state_strict;
CREATE TABLE skill_curator_state (
id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
last_attempt_at_ms INTEGER NOT NULL,
last_success_at_ms INTEGER,
last_error TEXT,
last_result_json TEXT NOT NULL
);
INSERT INTO skill_curator_state SELECT * FROM skill_curator_state_strict;
DROP TABLE skill_curator_state_strict;
PRAGMA user_version = 2;
UPDATE schema_meta SET schema_version = 2 WHERE meta_key = 'primary';
`);
legacy.close();
expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([
{ kind: "strict-tables-v3", path: databasePath },
]);
expect(repairOpenClawStateDatabaseSchema(options)).toEqual({
changes: ["Migrated shared state tables to SQLite STRICT typing (1)"],
warnings: [],
});
expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([]);
const migrated = openOpenClawStateDatabase(options);
expect(
migrated.db
.prepare("SELECT strict FROM pragma_table_list WHERE name = 'skill_curator_state'")
.get(),
).toEqual({ strict: 1 });
expect(migrated.db.prepare("SELECT * FROM skill_curator_state").get()).toEqual({
id: 1,
last_attempt_at_ms: 10,
last_success_at_ms: 20,
last_error: null,
last_result_json: "{}",
});
});
it("rejects a placement turn claim tuple without an owner", () => {
@@ -1131,11 +1203,15 @@ describe("openclaw state database", () => {
expect(detectOpenClawStateDatabaseSchemaMigrations(options)).toEqual([
{ kind: "audit-events-v2", path: databasePath },
{ kind: "strict-tables-v3", path: databasePath },
]);
expect(() => openOpenClawStateDatabase(options)).toThrow(/legacy audit event schema/);
expect(repairOpenClawStateDatabaseSchema(options)).toEqual({
changes: ["Migrated shared state audit event ledger → versioned message lifecycle schema"],
changes: [
"Migrated shared state audit event ledger → versioned message lifecycle schema",
"Migrated shared state tables to SQLite STRICT typing (3)",
],
warnings: [],
});
expect(repairOpenClawStateDatabaseSchema(options)).toEqual({ changes: [], warnings: [] });
+82 -43
View File
@@ -20,6 +20,7 @@ import {
assertSqliteSchemaContains,
type SqliteSchemaCompatibility,
} from "../infra/sqlite-schema-contract.js";
import { migrateSqliteSchemaToStrictInTransaction } from "../infra/sqlite-strict.js";
import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js";
import {
createNewerSqliteSchemaVersionError,
@@ -53,7 +54,8 @@ import { OPENCLAW_STATE_SCHEMA_SQL } from "./openclaw-state-schema.generated.js"
* tables, private file permissions, cached handles, and audit rows for
* migrations/backups that operate on local state.
*/
export const OPENCLAW_STATE_SCHEMA_VERSION = 2;
// v3 rebuilds every OpenClaw-owned table with SQLite STRICT type enforcement.
export const OPENCLAW_STATE_SCHEMA_VERSION = 3;
/** Maximum time one synchronous SQLite call may wait for a lock. */
export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5_000;
const OPENCLAW_STATE_DIR_MODE = 0o700;
@@ -114,7 +116,8 @@ export type OpenClawStateDatabaseSchemaMigration = {
kind:
| "agent-databases-composite-primary-key"
| "audit-events-v2"
| "operator-approvals-system-agent";
| "operator-approvals-system-agent"
| "strict-tables-v3";
path: string;
};
const cachedDatabases = new Map<string, OpenClawStateDatabase>();
@@ -771,6 +774,12 @@ export function detectOpenClawStateDatabaseSchemaMigrations(
if (!hasCanonicalAuditEventsSchema(db)) {
migrations.push({ kind: "audit-events-v2", path: pathname });
}
if (
tableExists(db, "audit_events") &&
readSqliteUserVersion(db) < OPENCLAW_STATE_SCHEMA_VERSION
) {
migrations.push({ kind: "strict-tables-v3", path: pathname });
}
migrations.push(
...operatorApprovalMigration.detectOperatorApprovalSchemaMigration(db, pathname),
);
@@ -795,6 +804,7 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
try {
assertStateDatabaseIntegrityBeforeMutation(db, pathname);
assertSupportedSchemaVersion(db, pathname);
db.exec("PRAGMA foreign_keys = OFF;");
const changes = runSqliteImmediateTransactionSync(
db,
() => {
@@ -809,6 +819,20 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
}
applied.push(...operatorApprovalMigration.repairOperatorApprovalSchema(db));
assertCanonicalStateSchemaShape(db, pathname);
if (tableExists(db, "audit_events")) {
ensureAdditiveStateColumns(db);
db.exec(OPENCLAW_STATE_SCHEMA_SQL);
const strictMigration = migrateSqliteSchemaToStrictInTransaction(
db,
OPENCLAW_STATE_SCHEMA_SQL,
{ databaseLabel: pathname },
);
if (strictMigration.migratedTables.length > 0) {
applied.push(
`Migrated shared state tables to SQLite STRICT typing (${strictMigration.migratedTables.length})`,
);
}
}
markCurrentStateSchemaVersion(db);
return applied;
},
@@ -825,6 +849,9 @@ export function repairOpenClawStateDatabaseSchema(options: OpenClawStateDatabase
warnings: [`Failed migrating shared state database schema at ${pathname}: ${String(err)}`],
};
} finally {
if (db.isOpen) {
db.exec("PRAGMA foreign_keys = ON;");
}
db.close();
ensureOpenClawStatePermissions(pathname, env);
}
@@ -1530,52 +1557,64 @@ function ensureAdditiveStateColumns(db: DatabaseSync): void {
function ensureSchema(db: DatabaseSync, pathname: string): void {
const now = Date.now();
const kysely = getNodeSqliteKysely<OpenClawStateMetadataDatabase>(db);
runSqliteImmediateTransactionSync(
db,
() => {
assertSupportedSchemaVersion(db, pathname);
ensureAdditiveStateColumns(db);
assertCanonicalStateSchemaShape(db, pathname);
db.exec(OPENCLAW_STATE_SCHEMA_SQL);
migrateLegacyCronRunLogsToTaskRuns(db);
repairCanonicalSqliteUniqueIndexes(db, pathname, OPENCLAW_STATE_CANONICAL_UNIQUE_INDEXES);
// Retired node_pairing_* tables were created by earlier schema revisions but
// never had a shipped writer (the node surface lives on device_pairing_paired
// records), so dropping the always-empty tables is safe, not destructive.
db.exec(
"DROP TABLE IF EXISTS node_pairing_pending; DROP TABLE IF EXISTS node_pairing_paired;",
);
db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};`);
executeSqliteQuerySync(
db,
kysely
.insertInto("schema_meta")
.values({
meta_key: "primary",
role: "global",
schema_version: OPENCLAW_STATE_SCHEMA_VERSION,
agent_id: null,
app_version: null,
created_at: now,
updated_at: now,
})
.onConflict((conflict) =>
conflict.column("meta_key").doUpdateSet({
// Rebuilding referenced tables requires disabling FK enforcement before BEGIN.
db.exec("PRAGMA foreign_keys = OFF;");
try {
runSqliteImmediateTransactionSync(
db,
() => {
assertSupportedSchemaVersion(db, pathname);
const previousVersion = readSqliteUserVersion(db);
ensureAdditiveStateColumns(db);
assertCanonicalStateSchemaShape(db, pathname);
db.exec(OPENCLAW_STATE_SCHEMA_SQL);
migrateLegacyCronRunLogsToTaskRuns(db);
if (previousVersion < OPENCLAW_STATE_SCHEMA_VERSION) {
migrateSqliteSchemaToStrictInTransaction(db, OPENCLAW_STATE_SCHEMA_SQL, {
databaseLabel: pathname,
});
}
repairCanonicalSqliteUniqueIndexes(db, pathname, OPENCLAW_STATE_CANONICAL_UNIQUE_INDEXES);
// Retired node_pairing_* tables were created by earlier schema revisions but
// never had a shipped writer (the node surface lives on device_pairing_paired
// records), so dropping the always-empty tables is safe, not destructive.
db.exec(
"DROP TABLE IF EXISTS node_pairing_pending; DROP TABLE IF EXISTS node_pairing_paired;",
);
db.exec(`PRAGMA user_version = ${OPENCLAW_STATE_SCHEMA_VERSION};`);
executeSqliteQuerySync(
db,
kysely
.insertInto("schema_meta")
.values({
meta_key: "primary",
role: "global",
schema_version: OPENCLAW_STATE_SCHEMA_VERSION,
agent_id: null,
app_version: null,
created_at: now,
updated_at: now,
}),
),
);
},
{
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: pathname,
operationLabel: "state.schema.ensure",
},
);
})
.onConflict((conflict) =>
conflict.column("meta_key").doUpdateSet({
role: "global",
schema_version: OPENCLAW_STATE_SCHEMA_VERSION,
agent_id: null,
app_version: null,
updated_at: now,
}),
),
);
},
{
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
databaseLabel: pathname,
operationLabel: "state.schema.ensure",
},
);
} finally {
db.exec("PRAGMA foreign_keys = ON;");
}
}
function resolveDatabasePath(options: OpenClawStateDatabaseOptions = {}): string {
+87 -87
View File
@@ -7,13 +7,13 @@ export const OPENCLAW_STATE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS auth_profil
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS auth_profile_state (
store_key TEXT NOT NULL PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS diagnostic_events (
scope TEXT NOT NULL,
@@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS diagnostic_events (
payload_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (scope, event_key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_diagnostic_events_scope_created
ON diagnostic_events(scope, created_at, event_key);
@@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS skill_usage (
last_used_at_ms INTEGER NOT NULL,
use_count INTEGER NOT NULL,
last_agent_id TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_skill_usage_key
ON skill_usage(skill_key, skill_file);
@@ -49,7 +49,7 @@ CREATE TABLE IF NOT EXISTS skill_lifecycle (
state_changed_at_ms INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
archived_reason TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_skill_lifecycle_key
ON skill_lifecycle(skill_key, skill_file);
@@ -63,7 +63,7 @@ CREATE TABLE IF NOT EXISTS skill_curator_state (
last_success_at_ms INTEGER,
last_error TEXT,
last_result_json TEXT NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS audit_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -97,7 +97,7 @@ CREATE TABLE IF NOT EXISTS audit_events (
conversation_ref TEXT,
message_ref TEXT,
target_ref TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_audit_events_time
ON audit_events(occurred_at DESC, sequence DESC);
@@ -128,7 +128,7 @@ CREATE TABLE IF NOT EXISTS audit_identity_keys (
key_id TEXT NOT NULL,
key BLOB NOT NULL,
created_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS session_state_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -143,7 +143,7 @@ CREATE TABLE IF NOT EXISTS session_state_events (
occurred_at INTEGER NOT NULL,
summary TEXT NOT NULL,
payload_json TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_session_state_events_session_sequence
ON session_state_events(session_key, sequence DESC);
@@ -158,7 +158,7 @@ CREATE TABLE IF NOT EXISTS session_state_heads (
pruned_max_sequence INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (session_key, agent_id)
);
) STRICT;
-- Watcher identity is the bare session key, matching the process-local system-event
-- queue it feeds. Producers only create rows for agent-qualified watcher keys;
@@ -172,7 +172,7 @@ CREATE TABLE IF NOT EXISTS session_watch_cursors (
material_sequence INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (watcher_session_key, target_session_key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_session_watch_cursors_target
ON session_watch_cursors(target_session_key);
@@ -192,7 +192,7 @@ CREATE TABLE IF NOT EXISTS session_upstream_links (
-- (session_key, agent_id) composite identity: under session.scope="global" agents
-- share bare keys; a key-only row would let one agent overwrite another's upstream.
PRIMARY KEY (session_key, agent_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_session_upstream_links_catalog_id
ON session_upstream_links(catalog_id);
@@ -203,7 +203,7 @@ CREATE TABLE IF NOT EXISTS diagnostic_stability_bundles (
generated_at TEXT NOT NULL,
bundle_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_diagnostic_stability_bundles_created
ON diagnostic_stability_bundles(created_at DESC, bundle_key);
@@ -218,7 +218,7 @@ CREATE TABLE IF NOT EXISTS state_leases (
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (scope, lease_key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_state_leases_expiry
ON state_leases(expires_at, scope, lease_key)
@@ -239,7 +239,7 @@ CREATE TABLE IF NOT EXISTS exec_approvals_config (
agent_count INTEGER NOT NULL,
allowlist_count INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS operator_approvals (
approval_id TEXT NOT NULL PRIMARY KEY CHECK (
@@ -345,7 +345,7 @@ CREATE TABLE IF NOT EXISTS operator_approvals (
AND consumed_by IS NOT NULL
)
)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_operator_approvals_status_expiry
ON operator_approvals(status, expires_at_ms, approval_id);
@@ -372,7 +372,7 @@ CREATE TABLE IF NOT EXISTS schema_meta (
app_version TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS device_pairing_pending (
request_id TEXT NOT NULL PRIMARY KEY,
@@ -391,7 +391,7 @@ CREATE TABLE IF NOT EXISTS device_pairing_pending (
is_repair INTEGER,
ts INTEGER NOT NULL,
refreshed_at_ms INTEGER
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_pairing_pending_device
ON device_pairing_pending(device_id, ts DESC);
@@ -418,7 +418,7 @@ CREATE TABLE IF NOT EXISTS device_pairing_paired (
approved_at_ms INTEGER NOT NULL,
last_seen_at_ms INTEGER,
last_seen_reason TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_pairing_paired_approved
ON device_pairing_paired(approved_at_ms DESC, device_id);
@@ -434,7 +434,7 @@ CREATE TABLE IF NOT EXISTS device_bootstrap_tokens (
pending_profile_json TEXT,
issued_at_ms INTEGER NOT NULL,
last_used_at_ms INTEGER
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_bootstrap_tokens_ts
ON device_bootstrap_tokens(ts);
@@ -446,7 +446,7 @@ CREATE TABLE IF NOT EXISTS device_identities (
private_key_pem TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_identities_device
ON device_identities(device_id, updated_at_ms DESC);
@@ -458,7 +458,7 @@ CREATE TABLE IF NOT EXISTS device_auth_tokens (
scopes_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (device_id, role)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_auth_tokens_updated
ON device_auth_tokens(updated_at_ms DESC, device_id, role);
@@ -467,7 +467,7 @@ CREATE TABLE IF NOT EXISTS android_notification_recent_packages (
package_name TEXT NOT NULL PRIMARY KEY,
sort_order INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_android_notification_recent_packages_order
ON android_notification_recent_packages(sort_order, package_name);
@@ -478,7 +478,7 @@ CREATE TABLE IF NOT EXISTS macos_port_guardian_records (
command TEXT NOT NULL,
mode TEXT NOT NULL,
timestamp REAL NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_macos_port_guardian_records_port
ON macos_port_guardian_records(port, timestamp DESC);
@@ -490,7 +490,7 @@ CREATE TABLE IF NOT EXISTS workspace_setup_state (
bootstrap_seeded_at TEXT,
setup_completed_at TEXT,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_workspace_setup_state_path
ON workspace_setup_state(workspace_path);
@@ -503,7 +503,7 @@ CREATE TABLE IF NOT EXISTS native_hook_relay_bridges (
token TEXT NOT NULL,
expires_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_native_hook_relay_bridges_expires
ON native_hook_relay_bridges(expires_at_ms, relay_id);
@@ -524,7 +524,7 @@ CREATE TABLE IF NOT EXISTS model_capability_cache (
cost_cache_write REAL NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (provider_id, model_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_model_capability_cache_provider_updated
ON model_capability_cache(provider_id, updated_at_ms DESC, model_id);
@@ -534,7 +534,7 @@ CREATE TABLE IF NOT EXISTS agent_model_catalogs (
agent_dir TEXT NOT NULL,
raw_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_model_catalogs_agent_dir
ON agent_model_catalogs(agent_dir, updated_at DESC);
@@ -558,7 +558,7 @@ CREATE TABLE IF NOT EXISTS managed_outgoing_image_records (
original_filename TEXT,
record_json TEXT NOT NULL,
cleanup_pending INTEGER NOT NULL DEFAULT 0 CHECK (cleanup_pending IN (0, 1))
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_managed_outgoing_images_session
ON managed_outgoing_image_records(session_key, created_at DESC, attachment_id);
@@ -583,7 +583,7 @@ CREATE TABLE IF NOT EXISTS channel_pairing_requests (
last_seen_at TEXT NOT NULL,
meta_json TEXT,
PRIMARY KEY (channel_key, account_id, request_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_channel_pairing_requests_code
ON channel_pairing_requests(channel_key, code);
@@ -598,7 +598,7 @@ CREATE TABLE IF NOT EXISTS channel_pairing_allow_entries (
sort_order INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (channel_key, account_id, entry)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_channel_pairing_allow_account
ON channel_pairing_allow_entries(channel_key, account_id, sort_order, entry);
@@ -611,7 +611,7 @@ CREATE TABLE IF NOT EXISTS web_push_subscriptions (
auth TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_web_push_subscriptions_updated
ON web_push_subscriptions(updated_at_ms DESC, subscription_id);
@@ -622,7 +622,7 @@ CREATE TABLE IF NOT EXISTS web_push_vapid_keys (
private_key TEXT NOT NULL,
subject TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS apns_registrations (
node_id TEXT NOT NULL PRIMARY KEY,
@@ -636,7 +636,7 @@ CREATE TABLE IF NOT EXISTS apns_registrations (
distribution TEXT,
token_debug_suffix TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_apns_registrations_updated
ON apns_registrations(updated_at_ms DESC, node_id);
@@ -653,7 +653,7 @@ CREATE TABLE IF NOT EXISTS node_host_config (
gateway_tls_fingerprint TEXT,
gateway_context_path TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS voicewake_triggers (
config_key TEXT NOT NULL,
@@ -661,7 +661,7 @@ CREATE TABLE IF NOT EXISTS voicewake_triggers (
trigger TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (config_key, position)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_voicewake_triggers_trigger
ON voicewake_triggers(config_key, trigger);
@@ -673,7 +673,7 @@ CREATE TABLE IF NOT EXISTS voicewake_routing_config (
default_target_agent_id TEXT,
default_target_session_key TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS voicewake_routing_routes (
config_key TEXT NOT NULL,
@@ -685,7 +685,7 @@ CREATE TABLE IF NOT EXISTS voicewake_routing_routes (
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (config_key, position),
FOREIGN KEY (config_key) REFERENCES voicewake_routing_config(config_key) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_voicewake_routing_routes_trigger
ON voicewake_routing_routes(config_key, trigger);
@@ -706,7 +706,7 @@ CREATE TABLE IF NOT EXISTS update_check_state (
auto_last_success_version TEXT,
auto_last_success_at TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS config_health_entries (
config_path TEXT NOT NULL PRIMARY KEY,
@@ -714,7 +714,7 @@ CREATE TABLE IF NOT EXISTS config_health_entries (
last_promoted_good_json TEXT,
last_observed_suspicious_signature TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS clawhub_promotions_feed_state (
state_key TEXT NOT NULL PRIMARY KEY,
@@ -724,7 +724,7 @@ CREATE TABLE IF NOT EXISTS clawhub_promotions_feed_state (
last_checked_at_ms INTEGER,
notified_slugs_json TEXT NOT NULL DEFAULT '[]',
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS clawhub_promotion_claims (
slug TEXT NOT NULL PRIMARY KEY,
@@ -732,7 +732,7 @@ CREATE TABLE IF NOT EXISTS clawhub_promotion_claims (
model_keys_json TEXT NOT NULL,
ends_at_ms INTEGER NOT NULL,
claimed_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS installed_plugin_index (
index_key TEXT NOT NULL PRIMARY KEY,
@@ -748,7 +748,7 @@ CREATE TABLE IF NOT EXISTS installed_plugin_index (
diagnostics_json TEXT NOT NULL,
warning TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_installed_plugin_index_generated
ON installed_plugin_index(generated_at_ms DESC, index_key);
@@ -767,7 +767,7 @@ CREATE TABLE IF NOT EXISTS official_external_plugin_catalog_snapshots (
trust_threshold INTEGER,
trust_verified_at TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_official_external_plugin_catalog_snapshots_updated
ON official_external_plugin_catalog_snapshots(updated_at_ms DESC, feed_url);
@@ -789,7 +789,7 @@ CREATE TABLE IF NOT EXISTS gateway_restart_sentinel (
stats_json TEXT,
payload_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_gateway_restart_sentinel_ts
ON gateway_restart_sentinel(ts DESC, sentinel_key);
@@ -803,7 +803,7 @@ CREATE TABLE IF NOT EXISTS gateway_restart_intent (
force INTEGER,
wait_ms INTEGER,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS gateway_restart_handoff (
handoff_key TEXT NOT NULL PRIMARY KEY,
@@ -821,7 +821,7 @@ CREATE TABLE IF NOT EXISTS gateway_restart_handoff (
restart_kind TEXT NOT NULL,
supervisor_mode TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_gateway_restart_handoff_expiry
ON gateway_restart_handoff(expires_at, pid);
@@ -834,7 +834,7 @@ CREATE TABLE IF NOT EXISTS gateway_boot_lifecycle (
outcome TEXT,
startup_reason TEXT,
reason TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_gateway_boot_lifecycle_started
ON gateway_boot_lifecycle(started_at_ms);
@@ -853,7 +853,7 @@ CREATE TABLE IF NOT EXISTS acp_sessions (
last_activity_at INTEGER NOT NULL,
last_error TEXT,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_acp_sessions_state_activity
ON acp_sessions(state, last_activity_at DESC, session_key);
@@ -873,7 +873,7 @@ CREATE TABLE IF NOT EXISTS acp_replay_sessions (
-- all event rows), maintained at insert/trim so budget checks never scan
-- acp_replay_events (#100622).
estimated_bytes INTEGER NOT NULL DEFAULT 0
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_acp_replay_sessions_key_updated
ON acp_replay_sessions(session_key, complete, updated_at DESC, session_id);
@@ -891,7 +891,7 @@ CREATE TABLE IF NOT EXISTS acp_replay_events (
estimated_bytes INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (session_id, seq),
FOREIGN KEY (session_id) REFERENCES acp_replay_sessions(session_id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_acp_replay_events_session_seq
ON acp_replay_events(session_id, seq);
@@ -903,7 +903,7 @@ CREATE TABLE IF NOT EXISTS agent_databases (
last_seen_at INTEGER NOT NULL,
size_bytes INTEGER,
PRIMARY KEY (agent_id, path)
);
) STRICT;
CREATE TABLE IF NOT EXISTS plugin_state_entries (
plugin_id TEXT NOT NULL,
@@ -913,7 +913,7 @@ CREATE TABLE IF NOT EXISTS plugin_state_entries (
created_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (plugin_id, namespace, entry_key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_plugin_state_expiry
ON plugin_state_entries(expires_at)
@@ -944,7 +944,7 @@ CREATE TABLE IF NOT EXISTS channel_ingress_events (
completed_at INTEGER,
completed_metadata_json TEXT,
PRIMARY KEY (queue_name, event_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_channel_ingress_pending
ON channel_ingress_events(queue_name, status, received_at, event_id);
@@ -964,7 +964,7 @@ CREATE TABLE IF NOT EXISTS plugin_blob_entries (
created_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (plugin_id, namespace, entry_key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_plugin_blob_expiry
ON plugin_blob_entries(expires_at)
@@ -982,7 +982,7 @@ CREATE TABLE IF NOT EXISTS media_blobs (
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (subdir, id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_media_blobs_created
ON media_blobs(created_at);
@@ -1002,7 +1002,7 @@ CREATE TABLE IF NOT EXISTS skill_uploads (
committed INTEGER NOT NULL,
committed_at INTEGER,
idempotency_key_hash TEXT UNIQUE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_skill_uploads_expiry
ON skill_uploads(expires_at);
@@ -1019,7 +1019,7 @@ CREATE TABLE IF NOT EXISTS skill_upload_chunks (
PRIMARY KEY (upload_id, byte_offset),
FOREIGN KEY (upload_id) REFERENCES skill_uploads(upload_id) ON DELETE CASCADE,
CHECK (length(chunk_blob) = size_bytes)
);
) STRICT;
CREATE TABLE IF NOT EXISTS capture_sessions (
id TEXT NOT NULL PRIMARY KEY,
@@ -1029,7 +1029,7 @@ CREATE TABLE IF NOT EXISTS capture_sessions (
source_scope TEXT NOT NULL,
source_process TEXT NOT NULL,
proxy_url TEXT
);
) STRICT;
CREATE TABLE IF NOT EXISTS capture_blobs (
blob_id TEXT NOT NULL PRIMARY KEY,
@@ -1039,7 +1039,7 @@ CREATE TABLE IF NOT EXISTS capture_blobs (
sha256 TEXT NOT NULL,
data BLOB NOT NULL,
created_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS capture_events (
id INTEGER NOT NULL PRIMARY KEY,
@@ -1065,7 +1065,7 @@ CREATE TABLE IF NOT EXISTS capture_events (
meta_json TEXT,
FOREIGN KEY (session_id) REFERENCES capture_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (data_blob_id) REFERENCES capture_blobs(blob_id) ON DELETE SET NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS capture_events_session_ts_idx
ON capture_events(session_id, ts);
@@ -1089,7 +1089,7 @@ CREATE TABLE IF NOT EXISTS sandbox_registry_entries (
entry_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (registry_kind, container_name)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_sandbox_registry_updated
ON sandbox_registry_entries(registry_kind, updated_at DESC, container_name);
@@ -1133,7 +1133,7 @@ CREATE TABLE IF NOT EXISTS commitments (
snoozed_until_ms INTEGER,
expired_at_ms INTEGER,
record_json TEXT NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_commitments_scope_due
ON commitments(agent_id, session_key, status, due_earliest_ms, due_latest_ms);
@@ -1227,7 +1227,7 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (store_key, job_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_updated
ON cron_jobs(store_key, sort_order ASC, updated_at DESC, job_id);
@@ -1251,7 +1251,7 @@ CREATE TABLE IF NOT EXISTS command_log_entries (
sender_id TEXT NOT NULL,
source TEXT NOT NULL,
entry_json TEXT NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_command_log_entries_timestamp
ON command_log_entries(timestamp_ms DESC, id);
@@ -1278,7 +1278,7 @@ CREATE TABLE IF NOT EXISTS delivery_queue_entries (
updated_at INTEGER NOT NULL,
failed_at INTEGER,
PRIMARY KEY (queue_name, id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_delivery_queue_pending
ON delivery_queue_entries(queue_name, status, enqueued_at, id);
@@ -1325,7 +1325,7 @@ CREATE TABLE IF NOT EXISTS task_runs (
terminal_summary TEXT,
terminal_outcome TEXT,
detail_json TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_task_runs_run_id ON task_runs(run_id);
CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(status);
@@ -1386,7 +1386,7 @@ CREATE TABLE IF NOT EXISTS subagent_runs (
pending_final_delivery_payload_json TEXT,
completion_announced_at INTEGER,
payload_json TEXT NOT NULL DEFAULT '{}'
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_subagent_runs_child_session_key
ON subagent_runs(child_session_key, created_at DESC, run_id);
@@ -1417,7 +1417,7 @@ CREATE TABLE IF NOT EXISTS current_conversation_bindings (
metadata_json TEXT,
record_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_current_conversation_bindings_target
ON current_conversation_bindings(target_agent_id, target_session_key, updated_at DESC, binding_key);
@@ -1434,7 +1434,7 @@ CREATE TABLE IF NOT EXISTS plugin_binding_approvals (
plugin_name TEXT,
approved_at INTEGER NOT NULL,
PRIMARY KEY (plugin_root, channel, account_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_plugin_binding_approvals_plugin
ON plugin_binding_approvals(plugin_id, approved_at DESC);
@@ -1443,7 +1443,7 @@ CREATE TABLE IF NOT EXISTS tui_last_sessions (
scope_key TEXT NOT NULL PRIMARY KEY,
session_key TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_tui_last_sessions_session_key
ON tui_last_sessions(session_key, updated_at DESC, scope_key);
@@ -1453,7 +1453,7 @@ CREATE TABLE IF NOT EXISTS task_delivery_state (
requester_origin_json TEXT,
last_notified_event_at INTEGER,
FOREIGN KEY (task_id) REFERENCES task_runs(task_id) ON DELETE CASCADE
);
) STRICT;
CREATE TABLE IF NOT EXISTS flow_runs (
flow_id TEXT NOT NULL PRIMARY KEY,
@@ -1475,7 +1475,7 @@ CREATE TABLE IF NOT EXISTS flow_runs (
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ended_at INTEGER
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_flow_runs_status ON flow_runs(status);
CREATE INDEX IF NOT EXISTS idx_flow_runs_owner_key ON flow_runs(owner_key);
@@ -1487,7 +1487,7 @@ CREATE TABLE IF NOT EXISTS migration_runs (
finished_at INTEGER,
status TEXT NOT NULL,
report_json TEXT NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_migration_runs_started
ON migration_runs(started_at DESC, id);
@@ -1506,7 +1506,7 @@ CREATE TABLE IF NOT EXISTS migration_sources (
removed_source INTEGER NOT NULL DEFAULT 0,
report_json TEXT NOT NULL,
FOREIGN KEY (last_run_id) REFERENCES migration_runs(id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_migration_sources_path
ON migration_sources(source_path, migration_kind, target_table);
@@ -1520,7 +1520,7 @@ CREATE TABLE IF NOT EXISTS backup_runs (
archive_path TEXT NOT NULL,
status TEXT NOT NULL,
manifest_json TEXT NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_backup_runs_created
ON backup_runs(created_at DESC, id);
@@ -1538,7 +1538,7 @@ CREATE TABLE IF NOT EXISTS worktrees (
created_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL,
removed_at INTEGER
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_worktrees_repo_fingerprint
ON worktrees(repo_fingerprint);
@@ -1553,7 +1553,7 @@ CREATE TABLE IF NOT EXISTS session_groups (
name TEXT NOT NULL PRIMARY KEY,
position INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
) STRICT;
-- Gateway-owned durable cloud worker lifecycle. Provider-specific execution
-- stays in plugins; this table records only core reconciliation facts.
@@ -1596,7 +1596,7 @@ CREATE TABLE IF NOT EXISTS worker_environments (
idle_since_at_ms INTEGER,
destroy_requested_at_ms INTEGER,
last_error TEXT
);
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_environments_provider_lease
ON worker_environments(provider_id, lease_id)
@@ -1710,7 +1710,7 @@ CREATE TABLE IF NOT EXISTS worker_session_placements (
(turn_claim_owner IS 'worker' AND state IN ('active', 'draining')
AND turn_claim_owner_epoch IS active_owner_epoch)
)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_worker_session_placements_session_key
ON worker_session_placements(agent_id, session_key);
@@ -1731,7 +1731,7 @@ CREATE TABLE IF NOT EXISTS worker_workspace_reconciliations (
base_pack BLOB NOT NULL CHECK (length(base_pack) <= 268435456),
created_at_ms INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES worker_session_placements(session_id) ON DELETE CASCADE
);
) STRICT;
-- A completed remote turn is fenced from stale-claim teardown until its
-- workspace result is durably reconciled into the managed worktree.
@@ -1747,7 +1747,7 @@ CREATE TABLE IF NOT EXISTS worker_workspace_pending_results (
workspace_accepted_at_ms INTEGER,
created_at_ms INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES worker_session_placements(session_id) ON DELETE CASCADE
);
) STRICT;
-- One active, opaque admission credential per worker environment. Plaintext
-- may be retried until delivery acknowledgement but never enters durable state.
@@ -1761,7 +1761,7 @@ CREATE TABLE IF NOT EXISTS worker_environment_credentials (
expires_at_ms INTEGER NOT NULL CHECK (expires_at_ms >= 0),
delivered_at_ms INTEGER CHECK (delivered_at_ms >= 0),
FOREIGN KEY (environment_id) REFERENCES worker_environments(environment_id) ON DELETE CASCADE
);
) STRICT;
-- One durable sequence cursor per attached session owner epoch. The environment
-- binding prevents independent workers with coincident epochs from sharing replay state.
@@ -1772,7 +1772,7 @@ CREATE TABLE IF NOT EXISTS worker_transcript_commit_heads (
next_seq INTEGER NOT NULL CHECK (next_seq >= 1),
updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0),
PRIMARY KEY (session_id, run_epoch)
);
) STRICT;
-- Pending rows preserve a claimed request across gateway restarts. Terminal rows
-- cache the exact result returned for deterministic at-least-once replay.
@@ -1793,7 +1793,7 @@ CREATE TABLE IF NOT EXISTS worker_transcript_commits (
(state = 'pending' AND result_json IS NULL) OR
(state = 'terminal' AND result_json IS NOT NULL)
)
);
) STRICT;
-- Pending rows preserve a claimed inference turn across gateway restarts.
-- Terminal rows cache the exact outcome returned for deterministic replay.
@@ -1814,7 +1814,7 @@ CREATE TABLE IF NOT EXISTS worker_inference_turns (
(state = 'pending' AND terminal_json IS NULL) OR
(state = 'terminal' AND terminal_json IS NOT NULL)
)
);
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_inference_turns_pending_run
ON worker_inference_turns(session_id, run_epoch, run_id)
@@ -1828,4 +1828,4 @@ CREATE TABLE IF NOT EXISTS fleet_cells (
host_port INTEGER NOT NULL,
container_name TEXT NOT NULL,
data_dir TEXT NOT NULL
);\n`;
) STRICT;\n`;
+87 -87
View File
@@ -2,13 +2,13 @@ CREATE TABLE IF NOT EXISTS auth_profile_stores (
store_key TEXT NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS auth_profile_state (
store_key TEXT NOT NULL PRIMARY KEY,
state_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS diagnostic_events (
scope TEXT NOT NULL,
@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS diagnostic_events (
payload_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (scope, event_key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_diagnostic_events_scope_created
ON diagnostic_events(scope, created_at, event_key);
@@ -30,7 +30,7 @@ CREATE TABLE IF NOT EXISTS skill_usage (
last_used_at_ms INTEGER NOT NULL,
use_count INTEGER NOT NULL,
last_agent_id TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_skill_usage_key
ON skill_usage(skill_key, skill_file);
@@ -44,7 +44,7 @@ CREATE TABLE IF NOT EXISTS skill_lifecycle (
state_changed_at_ms INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
archived_reason TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_skill_lifecycle_key
ON skill_lifecycle(skill_key, skill_file);
@@ -58,7 +58,7 @@ CREATE TABLE IF NOT EXISTS skill_curator_state (
last_success_at_ms INTEGER,
last_error TEXT,
last_result_json TEXT NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS audit_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -92,7 +92,7 @@ CREATE TABLE IF NOT EXISTS audit_events (
conversation_ref TEXT,
message_ref TEXT,
target_ref TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_audit_events_time
ON audit_events(occurred_at DESC, sequence DESC);
@@ -123,7 +123,7 @@ CREATE TABLE IF NOT EXISTS audit_identity_keys (
key_id TEXT NOT NULL,
key BLOB NOT NULL,
created_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS session_state_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -138,7 +138,7 @@ CREATE TABLE IF NOT EXISTS session_state_events (
occurred_at INTEGER NOT NULL,
summary TEXT NOT NULL,
payload_json TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_session_state_events_session_sequence
ON session_state_events(session_key, sequence DESC);
@@ -153,7 +153,7 @@ CREATE TABLE IF NOT EXISTS session_state_heads (
pruned_max_sequence INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (session_key, agent_id)
);
) STRICT;
-- Watcher identity is the bare session key, matching the process-local system-event
-- queue it feeds. Producers only create rows for agent-qualified watcher keys;
@@ -167,7 +167,7 @@ CREATE TABLE IF NOT EXISTS session_watch_cursors (
material_sequence INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (watcher_session_key, target_session_key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_session_watch_cursors_target
ON session_watch_cursors(target_session_key);
@@ -187,7 +187,7 @@ CREATE TABLE IF NOT EXISTS session_upstream_links (
-- (session_key, agent_id) composite identity: under session.scope="global" agents
-- share bare keys; a key-only row would let one agent overwrite another's upstream.
PRIMARY KEY (session_key, agent_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_session_upstream_links_catalog_id
ON session_upstream_links(catalog_id);
@@ -198,7 +198,7 @@ CREATE TABLE IF NOT EXISTS diagnostic_stability_bundles (
generated_at TEXT NOT NULL,
bundle_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_diagnostic_stability_bundles_created
ON diagnostic_stability_bundles(created_at DESC, bundle_key);
@@ -213,7 +213,7 @@ CREATE TABLE IF NOT EXISTS state_leases (
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (scope, lease_key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_state_leases_expiry
ON state_leases(expires_at, scope, lease_key)
@@ -234,7 +234,7 @@ CREATE TABLE IF NOT EXISTS exec_approvals_config (
agent_count INTEGER NOT NULL,
allowlist_count INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS operator_approvals (
approval_id TEXT NOT NULL PRIMARY KEY CHECK (
@@ -340,7 +340,7 @@ CREATE TABLE IF NOT EXISTS operator_approvals (
AND consumed_by IS NOT NULL
)
)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_operator_approvals_status_expiry
ON operator_approvals(status, expires_at_ms, approval_id);
@@ -367,7 +367,7 @@ CREATE TABLE IF NOT EXISTS schema_meta (
app_version TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS device_pairing_pending (
request_id TEXT NOT NULL PRIMARY KEY,
@@ -386,7 +386,7 @@ CREATE TABLE IF NOT EXISTS device_pairing_pending (
is_repair INTEGER,
ts INTEGER NOT NULL,
refreshed_at_ms INTEGER
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_pairing_pending_device
ON device_pairing_pending(device_id, ts DESC);
@@ -413,7 +413,7 @@ CREATE TABLE IF NOT EXISTS device_pairing_paired (
approved_at_ms INTEGER NOT NULL,
last_seen_at_ms INTEGER,
last_seen_reason TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_pairing_paired_approved
ON device_pairing_paired(approved_at_ms DESC, device_id);
@@ -429,7 +429,7 @@ CREATE TABLE IF NOT EXISTS device_bootstrap_tokens (
pending_profile_json TEXT,
issued_at_ms INTEGER NOT NULL,
last_used_at_ms INTEGER
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_bootstrap_tokens_ts
ON device_bootstrap_tokens(ts);
@@ -441,7 +441,7 @@ CREATE TABLE IF NOT EXISTS device_identities (
private_key_pem TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_identities_device
ON device_identities(device_id, updated_at_ms DESC);
@@ -453,7 +453,7 @@ CREATE TABLE IF NOT EXISTS device_auth_tokens (
scopes_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (device_id, role)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_device_auth_tokens_updated
ON device_auth_tokens(updated_at_ms DESC, device_id, role);
@@ -462,7 +462,7 @@ CREATE TABLE IF NOT EXISTS android_notification_recent_packages (
package_name TEXT NOT NULL PRIMARY KEY,
sort_order INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_android_notification_recent_packages_order
ON android_notification_recent_packages(sort_order, package_name);
@@ -473,7 +473,7 @@ CREATE TABLE IF NOT EXISTS macos_port_guardian_records (
command TEXT NOT NULL,
mode TEXT NOT NULL,
timestamp REAL NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_macos_port_guardian_records_port
ON macos_port_guardian_records(port, timestamp DESC);
@@ -485,7 +485,7 @@ CREATE TABLE IF NOT EXISTS workspace_setup_state (
bootstrap_seeded_at TEXT,
setup_completed_at TEXT,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_workspace_setup_state_path
ON workspace_setup_state(workspace_path);
@@ -498,7 +498,7 @@ CREATE TABLE IF NOT EXISTS native_hook_relay_bridges (
token TEXT NOT NULL,
expires_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_native_hook_relay_bridges_expires
ON native_hook_relay_bridges(expires_at_ms, relay_id);
@@ -519,7 +519,7 @@ CREATE TABLE IF NOT EXISTS model_capability_cache (
cost_cache_write REAL NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (provider_id, model_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_model_capability_cache_provider_updated
ON model_capability_cache(provider_id, updated_at_ms DESC, model_id);
@@ -529,7 +529,7 @@ CREATE TABLE IF NOT EXISTS agent_model_catalogs (
agent_dir TEXT NOT NULL,
raw_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_agent_model_catalogs_agent_dir
ON agent_model_catalogs(agent_dir, updated_at DESC);
@@ -553,7 +553,7 @@ CREATE TABLE IF NOT EXISTS managed_outgoing_image_records (
original_filename TEXT,
record_json TEXT NOT NULL,
cleanup_pending INTEGER NOT NULL DEFAULT 0 CHECK (cleanup_pending IN (0, 1))
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_managed_outgoing_images_session
ON managed_outgoing_image_records(session_key, created_at DESC, attachment_id);
@@ -578,7 +578,7 @@ CREATE TABLE IF NOT EXISTS channel_pairing_requests (
last_seen_at TEXT NOT NULL,
meta_json TEXT,
PRIMARY KEY (channel_key, account_id, request_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_channel_pairing_requests_code
ON channel_pairing_requests(channel_key, code);
@@ -593,7 +593,7 @@ CREATE TABLE IF NOT EXISTS channel_pairing_allow_entries (
sort_order INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (channel_key, account_id, entry)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_channel_pairing_allow_account
ON channel_pairing_allow_entries(channel_key, account_id, sort_order, entry);
@@ -606,7 +606,7 @@ CREATE TABLE IF NOT EXISTS web_push_subscriptions (
auth TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_web_push_subscriptions_updated
ON web_push_subscriptions(updated_at_ms DESC, subscription_id);
@@ -617,7 +617,7 @@ CREATE TABLE IF NOT EXISTS web_push_vapid_keys (
private_key TEXT NOT NULL,
subject TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS apns_registrations (
node_id TEXT NOT NULL PRIMARY KEY,
@@ -631,7 +631,7 @@ CREATE TABLE IF NOT EXISTS apns_registrations (
distribution TEXT,
token_debug_suffix TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_apns_registrations_updated
ON apns_registrations(updated_at_ms DESC, node_id);
@@ -648,7 +648,7 @@ CREATE TABLE IF NOT EXISTS node_host_config (
gateway_tls_fingerprint TEXT,
gateway_context_path TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS voicewake_triggers (
config_key TEXT NOT NULL,
@@ -656,7 +656,7 @@ CREATE TABLE IF NOT EXISTS voicewake_triggers (
trigger TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (config_key, position)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_voicewake_triggers_trigger
ON voicewake_triggers(config_key, trigger);
@@ -668,7 +668,7 @@ CREATE TABLE IF NOT EXISTS voicewake_routing_config (
default_target_agent_id TEXT,
default_target_session_key TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS voicewake_routing_routes (
config_key TEXT NOT NULL,
@@ -680,7 +680,7 @@ CREATE TABLE IF NOT EXISTS voicewake_routing_routes (
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (config_key, position),
FOREIGN KEY (config_key) REFERENCES voicewake_routing_config(config_key) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_voicewake_routing_routes_trigger
ON voicewake_routing_routes(config_key, trigger);
@@ -701,7 +701,7 @@ CREATE TABLE IF NOT EXISTS update_check_state (
auto_last_success_version TEXT,
auto_last_success_at TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS config_health_entries (
config_path TEXT NOT NULL PRIMARY KEY,
@@ -709,7 +709,7 @@ CREATE TABLE IF NOT EXISTS config_health_entries (
last_promoted_good_json TEXT,
last_observed_suspicious_signature TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS clawhub_promotions_feed_state (
state_key TEXT NOT NULL PRIMARY KEY,
@@ -719,7 +719,7 @@ CREATE TABLE IF NOT EXISTS clawhub_promotions_feed_state (
last_checked_at_ms INTEGER,
notified_slugs_json TEXT NOT NULL DEFAULT '[]',
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS clawhub_promotion_claims (
slug TEXT NOT NULL PRIMARY KEY,
@@ -727,7 +727,7 @@ CREATE TABLE IF NOT EXISTS clawhub_promotion_claims (
model_keys_json TEXT NOT NULL,
ends_at_ms INTEGER NOT NULL,
claimed_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS installed_plugin_index (
index_key TEXT NOT NULL PRIMARY KEY,
@@ -743,7 +743,7 @@ CREATE TABLE IF NOT EXISTS installed_plugin_index (
diagnostics_json TEXT NOT NULL,
warning TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_installed_plugin_index_generated
ON installed_plugin_index(generated_at_ms DESC, index_key);
@@ -762,7 +762,7 @@ CREATE TABLE IF NOT EXISTS official_external_plugin_catalog_snapshots (
trust_threshold INTEGER,
trust_verified_at TEXT,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_official_external_plugin_catalog_snapshots_updated
ON official_external_plugin_catalog_snapshots(updated_at_ms DESC, feed_url);
@@ -784,7 +784,7 @@ CREATE TABLE IF NOT EXISTS gateway_restart_sentinel (
stats_json TEXT,
payload_json TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_gateway_restart_sentinel_ts
ON gateway_restart_sentinel(ts DESC, sentinel_key);
@@ -798,7 +798,7 @@ CREATE TABLE IF NOT EXISTS gateway_restart_intent (
force INTEGER,
wait_ms INTEGER,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS gateway_restart_handoff (
handoff_key TEXT NOT NULL PRIMARY KEY,
@@ -816,7 +816,7 @@ CREATE TABLE IF NOT EXISTS gateway_restart_handoff (
restart_kind TEXT NOT NULL,
supervisor_mode TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_gateway_restart_handoff_expiry
ON gateway_restart_handoff(expires_at, pid);
@@ -829,7 +829,7 @@ CREATE TABLE IF NOT EXISTS gateway_boot_lifecycle (
outcome TEXT,
startup_reason TEXT,
reason TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_gateway_boot_lifecycle_started
ON gateway_boot_lifecycle(started_at_ms);
@@ -848,7 +848,7 @@ CREATE TABLE IF NOT EXISTS acp_sessions (
last_activity_at INTEGER NOT NULL,
last_error TEXT,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_acp_sessions_state_activity
ON acp_sessions(state, last_activity_at DESC, session_key);
@@ -868,7 +868,7 @@ CREATE TABLE IF NOT EXISTS acp_replay_sessions (
-- all event rows), maintained at insert/trim so budget checks never scan
-- acp_replay_events (#100622).
estimated_bytes INTEGER NOT NULL DEFAULT 0
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_acp_replay_sessions_key_updated
ON acp_replay_sessions(session_key, complete, updated_at DESC, session_id);
@@ -886,7 +886,7 @@ CREATE TABLE IF NOT EXISTS acp_replay_events (
estimated_bytes INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (session_id, seq),
FOREIGN KEY (session_id) REFERENCES acp_replay_sessions(session_id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_acp_replay_events_session_seq
ON acp_replay_events(session_id, seq);
@@ -898,7 +898,7 @@ CREATE TABLE IF NOT EXISTS agent_databases (
last_seen_at INTEGER NOT NULL,
size_bytes INTEGER,
PRIMARY KEY (agent_id, path)
);
) STRICT;
CREATE TABLE IF NOT EXISTS plugin_state_entries (
plugin_id TEXT NOT NULL,
@@ -908,7 +908,7 @@ CREATE TABLE IF NOT EXISTS plugin_state_entries (
created_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (plugin_id, namespace, entry_key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_plugin_state_expiry
ON plugin_state_entries(expires_at)
@@ -939,7 +939,7 @@ CREATE TABLE IF NOT EXISTS channel_ingress_events (
completed_at INTEGER,
completed_metadata_json TEXT,
PRIMARY KEY (queue_name, event_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_channel_ingress_pending
ON channel_ingress_events(queue_name, status, received_at, event_id);
@@ -959,7 +959,7 @@ CREATE TABLE IF NOT EXISTS plugin_blob_entries (
created_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (plugin_id, namespace, entry_key)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_plugin_blob_expiry
ON plugin_blob_entries(expires_at)
@@ -977,7 +977,7 @@ CREATE TABLE IF NOT EXISTS media_blobs (
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (subdir, id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_media_blobs_created
ON media_blobs(created_at);
@@ -997,7 +997,7 @@ CREATE TABLE IF NOT EXISTS skill_uploads (
committed INTEGER NOT NULL,
committed_at INTEGER,
idempotency_key_hash TEXT UNIQUE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_skill_uploads_expiry
ON skill_uploads(expires_at);
@@ -1014,7 +1014,7 @@ CREATE TABLE IF NOT EXISTS skill_upload_chunks (
PRIMARY KEY (upload_id, byte_offset),
FOREIGN KEY (upload_id) REFERENCES skill_uploads(upload_id) ON DELETE CASCADE,
CHECK (length(chunk_blob) = size_bytes)
);
) STRICT;
CREATE TABLE IF NOT EXISTS capture_sessions (
id TEXT NOT NULL PRIMARY KEY,
@@ -1024,7 +1024,7 @@ CREATE TABLE IF NOT EXISTS capture_sessions (
source_scope TEXT NOT NULL,
source_process TEXT NOT NULL,
proxy_url TEXT
);
) STRICT;
CREATE TABLE IF NOT EXISTS capture_blobs (
blob_id TEXT NOT NULL PRIMARY KEY,
@@ -1034,7 +1034,7 @@ CREATE TABLE IF NOT EXISTS capture_blobs (
sha256 TEXT NOT NULL,
data BLOB NOT NULL,
created_at INTEGER NOT NULL
);
) STRICT;
CREATE TABLE IF NOT EXISTS capture_events (
id INTEGER NOT NULL PRIMARY KEY,
@@ -1060,7 +1060,7 @@ CREATE TABLE IF NOT EXISTS capture_events (
meta_json TEXT,
FOREIGN KEY (session_id) REFERENCES capture_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (data_blob_id) REFERENCES capture_blobs(blob_id) ON DELETE SET NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS capture_events_session_ts_idx
ON capture_events(session_id, ts);
@@ -1084,7 +1084,7 @@ CREATE TABLE IF NOT EXISTS sandbox_registry_entries (
entry_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (registry_kind, container_name)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_sandbox_registry_updated
ON sandbox_registry_entries(registry_kind, updated_at DESC, container_name);
@@ -1128,7 +1128,7 @@ CREATE TABLE IF NOT EXISTS commitments (
snoozed_until_ms INTEGER,
expired_at_ms INTEGER,
record_json TEXT NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_commitments_scope_due
ON commitments(agent_id, session_key, status, due_earliest_ms, due_latest_ms);
@@ -1222,7 +1222,7 @@ CREATE TABLE IF NOT EXISTS cron_jobs (
sort_order INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (store_key, job_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_cron_jobs_store_updated
ON cron_jobs(store_key, sort_order ASC, updated_at DESC, job_id);
@@ -1246,7 +1246,7 @@ CREATE TABLE IF NOT EXISTS command_log_entries (
sender_id TEXT NOT NULL,
source TEXT NOT NULL,
entry_json TEXT NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_command_log_entries_timestamp
ON command_log_entries(timestamp_ms DESC, id);
@@ -1273,7 +1273,7 @@ CREATE TABLE IF NOT EXISTS delivery_queue_entries (
updated_at INTEGER NOT NULL,
failed_at INTEGER,
PRIMARY KEY (queue_name, id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_delivery_queue_pending
ON delivery_queue_entries(queue_name, status, enqueued_at, id);
@@ -1320,7 +1320,7 @@ CREATE TABLE IF NOT EXISTS task_runs (
terminal_summary TEXT,
terminal_outcome TEXT,
detail_json TEXT
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_task_runs_run_id ON task_runs(run_id);
CREATE INDEX IF NOT EXISTS idx_task_runs_status ON task_runs(status);
@@ -1381,7 +1381,7 @@ CREATE TABLE IF NOT EXISTS subagent_runs (
pending_final_delivery_payload_json TEXT,
completion_announced_at INTEGER,
payload_json TEXT NOT NULL DEFAULT '{}'
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_subagent_runs_child_session_key
ON subagent_runs(child_session_key, created_at DESC, run_id);
@@ -1412,7 +1412,7 @@ CREATE TABLE IF NOT EXISTS current_conversation_bindings (
metadata_json TEXT,
record_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_current_conversation_bindings_target
ON current_conversation_bindings(target_agent_id, target_session_key, updated_at DESC, binding_key);
@@ -1429,7 +1429,7 @@ CREATE TABLE IF NOT EXISTS plugin_binding_approvals (
plugin_name TEXT,
approved_at INTEGER NOT NULL,
PRIMARY KEY (plugin_root, channel, account_id)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_plugin_binding_approvals_plugin
ON plugin_binding_approvals(plugin_id, approved_at DESC);
@@ -1438,7 +1438,7 @@ CREATE TABLE IF NOT EXISTS tui_last_sessions (
scope_key TEXT NOT NULL PRIMARY KEY,
session_key TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_tui_last_sessions_session_key
ON tui_last_sessions(session_key, updated_at DESC, scope_key);
@@ -1448,7 +1448,7 @@ CREATE TABLE IF NOT EXISTS task_delivery_state (
requester_origin_json TEXT,
last_notified_event_at INTEGER,
FOREIGN KEY (task_id) REFERENCES task_runs(task_id) ON DELETE CASCADE
);
) STRICT;
CREATE TABLE IF NOT EXISTS flow_runs (
flow_id TEXT NOT NULL PRIMARY KEY,
@@ -1470,7 +1470,7 @@ CREATE TABLE IF NOT EXISTS flow_runs (
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ended_at INTEGER
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_flow_runs_status ON flow_runs(status);
CREATE INDEX IF NOT EXISTS idx_flow_runs_owner_key ON flow_runs(owner_key);
@@ -1482,7 +1482,7 @@ CREATE TABLE IF NOT EXISTS migration_runs (
finished_at INTEGER,
status TEXT NOT NULL,
report_json TEXT NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_migration_runs_started
ON migration_runs(started_at DESC, id);
@@ -1501,7 +1501,7 @@ CREATE TABLE IF NOT EXISTS migration_sources (
removed_source INTEGER NOT NULL DEFAULT 0,
report_json TEXT NOT NULL,
FOREIGN KEY (last_run_id) REFERENCES migration_runs(id) ON DELETE CASCADE
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_migration_sources_path
ON migration_sources(source_path, migration_kind, target_table);
@@ -1515,7 +1515,7 @@ CREATE TABLE IF NOT EXISTS backup_runs (
archive_path TEXT NOT NULL,
status TEXT NOT NULL,
manifest_json TEXT NOT NULL
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_backup_runs_created
ON backup_runs(created_at DESC, id);
@@ -1533,7 +1533,7 @@ CREATE TABLE IF NOT EXISTS worktrees (
created_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL,
removed_at INTEGER
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_worktrees_repo_fingerprint
ON worktrees(repo_fingerprint);
@@ -1548,7 +1548,7 @@ CREATE TABLE IF NOT EXISTS session_groups (
name TEXT NOT NULL PRIMARY KEY,
position INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
) STRICT;
-- Gateway-owned durable cloud worker lifecycle. Provider-specific execution
-- stays in plugins; this table records only core reconciliation facts.
@@ -1591,7 +1591,7 @@ CREATE TABLE IF NOT EXISTS worker_environments (
idle_since_at_ms INTEGER,
destroy_requested_at_ms INTEGER,
last_error TEXT
);
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_environments_provider_lease
ON worker_environments(provider_id, lease_id)
@@ -1705,7 +1705,7 @@ CREATE TABLE IF NOT EXISTS worker_session_placements (
(turn_claim_owner IS 'worker' AND state IN ('active', 'draining')
AND turn_claim_owner_epoch IS active_owner_epoch)
)
);
) STRICT;
CREATE INDEX IF NOT EXISTS idx_worker_session_placements_session_key
ON worker_session_placements(agent_id, session_key);
@@ -1726,7 +1726,7 @@ CREATE TABLE IF NOT EXISTS worker_workspace_reconciliations (
base_pack BLOB NOT NULL CHECK (length(base_pack) <= 268435456),
created_at_ms INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES worker_session_placements(session_id) ON DELETE CASCADE
);
) STRICT;
-- A completed remote turn is fenced from stale-claim teardown until its
-- workspace result is durably reconciled into the managed worktree.
@@ -1742,7 +1742,7 @@ CREATE TABLE IF NOT EXISTS worker_workspace_pending_results (
workspace_accepted_at_ms INTEGER,
created_at_ms INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES worker_session_placements(session_id) ON DELETE CASCADE
);
) STRICT;
-- One active, opaque admission credential per worker environment. Plaintext
-- may be retried until delivery acknowledgement but never enters durable state.
@@ -1756,7 +1756,7 @@ CREATE TABLE IF NOT EXISTS worker_environment_credentials (
expires_at_ms INTEGER NOT NULL CHECK (expires_at_ms >= 0),
delivered_at_ms INTEGER CHECK (delivered_at_ms >= 0),
FOREIGN KEY (environment_id) REFERENCES worker_environments(environment_id) ON DELETE CASCADE
);
) STRICT;
-- One durable sequence cursor per attached session owner epoch. The environment
-- binding prevents independent workers with coincident epochs from sharing replay state.
@@ -1767,7 +1767,7 @@ CREATE TABLE IF NOT EXISTS worker_transcript_commit_heads (
next_seq INTEGER NOT NULL CHECK (next_seq >= 1),
updated_at_ms INTEGER NOT NULL CHECK (updated_at_ms >= 0),
PRIMARY KEY (session_id, run_epoch)
);
) STRICT;
-- Pending rows preserve a claimed request across gateway restarts. Terminal rows
-- cache the exact result returned for deterministic at-least-once replay.
@@ -1788,7 +1788,7 @@ CREATE TABLE IF NOT EXISTS worker_transcript_commits (
(state = 'pending' AND result_json IS NULL) OR
(state = 'terminal' AND result_json IS NOT NULL)
)
);
) STRICT;
-- Pending rows preserve a claimed inference turn across gateway restarts.
-- Terminal rows cache the exact outcome returned for deterministic replay.
@@ -1809,7 +1809,7 @@ CREATE TABLE IF NOT EXISTS worker_inference_turns (
(state = 'pending' AND terminal_json IS NULL) OR
(state = 'terminal' AND terminal_json IS NOT NULL)
)
);
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_inference_turns_pending_run
ON worker_inference_turns(session_id, run_epoch, run_id)
@@ -1823,4 +1823,4 @@ CREATE TABLE IF NOT EXISTS fleet_cells (
host_port INTEGER NOT NULL,
container_name TEXT NOT NULL,
data_dir TEXT NOT NULL
);
) STRICT;
@@ -38,6 +38,7 @@ type SqliteSchemaShape = Record<
{
columns: ColumnShape[];
indexes: IndexShape[];
strict: number;
}
>;
@@ -100,11 +101,22 @@ export function collectSqliteSchemaShape(db: DatabaseSync): SqliteSchemaShape {
{
columns: collectColumns(db, table.name),
indexes: collectIndexes(db, table.name),
strict: collectStrictFlag(db, table.name),
},
]),
);
}
function collectStrictFlag(db: DatabaseSync, tableName: string): number {
const row = db
.prepare("SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = ?")
.get(tableName) as { strict?: unknown } | undefined;
if (typeof row?.strict !== "number") {
throw new Error(`SQLite table ${tableName} has no table_list entry`);
}
return row.strict;
}
function collectColumns(db: DatabaseSync, tableName: string): ColumnShape[] {
return (
db.prepare(`PRAGMA table_info(${quoteSqliteIdentifier(tableName)})`).all() as TableInfoRow[]