From fb125b6f5e01e65610710afdaa62b968b33fb08c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 8 Aug 2026 23:16:01 -0700 Subject: [PATCH] FN-8852: exclude heartbeat writes from revision history Prevent liveness bookkeeping from evicting real settings changes while making revision history pageable. - Exclude engineLastActiveAt from project revision snapshots and preserve its live value during rollback. - Add validated limit/offset paging with hasMore to configuration revision stores and the API. - Cover heartbeat filtering, rollback compatibility, and paging contracts; document the behavior and add a patch changeset. Files changed: .changeset/fn-8852-config-revision-heartbeat.md | 7 + docs/settings-reference.md | 4 + docs/storage.md | 6 + .../__tests__/configuration-revision-store.test.ts | 178 ++++++++++++++++++++- .../configuration-revision-heartbeat.pg.test.ts | 118 ++++++++++++++ .../async-configuration-revision-store.ts | 46 ++++-- .../src/config/configuration-revision-store.ts | 9 +- packages/core/src/config/global-settings.ts | 2 +- packages/core/src/config/settings-schema.ts | 20 +++ packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/task-store/task-mutation-ops.ts | 24 ++- packages/core/src/types.ts | 6 + packages/core/src/types/settings/settings-scope.ts | 3 + .../register-org-portability-routes.test.ts | 19 ++- .../src/routes/register-org-portability-routes.ts | 18 ++- 16 files changed, 437 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-8852 Fusion-Task-Lineage: 46d5ac9d-d2c0-4294-a5f7-d228fcb341c9 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8852-config-revision-heartbeat.md | 7 + docs/settings-reference.md | 4 + docs/storage.md | 6 + .../configuration-revision-store.test.ts | 178 +++++++++++++++++- ...onfiguration-revision-heartbeat.pg.test.ts | 118 ++++++++++++ .../async-configuration-revision-store.ts | 50 +++-- .../config/configuration-revision-store.ts | 9 +- packages/core/src/config/global-settings.ts | 2 +- packages/core/src/config/settings-schema.ts | 20 ++ packages/core/src/index.gate.ts | 2 +- packages/core/src/index.ts | 2 +- .../core/src/task-store/task-mutation-ops.ts | 24 ++- packages/core/src/types.ts | 6 + .../core/src/types/settings/settings-scope.ts | 3 + .../register-org-portability-routes.test.ts | 19 +- .../routes/register-org-portability-routes.ts | 18 +- 16 files changed, 439 insertions(+), 29 deletions(-) create mode 100644 .changeset/fn-8852-config-revision-heartbeat.md create mode 100644 packages/core/src/__tests__/postgres/configuration-revision-heartbeat.pg.test.ts diff --git a/.changeset/fn-8852-config-revision-heartbeat.md b/.changeset/fn-8852-config-revision-heartbeat.md new file mode 100644 index 0000000000..8c72d4836d --- /dev/null +++ b/.changeset/fn-8852-config-revision-heartbeat.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent engine heartbeat noise from flooding settings history and add revision API paging. +category: fix +dev: Uses a non-versioned key registry, preserves live heartbeat values on rollback, and adds limit/offset/hasMore. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 15fc9b31de..09d4606b8b 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1909,3 +1909,7 @@ Settings → Authentication can hold multiple named credential accounts for each ### Workflow principal limits `runtimeConfig.maxWorkflowSessions` is an optional per-agent cap for durable workflow sessions. It is independent of heartbeat `maxConcurrentRuns`: enabling a built-in agent heartbeat neither consumes nor changes workflow-session capacity. Scheduler release, executor graph admission and re-entry, mission start, and workflow-stage routing are governed by durable workflow principals and their configured capacity. + +### Engine liveness heartbeat + +`engineLastActiveAt` is engine liveness bookkeeping. It is deliberately non-versioned and is preserved from the live settings object when a project configuration rollback restores a historic snapshot. diff --git a/docs/storage.md b/docs/storage.md index 38454c8f41..21f7ec8576 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -822,3 +822,9 @@ FN-8685 adds `task_lifecycle_consumer_registrations`, `task_lifecycle_consumer_c Automatic admission locks the project-scoped feature row, records a running row only for an admitted dispatch, and writes one `validation memoized` mission activity event for each suppressed running/pass/budget decision. Matching static passes are reused without fabricating a run. Matching failed rows consume the per-fingerprint budget; exhaustion records `loop_state = blocked` plus fingerprint/run/timestamp provenance and emits exactly one additional `validation-stuck` event for that feature/fingerprint. Repeated unchanged suppressions remain individually auditable but do not repeat the stuck event. Reaped `error` and `blocked` runs are transient and do not seed reuse or the failure count. `project.agents.roles` is a normalized JSONB role-tag array. Migration `0045_fn_8764_multi_role_workflow_agents.sql` backfills it from legacy singular roles. `workflow_work_items` also persist the routed principal fence fields used for recovery and audit. + +### Revision heartbeat and paging (FN-8852) + +`engineLastActiveAt` is engine liveness bookkeeping, not operator configuration. It is a non-versioned project-settings key: revision diffs and stored snapshots omit it, while the live project setting remains written normally. Project-settings rollback overlays live non-versioned values over both modern stripped snapshots and legacy snapshots, so rollback cannot delete or resurrect a stale heartbeat. `appendConfigurationRevision` deliberately remains an unfiltered raw writer for migrations and legacy fixtures; a heartbeat-only rollback is rejected as already restored without writing. + +Revision listing defaults to 100 rows and clamps `limit` to 1–500. The API accepts `limit` and `offset` and returns `hasMore`, determined by fetching `limit + 1` rows rather than a count query. Rows are ordered `createdAt DESC, sequence DESC`. Since history is append-only, rows appended between offset page requests can shift offsets and be observed twice; they are not silently skipped backwards. diff --git a/packages/core/src/__tests__/configuration-revision-store.test.ts b/packages/core/src/__tests__/configuration-revision-store.test.ts index 5142633d4f..caad442005 100644 --- a/packages/core/src/__tests__/configuration-revision-store.test.ts +++ b/packages/core/src/__tests__/configuration-revision-store.test.ts @@ -1,9 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { readFile } from "node:fs/promises"; + +const settingsOps = vi.hoisted(() => ({ + readProjectConfig: vi.fn(), + writeProjectConfig: vi.fn(), +})); +vi.mock("../task-store/async/async-settings.js", () => settingsOps); + import { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, + listConfigurationRevisionsPage, } from "../async-stores/async-configuration-revision-store.js"; +import { createProjectSettingsRollbackSnapshotOps, rollbackConfigurationImpl } from "../task-store/task-mutation-ops.js"; +import { ConfigurationRevisionStore } from "../config/configuration-revision-store.js"; +import { GlobalSettingsStore } from "../config/global-settings.js"; describe("configuration revision snapshots", () => { it("uses canonical structured target identity independent of key order", () => { @@ -24,3 +36,167 @@ describe("configuration revision snapshots", () => { .toEqual([{ field: "deleted", oldValue: 2, newValue: undefined }]); }); }); + +describe("non-versioned project settings", () => { + it("omits heartbeat-only and strips heartbeat from mixed project revisions", async () => { + const { mergeRestoredProjectSettings, NON_VERSIONED_SETTINGS_KEYS, isProjectSettingsKey } = await import("../config/settings-schema.js"); + expect(NON_VERSIONED_SETTINGS_KEYS.every(isProjectSettingsKey)).toBe(true); + expect(createConfigurationRevision({ projectId: "p", ownerScope: "project", configKind: "project-settings", configTarget: { projectId: "p" }, before: { engineLastActiveAt: "old" }, after: { engineLastActiveAt: "new" }, changedBy: { kind: "system", id: "engine" } })).toBeNull(); + const revision = createConfigurationRevision({ projectId: "p", ownerScope: "project", configKind: "project-settings", configTarget: { projectId: "p" }, before: { engineLastActiveAt: "old", autoMerge: false }, after: { engineLastActiveAt: "new", autoMerge: true }, changedBy: { kind: "system", id: "engine" } }); + expect(revision?.diffs).toEqual([{ field: "autoMerge", oldValue: false, newValue: true }]); + expect(revision?.before).toEqual({ autoMerge: false }); + const snapshot = { engineLastActiveAt: "stale", autoMerge: false }; + const live = { engineLastActiveAt: "live", extra: true }; + expect(mergeRestoredProjectSettings(snapshot, live)).toEqual({ engineLastActiveAt: "live", autoMerge: false }); + expect(mergeRestoredProjectSettings({ autoMerge: false }, live)).toEqual({ autoMerge: false, engineLastActiveAt: "live" }); + expect(mergeRestoredProjectSettings({ engineLastActiveAt: "stale", autoMerge: false }, {})).toEqual({ autoMerge: false }); + expect(snapshot).toEqual({ engineLastActiveAt: "stale", autoMerge: false }); + expect(live).toEqual({ engineLastActiveAt: "live", extra: true }); + }); + + it("treats an absent or undefined heartbeat as a non-versioned no-op", () => { + const input = { projectId: "p", ownerScope: "project" as const, configKind: "project-settings" as const, configTarget: { projectId: "p" }, changedBy: { kind: "system" as const, id: "engine" } }; + expect(createConfigurationRevision({ ...input, before: {}, after: { engineLastActiveAt: "fresh" } })).toBeNull(); + expect(createConfigurationRevision({ ...input, before: { engineLastActiveAt: undefined }, after: { engineLastActiveAt: undefined } })).toBeNull(); + }); + + it("does not filter a same-named field outside project settings", () => { + for (const configKind of ["global-settings", "workflow-settings", "routine", "automation"] as const) { + expect(createConfigurationRevision({ projectId: "p", ownerScope: "project", configKind, configTarget: { id: configKind }, before: { engineLastActiveAt: "old" }, after: { engineLastActiveAt: "new" }, changedBy: { kind: "system", id: "test" } })?.diffs) + .toEqual([{ field: "engineLastActiveAt", oldValue: "old", newValue: "new" }]); + } + }); + + it("makes the project-bound facade inherit the skip while leaving the raw writer verbatim", async () => { + const values = vi.fn().mockResolvedValue(undefined); + const insert = vi.fn().mockReturnValue({ values }); + const handle = { insert }; + const store = new ConfigurationRevisionStore({ db: handle, projectId: "p" } as never, "p"); + await expect(store.append({ ownerScope: "project", configKind: "project-settings", configTarget: { projectId: "p" }, before: { engineLastActiveAt: "old" }, after: { engineLastActiveAt: "new" }, changedBy: { kind: "system", id: "engine" } })).resolves.toBeNull(); + expect(insert).not.toHaveBeenCalled(); + const raw = { + id: "legacy", projectId: "p", ownerScope: "project" as const, configKind: "project-settings" as const, + configTarget: { projectId: "p" }, configTargetKey: '{"projectId":"p"}', before: { engineLastActiveAt: "old" }, after: { engineLastActiveAt: "new" }, + diffs: [{ field: "engineLastActiveAt", oldValue: "old", newValue: "new" }], changedBy: { kind: "system" as const, id: "legacy" }, source: "mutation" as const, createdAt: "2026-08-09T00:00:00.000Z", + }; + const { appendConfigurationRevision } = await import("../async-stores/async-configuration-revision-store.js"); + await appendConfigurationRevision(handle as never, raw); + expect(values).toHaveBeenCalledWith(expect.objectContaining({ before: raw.before, after: raw.after, diffs: raw.diffs })); + }); +}); + +describe("configuration revision paging", () => { + it("clamps invalid core paging inputs before building the query", async () => { + const calls: { limit?: number; offset?: number } = {}; + const rows = [{ id: "one" }]; + const query = { + from: () => query, + where: () => query, + orderBy: () => query, + limit: (value: number) => { calls.limit = value; return query; }, + offset: async (value: number) => { calls.offset = value; return rows; }, + }; + const page = await listConfigurationRevisionsPage({ select: () => query } as never, { + projectId: "p", configKind: "project-settings", configTarget: { projectId: "p" }, limit: Number.NaN, offset: Number.NaN, + }); + expect(calls).toEqual({ limit: 101, offset: 0 }); + expect(page).toMatchObject({ hasMore: false, revisions: [{ id: "one" }] }); + }); + + it("preserves positional-number compatibility through project and global revision facades", async () => { + const calls: Array<{ limit: number; offset: number }> = []; + const query = { + from: () => query, where: () => query, orderBy: () => query, + limit: (limit: number) => { calls.push({ limit, offset: -1 }); return query; }, + offset: async (offset: number) => { calls.at(-1)!.offset = offset; return []; }, + }; + const target = { projectId: "p" }; + const project = new ConfigurationRevisionStore({ db: { select: () => query }, projectId: "p" } as never, "p"); + await project.list("project-settings", target, 7); + const tx = { execute: vi.fn(), select: () => query }; + const global = new GlobalSettingsStore("/tmp/fusion-fn-8852-global-settings", { + transactionImmediate: async (callback: (transaction: typeof tx) => Promise) => callback(tx), + } as never); + await global.listConfigurationRevisions("global-settings", { scope: "user-global" }, 9); + expect(calls).toEqual([{ limit: 8, offset: 0 }, { limit: 10, offset: 0 }]); + }); + + it("uses the documented limit boundaries and limit-plus-one hasMore probe", async () => { + const limits: number[] = []; + const query = { + from: () => query, where: () => query, orderBy: () => query, + limit: (value: number) => { limits.push(value); return query; }, + offset: async () => Array.from({ length: limits.at(-1)! }, (_, index) => ({ id: String(index) })), + }; + const base = { projectId: "p", configKind: "project-settings" as const, configTarget: { projectId: "p" } }; + expect((await listConfigurationRevisionsPage({ select: () => query } as never, { ...base, limit: 0 })).revisions).toHaveLength(1); + expect((await listConfigurationRevisionsPage({ select: () => query } as never, { ...base, limit: 1000 })).revisions).toHaveLength(500); + expect(limits).toEqual([2, 501]); + }); +}); + +describe("project settings rollback snapshot operations", () => { + it("uses the identical transaction for reads and writes and overlays the live heartbeat", async () => { + const tx = {}; + settingsOps.readProjectConfig.mockResolvedValueOnce({ settings: { autoMerge: false } }) + .mockResolvedValueOnce({ settings: { autoMerge: false, engineLastActiveAt: "live" } }); + settingsOps.writeProjectConfig.mockResolvedValue(undefined); + const ops = createProjectSettingsRollbackSnapshotOps({} as never, tx as never); + expect(await ops.readCurrent()).toEqual({ autoMerge: false }); + await ops.replace({ autoMerge: true, engineLastActiveAt: "stale" }); + expect(settingsOps.readProjectConfig).toHaveBeenNthCalledWith(1, expect.anything(), tx); + expect(settingsOps.readProjectConfig).toHaveBeenNthCalledWith(2, expect.anything(), tx); + expect(settingsOps.writeProjectConfig).toHaveBeenCalledWith(expect.anything(), { autoMerge: true, engineLastActiveAt: "live" }, undefined, tx); + }); + + it("re-overlays a live heartbeat for a stripped snapshot with the same transaction", async () => { + const tx = {}; + settingsOps.readProjectConfig.mockReset(); + settingsOps.writeProjectConfig.mockReset(); + settingsOps.readProjectConfig.mockResolvedValue({ settings: { autoMerge: false, engineLastActiveAt: "live" } }); + const ops = createProjectSettingsRollbackSnapshotOps({} as never, tx as never); + await ops.replace({ autoMerge: true }); + expect(settingsOps.readProjectConfig).toHaveBeenCalledWith(expect.anything(), tx); + expect(settingsOps.writeProjectConfig).toHaveBeenCalledWith(expect.anything(), { autoMerge: true, engineLastActiveAt: "live" }, undefined, tx); + }); + + it("keeps an absent live heartbeat absent", async () => { + const tx = {}; + settingsOps.readProjectConfig.mockReset(); + settingsOps.writeProjectConfig.mockReset(); + settingsOps.readProjectConfig.mockResolvedValue({ settings: { autoMerge: false } }); + const ops = createProjectSettingsRollbackSnapshotOps({} as never, tx as never); + await ops.replace({ autoMerge: true, engineLastActiveAt: "stale" }); + expect(settingsOps.writeProjectConfig).toHaveBeenCalledWith(expect.anything(), { autoMerge: true }, undefined, tx); + }); + + it("keeps workflow-settings rollback behavior independent of project snapshot operations", async () => { + const revision = { + id: "workflow-revision", projectId: "p", ownerScope: "project" as const, configKind: "workflow-settings" as const, + configTarget: { workflowId: "wf", projectId: "p" }, configTargetKey: '{"projectId":"p","workflowId":"wf"}', + before: { enabled: false }, after: { enabled: true }, diffs: [{ field: "enabled", oldValue: false, newValue: true }], + changedBy: { kind: "human" as const, id: "operator" }, source: "mutation" as const, createdAt: "2026-08-09T00:00:00.000Z", + }; + const revisionQuery = { from: () => revisionQuery, where: () => revisionQuery, limit: vi.fn().mockResolvedValue([revision]) }; + const settingsQuery = { from: () => settingsQuery, where: () => settingsQuery, limit: vi.fn().mockResolvedValue([{ values: { enabled: true } }]) }; + const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined); + const tx = { + select: (...args: unknown[]) => args.length > 0 ? settingsQuery : revisionQuery, + insert: vi.fn(() => ({ values: vi.fn(() => ({ onConflictDoUpdate })) })), + }; + const layer = { + db: { select: () => revisionQuery }, + transactionImmediate: vi.fn(async (callback: (transaction: typeof tx) => Promise) => callback(tx)), + }; + const store = { backendMode: true, asyncLayer: layer, getSettings: vi.fn().mockResolvedValue({}), emit: vi.fn() }; + await expect(rollbackConfigurationImpl(store as never, "workflow-revision")).resolves.toMatchObject({ rollbackToRevisionId: "workflow-revision" }); + expect(settingsQuery.limit).toHaveBeenCalled(); + expect(onConflictDoUpdate).toHaveBeenCalled(); + }); + + it("keeps rollback wired through the extracted production factory", async () => { + const source = await readFile(new URL("../task-store/task-mutation-ops.ts", import.meta.url), "utf8"); + expect(source).toContain("createProjectSettingsRollbackSnapshotOps(layer, tx)"); + expect(source).not.toContain("writeProjectConfig(layer, snapshot"); + }); +}); diff --git a/packages/core/src/__tests__/postgres/configuration-revision-heartbeat.pg.test.ts b/packages/core/src/__tests__/postgres/configuration-revision-heartbeat.pg.test.ts new file mode 100644 index 0000000000..4860ff3f98 --- /dev/null +++ b/packages/core/src/__tests__/postgres/configuration-revision-heartbeat.pg.test.ts @@ -0,0 +1,118 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest"; +import { randomUUID } from "node:crypto"; +import { pgDescribe, createSharedPgTaskStoreTestHarness, type SharedPgTaskStoreHarness } from "../../__test-utils__/pg-test-harness.js"; +import { + appendConfigurationRevision, + getConfigurationRevision, +} from "../../async-stores/async-configuration-revision-store.js"; +import { ConfigurationRevisionStore } from "../../config/configuration-revision-store.js"; +import { readProjectConfig } from "../../task-store/async/async-settings.js"; + +const pgTest = pgDescribe; + +pgTest("configuration revision heartbeat isolation", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_revision_heartbeat", projectId: "revision-heartbeat" }); + beforeAll(h.beforeAll); beforeEach(h.beforeEach); afterEach(h.afterEach); afterAll(h.afterAll); + + it("keeps a real revision queryable after 120 heartbeat writes", async () => { + const store = h.store(); + const layer = store.getAsyncLayer()!; + const target = { projectId: layer.projectId ?? "" }; + const revisionStore = new ConfigurationRevisionStore(layer, layer.projectId); + await store.updateSettings({ autoMerge: false }); + const rowsBeforeHeartbeats = (await revisionStore.list("project-settings", target)).length; + for (let index = 0; index < 120; index++) { + await store.updateSettings({ engineLastActiveAt: new Date(1_700_000_000_000 + index).toISOString() }); + } + const revisions = await revisionStore.list("project-settings", target); + expect(revisions).toHaveLength(rowsBeforeHeartbeats); + expect(revisions).toContainEqual(expect.objectContaining({ diffs: expect.arrayContaining([expect.objectContaining({ field: "autoMerge" })]) })); + expect((await store.getSettings()).engineLastActiveAt).toBe(new Date(1_700_000_000_119).toISOString()); + }); + + it("records only audit-worthy fields from a mixed heartbeat write", async () => { + const store = h.store(); + const layer = store.getAsyncLayer()!; + const target = { projectId: layer.projectId ?? "" }; + const revisionStore = new ConfigurationRevisionStore(layer, layer.projectId); + await store.updateSettings({ autoMerge: false, engineLastActiveAt: "2026-08-09T04:33:00.000Z" }); + const [revision] = await revisionStore.list("project-settings", target); + expect(revision.diffs).toEqual([expect.objectContaining({ field: "autoMerge", oldValue: true, newValue: false })]); + expect(revision.before).not.toHaveProperty("engineLastActiveAt"); + expect(revision.after).not.toHaveProperty("engineLastActiveAt"); + expect((await store.getSettings()).engineLastActiveAt).toBe("2026-08-09T04:33:00.000Z"); + }); + + it("preserves a fresh heartbeat while rolling back a genuinely legacy snapshot", async () => { + const store = h.store(); + const layer = store.getAsyncLayer()!; + const target = { projectId: layer.projectId ?? "" }; + await store.updateSettings({ autoMerge: false, engineLastActiveAt: "2026-01-01T00:00:00.000Z" }); + const live = (await readProjectConfig(layer)).settings ?? {}; + const stale = "2000-01-01T00:00:00.000Z"; + const id = randomUUID(); + await appendConfigurationRevision(layer.db, { + id, projectId: layer.projectId ?? "", ownerScope: "project", configKind: "project-settings", configTarget: target, + configTargetKey: JSON.stringify(target), before: { ...live, autoMerge: true, engineLastActiveAt: stale }, after: { ...live, autoMerge: false, engineLastActiveAt: stale }, + diffs: [{ field: "autoMerge", oldValue: true, newValue: false }, { field: "engineLastActiveAt", oldValue: stale, newValue: stale }], + changedBy: { kind: "system", id: "legacy-fixture" }, source: "mutation", createdAt: "2000-01-01T00:00:00.000Z", + }); + // The raw writer intentionally retains this legacy shape; otherwise this test cannot detect stale resurrection. + expect((await getConfigurationRevision(layer.db, layer.projectId ?? "", id))?.before).toMatchObject({ engineLastActiveAt: stale }); + const fresh = "2026-08-09T04:34:00.000Z"; + await store.updateSettings({ engineLastActiveAt: fresh }); + const rollback = await store.rollbackConfiguration(id); + const restored = await store.getSettings(); + expect(restored.autoMerge).toBe(true); + expect(restored.engineLastActiveAt).toBe(fresh); + const storedRollback = await getConfigurationRevision(layer.db, layer.projectId ?? "", rollback.id); + expect(rollback.diffs).toContainEqual(expect.objectContaining({ field: "autoMerge", oldValue: false, newValue: true })); + expect(storedRollback?.diffs).not.toContainEqual(expect.objectContaining({ field: "engineLastActiveAt" })); + expect(storedRollback?.before).not.toHaveProperty("engineLastActiveAt"); + expect(storedRollback?.after).not.toHaveProperty("engineLastActiveAt"); + }); + + it("rejects a heartbeat-only legacy rollback without writing or emitting", async () => { + const store = h.store(); + const layer = store.getAsyncLayer()!; + const target = { projectId: layer.projectId ?? "" }; + await store.updateSettings({ engineLastActiveAt: "2026-08-09T04:35:00.000Z" }); + const live = (await readProjectConfig(layer)).settings ?? {}; + const revisionStore = new ConfigurationRevisionStore(layer, layer.projectId); + const id = randomUUID(); + await appendConfigurationRevision(layer.db, { + id, projectId: layer.projectId ?? "", ownerScope: "project", configKind: "project-settings", configTarget: target, + configTargetKey: JSON.stringify(target), before: { ...live, engineLastActiveAt: "2001-01-01T00:00:00.000Z" }, after: live, + diffs: [{ field: "engineLastActiveAt", oldValue: "2001-01-01T00:00:00.000Z", newValue: live.engineLastActiveAt }], + changedBy: { kind: "system", id: "legacy-fixture" }, source: "mutation", createdAt: "2001-01-01T00:00:00.000Z", + }); + const beforeRows = await revisionStore.list("project-settings", target, 500); + const beforeSettings = structuredClone(await store.getSettings()); + const updated = vi.fn(); + store.on("settings:updated", updated); + await expect(store.rollbackConfiguration(id)).rejects.toThrow(/is already restored/); + expect(await revisionStore.list("project-settings", target, 500)).toHaveLength(beforeRows.length); + expect(await store.getSettings()).toEqual(beforeSettings); + expect(updated).not.toHaveBeenCalled(); + }); + + it("returns stable newest-first offset pages without a count query", async () => { + const store = h.store(); + const layer = store.getAsyncLayer()!; + const target = { projectId: layer.projectId ?? "" }; + const revisionStore = new ConfigurationRevisionStore(layer, layer.projectId); + for (let value = 2; value <= 7; value++) await store.updateSettings({ maxConcurrentTasks: value }); + const first = await revisionStore.listPage("project-settings", target, { limit: 2, offset: 0 }); + const second = await revisionStore.listPage("project-settings", target, { limit: 2, offset: 2 }); + const all = await revisionStore.list("project-settings", target, 500); + const final = await revisionStore.listPage("project-settings", target, { limit: 2, offset: Math.max(0, all.length - 2) }); + const beyond = await revisionStore.listPage("project-settings", target, { limit: 2, offset: all.length + 100 }); + expect(first.hasMore).toBe(true); + expect(second.hasMore).toBe(true); + expect(final.hasMore).toBe(false); + expect(first.revisions.map((revision) => revision.id)).toEqual(all.slice(0, 2).map((revision) => revision.id)); + expect(second.revisions.map((revision) => revision.id)).toEqual(all.slice(2, 4).map((revision) => revision.id)); + expect(first.revisions.map((revision) => revision.id)).not.toEqual(expect.arrayContaining(second.revisions.map((revision) => revision.id))); + expect(beyond).toEqual({ revisions: [], hasMore: false }); + }); +}); diff --git a/packages/core/src/async-stores/async-configuration-revision-store.ts b/packages/core/src/async-stores/async-configuration-revision-store.ts index 69780dd4b5..6f75c1a53f 100644 --- a/packages/core/src/async-stores/async-configuration-revision-store.ts +++ b/packages/core/src/async-stores/async-configuration-revision-store.ts @@ -2,6 +2,7 @@ import { and, desc, eq, sql } from "drizzle-orm"; import { randomUUID } from "node:crypto"; import { schema } from "../postgres/index.js"; import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js"; +import { isNonVersionedSettingsKey } from "../config/settings-schema.js"; type QueryHandle = AsyncDataLayer["db"] | DbTransaction; import type { @@ -44,7 +45,14 @@ export function createConfigurationRevision(input: { rollbackToRevisionId?: string; createdAt?: string; }): ConfigurationRevision | null { - const diffs = diffConfigurationSnapshots(input.before, input.after); + const projectSettings = input.configKind === "project-settings"; + const strip = (value: unknown) => value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value as Record).filter(([key]) => !isNonVersionedSettingsKey(key))) + : value; + const before = projectSettings ? strip(input.before) : input.before; + const after = projectSettings ? strip(input.after) : input.after; + const diffs = diffConfigurationSnapshots(input.before, input.after) + .filter((diff) => !projectSettings || !isNonVersionedSettingsKey(diff.field)); if (diffs.length === 0) return null; return { id: randomUUID(), @@ -53,8 +61,8 @@ export function createConfigurationRevision(input: { configKind: input.configKind, configTarget: input.configTarget, configTargetKey: configurationTargetKey(input.configTarget), - before: input.before, - after: input.after, + before, + after, diffs, changedBy: input.changedBy, source: input.source ?? "mutation", @@ -118,7 +126,7 @@ export async function listGlobalConfigurationRevisions( layer: AsyncDataLayer, configKind: ConfigKind, configTarget: ConfigurationTarget, - limit?: number, + paging?: number | { limit?: number; offset?: number }, ): Promise { return layer.transactionImmediate(async (tx) => { /* FNXC:ConfigVersioning 2026-07-18-02:00: global history listing is privileged only for the reserved central owner, matching the writer and preserving newest-first target filtering. */ @@ -127,24 +135,42 @@ export async function listGlobalConfigurationRevisions( projectId: GLOBAL_CONFIGURATION_OWNER_ID, configKind, configTarget, - limit, + ...(typeof paging === "number" ? { limit: paging } : paging), }); }); } +export async function listConfigurationRevisionsPage(handle: QueryHandle, params: { + projectId: string; + configKind: ConfigKind; + configTarget: ConfigurationTarget; + limit?: number; + offset?: number; +}): Promise<{ revisions: ConfigurationRevision[]; hasMore: boolean }> { + /* FNXC:ConfigVersioning 2026-08-09-04:09: Paging prevents the former 100-row window from hiding history. Clamp at the core boundary and use a limit+1 probe rather than COUNT; append-only offset pages can duplicate a row after concurrent appends but never silently skip history. */ + const clampInteger = (value: number | undefined, fallback: number, minimum: number, maximum = Number.MAX_SAFE_INTEGER) => { + const integer = typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : fallback; + return Math.min(maximum, Math.max(minimum, integer)); + }; + const limit = clampInteger(params.limit, 100, 1, 500); + const offset = clampInteger(params.offset, 0, 0); + const rows = await handle.select().from(schema.project.configurationRevisions).where(and( + eq(schema.project.configurationRevisions.projectId, params.projectId), + eq(schema.project.configurationRevisions.configKind, params.configKind), + eq(schema.project.configurationRevisions.configTargetKey, configurationTargetKey(params.configTarget)), + )).orderBy(desc(schema.project.configurationRevisions.createdAt), desc(schema.project.configurationRevisions.sequence)).limit(limit + 1).offset(offset); + const hasMore = rows.length > limit; + return { revisions: rows.slice(0, limit).map((row) => ({ ...row, configTarget: row.configTarget as ConfigurationTarget, before: row.before, after: row.after, diffs: row.diffs as RevisionFieldDiff[], changedBy: row.changedBy as ConfigChangedBy, ownerScope: row.ownerScope as ConfigurationOwnerScope, configKind: row.configKind as ConfigKind, source: row.source as "mutation" | "rollback", rollbackToRevisionId: row.rollbackToRevisionId ?? undefined })), hasMore }; +} + export async function listConfigurationRevisions(handle: QueryHandle, params: { projectId: string; configKind: ConfigKind; configTarget: ConfigurationTarget; limit?: number; + offset?: number; }): Promise { - const rows = await handle.select().from(schema.project.configurationRevisions).where(and( - eq(schema.project.configurationRevisions.projectId, params.projectId), - eq(schema.project.configurationRevisions.configKind, params.configKind), - eq(schema.project.configurationRevisions.configTargetKey, configurationTargetKey(params.configTarget)), - /* FNXC:ConfigVersioning 2026-07-18-14:00: createdAt has only millisecond precision; sequence preserves newest-first order for serialized same-millisecond mutations. */ - )).orderBy(desc(schema.project.configurationRevisions.createdAt), desc(schema.project.configurationRevisions.sequence)).limit(params.limit ?? 100); - return rows.map((row) => ({ ...row, configTarget: row.configTarget as ConfigurationTarget, before: row.before, after: row.after, diffs: row.diffs as RevisionFieldDiff[], changedBy: row.changedBy as ConfigChangedBy, ownerScope: row.ownerScope as ConfigurationOwnerScope, configKind: row.configKind as ConfigKind, source: row.source as "mutation" | "rollback", rollbackToRevisionId: row.rollbackToRevisionId ?? undefined })); + return (await listConfigurationRevisionsPage(handle, params)).revisions; } export async function getConfigurationRevision(handle: QueryHandle, projectId: string, id: string): Promise { diff --git a/packages/core/src/config/configuration-revision-store.ts b/packages/core/src/config/configuration-revision-store.ts index 8bb36fcf34..ec0544691a 100644 --- a/packages/core/src/config/configuration-revision-store.ts +++ b/packages/core/src/config/configuration-revision-store.ts @@ -5,6 +5,7 @@ import { createConfigurationRevision, getConfigurationRevision, listConfigurationRevisions, + listConfigurationRevisionsPage, } from "../async-stores/async-configuration-revision-store.js"; /** @@ -21,8 +22,12 @@ export class ConfigurationRevisionStore { return revision; } - list(configKind: ConfigKind, configTarget: ConfigurationTarget, limit?: number): Promise { - return listConfigurationRevisions(this.layer.db, { projectId: this.ownerProjectId, configKind, configTarget, limit }); + list(configKind: ConfigKind, configTarget: ConfigurationTarget, paging?: number | { limit?: number; offset?: number }): Promise { + return listConfigurationRevisions(this.layer.db, { projectId: this.ownerProjectId, configKind, configTarget, ...(typeof paging === "number" ? { limit: paging } : paging) }); + } + + listPage(configKind: ConfigKind, configTarget: ConfigurationTarget, paging?: number | { limit?: number; offset?: number }): Promise<{ revisions: ConfigurationRevision[]; hasMore: boolean }> { + return listConfigurationRevisionsPage(this.layer.db, { projectId: this.ownerProjectId, configKind, configTarget, ...(typeof paging === "number" ? { limit: paging } : paging) }); } get(id: string): Promise { diff --git a/packages/core/src/config/global-settings.ts b/packages/core/src/config/global-settings.ts index 48b8754009..7b496ec679 100644 --- a/packages/core/src/config/global-settings.ts +++ b/packages/core/src/config/global-settings.ts @@ -339,7 +339,7 @@ export class GlobalSettingsStore { async listConfigurationRevisions( configKind: ConfigKind = "global-settings", configTarget: ConfigurationTarget = { scope: "user-global" }, - limit?: number, + limit?: number | { limit?: number; offset?: number }, ): Promise { const layer = await this.getRevisionLayer(); if (!layer) throw new Error("Configuration history requires the PostgreSQL revision store"); diff --git a/packages/core/src/config/settings-schema.ts b/packages/core/src/config/settings-schema.ts index 4f37d0bf96..446d1554d0 100644 --- a/packages/core/src/config/settings-schema.ts +++ b/packages/core/src/config/settings-schema.ts @@ -922,6 +922,26 @@ export function isProjectSettingsKey(key: string): key is keyof ProjectSettings return (PROJECT_SETTINGS_KEYS as readonly string[]).includes(key); } +/* +FNXC:ConfigVersioning 2026-08-09-04:09: +`engineLastActiveAt` is liveness bookkeeping written every pollIntervalMs tick. Versioning it evicted real settings changes from the audit window within about 25 minutes, so project revision payloads omit these keys and restores overlay their live values. +*/ +export const NON_VERSIONED_SETTINGS_KEYS = Object.freeze(["engineLastActiveAt"] as const); + +export function isNonVersionedSettingsKey(key: string): key is (typeof NON_VERSIONED_SETTINGS_KEYS)[number] { + return (NON_VERSIONED_SETTINGS_KEYS as readonly string[]).includes(key); +} + +/** Preserve live liveness fields when an exact historic project snapshot is restored. */ +export function mergeRestoredProjectSettings(snapshot: Record, live: Record): Record { + const merged = { ...snapshot }; + for (const key of NON_VERSIONED_SETTINGS_KEYS) { + delete merged[key]; + if (Object.hasOwn(live, key)) merged[key] = live[key]; + } + return merged; +} + export function isGlobalOnlySettingsKey(key: string): key is keyof GlobalSettings { return isGlobalSettingsKey(key) && !isProjectSettingsKey(key); } diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 231a447c5d..f28d746f9c 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -980,7 +980,7 @@ export { ArchiveDatabase } from "./db/archive-db.js"; // FNXC:SqliteFinalRemoval 2026-07-08: db-migrate.ts (legacy sqlite migration) is removed on the PostgreSQL branch; its exports are dropped from this gate barrel to match index.ts. export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from "./config/global-settings.js"; export { ConfigurationRevisionStore, GLOBAL_CONFIGURATION_OWNER_ID } from "./config/configuration-revision-store.js"; -export { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, appendConfigurationRevision, appendGlobalConfigurationRevision, listConfigurationRevisions, listGlobalConfigurationRevisions, getConfigurationRevision, getGlobalConfigurationRevision, rollbackConfiguration } from "./async-stores/async-configuration-revision-store.js"; +export { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, appendConfigurationRevision, appendGlobalConfigurationRevision, listConfigurationRevisions, listConfigurationRevisionsPage, listGlobalConfigurationRevisions, getConfigurationRevision, getGlobalConfigurationRevision, rollbackConfiguration } from "./async-stores/async-configuration-revision-store.js"; export type { ConfigKind, ConfigChangedBy, ConfigurationOwnerScope, ConfigurationTarget, ConfigurationRevision } from "./types.js"; export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types.js"; export { isValidSqliteDatabaseFile } from "./db/sqlite-validation.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 40bf04e16c..d663829bb3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1121,7 +1121,7 @@ export type { EnsureProjectForPathInput, EnsureProjectForPathResult } from "./ce export { ArchiveDatabase } from "./db/archive-db.js"; export { GlobalSettingsStore, resolveGlobalDir, resolveGlobalDirForHome } from "./config/global-settings.js"; export { ConfigurationRevisionStore, GLOBAL_CONFIGURATION_OWNER_ID } from "./config/configuration-revision-store.js"; -export { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, appendConfigurationRevision, appendGlobalConfigurationRevision, listConfigurationRevisions, listGlobalConfigurationRevisions, getConfigurationRevision, getGlobalConfigurationRevision, rollbackConfiguration } from "./async-stores/async-configuration-revision-store.js"; +export { configurationTargetKey, createConfigurationRevision, diffConfigurationSnapshots, appendConfigurationRevision, appendGlobalConfigurationRevision, listConfigurationRevisions, listConfigurationRevisionsPage, listGlobalConfigurationRevisions, getConfigurationRevision, getGlobalConfigurationRevision, rollbackConfiguration } from "./async-stores/async-configuration-revision-store.js"; export type { ConfigKind, ConfigChangedBy, ConfigurationOwnerScope, ConfigurationTarget, ConfigurationRevision } from "./types.js"; export { CONFIG_CHANGED_BY_SYSTEM, CONFIG_CHANGED_BY_API_VERIFIED_TOKEN, CONFIG_CHANGED_BY_API_UNVERIFIED, CONFIG_CHANGED_BY_API_VERIFIED_NODE_KEY } from "./types.js"; export { isValidSqliteDatabaseFile } from "./db/sqlite-validation.js"; diff --git a/packages/core/src/task-store/task-mutation-ops.ts b/packages/core/src/task-store/task-mutation-ops.ts index 9d44721b0c..ecc362bf91 100644 --- a/packages/core/src/task-store/task-mutation-ops.ts +++ b/packages/core/src/task-store/task-mutation-ops.ts @@ -37,11 +37,13 @@ import {recoverExpiredMergeQueueLeases as recoverExpiredMergeQueueLeasesAsync} f import {updateBranchGroup as updateBranchGroupAsync, updatePrEntity as updatePrEntityAsync} from "../task-store/async/async-branch-groups.js"; import {recordCompletionHandoff as recordCompletionHandoffAsync, getCompletionHandoffMarker as getCompletionHandoffMarkerAsync} from "../task-store/async/async-workflow-workitems.js"; import { taskProjectScope } from "../postgres/data-layer.js"; +import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js"; import {getActivityLog as getActivityLogAsync} from "../task-store/async/async-audit.js"; import {insertArtifactRow as insertArtifactRowAsync} from "../task-store/async/async-comments-attachments.js"; import {appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration} from "../async-stores/async-configuration-revision-store.js"; import {readProjectConfig, writeProjectConfig} from "./async/async-settings.js"; import {publishSettingsUpdated} from "./settings-ops.js"; +import { mergeRestoredProjectSettings } from "../config/settings-schema.js"; import type {ConfigChangedBy, ConfigurationRevision} from "../types.js"; import { resolveArchivedLanes } from "../project-lane-vocabulary.js"; import { acquireTaskAdvisoryXactLock } from "./task-advisory-lock.js"; @@ -618,6 +620,20 @@ export async function updateWorkflowSettingValuesImpl(store: TaskStore, workflow return committed.next; } +/* +FNXC:ConfigVersioning 2026-08-09-04:09: +Exact project restores must retain the live heartbeat: new snapshots omit it while legacy snapshots can carry stale values that fabricate downtime. Extraction makes the same-transaction restore contract directly testable. +*/ +export function createProjectSettingsRollbackSnapshotOps(layer: AsyncDataLayer, tx: DbTransaction) { + return { + readCurrent: async () => (await readProjectConfig(layer, tx)).settings ?? {}, + replace: async (snapshot: unknown) => { + const live = (await readProjectConfig(layer, tx)).settings ?? {}; + await writeProjectConfig(layer, mergeRestoredProjectSettings(snapshot as Record, live), undefined, tx); + }, + }; +} + export async function rollbackConfigurationImpl(store: TaskStore, revisionId: string, changedBy: ConfigChangedBy = CONFIG_CHANGED_BY_SYSTEM): Promise { if (!store.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store"); const layer = store.asyncLayer!; @@ -637,9 +653,11 @@ export async function rollbackConfigurationImpl(store: TaskStore, revisionId: st /* FNXC:ConfigVersioning 2026-07-18-02:00: read both the selected revision and current config via tx so rollback's forward `before` snapshot cannot race a concurrent settings write. */ const revision = await getConfigurationRevision(tx, layer.projectId ?? "", revisionId); if (!revision) throw new Error(`Configuration revision ${revisionId} was not found`); + if (revision.configKind === "project-settings") { + return rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, createProjectSettingsRollbackSnapshotOps(layer, tx)); + } return rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, { readCurrent: async () => { - if (revision.configKind === "project-settings") return (await readProjectConfig(layer, tx)).settings ?? {}; if (revision.configKind === "workflow-settings") { const workflowId = String(revision.configTarget.workflowId); const projectId = String(revision.configTarget.projectId); @@ -649,10 +667,6 @@ export async function rollbackConfigurationImpl(store: TaskStore, revisionId: st throw new Error(`Configuration revision ${revisionId} belongs to ${revision.configKind}; use its resource store rollback API`); }, replace: async (snapshot) => { - if (revision.configKind === "project-settings") { - await writeProjectConfig(layer, snapshot as Record, undefined, tx); - return; - } if (revision.configKind === "workflow-settings") { const workflowId = String(revision.configTarget.workflowId); const projectId = String(revision.configTarget.projectId); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 872e093989..624e82d728 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -689,6 +689,9 @@ import { DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, + NON_VERSIONED_SETTINGS_KEYS, + isNonVersionedSettingsKey, + mergeRestoredProjectSettings, isGlobalOnlySettingsKey, isGlobalSettingsKey, isProjectSettingsKey, @@ -707,6 +710,9 @@ export { DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, + NON_VERSIONED_SETTINGS_KEYS, + isNonVersionedSettingsKey, + mergeRestoredProjectSettings, isGlobalOnlySettingsKey, isGlobalSettingsKey, isProjectSettingsKey, diff --git a/packages/core/src/types/settings/settings-scope.ts b/packages/core/src/types/settings/settings-scope.ts index f642663426..2c7cb3fd0e 100644 --- a/packages/core/src/types/settings/settings-scope.ts +++ b/packages/core/src/types/settings/settings-scope.ts @@ -2420,6 +2420,9 @@ export { DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, + NON_VERSIONED_SETTINGS_KEYS, + isNonVersionedSettingsKey, + mergeRestoredProjectSettings, isGlobalOnlySettingsKey, isGlobalSettingsKey, isProjectSettingsKey, diff --git a/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts index c437fa4a1d..a420bfc563 100644 --- a/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-org-portability-routes.test.ts @@ -82,14 +82,27 @@ describe("register-org-portability-routes", () => { }); it("lists project revisions in the core's newest-first order", async () => { - const list = vi.fn().mockResolvedValue([{ id: "new" }, { id: "old" }]); - core.ConfigurationRevisionStore.mockImplementation(function ConfigurationRevisionStore() { return { list }; }); + const listPage = vi.fn().mockResolvedValue({ revisions: [{ id: "new" }, { id: "old" }], hasMore: true }); + core.ConfigurationRevisionStore.mockImplementation(function ConfigurationRevisionStore() { return { listPage }; }); const { app } = createApp(); const response = await request(app, "GET", "/api/config/revisions"); expect(response.status).toBe(200); expect(response.body.revisions.map((revision: { id: string }) => revision.id)).toEqual(["new", "old"]); - expect(list).toHaveBeenCalledWith("project-settings", { projectId: "project-1" }); + expect(response.body).toMatchObject({ limit: 100, offset: 0, hasMore: true }); + expect(listPage).toHaveBeenCalledWith("project-settings", { projectId: "project-1" }, { limit: undefined, offset: undefined }); + }); + + it("validates and forwards revision paging", async () => { + const listPage = vi.fn().mockResolvedValue({ revisions: [], hasMore: false }); + core.ConfigurationRevisionStore.mockImplementation(function ConfigurationRevisionStore() { return { listPage }; }); + const { app } = createApp(); + const response = await request(app, "GET", "/api/config/revisions?limit=5&offset=10"); + expect(response.status).toBe(200); + expect(listPage).toHaveBeenCalledWith("project-settings", { projectId: "project-1" }, { limit: 5, offset: 10 }); + for (const query of ["limit=0", "limit=501", "limit=abc", "offset=-1", "offset=x", `offset=${"9".repeat(400)}`]) { + expect((await request(app, `GET`, `/api/config/revisions?${query}`)).status).toBe(400); + } }); it("rolls back through the core store and returns its forward revision", async () => { diff --git a/packages/dashboard/src/routes/register-org-portability-routes.ts b/packages/dashboard/src/routes/register-org-portability-routes.ts index 58f6aa42b5..f06dabea19 100644 --- a/packages/dashboard/src/routes/register-org-portability-routes.ts +++ b/packages/dashboard/src/routes/register-org-portability-routes.ts @@ -99,16 +99,28 @@ export function registerOrgPortabilityRoutes(ctx: ApiRoutesContext): void { if (configKind !== undefined && configKind !== "project-settings") { throw badRequest("configKind must be project-settings"); } + const parsePaging = (value: unknown, name: "limit" | "offset") => { + if (value === undefined) return undefined; + if (typeof value !== "string" || !/^\d+$/.test(value)) throw badRequest(`${name} must be a non-negative integer`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || (name === "limit" && (parsed < 1 || parsed > 500)) || (name === "offset" && parsed < 0)) throw badRequest(`${name} is out of range`); + return parsed; + }; + const limit = parsePaging(req.query.limit, "limit"); + const offset = parsePaging(req.query.offset, "offset"); const { store: scopedStore, projectId } = await getProjectContext(req); const layer = scopedStore.getAsyncLayer(); if (!layer) throw badRequest("Configuration history requires the PostgreSQL revision store"); // FNXC:CommandCenterConfig 2026-07-18-12:00: FN-8282's revision facade is consumed through this narrow compatibility type until the dependency export is merged into this branch. const { ConfigurationRevisionStore } = await import("@fusion/core") as unknown as { - ConfigurationRevisionStore: new (layer: unknown, projectId?: string) => { list(kind: "project-settings", target: Record): Promise }; + ConfigurationRevisionStore: new (layer: unknown, projectId?: string) => { + list(kind: "project-settings", target: Record, paging?: number | { limit?: number; offset?: number }): Promise; + listPage(kind: "project-settings", target: Record, paging?: number | { limit?: number; offset?: number }): Promise<{ revisions: unknown[]; hasMore: boolean }>; + }; }; // FNXC:CommandCenterConfig 2026-07-18-12:00: Dashboard history starts with the project settings target because that is the configuration surface rendered beside these controls; rollback remains the core's exact, forward-recorded operation. - const revisions = await new ConfigurationRevisionStore(layer, projectId).list("project-settings", { projectId: projectId ?? "" }); - res.json({ revisions }); + const page = await new ConfigurationRevisionStore(layer, projectId).listPage("project-settings", { projectId: projectId ?? "" }, { limit, offset }); + res.json({ revisions: page.revisions, limit: limit ?? 100, offset: offset ?? 0, hasMore: page.hasMore }); } catch (error: unknown) { if (error instanceof ApiError) throw error; rethrowAsApiError(error);