Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
198 lines
7.5 KiB
JavaScript
198 lines
7.5 KiB
JavaScript
#!/usr/bin/env node
|
|
// Migration 2026-04-29: ADR-0012 Phase 8.4 — collapse instance widgets into atom rows.
|
|
//
|
|
// Situation today:
|
|
// - 33 widgets rows with owner_kind='document' carry per-doc config (filter.ids etc.).
|
|
// - 23 of them are referenced by an atom row (data->>'widget_ref' matches widget.id,
|
|
// data->>'level' = 'widget') in the doc's companion atom table.
|
|
// - 10 of them are orphaned (no atom references them — leftovers from earlier
|
|
// prototyping; verified via single SQL across all table_rows).
|
|
//
|
|
// Target shape (ADR-0012 Phase 8 + ADR-0017 §5b):
|
|
// - effective_config = deepMerge(template.config, atom.settings_override)
|
|
// - templates live in `widgets` with is_template=true (created by Phase 8.1, ids 4123..4130).
|
|
//
|
|
// What this migration does:
|
|
// For each BOUND row → set atom.widget_ref = template_id (matched by preset_name),
|
|
// set atom.settings_override = deepMerge(widget.config, current_override),
|
|
// DELETE the instance widget row.
|
|
// For each ORPHAN row → DELETE the instance widget row (no atom to update).
|
|
//
|
|
// Merge policy: widget.config is the base layer, atom.settings_override layers on top.
|
|
// This is the same precedence the renderer applies today, so visible output stays identical.
|
|
// Arrays replace wholesale (Helm semantics).
|
|
//
|
|
// Idempotent: a second run finds 0 widgets where owner_kind='document' AND is_template=false.
|
|
//
|
|
// Usage:
|
|
// # dry-run (default — no writes, prints plan + per-row diff)
|
|
// POSTGRES_DB=godcrm_prod POSTGRES_PASSWORD=... node scripts/migrations/20260429_instances_to_atoms.js
|
|
//
|
|
// # apply (transactional — rolls back on first error)
|
|
// POSTGRES_DB=godcrm_prod POSTGRES_PASSWORD=... node scripts/migrations/20260429_instances_to_atoms.js --apply
|
|
|
|
import 'dotenv/config';
|
|
import pg from 'pg';
|
|
|
|
const { Client } = pg;
|
|
|
|
const HOST = process.env.POSTGRES_HOST || 'localhost';
|
|
const PORT = parseInt(process.env.POSTGRES_PORT || '5432', 10);
|
|
const DB = process.env.POSTGRES_DB || 'godcrm';
|
|
const USER = process.env.POSTGRES_USER || 'godcrm';
|
|
const PASS = process.env.POSTGRES_PASSWORD;
|
|
|
|
const APPLY = process.argv.includes('--apply');
|
|
|
|
if (!PASS) {
|
|
console.error('[migration] POSTGRES_PASSWORD not set');
|
|
process.exit(1);
|
|
}
|
|
|
|
function deepMerge(base, over) {
|
|
if (over === null || over === undefined) return base;
|
|
if (base === null || base === undefined) return over;
|
|
if (typeof base !== 'object' || typeof over !== 'object') return over;
|
|
if (Array.isArray(base) || Array.isArray(over)) return over;
|
|
const out = { ...base };
|
|
for (const k of Object.keys(over)) out[k] = deepMerge(base[k], over[k]);
|
|
return out;
|
|
}
|
|
|
|
function safeParse(s) {
|
|
if (s === null || s === undefined) return null;
|
|
if (typeof s === 'object') return s;
|
|
try { return JSON.parse(s); } catch { return null; }
|
|
}
|
|
|
|
async function main() {
|
|
const client = new Client({ host: HOST, port: PORT, database: DB, user: USER, password: PASS });
|
|
await client.connect();
|
|
console.log(`[migration] connected to ${DB}@${HOST}:${PORT} — mode=${APPLY ? 'APPLY' : 'DRY-RUN'}`);
|
|
|
|
await client.query('BEGIN');
|
|
try {
|
|
const templates = await client.query(`
|
|
SELECT id, preset_name FROM widgets WHERE is_template = true
|
|
`);
|
|
const tplByPreset = new Map(templates.rows.map(r => [r.preset_name, r.id]));
|
|
console.log(`[migration] templates loaded: ${templates.rows.length} (${[...tplByPreset.keys()].sort().join(', ')})`);
|
|
|
|
const instances = await client.query(`
|
|
SELECT id, preset_name, owner_id, config
|
|
FROM widgets
|
|
WHERE owner_kind = 'document' AND COALESCE(is_template, false) = false
|
|
ORDER BY preset_name, id
|
|
`);
|
|
console.log(`[migration] instance widgets: ${instances.rows.length}`);
|
|
|
|
const summary = { bound: [], orphan: [], errors: [] };
|
|
|
|
for (const w of instances.rows) {
|
|
const wid = w.id;
|
|
const preset = w.preset_name;
|
|
const tplId = tplByPreset.get(preset);
|
|
if (!tplId) {
|
|
summary.errors.push(`widget ${wid}: no template for preset "${preset}"`);
|
|
continue;
|
|
}
|
|
|
|
// Find the atom that references this widget. data->>'widget_ref' is text,
|
|
// data->'widget_ref' is a JSON number; match either form.
|
|
const atomRes = await client.query(`
|
|
SELECT id, table_id, data
|
|
FROM table_rows
|
|
WHERE data->>'widget_ref' = $1 AND data->>'level' = 'widget'
|
|
LIMIT 2
|
|
`, [String(wid)]);
|
|
|
|
if (atomRes.rows.length === 0) {
|
|
summary.orphan.push({ widget_id: wid, preset, owner_id: w.owner_id });
|
|
continue;
|
|
}
|
|
if (atomRes.rows.length > 1) {
|
|
summary.errors.push(`widget ${wid}: ${atomRes.rows.length} atoms reference it (expected 1)`);
|
|
continue;
|
|
}
|
|
|
|
const atom = atomRes.rows[0];
|
|
const widgetCfg = safeParse(w.config) || {};
|
|
const curOverride = safeParse(atom.data.settings_override) || {};
|
|
const newOverride = deepMerge(widgetCfg, curOverride);
|
|
|
|
summary.bound.push({
|
|
widget_id: wid,
|
|
preset,
|
|
template_id: tplId,
|
|
atom_id: atom.id,
|
|
atom_table: atom.table_id,
|
|
widget_config: widgetCfg,
|
|
prev_override: curOverride,
|
|
new_override: newOverride,
|
|
});
|
|
|
|
if (APPLY) {
|
|
// Set atom.widget_ref = template_id, atom.settings_override = newOverride.
|
|
await client.query(`
|
|
UPDATE table_rows
|
|
SET data = jsonb_set(
|
|
jsonb_set(COALESCE(data, '{}'::jsonb), '{widget_ref}', to_jsonb($1::int)),
|
|
'{settings_override}', $2::jsonb
|
|
),
|
|
updated_at = NOW()
|
|
WHERE id = $3
|
|
`, [tplId, JSON.stringify(newOverride), atom.id]);
|
|
}
|
|
}
|
|
|
|
if (APPLY) {
|
|
// Delete all 33 instance widgets in one shot (both bound and orphaned).
|
|
const del = await client.query(`
|
|
DELETE FROM widgets
|
|
WHERE owner_kind = 'document' AND COALESCE(is_template, false) = false
|
|
`);
|
|
console.log(`[migration] deleted instance widgets: ${del.rowCount}`);
|
|
}
|
|
|
|
// Report
|
|
console.log(`\n=== SUMMARY ===`);
|
|
console.log(`bound (atom rewires + widget delete): ${summary.bound.length}`);
|
|
console.log(`orphan (widget delete only): ${summary.orphan.length}`);
|
|
console.log(`errors: ${summary.errors.length}`);
|
|
|
|
if (summary.errors.length) {
|
|
console.log(`\n--- ERRORS ---`);
|
|
summary.errors.forEach(e => console.log(` ${e}`));
|
|
throw new Error('errors detected — rolling back');
|
|
}
|
|
|
|
console.log(`\n--- BOUND ---`);
|
|
for (const b of summary.bound) {
|
|
console.log(` widget ${b.widget_id} (${b.preset}) → tpl ${b.template_id}; atom ${b.atom_id}@t${b.atom_table}`);
|
|
console.log(` new_override: ${JSON.stringify(b.new_override)}`);
|
|
}
|
|
console.log(`\n--- ORPHAN ---`);
|
|
for (const o of summary.orphan) {
|
|
console.log(` widget ${o.widget_id} (${o.preset}) — owner_id=${o.owner_id}, no atom refs`);
|
|
}
|
|
|
|
if (APPLY) {
|
|
await client.query('COMMIT');
|
|
console.log(`\n[migration] COMMIT — ${summary.bound.length} atom rewires, ${summary.bound.length + summary.orphan.length} widget rows deleted`);
|
|
} else {
|
|
await client.query('ROLLBACK');
|
|
console.log(`\n[migration] DRY-RUN ROLLBACK — re-run with --apply to commit`);
|
|
}
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error(`[migration] ROLLBACK on error:`, err.message);
|
|
process.exit(1);
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('[migration] fatal:', err);
|
|
process.exit(1);
|
|
});
|