**`main` is currently red on the FNXC gate.** ``` $ node scripts/check-fnxc-future-dates.mjs # on origin/main scripts/reconcile-task-state-consistency.mjs: 2 future-dated FNXC stamp(s), baseline allows 0 scripts/lib/backend-db.mjs: 1 scripts/__tests__/reconcile-task-state-consistency.test.mjs: 1 exit 1 ``` #2994 carried four `2026-07-30-26:10` stamps. I flagged them on that PR before it merged; #2995 (the hour check) landed first, so the merge order turned the warning into a red gate rather than a red PR. Clamped to `23:10` — same rule as the nine before it: hour to `23`, minutes preserved, so ordering within each file survives. This is a normalization with a stated rule, not a claim about the true minute. **Verified:** FNXC gate exit 0, `reconcile-task-state-consistency` 8 pass / 0 fail. Comment-text only. ### Worth fixing at the source Thirteen impossible-hour stamps across six PRs in two days, and the hours climb — `24:40` → `25:30` → `26:10`. They are being written as a continuing sequence past midnight rather than read off a clock, which is a reasonable instinct and produces an invalid stamp every time. The trap is that the honest spelling does not work either: a genuine post-midnight stamp needs *tomorrow's* date, and the gate compares against the **local** calendar — so `2026-07-31-00:40` written from UTC-7 is future-dated and fails for a different reason. Clamping to `23:xx` is currently the only spelling that satisfies both, which is not obvious and is why this keeps recurring. If it recurs again, the fix is probably in the error message rather than more normalization PRs: the gate could name the valid range and the timezone it compares against, so the next author sees the constraint at the moment they hit it. 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-30-23: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 [];
|
|
}
|