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
416 changes: 414 additions & 2 deletions packages/core/schema.json

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions packages/core/script/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,23 @@ export default {
up(tx) {
return Effect.gen(function* () {
${renderStatements(sql)}
${renderReceiptTriggers()}
})
},
} satisfies Omit<DatabaseMigration.Migration, "id">
`
}

function renderReceiptTriggers() {
return [
"CREATE TRIGGER session_receipt_immutable BEFORE UPDATE ON session_receipt BEGIN SELECT RAISE(ABORT, 'session receipt facts are immutable'); END;",
"CREATE TRIGGER session_receipt_assessment_append_only BEFORE UPDATE ON session_receipt_assessment BEGIN SELECT RAISE(ABORT, 'session receipt assessments are append-only'); END;",
"CREATE TRIGGER session_receipt_operation_state BEFORE UPDATE OF state ON session_receipt_operation WHEN NOT ((OLD.state = 'prepared' AND NEW.state = 'evidence_ready') OR (OLD.state = 'evidence_ready' AND NEW.state = 'committed')) BEGIN SELECT RAISE(ABORT, 'invalid session receipt operation state transition'); END;",
]
.map((statement) => ` yield* tx.run(${JSON.stringify(statement)})`)
.join("\n")
}

function renderStatements(sql: string) {
return sql
.split("--> statement-breakpoint")
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/database/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ const MERGE_TABLES = [
"session",
"session_lineage",
"session_lineage_origin",
"session_receipt_operation",
"session_receipt",
"session_receipt_assessment",
"session_message",
"session_input",
"session_context_epoch",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"

export default {
id: "20260913212936_session-receipt-storage",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_receipt_assessment\` (
\`id\` text PRIMARY KEY,
\`receipt_id\` text NOT NULL,
\`root_id\` text NOT NULL,
\`confidence\` text NOT NULL,
\`net_state\` text NOT NULL,
\`evidence_state\` text NOT NULL,
\`revision\` integer NOT NULL,
\`expires_at\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_receipt_assessment_receipt_id_session_receipt_id_fk\` FOREIGN KEY (\`receipt_id\`) REFERENCES \`session_receipt\`(\`id\`) ON DELETE CASCADE,
CONSTRAINT \`fk_session_receipt_assessment_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_receipt_operation\` (
\`id\` text PRIMARY KEY,
\`root_id\` text NOT NULL,
\`session_id\` text NOT NULL,
\`origin\` text NOT NULL,
\`state\` text NOT NULL,
CONSTRAINT \`fk_session_receipt_operation_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_receipt\` (
\`id\` text PRIMARY KEY,
\`operation_id\` text NOT NULL,
\`root_id\` text NOT NULL,
\`creation_seq\` integer NOT NULL,
\`resource\` text NOT NULL,
\`operation\` text NOT NULL,
\`outcome\` text NOT NULL,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_receipt_operation_id_session_receipt_operation_id_fk\` FOREIGN KEY (\`operation_id\`) REFERENCES \`session_receipt_operation\`(\`id\`) ON DELETE CASCADE,
CONSTRAINT \`fk_session_receipt_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_receipt_assessment_receipt_revision_idx\` ON \`session_receipt_assessment\` (\`receipt_id\`,\`revision\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_receipt_assessment_root_idx\` ON \`session_receipt_assessment\` (\`root_id\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_receipt_operation_root_idx\` ON \`session_receipt_operation\` (\`root_id\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_receipt_root_creation_seq_idx\` ON \`session_receipt\` (\`root_id\`,\`creation_seq\`);`,
)
yield* tx.run(`CREATE INDEX \`session_receipt_operation_idx\` ON \`session_receipt\` (\`operation_id\`);`)
yield* tx.run(`
CREATE TRIGGER \`session_receipt_immutable\`
BEFORE UPDATE ON \`session_receipt\`
BEGIN
SELECT RAISE(ABORT, 'session receipt facts are immutable');
END;
`)
yield* tx.run(`
CREATE TRIGGER \`session_receipt_assessment_append_only\`
BEFORE UPDATE ON \`session_receipt_assessment\`
BEGIN
SELECT RAISE(ABORT, 'session receipt assessments are append-only');
END;
`)
yield* tx.run(`
CREATE TRIGGER \`session_receipt_operation_state\`
BEFORE UPDATE OF \`state\` ON \`session_receipt_operation\`
WHEN NOT (
(OLD.\`state\` = 'prepared' AND NEW.\`state\` = 'evidence_ready')
OR (OLD.\`state\` = 'evidence_ready' AND NEW.\`state\` = 'committed')
)
BEGIN
SELECT RAISE(ABORT, 'invalid session receipt operation state transition');
END;
`)
})
},
} satisfies DatabaseMigration.Migration
61 changes: 61 additions & 0 deletions packages/core/src/database/schema.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,45 @@ export default {
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_receipt_assessment\` (
\`id\` text PRIMARY KEY,
\`receipt_id\` text NOT NULL,
\`root_id\` text NOT NULL,
\`confidence\` text NOT NULL,
\`net_state\` text NOT NULL,
\`evidence_state\` text NOT NULL,
\`revision\` integer NOT NULL,
\`expires_at\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_receipt_assessment_receipt_id_session_receipt_id_fk\` FOREIGN KEY (\`receipt_id\`) REFERENCES \`session_receipt\`(\`id\`) ON DELETE CASCADE,
CONSTRAINT \`fk_session_receipt_assessment_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_receipt_operation\` (
\`id\` text PRIMARY KEY,
\`root_id\` text NOT NULL,
\`session_id\` text NOT NULL,
\`origin\` text NOT NULL,
\`state\` text NOT NULL,
CONSTRAINT \`fk_session_receipt_operation_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_receipt\` (
\`id\` text PRIMARY KEY,
\`operation_id\` text NOT NULL,
\`root_id\` text NOT NULL,
\`creation_seq\` integer NOT NULL,
\`resource\` text NOT NULL,
\`operation\` text NOT NULL,
\`outcome\` text NOT NULL,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_receipt_operation_id_session_receipt_operation_id_fk\` FOREIGN KEY (\`operation_id\`) REFERENCES \`session_receipt_operation\`(\`id\`) ON DELETE CASCADE,
CONSTRAINT \`fk_session_receipt_root_id_session_id_fk\` FOREIGN KEY (\`root_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session\` (
\`id\` text PRIMARY KEY,
Expand Down Expand Up @@ -306,10 +345,32 @@ export default {
`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\`);`)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_receipt_assessment_receipt_revision_idx\` ON \`session_receipt_assessment\` (\`receipt_id\`,\`revision\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_receipt_assessment_root_idx\` ON \`session_receipt_assessment\` (\`root_id\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_receipt_operation_root_idx\` ON \`session_receipt_operation\` (\`root_id\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_receipt_root_creation_seq_idx\` ON \`session_receipt\` (\`root_id\`,\`creation_seq\`);`,
)
yield* tx.run(`CREATE INDEX \`session_receipt_operation_idx\` ON \`session_receipt\` (\`operation_id\`);`)
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`)
yield* tx.run(
"CREATE TRIGGER session_receipt_immutable BEFORE UPDATE ON session_receipt BEGIN SELECT RAISE(ABORT, 'session receipt facts are immutable'); END;",
)
yield* tx.run(
"CREATE TRIGGER session_receipt_assessment_append_only BEFORE UPDATE ON session_receipt_assessment BEGIN SELECT RAISE(ABORT, 'session receipt assessments are append-only'); END;",
)
yield* tx.run(
"CREATE TRIGGER session_receipt_operation_state BEFORE UPDATE OF state ON session_receipt_operation WHEN NOT ((OLD.state = 'prepared' AND NEW.state = 'evidence_ready') OR (OLD.state = 'evidence_ready' AND NEW.state = 'committed')) BEGIN SELECT RAISE(ABORT, 'invalid session receipt operation state transition'); END;",
)
})
},
} satisfies Omit<DatabaseMigration.Migration, "id">
65 changes: 65 additions & 0 deletions packages/core/src/session/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,71 @@ export const SessionLineageOriginTable = sqliteTable(
],
)

/** Immutable root-owned facts for Files Changed operation publication. */
export const SessionReceiptOperationTable = sqliteTable(
"session_receipt_operation",
{
id: text().primaryKey(),
root_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
session_id: text().$type<SessionSchema.ID>().notNull(),
origin: text().notNull(),
state: text().$type<"prepared" | "evidence_ready" | "committed">().notNull(),
},
(table) => [index("session_receipt_operation_root_idx").on(table.root_id)],
)

/** Immutable resource facts. Creation sequence is scoped to the lineage root. */
export const SessionReceiptTable = sqliteTable(
"session_receipt",
{
id: text().primaryKey(),
operation_id: text()
.notNull()
.references(() => SessionReceiptOperationTable.id, { onDelete: "cascade" }),
root_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
creation_seq: integer().notNull(),
resource: text().notNull(),
operation: text().notNull(),
outcome: text().notNull(),
time_created: integer().notNull(),
},
(table) => [
uniqueIndex("session_receipt_root_creation_seq_idx").on(table.root_id, table.creation_seq),
index("session_receipt_operation_idx").on(table.operation_id),
],
)

/** Append-only observations deliberately separate from immutable receipt facts. */
export const SessionReceiptAssessmentTable = sqliteTable(
"session_receipt_assessment",
{
id: text().primaryKey(),
receipt_id: text()
.notNull()
.references(() => SessionReceiptTable.id, { onDelete: "cascade" }),
root_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
confidence: text().notNull(),
net_state: text().notNull(),
evidence_state: text().notNull(),
revision: integer().notNull(),
expires_at: integer(),
time_created: integer().notNull(),
},
(table) => [
uniqueIndex("session_receipt_assessment_receipt_revision_idx").on(table.receipt_id, table.revision),
index("session_receipt_assessment_root_idx").on(table.root_id),
],
)

export const MessageTable = sqliteTable(
"message",
{
Expand Down
45 changes: 45 additions & 0 deletions packages/core/test/database-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
)

const runAtPath = <A, E>(filename: string, effect: Effect.Effect<A, E, SqlClientService>) =>
Effect.runPromise(effect.pipe(Effect.provide(SqliteClient.layer({ filename, disableWAL: true })), Effect.scoped))

const makeDb = EffectDrizzleSqlite.makeWithDefaults()

describe("DatabaseMigration", () => {
Expand Down Expand Up @@ -99,6 +102,48 @@ describe("DatabaseMigration", () => {
)
})

test("preserves committed receipt groups, immutable receipts, and assessment history across restart", async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "receipts.sqlite")
await runAtPath(
filename,
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
yield* db.run(
sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('project', '/project', 1, 1, '[]')`,
)
yield* db.run(
sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('root', 'project', 'root', '/project', 'Root', 'test', 1, 1)`,
)
yield* db.run(
sql`INSERT INTO session_receipt_operation (id, root_id, session_id, origin, state) VALUES ('operation', 'root', 'root', 'agent', 'committed')`,
)
yield* db.run(
sql`INSERT INTO session_receipt (id, operation_id, root_id, creation_seq, resource, operation, outcome, time_created) VALUES ('receipt', 'operation', 'root', 1, 'file:///root', 'write', 'applied', 2)`,
)
yield* db.run(
sql`INSERT INTO session_receipt_assessment (id, receipt_id, root_id, confidence, net_state, evidence_state, revision, expires_at, time_created) VALUES ('assessment', 'receipt', 'root', 'verified', 'changed', 'available', 1, 3, 3)`,
)
}),
)
await runAtPath(
filename,
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
expect(
yield* db.get(sql`
SELECT operation.state, receipt.creation_seq AS sequence, assessment.revision, assessment.expires_at AS expiresAt
FROM session_receipt_operation operation
JOIN session_receipt receipt ON receipt.operation_id = operation.id
JOIN session_receipt_assessment assessment ON assessment.receipt_id = receipt.id
`),
).toEqual({ state: "committed", sequence: 1, revision: 1, expiresAt: 3 })
}),
)
})

test("rejects a non-empty database without a session table", async () => {
await expect(
run(
Expand Down
Loading
Loading