fix(sqlite): reject schema data loss during upgrades (#113473)

* fix(sqlite): reject schema data loss during upgrades

* test(macos): align native state schema boundary
This commit is contained in:
Vincent Koc
2026-07-25 12:22:02 +08:00
committed by GitHub
parent c780bfbf0d
commit 509a5f0373
24 changed files with 629 additions and 127 deletions
@@ -532,7 +532,7 @@ struct PortGuardianRecordStoreTests {
let fixture = try Self.fixture()
defer { fixture.cleanup() }
for version in [4, 5] {
for version in [4, 5, 6] {
let databaseURL = fixture.root.appendingPathComponent("supported-v\(version).sqlite")
try Self.seedVersionedPortGuardianDatabase(databaseURL, schemaVersion: version)
let store = try PortGuardianRecordStore(databaseURL: databaseURL)
@@ -544,7 +544,7 @@ struct PortGuardianRecordStoreTests {
#expect(try store.records() == [record])
}
for version in [6, 99] {
for version in [7, 99] {
let databaseURL = fixture.root.appendingPathComponent("newer-v\(version).sqlite")
try Self.seedVersionedPortGuardianDatabase(databaseURL, schemaVersion: version)
#expect(throws: PortGuardianStoreError.self) {
@@ -592,7 +592,7 @@ struct PortGuardianRecordStoreTests {
try store.upsert(existing)
try JSONEncoder().encode([existing]).write(to: fixture.legacyURL, options: [.atomic])
try Self.execute(fixture.databaseURL, "PRAGMA user_version = 6")
try Self.execute(fixture.databaseURL, "PRAGMA user_version = 7")
#expect(throws: PortGuardianStoreError.self) {
try store.upsert(Self.record(pid: 4243, port: 18790, timestamp: 43))
@@ -35,7 +35,7 @@ public enum OpenClawNativeStateSQLiteValueType: Equatable, Sendable {
/// One recursive connection lock serializes transactions and statement access.
public final class OpenClawNativeStateSQLite: @unchecked Sendable {
// Keep aligned with OPENCLAW_STATE_SCHEMA_VERSION. Native clients never upgrade this database.
private static let maximumSupportedSchemaVersion: Int64 = 5
private static let maximumSupportedSchemaVersion: Int64 = 6
private static let defaultBusyTimeoutMilliseconds: Int32 = 5000
private struct SchemaObject: Hashable {
+2 -2
View File
@@ -3,8 +3,8 @@
"version": "2026.7.2",
"openclaw": {
"schemaVersions": {
"state": 5,
"agent": 14
"state": 6,
"agent": 15
}
},
"description": "Multi-channel AI gateway with extensible messaging integrations",
+5 -1
View File
@@ -130,7 +130,11 @@ describe("managed worktree registry", () => {
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const legacy = new DatabaseSync(databasePath);
legacy.exec("ALTER TABLE worktrees DROP COLUMN provisioned_paths_json");
legacy.exec(`
ALTER TABLE worktrees DROP COLUMN provisioned_paths_json;
PRAGMA user_version = 5;
UPDATE schema_meta SET schema_version = 5 WHERE meta_key = 'primary';
`);
legacy.close();
expect(getRegistryWorktreeProvisionedPaths(env, "missing")).toBeUndefined();
+5 -4
View File
@@ -510,7 +510,7 @@ describe("SqliteBoardStore persistence", () => {
expect(store.readWidgetMcpApp(sessionKey, "legacy-app")).toBeUndefined();
});
it("lazily creates board tables for an existing v14 database", () => {
it("migrates board tables into an existing v14 database", () => {
const stateDir = tempDirs.make("openclaw-board-lazy-schema-");
const env = { OPENCLAW_STATE_DIR: stateDir };
const sessionKey = "agent:main:board";
@@ -535,13 +535,12 @@ describe("SqliteBoardStore persistence", () => {
reopened.db
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'board_tabs'")
.get(),
).toBeUndefined();
).toEqual({ name: "board_tabs" });
const store = new SqliteBoardStore({
resolveSession: () => ({ agentId: "main", sessionKey }),
env,
});
// Reads before any write must see "no boards", not "no such table".
expect(store.getSnapshot(sessionKey)).toMatchObject({ revision: 0, tabs: [], widgets: [] });
expect(store.readWidgetHtml(sessionKey, "status")).toBeUndefined();
expect(store.listSessionsWithBoards()).toEqual([]);
@@ -579,7 +578,7 @@ describe("SqliteBoardStore persistence", () => {
).toEqual({ name: "idx_agent_board_widgets_tab_position" });
});
it("upgrades the unreleased v13 board constraint before storing plugin widgets", () => {
it("upgrades the v14 board constraint before storing plugin widgets", () => {
const stateDir = tempDirs.make("openclaw-board-plugin-kind-schema-");
const env = { OPENCLAW_STATE_DIR: stateDir };
const sessionKey = "agent:main:board";
@@ -622,6 +621,8 @@ describe("SqliteBoardStore persistence", () => {
ON board_widgets(session_key, tab_id, position);
COMMIT;
PRAGMA foreign_keys = ON;
PRAGMA user_version = 14;
UPDATE schema_meta SET schema_version = 14 WHERE meta_key = 'primary';
`);
closeOpenClawAgentDatabasesForTest();
@@ -20,7 +20,7 @@ import {
afterEach(() => closeOpenClawAgentDatabasesForTest());
describe("session sharing store", () => {
it("lazily ensures the additive membership table and keeps deterministic rows", async () => {
it("keeps deterministic membership rows", async () => {
await withTempDir({ prefix: "openclaw-session-sharing-" }, async (dir) => {
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
const scope = { agentId: "main", env, sessionKey: "agent:main:main" };
@@ -30,13 +30,6 @@ describe("session sharing store", () => {
visibility: "shared",
});
expect(loadSessionEntry(scope)?.visibility).toBe("shared");
const database = openOpenClawAgentDatabase({ agentId: "main", env });
database.db.exec("DROP TABLE session_members;");
expect(
database.db
.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'session_members'")
.get(),
).toBeUndefined();
expect(listSessionMembers(scope)).toEqual([]);
expect(
@@ -67,6 +60,23 @@ describe("session sharing store", () => {
});
});
it("does not recreate a missing canonical membership table", async () => {
await withTempDir({ prefix: "openclaw-session-sharing-missing-" }, async (dir) => {
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
const scope = { agentId: "main", env, sessionKey: "agent:main:main" };
await upsertSessionEntry(scope, { sessionId: "session-main", updatedAt: 1 });
const database = openOpenClawAgentDatabase({ agentId: "main", env });
database.db.exec("DROP TABLE session_members;");
expect(() => listSessionMembers(scope)).toThrow(/no such table: session_members/);
expect(
database.db
.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'session_members'")
.get(),
).toBeUndefined();
});
});
it("refuses member writes whose expected session instance no longer matches", async () => {
await withTempDir({ prefix: "openclaw-session-sharing-instance-" }, async (dir) => {
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
+3 -20
View File
@@ -1,4 +1,3 @@
import type { DatabaseSync } from "node:sqlite";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
@@ -11,7 +10,6 @@ import {
type OpenClawAgentDatabase,
type OpenClawAgentDatabaseOptions,
} from "../../state/openclaw-agent-db.js";
import { ensureOpenClawAgentSessionSharingSchemaInTransaction } from "../../state/openclaw-agent-session-sharing-schema.js";
import type { SessionAccessScope } from "./session-accessor.sqlite-contract.js";
import { resolveSqliteScope, toDatabaseOptions } from "./session-accessor.sqlite-scope.js";
@@ -23,31 +21,18 @@ type SessionMember = {
addedAt: number;
};
const ensuredDatabases = new WeakSet<DatabaseSync>();
const SESSION_MEMBERSHIP_QUERY_CHUNK_SIZE = 400;
function resolveDatabaseOptions(scope: SessionAccessScope): OpenClawAgentDatabaseOptions {
return toDatabaseOptions(resolveSqliteScope(scope));
}
function ensureSessionSharingSchema(options: OpenClawAgentDatabaseOptions): OpenClawAgentDatabase {
const database = openOpenClawAgentDatabase(options);
if (ensuredDatabases.has(database.db)) {
return database;
}
runOpenClawAgentWriteTransaction((transactionDatabase) => {
ensureOpenClawAgentSessionSharingSchemaInTransaction(transactionDatabase.db);
}, options);
ensuredDatabases.add(database.db);
return database;
}
function getSessionMemberKysely(database: OpenClawAgentDatabase) {
return getNodeSqliteKysely<SessionMemberDatabase>(database.db);
}
export function listSessionMembers(scope: SessionAccessScope): SessionMember[] {
const database = ensureSessionSharingSchema(resolveDatabaseOptions(scope));
const database = openOpenClawAgentDatabase(resolveDatabaseOptions(scope));
const db = getSessionMemberKysely(database);
return executeSqliteQuerySync(
database.db,
@@ -73,7 +58,7 @@ export function listSessionMembershipKeys(
if (!normalizedIdentityId || normalizedSessionKeys.length === 0) {
return new Set();
}
const database = ensureSessionSharingSchema(resolveDatabaseOptions(scope));
const database = openOpenClawAgentDatabase(resolveDatabaseOptions(scope));
const db = getSessionMemberKysely(database);
const memberships = new Set<string>();
for (
@@ -102,7 +87,7 @@ export function isSessionMember(scope: SessionAccessScope, identityId: string):
if (!normalizedIdentityId) {
return false;
}
const database = ensureSessionSharingSchema(resolveDatabaseOptions(scope));
const database = openOpenClawAgentDatabase(resolveDatabaseOptions(scope));
const db = getSessionMemberKysely(database);
return Boolean(
executeSqliteQueryTakeFirstSync(
@@ -160,7 +145,6 @@ export function addSessionMember(
throw new Error("session member identity and actor are required");
}
const options = resolveDatabaseOptions(scope);
ensureSessionSharingSchema(options);
const addedAt = params.addedAt ?? Date.now();
const inserted = runOpenClawAgentWriteTransaction((database) => {
assertAuthorizedSessionInstance(
@@ -197,7 +181,6 @@ export function removeSessionMember(
return null;
}
const options = resolveDatabaseOptions(scope);
ensureSessionSharingSchema(options);
return runOpenClawAgentWriteTransaction((database) => {
assertAuthorizedSessionInstance(
database,
@@ -40,13 +40,11 @@ function resolvePendingSuggestion(params: {
afterEach(() => closeOpenClawAgentDatabasesForTest());
describe("session suggestion store", () => {
it("lazily ensures deterministic rows and resolves only pending suggestions", async () => {
it("keeps deterministic rows and resolves only pending suggestions", async () => {
await withTempDir({ prefix: "openclaw-session-suggestions-" }, async (dir) => {
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
const scope = { agentId: "main", env, sessionKey: "agent:main:main" };
await upsertSessionEntry(scope, { sessionId: "session-a", updatedAt: 1 });
const database = openOpenClawAgentDatabase({ agentId: "main", env });
database.db.exec("DROP TABLE session_suggestions;");
expect(listSessionSuggestions(scope)).toEqual([]);
addSessionSuggestion(scope, {
@@ -91,6 +89,25 @@ describe("session suggestion store", () => {
});
});
it("does not recreate a missing canonical suggestions table", async () => {
await withTempDir({ prefix: "openclaw-session-suggestions-missing-" }, async (dir) => {
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
const scope = { agentId: "main", env, sessionKey: "agent:main:main" };
await upsertSessionEntry(scope, { sessionId: "session-a", updatedAt: 1 });
const database = openOpenClawAgentDatabase({ agentId: "main", env });
database.db.exec("DROP TABLE session_suggestions;");
expect(() => listSessionSuggestions(scope)).toThrow(/no such table: session_suggestions/);
expect(
database.db
.prepare(
"SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'session_suggestions'",
)
.get(),
).toBeUndefined();
});
});
it("binds writes to the session instance and clears rows on replacement", async () => {
await withTempDir({ prefix: "openclaw-session-suggestions-reset-" }, async (dir) => {
const env = { ...process.env, OPENCLAW_STATE_DIR: dir };
@@ -1,5 +1,4 @@
import { randomUUID } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
@@ -12,7 +11,6 @@ import {
type OpenClawAgentDatabase,
type OpenClawAgentDatabaseOptions,
} from "../../state/openclaw-agent-db.js";
import { ensureOpenClawAgentSessionSharingSchemaInTransaction } from "../../state/openclaw-agent-session-sharing-schema.js";
import { SessionWorkStartInvalidatedError } from "./lifecycle.js";
import type { SessionAccessScope } from "./session-accessor.sqlite-contract.js";
import { resolveSqliteScope, toDatabaseOptions } from "./session-accessor.sqlite-scope.js";
@@ -31,7 +29,6 @@ export type StoredSessionSuggestion = {
state: StoredSessionSuggestionState;
};
const ensuredDatabases = new WeakSet<DatabaseSync>();
const MAX_PENDING_SESSION_SUGGESTIONS_PER_AUTHOR = 20;
const MAX_PENDING_SESSION_SUGGESTIONS_PER_SESSION = 100;
const MAX_RETAINED_RESOLVED_SESSION_SUGGESTIONS = 200;
@@ -41,18 +38,6 @@ function resolveDatabaseOptions(scope: SessionAccessScope): OpenClawAgentDatabas
return toDatabaseOptions(resolveSqliteScope(scope));
}
function ensureSuggestionSchema(options: OpenClawAgentDatabaseOptions): OpenClawAgentDatabase {
const database = openOpenClawAgentDatabase(options);
if (ensuredDatabases.has(database.db)) {
return database;
}
runOpenClawAgentWriteTransaction((transactionDatabase) => {
ensureOpenClawAgentSessionSharingSchemaInTransaction(transactionDatabase.db);
}, options);
ensuredDatabases.add(database.db);
return database;
}
function suggestionDb(database: OpenClawAgentDatabase) {
return getNodeSqliteKysely<SuggestionDatabase>(database.db);
}
@@ -154,7 +139,6 @@ export function addSessionSuggestion(
throw new Error("suggestion author and text are required");
}
const options = resolveDatabaseOptions(scope);
ensureSuggestionSchema(options);
const sessionKey = resolveSqliteScope(scope).sessionKey;
const suggestion: StoredSessionSuggestion = {
id: params.id ?? randomUUID(),
@@ -209,7 +193,7 @@ export function listSessionSuggestions(
params: { authorId?: string; pendingOnly?: boolean } = {},
): StoredSessionSuggestion[] {
const options = resolveDatabaseOptions(scope);
const database = ensureSuggestionSchema(options);
const database = openOpenClawAgentDatabase(options);
const sessionKey = resolveSqliteScope(scope).sessionKey;
let query = suggestionDb(database)
.selectFrom("session_suggestions")
@@ -243,7 +227,6 @@ export function claimSessionSuggestionDispatch(
},
): SessionSuggestionDispatchClaim | null {
const options = resolveDatabaseOptions(scope);
ensureSuggestionSchema(options);
const sessionKey = resolveSqliteScope(scope).sessionKey;
return runOpenClawAgentWriteTransaction((database) => {
assertSessionInstance(database, sessionKey, params.expectedSessionId);
@@ -308,7 +291,6 @@ export function releaseSessionSuggestionDispatch(
params: { id: string; token: string; expectedSessionId?: string },
): boolean {
const options = resolveDatabaseOptions(scope);
ensureSuggestionSchema(options);
const sessionKey = resolveSqliteScope(scope).sessionKey;
return runOpenClawAgentWriteTransaction((database) => {
assertSessionInstance(database, sessionKey, params.expectedSessionId);
@@ -336,7 +318,6 @@ export function finalizeSessionSuggestionClaim(
},
): StoredSessionSuggestion | null {
const options = resolveDatabaseOptions(scope);
ensureSuggestionSchema(options);
const sessionKey = resolveSqliteScope(scope).sessionKey;
return runOpenClawAgentWriteTransaction((database) => {
assertSessionInstance(database, sessionKey, params.expectedSessionId);
+33 -7
View File
@@ -179,16 +179,42 @@ export function assertSqliteSchemaContains(
}
if (mismatches.length > 0) {
const shown = mismatches.slice(0, 8);
if (mismatches.length > shown.length) {
shown.push(`${mismatches.length - shown.length} additional mismatch(es)`);
}
throw new Error(
`SQLite schema is incomplete or noncanonical for ${databaseLabel}: ${shown.join("; ")}`,
);
throwSqliteSchemaMismatches(databaseLabel, mismatches);
}
}
/** Require stable canonical tables before a version-specific additive migration. */
export function assertSqliteSchemaTablesPresent(
database: DatabaseSync,
databaseLabel: string,
schemaSql: string,
options: { allowedMissingTables?: readonly string[] } = {},
): void {
const allowedMissingTables = new Set(options.allowedMissingTables ?? []);
const missingTables = getCanonicalSqliteTableNames(schemaSql)
.filter((tableName) => !allowedMissingTables.has(tableName))
.filter(
(tableName) =>
!database
.prepare("SELECT 1 FROM main.sqlite_schema WHERE type = 'table' AND name = ? LIMIT 1")
.get(tableName),
)
.map((tableName) => `missing table ${tableName}`);
if (missingTables.length > 0) {
throwSqliteSchemaMismatches(databaseLabel, missingTables);
}
}
function throwSqliteSchemaMismatches(databaseLabel: string, mismatches: string[]): never {
const shown = mismatches.slice(0, 8);
if (mismatches.length > shown.length) {
shown.push(`${mismatches.length - shown.length} additional mismatch(es)`);
}
throw new Error(
`SQLite schema is incomplete or noncanonical for ${databaseLabel}: ${shown.join("; ")}`,
);
}
/** Return every explicit named index owned by one committed schema. */
export function getCanonicalSqliteNamedIndexContracts(
schemaSql: string,
+5 -1
View File
@@ -210,7 +210,11 @@ describe("node-host SQLite config", () => {
it("adds the gateway context-path column to an existing state database", async () => {
const { env } = makeTestEnv();
const database = openOpenClawStateDatabase({ env });
database.db.exec("ALTER TABLE node_host_config DROP COLUMN gateway_context_path");
database.db.exec(`
ALTER TABLE node_host_config DROP COLUMN gateway_context_path;
PRAGMA user_version = 5;
UPDATE schema_meta SET schema_version = 5 WHERE meta_key = 'primary';
`);
closeOpenClawStateDatabaseForTest();
const configured = await configureNodeHost({
+2 -4
View File
@@ -26,6 +26,7 @@ function splitBoardSchema(sql: string): { board: string; withoutBoard: string }
const boardSchema = splitBoardSchema(OPENCLAW_AGENT_SCHEMA_SQL);
const OPENCLAW_AGENT_BOARD_SCHEMA_SQL = boardSchema.board;
export const AGENT_V14_BOARD_SCHEMA_SQL = OPENCLAW_AGENT_BOARD_SCHEMA_SQL;
export const OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL = boardSchema.withoutBoard;
function canonicalBoardWidgetsCreateSql(): string {
@@ -59,10 +60,7 @@ function normalizeBoardWidgetsCreateSql(sql: string): string {
.trim();
}
/**
* Repairs the unreleased v13 board table shape without advancing the agent DB version.
* Delete this same-version bridge when the lazy board schema folds into the next natural bump.
*/
/** Repair the v14 board constraint inside the caller's schema/write transaction. */
export function ensureOpenClawAgentBoardSchemaInTransaction(db: DatabaseSync): void {
if (!db.isTransaction) {
throw new Error("board schema ensure requires an active transaction");
+2 -1
View File
@@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite";
import type { SqliteWalMaintenance } from "../infra/sqlite-wal.js";
import type { OpenClawStateDatabaseOptions } from "./openclaw-state-db.js";
// v15 makes board and session-sharing tables part of the canonical agent schema.
// v14 = logical session nodes, generation windows, and node-owned artifact FKs.
// v13 = one durable rewrite watermark per raw session transcript.
// v12 = session-owned ACP parent-stream events.
@@ -15,7 +16,7 @@ import type { OpenClawStateDatabaseOptions } from "./openclaw-state-db.js";
// The v4 session/transcript flip and main's v2 memory-identity
// change is folded in structure-gated migrations, so v2 main DBs and
// pre-merge v4 flip DBs both converge on this schema.
export const OPENCLAW_AGENT_SCHEMA_VERSION = 14;
export const OPENCLAW_AGENT_SCHEMA_VERSION = 15;
/** Open per-agent SQLite database handle plus lifecycle maintenance. */
export type OpenClawAgentDatabase = {
+2 -26
View File
@@ -1,15 +1,7 @@
import type { DatabaseSync } from "node:sqlite";
import {
MEMORY_INDEX_SOURCES_TABLE,
MEMORY_PATH_FTS_TRIGGER_DEFINITIONS,
} from "../../packages/memory-host-sdk/src/host/memory-schema.js";
import { clearNodeSqliteKyselyCacheForDatabase } from "../infra/kysely-sync.js";
import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
import { repairCanonicalSqliteIndexes } from "../infra/sqlite-index-schema.js";
import {
assertSqliteSchemaContains,
type SqliteSchemaCompatibility,
} from "../infra/sqlite-schema-contract.js";
import {
createNewerSqliteSchemaVersionError,
readSqliteUserVersion,
@@ -18,6 +10,7 @@ import { normalizeAgentId } from "../routing/session-key.js";
import { OPENCLAW_AGENT_SCHEMA_VERSION } from "./openclaw-agent-db-contract.js";
import {
assertExistingAgentSchemaOwner,
assertOpenClawAgentSchemaContains,
assertSupportedAgentSchemaVersion,
readExistingAgentSchemaMeta,
} from "./openclaw-agent-db-schema-helpers.js";
@@ -25,18 +18,6 @@ import { ensureOpenClawAgentDatabaseSchema } from "./openclaw-agent-db-schema.js
import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.generated.js";
import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db.js";
const OPENCLAW_AGENT_MAINTENANCE_SCHEMA_COMPATIBILITY = {
allowedColumnDefinitions: {
"conversations.delivery_target": ["delivery_target TEXT NOT NULL DEFAULT ''"],
},
optionalCanonicalTriggerGroups: [
{
tableName: MEMORY_INDEX_SOURCES_TABLE,
triggers: MEMORY_PATH_FTS_TRIGGER_DEFINITIONS,
},
],
} satisfies SqliteSchemaCompatibility;
/** Require exact agent ownership without requiring the latest schema. */
export function assertOpenClawAgentDatabaseOwner(
database: DatabaseSync,
@@ -84,12 +65,7 @@ export function assertOpenClawAgentDatabaseForMaintenance(
`OpenClaw agent database ${options.pathname} metadata schema version ${metadata.schemaVersion ?? "invalid"} does not match ${OPENCLAW_AGENT_SCHEMA_VERSION}; run openclaw doctor --fix before compacting it.`,
);
}
assertSqliteSchemaContains(
database,
options.pathname,
OPENCLAW_AGENT_SCHEMA_SQL,
OPENCLAW_AGENT_MAINTENANCE_SCHEMA_COMPATIBILITY,
);
assertOpenClawAgentSchemaContains(database, options.pathname, OPENCLAW_AGENT_SCHEMA_SQL);
}
/** Upgrade or repair a supported owned schema before strict offline maintenance. */
@@ -1,10 +1,31 @@
import type { DatabaseSync } from "node:sqlite";
import {
MEMORY_INDEX_SOURCES_TABLE,
MEMORY_PATH_FTS_TRIGGER_DEFINITIONS,
} from "../../packages/memory-host-sdk/src/host/memory-schema.js";
import { repairCanonicalSqliteIndexes } from "../infra/sqlite-index-schema.js";
import {
assertSqliteSchemaContains,
assertSqliteSchemaTablesPresent,
getCanonicalSqliteTableNames,
type SqliteSchemaCompatibility,
} from "../infra/sqlite-schema-contract.js";
import {
createNewerSqliteSchemaVersionError,
readSqliteUserVersion,
} from "../infra/sqlite-user-version.js";
import { normalizeAgentId } from "../routing/session-key.js";
import {
AGENT_V14_BOARD_SCHEMA_SQL,
ensureOpenClawAgentBoardSchemaInTransaction,
} from "./openclaw-agent-board-schema.js";
import { OPENCLAW_AGENT_SCHEMA_VERSION } from "./openclaw-agent-db-contract.js";
import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.generated.js";
import {
AGENT_V14_ADDITIVE_SCHEMA_SQL,
AGENT_V14_CORE_SCHEMA_SQL,
AGENT_V14_SESSION_SHARING_SCHEMA_SQL,
} from "./openclaw-agent-session-sharing-schema.js";
type ExistingAgentSchemaMeta = {
agentId: string | null;
@@ -12,6 +33,115 @@ type ExistingAgentSchemaMeta = {
schemaVersion: number | null;
};
const OPENCLAW_AGENT_SCHEMA_COMPATIBILITY = {
allowedColumnDefinitions: {
"conversations.delivery_target": ["delivery_target TEXT NOT NULL DEFAULT ''"],
},
optionalCanonicalTriggerGroups: [
{
tableName: MEMORY_INDEX_SOURCES_TABLE,
triggers: MEMORY_PATH_FTS_TRIGGER_DEFINITIONS,
},
],
} satisfies SqliteSchemaCompatibility;
export function assertOpenClawAgentSchemaContains(
database: DatabaseSync,
pathname: string,
schemaSql: string,
): void {
assertSqliteSchemaContains(database, pathname, schemaSql, OPENCLAW_AGENT_SCHEMA_COMPATIBILITY);
}
export function assertOpenClawAgentCurrentRuntimeSchema(
database: DatabaseSync,
options: { agentId: string; pathname: string },
): void {
const agentId = normalizeAgentId(options.agentId);
const metadata = readExistingAgentSchemaMeta(database);
if (!metadata) {
throw new Error(
`OpenClaw agent database ${options.pathname} has no schema ownership metadata.`,
);
}
assertExistingAgentSchemaOwner(metadata, agentId, options.pathname);
if (metadata.schemaVersion !== OPENCLAW_AGENT_SCHEMA_VERSION) {
throw new Error(
`OpenClaw agent database ${options.pathname} metadata schema version ${metadata.schemaVersion ?? "invalid"} does not match ${OPENCLAW_AGENT_SCHEMA_VERSION}; run openclaw doctor --fix before using it.`,
);
}
assertOpenClawAgentSchemaContains(database, options.pathname, OPENCLAW_AGENT_SCHEMA_SQL);
}
function hasAnyCanonicalTable(database: DatabaseSync, schemaSql: string): boolean {
const tableNames = getCanonicalSqliteTableNames(schemaSql);
const placeholders = tableNames.map(() => "?").join(", ");
return Boolean(
database
.prepare(
`SELECT 1 FROM main.sqlite_schema
WHERE type = 'table' AND name IN (${placeholders})
LIMIT 1`,
)
.get(...tableNames),
);
}
function repairAndAssertAgentSchemaGroup(
database: DatabaseSync,
pathname: string,
schemaSql: string,
): void {
repairCanonicalSqliteIndexes(database, pathname, schemaSql, {
verifyPhysicalIntegrity: false,
});
assertOpenClawAgentSchemaContains(database, pathname, schemaSql);
}
export function repairAndAssertOpenClawAgentV14SchemaForMigration(
database: DatabaseSync,
options: { agentId: string; pathname: string },
): void {
const userVersion = readSqliteUserVersion(database);
if (userVersion !== 14) {
throw new Error(
`OpenClaw agent database ${options.pathname} uses schema version ${userVersion}; expected 14 before migrating it.`,
);
}
const agentId = normalizeAgentId(options.agentId);
const metadata = readExistingAgentSchemaMeta(database);
if (!metadata) {
throw new Error(
`OpenClaw agent database ${options.pathname} has no schema ownership metadata.`,
);
}
assertExistingAgentSchemaOwner(metadata, agentId, options.pathname);
if (metadata.schemaVersion !== 14) {
throw new Error(
`OpenClaw agent database ${options.pathname} metadata schema version ${metadata.schemaVersion ?? "invalid"} does not match 14; repair the ownership metadata before migrating it.`,
);
}
// v14 always owned the core schema. Board and collaboration groups were
// lazy, but a partially present group still has to be complete and canonical.
repairAndAssertAgentSchemaGroup(database, options.pathname, AGENT_V14_CORE_SCHEMA_SQL);
if (hasAnyCanonicalTable(database, AGENT_V14_SESSION_SHARING_SCHEMA_SQL)) {
repairAndAssertAgentSchemaGroup(
database,
options.pathname,
AGENT_V14_SESSION_SHARING_SCHEMA_SQL,
);
}
if (hasAnyCanonicalTable(database, AGENT_V14_ADDITIVE_SCHEMA_SQL)) {
repairAndAssertAgentSchemaGroup(database, options.pathname, AGENT_V14_ADDITIVE_SCHEMA_SQL);
}
if (hasAnyCanonicalTable(database, AGENT_V14_BOARD_SCHEMA_SQL)) {
assertSqliteSchemaTablesPresent(database, options.pathname, AGENT_V14_BOARD_SCHEMA_SQL);
ensureOpenClawAgentBoardSchemaInTransaction(database);
repairAndAssertAgentSchemaGroup(database, options.pathname, AGENT_V14_BOARD_SCHEMA_SQL);
}
}
export function assertSupportedAgentSchemaVersion(db: DatabaseSync, pathname: string): void {
const userVersion = readSqliteUserVersion(db);
if (userVersion > OPENCLAW_AGENT_SCHEMA_VERSION) {
+26 -8
View File
@@ -10,6 +10,7 @@ import { configureSqlitePreSchemaPragmas } from "../infra/sqlite-wal.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { VERSION } from "../version.js";
import { ensureOpenClawAgentBoardSchemaInTransaction } from "./openclaw-agent-board-schema.js";
import {
OPENCLAW_AGENT_SCHEMA_VERSION,
type OpenClawAgentDatabaseOptions,
@@ -18,8 +19,10 @@ import { ensureOpenClawAgentDatabasePermissions } from "./openclaw-agent-db-perm
import { registerOpenClawAgentDatabase } from "./openclaw-agent-db-registry.js";
import {
assertExistingAgentSchemaOwner,
assertOpenClawAgentCurrentRuntimeSchema,
assertSupportedAgentSchemaVersion,
readExistingAgentSchemaMeta,
repairAndAssertOpenClawAgentV14SchemaForMigration,
} from "./openclaw-agent-db-schema-helpers.js";
import {
backfillSessionConversations,
@@ -36,7 +39,6 @@ import {
import type { DB as OpenClawAgentKyselyDatabase } from "./openclaw-agent-db.generated.js";
import { resolveOpenClawAgentSqlitePath } from "./openclaw-agent-db.paths.js";
import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.generated.js";
import { AGENT_SCHEMA_WITHOUT_LAZY_SURFACES_SQL } from "./openclaw-agent-session-sharing-schema.js";
import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db.js";
type OpenClawAgentMetadataDatabase = Pick<OpenClawAgentKyselyDatabase, "schema_meta">;
@@ -447,6 +449,7 @@ function backfillOpenClawAgentSchema(db: DatabaseSync, previousVersion: number):
export function assertAgentDatabaseIntegrityBeforeMutation(
database: DatabaseSync,
agentId: string,
pathname: string,
): void {
database.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`);
@@ -464,6 +467,8 @@ export function assertAgentDatabaseIntegrityBeforeMutation(
toVersion: OPENCLAW_AGENT_SCHEMA_VERSION,
});
}
// Named indexes are repairable; the full schema assertion below must run
// after this repair while still rejecting table and constraint drift.
const rebuiltIndexes =
userVersion === OPENCLAW_AGENT_SCHEMA_VERSION
? repairCanonicalSqliteIndexes(database, pathname, OPENCLAW_AGENT_SCHEMA_SQL, {
@@ -474,6 +479,9 @@ export function assertAgentDatabaseIntegrityBeforeMutation(
// Every physical open proves the full file before schema mutation or exposure.
assertSqliteIntegrity(database, pathname);
}
if (userVersion === OPENCLAW_AGENT_SCHEMA_VERSION) {
assertOpenClawAgentCurrentRuntimeSchema(database, { agentId, pathname });
}
}
function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string): void {
@@ -490,6 +498,16 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string):
assertExistingAgentSchemaOwner(readExistingAgentSchemaMeta(db), agentId, pathname);
assertSupportedAgentSchemaVersion(db, pathname);
const previousVersion = readSqliteUserVersion(db);
if (previousVersion === OPENCLAW_AGENT_SCHEMA_VERSION) {
// Repeat index repair before the transactional schema assertion so a
// concurrent opener cannot turn repairable drift into a hard refusal.
repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_AGENT_SCHEMA_SQL, {
verifyPhysicalIntegrity: false,
});
assertOpenClawAgentCurrentRuntimeSchema(db, { agentId, pathname });
} else if (previousVersion === 14) {
repairAndAssertOpenClawAgentV14SchemaForMigration(db, { agentId, pathname });
}
// Two legacy memory shapes exist: the flip lineage's source_kind schema
// (derived cache — dropped for rebuild) and main's path/source-keyed
// schema (migrated in place by the identity migration). Both helpers are
@@ -506,11 +524,10 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string):
}
backfillSessionEntryProvenance(db, previousVersion);
migrateSessionNodesAndWindows(db, previousVersion);
const schemaSql =
previousVersion === OPENCLAW_AGENT_SCHEMA_VERSION
? AGENT_SCHEMA_WITHOUT_LAZY_SURFACES_SQL
: OPENCLAW_AGENT_SCHEMA_SQL;
db.exec(schemaSql);
db.exec(OPENCLAW_AGENT_SCHEMA_SQL);
if (previousVersion < OPENCLAW_AGENT_SCHEMA_VERSION) {
ensureOpenClawAgentBoardSchemaInTransaction(db);
}
migrateSessionTranscriptGenerations(db, previousVersion);
migrateSessionTranscriptActiveProjection(db, previousVersion);
if (previousVersion < 11) {
@@ -518,7 +535,7 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string):
databaseLabel: pathname,
});
}
repairCanonicalSqliteIndexes(db, pathname, schemaSql, {
repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_AGENT_SCHEMA_SQL, {
verifyPhysicalIntegrity: false,
});
const kysely = getNodeSqliteKysely<OpenClawAgentMetadataDatabase>(db);
@@ -547,6 +564,7 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string):
}),
),
);
assertOpenClawAgentCurrentRuntimeSchema(db, { agentId, pathname });
});
} finally {
db.exec("PRAGMA foreign_keys = ON;");
@@ -565,7 +583,7 @@ export function ensureOpenClawAgentDatabaseSchema(
db.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`);
assertSupportedAgentSchemaVersion(db, pathname);
assertExistingAgentSchemaOwner(readExistingAgentSchemaMeta(db), agentId, pathname);
assertAgentDatabaseIntegrityBeforeMutation(db, pathname);
assertAgentDatabaseIntegrityBeforeMutation(db, agentId, pathname);
configureSqlitePreSchemaPragmas(db, {
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
});
+166 -2
View File
@@ -932,7 +932,7 @@ describe("openclaw agent database", () => {
});
it("opens a v13 database that already contains additive board storage", () => {
expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(14);
expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(15);
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const opened = openOpenClawAgentDatabase({ agentId: "worker-1", env });
@@ -1164,7 +1164,7 @@ describe("openclaw agent database", () => {
});
it("keeps additive heartbeat repair while upgrading schema version 12", () => {
expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(14);
expect(OPENCLAW_AGENT_SCHEMA_VERSION).toBe(15);
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const opened = openOpenClawAgentDatabase({ agentId: "worker-1", env });
@@ -2433,6 +2433,170 @@ describe("openclaw agent database", () => {
).toEqual([{ key: "key-a" }]);
});
it("rejects a missing current-schema table instead of recreating it empty", () => {
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path;
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const drifted = new DatabaseSync(databasePath);
drifted.exec("DROP TABLE auth_profile_store;");
drifted.close();
expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow(
/missing table auth_profile_store/iu,
);
const after = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(
after
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'auth_profile_store'",
)
.get(),
).toBeUndefined();
} finally {
after.close();
}
});
it("rejects a missing stable v14 table before the v15 migration", () => {
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path;
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const damaged = new DatabaseSync(databasePath);
damaged.exec(`
DROP TABLE auth_profile_store;
PRAGMA user_version = 14;
UPDATE schema_meta SET schema_version = 14 WHERE meta_key = 'primary';
`);
damaged.close();
expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow(
/missing table auth_profile_store/iu,
);
const after = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(
after
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'auth_profile_store'",
)
.get(),
).toBeUndefined();
} finally {
after.close();
}
});
it("adds post-v14 suggestion tables during the v15 migration", () => {
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path;
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const legacy = new DatabaseSync(databasePath);
legacy.exec(`
DROP TABLE session_suggestions;
PRAGMA user_version = 14;
UPDATE schema_meta SET schema_version = 14 WHERE meta_key = 'primary';
`);
legacy.close();
const migrated = openOpenClawAgentDatabase({ agentId: "worker-1", env });
expect(
migrated.db
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'session_suggestions'",
)
.get(),
).toEqual({ name: "session_suggestions" });
});
it("rejects an inline unique constraint hidden behind a SQLite autoindex", () => {
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path;
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const drifted = new DatabaseSync(databasePath);
drifted.exec(`
DROP TABLE cache_entries;
CREATE TABLE cache_entries (
scope TEXT NOT NULL UNIQUE,
key TEXT NOT NULL,
value_json TEXT,
blob BLOB,
expires_at INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (scope, key)
) STRICT;
CREATE INDEX idx_agent_cache_expiry
ON cache_entries(scope, expires_at, key)
WHERE expires_at IS NOT NULL;
CREATE INDEX idx_agent_cache_updated
ON cache_entries(scope, updated_at DESC, key);
`);
drifted.close();
expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow(
/unexpected unique index on cache_entries/iu,
);
});
it("rejects primary-key collation drift in a current-schema table", () => {
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path;
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const drifted = new DatabaseSync(databasePath);
drifted.exec(`
DROP TABLE auth_profile_store;
CREATE TABLE auth_profile_store (
store_key TEXT COLLATE NOCASE NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
`);
drifted.close();
expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow(
/column definitions differ for auth_profile_store/iu,
);
});
it("rejects a partially missing lazy board schema", () => {
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const databasePath = openOpenClawAgentDatabase({ agentId: "worker-1", env }).path;
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const drifted = new DatabaseSync(databasePath);
drifted.exec("DROP TABLE board_widgets;");
drifted.close();
expect(() => openOpenClawAgentDatabase({ agentId: "worker-1", env })).toThrow(
/missing table board_widgets/iu,
);
});
it("rejects same-name transcript index drift when duplicate rows block repair", () => {
const stateDir = createTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
+1 -1
View File
@@ -311,7 +311,7 @@ export function openOpenClawAgentDatabase(
}
// Integrity is not process-stable: the file can be damaged while evicted.
// This guard is read-only (no busy waits), so every physical open pays it.
assertAgentDatabaseIntegrityBeforeMutation(db, pathname);
assertAgentDatabaseIntegrityBeforeMutation(db, agentId, pathname);
configureSqlitePreSchemaPragmas(db, {
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
});
@@ -1,8 +1,8 @@
import type { DatabaseSync } from "node:sqlite";
import { OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL } from "./openclaw-agent-board-schema.js";
const SHARING_SCHEMA_START = "CREATE TABLE IF NOT EXISTS session_members (";
const SHARING_SCHEMA_END = "CREATE TABLE IF NOT EXISTS heartbeat_outcomes (";
const SUGGESTIONS_SCHEMA_START = "CREATE TABLE IF NOT EXISTS session_suggestions (";
function splitSessionSharingSchema(sql: string): { sharing: string; withoutSharing: string } {
const start = sql.indexOf(SHARING_SCHEMA_START);
@@ -17,13 +17,14 @@ function splitSessionSharingSchema(sql: string): { sharing: string; withoutShari
}
const sessionSharingSchema = splitSessionSharingSchema(OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL);
export const AGENT_SCHEMA_WITHOUT_LAZY_SURFACES_SQL = sessionSharingSchema.withoutSharing;
/** Adds the phase-2 collaboration table on first use without a schema-version bump. */
export function ensureOpenClawAgentSessionSharingSchemaInTransaction(db: DatabaseSync): void {
if (!db.isTransaction) {
throw new Error("session sharing schema ensure requires an active transaction");
}
db.exec(sessionSharingSchema.sharing); // sqlite-allow-raw -- Canonical DDL for an additive lazy table.
const sessionSuggestionsStart = sessionSharingSchema.sharing.indexOf(SUGGESTIONS_SCHEMA_START);
if (sessionSuggestionsStart === -1) {
throw new Error("OpenClaw agent session-suggestions schema marker is missing.");
}
export const AGENT_V14_SESSION_SHARING_SCHEMA_SQL = sessionSharingSchema.sharing.slice(
0,
sessionSuggestionsStart,
);
export const AGENT_V14_ADDITIVE_SCHEMA_SQL =
sessionSharingSchema.sharing.slice(sessionSuggestionsStart);
export const AGENT_V14_CORE_SCHEMA_SQL = sessionSharingSchema.withoutSharing;
+2 -1
View File
@@ -1,8 +1,9 @@
import type { DatabaseSync } from "node:sqlite";
import type { SqliteWalMaintenance } from "../infra/sqlite-wal.js";
// v6 makes every committed shared-state table part of the canonical runtime schema.
// v5 records durable cloud-worker result refs on pending workspace fences.
export const OPENCLAW_STATE_SCHEMA_VERSION = 5;
export const OPENCLAW_STATE_SCHEMA_VERSION = 6;
export const OPENCLAW_STATE_STRICT_SCHEMA_VERSION = 3;
/** Maximum time one synchronous SQLite call may wait for a lock. */
export const OPENCLAW_SQLITE_BUSY_TIMEOUT_MS = 5_000;
@@ -2,6 +2,7 @@ import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import {
assertSqliteSchemaContains,
assertSqliteSchemaTablesPresent,
type SqliteSchemaCompatibility,
} from "../infra/sqlite-schema-contract.js";
import {
@@ -29,6 +30,10 @@ const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY = {
"commitments.sensitivity": ["sensitivity TEXT NOT NULL DEFAULT 'normal'"],
"commitments.source": ["source TEXT NOT NULL DEFAULT 'unknown'"],
"commitments.suggested_text": ["suggested_text TEXT NOT NULL DEFAULT ''"],
"claw_package_refs.package_integrity": [
"package_integrity TEXT NOT NULL DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000'",
],
"claw_package_refs.updated_at_ms": ["updated_at_ms INTEGER NOT NULL DEFAULT 0"],
"cron_jobs.created_at_ms": ["created_at_ms INTEGER NOT NULL DEFAULT 0"],
"cron_jobs.enabled": ["enabled INTEGER NOT NULL DEFAULT 1"],
"cron_jobs.name": ["name TEXT NOT NULL DEFAULT ''"],
@@ -42,9 +47,29 @@ const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY = {
"current_conversation_bindings.target_agent_id": [
"target_agent_id TEXT NOT NULL DEFAULT 'main'",
],
"operator_approvals.resolution_ref": ["resolution_ref TEXT"],
},
} satisfies SqliteSchemaCompatibility;
const STATE_V5_ADDITIVE_TABLES = [
"agent_database_leases",
"agent_deletion_journal",
"claw_cron_refs",
"claw_installs",
"claw_mcp_server_refs",
"claw_package_refs",
"claw_workspace_files",
"config_machine_state",
"cron_job_scratch",
"meeting_transcript_sessions",
"meeting_transcript_summaries",
"meeting_transcript_utterances",
"outbound_media_provenance",
"worker_environment_credentials",
"worker_transcript_commit_heads",
"worker_transcript_commits",
] as const;
/** Open shared SQLite database handle plus WAL maintenance lifecycle. */
export function createOpenClawDatabaseVerificationError(
@@ -133,6 +158,33 @@ export function assertOpenClawStateDatabaseForMaintenance(
);
}
/** Require every stable v5 table before the v6 additive migration can run. */
export function assertOpenClawStateDatabaseV5ForMigration(
database: DatabaseSync,
options: { pathname: string },
): void {
const userVersion = readSqliteUserVersion(database);
if (userVersion !== 5) {
throw new Error(
`OpenClaw state database ${options.pathname} uses schema version ${userVersion}; expected 5 before migrating it.`,
);
}
assertOpenClawStateDatabaseOwner(database, options);
const metadata = database
.prepare("SELECT schema_version FROM schema_meta WHERE meta_key = 'primary' LIMIT 1")
.get() as { schema_version?: unknown } | undefined;
if (metadata?.schema_version !== 5) {
const schemaVersion =
typeof metadata?.schema_version === "number" ? metadata.schema_version : "invalid";
throw new Error(
`OpenClaw state database ${options.pathname} metadata schema version ${schemaVersion} does not match 5; repair the ownership metadata before migrating it.`,
);
}
assertSqliteSchemaTablesPresent(database, options.pathname, OPENCLAW_STATE_SCHEMA_SQL, {
allowedMissingTables: STATE_V5_ADDITIVE_TABLES,
});
}
export function resolveDatabasePath(options: OpenClawStateDatabaseOptions = {}): string {
return path.resolve(options.path ?? resolveOpenClawStateSqlitePath(options.env ?? process.env));
}
+124
View File
@@ -139,6 +139,13 @@ function replaceManagedImageRecordsWithLegacyTable(
const LEGACY_SESSION_WATCH_SCHEMA_VERSION = 3;
const LEGACY_AMBIENT_WATCH_PREFIX = "ambient-group-watch:";
function markStateDatabaseAsV5(database: DatabaseSync): void {
database.exec(`
PRAGMA user_version = 5;
UPDATE schema_meta SET schema_version = 5 WHERE meta_key = 'primary';
`);
}
function seedLegacySessionWatchCursorSchema(stateDir: string): {
ambientTarget: string;
bomTarget: string;
@@ -1791,6 +1798,112 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
).toEqual([{ task_id: "task-index-repair" }]);
});
it("rejects a missing current-schema table instead of recreating it empty", () => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const databasePath = openOpenClawStateDatabase(options).path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const drifted = new DatabaseSync(databasePath);
drifted.exec("DROP TABLE auth_profile_stores;");
drifted.close();
expect(() => openOpenClawStateDatabase(options)).toThrow(/missing table auth_profile_stores/iu);
const after = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(
after
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'auth_profile_stores'",
)
.get(),
).toBeUndefined();
} finally {
after.close();
}
});
it("rejects a missing stable v5 table before the v6 migration", () => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const databasePath = openOpenClawStateDatabase(options).path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const damaged = new DatabaseSync(databasePath);
damaged.exec("DROP TABLE auth_profile_stores;");
markStateDatabaseAsV5(damaged);
damaged.close();
expect(() => openOpenClawStateDatabase(options)).toThrow(/missing table auth_profile_stores/iu);
const after = new DatabaseSync(databasePath, { readOnly: true });
try {
expect(
after
.prepare(
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'auth_profile_stores'",
)
.get(),
).toBeUndefined();
} finally {
after.close();
}
});
it("rejects an inline unique constraint hidden behind a SQLite autoindex", () => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const databasePath = openOpenClawStateDatabase(options).path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const drifted = new DatabaseSync(databasePath);
drifted.exec(`
DROP TABLE diagnostic_events;
CREATE TABLE diagnostic_events (
scope TEXT NOT NULL UNIQUE,
event_key TEXT NOT NULL,
payload_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
sequence INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (scope, event_key)
) STRICT;
CREATE INDEX idx_diagnostic_events_scope_sequence
ON diagnostic_events(scope, sequence, event_key);
`);
drifted.close();
expect(() => openOpenClawStateDatabase(options)).toThrow(
/unexpected unique index on diagnostic_events/iu,
);
});
it("rejects primary-key collation drift in a current-schema table", () => {
const stateDir = createTempStateDir();
const options = { env: { OPENCLAW_STATE_DIR: stateDir } };
const databasePath = openOpenClawStateDatabase(options).path;
closeOpenClawStateDatabaseForTest();
const { DatabaseSync } = requireNodeSqlite();
const drifted = new DatabaseSync(databasePath);
drifted.exec(`
DROP TABLE auth_profile_stores;
CREATE TABLE auth_profile_stores (
store_key TEXT COLLATE NOCASE NOT NULL PRIMARY KEY,
store_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
`);
drifted.close();
expect(() => openOpenClawStateDatabase(options)).toThrow(
/column definitions differ for auth_profile_stores/iu,
);
});
it("migrates the released audit ledger to message-compatible attribution exactly once", () => {
const stateDir = createTempStateDir();
const databasePath = createLegacyAuditStateDatabase(stateDir);
@@ -2630,6 +2743,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
const { DatabaseSync } = requireNodeSqlite();
const legacyDb = new DatabaseSync(databasePath);
legacyDb.exec("ALTER TABLE gateway_boot_lifecycle DROP COLUMN startup_reason");
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
const reopened = openOpenClawStateDatabase({
@@ -2676,6 +2790,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
const { DatabaseSync } = requireNodeSqlite();
const legacyDb = new DatabaseSync(databasePath);
legacyDb.exec("ALTER TABLE claw_package_refs DROP COLUMN updated_at_ms");
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
const reopened = openOpenClawStateDatabase({
@@ -2705,6 +2820,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
ALTER TABLE worker_environments DROP COLUMN teardown_terminal_state;
ALTER TABLE worker_environments DROP COLUMN ssh_host_key;
`);
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
const reopened = openOpenClawStateDatabase({
@@ -2774,6 +2890,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
DROP TABLE worker_transcript_commits;
DROP TABLE worker_transcript_commit_heads;
`);
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
const reopened = openOpenClawStateDatabase({
@@ -2840,6 +2957,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
DROP INDEX idx_operator_approvals_resolution_ref;
ALTER TABLE operator_approvals DROP COLUMN resolution_ref;
`);
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
const reopened = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir } });
@@ -3018,6 +3136,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
('alpha', 'tie-second', '{}', 10),
('beta', 'only', '{}', 30);
`);
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
const reopened = openOpenClawStateDatabase(options);
@@ -3058,6 +3177,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
const { DatabaseSync } = requireNodeSqlite();
const legacyDb = new DatabaseSync(databasePath);
legacyDb.exec("ALTER TABLE apns_registrations DROP COLUMN relay_origin");
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
const reopened = openOpenClawStateDatabase(options);
@@ -3205,6 +3325,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
110,
110,
);
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
const reopened = openOpenClawStateDatabase({
@@ -3372,6 +3493,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
ALTER TABLE official_external_plugin_catalog_snapshots DROP COLUMN trust_threshold;
ALTER TABLE official_external_plugin_catalog_snapshots DROP COLUMN trust_verified_at;
`);
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
const reopened = openOpenClawStateDatabase({
@@ -3404,6 +3526,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
const { DatabaseSync } = requireNodeSqlite();
const legacyDb = new DatabaseSync(databasePath);
legacyDb.exec("ALTER TABLE task_runs DROP COLUMN detail_json");
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
const reopened = openOpenClawStateDatabase({
@@ -3466,6 +3589,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're
100,
100,
);
markStateDatabaseAsV5(legacyDb);
legacyDb.close();
expect(() =>
+11
View File
@@ -47,6 +47,7 @@ import {
} from "./openclaw-state-db-contract.js";
import {
assertOpenClawStateDatabaseForMaintenance,
assertOpenClawStateDatabaseV5ForMigration,
assertSupportedSchemaVersion,
createOpenClawDatabaseVerificationError,
resolveDatabasePath,
@@ -257,6 +258,15 @@ function ensureSchema(db: DatabaseSync, pathname: string): void {
() => {
assertSupportedSchemaVersion(db, pathname);
const previousVersion = readSqliteUserVersion(db);
if (previousVersion === OPENCLAW_STATE_SCHEMA_VERSION) {
repairCanonicalSqliteIndexes(db, pathname, OPENCLAW_STATE_SCHEMA_SQL, {
verifyPhysicalIntegrity: false,
});
assertCanonicalStateSchemaShape(db, pathname);
assertOpenClawStateDatabaseForMaintenance(db, { pathname });
} else if (previousVersion === 5) {
assertOpenClawStateDatabaseV5ForMigration(db, { pathname });
}
dropLegacyStateTables(db);
ensureAdditiveStateColumns(db);
sessionWatchMigration.migrateSessionWatchCursorProvenance(db);
@@ -296,6 +306,7 @@ function ensureSchema(db: DatabaseSync, pathname: string): void {
}),
),
);
assertOpenClawStateDatabaseForMaintenance(db, { pathname });
},
{
busyTimeoutMs: OPENCLAW_SQLITE_BUSY_TIMEOUT_MS,
@@ -6,15 +6,15 @@ import {
describe("native state schema version guard", () => {
it("keeps the checked-in Swift and TypeScript contracts aligned", () => {
expect(checkNativeStateSchemaVersion()).toBe(5);
expect(checkNativeStateSchemaVersion()).toBe(6);
});
it("fails when a deliberate Swift fixture drifts behind TypeScript", () => {
expect(() =>
compareNativeStateSchemaVersions({
swiftSource: "private static let maximumSupportedSchemaVersion: Int64 = 4\n",
typescriptSource: "export const OPENCLAW_STATE_SCHEMA_VERSION = 5;\n",
swiftSource: "private static let maximumSupportedSchemaVersion: Int64 = 5\n",
typescriptSource: "export const OPENCLAW_STATE_SCHEMA_VERSION = 6;\n",
}),
).toThrow("Native state schema version drift: Swift supports 4, TypeScript owns 5");
).toThrow("Native state schema version drift: Swift supports 5, TypeScript owns 6");
});
});