Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
132 lines
4.8 KiB
JavaScript
132 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// Migrate all documents_registry tables from legacy `status` (select text)
|
|
// to canonical `status_id` (relation → _doc_statuses). Idempotent.
|
|
//
|
|
// For each documents_registry table:
|
|
// 1. Ensure `status_id` relation column exists (→ _doc_statuses.id)
|
|
// 2. Ensure `verified` checkbox column exists (Gate A plan approval)
|
|
// 3. Backfill data->>'status_id' from data->>'status' slug on every row
|
|
// that is missing a status_id value.
|
|
//
|
|
// Safe to re-run: columns are only added if missing; backfill only
|
|
// populates rows whose status_id is NULL/missing.
|
|
//
|
|
// Usage: node scripts/migrate-doc-status-to-relation.mjs [--dry-run]
|
|
|
|
import { dbAll, dbGet, dbRun, sqlNow } from '../backend/database/connection.js';
|
|
|
|
const DRY = process.argv.includes('--dry-run');
|
|
const log = (...a) => console.log('[migrate-status]', ...a);
|
|
|
|
async function findStatusesTable() {
|
|
const row = await dbGet(`SELECT id FROM universal_tables WHERE name = '_doc_statuses' LIMIT 1`);
|
|
if (!row) throw new Error('_doc_statuses table not found — create it first');
|
|
return row.id;
|
|
}
|
|
|
|
async function loadStatusMap(statusesTableId) {
|
|
const rows = await dbAll(`SELECT id, data FROM table_rows WHERE table_id = ?`, [statusesTableId]);
|
|
const slugToId = new Map();
|
|
for (const r of rows) {
|
|
const d = typeof r.data === 'string' ? JSON.parse(r.data) : (r.data || {});
|
|
if (d.slug) slugToId.set(String(d.slug), r.id);
|
|
}
|
|
return slugToId;
|
|
}
|
|
|
|
async function ensureColumn(tableId, spec) {
|
|
const existing = await dbGet(
|
|
`SELECT id FROM table_columns WHERE table_id = ? AND column_name = ? LIMIT 1`,
|
|
[tableId, spec.column_name]
|
|
);
|
|
if (existing) {
|
|
log(` ✓ column ${spec.column_name} already on ${tableId}`);
|
|
return false;
|
|
}
|
|
if (DRY) {
|
|
log(` [dry] would add column ${spec.column_name} to ${tableId}`);
|
|
return true;
|
|
}
|
|
await dbRun(
|
|
`INSERT INTO table_columns (table_id, column_name, display_name, type, order_index, is_visible, config)
|
|
VALUES (?, ?, ?, ?, ?, 1, ?)`,
|
|
[tableId, spec.column_name, spec.display_name, spec.type, spec.order_index, spec.config ? JSON.stringify(spec.config) : null]
|
|
);
|
|
log(` + added column ${spec.column_name} (${spec.type}) to ${tableId}`);
|
|
return true;
|
|
}
|
|
|
|
async function backfillRows(tableId, slugToId) {
|
|
const rows = await dbAll(`SELECT id, data FROM table_rows WHERE table_id = ?`, [tableId]);
|
|
let updated = 0;
|
|
let skipped = 0;
|
|
for (const r of rows) {
|
|
const data = typeof r.data === 'string' ? JSON.parse(r.data) : (r.data || {});
|
|
const currentSlug = data.status || null;
|
|
const currentSid = data.status_id || null;
|
|
if (currentSid) { skipped++; continue; }
|
|
if (!currentSlug) { skipped++; continue; }
|
|
const sid = slugToId.get(String(currentSlug));
|
|
if (!sid) {
|
|
log(` ! row ${r.id}: slug "${currentSlug}" has no match in _doc_statuses`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
if (DRY) {
|
|
log(` [dry] row ${r.id}: status "${currentSlug}" → status_id ${sid}`);
|
|
} else {
|
|
const newData = { ...data, status_id: sid };
|
|
await dbRun(
|
|
`UPDATE table_rows SET data = ?::jsonb, updated_at = ${sqlNow()} WHERE id = ? AND table_id = ?`,
|
|
[JSON.stringify(newData), r.id, tableId]
|
|
);
|
|
}
|
|
updated++;
|
|
}
|
|
log(` backfill: ${updated} updated, ${skipped} skipped`);
|
|
return { updated, skipped };
|
|
}
|
|
|
|
async function main() {
|
|
log(DRY ? 'DRY RUN' : 'LIVE RUN');
|
|
const statusesTableId = await findStatusesTable();
|
|
log(`_doc_statuses table_id = ${statusesTableId}`);
|
|
const slugToId = await loadStatusMap(statusesTableId);
|
|
log(`status slug → id map:`, Object.fromEntries(slugToId));
|
|
|
|
const registries = await dbAll(
|
|
`SELECT id, name, project_id FROM universal_tables WHERE table_type = 'documents_registry' ORDER BY id`
|
|
);
|
|
log(`found ${registries.length} documents_registry tables`);
|
|
|
|
const summary = [];
|
|
for (const reg of registries) {
|
|
log(`\n→ registry ${reg.id} (project ${reg.project_id}, name=${reg.name})`);
|
|
await ensureColumn(reg.id, {
|
|
column_name: 'status_id',
|
|
display_name: 'Status',
|
|
type: 'relation',
|
|
order_index: 9,
|
|
config: { target_table_id: statusesTableId, display_column: 'label', icon: '🏷️' }
|
|
});
|
|
await ensureColumn(reg.id, {
|
|
column_name: 'verified',
|
|
display_name: 'Verified (Plan)',
|
|
type: 'checkbox',
|
|
order_index: 10,
|
|
config: {}
|
|
});
|
|
const { updated, skipped } = await backfillRows(reg.id, slugToId);
|
|
summary.push({ table_id: reg.id, updated, skipped });
|
|
}
|
|
|
|
log('\n=== summary ===');
|
|
for (const s of summary) log(` t${s.table_id}: updated=${s.updated} skipped=${s.skipped}`);
|
|
log(DRY ? '\nDRY RUN complete — no changes written.' : '\nmigration complete.');
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error('[migrate-status] FAILED:', e);
|
|
process.exit(1);
|
|
});
|