Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .context/sync-and-readiness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Sync & Readiness

## How Sync Works

1. **Pull from GCP**: `npm run db:pull:dump` exports schema + data from GCP source of truth into `dumps/schema.sql` and `dumps/data.sql` (gitignored).
2. **Sync to targets**: `npm run db:sync:<target>` destroys the target database and re-applies schema + data from the dumps.
- Supported targets: `local`, `neon`, `supabase`, `all`
- `gcp` is always refused (read-only source)
3. **Safety**: Requires either `DB_SYNC_ALLOW_DESTRUCTIVE=true` env var or interactive `y/N` confirmation.
4. **Verification**: After sync completes, the script prints table count and key row counts (users, attendance).

### Scripts

```bash
npm run db:sync:local # Sync dumps -> local Docker
npm run db:sync:neon # Sync dumps -> Neon
npm run db:sync:supabase # Sync dumps -> Supabase
npm run db:sync:all # Sync dumps -> all three targets sequentially
```

### Prerequisites

- `dumps/schema.sql` and `dumps/data.sql` must exist (run `npm run db:pull:dump` first)
- Target env vars must be set in `.env.local` (`DATABASE_URL_LOCAL`, `DATABASE_URL_NEON`, `DATABASE_URL_SUPABASE`)

## Readiness Check API

### `GET /api/readiness`

Returns an array of platform readiness statuses:

```json
[
{
"platformId": "gcp",
"name": "GCP (Source)",
"tableCount": 43,
"sampleRowCount": 54000,
"ready": true
},
{
"platformId": "neon",
"name": "Neon",
"tableCount": 0,
"sampleRowCount": 0,
"ready": false
}
]
```

- Checks `information_schema.tables` for table count
- Checks `public.users` row count as a sentinel
- `ready = tableCount > 0 && sampleRowCount > 0`
- Each platform check is wrapped in try/catch (unreachable platforms return `ready: false`)

### UI Integration

- Platform pills show an amber warning dot when a platform is not ready
- Warning cards appear below the pill selector for selected platforms that are not ready
- The DataDiff component shows diagnostic hints when one side returns 0 rows
- The Performance Chart shows warning banners when row counts differ

## Troubleshooting

1. **"dumps not found" error**: Run `npm run db:pull:dump` to export from GCP
2. **Env var not set**: Add `DATABASE_URL_<TARGET>` to `.env.local`
3. **Platform shows 0 tables after sync**: Check that `db-migrate.sh` and `db-seed.sh` completed without errors. Run `npm run db:verify:<target>` for details.
4. **Neon/Supabase connection timeout**: Check that IP allowlists include your current IP
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ npm run db:verify:local # Health check local Docker
npm run db:verify:gcp # Health check GCP
npm run db:verify:neon # Health check Neon
npm run db:verify:supabase # Health check Supabase
npm run db:sync:local # Reset + migrate + seed local from dumps
npm run db:sync:neon # Reset + migrate + seed Neon from dumps
npm run db:sync:supabase # Reset + migrate + seed Supabase from dumps
npm run db:sync:all # Sync all targets sequentially
npm run db:generate # Drizzle generate migrations
npm run db:migrate:metadata # Push schema to metadata DB
npm run db:studio # Drizzle Studio GUI
Expand Down Expand Up @@ -55,6 +59,7 @@ npm run db:studio # Drizzle Studio GUI
- `POST /api/compare/schema` β€” {leftId, rightId} -> schema diff
- `POST /api/cron/latency` β€” Collect health snapshots (QStash target)
- `GET /api/analytics/latency` β€” Query latency history + stats
- `GET /api/readiness` β€” Platform readiness (table count, sample rows, ready flag)

## Env Variables

Expand All @@ -80,3 +85,4 @@ npm run db:studio # Drizzle Studio GUI
- `architecture.md` β€” Key patterns, env vars, API routes
- `status.md` β€” Phase progress tracker
- `pax-vault-patterns.md` β€” Patterns borrowed from PAX-VAULT
- `sync-and-readiness.md` β€” Sync scripts + readiness API docs
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
"db:verify:neon": "tsx scripts/db-verify.ts neon",
"db:verify:supabase": "tsx scripts/db-verify.ts supabase",
"db:verify:gcp": "tsx scripts/db-verify.ts gcp",
"db:sync:local": "tsx scripts/db-sync.ts local",
"db:sync:neon": "tsx scripts/db-sync.ts neon",
"db:sync:supabase": "tsx scripts/db-sync.ts supabase",
"db:sync:all": "tsx scripts/db-sync.ts all",
"firebase:env": "bash scripts/firebase-env.sh",
"db:generate": "drizzle-kit generate",
"db:migrate:metadata": "drizzle-kit push",
Expand Down
224 changes: 224 additions & 0 deletions scripts/db-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { Pool } from "pg";
import { resolve } from "path";
import { config } from "dotenv";
import { execSync } from "child_process";
import { createInterface } from "readline";
import { existsSync } from "fs";

// Load .env.local
config({ path: resolve(__dirname, "../.env.local") });

const target = process.argv[2];

if (!target) {
console.error("Usage: db-sync.ts <target>");
console.error("Targets: local, neon, supabase, all");
process.exit(1);
}

if (target === "gcp") {
console.error(
"ERROR: Refusing to sync to GCP β€” it is the read-only source of truth.",
);
console.error("Use `npm run db:pull:dump` to pull data FROM GCP.");
process.exit(1);
}

const validTargets = ["local", "neon", "supabase", "all"];
if (!validTargets.includes(target)) {
console.error(`Unknown target: ${target}. Use: ${validTargets.join(", ")}`);
process.exit(1);
}

const envKeyMap: Record<string, string> = {
local: "DATABASE_URL_LOCAL",
neon: "DATABASE_URL_NEON",
supabase: "DATABASE_URL_SUPABASE",
};

const dumpDir = resolve(__dirname, "../dumps");
const schemaPath = resolve(dumpDir, "schema.sql");
const dataPath = resolve(dumpDir, "data.sql");

function confirm(message: string): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(`${message} [y/N] `, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase() === "y");
});
});
}

function run(cmd: string) {
console.log(`$ ${cmd}`);
execSync(cmd, { stdio: "inherit", cwd: resolve(__dirname, "..") });
}

async function resetLocal() {
console.log("Tearing down Docker volumes and restarting...");
run("docker compose down -v");
run("docker compose up -d");

console.log("Waiting for Postgres to be ready...");
const maxAttempts = 30;
for (let i = 0; i < maxAttempts; i++) {
try {
execSync("docker compose exec -T postgres pg_isready -U postgres", {
stdio: "pipe",
cwd: resolve(__dirname, ".."),
});
console.log("Postgres is ready.");
break;
} catch {
if (i === maxAttempts - 1) {
console.error("Postgres did not become ready in time.");
process.exit(1);
}
await new Promise((r) => setTimeout(r, 1000));
}
}

run("bash scripts/db-migrate.sh local");
run("bash scripts/db-seed.sh local");
}

async function resetRemote(t: string) {
const envKey = envKeyMap[t];
const connectionString = process.env[envKey];
if (!connectionString) {
console.error(`${envKey} is not set. Add it to .env.local`);
process.exit(1);
}

const pool = new Pool({ connectionString, connectionTimeoutMillis: 10000 });

try {
console.log(`Dropping schemas on ${t}...`);
await pool.query("DROP SCHEMA IF EXISTS public CASCADE");
await pool.query("DROP SCHEMA IF EXISTS codex CASCADE");
await pool.query("DROP SCHEMA IF EXISTS regionpages CASCADE");
await pool.query("CREATE SCHEMA public");
console.log("Schemas dropped and public recreated.");
} finally {
await pool.end();
}

run(`bash scripts/db-migrate.sh ${t}`);
run(`bash scripts/db-seed.sh ${t}`);
}

async function verify(t: string) {
const envKey = envKeyMap[t];
const connectionString = process.env[envKey];
if (!connectionString) {
console.error(`${envKey} is not set β€” skipping verification.`);
return;
}

const pool = new Pool({ connectionString, connectionTimeoutMillis: 10000 });

try {
const tablesResult = await pool.query(`
SELECT COUNT(*) as count
FROM information_schema.tables
WHERE table_schema NOT IN ('information_schema', 'pg_catalog')
AND table_type = 'BASE TABLE'
`);
const tableCount = tablesResult.rows[0].count;

let userCount = "N/A";
try {
const usersResult = await pool.query(
"SELECT COUNT(*) as count FROM public.users",
);
userCount = usersResult.rows[0].count;
} catch {
// users table may not exist
}

let attendanceCount = "N/A";
try {
const attendanceResult = await pool.query(
"SELECT COUNT(*) as count FROM public.attendance",
);
attendanceCount = attendanceResult.rows[0].count;
} catch {
// attendance table may not exist
}

console.log(`\n Verification for ${t}:`);
console.log(` Tables: ${tableCount}`);
console.log(` Users: ${userCount}`);
console.log(` Attendance: ${attendanceCount}`);
} catch (err) {
console.error(
` Verification failed for ${t}:`,
err instanceof Error ? err.message : err,
);
} finally {
await pool.end();
}
}

async function syncTarget(t: string) {
const start = performance.now();
console.log(`\n========== Syncing ${t} ==========\n`);

if (t === "local") {
await resetLocal();
} else {
await resetRemote(t);
}

await verify(t);

const duration = ((performance.now() - start) / 1000).toFixed(1);
console.log(`\n ${t} sync complete in ${duration}s`);
}

async function main() {
// Check dumps exist
if (!existsSync(schemaPath)) {
console.error(`ERROR: ${schemaPath} not found.`);
console.error("Run `npm run db:pull:dump` first to pull from GCP.");
process.exit(1);
}
if (!existsSync(dataPath)) {
console.error(`ERROR: ${dataPath} not found.`);
console.error("Run `npm run db:pull:dump` first to pull from GCP.");
process.exit(1);
}

const targets = target === "all" ? ["local", "neon", "supabase"] : [target];

// Verify env vars for all targets
for (const t of targets) {
const envKey = envKeyMap[t];
if (!process.env[envKey]) {
console.error(`${envKey} is not set. Add it to .env.local`);
process.exit(1);
}
}

// Safety check
const allowDestructive = process.env.DB_SYNC_ALLOW_DESTRUCTIVE === "true";

if (!allowDestructive) {
const confirmed = await confirm(
`Sync ${targets.join(", ")}? This will DESTROY all existing data on these targets.`,
);
if (!confirmed) {
console.log("Aborted.");
process.exit(0);
}
}

for (const t of targets) {
await syncTarget(t);
}

console.log("\nAll syncs complete.");
}

main();
Loading