Skip to content
Closed
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
79 changes: 79 additions & 0 deletions apps/server/src/persistence/Migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,85 @@ 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("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;
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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}`);
Expand Down
56 changes: 56 additions & 0 deletions apps/server/src/persistence/reconcilePeerLinkPreviewMigration.ts
Original file line number Diff line number Diff line change
@@ -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<readonly [number, string]>) {
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;
}),
);
},
);
Loading