fix(cli): keep session database compatible with released clients (#12207)

This commit is contained in:
Marius
2026-07-14 19:26:22 +02:00
committed by GitHub
parent d318763da7
commit c49560af0f
19 changed files with 2323 additions and 27 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep shared session databases writable by released Kilo clients after newer schema migrations run.
@@ -1,5 +1,6 @@
DELETE FROM `session_message`;--> statement-breakpoint
ALTER TABLE `session_message` ADD `seq` integer NOT NULL;--> statement-breakpoint
-- kilocode_change
ALTER TABLE `session_message` ADD `seq` integer;--> statement-breakpoint
DROP INDEX IF EXISTS `session_message_session_time_created_id_idx`;--> statement-breakpoint
DROP INDEX IF EXISTS `session_message_session_type_time_created_id_idx`;--> statement-breakpoint
CREATE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);--> statement-breakpoint
@@ -756,7 +756,7 @@
},
{
"type": "integer",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@@ -830,7 +830,7 @@
},
{
"type": "integer",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@@ -874,7 +874,7 @@
},
{
"type": "integer",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@@ -874,7 +874,7 @@
},
{
"type": "integer",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@@ -938,7 +938,7 @@
},
{
"type": "integer",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@@ -950,7 +950,7 @@
},
{
"type": "integer",
"notNull": true,
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
@@ -2080,4 +2080,4 @@
}
],
"renames": []
}
}
@@ -0,0 +1,19 @@
-- kilocode_change - new file
CREATE TABLE `__new_session_message` (
`id` text PRIMARY KEY,
`session_id` text NOT NULL,
`type` text NOT NULL,
`seq` integer,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
`data` text NOT NULL,
CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
INSERT INTO `__new_session_message`(`id`, `session_id`, `type`, `seq`, `time_created`, `time_updated`, `data`) SELECT `id`, `session_id`, `type`, `seq`, `time_created`, `time_updated`, `data` FROM `session_message`;--> statement-breakpoint
DROP TABLE `session_message`;--> statement-breakpoint
ALTER TABLE `__new_session_message` RENAME TO `session_message`;--> statement-breakpoint
CREATE UNIQUE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);--> statement-breakpoint
CREATE INDEX `session_message_session_type_seq_idx` ON `session_message` (`session_id`,`type`,`seq`);--> statement-breakpoint
CREATE INDEX `session_message_session_time_created_id_idx` ON `session_message` (`session_id`,`time_created`,`id`);--> statement-breakpoint
CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);
File diff suppressed because it is too large Load Diff
+1
View File
@@ -34,5 +34,6 @@ export const migrations = (
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260605003541_add_session_context_snapshot"),
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260714141136_session-message-legacy-writer-compat"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -8,7 +8,7 @@ export default {
// Pre-launch Session projections were written before durable event persistence
// became unconditional, so they cannot be assigned truthful aggregate order.
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`ALTER TABLE \`session_message\` ADD COLUMN \`seq\` integer NOT NULL;`)
yield* tx.run(`ALTER TABLE \`session_message\` ADD COLUMN \`seq\` integer;`) // kilocode_change
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_time_created_id_idx\`;`)
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
yield* tx.run(
@@ -0,0 +1,30 @@
// kilocode_change - new file
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260714141136_session-message-legacy-writer-compat",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`__new_session_message\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`seq\` integer,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`INSERT INTO \`__new_session_message\`(\`id\`, \`session_id\`, \`type\`, \`seq\`, \`time_created\`, \`time_updated\`, \`data\`) SELECT \`id\`, \`session_id\`, \`type\`, \`seq\`, \`time_created\`, \`time_updated\`, \`data\` FROM \`session_message\`;`)
yield* tx.run(`DROP TABLE \`session_message\`;`)
yield* tx.run(`ALTER TABLE \`__new_session_message\` RENAME TO \`session_message\`;`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
})
},
} satisfies DatabaseMigration.Migration
+13 -8
View File
@@ -2,7 +2,7 @@ export * as SessionV2 from "./session"
export * from "./session/schema"
import { Cause, Effect, Layer, Schema, Context, Stream } from "effect"
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, gt, isNotNull, like, lt, or, type SQL } from "drizzle-orm" // kilocode_change
import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace"
import { ModelV2 } from "./model"
@@ -304,20 +304,25 @@ export const layer = Layer.effect(
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
and(
eq(SessionMessageTable.session_id, input.sessionID),
eq(SessionMessageTable.id, input.cursor.id),
isNotNull(SessionMessageTable.seq), // kilocode_change
),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
const seq = anchor?.seq
if (input.cursor && seq == null) return []
const boundary = seq != null
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
? gt(SessionMessageTable.seq, seq)
: lt(SessionMessageTable.seq, seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
? and(eq(SessionMessageTable.session_id, input.sessionID), isNotNull(SessionMessageTable.seq), boundary)
: and(eq(SessionMessageTable.session_id, input.sessionID), isNotNull(SessionMessageTable.seq)) // kilocode_change
const query = db
.select()
.from(SessionMessageTable)
+12 -3
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, isNotNull, ne, or } from "drizzle-orm" // kilocode_change
import { Effect, Schema } from "effect"
import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
@@ -11,14 +11,22 @@ type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
const row = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
isNotNull(SessionMessageTable.seq), // kilocode_change
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row || row.seq === null) return
return { seq: row.seq }
})
const messageRows = Effect.fnUntraced(function* (
@@ -33,6 +41,7 @@ const messageRows = Effect.fnUntraced(function* (
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
isNotNull(SessionMessageTable.seq), // kilocode_change
compaction
? or(
gte(SessionMessageTable.seq, compaction.seq),
+19 -4
View File
@@ -1,6 +1,6 @@
export * as SessionProjector from "./projector"
import { and, desc, eq, sql } from "drizzle-orm"
import { and, desc, eq, isNotNull, sql } from "drizzle-orm" // kilocode_change
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
@@ -138,7 +138,11 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
.select()
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")),
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "assistant"),
isNotNull(SessionMessageTable.seq), // kilocode_change
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
@@ -159,6 +163,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
eq(SessionMessageTable.id, messageID),
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "assistant"),
isNotNull(SessionMessageTable.seq), // kilocode_change
),
)
.get()
@@ -174,7 +179,11 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
.select()
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction")),
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "compaction"),
isNotNull(SessionMessageTable.seq), // kilocode_change
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
@@ -190,7 +199,13 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell")))
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "shell"),
isNotNull(SessionMessageTable.seq), // kilocode_change
),
)
.orderBy(desc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
+1 -1
View File
@@ -124,7 +124,7 @@ export const SessionMessageTable = sqliteTable(
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().$type<SessionMessage.Type>().notNull(),
seq: integer().notNull(),
seq: integer(), // kilocode_change - allow released clients to share databases with newer schemas
...Timestamps,
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
},
+2 -2
View File
@@ -1,6 +1,6 @@
export * as SessionStore from "./store"
import { eq } from "drizzle-orm"
import { and, eq, isNotNull } from "drizzle-orm" // kilocode_change
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { SessionHistory } from "./history"
@@ -45,7 +45,7 @@ export const layer = Layer.effect(
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, messageID))
.where(and(eq(SessionMessageTable.id, messageID), isNotNull(SessionMessageTable.seq))) // kilocode_change
.get()
.pipe(Effect.orDie)
return row
@@ -0,0 +1,128 @@
import { describe, expect, test } from "bun:test"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import { migrations } from "@opencode-ai/core/database/migration.gen"
import legacyWriterMigration from "@opencode-ai/core/database/migration/20260714141136_session-message-legacy-writer-compat"
import { Effect } from "effect"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import { sql } from "drizzle-orm"
import path from "path"
import { tmpdir } from "../fixture/tmpdir"
const make = EffectDrizzleSqlite.makeWithDefaults()
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
Effect.runPromise(
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
)
describe("database migration compatibility", () => {
test("accepts released v7.4.7 session message writes after current migrations", async () => {
await run(
Effect.gen(function* () {
const db = yield* make
const split = migrations.findIndex((migration) => migration.id === "20260601010001_normalize_storage_paths")
expect(split).toBeGreaterThan(0)
yield* DatabaseMigration.applyOnly(db, migrations.slice(0, split))
yield* db.run(
sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('project', '/repo', 1, 1, '[]')`,
)
yield* db.run(
sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'project', 'session', '/repo', 'Session', '7.4.7', 1, 1)`,
)
yield* db.run(
sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy-message', 'session', 1, 1, '{}')`,
)
yield* db.run(
sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy-part', 'legacy-message', 'session', 1, 1, '{}')`,
)
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('legacy-projection', 'session', 'user', 1, 1, '{}')`,
)
yield* DatabaseMigration.applyOnly(db, migrations.slice(split))
// This is the projection shape written by the CLI bundled with VS Code v7.4.7.
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('message', 'session', 'user', 1, 1, '{}')`,
)
yield* db.run(
sql`UPDATE session_message SET data = '{"text":"updated"}' WHERE id = 'message'`,
)
expect(yield* db.get(sql`SELECT id, seq, data FROM session_message WHERE id = 'message'`)).toEqual({
id: "message",
seq: null,
data: '{"text":"updated"}',
})
expect(yield* db.get(sql`SELECT id FROM session WHERE id = 'session'`)).toEqual({ id: "session" })
expect(yield* db.get(sql`SELECT id FROM message WHERE id = 'legacy-message'`)).toEqual({ id: "legacy-message" })
expect(yield* db.get(sql`SELECT id FROM part WHERE id = 'legacy-part'`)).toEqual({ id: "legacy-part" })
}),
)
})
test("preserves sequenced projections when repairing an already-migrated database", async () => {
await run(
Effect.gen(function* () {
const db = yield* make
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL, FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE)`,
)
yield* db.run(sql`CREATE UNIQUE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`)
yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('sequenced', 'session', 'user', 7, 1, 1, '{}')`,
)
yield* DatabaseMigration.applyOnly(db, [legacyWriterMigration])
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('legacy', 'session', 'user', 2, 2, '{}')`,
)
expect(yield* db.all(sql`SELECT id, seq FROM session_message ORDER BY id`)).toEqual([
{ id: "legacy", seq: null },
{ id: "sequenced", seq: 7 },
])
}),
)
})
test("repairs a WAL database while preserving foreign keys and sequence uniqueness", async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "kilo.db")
await Effect.runPromise(
Effect.gen(function* () {
const db = yield* make
yield* db.run(sql`PRAGMA journal_mode = WAL`)
yield* db.run(sql`PRAGMA foreign_keys = ON`)
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(
sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL, FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE)`,
)
yield* db.run(sql`CREATE UNIQUE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`)
yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('sequenced', 'session', 'user', 7, 1, 1, '{}')`,
)
yield* DatabaseMigration.applyOnly(db, [legacyWriterMigration])
yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('legacy-1', 'session', 'user', 2, 2, '{}'), ('legacy-2', 'session', 'user', 3, 3, '{}')`,
)
expect(yield* db.all(sql`PRAGMA foreign_key_check`)).toEqual([])
expect(
yield* Effect.exit(
db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('duplicate', 'session', 'user', 7, 4, 4, '{}')`,
),
),
).toMatchObject({ _tag: "Failure" })
yield* db.run(sql`DELETE FROM session WHERE id = 'session'`)
expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
}).pipe(Effect.provide(SqliteClient.layer({ filename })), Effect.scoped),
)
})
})