From 95948284cca37c85d3d5c1e0c4ca2a8f06197732 Mon Sep 17 00:00:00 2001 From: Obed0101 Date: Wed, 23 Sep 2026 15:14:52 -0500 Subject: [PATCH] fix: preserve main schema guard during promotion --- .../opencode/src/storage/recorded-schema.ts | 19 +++++++++++ .../test/installation/recorded-schema.test.ts | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/mendcode/packages/opencode/src/storage/recorded-schema.ts create mode 100644 src/mendcode/packages/opencode/test/installation/recorded-schema.test.ts diff --git a/src/mendcode/packages/opencode/src/storage/recorded-schema.ts b/src/mendcode/packages/opencode/src/storage/recorded-schema.ts new file mode 100644 index 00000000..2fbf114e --- /dev/null +++ b/src/mendcode/packages/opencode/src/storage/recorded-schema.ts @@ -0,0 +1,19 @@ +import { existsSync } from "node:fs" +import { createHash } from "node:crypto" +import { init } from "#db" + +/** Refuse newer channel schemas before opening a writer; preserve legacy migration handling. */ +export function assertRecordedSchema(file: string, entries: Array<{ name: string; timestamp: number; sql: string }>) { + if (file === ":memory:" || !existsSync(file)) return + const db = init(file, true) + try { + if (!db.all("SELECT name FROM sqlite_master WHERE type='table' AND name='__mendcode_schema_identity'").length) return + const known = new Map(entries.map(entry => [entry.name, entry])) + for (const row of db.all<{ name: string; timestamp: number; hash: string }>("SELECT name, timestamp, hash FROM __mendcode_schema_identity")) { + const expected = known.get(row.name) + if (!expected || expected.timestamp !== row.timestamp || createHash("sha256").update(expected.sql).digest("hex") !== row.hash) { + throw new Error("This session database was written by an incompatible release. Continue using that release channel; no database changes were made.") + } + } + } finally { db.$client.close() } +} diff --git a/src/mendcode/packages/opencode/test/installation/recorded-schema.test.ts b/src/mendcode/packages/opencode/test/installation/recorded-schema.test.ts new file mode 100644 index 00000000..e46b0513 --- /dev/null +++ b/src/mendcode/packages/opencode/test/installation/recorded-schema.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "bun:test" +import { Database } from "bun:sqlite" +import { createHash } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import { tmpdir } from "../fixture/fixture" +import { assertRecordedSchema } from "../../src/storage/recorded-schema" + +test("stable refuses an incompatible beta schema without changing data", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "sessions.db") + const db = new Database(file) + db.run("CREATE TABLE session(id TEXT); INSERT INTO session VALUES ('preserved')") + db.run("CREATE TABLE __mendcode_schema_identity(name TEXT, timestamp INTEGER, hash TEXT)") + const entry = { name: "future", timestamp: 1, sql: "CREATE TABLE future(id TEXT)" } + db.query("INSERT INTO __mendcode_schema_identity VALUES (?, ?, ?)").run(entry.name, entry.timestamp, createHash("sha256").update(entry.sql).digest("hex")) + db.close() + const before = await fs.readFile(file) + expect(() => assertRecordedSchema(file, [])).toThrow("incompatible release") + expect(await fs.readFile(file)).toEqual(before) + expect(() => assertRecordedSchema(file, [entry])).not.toThrow() + expect(() => assertRecordedSchema(file, [{ ...entry, sql: "different" }])).toThrow("incompatible release") +}) + +test("legacy databases keep the existing migration path", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "legacy.db") + const db = new Database(file) + db.run("CREATE TABLE session(id TEXT)") + db.close() + expect(() => assertRecordedSchema(file, [])).not.toThrow() +})