Skip to content

Commit c86fdf6

Browse files
committed
feat(migrate-ts): runner — PgHistoryStore (configurable schema/table; multi-tenant)
1 parent 0e9288c commit c86fdf6

2 files changed

Lines changed: 138 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// src/runner/pg-history-store.ts
2+
import { createHash } from "node:crypto";
3+
import type { Pool, PoolClient } from "pg";
4+
import type { AppliedRow, HistoryStore } from "./history-store.js";
5+
6+
export interface PgHistoryStoreOptions {
7+
/** Schema holding the history table + (by default) the lock scope. Default "public". */
8+
schema?: string;
9+
/** History table name. Default "metaobjects_migrations". */
10+
table?: string;
11+
/** Advisory-lock name. Default `${schema}.${table}`. Override to share/separate lock scope. */
12+
lockName?: string;
13+
}
14+
15+
/**
16+
* Postgres history store. Per-tenant isolation = a per-tenant instance with its own
17+
* {schema, table, lockName} — independent lineage + lock scope in one physical DB.
18+
*/
19+
export class PgHistoryStore implements HistoryStore {
20+
private readonly schema: string;
21+
private readonly table: string;
22+
private readonly lockKey: string; // 64-bit signed, as a decimal string for $1::bigint
23+
private lockClient: PoolClient | null = null;
24+
25+
constructor(private readonly pool: Pool, opts: PgHistoryStoreOptions = {}) {
26+
this.schema = opts.schema ?? "public";
27+
this.table = opts.table ?? "metaobjects_migrations";
28+
this.lockKey = advisoryKey(opts.lockName ?? `${this.schema}.${this.table}`);
29+
}
30+
31+
private q(): string {
32+
return `"${this.schema}"."${this.table}"`;
33+
}
34+
35+
async ensure(): Promise<void> {
36+
await this.pool.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`);
37+
await this.pool.query(
38+
`CREATE TABLE IF NOT EXISTS ${this.q()} (
39+
version TEXT PRIMARY KEY,
40+
name TEXT NOT NULL,
41+
checksum TEXT NOT NULL,
42+
applied_at TIMESTAMPTZ NOT NULL,
43+
execution_ms INTEGER NOT NULL,
44+
success BOOLEAN NOT NULL
45+
)`,
46+
);
47+
}
48+
49+
async applied(): Promise<AppliedRow[]> {
50+
const r = await this.pool.query(
51+
`SELECT version, name, checksum, applied_at, execution_ms, success
52+
FROM ${this.q()} ORDER BY version ASC`,
53+
);
54+
return r.rows.map((row: Record<string, unknown>) => ({
55+
version: String(row["version"]),
56+
name: String(row["name"]),
57+
checksum: String(row["checksum"]),
58+
appliedAt: new Date(row["applied_at"] as string).toISOString(),
59+
executionMs: Number(row["execution_ms"]),
60+
success: Boolean(row["success"]),
61+
}));
62+
}
63+
64+
async record(row: AppliedRow): Promise<void> {
65+
await this.pool.query(
66+
`INSERT INTO ${this.q()} (version, name, checksum, applied_at, execution_ms, success)
67+
VALUES ($1,$2,$3,$4,$5,$6)
68+
ON CONFLICT (version) DO UPDATE SET
69+
name = EXCLUDED.name, checksum = EXCLUDED.checksum,
70+
applied_at = EXCLUDED.applied_at, execution_ms = EXCLUDED.execution_ms,
71+
success = EXCLUDED.success`,
72+
[row.version, row.name, row.checksum, row.appliedAt, row.executionMs, row.success],
73+
);
74+
}
75+
76+
async unrecord(version: string): Promise<void> {
77+
await this.pool.query(`DELETE FROM ${this.q()} WHERE version = $1`, [version]);
78+
}
79+
80+
async acquireLock(): Promise<void> {
81+
this.lockClient = await this.pool.connect();
82+
// Session-level advisory lock (not transaction-level) so CREATE INDEX
83+
// CONCURRENTLY in a migration does not deadlock against the lock.
84+
await this.lockClient.query("SELECT pg_advisory_lock($1::bigint)", [this.lockKey]);
85+
}
86+
87+
async releaseLock(): Promise<void> {
88+
if (!this.lockClient) return;
89+
try {
90+
await this.lockClient.query("SELECT pg_advisory_unlock($1::bigint)", [this.lockKey]);
91+
} finally {
92+
this.lockClient.release();
93+
this.lockClient = null;
94+
}
95+
}
96+
}
97+
98+
/** Stable 64-bit signed advisory-lock key (decimal string) from a lock name. */
99+
function advisoryKey(name: string): string {
100+
const hash = createHash("sha256").update(name).digest();
101+
return hash.readBigInt64BE(0).toString();
102+
}

server/typescript/packages/migrate-ts/test/integration/runner-pg.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { test, expect, describe } from "bun:test";
33
import { newDb } from "pg-mem";
44
import { PgExecutor } from "../../src/runner/pg-executor.js";
5+
import { PgHistoryStore } from "../../src/runner/pg-history-store.js";
56

67
/** A pg-mem-backed Pool-compatible object. */
78
function memPool() {
@@ -33,3 +34,38 @@ describe("PgExecutor (pg-mem)", () => {
3334
await pool.end();
3435
});
3536
});
37+
38+
describe("PgHistoryStore tracking (pg-mem)", () => {
39+
function memPool2() {
40+
const db = newDb();
41+
const { Pool } = db.adapters.createPg();
42+
return new Pool();
43+
}
44+
const sample = (v: string, success = true) => ({
45+
version: v, name: "m", checksum: "c", appliedAt: "2026-01-01T00:00:00.000Z", executionMs: 5, success,
46+
});
47+
48+
test("ensure creates the table; record/applied/unrecord round-trip", async () => {
49+
const pool = memPool2();
50+
const store = new PgHistoryStore(pool, { schema: "public", table: "mo_migrations" });
51+
await store.ensure();
52+
await store.record(sample("20260102000000"));
53+
await store.record(sample("20260101000000"));
54+
expect((await store.applied()).map((r) => r.version)).toEqual(["20260101000000", "20260102000000"]);
55+
await store.unrecord("20260101000000");
56+
expect((await store.applied()).map((r) => r.version)).toEqual(["20260102000000"]);
57+
await pool.end();
58+
});
59+
60+
test("two stores with different table names are independent (multi-tenant)", async () => {
61+
const pool = memPool2();
62+
const a = new PgHistoryStore(pool, { schema: "public", table: "tenant_a_migrations" });
63+
const b = new PgHistoryStore(pool, { schema: "public", table: "tenant_b_migrations" });
64+
await a.ensure();
65+
await b.ensure();
66+
await a.record(sample("20260101000000"));
67+
expect((await a.applied()).map((r) => r.version)).toEqual(["20260101000000"]);
68+
expect(await b.applied()).toEqual([]); // independent lineage
69+
await pool.end();
70+
});
71+
});

0 commit comments

Comments
 (0)