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
3 changes: 3 additions & 0 deletions docs/disaster-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,12 @@ producing a silently partial artifact.
| `repo_sync` | operational | yes | phase markers; self-healing hints |
| `user_sync` | operational | yes | stale `syncing`/`locked` rows are taken over by cron's stale-lock logic |
| `ownership_scopes` | core | yes | ownership scope map — for retained/disconnected repos GitHub may no longer serve the data; irreplaceable |
| `ownership_scope_versions` | core | yes | per-version scope membership — required to interpret active ownership state (exclusion + which scopes belong to active_version) |
| `ownership_contributors` | core | yes | repo-scoped contributor identity rows — same irreplaceability |
| `ownership_snapshots` | core | yes | ownership aggregates — real retained user history, not always reconstructable |
| `ownership_sync_state` | operational | yes | ownership cursors/version markers; losing it means a re-ingest |
| `ownership_history_work` | ephemeral | **no** | crash-safe build counters — pseudonymous work keys; loss means the building version is abandoned and restarted, never active user data |
| `ownership_contributor_build_work` | ephemeral | **no** | build-level identity staging — exists only while a build runs; same discard-and-rebuild recovery |
| `auth_sessions` | ephemeral | **no** | recovery must force re-auth, never resurrect live sids |
| `webhook_deliveries` | ephemeral | **no** | dedup history; post-restore deliveries reprocess idempotently |
| `security_events` | ephemeral | **no** | 30-day operational telemetry |
Expand Down
1,105 changes: 1,105 additions & 0 deletions migrations/013_ownership_checkpointing.sql

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions scripts/backup-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export const CORE_TABLES = [
'ownership_scopes', // ownership scope map — irreplaceable for
// retained/disconnected repos GitHub can no
// longer serve
'ownership_scope_versions', // retained per-version scope membership —
// required to interpret active ownership state
// (exclusion state + which scopes belong to
// active_version); backed up like snapshots
'ownership_contributors',// repo-scoped contributor identity rows
'ownership_snapshots', // ownership aggregates — same irreplaceability
]
Expand All @@ -54,6 +58,13 @@ export const EPHEMERAL_TABLES = [
// reprocess idempotently — safe to start empty
'security_events', // operational telemetry, 30-day retention anyway
'schema_migrations', // rebuilt by the migration runner, not restored
'ownership_history_work', // crash-safe build counters — pseudonymous
// work keys; loss = discard the building
// version via canonical abandon, never
// active user data (architecture §9)
'ownership_contributor_build_work', // build-level identity staging — exists
// only while a building_version exists;
// same discard-and-rebuild recovery
]

// The canonical dump allowlist — the ONLY tables pg_dump may ever be asked
Expand Down
9 changes: 8 additions & 1 deletion scripts/migrate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ const sql = postgres(url, { onnotice: () => {} })

async function run() {
const dir = path.join(root, 'migrations')
const files = (await fs.readdir(dir)).filter((f) => f.endsWith('.sql')).sort()
// MIGRATE_UP_TO bounds the chain at a migration prefix (e.g. '012') —
// used only by the recovery drill's upgrade leg to rehearse an
// old-version -> current-version apply. Unset in normal operation.
const upTo = process.env.MIGRATE_UP_TO
const files = (await fs.readdir(dir))
.filter((f) => f.endsWith('.sql'))
.sort()
.filter((f) => !upTo || f.slice(0, 3) <= upTo)

await sql`
create table if not exists schema_migrations (
Expand Down
765 changes: 765 additions & 0 deletions scripts/ownership-checkpoint-proofs.mjs

Large diffs are not rendered by default.

220 changes: 202 additions & 18 deletions scripts/recovery-drill.mjs

Large diffs are not rendered by default.

44 changes: 26 additions & 18 deletions test/backup-schema.test.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Schema-aware backup resolution — unit tests for
// scripts/backup-scope.mjs resolveBackupTables(). Covers the transition
// window where repository main has migration 012 but a target database has
// window where repository main has migration 013 but a target database has
// not applied it yet: the backup must request only the tables the applied
// schema actually created, and fail closed on any broken/inconsistent
// schema state. The live end-to-end path (real Postgres) is exercised by
// scripts/recovery-drill.mjs, whose target is post-012 and therefore backs
// scripts/recovery-drill.mjs, whose target is post-013 and therefore backs
// up all allowlisted tables.

import { test } from 'node:test'
Expand Down Expand Up @@ -41,16 +41,20 @@ const expected = (applied) => {
return out
}
const allCreated = expected(files)
const pre012Files = files.filter((f) => !f.startsWith('012'))
const pre012Files = files.filter((f) => f < '012')
const pre012Created = expected(pre012Files)
const OWNERSHIP = ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state']
const OWNERSHIP = ['ownership_scopes', 'ownership_contributors', 'ownership_snapshots', 'ownership_sync_state', 'ownership_scope_versions']
const WORK = ['ownership_history_work', 'ownership_contributor_build_work']

/* ── mapping sanity ──────────────────────────────────────────────────── */

test('migrationTables maps 001..012 to their created/dropped tables', () => {
assert.ok(files.length >= 12, `expected >=12 migration files, got ${files.length}`)
assert.deepEqual(files.at(-1), '012_ownership.sql')
for (const t of OWNERSHIP) assert.ok(byMig.get('012_ownership.sql').created.has(t), `012 must create ${t}`)
test('migrationTables maps 001..013 to their created/dropped tables', () => {
assert.ok(files.length >= 13, `expected >=13 migration files, got ${files.length}`)
assert.deepEqual(files.at(-1), '013_ownership_checkpointing.sql')
for (const t of OWNERSHIP.slice(0, 4)) assert.ok(byMig.get('012_ownership.sql').created.has(t), `012 must create ${t}`)
for (const t of [...WORK, 'ownership_scope_versions']) {
assert.ok(byMig.get('013_ownership_checkpointing.sql').created.has(t), `013 must create ${t}`)
}
assert.ok(byMig.get('001_initial.sql').created.has('users'))
assert.ok(byMig.get('001_initial.sql').created.has('repositories'))
// 001-era tables dropped by 002 must not remain expected
Expand All @@ -66,30 +70,34 @@ test('PRE-012 target: resolves the 10 pre-012 tables, excludes ownership + unkno
const existing = new Set([...pre012Created, 'drill_internal', 'pg_stat_statements_info'])
const resolved = resolveBackupTables({ applied: pre012Files, existing })
assert.equal(resolved.length, BACKUP_TABLES.length - OWNERSHIP.length)
for (const t of OWNERSHIP) assert.ok(!resolved.includes(t), `${t} must not be requested pre-012`)
for (const t of [...OWNERSHIP, ...WORK]) assert.ok(!resolved.includes(t), `${t} must not be requested pre-012`)
assert.ok(resolved.includes('commits') && resolved.includes('users'), 'old core tables must be included')
assert.ok(!resolved.includes('drill_internal'), 'unknown table must never be requested')
assert.deepEqual(resolved, BACKUP_TABLES.filter((t) => !OWNERSHIP.includes(t)),
'resolution must preserve allowlist ordering')
})

/* ── B. post-012 target ──────────────────────────────────────────────── */
/* ── B. post-013 target ──────────────────────────────────────────────── */

test('POST-012 target: all 14 allowlisted tables resolve', () => {
test('POST-013 target: all 15 allowlisted tables resolve, work tables excluded', () => {
const resolved = resolveBackupTables({ applied: files, existing: allCreated })
assert.deepEqual(resolved, BACKUP_TABLES)
assert.equal(resolved.length, 14)
assert.equal(resolved.length, 15)
assert.ok(resolved.includes('ownership_scope_versions'), 'CORE membership must resolve')
for (const t of WORK) assert.ok(!resolved.includes(t), `ephemeral ${t} must never be backed up`)
})

/* ── C. broken post-012 target fails closed ──────────────────────────── */
/* ── C. broken post-013 target fails closed ──────────────────────────── */

test('BROKEN post-012: recorded 012 + missing ownership table fails closed', () => {
for (const t of OWNERSHIP) {
test('BROKEN post-013: recorded 013 + missing migration-created table fails closed', () => {
// every migration-created table — including the EPHEMERAL work tables,
// which must exist even though they are never backed up
for (const t of [...OWNERSHIP, ...WORK]) {
const existing = new Set([...allCreated].filter((x) => x !== t))
assert.throws(
() => resolveBackupTables({ applied: files, existing }),
new RegExp(t),
`missing ${t} with 012 applied must fail`,
`missing ${t} with 013 applied must fail`,
)
}
})
Expand Down Expand Up @@ -198,10 +206,10 @@ test('the exact production transition: pre-012 then post-012', () => {
const pre = resolveBackupTables({ applied: pre012Files, existing: pre012Created })
assert.deepEqual(pre, BACKUP_TABLES.filter((t) => !OWNERSHIP.includes(t)))
assert.equal(pre.length, 10)
// after 012 is applied, all 14 resolve
// after 012+013 are applied, all 15 resolve
assert.equal(validateAppliedMigrationPrefix(files), true)
const post = resolveBackupTables({ applied: files, existing: allCreated })
assert.equal(post.length, 14)
assert.equal(post.length, 15)
})

/* ── integration surface ─────────────────────────────────────────────── */
Expand Down
10 changes: 10 additions & 0 deletions test/data-minimization.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,16 @@ test('every user-linked table cascades from users in the migrations', () => {
`${tbl} must tenant-cascade through repositories(id, user_id)`,
)
}
// 013 checkpointing tables — retained membership + both ephemeral work
// relations — teardown still cascades through the same tenant FK
const checkpointing = src('migrations/013_ownership_checkpointing.sql')
for (const tbl of ['ownership_scope_versions', 'ownership_history_work', 'ownership_contributor_build_work']) {
assert.match(
checkpointing,
new RegExp(`create table if not exists public\\.${tbl}[\\s\\S]*?foreign key \\(repository_id, user_id\\)[\\s\\S]*?references public\\.repositories \\(id, user_id\\)[\\s\\S]*?on delete cascade`, 'i'),
`${tbl} must tenant-cascade through repositories(id, user_id)`,
)
}
})

/* ── WS7: backup scope stays allowlist-only ─────────────────────────────── */
Expand Down
Loading
Loading