## `main` is currently red on `check-fnxc-future-dates` Four stamps read `2026-07-30-26:10` — an hour that cannot exist. They're exactly what #2995 taught this gate to catch. That PR landed the hour validation (`00-23`) *after* #2999 had already merged these four, so the gate started reporting a defect that was already sitting there rather than one introduced afterwards. **The guard is working**; nothing was checking before it. ``` scripts/lib/backend-db.mjs:41 scripts/reconcile-task-state-consistency.mjs:8, :51 scripts/__tests__/reconcile-task-state-consistency.test.mjs:109 ``` Corrected by **literal normalisation** — 26:10 on the 30th *is* 02:10 on the 31st — rather than flattening them to an arbitrary in-range hour. AGENTS.md specifies `yyyy-MM-dd-hh:mm`, and the stamp exists to give a readable why-does-this-exist trail, so the ordering is the part worth preserving. ## The baseline tightening rides along, and it's a date rollover Stamps written yesterday as `2026-07-31` were future *then* and were baselined as such. Today they're past, so **176 files ratchet to zero**. Nobody did anything. The gate rewrites the baseline as a side effect and exits 0, so leaving it uncommitted dirties the tree on every subsequent run **for everyone** — which is why it belongs in this commit rather than a later one. Re-recording on a decrease is the rule this gate and its siblings already state. Worth knowing about the design, since I wrote it: this churn recurs whenever a day boundary passes with future-dated stamps in the baseline, and it shrinks only as people stop writing them — which is the behaviour the gate exists to produce. **93 files still carry a non-zero allowance**, so the drain isn't finished. If it stays noisy once those clear, the gate's fail-on-tighten contract is the thing to revisit, not the stamps. ## Measured | check | result | |---|---| | gate | red before, **exit 0 after**, stable across two consecutive runs | | baseline | −176/+25 entries, all date-rollover | | inert-seam · sql-literal · lane-wiring · census | all green | | reconciler's own suite | green | ## One correction to a claim I made earlier this session While investigating I reported the gate as hanging for 600s. It wasn't — the harness killed the process (exit 144) and the empty output made it look like a stall. The gate completes in seconds. Noting it because I nearly filed a performance bug against a healthy script. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
99 lines
4.2 KiB
JavaScript
99 lines
4.2 KiB
JavaScript
/**
|
|
* FNXC:PostgresCutover 2026-07-05-13:00:
|
|
* Shared PostgreSQL backend access for operational scripts.
|
|
*
|
|
* The ops/maintenance scripts under scripts/ used to open `.fusion/fusion.db`
|
|
* directly (node:sqlite or the sqlite3 CLI). After the PostgreSQL cutover the
|
|
* live data lives in the embedded PostgreSQL cluster (or an external cluster
|
|
* via DATABASE_URL), so a direct SQLite open would silently operate on a
|
|
* stale/empty marker file. Every script now boots the real backend through
|
|
* @fusion/core's startup factory via this helper.
|
|
*
|
|
* Requires packages/core to be built (`pnpm --filter @fusion/core build`).
|
|
*/
|
|
import { cpSync, existsSync, readdirSync } from "node:fs";
|
|
import { resolve, dirname } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
|
/**
|
|
* Stage the PostgreSQL migration SQL into core's dist. `tsc` emits
|
|
* only JS, so dist lacks src/postgres/migrations/*.sql; the schema applier
|
|
* resolves them relative to the compiled file (__dirname/migrations). The CLI
|
|
* bundle does the same staging in packages/cli/tsup.config.ts.
|
|
*/
|
|
function ensureMigrationsStaged() {
|
|
const src = resolve(repoRoot, "packages/core/src/postgres/migrations");
|
|
const dest = resolve(repoRoot, "packages/core/dist/postgres/migrations");
|
|
/*
|
|
* FNXC:AutomationIsolation 2026-07-13-22:37:
|
|
* Operational scripts must stage every versioned PostgreSQL migration, not merely the initial baseline, so an already-initialized database receives the automation project-isolation upgrade before scripts open it.
|
|
*/
|
|
const requiredMigrations = existsSync(src)
|
|
? readdirSync(src).filter((file) => file.endsWith(".sql"))
|
|
: [];
|
|
if (existsSync(src) && requiredMigrations.some((file) => !existsSync(resolve(dest, file)))) {
|
|
cpSync(src, dest, { recursive: true });
|
|
}
|
|
}
|
|
|
|
/* FNXC:OperatorScriptLaneAssumptions 2026-07-31-02:10: exported so operator scripts can reach core
|
|
helpers (lane resolution) through the SAME staged-dist seam `openBackend` already uses, rather than
|
|
each growing its own dist path — `@fusion/core` is not resolvable from the repo-root `scripts/`. */
|
|
export async function importCore() {
|
|
ensureMigrationsStaged();
|
|
try {
|
|
return await import(pathToFileURL(resolve(repoRoot, "packages/core/dist/index.js")).href);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(
|
|
`Unable to import packages/core/dist/index.js (${message}). Run: pnpm --filter @fusion/core build`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Boot a PostgreSQL-backed TaskStore for the given project root.
|
|
*
|
|
* Returns `{ core, store, asyncLayer, sql, schema, shutdown }`:
|
|
* - `core` — the @fusion/core module (for helpers like recordRunAuditEvent).
|
|
* - `store` — the initialized TaskStore (backend mode).
|
|
* - `asyncLayer` — the AsyncDataLayer; `asyncLayer.db.execute(sql\`...\`)`
|
|
* runs raw SQL. Project tables are schema-qualified (`project."tasks"`).
|
|
* - `sql` — the drizzle-orm `sql` template tag (re-exported as drizzleSql).
|
|
* - `schema` — postgresSchema (drizzle table objects, e.g. schema.project.tasks).
|
|
* - `shutdown` — releases the pool and stops an embedded cluster this boot
|
|
* started. Always call it in `finally`.
|
|
*
|
|
* Throws when PostgreSQL cannot start. These scripts must never fall back to
|
|
* the removed SQLite runtime.
|
|
*/
|
|
export async function openBackend(rootDir = process.cwd()) {
|
|
const core = await importCore();
|
|
const boot = await core.createTaskStoreForBackend({ rootDir });
|
|
const asyncLayer = boot.taskStore.getAsyncLayer();
|
|
if (!asyncLayer) {
|
|
await boot.shutdown().catch(() => {});
|
|
throw new Error("Backend TaskStore has no AsyncDataLayer; cannot run this script.");
|
|
}
|
|
return {
|
|
core,
|
|
store: boot.taskStore,
|
|
asyncLayer,
|
|
sql: core.drizzleSql,
|
|
schema: core.postgresSchema,
|
|
shutdown: boot.shutdown,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize a drizzle `db.execute(...)` result to a plain array of rows.
|
|
* postgres-js returns a RowList (array-like); this keeps call sites simple.
|
|
*/
|
|
export function rowsOf(result) {
|
|
if (Array.isArray(result)) return [...result];
|
|
if (result && Array.isArray(result.rows)) return [...result.rows];
|
|
return [];
|
|
}
|