|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Out-of-band schema migration for ObjectStack Cloud. |
| 5 | + * |
| 6 | + * Why this exists |
| 7 | + * ─────────────── |
| 8 | + * The Cloudflare Containers runtime gives an inbound Worker request |
| 9 | + * roughly 30s of wallclock before the platform tears the DO invocation |
| 10 | + * down. A cold control-plane boot against a fresh Neon DB has to: |
| 11 | + * |
| 12 | + * 1. Open a Postgres connection (1–3s cold). |
| 13 | + * 2. Run `CREATE TABLE IF NOT EXISTS` for every `sys_*` object — |
| 14 | + * one round-trip per table because `driver-sql` does not yet |
| 15 | + * implement `batchSchemaSync` (verified at |
| 16 | + * packages/plugins/driver-sql/src/sql-driver.ts:108). |
| 17 | + * 3. Hydrate `sys_metadata`, then DDL any custom tables that just |
| 18 | + * came in (Phase 3 in `ObjectQLPlugin.start`). |
| 19 | + * |
| 20 | + * Steps 1+2 alone routinely take 30–60s, so the container is killed |
| 21 | + * mid-DDL on every cold request and never reaches `listen(4000)`. The |
| 22 | + * three timeouts we previously bumped (`startupTimeout`, |
| 23 | + * `portReadyTimeoutMS`, `instanceGetTimeoutMS`) are necessary but |
| 24 | + * insufficient — the platform's request-budget is the actual wall. |
| 25 | + * |
| 26 | + * Strategy |
| 27 | + * ──────── |
| 28 | + * Run schema sync ONCE from the deploy machine against the production |
| 29 | + * DB, then ship the container with `OS_SKIP_SCHEMA_SYNC=1` so cold |
| 30 | + * boots only do connection-open + sys_metadata hydration (~sub-second |
| 31 | + * on warm Neon). |
| 32 | + * |
| 33 | + * How it works |
| 34 | + * ──────────── |
| 35 | + * This script just delegates to the existing `objectstack serve` |
| 36 | + * machinery (which already knows how to load `dist/objectstack.config.js`, |
| 37 | + * register every plugin, and bootstrap the kernel) but with two env |
| 38 | + * overrides: |
| 39 | + * |
| 40 | + * • `OS_SKIP_SCHEMA_SYNC=0` — force `ObjectQLPlugin.start()` to |
| 41 | + * actually run `syncRegisteredSchemas()` even if the operator's |
| 42 | + * shell exports the production default. |
| 43 | + * • `OS_MIGRATE_AND_EXIT=1` — `serve.ts` watches for this and |
| 44 | + * `kernel.shutdown() + process.exit(0)` immediately after |
| 45 | + * `runtime.start()` resolves successfully, instead of holding the |
| 46 | + * port open. |
| 47 | + * |
| 48 | + * The `OS_DATABASE_URL` (and any other secrets) must be present in |
| 49 | + * the script's env when it runs — we do NOT push secrets here, they |
| 50 | + * come from `apps/cloud/.env.cloudflare.secrets` (loaded by the |
| 51 | + * caller, e.g. `deploy-cloudflare.sh`) or from the operator's shell. |
| 52 | + * |
| 53 | + * Usage |
| 54 | + * ───── |
| 55 | + * pnpm --filter @objectstack/cloud build # produces dist/objectstack.config.js |
| 56 | + * pnpm --filter @objectstack/cloud migrate # this script |
| 57 | + * |
| 58 | + * Or, automatically as part of `cf:deploy` (see deploy-cloudflare.sh). |
| 59 | + */ |
| 60 | + |
| 61 | +import { spawn } from 'node:child_process'; |
| 62 | +import path from 'node:path'; |
| 63 | +import { fileURLToPath } from 'node:url'; |
| 64 | +import { existsSync, readFileSync } from 'node:fs'; |
| 65 | + |
| 66 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 67 | +const APP_DIR = path.resolve(__dirname, '..'); |
| 68 | +const CONFIG_PATH = path.join(APP_DIR, 'dist', 'objectstack.config.js'); |
| 69 | +const SECRETS_FILE = path.join(APP_DIR, '.env.cloudflare.secrets'); |
| 70 | + |
| 71 | +// ── 1. Verify the prebuilt config exists ───────────────────────────── |
| 72 | +if (!existsSync(CONFIG_PATH)) { |
| 73 | + console.error(`✗ ${CONFIG_PATH} not found.`); |
| 74 | + console.error(' Run `pnpm --filter @objectstack/cloud build` first.'); |
| 75 | + process.exit(1); |
| 76 | +} |
| 77 | + |
| 78 | +// ── 2. Load .env.cloudflare.secrets if present ─────────────────────── |
| 79 | +// Mirrors the parser in setup-cloudflare-secrets.sh — values may |
| 80 | +// contain `&`, `;`, `$`, etc., so we MUST NOT use `bash source`-style |
| 81 | +// parsing. We strip ONE optional layer of surrounding `'` or `"`. |
| 82 | +function loadEnvFile(file: string): Record<string, string> { |
| 83 | + if (!existsSync(file)) return {}; |
| 84 | + const out: Record<string, string> = {}; |
| 85 | + const text = readFileSync(file, 'utf8'); |
| 86 | + for (const rawLine of text.split(/\r?\n/)) { |
| 87 | + const line = rawLine.trim(); |
| 88 | + if (!line || line.startsWith('#')) continue; |
| 89 | + const m = /^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); |
| 90 | + if (!m) continue; |
| 91 | + const key = m[1]; |
| 92 | + let value = m[2]; |
| 93 | + if ( |
| 94 | + (value.startsWith('"') && value.endsWith('"')) || |
| 95 | + (value.startsWith("'") && value.endsWith("'")) |
| 96 | + ) { |
| 97 | + value = value.slice(1, -1); |
| 98 | + } |
| 99 | + out[key] = value; |
| 100 | + } |
| 101 | + return out; |
| 102 | +} |
| 103 | + |
| 104 | +const fileEnv = loadEnvFile(SECRETS_FILE); |
| 105 | +// Don't clobber values the operator already set in their shell — the |
| 106 | +// shell wins because that's where one-off overrides happen. |
| 107 | +const mergedEnv: NodeJS.ProcessEnv = { ...fileEnv, ...process.env }; |
| 108 | + |
| 109 | +// ── 3. Sanity-check that we have a real DB URL ─────────────────────── |
| 110 | +const dbUrl = mergedEnv.OS_DATABASE_URL || mergedEnv.OS_CONTROL_DATABASE_URL; |
| 111 | +if (!dbUrl) { |
| 112 | + console.error('✗ OS_DATABASE_URL is not set.'); |
| 113 | + console.error(` Set it in ${SECRETS_FILE} or export it in your shell.`); |
| 114 | + process.exit(1); |
| 115 | +} |
| 116 | +if (dbUrl.startsWith('file:') || dbUrl.includes(':memory:')) { |
| 117 | + console.error(`✗ OS_DATABASE_URL points at a local file (${dbUrl}).`); |
| 118 | + console.error(' Migrating local SQLite is pointless — the container ships'); |
| 119 | + console.error(' with a fresh ephemeral filesystem. Point at production Neon/Turso.'); |
| 120 | + process.exit(1); |
| 121 | +} |
| 122 | + |
| 123 | +// ── 4. Force schema sync ON, exit-after-bootstrap ON ───────────────── |
| 124 | +mergedEnv.OS_SKIP_SCHEMA_SYNC = '0'; |
| 125 | +mergedEnv.OS_MIGRATE_AND_EXIT = '1'; |
| 126 | +// Run on a non-conflicting port so we don't fight a `pnpm dev` instance. |
| 127 | +mergedEnv.PORT = mergedEnv.MIGRATE_PORT ?? '4099'; |
| 128 | +// Quiet down the runtime banner — the migration banner is what matters here. |
| 129 | +mergedEnv.OS_DISABLE_CONSOLE = '1'; |
| 130 | + |
| 131 | +const redactedUrl = dbUrl.replace(/:\/\/[^@]+@/, '://***@'); |
| 132 | +console.log('────────────────────────────────────────────────────────────'); |
| 133 | +console.log(' ObjectStack Cloud — out-of-band schema migration'); |
| 134 | +console.log('────────────────────────────────────────────────────────────'); |
| 135 | +console.log(` Config : ${path.relative(process.cwd(), CONFIG_PATH)}`); |
| 136 | +console.log(` Target : ${redactedUrl}`); |
| 137 | +console.log(' Mode : OS_MIGRATE_AND_EXIT=1, OS_SKIP_SCHEMA_SYNC=0'); |
| 138 | +console.log('────────────────────────────────────────────────────────────'); |
| 139 | + |
| 140 | +// ── 5. Delegate to `objectstack serve --prebuilt` ──────────────────── |
| 141 | +// We use the local CLI binary from the workspace so this works inside |
| 142 | +// `pnpm --filter @objectstack/cloud migrate` without a separate npx |
| 143 | +// resolution. `--prebuilt` skips esbuild/bundle-require — the dist file |
| 144 | +// is already pure ESM. |
| 145 | +const cliBin = path.join(APP_DIR, 'node_modules', '.bin', 'objectstack'); |
| 146 | +// `--no-server` skips the Hono HTTP server plugin entirely so we don't |
| 147 | +// have to bind a port at all. Schema sync lives in |
| 148 | +// `ObjectQLPlugin.start()`, which runs regardless. `--no-ui` skips the |
| 149 | +// Studio static asset plugin (irrelevant for migration). |
| 150 | +const args = ['serve', CONFIG_PATH, '--prebuilt', '--no-ui', '--no-server']; |
| 151 | + |
| 152 | +const child = spawn(cliBin, args, { |
| 153 | + cwd: APP_DIR, |
| 154 | + env: mergedEnv, |
| 155 | + stdio: 'inherit', |
| 156 | +}); |
| 157 | + |
| 158 | +child.on('exit', (code, signal) => { |
| 159 | + if (signal) { |
| 160 | + console.error(`✗ migrate killed by signal ${signal}`); |
| 161 | + process.exit(1); |
| 162 | + } |
| 163 | + process.exit(code ?? 0); |
| 164 | +}); |
| 165 | +child.on('error', (err) => { |
| 166 | + console.error(`✗ failed to spawn objectstack CLI: ${err.message}`); |
| 167 | + process.exit(1); |
| 168 | +}); |
0 commit comments