fix: widen ce_sessions.last_activity_at to bigint so PG first-boot migration survives epoch-ms values

project.ce_sessions.last_activity_at stores Date.now() epoch milliseconds but
was declared integer in both the Drizzle shape and the CE plugin schema-hook
DDL, overflowing PG int4 during the SQLite -> PostgreSQL first-boot
auto-migration and blocking startup at task-store init. Now bigint in both
sites, with an idempotent ALTER for datadirs that already materialized the
integer column, plus a schema-wide invariant test that no numeric
*_at/*_time/*_timestamp column is 32-bit integer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-13 19:52:20 -07:00
parent c15c78feeb
commit eb5c81cc59
4 changed files with 62 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix startup failure where the SQLite → PostgreSQL migration aborted on CE session timestamps.
category: fix
dev: project.ce_sessions.last_activity_at was `integer` but stores epoch milliseconds, overflowing PG int4 and failing first-boot auto-migration. Now `bigint` in the Drizzle shape and CE plugin schema-hook DDL, with an idempotent `ALTER COLUMN ... TYPE bigint` for datadirs that already materialized the integer column.

View File

@@ -0,0 +1,39 @@
/*
FNXC:PostgresSchema 2026-07-13-19:40:
Regression invariant for the SQLite → PostgreSQL first-boot migration failure
where project.ce_sessions.last_activity_at was declared `integer` but stores
epoch milliseconds (Date.now() ≈ 1.78e12), overflowing PG integer (max ~2.1e9)
and aborting startup ("value ... is out of range for type integer").
Invariant across ALL schemas (project/central/archive/plugin): a numeric column
whose name marks it as a point-in-time value (`*_at`, `*_time`, `*_timestamp`)
must be bigint, never 32-bit integer. Timestamp columns stored as ISO text are
fine; durations/counters (`*_ms` intervals, counts) fit integer and are exempt.
*/
import { describe, it, expect } from "vitest";
import { getTableColumns, getTableName } from "drizzle-orm";
import { PgTable } from "drizzle-orm/pg-core";
import * as schema from "../../postgres/schema/index.js";
const TIMESTAMP_NAME = /(_at|_time|_timestamp)$/;
function collectTables(mod: Record<string, unknown>): PgTable[] {
return Object.values(mod).filter((v): v is PgTable => v instanceof PgTable);
}
describe("PG schema epoch-ms columns", () => {
it("declares every numeric *_at/*_time/*_timestamp column as bigint, not integer", () => {
const offenders: string[] = [];
for (const mod of [schema.project, schema.central, schema.archive, schema.plugin]) {
for (const table of collectTables(mod as Record<string, unknown>)) {
for (const column of Object.values(getTableColumns(table))) {
if (!TIMESTAMP_NAME.test(column.name)) continue;
if (column.getSQLType() === "integer") {
offenders.push(`${getTableName(table)}.${column.name}`);
}
}
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -107,10 +107,16 @@ export const cePluginSchemaInit: PluginSchemaInitHook = {
artifact_path text, artifact_path text,
error text, error text,
turn_interval_ms integer NOT NULL DEFAULT 120000, turn_interval_ms integer NOT NULL DEFAULT 120000,
last_activity_at integer NOT NULL, last_activity_at bigint NOT NULL,
created_at text NOT NULL, created_at text NOT NULL,
updated_at text NOT NULL updated_at text NOT NULL
); );
-- FNXC:PostgresSchema 2026-07-13-19:35:
-- last_activity_at holds epoch milliseconds (Date.now()), which overflows
-- PG integer. Datadirs created before this fix materialized the column as
-- integer via the CREATE TABLE IF NOT EXISTS above, so widen it in place.
-- Idempotent: ALTER ... TYPE bigint on an already-bigint column is a no-op.
ALTER TABLE project.ce_sessions ALTER COLUMN last_activity_at TYPE bigint;
CREATE INDEX IF NOT EXISTS "idxCeSessionsStatusUpdated" CREATE INDEX IF NOT EXISTS "idxCeSessionsStatusUpdated"
ON project.ce_sessions(status, updated_at DESC, id); ON project.ce_sessions(status, updated_at DESC, id);
CREATE INDEX IF NOT EXISTS "idxCeSessionsStageCreated" CREATE INDEX IF NOT EXISTS "idxCeSessionsStageCreated"

View File

@@ -16,7 +16,7 @@
* while still materializing on a fresh database. * while still materializing on a fresh database.
*/ */
import { text, integer, boolean, foreignKey, index, uniqueIndex } from "drizzle-orm/pg-core"; import { text, integer, bigint, boolean, foreignKey, index, uniqueIndex } from "drizzle-orm/pg-core";
import { projectSchema } from "./project.js"; import { projectSchema } from "./project.js";
/** /**
@@ -87,7 +87,14 @@ export const ceSessions = projectSchema.table("ce_sessions", {
artifactPath: text("artifact_path"), artifactPath: text("artifact_path"),
error: text("error"), error: text("error"),
turnIntervalMs: integer("turn_interval_ms").notNull().default(120000), turnIntervalMs: integer("turn_interval_ms").notNull().default(120000),
lastActivityAt: integer("last_activity_at").notNull(), /*
FNXC:PostgresSchema 2026-07-13-19:35:
last_activity_at stores epoch milliseconds (Date.now()), which overflows PG
integer (max ~2.1e9) — epoch-ms values are ~1.78e12. Must be bigint like the
other epoch-ms columns (see project.ts pr_* tables). An integer column here
broke the SQLite → PostgreSQL first-boot auto-migration and blocked startup.
*/
lastActivityAt: bigint("last_activity_at", { mode: "number" }).notNull(),
createdAt: text("created_at").notNull(), createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(), updatedAt: text("updated_at").notNull(),
}, (t) => [ }, (t) => [