fix(core): guard model-data migration on valid JSON

This commit is contained in:
starptech 2026-06-03 16:50:43 +02:00
commit 54a2dd9235
10 changed files with 77 additions and 95 deletions

View file

@ -2,7 +2,8 @@ ALTER TABLE `part` ADD `data_model` text;
--> statement-breakpoint
UPDATE part
SET data_model = json_remove(data, '$.state.metadata')
WHERE length(CAST(data AS BLOB)) > 65536
WHERE json_valid(data)
AND length(CAST(data AS BLOB)) > 65536
AND json_extract(data, '$.type') = 'tool'
AND json_extract(data, '$.state.status') = 'completed'
AND length(CAST(json_extract(data, '$.state.metadata') AS BLOB)) > 65536;

View file

@ -11,7 +11,8 @@ export default {
yield* tx.run(`
UPDATE part
SET data_model = json_remove(data, '$.state.metadata')
WHERE length(CAST(data AS BLOB)) > 65536
WHERE json_valid(data)
AND length(CAST(data AS BLOB)) > 65536
AND json_extract(data, '$.type') = 'tool'
AND json_extract(data, '$.state.status') = 'completed'
AND length(CAST(json_extract(data, '$.state.metadata') AS BLOB)) > 65536

View file

@ -6,10 +6,14 @@ type V1PartData<Data extends SessionV1.Part = SessionV1.Part> = Data extends Ses
? Omit<Data, "id" | "sessionID" | "messageID">
: never
export type ModelData = Omit<V1PartData<SessionV1.ToolPart>, "state"> & {
state: Omit<SessionV1.ToolStateCompleted, "metadata">
}
export const THRESHOLD = 64 * 1024
// Strip UI-only metadata only when the stored prompt projection benefits.
export function create(data: unknown): V1PartData | null {
export function create(data: unknown): ModelData | null {
if (!data || typeof data !== "object") return null
if (!("type" in data) || data.type !== "tool") return null
if (!("state" in data) || !data.state || typeof data.state !== "object") return null
@ -18,5 +22,5 @@ export function create(data: unknown): V1PartData | null {
const metadata = JSON.stringify(data.state.metadata)
if (!metadata || Buffer.byteLength(metadata) <= THRESHOLD) return null
const { metadata: _, ...state } = data.state
return { ...data, state } as V1PartData
return { ...data, state } as ModelData
}

View file

@ -9,6 +9,7 @@ import type { SessionSchema } from "./schema"
import type { MessageID, PartID, SessionV1 } from "../v1/session"
import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { ModelData } from "./model-data"
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@ -86,7 +87,7 @@ export const PartTable = sqliteTable(
...Timestamps,
data: text({ mode: "json" }).notNull().$type<V1PartData>(),
// Derived prompt projection; data remains canonical.
data_model: text({ mode: "json" }).$type<V1PartData>(),
data_model: text({ mode: "json" }).$type<ModelData>(),
},
(table) => [
index("part_message_id_id_idx").on(table.message_id, table.id),

View file

@ -86,7 +86,8 @@ describe("DatabaseMigration", () => {
const large = JSON.stringify({ type: "tool", state: { status: "completed", metadata: { diff: "x".repeat(70_000) } } })
const unicode = JSON.stringify({ type: "tool", state: { status: "completed", metadata: { diff: "😀".repeat(20_000) } } })
const small = JSON.stringify({ type: "tool", state: { status: "completed", metadata: { diff: "small" } } })
yield* db.run(sql`INSERT INTO part (id, data) VALUES (${"large"}, ${large}), (${"unicode"}, ${unicode}), (${"small"}, ${small})`)
const malformed = "{" + "x".repeat(70_000)
yield* db.run(sql`INSERT INTO part (id, data) VALUES (${"large"}, ${large}), (${"unicode"}, ${unicode}), (${"small"}, ${small}), (${"malformed"}, ${malformed})`)
yield* DatabaseMigration.applyOnly(db, [partModelDataMigration])
@ -98,6 +99,7 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT data_model FROM part WHERE id = ${"unicode"}`)).toEqual({
data_model: JSON.stringify({ type: "tool", state: { status: "completed" } }),
})
expect(yield* db.get(sql`SELECT data_model FROM part WHERE id = ${"malformed"}`)).toEqual({ data_model: null })
}),
)
})