Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/mendcode/packages/opencode/src/storage/recorded-schema.ts
Original file line number Diff line number Diff line change
@@ -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() }
}
Original file line number Diff line number Diff line change
@@ -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()
})
Loading