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:
7
.changeset/pg-ce-sessions-bigint-last-activity.md
Normal file
7
.changeset/pg-ce-sessions-bigint-last-activity.md
Normal 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.
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) => [
|
||||||
|
|||||||
Reference in New Issue
Block a user