forked from pingdotgg/t3code
-
Notifications
You must be signed in to change notification settings - Fork 0
fix(persistence): move fork-only schema onto its own migration ledger #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| import { assert, it } from "@effect/vitest"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| import { describe, expect, it as vpIt } from "vite-plus/test"; | ||
|
|
||
| import { migrationEntries, runMigrations } from "./Migrations.ts"; | ||
| import { | ||
| forkMigrationEntries, | ||
| realignSharedMigrationLedger, | ||
| runForkMigrations, | ||
| } from "./ForkMigrations.ts"; | ||
| import * as NodeSqliteClient from "./NodeSqliteClient.ts"; | ||
|
|
||
| const UPSTREAM_MAX = Math.max(...migrationEntries.map(([id]) => id)); | ||
|
|
||
| const selectSharedLedger = (sql: SqlClient.SqlClient) => | ||
| sql<{ readonly migration_id: number; readonly name: string }>` | ||
| SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id | ||
| `; | ||
|
|
||
| const selectForkLedger = (sql: SqlClient.SqlClient) => | ||
| sql<{ readonly migration_id: number; readonly name: string }>` | ||
| SELECT migration_id, name FROM trogonstack_fork_migrations ORDER BY migration_id | ||
| `; | ||
|
|
||
| describe("forkMigrationEntries", () => { | ||
| vpIt("never overlaps with the shared migrationEntries names", () => { | ||
| const sharedNames: ReadonlySet<string> = new Set(migrationEntries.map(([, name]) => name)); | ||
| for (const [, name] of forkMigrationEntries) { | ||
| expect(sharedNames.has(name)).toBe(false); | ||
| } | ||
| }); | ||
|
|
||
| vpIt("lists entries with unique, ascending ids", () => { | ||
| const ids = forkMigrationEntries.map(([id]) => id); | ||
| expect(new Set(ids).size).toBe(ids.length); | ||
| expect(ids).toEqual([...ids].sort((a, b) => a - b)); | ||
| }); | ||
| }); | ||
|
|
||
| const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); | ||
|
|
||
| layer("fresh install", (it) => { | ||
| it.effect("runs the fork chain on its own ledger without touching shared numbering", () => | ||
| Effect.gen(function* () { | ||
| const sql = yield* SqlClient.SqlClient; | ||
|
|
||
| yield* realignSharedMigrationLedger(); | ||
| yield* runMigrations(); | ||
| yield* runForkMigrations(); | ||
|
|
||
| const columns = yield* sql<{ readonly name: string }>` | ||
| PRAGMA table_info(projection_threads) | ||
| `; | ||
| assert.ok(columns.some((column) => column.name === "parent_thread_id")); | ||
|
|
||
| const forkLedger = yield* selectForkLedger(sql); | ||
| assert.deepStrictEqual(forkLedger, [{ migration_id: 1, name: "ProjectionThreadParent" }]); | ||
|
|
||
| const sharedLedger = yield* selectSharedLedger(sql); | ||
| assert.strictEqual(sharedLedger[sharedLedger.length - 1]?.migration_id, UPSTREAM_MAX); | ||
| assert.ok(!sharedLedger.some((row) => row.name === "ProjectionThreadParent")); | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| layer("legacy full install", (it) => { | ||
| it.effect("realigns a ledger that ran the old fork chain through upstream 35 (legacy 36)", () => | ||
| Effect.gen(function* () { | ||
| const sql = yield* SqlClient.SqlClient; | ||
|
|
||
| yield* runMigrations(); | ||
|
|
||
| yield* sql` | ||
| ALTER TABLE projection_threads | ||
| ADD COLUMN parent_thread_id TEXT | ||
| `; | ||
| yield* sql` | ||
| CREATE INDEX IF NOT EXISTS idx_projection_threads_parent | ||
| ON projection_threads(parent_thread_id) | ||
| `; | ||
|
|
||
| yield* sql`UPDATE effect_sql_migrations SET migration_id = 36 WHERE migration_id = 35`; | ||
| yield* sql`UPDATE effect_sql_migrations SET migration_id = 35 WHERE migration_id = 34`; | ||
| yield* sql`UPDATE effect_sql_migrations SET migration_id = 34 WHERE migration_id = 33`; | ||
| yield* sql` | ||
| INSERT INTO effect_sql_migrations (migration_id, name, created_at) | ||
| VALUES (33, 'ProjectionThreadParent', CURRENT_TIMESTAMP) | ||
| `; | ||
|
|
||
| yield* realignSharedMigrationLedger(); | ||
| yield* runMigrations(); | ||
| yield* runForkMigrations(); | ||
|
|
||
| const sharedLedger = yield* selectSharedLedger(sql); | ||
| assert.deepStrictEqual( | ||
| sharedLedger.map((row) => row.migration_id), | ||
| Array.from({ length: UPSTREAM_MAX }, (_, index) => index + 1), | ||
| ); | ||
| assert.deepStrictEqual( | ||
| sharedLedger.find((row) => row.migration_id === 33)?.name, | ||
| "ProjectionThreadsSettled", | ||
| ); | ||
| assert.deepStrictEqual( | ||
| sharedLedger.find((row) => row.migration_id === 34)?.name, | ||
| "ProjectionThreadsSnoozed", | ||
| ); | ||
| assert.deepStrictEqual( | ||
| sharedLedger.find((row) => row.migration_id === 35)?.name, | ||
| "ProjectionThreadTitleRegeneration", | ||
| ); | ||
| assert.ok(!sharedLedger.some((row) => row.name === "ProjectionThreadParent")); | ||
|
|
||
| const forkLedger = yield* selectForkLedger(sql); | ||
| assert.deepStrictEqual(forkLedger, [{ migration_id: 1, name: "ProjectionThreadParent" }]); | ||
|
|
||
| const columns = yield* sql<{ readonly name: string }>` | ||
| PRAGMA table_info(projection_threads) | ||
| `; | ||
| assert.ok(columns.some((column) => column.name === "parent_thread_id")); | ||
|
|
||
| const ledgerBeforeSecondRealign = yield* selectSharedLedger(sql); | ||
| yield* realignSharedMigrationLedger(); | ||
| const ledgerAfterSecondRealign = yield* selectSharedLedger(sql); | ||
| assert.deepStrictEqual(ledgerAfterSecondRealign, ledgerBeforeSecondRealign); | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| layer("legacy mid-history install", (it) => { | ||
| it.effect("realigns a ledger that stopped right after the old fork migration 33", () => | ||
| Effect.gen(function* () { | ||
| const sql = yield* SqlClient.SqlClient; | ||
|
|
||
| yield* runMigrations({ toMigrationInclusive: 32 }); | ||
|
|
||
| yield* sql` | ||
| ALTER TABLE projection_threads | ||
| ADD COLUMN parent_thread_id TEXT | ||
| `; | ||
| yield* sql` | ||
| CREATE INDEX IF NOT EXISTS idx_projection_threads_parent | ||
| ON projection_threads(parent_thread_id) | ||
| `; | ||
| yield* sql` | ||
| INSERT INTO effect_sql_migrations (migration_id, name, created_at) | ||
| VALUES (33, 'ProjectionThreadParent', CURRENT_TIMESTAMP) | ||
| `; | ||
|
|
||
| yield* realignSharedMigrationLedger(); | ||
| yield* runMigrations(); | ||
| yield* runForkMigrations(); | ||
|
|
||
| const sharedLedger = yield* selectSharedLedger(sql); | ||
| assert.strictEqual(sharedLedger[sharedLedger.length - 1]?.migration_id, UPSTREAM_MAX); | ||
|
|
||
| const columns = yield* sql<{ readonly name: string }>` | ||
| PRAGMA table_info(projection_threads) | ||
| `; | ||
| assert.ok(columns.some((column) => column.name === "title_regeneration_request_id")); | ||
|
|
||
| const forkLedger = yield* selectForkLedger(sql); | ||
| assert.deepStrictEqual(forkLedger, [{ migration_id: 1, name: "ProjectionThreadParent" }]); | ||
| }), | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| /** | ||
| * ForkMigrationsLive - fork-only migration runner with its own ledger | ||
| * | ||
| * Fork-only schema (TrogonStack additions that do not exist upstream) lives | ||
| * in this second migration chain, tracked in its own migrations table | ||
| * (`trogonstack_fork_migrations`) instead of the shared `effect_sql_migrations` | ||
| * ledger. This keeps `Migrations.ts` and every file under `./Migrations/` | ||
| * byte-identical to upstream (pingdotgg/t3code) forever, since the shared | ||
| * chain never has to make room for fork ids. A fork migration must never be | ||
| * added to `Migrations.ts` - new fork-only schema belongs here, under | ||
| * `./Migrations/fork/`, numbered independently of the shared chain. | ||
| * | ||
| * The fork once shipped its first migration (ProjectionThreadParent) as id 33 | ||
| * in the shared chain, before this second chain existed, which permanently | ||
| * offset every upstream id after it. `realignSharedMigrationLedger` repairs | ||
| * that on installs that ran the old shared-chain history, moving the shared | ||
| * ledger back to upstream's numbering before the shared migrator runs. | ||
| */ | ||
|
|
||
| import * as Migrator from "effect/unstable/sql/Migrator"; | ||
| import * as Layer from "effect/Layer"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
|
|
||
| import Migration0001 from "./Migrations/fork/001_ProjectionThreadParent.ts"; | ||
|
|
||
| export const FORK_MIGRATIONS_TABLE = "trogonstack_fork_migrations"; | ||
|
|
||
| export const forkMigrationEntries = [[1, "ProjectionThreadParent", Migration0001]] as const; | ||
|
|
||
| export const makeForkMigrationLoader = (throughId?: number) => | ||
| Migrator.fromRecord( | ||
| Object.fromEntries( | ||
| forkMigrationEntries | ||
| .filter(([id]) => throughId === undefined || id <= throughId) | ||
| .map(([id, name, migration]) => [`${id}_${name}`, migration]), | ||
| ), | ||
| ); | ||
|
|
||
| const run = Migrator.make({}); | ||
|
|
||
| export interface RunForkMigrationsOptions { | ||
| readonly toMigrationInclusive?: number | undefined; | ||
| } | ||
|
|
||
| /** | ||
| * Run all pending fork-only migrations against the fork's own ledger table. | ||
| * | ||
| * @returns Effect containing array of executed migrations | ||
| */ | ||
| export const runForkMigrations = Effect.fn("runForkMigrations")(function* ({ | ||
| toMigrationInclusive, | ||
| }: RunForkMigrationsOptions = {}) { | ||
| const executedMigrations = yield* run({ | ||
| loader: makeForkMigrationLoader(toMigrationInclusive), | ||
| table: FORK_MIGRATIONS_TABLE, | ||
| }); | ||
| const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`); | ||
| yield* migrations.length === 0 | ||
| ? Effect.logDebug("Fork database schema is current") | ||
| : Effect.log("Fork migrations ran successfully").pipe(Effect.annotateLogs({ migrations })); | ||
| return executedMigrations; | ||
| }); | ||
|
|
||
| /** | ||
| * Realign the shared `effect_sql_migrations` ledger for installs that ran | ||
| * the fork's old migration chain, where ProjectionThreadParent shipped as | ||
| * shared id 33 and pushed upstream's 33/34/35 to 34/35/36. | ||
| * | ||
| * No-ops for fresh installs (no shared ledger table yet) and for installs | ||
| * already realigned (no id-33 ProjectionThreadParent row left to move). | ||
| * | ||
| * TODO: delete this function, its call in Layers/Sqlite.ts, and the legacy | ||
| * scenarios in ForkMigrations.test.ts once every install that ran the old | ||
| * shared-chain fork history has started up with it at least once. Fresh | ||
| * installs never need it. | ||
| */ | ||
| export const realignSharedMigrationLedger = Effect.fn("realignSharedMigrationLedger")(function* () { | ||
| const sql = yield* SqlClient.SqlClient; | ||
|
|
||
| const tables = yield* sql<{ readonly name: string }>` | ||
| SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_sql_migrations' | ||
| `; | ||
| if (tables.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| const legacyRows = yield* sql<{ readonly migration_id: number; readonly name: string }>` | ||
| SELECT migration_id, name FROM effect_sql_migrations | ||
| WHERE migration_id = 33 AND name = 'ProjectionThreadParent' | ||
| `; | ||
| if (legacyRows.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| yield* sql.withTransaction( | ||
| Effect.gen(function* () { | ||
| yield* sql` | ||
| DELETE FROM effect_sql_migrations WHERE migration_id = 33 AND name = 'ProjectionThreadParent' | ||
| `; | ||
| yield* sql`UPDATE effect_sql_migrations SET migration_id = 33 WHERE migration_id = 34`; | ||
| yield* sql`UPDATE effect_sql_migrations SET migration_id = 34 WHERE migration_id = 35`; | ||
| yield* sql`UPDATE effect_sql_migrations SET migration_id = 35 WHERE migration_id = 36`; | ||
| }), | ||
| ); | ||
|
|
||
| yield* Effect.log("Realigned shared migration ledger to upstream numbering"); | ||
| }); | ||
|
|
||
| /** | ||
| * Layer that runs fork migrations when the layer is built. | ||
| */ | ||
| export const ForkMigrationsLive = Layer.effectDiscard(runForkMigrations()); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
File renamed without changes.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Verify the
vp test runcommand syntax against the CLI convention.Line 45 uses
vp test run apps/server/src/persistence/ForkMigrations.test.ts. The coding guideline states two valid forms:vp testfor the built-in Vite+ test command, andvp run testonly for thetestpackage script.vp test run <path>matches neither form and looks like a merge of the two.Confirm the intended command and correct it. If the intent was to run
vp testagainst a specific file, droprun.📝 Proposed fix
As per coding guidelines, "Use
vp testfor the built-in Vite+ test command; usevp run testonly when specifically requiring thetestpackage script."📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Coding guidelines