diff --git a/.changeset/pg-ce-sessions-bigint-last-activity.md b/.changeset/pg-ce-sessions-bigint-last-activity.md new file mode 100644 index 0000000000..3c6fdf1aae --- /dev/null +++ b/.changeset/pg-ce-sessions-bigint-last-activity.md @@ -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. diff --git a/packages/core/src/__tests__/postgres/schema-epoch-ms-integer-overflow.test.ts b/packages/core/src/__tests__/postgres/schema-epoch-ms-integer-overflow.test.ts new file mode 100644 index 0000000000..a934bcdaf6 --- /dev/null +++ b/packages/core/src/__tests__/postgres/schema-epoch-ms-integer-overflow.test.ts @@ -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): 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)) { + 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([]); + }); +}); diff --git a/packages/core/src/postgres/plugin-schema-hook.ts b/packages/core/src/postgres/plugin-schema-hook.ts index de9c145529..99011dddac 100644 --- a/packages/core/src/postgres/plugin-schema-hook.ts +++ b/packages/core/src/postgres/plugin-schema-hook.ts @@ -107,10 +107,16 @@ export const cePluginSchemaInit: PluginSchemaInitHook = { artifact_path text, error text, 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, 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" ON project.ce_sessions(status, updated_at DESC, id); CREATE INDEX IF NOT EXISTS "idxCeSessionsStageCreated" diff --git a/packages/core/src/postgres/schema/plugin.ts b/packages/core/src/postgres/schema/plugin.ts index 4da7bf699d..08fbf75bc2 100644 --- a/packages/core/src/postgres/schema/plugin.ts +++ b/packages/core/src/postgres/schema/plugin.ts @@ -16,7 +16,7 @@ * 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"; /** @@ -87,7 +87,14 @@ export const ceSessions = projectSchema.table("ce_sessions", { artifactPath: text("artifact_path"), error: text("error"), 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(), updatedAt: text("updated_at").notNull(), }, (t) => [