Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
202 lines
6.2 KiB
TypeScript
202 lines
6.2 KiB
TypeScript
/* eslint-disable no-console */
|
|
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
import { createServer } from 'http'
|
|
import { Kysely, PostgresDialect, sql } from 'kysely'
|
|
import pg from 'pg'
|
|
import { hasTransactionBlock } from './migrationSql'
|
|
|
|
const postgresConnectionString =
|
|
process.env.BOTCOM_POSTGRES_POOLED_CONNECTION_STRING ||
|
|
'postgresql://user:password@127.0.0.1:6543/postgres'
|
|
console.log('Using connection string:', postgresConnectionString)
|
|
|
|
const migrationsPath = `./migrations`
|
|
if (!existsSync(migrationsPath)) {
|
|
throw new Error(`Migrations path not found: ${migrationsPath}`)
|
|
}
|
|
|
|
const init = `
|
|
CREATE SCHEMA IF NOT EXISTS migrations;
|
|
|
|
CREATE TABLE IF NOT EXISTS migrations.applied_migrations (
|
|
filename VARCHAR PRIMARY KEY,
|
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
`
|
|
|
|
/**
|
|
INSERT INTO migrations.applied_migrations (filename) VALUES
|
|
('000_seed.sql'),
|
|
('001_replicator_boot.sql'),
|
|
('002_add_user_id.sql'),
|
|
('003_make_published_slug_unique.sql'),
|
|
('004_guest_column_on_file_state.sql'),
|
|
('005_update_file_trigger.sql'),
|
|
('006_add_file_soft_delete.sql'),
|
|
('007_update_file_owner_details.sql')
|
|
ON CONFLICT DO NOTHING;
|
|
*/
|
|
|
|
// Tell Zero about schema changes so it updates its replica in place instead of a
|
|
// full reset (Supabase doesn't fire event triggers for ALTER PUBLICATION). Guarded
|
|
// because migrations run before Zero boots, so zero_0 may not exist yet on a fresh
|
|
// database. to_regprocedure resolves the no-arg overload (to_regproc returns NULL
|
|
// when the name is ambiguous, silently skipping the call).
|
|
// https://zero.rocicorp.dev/docs/connecting-to-postgres#schema-change-hooks
|
|
const notifyZeroOfSchemaChange = `
|
|
DO $$
|
|
BEGIN
|
|
IF to_regprocedure('zero_0.update_schemas()') IS NOT NULL THEN
|
|
PERFORM zero_0.update_schemas();
|
|
END IF;
|
|
END $$;
|
|
`
|
|
|
|
const shouldSignalSuccess = process.argv.includes('--signal-success')
|
|
const dryRun = process.argv.includes('--dry-run')
|
|
|
|
const DRY_RUN_ROLLBACK = new Error('dry-run-rollback')
|
|
|
|
const db = new Kysely({
|
|
dialect: new PostgresDialect({
|
|
pool: new pg.Pool({
|
|
connectionString: postgresConnectionString,
|
|
application_name: 'migrate',
|
|
idleTimeoutMillis: 10_000,
|
|
max: 1,
|
|
}),
|
|
}),
|
|
log: ['error'],
|
|
})
|
|
|
|
async function waitForPostgres() {
|
|
let attempts = 0
|
|
do {
|
|
try {
|
|
await sql`SELECT 1`.execute(db)
|
|
break
|
|
} catch (_e) {
|
|
if (attempts++ > 100) {
|
|
throw new Error('Failed to connect to postgres')
|
|
}
|
|
console.log('Waiting for postgres' + '.'.repeat(attempts))
|
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
}
|
|
// eslint-disable-next-line no-constant-condition
|
|
} while (true)
|
|
await sql.raw(init).execute(db)
|
|
}
|
|
|
|
async function migrate(summary: string[]) {
|
|
await db.transaction().execute(async (tx) => {
|
|
const appliedMigrations = await sql<{
|
|
filename: string
|
|
}>`SELECT filename FROM migrations.applied_migrations`.execute(tx)
|
|
const migrations = readdirSync(migrationsPath).sort()
|
|
if (migrations.length === 0) {
|
|
throw new Error('No migrations found')
|
|
}
|
|
|
|
// check that all applied migrations exist
|
|
for (const appliedMigration of appliedMigrations.rows) {
|
|
if (!migrations.includes(appliedMigration.filename)) {
|
|
throw new Error(`Previously-applied migration ${appliedMigration.filename} not found`)
|
|
}
|
|
}
|
|
|
|
// We hardcode the `zero_0` shard schema. If Zero is ever reconfigured with a
|
|
// different app id or shard, that name changes and our guards would silently
|
|
// stop firing, reverting us to full resets. Fail loudly if it drifts. (No rows
|
|
// means Zero hasn't booted yet, expected on a fresh database.)
|
|
const shardSchemas = await sql<{ nspname: string }>`
|
|
SELECT DISTINCT n.nspname FROM pg_proc p
|
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
WHERE p.proname = 'update_schemas'
|
|
`.execute(tx)
|
|
const schemaNames = shardSchemas.rows.map((r) => r.nspname)
|
|
if (schemaNames.length > 0 && !schemaNames.includes('zero_0')) {
|
|
throw new Error(
|
|
`Expected Zero shard schema "zero_0" but found ${schemaNames.join(', ')}. Update the hardcoded schema name in migrate.ts and add a new migration to enable ddlDetection on the new schema.`
|
|
)
|
|
}
|
|
|
|
// DDL on a replicated table takes ACCESS EXCLUSIVE. Without a lock timeout, one long-running
|
|
// reader makes the deploy hang while every room persist queues behind the waiting DDL.
|
|
// Failing here aborts the deploy before Zero and the sync-worker roll, and a rerun picks up
|
|
// where it left off. SET LOCAL is transaction-scoped, so it holds through a
|
|
// transaction-mode pooler.
|
|
await sql`SET LOCAL lock_timeout = '10s'`.execute(tx)
|
|
|
|
let appliedNewMigration = false
|
|
for (const migration of migrations) {
|
|
if (appliedMigrations.rows.some((m) => m.filename === migration)) {
|
|
summary.push(`🏃 ${migration} already applied`)
|
|
continue
|
|
}
|
|
|
|
try {
|
|
const migrationSql = readFileSync(`${migrationsPath}/${migration}`, 'utf8')
|
|
if (hasTransactionBlock(migrationSql)) {
|
|
throw new Error(
|
|
`Migration ${migration} contains a transaction block. Migrations run in transactions, so you don't need to include them in the migration file.`
|
|
)
|
|
}
|
|
await sql.raw(migrationSql).execute(tx)
|
|
await sql`INSERT INTO migrations.applied_migrations (filename) VALUES (${migration})`.execute(
|
|
tx
|
|
)
|
|
appliedNewMigration = true
|
|
summary.push(`✅ ${migration} applied`)
|
|
} catch (e) {
|
|
summary.push(`❌ ${migration} failed`)
|
|
throw e
|
|
}
|
|
}
|
|
|
|
// Notify Zero once after all DDL has run (see notifyZeroOfSchemaChange).
|
|
if (appliedNewMigration) {
|
|
await sql.raw(notifyZeroOfSchemaChange).execute(tx)
|
|
}
|
|
|
|
if (dryRun) {
|
|
throw DRY_RUN_ROLLBACK
|
|
}
|
|
})
|
|
await db.destroy()
|
|
}
|
|
async function run() {
|
|
try {
|
|
await waitForPostgres()
|
|
} catch (e) {
|
|
console.error(e)
|
|
process.exit(1)
|
|
}
|
|
|
|
const summary: string[] = []
|
|
try {
|
|
await migrate(summary)
|
|
console.log(summary.join('\n'))
|
|
// need to do this to close the db connection
|
|
if (shouldSignalSuccess) {
|
|
const s = createServer((_, res) => {
|
|
res.end('ok')
|
|
})
|
|
s.listen(7654)
|
|
} else {
|
|
process.exit(0)
|
|
}
|
|
} catch (e) {
|
|
if (e === DRY_RUN_ROLLBACK) {
|
|
console.log(summary.join('\n'))
|
|
console.log('🧹 Rolling back dry run...')
|
|
process.exit(0)
|
|
}
|
|
console.error(e)
|
|
console.error(summary.join('\n'))
|
|
console.error('🧹 Rolling back...')
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
run()
|