| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import assert from 'node:assert/strict'; |
| import { DatabaseSync } from 'node:sqlite'; |
| import { describe, it } from 'node:test'; |
| import { |
| SQLITE_RUNTIME_SCHEMA_VERSION, |
| migrateSqliteRuntimeDatabase, |
| } from '../sqlite-runtime-schema.js'; |
| |
| describe('SQLite runtime schema migration', () => { |
| it('uses the locked current version after an optimistic stale read', () => { |
| const executed: string[] = []; |
| let versionReads = 0; |
| const db = { |
| prepare(sql: string) { |
| assert.equal(sql, 'PRAGMA user_version'); |
| return { |
| get() { |
| versionReads += 1; |
| return { |
| user_version: versionReads === 1 ? 4 : SQLITE_RUNTIME_SCHEMA_VERSION, |
| }; |
| }, |
| }; |
| }, |
| exec(sql: string) { |
| executed.push(sql); |
| }, |
| } as unknown as DatabaseSync; |
| |
| migrateSqliteRuntimeDatabase(db); |
| |
| assert.equal(versionReads, 2); |
| assert.deepEqual(executed, ['BEGIN IMMEDIATE', 'COMMIT']); |
| assert.equal( |
| executed.some((sql) => sql.includes('runtime_capabilities')), |
| false, |
| ); |
| }); |
| |
| it('re-reads user_version under the write lock before applying migrations', () => { |
| const real = new DatabaseSync(':memory:'); |
| let migrationLocked = false; |
| let lockedVersionRead = false; |
| const db = new Proxy(real, { |
| get(target, property) { |
| if (property === 'exec') { |
| return (sql: string) => { |
| const statement = sql.trim().toUpperCase(); |
| if (statement === 'BEGIN IMMEDIATE') migrationLocked = true; |
| if (statement.includes('CREATE TABLE RUNTIME_EVENTS')) { |
| assert.equal( |
| lockedVersionRead, |
| true, |
| 'pending migrations require a fresh user_version read under the write lock', |
| ); |
| } |
| try { |
| return target.exec(sql); |
| } finally { |
| if (statement === 'COMMIT' || statement === 'ROLLBACK') { |
| migrationLocked = false; |
| } |
| } |
| }; |
| } |
| if (property === 'prepare') { |
| return (sql: string) => { |
| if (sql.trim().toUpperCase() === 'PRAGMA USER_VERSION' && migrationLocked) { |
| lockedVersionRead = true; |
| } |
| return target.prepare(sql); |
| }; |
| } |
| const value = Reflect.get(target, property, target) as unknown; |
| return typeof value === 'function' ? value.bind(target) : value; |
| }, |
| }) as DatabaseSync; |
| |
| try { |
| migrateSqliteRuntimeDatabase(db); |
| assert.equal(lockedVersionRead, true); |
| } finally { |
| real.close(); |
| } |
| }); |
| |
| it('preserves v1 continuation claims while admitting the v2 replay projection', () => { |
| const db = new DatabaseSync(':memory:'); |
| try { |
| db.exec('PRAGMA foreign_keys = ON'); |
| db.exec(` |
| CREATE TABLE runtime_events ( |
| event_id TEXT PRIMARY KEY, |
| session_id TEXT NOT NULL, |
| invocation_id TEXT NOT NULL, |
| run_id TEXT NOT NULL, |
| turn_id TEXT NOT NULL, |
| event_seq INTEGER NOT NULL, |
| event_kind TEXT NOT NULL, |
| payload_json TEXT NOT NULL, |
| committed_at INTEGER NOT NULL |
| ); |
| CREATE TABLE runtime_session_event_ordinals ( |
| session_id TEXT NOT NULL, |
| ordinal INTEGER NOT NULL, |
| event_id TEXT NOT NULL UNIQUE, |
| PRIMARY KEY (session_id, ordinal) |
| ); |
| CREATE TABLE runtime_continuation_claims ( |
| claim_id TEXT PRIMARY KEY, |
| source_session_id TEXT NOT NULL, |
| source_invocation_id TEXT NOT NULL, |
| source_run_id TEXT NOT NULL, |
| source_turn_id TEXT NOT NULL, |
| source_event_high_water INTEGER NOT NULL CHECK (source_event_high_water > 0), |
| source_prefix_digest TEXT NOT NULL, |
| boundary_digest TEXT NOT NULL UNIQUE, |
| boundary_json TEXT NOT NULL, |
| provider_projection_version INTEGER NOT NULL CHECK (provider_projection_version = 1), |
| provider_replay_digest TEXT NOT NULL, |
| target_session_id TEXT NOT NULL, |
| target_invocation_id TEXT NOT NULL UNIQUE, |
| target_run_id TEXT NOT NULL UNIQUE, |
| target_turn_id TEXT NOT NULL, |
| target_run_header_json TEXT NOT NULL, |
| claimed_at INTEGER NOT NULL, |
| start_event_id TEXT UNIQUE REFERENCES runtime_events(event_id), |
| start_kind TEXT CHECK (start_kind IS NULL OR start_kind IN ('runtime_admission', 'claim_repair')), |
| protocol_version INTEGER NOT NULL CHECK (protocol_version = 1), |
| UNIQUE (source_session_id, source_run_id, source_event_high_water, source_prefix_digest), |
| UNIQUE (target_session_id, target_turn_id) |
| ); |
| INSERT INTO runtime_continuation_claims VALUES ( |
| 'claim-v1', 'session', 'source-invocation', 'source-run', 'source-turn', 1, |
| 'sha256:source', 'sha256:boundary-v1', '{}', 1, 'sha256:replay-v1', |
| 'session', 'target-invocation-v1', 'target-run-v1', 'target-turn-v1', |
| '{"runId": "target-run-v1", "invocationId": "target-invocation-v1", "sessionId": "session", "turnId": "target-turn-v1", "status": "created", "backendKind": "fake", "llmConnectionSlug": "connection-1", "modelId": "model-1", "cwd": "/workspace", "permissionMode": "ask", "createdAt": 1, "updatedAt": 1}', |
| 1, NULL, NULL, 1 |
| ); |
| PRAGMA user_version = 14; |
| `); |
| |
| migrateSqliteRuntimeDatabase(db); |
| |
| assert.equal( |
| (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, |
| SQLITE_RUNTIME_SCHEMA_VERSION, |
| ); |
| assert.equal( |
| ( |
| db |
| .prepare( |
| "SELECT provider_projection_version AS version FROM runtime_continuation_claims WHERE claim_id = 'claim-v1'", |
| ) |
| .get() as { version: number } |
| ).version, |
| 1, |
| ); |
| assert.equal( |
| JSON.parse( |
| ( |
| db |
| .prepare( |
| "SELECT target_opening_json AS opening FROM runtime_continuation_claims WHERE claim_id = 'claim-v1'", |
| ) |
| .get() as { opening: string } |
| ).opening, |
| ).kind, |
| 'invocation_opened', |
| 'an open claim carries the opening it always implied, not a copy of the Run header', |
| ); |
| db.exec(` |
| INSERT INTO runtime_continuation_claims VALUES ( |
| 'claim-v2', 'session', 'source-invocation', 'source-run', 'source-turn', 2, |
| 'sha256:source-2', 'sha256:boundary-v2', '{}', 2, 'sha256:replay-v2', |
| 'session', 'target-invocation-v2', 'target-run-v2', 'target-turn-v2', '{}', |
| 2, NULL, NULL, 1 |
| ); |
| `); |
| assert.throws(() => |
| db.exec(` |
| UPDATE runtime_continuation_claims |
| SET provider_projection_version = 3 |
| WHERE claim_id = 'claim-v2' |
| `), |
| ); |
| } finally { |
| db.close(); |
| } |
| }); |
| |
| it('backfills the extent of every visible Turn already in the ledger', () => { |
| const db = new DatabaseSync(':memory:'); |
| try { |
| migrateSqliteRuntimeDatabase(db); |
| const insert = db.prepare( |
| 'INSERT INTO runtime_events(event_id, session_id, invocation_id, run_id, turn_id, event_seq, event_kind, payload_json, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', |
| ); |
| const ordinal = db.prepare( |
| 'INSERT INTO runtime_session_event_ordinals(session_id, ordinal, event_id) VALUES (?, ?, ?)', |
| ); |
| const opening = (lineage?: object) => |
| JSON.stringify({ |
| content: { |
| kind: 'invocation_opened', |
| source: { kind: 'fresh' }, |
| ...(lineage ? { lineage } : {}), |
| }, |
| }); |
| const rows = [ |
| ['shown-opened', 'shown', 'invocation_opened', opening()], |
| ['hidden-opened', 'hidden', 'invocation_opened', opening({ parentRunId: 'shown' })], |
| ['hidden-text', 'hidden', 'text', '{}'], |
| ['shown-text', 'shown', 'text', '{}'], |
| ] as const; |
| rows.forEach(([eventId, invocation, kind, payload], index) => { |
| insert.run( |
| eventId, |
| 'session', |
| invocation, |
| invocation, |
| `${invocation}-turn`, |
| index + 1, |
| kind, |
| payload, |
| 1, |
| ); |
| ordinal.run('session', index + 1, eventId); |
| }); |
| db.exec('PRAGMA user_version = 18'); |
| migrateSqliteRuntimeDatabase(db); |
| |
| assert.deepEqual( |
| db |
| .prepare('SELECT turn_id, first_ordinal, last_ordinal FROM runtime_session_turn_extents') |
| .all() |
| .map((row) => ({ ...row })), |
| [{ turn_id: 'shown-turn', first_ordinal: 1, last_ordinal: 4 }], |
| ); |
| } finally { |
| db.close(); |
| } |
| }); |
| |
| it('backfills extents over a ledger holding an undecodable opening', () => { |
| const db = new DatabaseSync(':memory:'); |
| try { |
| migrateSqliteRuntimeDatabase(db); |
| const insert = db.prepare( |
| 'INSERT INTO runtime_events(event_id, session_id, invocation_id, run_id, turn_id, event_seq, event_kind, payload_json, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', |
| ); |
| insert.run( |
| 'broken-opened', |
| 'session', |
| 'broken', |
| 'broken', |
| 'broken-turn', |
| 1, |
| 'invocation_opened', |
| '{', |
| 1, |
| ); |
| insert.run( |
| 'shown-opened', |
| 'session', |
| 'shown', |
| 'shown', |
| 'shown-turn', |
| 1, |
| 'invocation_opened', |
| JSON.stringify({ content: { kind: 'invocation_opened', source: { kind: 'fresh' } } }), |
| 1, |
| ); |
| insert.run('legacy-text', 'session', 'legacy', 'legacy', 'legacy-turn', 1, 'text', '{}', 1); |
| db.prepare( |
| 'INSERT INTO runtime_legacy_invocation_openings(invocation_id, session_id, run_id, turn_id, opened_at, opening_json, anchor_event_id) VALUES (?, ?, ?, ?, ?, ?, ?)', |
| ).run('legacy', 'session', 'legacy', 'legacy-turn', 1, '{', 'legacy-text'); |
| const ordinal = db.prepare( |
| 'INSERT INTO runtime_session_event_ordinals(session_id, ordinal, event_id) VALUES (?, ?, ?)', |
| ); |
| ordinal.run('session', 1, 'broken-opened'); |
| ordinal.run('session', 2, 'shown-opened'); |
| ordinal.run('session', 3, 'legacy-text'); |
| db.exec('PRAGMA user_version = 18'); |
| migrateSqliteRuntimeDatabase(db); |
| |
| assert.equal( |
| (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, |
| SQLITE_RUNTIME_SCHEMA_VERSION, |
| ); |
| assert.deepEqual( |
| db |
| .prepare('SELECT turn_id FROM runtime_session_turn_extents') |
| .all() |
| .map((row) => row.turn_id), |
| ['shown-turn'], |
| ); |
| } finally { |
| db.close(); |
| } |
| }); |
| |
| it('builds the terminal index over a ledger holding an undecodable payload', () => { |
| const db = new DatabaseSync(':memory:'); |
| try { |
| migrateSqliteRuntimeDatabase(db); |
| db.prepare( |
| 'INSERT INTO runtime_events(event_id, session_id, invocation_id, run_id, turn_id, event_seq, event_kind, payload_json, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', |
| ).run('event', 'session', 'invocation', 'run', 'turn', 1, 'text', '{', 1); |
| // A partial index is rebuilt by evaluating its predicate over every row, |
| // so one such row would otherwise fail this migration — and the failure |
| // rolls the version back, leaving the next open to fail the same way. |
| db.exec('DROP INDEX runtime_events_terminal; PRAGMA user_version = 17'); |
| migrateSqliteRuntimeDatabase(db); |
| |
| assert.equal( |
| (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version, |
| SQLITE_RUNTIME_SCHEMA_VERSION, |
| ); |
| assert.ok( |
| db.prepare("SELECT 1 FROM sqlite_master WHERE name = 'runtime_events_terminal'").get(), |
| ); |
| } finally { |
| db.close(); |
| } |
| }); |
| }); |