diff --git a/AGENTS.md b/AGENTS.md index b2b82f866ade..a1a941ea0e7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,7 @@ Skills own workflows; root owns hard policy and routing. Product direction and m - State/storage migrations are database-first. Runtime reads/writes the canonical store only. Old file stores, sidecars, aliases, and fallback readers belong in `openclaw doctor --fix` migration code only, never steady-state runtime. - Storage default: SQLite only. Do not add JSON/JSONL/TXT/sidecar files for OpenClaw-owned runtime state, caches, queues, registries, indexes, cursors, checkpoints, or plugin scratch data. - Any SQLite change requiring a schema-version bump needs explicit user discussion and acceptance before implementation. Agents must not advance SQLite schema versions autonomously. -- Purely additive SQLite surface (new tables; downgraded builds keep working, just without the new feature): do not bump the schema version. Declare in the canonical schema file plus a one-time idempotent lazy ensure on first feature use; fold into the migration path at the next natural bump. Bumps are reserved for changes older readers cannot tolerate. +- Purely additive SQLite surface may stay at the same schema version only when downgraded readers remain safe: new tables, or explicitly compatible existing-table columns declared as exactly one bare nullable SQLite `STRICT` datatype (`ANY`, `BLOB`, `INT`, `INTEGER`, `REAL`, or `TEXT`) with no suffix. Defaults, `NOT NULL`, keys, uniqueness, checks, references, collations, generated expressions, and other constraints on an existing-table addition require a schema-version bump or a companion table. Declare same-version surface in the canonical schema plus a one-time idempotent lazy ensure on first feature use; fold it into the migration path at the next natural bump. - SQLite runtime access uses Kysely helpers, not raw SQL statement strings, except schema DDL, migrations, low-level DB bootstrap, or narrowly justified SQLite primitives. - SQLite write transactions are synchronous commit sections only. Finish async planning, filesystem access, plugin hooks, and predicates before `BEGIN`; then reread and validate authoritative rows before writing. Never return a Promise or execute `await` from a transaction callback. - Use the shared state DB (`state/openclaw.sqlite`) for global runtime state and plugin KV data. Use the per-agent DB (`agents//agent/openclaw-agent.sqlite`) for agent-scoped state/cache. Use a dedicated SQLite DB only when schema, volume, or lifecycle clearly does not fit those stores. diff --git a/docs/reference/database-schemas.md b/docs/reference/database-schemas.md index d9d6112fb8e5..9d0c77ed2248 100644 --- a/docs/reference/database-schemas.md +++ b/docs/reference/database-schemas.md @@ -27,6 +27,8 @@ Each database records its schema in two places: OpenClaw applies forward-only migrations when it opens an older supported database. It refuses a database whose `user_version` is newer than the running build and reports a `newer schema version` error. The Gateway checks all registered databases before startup. `openclaw update` also refuses a package or source target whose declared schema support is older than an on-disk database. Target packages published before schema metadata was added cannot be preflighted. +Changes may stay at the same schema version only when downgraded readers remain safe. New tables qualify because older builds ignore them. An explicitly compatible column on an existing table qualifies only when its declaration is exactly one bare nullable SQLite `STRICT` datatype: `ANY`, `BLOB`, `INT`, `INTEGER`, `REAL`, or `TEXT`. The declaration cannot have a default, `NOT NULL`, a primary or unique key, a check, a reference, a collation, a generated expression, or another suffix. Constrained existing-table additions require a schema-version bump or a companion table instead. + Installing OpenClaw manually through npm bypasses the updater guard. Database open checks still refuse an incompatible build. ## Agent schema history diff --git a/src/infra/sqlite-schema-contract.test.ts b/src/infra/sqlite-schema-contract.test.ts index acbfba45ae16..e47f17af7ef8 100644 --- a/src/infra/sqlite-schema-contract.test.ts +++ b/src/infra/sqlite-schema-contract.test.ts @@ -30,6 +30,9 @@ const CANONICAL_SCHEMA = ` parent_id TEXT, FOREIGN KEY (parent_id) REFERENCES parents(id) DEFERRABLE INITIALLY DEFERRED ); + CREATE TABLE compatible_columns ( + value TEXT + ) STRICT; CREATE INDEX idx_children_parent ON children(parent_id, id); CREATE TRIGGER children_value_after_update AFTER UPDATE OF value ON children @@ -145,6 +148,69 @@ describe("assertSqliteSchemaContains", () => { } }); + it.each(["ANY", "BLOB", "INT", "INTEGER", "REAL", "TEXT"])( + "accepts a compatible future additive %s column only when enabled", + (type) => { + const database = createDatabase(CANONICAL_SCHEMA); + try { + database.exec(`ALTER TABLE compatible_columns ADD COLUMN future_note ${type};`); + + expect(() => + assertSqliteSchemaContains(database, "test database", CANONICAL_SCHEMA), + ).toThrow("column definitions differ for compatible_columns"); + expect(() => + assertSqliteSchemaContains(database, "test database", CANONICAL_SCHEMA, { + allowCompatibleAdditiveColumns: true, + }), + ).not.toThrow(); + } finally { + database.close(); + } + }, + ); + + it.each([ + "TEXT DEFAULT NULL", + "TEXT NOT NULL DEFAULT ''", + "TEXT PRIMARY KEY", + "TEXT UNIQUE", + "TEXT CHECK (length(future_note) > 0)", + "TEXT REFERENCES parents(id)", + "TEXT COLLATE NOCASE", + "TEXT GENERATED ALWAYS AS (value) VIRTUAL", + ])("rejects a future additive column declared as %s", (declaration) => { + const database = createDatabase(schemaWithFutureColumn(declaration)); + try { + expect(() => + assertSqliteSchemaContains(database, "test database", CANONICAL_SCHEMA, { + allowCompatibleAdditiveColumns: true, + }), + ).toThrow("column definitions differ for compatible_columns"); + } finally { + database.close(); + } + }); + + it("keeps allowlisted missing additive columns compatible in the upgrade direction", () => { + const futureSchema = CANONICAL_SCHEMA.replace( + " value TEXT\n ) STRICT;", + " value TEXT,\n future_note TEXT\n ) STRICT;", + ); + const database = createDatabase(CANONICAL_SCHEMA); + try { + expect(() => assertSqliteSchemaContains(database, "test database", futureSchema)).toThrow( + "column definitions differ for compatible_columns", + ); + expect(() => + assertSqliteSchemaContains(database, "test database", futureSchema, { + allowedMissingColumns: ["compatible_columns.future_note"], + }), + ).not.toThrow(); + } finally { + database.close(); + } + }); + it("accepts only allowlisted missing lazy-additive tables", () => { const migratedSchema = CANONICAL_SCHEMA.replace( / {2}CREATE TABLE events \([\s\S]*?\n {2}\);\n/u, @@ -272,3 +338,10 @@ function createDatabase(schema: string): DatabaseSync { database.exec(schema); return database; } + +function schemaWithFutureColumn(declaration: string): string { + return CANONICAL_SCHEMA.replace( + " value TEXT\n ) STRICT;", + ` value TEXT,\n future_note ${declaration}\n ) STRICT;`, + ); +} diff --git a/src/infra/sqlite-schema-contract.ts b/src/infra/sqlite-schema-contract.ts index 55ec90f6f1b9..c8360faf38b2 100644 --- a/src/infra/sqlite-schema-contract.ts +++ b/src/infra/sqlite-schema-contract.ts @@ -80,6 +80,11 @@ export type SqliteSchemaCompatibility = { * requires a temporary default that the clean schema does not retain. */ allowedColumnDefinitions?: Readonly>; + /** + * Allow unexpected columns declared as a name plus one bare nullable SQLite + * STRICT datatype. Allowed-missing tables remain exact when present. + */ + allowCompatibleAdditiveColumns?: boolean; /** * Exact owner-defined trigger groups that may be absent when their derived * or lazily ensured schema is absent, but must be complete and canonical @@ -128,6 +133,7 @@ export function assertSqliteSchemaContains( actualTable.definition, expectedTable.definition, compatibility, + !allowedMissingTables.has(tableName), ); if (definitionMismatch) { mismatches.push(`${definitionMismatch} differ for ${tableName}`); @@ -436,19 +442,23 @@ function compareTableDefinitions( actual: SqliteTableDefinition | null, expected: SqliteTableDefinition | null, compatibility: SqliteSchemaCompatibility, + allowCompatibleAdditiveColumns: boolean, ): "column definitions" | "table constraints" | "table definition" | null { if (!actual || !expected) { return actual === expected ? null : "table definition"; } const allowedMissingColumns = new Set(compatibility.allowedMissingColumns ?? []); - const allowedMissingCount = [...expected.columns].filter( - ([columnName]) => - !actual.columns.has(columnName) && allowedMissingColumns.has(`${tableName}.${columnName}`), - ).length; - if (actual.columns.size + allowedMissingCount !== expected.columns.size) { - return "column definitions"; - } - if ([...actual.columns].some(([columnName]) => !expected.columns.has(columnName))) { + const unexpectedColumns = [...actual.columns].filter( + ([columnName]) => !expected.columns.has(columnName), + ); + if ( + unexpectedColumns.some( + ([, definition]) => + !allowCompatibleAdditiveColumns || + !compatibility.allowCompatibleAdditiveColumns || + !isCompatibleAdditiveColumnDefinition(definition), + ) + ) { return "column definitions"; } for (const [columnName, expectedDefinition] of expected.columns) { @@ -467,6 +477,18 @@ function compareTableDefinitions( return isEqual(actual.constraints, expected.constraints) ? null : "table constraints"; } +const SQLITE_STRICT_DATATYPES = new Set(["ANY", "BLOB", "INT", "INTEGER", "REAL", "TEXT"]); + +function isCompatibleAdditiveColumnDefinition(definition: string): boolean { + const name = readSqlToken(definition, 0); + const type = name ? readSqlToken(definition, name.end) : null; + return Boolean( + type?.keyword && + SQLITE_STRICT_DATATYPES.has(type.keyword) && + definition.slice(type.end).trim().length === 0, + ); +} + function parseTableDefinition(sql: string | null, tableName: string): SqliteTableDefinition { if (sql === null) { throw new Error(`Could not inspect SQLite table definition for ${tableName}.`); diff --git a/src/state/openclaw-agent-db-schema-helpers.ts b/src/state/openclaw-agent-db-schema-helpers.ts index 8b36061f4d76..6e2135270d2b 100644 --- a/src/state/openclaw-agent-db-schema-helpers.ts +++ b/src/state/openclaw-agent-db-schema-helpers.ts @@ -44,6 +44,7 @@ type ExistingAgentSchemaMeta = { }; const AGENT_SCHEMA_COMPATIBILITY = { + allowCompatibleAdditiveColumns: true, allowedMissingTables: [ MEMORY_INDEX_CHUNK_PROVENANCE_TABLE, MEMORY_INDEX_CHUNK_RECALL_METADATA_TABLE, diff --git a/src/state/openclaw-database-maintenance.test.ts b/src/state/openclaw-database-maintenance.test.ts index 619cf3e66ab7..018fdd4ce97d 100644 --- a/src/state/openclaw-database-maintenance.test.ts +++ b/src/state/openclaw-database-maintenance.test.ts @@ -1,11 +1,15 @@ import { DatabaseSync } from "node:sqlite"; import { describe, expect, it } from "vitest"; import { ensureMemoryIndexSchema } from "../../packages/memory-host-sdk/src/host/memory-schema.js"; +import { assertSqliteSchemaContains } from "../infra/sqlite-schema-contract.js"; import { assertOpenClawAgentDatabaseForMaintenance, OPENCLAW_AGENT_SCHEMA_VERSION, } from "./openclaw-agent-db.js"; import { OPENCLAW_AGENT_SCHEMA_SQL } from "./openclaw-agent-schema.js"; +import { CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS } from "./openclaw-state-db-additive-columns.js"; +import { CLAW_LAZY_ADDITIVE_STATE_COLUMNS } from "./openclaw-state-db-maintenance.js"; +import { ensureAdditiveStateColumns } from "./openclaw-state-db-schema-additive.js"; import { assertOpenClawStateDatabaseForMaintenance, OPENCLAW_STATE_SCHEMA_VERSION, @@ -53,6 +57,139 @@ describe("OpenClaw database maintenance schema validation", () => { } }); + it("keeps a newer nullable shared-state column compatible with the previous schema", () => { + const previousSchema = OPENCLAW_STATE_SCHEMA_SQL.replace( + " removed_at INTEGER,\n run_end_cleanup_json TEXT\n", + " removed_at INTEGER\n", + ); + const database = createGlobalDatabase(); + try { + expect(previousSchema).not.toBe(OPENCLAW_STATE_SCHEMA_SQL); + expect(() => + assertSqliteSchemaContains(database, "previous global schema", previousSchema, { + allowCompatibleAdditiveColumns: true, + }), + ).not.toThrow(); + } finally { + database.close(); + } + }); + + it("accepts compatible future columns in shared-state and agent databases", () => { + const globalDatabase = createGlobalDatabase(); + const agentDatabase = createAgentDatabase(); + try { + globalDatabase.exec("ALTER TABLE worktrees ADD COLUMN future_note TEXT;"); + agentDatabase.exec("ALTER TABLE conversations ADD COLUMN future_note TEXT;"); + + expect(() => + assertOpenClawStateDatabaseForMaintenance(globalDatabase, { + pathname: "global.sqlite", + }), + ).not.toThrow(); + expect(() => + assertOpenClawAgentDatabaseForMaintenance(agentDatabase, { + agentId: "worker-1", + pathname: "agent.sqlite", + }), + ).not.toThrow(); + } finally { + agentDatabase.close(); + globalDatabase.close(); + } + }); + + it("accepts the historical checked shared-host column but rejects other constraints", () => { + const historicalSchema = OPENCLAW_STATE_SCHEMA_SQL.replace( + " shared_host INTEGER\n) STRICT;", + " shared_host INTEGER CHECK (shared_host IN (0, 1))\n) STRICT;", + ); + const database = createGlobalDatabase(historicalSchema); + try { + expect(historicalSchema).not.toBe(OPENCLAW_STATE_SCHEMA_SQL); + expect(() => + assertOpenClawStateDatabaseForMaintenance(database, { + pathname: "global.sqlite", + }), + ).not.toThrow(); + + database.exec("ALTER TABLE worktrees ADD COLUMN future_note TEXT DEFAULT NULL;"); + expect(() => + assertOpenClawStateDatabaseForMaintenance(database, { + pathname: "global.sqlite", + }), + ).toThrow("column definitions differ for worktrees"); + } finally { + database.close(); + } + }); + + it("keeps every registered same-version column bare, canonical, and ensured", () => { + expect( + CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map( + ({ columnName, tableName }) => `${tableName}.${columnName}`, + ), + ).toEqual(CLAW_LAZY_ADDITIVE_STATE_COLUMNS); + expect( + CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS.map( + ({ columnName, dataType, tableName }) => `${tableName}.${columnName} ${dataType}`, + ), + ).toEqual([ + "claw_installs.bootstrap_content_digest TEXT", + "claw_installs.bootstrap_source_path TEXT", + "claw_package_refs.extension_adapter_identity TEXT", + "claw_package_refs.extension_detected_format TEXT", + "claw_package_refs.extension_format TEXT", + "claw_package_refs.extension_id TEXT", + "claw_package_refs.extension_mapped_json TEXT", + "claw_package_refs.extension_unavailable_json TEXT", + "worker_environments.shared_host INTEGER", + "worktrees.run_end_cleanup_json TEXT", + ]); + + const database = createGlobalDatabase(); + try { + for (const { + columnName, + dataType, + tableName, + } of CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS) { + expect(readColumnContract(database, tableName, columnName)).toEqual({ + dflt_value: null, + hidden: 0, + name: columnName, + notnull: 0, + pk: 0, + type: dataType, + }); + database.exec(`ALTER TABLE "${tableName}" DROP COLUMN "${columnName}";`); + } + + ensureAdditiveStateColumns(database); + expect(() => + assertOpenClawStateDatabaseForMaintenance(database, { + pathname: "global.sqlite", + }), + ).not.toThrow(); + for (const { + columnName, + dataType, + tableName, + } of CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS) { + expect(readColumnContract(database, tableName, columnName)).toEqual({ + dflt_value: null, + hidden: 0, + name: columnName, + notnull: 0, + pk: 0, + type: dataType, + }); + } + } finally { + database.close(); + } + }); + it("accepts a migrated required column with its temporary default", () => { const schemaWithoutMigratedColumn = OPENCLAW_STATE_SCHEMA_SQL.replace( " owner_session_key TEXT,\n name TEXT NOT NULL,\n description TEXT,\n", @@ -366,3 +503,23 @@ function createAgentDatabase(): DatabaseSync { .run(OPENCLAW_AGENT_SCHEMA_VERSION); return database; } + +function readColumnContract( + database: DatabaseSync, + tableName: string, + columnName: string, +): Record | undefined { + const column = ( + database.prepare(`PRAGMA table_xinfo("${tableName}")`).all() as Array> + ).find((candidate) => candidate.name === columnName); + return column + ? { + dflt_value: column.dflt_value, + hidden: column.hidden, + name: column.name, + notnull: column.notnull, + pk: column.pk, + type: column.type, + } + : undefined; +} diff --git a/src/state/openclaw-database-preflight.test.ts b/src/state/openclaw-database-preflight.test.ts index 29c2cd9a6a4d..d3219ec22d7b 100644 --- a/src/state/openclaw-database-preflight.test.ts +++ b/src/state/openclaw-database-preflight.test.ts @@ -160,7 +160,9 @@ describe("OpenClaw database schema preflight", () => { const { DatabaseSync } = requireNodeSqlite(); const agent = new DatabaseSync(agentPath); try { - agent.exec("ALTER TABLE schema_meta ADD COLUMN unexpected TEXT;"); + agent.exec( + "ALTER TABLE schema_meta ADD COLUMN unexpected TEXT CHECK (length(unexpected) > 0);", + ); } finally { agent.close(); } diff --git a/src/state/openclaw-state-db-additive-columns.ts b/src/state/openclaw-state-db-additive-columns.ts new file mode 100644 index 000000000000..c92e5605fe4a --- /dev/null +++ b/src/state/openclaw-state-db-additive-columns.ts @@ -0,0 +1,21 @@ +type BareNullableSqliteDatatype = "ANY" | "BLOB" | "INT" | "INTEGER" | "REAL" | "TEXT"; +type LazyAdditiveStateColumnDefinition = { + columnName: string; + dataType: BareNullableSqliteDatatype; + tableName: string; +}; + +// Added after v6 shipped. Every definition stays bare and nullable so older v6 +// writers can omit it safely when a newer build has already ensured the column. +export const CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS = [ + { columnName: "bootstrap_content_digest", dataType: "TEXT", tableName: "claw_installs" }, + { columnName: "bootstrap_source_path", dataType: "TEXT", tableName: "claw_installs" }, + { columnName: "extension_adapter_identity", dataType: "TEXT", tableName: "claw_package_refs" }, + { columnName: "extension_detected_format", dataType: "TEXT", tableName: "claw_package_refs" }, + { columnName: "extension_format", dataType: "TEXT", tableName: "claw_package_refs" }, + { columnName: "extension_id", dataType: "TEXT", tableName: "claw_package_refs" }, + { columnName: "extension_mapped_json", dataType: "TEXT", tableName: "claw_package_refs" }, + { columnName: "extension_unavailable_json", dataType: "TEXT", tableName: "claw_package_refs" }, + { columnName: "shared_host", dataType: "INTEGER", tableName: "worker_environments" }, + { columnName: "run_end_cleanup_json", dataType: "TEXT", tableName: "worktrees" }, +] as const satisfies readonly LazyAdditiveStateColumnDefinition[]; diff --git a/src/state/openclaw-state-db-maintenance.ts b/src/state/openclaw-state-db-maintenance.ts index 4287b634eb1f..64206d80680b 100644 --- a/src/state/openclaw-state-db-maintenance.ts +++ b/src/state/openclaw-state-db-maintenance.ts @@ -37,6 +37,7 @@ export const CLAW_LAZY_ADDITIVE_STATE_COLUMNS = [ ] as const; const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY = { + allowCompatibleAdditiveColumns: true, allowedMissingTables: LAZY_ADDITIVE_STATE_TABLES, allowedMissingColumns: CLAW_LAZY_ADDITIVE_STATE_COLUMNS, allowedColumnDefinitions: { @@ -69,6 +70,7 @@ const OPENCLAW_STATE_MAINTENANCE_SCHEMA_COMPATIBILITY = { "target_agent_id TEXT NOT NULL DEFAULT 'main'", ], "operator_approvals.resolution_ref": ["resolution_ref TEXT"], + "worker_environments.shared_host": ["shared_host INTEGER CHECK (shared_host IN (0, 1))"], }, } satisfies SqliteSchemaCompatibility; diff --git a/src/state/openclaw-state-db-schema-additive.ts b/src/state/openclaw-state-db-schema-additive.ts index 0ff63f1c2ae2..057caaa3149e 100644 --- a/src/state/openclaw-state-db-schema-additive.ts +++ b/src/state/openclaw-state-db-schema-additive.ts @@ -1,6 +1,7 @@ import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS } from "./openclaw-state-db-additive-columns.js"; import { backfillAcpReplayEstimatedBytes, backfillCronJobsFromJobJson, @@ -94,8 +95,9 @@ function backfillLegacyManagedImageRoots(db: DatabaseSync): void { } export function ensureAdditiveStateColumns(db: DatabaseSync): void { - ensureColumn(db, "claw_installs", "bootstrap_source_path TEXT"); - ensureColumn(db, "claw_installs", "bootstrap_content_digest TEXT"); + for (const { columnName, dataType, tableName } of CLAW_LAZY_ADDITIVE_STATE_COLUMN_DEFINITIONS) { + ensureColumn(db, tableName, `${columnName} ${dataType}`); + } if (ensureColumn(db, "claw_package_refs", "updated_at_ms INTEGER NOT NULL DEFAULT 0")) { db.exec("UPDATE claw_package_refs SET updated_at_ms = installed_at_ms;"); } @@ -104,12 +106,6 @@ export function ensureAdditiveStateColumns(db: DatabaseSync): void { "claw_package_refs", "package_integrity TEXT NOT NULL DEFAULT 'sha256:0000000000000000000000000000000000000000000000000000000000000000'", ); - ensureColumn(db, "claw_package_refs", "extension_id TEXT"); - ensureColumn(db, "claw_package_refs", "extension_format TEXT"); - ensureColumn(db, "claw_package_refs", "extension_detected_format TEXT"); - ensureColumn(db, "claw_package_refs", "extension_mapped_json TEXT"); - ensureColumn(db, "claw_package_refs", "extension_unavailable_json TEXT"); - ensureColumn(db, "claw_package_refs", "extension_adapter_identity TEXT"); const addedDiagnosticEventSequence = ensureColumn( db, "diagnostic_events", @@ -138,7 +134,6 @@ export function ensureAdditiveStateColumns(db: DatabaseSync): void { } db.exec("DROP INDEX IF EXISTS idx_diagnostic_events_scope_created;"); ensureColumn(db, "worktrees", "provisioned_paths_json TEXT"); - ensureColumn(db, "worktrees", "run_end_cleanup_json TEXT"); ensureColumn(db, "node_host_config", "gateway_context_path TEXT"); ensureColumn(db, "node_host_config", "installed_apps_sharing INTEGER NOT NULL DEFAULT 0"); ensureColumn(db, "apns_registrations", "relay_origin TEXT"); @@ -363,7 +358,6 @@ export function ensureAdditiveStateColumns(db: DatabaseSync): void { "owner_epoch INTEGER NOT NULL DEFAULT 0 CHECK (owner_epoch >= 0)", ); ensureColumn(db, "worker_environments", "ssh_host_key TEXT"); - ensureColumn(db, "worker_environments", "shared_host INTEGER CHECK (shared_host IN (0, 1))"); ensureColumn(db, "worker_workspace_pending_results", "staged_result_ref TEXT"); ensureColumn( db, diff --git a/src/state/openclaw-state-db.test.ts b/src/state/openclaw-state-db.test.ts index f72504a65e96..03a25de98295 100644 --- a/src/state/openclaw-state-db.test.ts +++ b/src/state/openclaw-state-db.test.ts @@ -1968,11 +1968,7 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const { DatabaseSync } = requireNodeSqlite(); const shippedSchema = new DatabaseSync(databasePath); - let canonicalColumnOrder: string[]; try { - canonicalColumnOrder = ( - shippedSchema.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }> - ).map((column) => column.name); shippedSchema.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName};`); expect(readSqliteNumberPragma(shippedSchema, "user_version")).toBe( OPENCLAW_STATE_SCHEMA_VERSION, @@ -1986,8 +1982,10 @@ INSERT INTO macos_port_guardian_records VALUES (4242, 18789, '/usr/bin/ssh', 're const columns = reopened.db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string; }>; - expect(columns.map((column) => column.name)).toEqual(canonicalColumnOrder); - expect(canonicalColumnOrder.at(-1)).toBe(columnName); + expect(columns.map((column) => column.name)).toContain(columnName); + expect(() => + assertOpenClawStateDatabaseForMaintenance(reopened.db, { pathname: reopened.path }), + ).not.toThrow(); expect(reopened.db.prepare("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok", }); diff --git a/src/state/openclaw-state-schema.sql b/src/state/openclaw-state-schema.sql index 9d2c9239587b..ba8d27afaec4 100644 --- a/src/state/openclaw-state-schema.sql +++ b/src/state/openclaw-state-schema.sql @@ -1878,7 +1878,7 @@ CREATE TABLE IF NOT EXISTS worker_environments ( idle_since_at_ms INTEGER, destroy_requested_at_ms INTEGER, last_error TEXT, - shared_host INTEGER CHECK (shared_host IN (0, 1)) + shared_host INTEGER ) STRICT; CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_environments_provider_lease