Skip to content

Commit d1fa530

Browse files
committed
fix(migrations): Preserve PGlite progress on failure
Serialize PGlite migration runs outside database transactions so data migration checkpoints and failure state survive an interrupted run.
1 parent 5f6e892 commit d1fa530

3 files changed

Lines changed: 67 additions & 1 deletion

File tree

packages/junior-testing/src/pglite.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@ export interface LocalPgliteFixture<TDatabase> {
2121
): Promise<T[]>;
2222
transaction<T>(callback: () => Promise<T>): Promise<T>;
2323
withLock<T>(lockName: string, callback: () => Promise<T>): Promise<T>;
24+
withMigrationLock<T>(
25+
lockName: string,
26+
callback: () => Promise<T>,
27+
): Promise<T>;
2428
close(): Promise<void>;
2529
}
2630

2731
class LocalPgliteExecutor<TDatabase> implements LocalPgliteFixture<TDatabase> {
2832
private activeTransaction: Transaction | undefined;
33+
private readonly migrationLocks = new Map<string, Promise<void>>();
2934

3035
constructor(
3136
readonly client: PGlite,
@@ -85,6 +90,30 @@ class LocalPgliteExecutor<TDatabase> implements LocalPgliteFixture<TDatabase> {
8590
});
8691
}
8792

93+
async withMigrationLock<T>(
94+
lockName: string,
95+
callback: () => Promise<T>,
96+
): Promise<T> {
97+
if (!lockName) {
98+
throw new Error("Migration lock name is required");
99+
}
100+
const previous = this.migrationLocks.get(lockName) ?? Promise.resolve();
101+
let release!: () => void;
102+
const current = new Promise<void>((resolve) => {
103+
release = resolve;
104+
});
105+
this.migrationLocks.set(lockName, current);
106+
await previous;
107+
try {
108+
return await callback();
109+
} finally {
110+
release();
111+
if (this.migrationLocks.get(lockName) === current) {
112+
this.migrationLocks.delete(lockName);
113+
}
114+
}
115+
}
116+
88117
close(): Promise<void> {
89118
return this.client.close();
90119
}

packages/junior/tests/fixtures/sql.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export async function createLocalJuniorSqlFixture(): Promise<LocalJuniorSqlFixtu
4747
transaction: (callback) => fixture.transaction(callback),
4848
withLock: (lockName, callback) => fixture.withLock(lockName, callback),
4949
withMigrationLock: (migrationTable, callback) =>
50-
fixture.withLock(`junior:migrate:${migrationTable}`, callback),
50+
fixture.withMigrationLock(`junior:migrate:${migrationTable}`, callback),
5151
};
5252

5353
return {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import {
3+
createLocalPgliteFixture,
4+
type LocalPgliteFixture,
5+
} from "@sentry/junior-testing/pglite";
6+
7+
let fixture: LocalPgliteFixture<unknown> | undefined;
8+
9+
afterEach(async () => {
10+
await fixture?.close();
11+
fixture = undefined;
12+
});
13+
14+
describe("PGlite migration lock", () => {
15+
it("preserves writes when a migration callback fails", async () => {
16+
fixture = await createLocalPgliteFixture<unknown>({});
17+
await fixture.execute(`
18+
CREATE TABLE migration_progress (
19+
cursor INTEGER NOT NULL
20+
)
21+
`);
22+
23+
await expect(
24+
fixture.withMigrationLock("test-journal", async () => {
25+
await fixture!.execute(
26+
"INSERT INTO migration_progress (cursor) VALUES ($1)",
27+
[1],
28+
);
29+
throw new Error("migration interrupted");
30+
}),
31+
).rejects.toThrow("migration interrupted");
32+
33+
await expect(
34+
fixture.query("SELECT cursor FROM migration_progress"),
35+
).resolves.toEqual([{ cursor: 1 }]);
36+
});
37+
});

0 commit comments

Comments
 (0)