/** * Boot-time deploy step for the server component. * * Two jobs, both idempotent: * * 1. Apply pending SQL migrations from `migrations/` so the database schema * is up-to-date before the HTTP server starts taking traffic. Migration * failure is FATAL — we'd rather crash than serve traffic against a * half-migrated database. * 2. Tell the sync service which tables to replicate to clients. We do this * from inside the app — not from a deploy pipeline — so a new table * becomes syncable the moment its migration lands. There's no separate * "sync config" file to keep in sync with the schema. Sync rules push * failure is NON-FATAL: the app still boots and serves HTTP, sync just * doesn't pick up schema changes from this boot. The platform retries. * * The whole function is safe to re-run on every process start. Migrations * track their own state in `migrations.applied` (created by 001_init); the * sync rules deploy replaces the existing rule set atomically. */ import postgres from 'postgres'; import { readdirSync, existsSync } from 'fs'; import { join } from 'path'; export async function deploy() { // Use the direct (non-pooled) connection string. Migrations may run DDL // that PgBouncer's transaction pooling can't handle, and the connection is // closed immediately afterwards so we don't need pooling here anyway. const sql = postgres(process.env.DIRECT_DATABASE_URL!); // ─── 1. Migrations ─────────────────────────────────────────────────────── // Convention: numbered `.sql` files in `migrations/`, sorted lexically. // Each file is one transaction. To add a migration: create // `migrations/002_.sql` and ship — it will run on next boot. const migrationsDir = join(import.meta.dir, 'migrations'); if (existsSync(migrationsDir)) { const files = readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); for (const file of files) { const content = await Bun.file(join(migrationsDir, file)).text(); await sql.unsafe(content); console.log(`[deploy] Applied migration: ${file}`); } } // ─── 2. Sync rules ─────────────────────────────────────────────────────── // Introspect every base table in the `public` schema. The sync service is // told to replicate all of them to every authenticated client. // // If you need fine-grained access control (e.g. each user only sees their // own rows), replace the wildcard `SELECT *` below with a query that // references `auth.user_id()` — the sync service evaluates that function // per-connection using the JWT's `sub` claim. Example: // // SELECT * FROM notes WHERE owner_id = auth.user_id() const tables = await sql` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' AND table_name NOT IN ('powersync', 'pg_stat_statements') `; if (tables.length === 0) { console.log('[deploy] No tables found — skipping sync streams'); await sql.end(); return; } // Sync rules are written in YAML and posted to the sync service's admin // API. "edition: 3" is the current rules format. The `auto_subscribe: true` // flag means clients receive these tables without having to explicitly // subscribe — appropriate for a small app with a single rule group. const queries = tables.map(t => ` - SELECT * FROM ${t.table_name}`).join('\n'); const streamsYaml = `config:\n edition: 3\nstreams:\n app_data:\n auto_subscribe: true\n queries:\n${queries}`; // NON-FATAL: if the sync service is unreachable (DNS, network, dead Fly // app, etc.) the app still boots and serves HTTP. Sync just won't pick up // schema changes from this particular boot. Holding the entire app hostage // on a sync push failure means a single dead PowerSync instance makes the // whole product look broken — UI shell included. try { const res = await fetch(`${process.env.APP_SYNC_URL}/api/sync-rules/v1/deploy`, { method: 'POST', headers: { 'Content-Type': 'application/yaml', // The admin token is a shared secret between this app and its sync // service — different from the per-client JWTs minted in routes.ts. 'Authorization': `Bearer ${process.env.APP_SYNC_API_TOKEN}`, }, body: streamsYaml, signal: AbortSignal.timeout(10_000), }); if (!res.ok) { const body = await res.text(); console.warn(`[deploy] Sync streams deploy failed (HTTP ${res.status}): ${body}. Continuing without re-deploying sync rules.`); } else { console.log('[deploy] Sync streams deployed successfully'); } } catch (err) { const message = err instanceof Error ? err.message : String(err); console.warn(`[deploy] Sync streams deploy failed: ${message}. Continuing without re-deploying sync rules.`); } await sql.end(); }