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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,12 @@ section by keeping the fork's version.
does not extend to browsers or computer use. For changes that aren't
visible in the web app, state concretely how to observe the result
instead (command to run, log to tail, or the mobile test flow).
- Fork-only database migrations live in
`apps/server/src/persistence/ForkMigrations/` with their own IDs (001, 002,
...) and their own ledger table, run by `runAllMigrations` after upstream's.
Never add a fork migration to `Migrations.ts`: the Effect Migrator is a
high-water mark, so a fork ID in upstream's sequence either skips upstream's
next migration or crashes on it at the following sync.
- Upstream syncs are explicit and discretionary, done as a
`sync-upstream-<date>` branch PR'd into fork `main` — never a bare pull of
upstream into `main`.
34 changes: 32 additions & 2 deletions apps/server/scripts/migrate-dev-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import { runMigrations } from "../src/persistence/Migrations.ts";
import { runAllMigrations } from "../src/persistence/ForkMigrations.ts";
import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";
import { runMigrateDevDb } from "./migrate-dev-db.ts";

Expand All @@ -28,7 +28,7 @@ const createFixtureSource = Effect.fn("createMigrateDevDbFixtureSource")(functio
databasePath,
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* runMigrations();
yield* runAllMigrations();
// The real shared db carries this column from a branch build without a
// matching migration; reproduce that drift so the filter is exercised.
yield* sql`ALTER TABLE projection_threads ADD COLUMN monitor_json TEXT`;
Expand Down Expand Up @@ -132,6 +132,36 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
}),
);

it.effect("fails loudly on a fork migration slot collision", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-fork-slot-" });
const destDir = yield* fs.makeTempDirectoryScoped({
prefix: "migrate-dev-db-fork-slot-dest-",
});
const source = yield* createFixtureSource(sourceDir);
yield* withDatabase(
source,
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`UPDATE effect_sql_migrations_fork
SET name = 'SomebodyElsesForkMigration' WHERE migration_id = 1`;
}),
);

const error = yield* runMigrateDevDb(
{ baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
{ sharedHome: sourceDir },
).pipe(Effect.flip);
assert.equal(error._tag, "MigrateDevDbSlotCollisionError");
if (error._tag === "MigrateDevDbSlotCollisionError") {
assert.equal(error.ledger, "effect_sql_migrations_fork");
assert.equal(error.slot, 1);
assert.equal(error.appliedName, "SomebodyElsesForkMigration");
}
}),
);

it.effect("refuses while a dev server holds the destination", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
39 changes: 28 additions & 11 deletions apps/server/scripts/migrate-dev-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ import * as Schema from "effect/Schema";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import { Command, Flag } from "effect/unstable/cli";

import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts";
import {
forkMigrationManifest,
forkMigrationsTable,
runAllMigrations,
} from "../src/persistence/ForkMigrations.ts";
import { migrationManifest } from "../src/persistence/Migrations.ts";
import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";

export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass<MigrateDevDbNotInWorktreeError>()(
Expand Down Expand Up @@ -118,13 +123,14 @@ export class MigrateDevDbDestinationBusyError extends Schema.TaggedErrorClass<Mi
export class MigrateDevDbSlotCollisionError extends Schema.TaggedErrorClass<MigrateDevDbSlotCollisionError>()(
"MigrateDevDbSlotCollisionError",
{
ledger: Schema.String,
slot: Schema.Number,
codeName: Schema.String,
appliedName: Schema.String,
},
) {
override get message(): string {
return `Migration slot collision at ${this.slot}: this checkout registers '${this.codeName}' but the database already applied '${this.appliedName}' in that slot. Renumber the new migration to a free slot.`;
return `Migration slot collision at ${this.ledger} ${this.slot}: this checkout registers '${this.codeName}' but the database already applied '${this.appliedName}' in that slot. Renumber the new migration to a free slot.`;
}
}

Expand Down Expand Up @@ -336,15 +342,22 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
/** Compare this checkout's migration registry against what the cloned
* database recorded: same slot under a different name means the migration
* was skipped, not applied. */
const migrationLedgers = [
["effect_sql_migrations", migrationManifest],
[forkMigrationsTable, forkMigrationManifest],
] as const;

const verifyMigrationSlots = Effect.fn("verifyMigrationSlots")(function* () {
const sql = yield* SqlClient.SqlClient;
const applied = yield* sql<{ migration_id: number; name: string }>`
SELECT migration_id, name FROM effect_sql_migrations`;
const appliedById = new Map(applied.map((row) => [Number(row.migration_id), row.name]));
for (const [slot, codeName] of migrationManifest) {
const appliedName = appliedById.get(slot);
if (appliedName !== undefined && appliedName !== codeName) {
return yield* new MigrateDevDbSlotCollisionError({ slot, codeName, appliedName });
for (const [ledger, manifest] of migrationLedgers) {
const applied = yield* sql<{ migration_id: number; name: string }>`
SELECT migration_id, name FROM ${sql(ledger)}`;
const appliedById = new Map(applied.map((row) => [Number(row.migration_id), row.name]));
for (const [slot, codeName] of manifest) {
const appliedName = appliedById.get(slot);
if (appliedName !== undefined && appliedName !== codeName) {
return yield* new MigrateDevDbSlotCollisionError({ ledger, slot, codeName, appliedName });
}
}
}
});
Expand Down Expand Up @@ -436,7 +449,11 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
const sql = yield* SqlClient.SqlClient;
// Mirror server boot (persistence/Layers/Sqlite.ts).
yield* sql.unsafe("PRAGMA foreign_keys = ON").unprepared;
return yield* runMigrations();
const { upstream, fork } = yield* runAllMigrations();
return [
...upstream.map(([id, name]) => `${id}_${name}`),
...fork.map(([id, name]) => `fork/${id}_${name}`),
];
}).pipe(
Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath })),
wrapPhase("migrate", snapshotPath),
Expand Down Expand Up @@ -495,7 +512,7 @@ export const runMigrateDevDb = Effect.fn("runMigrateDevDb")(function* (
sizeBytes: Number(size),
projects: pruned.projects,
eventCount: pruned.eventCount,
executedMigrations: executedMigrations.map(([id, name]) => `${id}_${name}`),
executedMigrations,
};
});

Expand Down
177 changes: 177 additions & 0 deletions apps/server/src/persistence/ForkMigrations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Migrator from "effect/unstable/sql/Migrator";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import { migrationManifest, runMigrations } from "./Migrations.ts";
import {
forkMigrationEntries,
forkMigrationManifest,
forkMigrationsTable,
runAllMigrations,
} from "./ForkMigrations.ts";
import * as NodeSqliteClient from "./NodeSqliteClient.ts";

const freshLayer = () => it.layer(Layer.fresh(Layer.mergeAll(NodeSqliteClient.layerMemory())));

const upstreamLatestId = migrationManifest.at(-1)![0];
const legacyForkRow = (id: number) => 40 + id;

const readLedger = (table: string) =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
return yield* sql<{ readonly id: number; readonly name: string; readonly createdAt: string }>`
SELECT migration_id AS id, name, created_at AS "createdAt"
FROM ${sql(table)}
ORDER BY migration_id ASC
`;
});

const readIds = (table: string) =>
Effect.map(readLedger(table), (rows) => rows.map((row) => row.id));

// Reproduces a database from before the split: fork migrations applied
// through upstream's ledger as 041..046.
const applyLegacyForkMigrations = Effect.gen(function* () {
yield* runMigrations();
const legacyLoader = Migrator.fromRecord(
Object.fromEntries(
forkMigrationEntries.map(([id, name, migration]) => [
`${legacyForkRow(id)}_${name}`,
migration,
]),
),
);
yield* Migrator.make({})({ loader: legacyLoader });
});

freshLayer()("ForkMigrations on a fresh database", (it) => {
it.effect("runs upstream migrations in their ledger and fork migrations in the fork ledger", () =>
Effect.gen(function* () {
const result = yield* runAllMigrations();

assert.deepEqual(
result.upstream.map(([id]) => id),
migrationManifest.map(([id]) => id),
);
assert.deepEqual(result.fork, forkMigrationManifest);
assert.deepEqual(
yield* readIds("effect_sql_migrations"),
migrationManifest.map(([id]) => id),
);
assert.deepEqual(
yield* readIds(forkMigrationsTable),
forkMigrationManifest.map(([id]) => id),
);

const again = yield* runAllMigrations();
assert.deepEqual(again, { upstream: [], fork: [] });
}),
);
});

freshLayer()("ForkMigrations on a database migrated before the split", (it) => {
it.effect("moves fork rows out of the upstream ledger without re-running them", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* applyLegacyForkMigrations;
yield* sql`
UPDATE effect_sql_migrations
SET created_at = '2026-08-12 03:04:05'
WHERE migration_id > ${upstreamLatestId}
`;
// A projection row that a re-run of the rebuild migrations would wipe.
yield* sql`
INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
VALUES ('projection.threads', 27427, '2026-08-12T03:04:05.000Z')
`;

const result = yield* runAllMigrations();

assert.deepEqual(result, { upstream: [], fork: [] });
assert.deepEqual(
yield* readIds("effect_sql_migrations"),
migrationManifest.map(([id]) => id),
);
assert.deepEqual(
yield* readLedger(forkMigrationsTable),
forkMigrationManifest.map(([id, name]) => ({
id,
name,
createdAt: "2026-08-12 03:04:05",
})),
);
const stateRows = yield* sql<{ readonly lastAppliedSequence: number }>`
SELECT last_applied_sequence AS "lastAppliedSequence" FROM projection_state
`;
assert.deepEqual(stateRows, [{ lastAppliedSequence: 27427 }]);
}),
);
});

freshLayer()(
"ForkMigrations next to an upstream migration that reuses a legacy fork number",
(it) => {
it.effect("adopts only rows whose name matches a fork migration", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* applyLegacyForkMigrations;
const takenId = legacyForkRow(forkMigrationEntries.length);
yield* sql`
UPDATE effect_sql_migrations SET name = 'ApplicationEventSource' WHERE migration_id = ${takenId}
`;

const result = yield* runAllMigrations();

// The renamed row stays put and, since it is above the fork's legacy
// range, the fork migration it displaced re-runs under its own ledger.
assert.deepEqual(
result.fork.map(([id]) => id),
[forkMigrationEntries.length],
);
assert.deepEqual(yield* readIds("effect_sql_migrations"), [
...migrationManifest.map(([id]) => id),
takenId,
]);
assert.deepEqual(
yield* readIds(forkMigrationsTable),
forkMigrationManifest.map(([id]) => id),
);
}),
);
},
);

freshLayer()("ForkMigrations when the fork ledger already holds a different migration", (it) => {
it.effect("refuses to adopt rather than dropping the legacy row", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* applyLegacyForkMigrations;
yield* sql`
CREATE TABLE ${sql(forkMigrationsTable)} (
migration_id integer PRIMARY KEY NOT NULL,
created_at datetime NOT NULL DEFAULT current_timestamp,
name VARCHAR(255) NOT NULL
)
`;
yield* sql`
INSERT INTO ${sql(forkMigrationsTable)} (migration_id, name)
VALUES (${forkMigrationEntries.length}, 'DifferentMigration')
`;

const error = yield* runAllMigrations().pipe(Effect.flip);

assert.equal(error._tag, "MigrationError");
if (error._tag === "MigrationError") {
assert.equal(error.kind, "BadState");
}
// The transaction rolled back: every legacy row is still in place.
assert.deepEqual(yield* readIds("effect_sql_migrations"), [
...migrationManifest.map(([id]) => id),
...forkMigrationManifest.map(([id]) => legacyForkRow(id)),
]);
assert.deepEqual(yield* readIds(forkMigrationsTable), [forkMigrationEntries.length]);
}),
);
});
Loading
Loading