app/scripts/deletion-recovery.ts
Rama main · réplica de lectura del repositorio original.
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import { parse } from 'jsonc-parser'
import type { ParseError } from 'jsonc-parser'
import { createClient } from '@libsql/client/web'
import { drizzle } from 'drizzle-orm/sqlite-proxy'
import { openSqlite } from '../src/server/db/sqlite-connection'
import { openLibsql } from '../src/server/db/libsql-connection'
import { sqliteParams } from '../src/server/db/sqlite-params'
import {
RecoveryInputError,
deletionRecoveryPlan,
listDeletedResourcesQuery,
inspectDeletion,
recoveryRows,
recoveryTargetQuery,
} from '../src/server/deletion-recovery'
import type {
RecoveryAction,
RecoveryTarget,
} from '../src/server/deletion-recovery'
const help = `Deletion recovery (installation operator access required)
pnpm cleanup:recover list [--cloudflare]
pnpm cleanup:recover inspect <project|server|team|job|artifact> <id> [--cloudflare]
pnpm cleanup:recover <retry-job|confirm-job|cleanup-project|confirm-artifact> <id>
--revision <inspection revision> --operator <identity> --reason <evidence> [--apply] [--cloudflare]
Without --apply, mutations only print a preview. confirm-job attests cleanup of
all job targets; confirm-artifact attests the exact archive is already removed.
Never use confirmations to bypass cleanup or merely because a server is offline.
--cloudflare selects the main D1 binding in the generated Wrangler config.
Otherwise uses DEVPUSH_DATABASE_URL/token or an existing DEVPUSH_DATABASE_PATH.
`
async function main() {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
help: { type: 'boolean' },
apply: { type: 'boolean' },
cloudflare: { type: 'boolean' },
revision: { type: 'string' },
operator: { type: 'string' },
reason: { type: 'string' },
},
})
if (values.help) {
console.log(help)
return
}
const [command, second, third] = positionals
const targets = ['project', 'server', 'team', 'job', 'artifact']
const actions = [
'retry-job',
'confirm-job',
'cleanup-project',
'confirm-artifact',
]
const inspect = command === 'inspect'
const list = command === 'list' && positionals.length === 1
if (
(inspect &&
(positionals.length !== 3 || !targets.includes(second ?? ''))) ||
(!inspect &&
!list &&
(positionals.length !== 2 || !actions.includes(command ?? '')))
)
throw new RecoveryInputError(help)
const target = (
inspect
? second
: command === 'cleanup-project'
? 'project'
: command === 'confirm-artifact'
? 'artifact'
: 'job'
) as RecoveryTarget
const id = (inspect ? third : second)!
const handle = values.cloudflare ? await openCloudflare() : openNode()
try {
if (list) {
console.log(
JSON.stringify(
{
database: handle.label,
resources: recoveryRows(
await handle.store.all(listDeletedResourcesQuery()),
),
note: 'Limited to 101 resources, oldest first.',
},
null,
2,
),
)
return
}
const [row] = recoveryRows(
await handle.store.all(recoveryTargetQuery(target, id)),
)
if (!row)
throw new RecoveryInputError(
'Resource not found in the selected database.',
)
const revision = createHash('sha256')
.update(JSON.stringify(row))
.digest('hex')
const report = await inspectDeletion(handle.store, target, row)
if (inspect) {
console.log(
JSON.stringify(
{ database: handle.label, revision, ...report },
null,
2,
),
)
return
}
if (values.revision !== revision)
throw new RecoveryInputError(
'Inspection revision missing or stale. Inspect this target again.',
)
const plan = deletionRecoveryPlan({
action: command as RecoveryAction,
row,
operator: values.operator ?? '',
reason: values.reason ?? '',
now: new Date(),
})
if (!values.apply) {
console.log(
JSON.stringify(
{
preview: true,
database: handle.label,
action: command,
operator: values.operator,
reason: values.reason,
...report,
},
null,
2,
),
)
return
}
// Every plan starts with a guarded audit insert; native batches roll back together.
const [first, ...rest] = plan
const results = await handle.store.batch([
handle.store.all(first),
...rest.map((query) => handle.store.run(query)),
])
if (results[0].length !== 1)
throw new RecoveryInputError(
'Recovery target changed or is not eligible. Inspect again.',
)
console.log(
JSON.stringify({
submitted: true,
database: handle.label,
action: command,
id,
note: 'Recovery recorded. Normal maintenance performs final collection after retention and other blockers are resolved.',
}),
)
} finally {
await handle.close()
}
}
function openNode() {
const url = process.env.DEVPUSH_DATABASE_URL
if (url) {
const connection = openLibsql(
createClient({ url, authToken: process.env.DEVPUSH_DATABASE_AUTH_TOKEN }),
{},
)
return {
store: connection.db,
close: connection.close,
label: 'configured libSQL main database',
}
}
const path = process.env.DEVPUSH_DATABASE_PATH || '.data/devpush.sqlite'
if (!existsSync(path))
throw new RecoveryInputError(
'SQLite database does not exist. Set DEVPUSH_DATABASE_PATH explicitly.',
)
const connection = openSqlite(path, {})
return { store: connection.db, close: connection.close, label: path }
}
async function openCloudflare() {
const path = process.env.DEVPUSH_WRANGLER_CONFIG || 'wrangler.jsonc'
const errors: ParseError[] = []
const config = parse(readFileSync(path, 'utf8'), errors, {
allowTrailingComma: true,
})
const binding = config?.d1_databases?.find(
(entry: { binding: string }) => entry.binding === 'DEVPUSH_DATABASE',
)
if (
errors.length ||
!/^[a-f0-9]{32}$/.test(config?.account_id ?? '') ||
!/^[a-f0-9-]{36}$/.test(binding?.database_id ?? '') ||
binding.database_id.startsWith('00000000-')
)
throw new RecoveryInputError(
'An explicit account_id and real main D1 binding are required in the Wrangler config.',
)
const credentials = JSON.parse(
execFileSync('pnpm', ['exec', 'wrangler', 'auth', 'token', '--json'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}),
)
if (typeof credentials.token !== 'string' || !credentials.token)
throw new RecoveryInputError('Wrangler authentication required.')
const endpoint = `https://api.cloudflare.com/client/v4/accounts/${config.account_id}/d1/database/${binding.database_id}/query`
async function request(batch: Array<{ sql: string; params: unknown[] }>) {
const response = await fetch(endpoint, {
method: 'POST',
redirect: 'error',
signal: AbortSignal.timeout(60_000),
headers: {
Authorization: `Bearer ${credentials.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
batch: batch.map((query) => ({
sql: query.sql,
params: sqliteParams(query.params),
})),
}),
})
const body = (await response.json()) as {
success?: boolean
result?: Array<{ success?: boolean; results?: unknown[] }>
}
if (
!response.ok ||
body.success !== true ||
body.result?.length !== batch.length ||
body.result.some((entry) => entry.success !== true)
)
throw new RecoveryInputError(
`D1 recovery request failed (${response.status}); no automatic retry. Inspect before trying again.`,
)
return body.result.map((entry) => ({ rows: entry.results ?? [] }))
}
const store = drizzle(
async (sql, params) => (await request([{ sql, params }]))[0]!,
(queries) => request(queries),
{},
)
return {
store,
close: () => {},
label: `D1 ${config.account_id}/${binding.database_id}`,
}
}
main().catch((error: unknown) => {
// Driver errors can include SQL parameters; never print them with credentials.
console.error(
error instanceof RecoveryInputError
? error.message
: 'Database or authentication request failed. Inspect before retrying; driver details are withheld because they can contain secrets.',
)
process.exitCode = 1
})