Skip to content

Commit b30d343

Browse files
committed
add migration installer API endpoints for no-CLI platforms
1 parent 92e72cb commit b30d343

File tree

11 files changed

+548
-20
lines changed

11 files changed

+548
-20
lines changed

pkgs/edge-worker/deno.lock

Lines changed: 19 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkgs/edge-worker/project.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,16 @@
134134
"parallel": false
135135
}
136136
},
137+
"db-clean:ensure": {
138+
"executor": "nx:run-commands",
139+
"local": true,
140+
"cache": false,
141+
"options": {
142+
"cwd": "pkgs/edge-worker",
143+
"commands": ["./scripts/ensure-db-clean"],
144+
"parallel": false
145+
}
146+
},
137147
"test:unit": {
138148
"dependsOn": ["^build"],
139149
"executor": "nx:run-commands",
@@ -160,6 +170,19 @@
160170
"parallel": false
161171
}
162172
},
173+
"test:migrations": {
174+
"dependsOn": ["db-clean:ensure", "^build"],
175+
"executor": "nx:run-commands",
176+
"local": true,
177+
"inputs": ["default", "^production"],
178+
"options": {
179+
"cwd": "pkgs/edge-worker",
180+
"commands": [
181+
"deno test --config deno.test.json --allow-all --env=supabase/functions/.env tests/migrations/"
182+
],
183+
"parallel": false
184+
}
185+
},
163186
"serve:functions:e2e": {
164187
"executor": "nx:run-commands",
165188
"dependsOn": ["^build"],
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# Clean up any existing containers
5+
echo "Shutting down existing clean database containers..."
6+
docker compose -f tests/db-clean/compose.yaml down --volumes --remove-orphans
7+
8+
# Start fresh containers
9+
echo "Starting clean database..."
10+
docker compose -f tests/db-clean/compose.yaml up --detach
11+
12+
# Wait for database to be ready
13+
echo "Waiting for database to be available..."
14+
./scripts/wait-for-localhost 5433
15+
16+
# Additional pause to ensure database is fully initialized
17+
echo "Waiting for database initialization..."
18+
sleep 3
19+
20+
echo "Clean database is ready on port 5433!"
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* MigrationRunner - Applies pgflow migrations to a database
3+
*
4+
* This class handles:
5+
* - Creating the pgflow_installer schema and tracking table
6+
* - Listing migration status (pending/applied)
7+
* - Applying pending migrations with advisory locking
8+
*/
9+
10+
import type postgres from 'postgres';
11+
import { MIGRATIONS } from './loader.ts';
12+
import type { MigrationWithStatus, ApplyResult, MigrationApplyResult } from './types.ts';
13+
14+
// Advisory lock key for migration serialization
15+
// Using a fixed key to ensure only one migration runs at a time
16+
const MIGRATION_LOCK_KEY = 0x706766_6c6f77; // 'pgflow' in hex
17+
18+
export class MigrationRunner {
19+
constructor(private sql: postgres.Sql) {}
20+
21+
/**
22+
* Lists all migrations with their current status (pending/applied)
23+
*/
24+
async list(): Promise<MigrationWithStatus[]> {
25+
await this.ensureInstallerSchema();
26+
const appliedSet = await this.getAppliedTimestamps();
27+
28+
return MIGRATIONS.map((m) => ({
29+
timestamp: m.timestamp,
30+
filename: m.filename,
31+
status: appliedSet.has(m.timestamp) ? 'applied' : 'pending',
32+
}));
33+
}
34+
35+
/**
36+
* Applies all pending migrations
37+
* Each migration runs in its own transaction with advisory locking
38+
*/
39+
async up(): Promise<ApplyResult> {
40+
await this.ensureInstallerSchema();
41+
const appliedSet = await this.getAppliedTimestamps();
42+
43+
const results: MigrationApplyResult[] = [];
44+
let applied = 0;
45+
let skipped = 0;
46+
47+
for (const migration of MIGRATIONS) {
48+
if (appliedSet.has(migration.timestamp)) {
49+
results.push({ timestamp: migration.timestamp, status: 'skipped' });
50+
skipped++;
51+
continue;
52+
}
53+
54+
try {
55+
// Run each migration in its own transaction with advisory lock
56+
await this.sql.begin(async (tx) => {
57+
// Acquire advisory lock (released at transaction end)
58+
await tx`SELECT pg_advisory_xact_lock(${MIGRATION_LOCK_KEY})`;
59+
60+
// Double-check migration hasn't been applied (race condition protection)
61+
const existing = await tx`
62+
SELECT 1 FROM pgflow_installer.migrations
63+
WHERE timestamp = ${migration.timestamp}
64+
`;
65+
66+
if (existing.length > 0) {
67+
// Another process applied it while we were waiting for lock
68+
return;
69+
}
70+
71+
// Execute migration SQL
72+
await tx.unsafe(migration.content);
73+
74+
// Record that migration was applied
75+
await tx`
76+
INSERT INTO pgflow_installer.migrations (timestamp)
77+
VALUES (${migration.timestamp})
78+
`;
79+
});
80+
81+
results.push({ timestamp: migration.timestamp, status: 'applied' });
82+
applied++;
83+
} catch (error) {
84+
// Migration failed - return partial results with error
85+
return {
86+
success: false,
87+
applied,
88+
skipped,
89+
total: MIGRATIONS.length,
90+
results,
91+
error: `Failed at ${migration.timestamp}: ${error instanceof Error ? error.message : 'Unknown error'}`,
92+
};
93+
}
94+
}
95+
96+
return {
97+
success: true,
98+
applied,
99+
skipped,
100+
total: MIGRATIONS.length,
101+
results,
102+
};
103+
}
104+
105+
/**
106+
* Ensures the installer schema and tracking table exist
107+
*/
108+
private async ensureInstallerSchema(): Promise<void> {
109+
await this.sql`CREATE SCHEMA IF NOT EXISTS pgflow_installer`;
110+
await this.sql`
111+
CREATE TABLE IF NOT EXISTS pgflow_installer.migrations (
112+
timestamp TEXT PRIMARY KEY,
113+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
114+
)
115+
`;
116+
}
117+
118+
/**
119+
* Gets the set of already-applied migration timestamps
120+
*/
121+
private async getAppliedTimestamps(): Promise<Set<string>> {
122+
const rows = await this.sql<{ timestamp: string }[]>`
123+
SELECT timestamp FROM pgflow_installer.migrations
124+
`;
125+
return new Set(rows.map((r) => r.timestamp));
126+
}
127+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export { MigrationRunner } from './MigrationRunner.ts';
2+
export { MIGRATIONS, MIGRATIONS_BY_TIMESTAMP } from './loader.ts';
3+
export type {
4+
Migration,
5+
MigrationStatus,
6+
MigrationWithStatus,
7+
MigrationApplyResult,
8+
ApplyResult,
9+
} from './types.ts';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Migration Loader - Deno-specific
3+
*
4+
* Uses Deno's sync filesystem APIs at module initialization time to load
5+
* SQL migration files from @pgflow/core package.
6+
*
7+
* IMPORTANT: These sync APIs (readDirSync, readTextFileSync) are ONLY available
8+
* during module initialization (top-level code). They are NOT available in HTTP
9+
* handlers or callbacks. This code runs at import time, which is allowed.
10+
*
11+
* From Supabase docs:
12+
* > "You can safely use the following synchronous Deno APIs during initial script evaluation:
13+
* > - Deno.readDirSync, Deno.readTextFileSync, Deno.readFileSync"
14+
*
15+
* @see https://supabase.com/docs/guides/functions/ephemeral-storage
16+
*/
17+
18+
import type { Migration } from './types.ts';
19+
20+
// Resolve path to @pgflow/core package entry point
21+
const pkgEntryPath = import.meta.resolve('@pgflow/core');
22+
23+
// Handle both development and production paths:
24+
// - Dev/test: pkgEntryPath = .../core/src/index.ts -> migrations at .../core/supabase/migrations/
25+
// - Production: pkgEntryPath = .../dist/index.js -> migrations at .../dist/supabase/migrations/
26+
const isDevPath = pkgEntryPath.includes('/src/index.ts');
27+
const migrationsDir = isDevPath
28+
? new URL('../supabase/migrations/', pkgEntryPath)
29+
: new URL('./supabase/migrations/', pkgEntryPath);
30+
31+
/**
32+
* All migrations loaded from @pgflow/core, sorted by timestamp
33+
*/
34+
export const MIGRATIONS: Migration[] = [];
35+
36+
// Load migrations at module initialization time
37+
for (const entry of Deno.readDirSync(migrationsDir)) {
38+
if (entry.isFile && entry.name.endsWith('.sql') && !entry.name.startsWith('atlas')) {
39+
// Extract 14-digit timestamp from filename (e.g., "20250429164909_...")
40+
const match = entry.name.match(/^(\d{14})_/);
41+
if (match) {
42+
const content = Deno.readTextFileSync(new URL(entry.name, migrationsDir));
43+
MIGRATIONS.push({
44+
timestamp: match[1],
45+
filename: entry.name,
46+
content,
47+
});
48+
}
49+
}
50+
}
51+
52+
// Sort by timestamp (ascending)
53+
MIGRATIONS.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
54+
55+
/**
56+
* Migrations keyed by timestamp for O(1) lookup
57+
*/
58+
export const MIGRATIONS_BY_TIMESTAMP: Record<string, Migration> = Object.fromEntries(
59+
MIGRATIONS.map((m) => [m.timestamp, m])
60+
);

0 commit comments

Comments
 (0)