Files
fusion/scripts/lib/backend-db.mjs
gsxdsm 52a66297fc fix(gate): main is red — normalize #2994's four impossible-hour stamps (#3006)
**`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>
2026-07-31 00:16:39 -07:00

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 [];
}