## Summary Bundled plugins now persist shared runtime state in project-scoped PostgreSQL tables instead of maintaining independent SQLite authority. Reports, CLI Printing Press, Compound Engineering, Roadmap, Even Realities, and WhatsApp all follow the same ownership and startup contract as Fusion core. ## Design decisions - Plugin schema hooks run through the host’s PostgreSQL owner and enforce project isolation. - The SDK exposes the host contract needed by bundled plugins without importing engine internals. - Legacy Roadmap ownership fixtures use the supported empty-owner sentinel, preserving current composite primary/foreign keys while exercising backfill behavior. - The lockfile travels with the Even Realities PostgreSQL dependency so packaged installs remain reproducible. ## Validation - All six affected plugin builds pass. - Affected plugin suites pass: 773 tests across Printing Press, Compound Engineering, Even Realities, Reports, Roadmap, and WhatsApp. - `pnpm test:gate` passes all 478 gate tests. - This PR changes 40 files. ## Stack - Depends on #2110 → #2109 → #2108. - The documentation/release PR completes the stack. Related: #2105 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Breaking Changes** * PostgreSQL is now required for runtime storage; SQLite files are used only as one-time migration inputs. * The legacy `FUSION_NO_EMBEDDED_PG` fallback has been removed. * **New Features** * Added project-isolated PostgreSQL storage for plugins, reports, tasks, notifications, and other plugin data. * Added agent tools for reports and CLI service drafts. * Added PostgreSQL schema initialization support for plugin authors. * **Bug Fixes** * Improved migration and recovery of legacy plugin state. * Prevented cross-project data access and strengthened transactional schema updates. * **Documentation** * Updated storage, migration, deployment, plugin authoring, CLI, and dashboard guidance for PostgreSQL. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
18 KiB
Code Review — SQLite → PostgreSQL Storage Migration
Historical review: This document records the incomplete migration branch as reviewed on 2026-06-26. Its NOT READY verdict and line-specific findings are not the current cutover status. See the 2026-07-14 PostgreSQL runtime cutover review for the audited current architecture, residual legacy-reader inventory, and deployment contract.
Date: 2026-06-26
Branch: feature/postgres reviewed against origin/main (merge-base 7d13f880b)
HEAD: 387cec1a7 — feat: migrate storage from SQLite to PostgreSQL (squash)
Reviewers: 13 persona agents (ce-code-review multi-agent pipeline) + learnings researcher + deployment verification
Run artifacts: /tmp/compound-engineering/ce-code-review/20260626-084137-41a91d02/ (per-reviewer JSON)
Plan: docs/plans/2026-06-23-001-feat-migrate-sqlite-to-postgres-plan.md
Scope
- 714 files changed, +64,470 / −173,769.
- 42,858 lines of new code under
packages/core/src/postgres/,packages/core/src/task-store/(63 files), and 19async-*satellite stores. - 388 deleted test files (167 core, 123 dashboard, 73 engine, plugins); 53 new
__tests__/postgres/*.pg.test.tsadded;scripts/lib/test-quarantine.json+175 lines.
Verdict: NOT READY TO MERGE
A well-architected migration that honors the plan's design (R1–R12 are all honored in design), but as a single 42k-line squash it ships with 7 P0 and ~27 P1 findings. Three structural facts dominate:
- The async rewrite repeatedly dropped guards the sync path still enforces. Soft-delete write-conflict guards, handoff atomicity, and — most severely — entire merge-critical store methods were never given a
backendModebranch, so they throw on every merge in the default embedded-PG backend. - The tests that protected those invariants were deleted, and the new PG tests do not run in CI. No Postgres service is provisioned and the skip logic is inverted, so 42k lines of new data-layer code is effectively uncovered. This is the FN-5893 "deleted the repro, kept the bug" failure mode.
- This is a mid-migration (dual-path) branch, not post-cutover. Both SQLite and Postgres paths are live behind 289
backendModebranches; R11 (SQLite removed) is intentionally incomplete. The unguarded methods below are un-migrated leftovers of an incomplete flip.
The backup subsystem is independently broken three ways in the default embedded mode, and there is no first-class migration entry point.
P0 — Critical (must fix before merge)
| # | File:Line | Issue | Reviewer(s) | Conf |
|---|---|---|---|---|
| 1 | task-store/remaining-ops-6.ts:441 |
getActiveMergingTask throws in PG mode. Calls store.db.prepare(...) with no backendMode guard; the db getter throws "SQLite Database is not available in backend mode". The merge concurrency guard (callers merger.ts:9755, project-engine.ts:2247) fails on every merge. Verified. |
api-contract | 100 |
| 2 | task-store/remaining-ops-6.ts:818 |
upsertMergeRequestRecord throws in PG mode — unguarded store.db. Callers merger.ts:8466, executor.ts:1970, self-healing.ts:828, project-engine.ts:1866. Method must become async + all callsites awaited. |
api-contract | 100 |
| 3 | task-store/remaining-ops-6.ts:845 |
transitionMergeRequestState throws in PG mode — unguarded store.db. ~12 callers in merger.ts/project-engine.ts. The merge state machine cannot advance. |
api-contract | 100 |
| 4 | .github/workflows/full-suite.yml · __test-utils__/pg-test-harness.ts:81 |
New PG tests don't run in CI. No postgres service is provisioned and PG_AVAILABLE is always truthy (PG_TEST_URL_BASE defaults non-empty, FUSION_PG_TEST_SKIP never set), so the 57 pgDescribe suites fail with ECONNREFUSED or are dead. 42k lines of new data-layer code has no integration coverage in CI. |
testing | 100 |
| 5 | postgres/pg-backup.ts:261 |
pg_dump connects to the wrong DB. Connection string passed via PG_CONNECTION_STRING — not a libpq variable. With no --dbname/PG* vars it hits the system default (localhost:5432, current user); in embedded mode (random port) backups fail or target an empty DB. The FNXC comment documents the (good) intent but the env var is non-functional. Verified. |
reliability | 100 |
| 6 | postgres/pg-backup.ts:302 |
Same gap for pg_restore — restore targets the wrong server. Verified. |
reliability | 100 |
| 7 | task-store/remaining-ops-1.ts:132 |
Soft-delete resurrection. The backendMode branch of atomicWriteTaskJsonWithAudit blind-upserts the row with no deletedAt re-read and no throwSoftDeletedWriteBlocked — the guard the sync branch has (lines 144-167). A write to / racing a soft-deleted task silently resurrects it (R7 / VAL-DATA-005/006). Verified the guard is absent. |
adversarial (corrob. correctness, learnings, testing) | 75 |
Note: #1–#3 and #7 are the same root cause as the structural P1 below (#13) — an incomplete sync→async flip — manifesting as hard runtime failures and data-integrity regressions on critical paths.
P1 — High
Unguarded store.db on async-converted paths (all throw in PG mode, confidence 100, api-contract)
| # | File:Line | Method / impact |
|---|---|---|
| 8 | task-store/remaining-ops-2.ts:438 |
renewCheckoutLeaseImpl — checkout lease renewal throws; silently escalates to checkout expiry during active execution. |
| 9 | task-store/remaining-ops-2.ts:871 |
registerArtifactImpl — preliminary taskId check at :871 sits outside the register() guard at :890; throws whenever input.taskId is set. |
| 10 | task-store/remaining-ops-6.ts:618, :662, :699 · remaining-ops-2.ts:489, :509 · workflow-ops.ts:24 |
Workflow settings read/write (×6) + workflow-step creation — engine agent-tools and dashboard workflow/settings routes throw in PG mode. |
| 11 | task-store/remaining-ops-6.ts:460 |
findRecentTasksByContentFingerprint — unguarded and uses SQLite-only json_extract(...); near-duplicate intake breaks. |
Other P1
| # | File:Line | Issue | Reviewer(s) | Conf |
|---|---|---|---|---|
| 12 | task-store/moves.ts:187, :702 |
Handoff-to-review atomicity broken. createCompletionHandoffWorkflowWork runs its workflow-work cancel/upsert in their own fresh-pool transactions, not the outer handoff tx; an outer rollback leaves committed workflow-work / orphaned merge-gate rows (R7 mergeQueue invariant). Pool-exhaustion deadlock risk via nested transactionImmediate (workflow-workitems-ops-2.ts:20). |
correctness | 75 |
| 13 | store.ts (289 sites) |
The flip never completed. 19 async-* stores added alongside unchanged sync stores with 289 backendMode branches; agent-store.ts (3202 L), mission-store.ts (4390 L), central-core.ts (4374 L) carry both paths. Every feature written twice; the SQLite-fallback path (in-process-runtime.ts:239, asyncLayer null) runs untested. Root cause of #1–#3, #7–#11. |
maintainability (corrob. correctness, testing) | 100 |
| 14 | postgres/sqlite-migrator.ts:369 |
Migration data-corruption risk. resolveColumnMapping joins information_schema.columns by column name only (no table predicate); data is text in archived_tasks but jsonb in 5+ tables → nondeterministic type classification → batch aborts on ::jsonb mismatch. Fixtures pass, prod fails. |
data-migration | 75 |
| 15 | postgres/sqlite-migrator.ts:596 |
Content-blind verification. targetRows >= sourceRows with ON CONFLICT DO NOTHING cannot detect under-migration or content divergence on re-run; reports verified regardless. |
data-migration + adversarial (agree) | 100 |
| 16 | dashboard/routes/register-signal-routes.ts:222 |
resolveIncident() became async but the caller was not updated — floating Promise, incident-resolution errors silently dropped. |
api-contract | 100 |
| 17 | dashboard/monitor-store.ts:170 |
Broken backend discriminator. 'transactionImmediate' in db always routes SQLite Database instances (which also expose transactionImmediate, db.ts:5746) to the async path → resolveIncidentAsync runs with a DatabaseSync as the Drizzle arg. |
api-contract | 75 |
| 18 | postgres/migrations/0000_initial.sql:1436 |
Missing index on source_parent_task_id → the lineage gate (findLiveLineageChildren/removeLineageReferences, run on every archive/delete) is a full tasks-table scan. |
performance | 100 |
| 19 | task-store/async-merge-coordination.ts:255 |
N+1 in merge-queue lease acquire — 2 round-trips per stale row inside the tx, on every merge attempt (20 stale rows = 40 sequential round-trips before the first lease). | performance | 100 |
| 20 | task-store/async-audit.ts:120, :252 |
LIMIT applied in JS, not SQL — audit/activity queries pull the entire matching set then .slice(); activity_log has no rotation. |
performance | 100 |
| 21 | task-store/async-persistence.ts:280 |
readLiveTaskRows does an unbounded SELECT * FROM tasks WHERE deleted_at IS NULL (80+ cols, jsonb) on every board hydration — MB/request over the wire. |
performance | 100 |
| 22 | postgres/credential-redact.ts:39 |
Redaction misses ?password= query-param URLs; logged verbatim by DatabaseConnectionError/describeBackendForLog. |
security | 75 |
| 23 | postgres/embedded-lifecycle.ts:414 |
SIGTERM/SIGINT handler await this.stop() but never re-raises → process hangs alive until SIGKILL after the cluster stops. |
reliability | 100 |
| 24 | postgres/startup-factory.ts:292 |
No timeout on embeddedLifecycle.start() — a stalled initdb/pg_ctl hangs startup forever. |
reliability | 75 |
| 25 | postgres/pg-backup.ts:130 |
Partial backup not cleaned up — central dump failure orphans the project dump; listBackups() counts it as a pair, skewing retention. |
reliability | 75 |
| 26 | postgres/pg-backup.ts (packaging) |
Backup broken end-to-end in embedded mode: pg_dump/pg_restore not bundled with @embedded-postgres/* (only initdb/pg_ctl/postgres); BackupManager also throws standalone because the embedded URL resolves only at daemon start. Compounds #5/#6. |
deployment + agent-native (agree) | 100 |
| 27 | cli/src/commands/db.ts |
No fn db migrate command and no auto-migrate at startup. First boot on the new embedded-PG default produces an empty database; existing SQLite data is invisible until a hand-written script runs migrateSqliteToPostgres. Silent data-loss trap. |
agent-native + deployment (agree) | 100 |
| 28 | __tests__/postgres/create-task-reserved-id.pg.test.ts |
TombstonedTaskResurrectionError (FN-5208/FN-5233, an AGENTS.md repeat-regression incident) has zero PG coverage; 13 engine reliability-interaction tests + soft-delete-stickiness-FN-5233.test.ts deleted (they used the removed inMemoryDb option, not deleted code). This is the test that would catch #7. |
testing | 100 |
| 29 | async-central-core.ts:1424+ |
FNXC gap: 1789-line file, 3 FNXC comments; the concurrency-slot + mesh-state sections (the "important technical decisions" AGENTS.md requires marked) are unmarked. | project-standards | 75 |
| 30 | task-store/remaining-ops-1.ts…-10.ts |
remaining-ops-1..10 (~9000 L) are explicitly un-categorized overflow modules (mixed domains, several >1000 L); lifecycle-ops.ts is a new 1241-line file mixing DB open, FS watching, and settings migration. |
maintainability | 100 |
P2 — Moderate
moves.ts:626— soft-delete guard also missing onmoveTaskInternalbackend path (sibling of #7). (adversarial, 50)moves.ts:629— WIP capacity limit overrun: two concurrent backend moves into one slot both commit under READ COMMITTED. (adversarial, 50)task-store/audit-ops.ts:59—taskRow as unknown as TaskDetailbypasses deserialization; hook consumers get raw JSON-string columns. (maintainability, 100)postgres/connection.ts:46— default poolmax=10may starve undermaxWorktrees-level concurrenttransactionImmediateholders. (performance, 75)postgres/postgres-health.ts:329—healSchemaDriftcatch {}swallows ALTER TABLE errors silently. (reliability, 100; safe_auto)postgres-health.ts:354/389—validateAndHealSchemaALTER andvacuumAnalyzeVACUUM run on the runtime pool, not the migration connection → fail under a transaction-mode pooler.sqlite-migrator.ts:471— empty-string → NULL forjsonb;NOT NULL jsonbcolumns (data/ir/step_ids) abort the batch on legacy''rows. (data-migration)__test-utils__/pg-test-harness.ts:128—execSync('psql …')violates the AGENTS.md execSync ban (not git plumbing; no timeout → can hang the vitest worker). (project-standards)0000_initial.sql:1425— no partial index for the hotWHERE deleted_at IS NULL AND column = ?kanban read (forces bitmap-AND). (performance)- 9 quarantine entries are migration-caused mock drift, not flakes (CE orchestrator, desktop
local-server, dashboardresearch-api) — AGENTS.md forbids quarantining tests that fail because of the change; 14-day deletion clock expires 2026-07-09. (testing) index.ts—detectLegacyData/migrateFromLegacy/getMigrationStatusremoved from the@fusion/corepublic index with no deprecation;dist/index.d.tsstill referenced them. (api-contract)store.ts:389/plugin-store.ts:130—inMemoryDbconstructor option removed fromTaskStore/PluginStore→ TypeScript compile break for any external/plugin caller..changeset/embedded-postgres-lifecycle.md— freeform body, missingsummary:/category:/dev:(gate warns;--strictfails). (project-standards; safe_auto)
P3 — Low
.returning()would collapse insert-then-select double round-trips (async-branch-groups.ts:120,async-monitor.ts:203, …). (safe_auto)searchTasks*return unbounded result sets with no default cap (async-search.ts:159). (safe_auto)- Repeated
as unknown as Record<string,unknown>settings casts (settings-ops.ts:63). flip-embedded-pg-default.mdfiledminor/feature— a default-backend swap is arguablymajor/breaking.
Learnings & Past Solutions (all honored in design, at risk in execution)
docs/soft-delete-verification-matrix.md— the acceptance contract for R7. Findings #7, #28 are direct hits; re-run the matrix GREEN against the async store before cutover.docs/solutions/database-issues/schema-version-constant-must-equal-highest-migration.md— carry the version-gate discipline to the Drizzle journal; add a seed-at-previous-state upgrade test (not fresh-DB only).docs/solutions/database-issues/task-field-silently-dropped-without-sqlite-column-mapping.md— round-trip everyTaskfield throughupdateTask→getTask→reopen(theaudit-ops.ts:59cast is this risk realized).docs/solutions/integration-issues/engine-already-running-is-not-no-engine.md— thetaskClaimstwo-write lease release must keepBEGIN IMMEDIATE-equivalent isolation (SELECT FOR UPDATE/serializable), not drift to plain READ COMMITTED.docs/solutions/test-failures/schema-version-sweep-must-include-plugin-workspaces.md— sweep plugin version pins from repo root after the first Drizzle migration bump; the roadmap plugin's snake_case vs camelCase column mismatch (api-contract residual) is unaudited.
Deployment — Go/No-Go (blocking items)
- No
fn db migrateCLI (#27). - No automated pre-migration SQLite backup (operator must manually
cpfusion.db,archive.db,fusion-central.db). pg_dump/pg_restorenot bundled (#26).- No auto-migrate → empty-DB-on-first-boot data-loss for naive upgraders.
Historical note: the original checklist included rollback via FUSION_NO_EMBEDDED_PG=1. The final cutover removed that runtime fallback; recovery now restores the retained SQLite backup into a controlled migration workflow, while normal startup always uses embedded PostgreSQL or DATABASE_URL.
Residual Risks
- Embedded mode hard-codes superuser password
"password"(local-only, 127.0.0.1 + random port — parity with prior local SQLite trust; consider a random per-instance password at 0600). - Fixed
project/central/archiveschema names → two projects sharing one externalDATABASE_URLclobber each other (no isolation). tsvector GENERATED ALWAYS AS STOREDadds write amplification on every unrelated task update (heartbeat/timing writes recompute the vector).- No
DATABASE_URLformat validation (backend-resolver.ts:92) — malformed URL fails only at connect. pgRowToTaskRowshim re-serializes parsed jsonb back to strings forfromJson(); any new async path skipping it feeds parsed objects toJSON.parse→'[object Object]'garbage (not enumerated across all helpers).
Coverage
- Confidence gate: no findings suppressed below anchor 75 except retained P0@75 (#7); ~4 testing/maintainability P2/P3 advisory items demoted to soft buckets.
- All 13 reviewers returned results; 0 failures/timeouts.
- Testing gaps: no concurrency tests for the atomicity/lost-update paths (#7, #12, WIP); no perf benchmark for the N+1 hot paths at realistic volume; migrator untested for cross-table type collision, non-superuser FK-order fallback, pre-populated-target verification, and jsonb round-trip.
Suggested Fix Order
- Restore the safety net: #4 (provision Postgres in CI + fix
PG_AVAILABLEprobe) and #28 (rescue the deleted invariant tests) — so everything below is verifiable. - Unblock the default backend: #1, #2, #3 and the #8–#11 unguarded
store.dbmethods — complete thebackendModebranches (this is finding #13, the incomplete flip). - Data-integrity guards: #7, #12, #14, #15.
- Backup / lifecycle: #5, #6, #23, #25, #26, #27.
- Performance: #18, #19, #20, #21.
- Standards / structure: #16, #17, #22, #29, #30.