From e06dfa6a9015490699c01143c02ccf1c40e769af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 03:49:12 +0000 Subject: [PATCH 1/2] test(server): reproduce preview 3511 peer-link migration collision Refs pingdotgg/t3code#25289 Co-Authored-By: Claude Opus 5.5 --- .../server/src/persistence/Migrations.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 67f238b7484f5..e01184f37b32c 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -110,6 +110,68 @@ describe("runMigrations", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); + it.effect("upgrades a 0.1.2-preview.3511 database with peer links recorded at 68-70", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 67 }); + // Schema and history written by 068_PeerLinks, 069_PeerLinkFence and + // 070_WaitingLinkRequestIndex in v0.1.2-preview.20261022.3511. + yield* sql` + CREATE TABLE peer_environment_links ( + environment_id TEXT PRIMARY KEY, + secret_name TEXT NOT NULL, + label TEXT NOT NULL, + urls_json TEXT NOT NULL, + access TEXT NOT NULL, + linked_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_reached_at TEXT, + last_error TEXT + ) + `; + yield* sql` + ALTER TABLE peer_environment_links ADD COLUMN fenced INTEGER NOT NULL DEFAULT 0 + `; + yield* sql` + CREATE INDEX orchestration_v2_projection_turn_items_waiting_link_idx + ON orchestration_v2_projection_turn_items(thread_id, updated_at DESC, turn_item_id DESC) + WHERE type = 'link_request' AND status = 'waiting' + `; + yield* sql` + INSERT INTO effect_sql_migrations (migration_id, name) + VALUES (68, 'PeerLinks'), (69, 'PeerLinkFence'), (70, 'WaitingLinkRequestIndex') + `; + yield* sql` + INSERT INTO peer_environment_links + (environment_id, secret_name, label, urls_json, access, linked_at, expires_at, fenced) + VALUES + ('env-vps', 'peer-link:env-vps:1', 'vps', '["https://vps.example"]', 'full-access', + '2026-10-22T00:00:00.000Z', '2026-11-22T00:00:00.000Z', 1) + `; + const peerLinks = sql`SELECT environment_id, secret_name, fenced FROM peer_environment_links`; + const waitingLinkIndex = sql` + SELECT name FROM sqlite_master + WHERE type = 'index' AND name = 'orchestration_v2_projection_turn_items_waiting_link_idx' + `; + + const applied = yield* runMigrations(); + + assert.includeDeepMembers(applied, [ + [68, "ShellStopRequestIndex"], + [69, "ThreadMessageSearch"], + ]); + assert.includeDeepMembers(yield* history, migrationManifest); + assert.deepStrictEqual(yield* peerLinks, [ + { environment_id: "env-vps", secret_name: "peer-link:env-vps:1", fenced: 1 }, + ]); + assert.lengthOf(yield* waitingLinkIndex, 1); + + const historyAfterUpgrade = yield* history; + assert.deepStrictEqual(yield* runMigrations(), []); + assert.deepStrictEqual(yield* history, historyAfterUpgrade); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), + ); + it.effect("still starts after a newer build recorded a later V2 migration", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; From d511961ca4d815066b2a979270a77a1698d72181 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 03:53:16 +0000 Subject: [PATCH 2/2] fix(server): upgrade preview 3511 databases with peer links at 68-70 Previews 3511/3516 recorded PeerLinks, PeerLinkFence and WaitingLinkRequestIndex at ids 68-70, which main uses for ShellStopRequestIndex and ThreadMessageSearch, so startup refused the history. Recognize that exact history, renumber the peer-link rows to 70-72 (the ids 3702 uses) keeping their schema and data, and apply 68/69 in the same transaction. Any other history still hits the guard. Fixes pingdotgg/t3code#25289 Co-Authored-By: Claude Opus 5.5 --- .../server/src/persistence/Migrations.test.ts | 17 ++++++ apps/server/src/persistence/Migrations.ts | 6 ++ .../reconcilePeerLinkPreviewMigration.ts | 56 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 apps/server/src/persistence/reconcilePeerLinkPreviewMigration.ts diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index e01184f37b32c..86a9610c30abe 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -172,6 +172,23 @@ describe("runMigrations", () => { }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); + it.effect("refuses a peer-link preview history it does not know exactly", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 67 }); + yield* sql` + INSERT INTO effect_sql_migrations (migration_id, name) + VALUES (68, 'PeerLinks'), (69, 'PeerLinkFence') + `; + const before = yield* history; + + const exit = yield* Effect.exit(runMigrations()); + + assert.isTrue(Exit.isFailure(exit)); + assert.deepStrictEqual(yield* history, before); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), + ); + it.effect("still starts after a newer build recorded a later V2 migration", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index f85cb14e309b1..6613b814fe3a5 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -1,6 +1,7 @@ import * as Migrator from "effect/sql/Migrator"; import * as Effect from "effect/Effect"; import * as SqlClient from "effect/sql/SqlClient"; +import { reconcilePeerLinkPreviewMigration } from "./reconcilePeerLinkPreviewMigration.ts"; import { reconcileV2PreviewMigration } from "./reconcileV2PreviewMigration.ts"; import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; @@ -204,11 +205,16 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ const includesV2 = toMigrationInclusive === undefined || toMigrationInclusive >= FIRST_V2_MIGRATION_ID; const previewMigrations = includesV2 ? yield* reconcileV2PreviewMigration() : []; + const peerLinkPreviewMigrations = + toMigrationInclusive === undefined || toMigrationInclusive >= 69 + ? yield* reconcilePeerLinkPreviewMigration(migrationManifest) + : []; if (includesV2) { yield* assertV2MigrationsCanRun(toMigrationInclusive); } const executedMigrations = [ ...previewMigrations, + ...peerLinkPreviewMigrations, ...(yield* run({ loader: makeMigrationLoader(toMigrationInclusive) })), ]; const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`); diff --git a/apps/server/src/persistence/reconcilePeerLinkPreviewMigration.ts b/apps/server/src/persistence/reconcilePeerLinkPreviewMigration.ts new file mode 100644 index 0000000000000..2b38069b8118f --- /dev/null +++ b/apps/server/src/persistence/reconcilePeerLinkPreviewMigration.ts @@ -0,0 +1,56 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +import ShellStopRequestIndex from "./Migrations/068_ShellStopRequestIndex.ts"; +import ThreadMessageSearch from "./Migrations/069_ThreadMessageSearch.ts"; + +const PREVIEW_PEER_LINK_HISTORY = [ + [68, "PeerLinks"], + [69, "PeerLinkFence"], + [70, "WaitingLinkRequestIndex"], +] as const; + +// Previews 3511/3516 recorded peer links at 68-70; keep their schema at 3702's ids 70-72. +export const reconcilePeerLinkPreviewMigration = Effect.fn("reconcilePeerLinkPreviewMigration")( + function* (manifest: ReadonlyArray) { + const sql = yield* SqlClient.SqlClient; + return yield* sql.withTransaction( + Effect.gen(function* () { + const tables = yield* sql` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_sql_migrations' + `; + if (tables.length === 0) return []; + const history = yield* sql<{ readonly migration_id: number; readonly name: string }>` + SELECT migration_id, name FROM effect_sql_migrations + WHERE migration_id >= 55 ORDER BY migration_id + `; + const expected = [ + ...manifest.filter(([id]) => id >= 55 && id < 68), + ...PREVIEW_PEER_LINK_HISTORY, + ]; + const matches = + history.length === expected.length && + history.every( + (row, index) => + row.migration_id === expected[index]![0] && row.name === expected[index]![1], + ); + // Any other history is left untouched for the divergence guard to reject. + if (!matches) return []; + // Move the latest entry first to avoid primary-key collisions. + for (const [id] of PREVIEW_PEER_LINK_HISTORY.toReversed()) { + yield* sql`UPDATE effect_sql_migrations SET migration_id = ${id + 2} WHERE migration_id = ${id}`; + } + yield* ShellStopRequestIndex; + yield* ThreadMessageSearch; + yield* sql` + INSERT INTO effect_sql_migrations (migration_id, name) + VALUES (68, 'ShellStopRequestIndex'), (69, 'ThreadMessageSearch') + `; + return [ + [68, "ShellStopRequestIndex"], + [69, "ThreadMessageSearch"], + ] as const; + }), + ); + }, +);