test(core): fast harness reset, sqlite-migrator template clones, top-3 harness adoptions
Slow-test trim, OPTIMIZE tier (harness owner). Profiling showed 163ms of the shared harness's 169ms per-test beforeEach was one TRUNCATE over ~110 tables (per-table catalog/fsync constants, row-independent). The reset is now a single DO block: transaction-local session_replication_role=replica, DELETE only from non-empty tables, and sequence resets reproducing RESTART IDENTITY exactly — 169ms → 10ms per test across every harness file, with the legacy TRUNCATE retained as an automatic error fallback for non-superuser roles. sqlite-migrator (the live SQLite→PG upgrade path; all 43 tests kept incl. VAL-MIGRATE-001..006) now provisions targets from a golden-template clone via new createBaselinedPgTestDatabase/createEmptyPgTestDatabase exports — the dry-run test keeps a pristine empty DB so its no-schemas-left-behind assertions stay meaningful — 148.8s → ~26s. taskstore-lifecycle, data-layer, and taskstore-remaining migrate onto the shared harness with byte-identical test bodies (187s → ~11s combined); data-layer's close() test closes a private layer so the shared pool survives. Seeding hoists were measured and deliberately skipped: the harness fix already exceeded their projected savings and a beforeAll seed would weaken the clean-DB-per-test contract. Verified: 287 tests green across the touched files plus untouched harness consumers (schema-applier 80/80, mission-store 62/62). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -671,6 +671,104 @@ function ensureSchemaTemplate(): Promise<string> {
|
||||
return ready;
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:PgTestTemplateDb 2026-07-17-22:34:
|
||||
* PostgreSQL can retain a just-closed baseline connection briefly. Terminate
|
||||
* stale template sessions immediately before copying; the module-local copy
|
||||
* mutex ensures this never interrupts a sibling copy using the same source.
|
||||
*
|
||||
* FNXC:PgTestHarness 2026-07-18-17:40:
|
||||
* Keep terminate + DROP + CREATE TEMPLATE on one maintenance session and
|
||||
* retry the short "source database is being accessed by other users" window
|
||||
* (seen after switching admin DDL off shell psql). Split sessions left a race
|
||||
* where a late-closing baseline/pool client reattached between terminate and
|
||||
* CREATE DATABASE ... TEMPLATE.
|
||||
*
|
||||
* FNXC:PgTestHarnessBaselinedDb 2026-08-15-03:52:
|
||||
* Extracted from createTaskStoreForTest so createBaselinedPgTestDatabase can
|
||||
* share the identical serialized, retried clone path.
|
||||
*/
|
||||
async function cloneDatabaseFromTemplate(dbName: string, template: string): Promise<void> {
|
||||
await serializeTemplateCopy(async () => {
|
||||
const maxAttempts = 5;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await withMaintenanceSql(async (client) => {
|
||||
await client`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = ${template} AND pid <> pg_backend_pid()
|
||||
`;
|
||||
await client.unsafe(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`).catch(() => {});
|
||||
await client.unsafe(`CREATE DATABASE "${dbName}" TEMPLATE "${template}"`);
|
||||
});
|
||||
lastError = undefined;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const contended = /being accessed by other users/i.test(message);
|
||||
if (!contended || attempt === maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25 * attempt));
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PgTestHarnessBaselinedDb 2026-08-15-03:52:
|
||||
Slow-test fix for tests that need a baselined DATABASE but no TaskStore (e.g.
|
||||
sqlite-migrator.test.ts, whose 43 integration tests each paid a fresh CREATE
|
||||
DATABASE plus a full in-migrator applySchemaBaseline DDL run — ~3.5s/test).
|
||||
Cloning from the run-shared golden template yields a database with the exact
|
||||
applySchemaBaseline end-state (schema + markers), so the migrator's own
|
||||
idempotent baseline call becomes a marker-check no-op. Callers own connections
|
||||
to the returned URL; drop() force-drops the database.
|
||||
*/
|
||||
export async function createBaselinedPgTestDatabase(prefix = "fusion_test"): Promise<{
|
||||
readonly dbName: string;
|
||||
readonly testUrl: string;
|
||||
drop(): Promise<void>;
|
||||
}> {
|
||||
const dbName = uniqueDbName(prefix);
|
||||
const template = await ensureGoldenTemplate();
|
||||
await cloneDatabaseFromTemplate(dbName, template);
|
||||
return {
|
||||
dbName,
|
||||
testUrl: `${PG_TEST_URL_BASE}/${dbName}`,
|
||||
drop: async () => {
|
||||
await adminExecAsync(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PgTestHarnessBaselinedDb 2026-08-15-03:52:
|
||||
Companion to createBaselinedPgTestDatabase for tests whose CONTRACT is a
|
||||
pristine target (e.g. VAL-MIGRATE-005: a dry-run against an external database
|
||||
must leave no schemas/tables/markers behind — pre-applied baseline would make
|
||||
that assertion vacuous). Plain CREATE DATABASE, no template, no baseline.
|
||||
*/
|
||||
export async function createEmptyPgTestDatabase(prefix = "fusion_test"): Promise<{
|
||||
readonly dbName: string;
|
||||
readonly testUrl: string;
|
||||
drop(): Promise<void>;
|
||||
}> {
|
||||
const dbName = uniqueDbName(prefix);
|
||||
await adminExecAsync(`CREATE DATABASE "${dbName}"`);
|
||||
return {
|
||||
dbName,
|
||||
testUrl: `${PG_TEST_URL_BASE}/${dbName}`,
|
||||
drop: async () => {
|
||||
await adminExecAsync(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:TestMigrationTail 2026-06-24-16:00:
|
||||
* Create a fresh, isolated PostgreSQL database with the Fusion schema applied,
|
||||
@@ -721,47 +819,7 @@ export async function createTaskStoreForTest(options?: {
|
||||
const template = options?.copyFromGolden
|
||||
? await ensureGoldenTemplate()
|
||||
: await ensureSchemaTemplate();
|
||||
await serializeTemplateCopy(async () => {
|
||||
/*
|
||||
* FNXC:PgTestTemplateDb 2026-07-17-22:34:
|
||||
* PostgreSQL can retain a just-closed baseline connection briefly. Terminate
|
||||
* stale template sessions immediately before copying; the module-local copy
|
||||
* mutex ensures this never interrupts a sibling copy using the same source.
|
||||
*
|
||||
* FNXC:PgTestHarness 2026-07-18-17:40:
|
||||
* Keep terminate + DROP + CREATE TEMPLATE on one maintenance session and
|
||||
* retry the short "source database is being accessed by other users" window
|
||||
* (seen after switching admin DDL off shell psql). Split sessions left a race
|
||||
* where a late-closing baseline/pool client reattached between terminate and
|
||||
* CREATE DATABASE ... TEMPLATE.
|
||||
*/
|
||||
const maxAttempts = 5;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await withMaintenanceSql(async (client) => {
|
||||
await client`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = ${template} AND pid <> pg_backend_pid()
|
||||
`;
|
||||
await client.unsafe(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`).catch(() => {});
|
||||
await client.unsafe(`CREATE DATABASE "${dbName}" TEMPLATE "${template}"`);
|
||||
});
|
||||
lastError = undefined;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const contended = /being accessed by other users/i.test(message);
|
||||
if (!contended || attempt === maxAttempts) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25 * attempt));
|
||||
}
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
});
|
||||
await cloneDatabaseFromTemplate(dbName, template);
|
||||
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
|
||||
|
||||
// The database already carries the full schema (copied from the template),
|
||||
@@ -945,6 +1003,48 @@ const ALL_APPLICATION_TABLES = [
|
||||
];
|
||||
const TRUNCATE_ALL_SQL = `TRUNCATE TABLE ${ALL_APPLICATION_TABLES.join(", ")} RESTART IDENTITY CASCADE`;
|
||||
|
||||
/*
|
||||
FNXC:PgTestHarnessResetSpeed 2026-08-15-03:52:
|
||||
Slow-test fix (harness-wide): the per-test reset was a single TRUNCATE ... RESTART
|
||||
IDENTITY CASCADE over all ~110 application tables. Profiled at 163ms of the 169ms
|
||||
shared-harness beforeEach (mission-store.pg: 62 tests -> ~10s of pure TRUNCATE),
|
||||
because TRUNCATE pays a per-table constant (new relfilenode + catalog churn +
|
||||
fsync) regardless of row count — and in a typical test only a handful of tables
|
||||
hold rows. Replace it with one DO block that:
|
||||
1. switches session_replication_role to 'replica' (transaction-local) so FK
|
||||
triggers are inert and deletion order is irrelevant;
|
||||
2. DELETEs only tables that actually contain rows (EXISTS probe per table is
|
||||
~0.05ms; empty tables are skipped entirely);
|
||||
3. resets EVERY sequence in the three application schemas to its declared
|
||||
start value, reproducing RESTART IDENTITY exactly (including sequences
|
||||
advanced by insert-then-delete tests whose tables ended empty — the full
|
||||
TRUNCATE reset those too, so the sweep must be unconditional).
|
||||
Observable semantics are identical: every application table is empty and every
|
||||
identity restarts, so ID-reuse assertions (KB-001) keep holding. The caller
|
||||
falls back to the legacy full TRUNCATE if this fast path errors (e.g. a
|
||||
non-superuser test role that may not set session_replication_role).
|
||||
*/
|
||||
const FAST_RESET_SQL = `DO $fusion_reset$
|
||||
DECLARE
|
||||
tbl text;
|
||||
has_rows boolean;
|
||||
BEGIN
|
||||
PERFORM set_config('session_replication_role', 'replica', true);
|
||||
FOREACH tbl IN ARRAY ARRAY[${ALL_APPLICATION_TABLES.map((t) => `'${t}'`).join(", ")}] LOOP
|
||||
EXECUTE 'SELECT EXISTS(SELECT 1 FROM ' || tbl || ')' INTO has_rows;
|
||||
IF has_rows THEN
|
||||
EXECUTE 'DELETE FROM ' || tbl;
|
||||
END IF;
|
||||
END LOOP;
|
||||
PERFORM setval(c.oid, s.seqstart, false)
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_sequence s ON s.seqrelid = c.oid
|
||||
WHERE c.relkind = 'S'
|
||||
AND n.nspname IN ('${PROJECT_SCHEMA}', '${CENTRAL_SCHEMA}', '${ARCHIVE_SCHEMA}');
|
||||
END
|
||||
$fusion_reset$`;
|
||||
|
||||
export function createSharedPgTaskStoreTestHarness(options?: {
|
||||
readonly poolMax?: number;
|
||||
readonly prefix?: string;
|
||||
@@ -1036,7 +1136,13 @@ export function createSharedPgTaskStoreTestHarness(options?: {
|
||||
beforeEach: async () => {
|
||||
if (!harness || !store) throw new Error("SharedPgTaskStoreHarness: beforeAll not called yet");
|
||||
// Wipe all application data and reset sequences in one statement.
|
||||
await harness.adminDb.execute(sql.raw(TRUNCATE_ALL_SQL));
|
||||
// FNXC:PgTestHarnessResetSpeed 2026-08-15-03:52: fast DELETE-based reset
|
||||
// (see FAST_RESET_SQL) with the legacy full TRUNCATE as an error fallback.
|
||||
try {
|
||||
await harness.adminDb.execute(sql.raw(FAST_RESET_SQL));
|
||||
} catch {
|
||||
await harness.adminDb.execute(sql.raw(TRUNCATE_ALL_SQL));
|
||||
}
|
||||
// Re-seed the singleton config row with default project settings so the
|
||||
// store sees a clean project on every test.
|
||||
const defaults = await ensureDefaults();
|
||||
|
||||
@@ -28,11 +28,8 @@
|
||||
* gate stays green without a running server.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, beforeAll } from "vitest";
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { execSync } from "node:child_process";
|
||||
import {
|
||||
createAsyncDataLayer,
|
||||
recordRunAuditEvent,
|
||||
@@ -42,74 +39,55 @@ import {
|
||||
} from "../../postgres/data-layer.js";
|
||||
import { createConnectionSetFromUrl } from "../../postgres/connection.js";
|
||||
import type { ResolvedBackend } from "../../postgres/backend-resolver.js";
|
||||
import { applySchemaBaseline } from "../../postgres/schema-applier.js";
|
||||
import * as schema from "../../postgres/schema/index.js";
|
||||
|
||||
const PG_ADMIN_URL =
|
||||
process.env.FUSION_PG_TEST_ADMIN_URL ?? "postgresql://localhost:5432/postgres";
|
||||
const PG_TEST_URL_BASE =
|
||||
process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432";
|
||||
const PG_AVAILABLE =
|
||||
process.env.FUSION_PG_TEST_SKIP !== "1" && Boolean(PG_TEST_URL_BASE);
|
||||
|
||||
const pgDescribe = PG_AVAILABLE ? describe : describe.skip;
|
||||
|
||||
/**
|
||||
* FNXC:AsyncDataLayer 2026-06-24-10:00:
|
||||
* Create a uniquely-named fresh database for each test so tests are hermetic
|
||||
* and never touch existing data. Mirrors the schema-applier test harness.
|
||||
*/
|
||||
function uniqueDbName(): string {
|
||||
return `fusion_data_test_${process.pid}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
import {
|
||||
pgDescribe,
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
type SharedPgTaskStoreHarness,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
|
||||
/*
|
||||
FNXC:PgTestAuthFix 2026-07-14-00:00:
|
||||
The inline adminExec used process.env.USER for the psql -U flag, which is 'runner' on GitHub Actions (not 'postgres'). Use the PG_TEST_URL_BASE connection string instead so credentials are always correct.
|
||||
FNXC:AsyncDataLayer 2026-08-15-03:52:
|
||||
Slow-test fix: this file hand-rolled CREATE DATABASE + full applySchemaBaseline
|
||||
PER TEST (~4.6s/test, 65s for the file). The transaction primitives under test
|
||||
write only data, so each describe block now shares one golden-template database
|
||||
with the harness's per-test reset. Transaction-visibility semantics are
|
||||
unchanged: the layer pool and the harness adminDb connection remain SEPARATE
|
||||
sessions, which is what the VAL-DATA-004 concurrent-reader assertions rely on.
|
||||
The close() lifecycle test builds a PRIVATE layer against the shared database
|
||||
so closing it cannot break the harness's pooled layer for later tests.
|
||||
`ctx` keeps its original shape so test bodies stay byte-identical.
|
||||
*/
|
||||
function adminExec(statement: string): void {
|
||||
// psql via execSync for DDL that the postgres.js connection pool can't run
|
||||
// (CREATE/DROP DATABASE cannot run inside a transaction). Short deterministic
|
||||
// DDL — the acceptable execSync use per AGENTS.md.
|
||||
execSync(
|
||||
`psql "${PG_TEST_URL_BASE}/postgres" -v ON_ERROR_STOP=1 -c "${statement.replace(/"/g, '\\"')}"`,
|
||||
{ stdio: "pipe", env: process.env },
|
||||
);
|
||||
}
|
||||
|
||||
interface TestLayer {
|
||||
dbName: string;
|
||||
testUrl: string;
|
||||
layer: AsyncDataLayer;
|
||||
adminSql: ReturnType<typeof postgres>;
|
||||
adminDb: ReturnType<typeof drizzle>;
|
||||
readonly layer: AsyncDataLayer;
|
||||
readonly adminDb: ReturnType<SharedPgTaskStoreHarness["adminDb"]>;
|
||||
}
|
||||
|
||||
async function setupFreshLayer(): Promise<TestLayer> {
|
||||
const dbName = uniqueDbName();
|
||||
try {
|
||||
adminExec(`DROP DATABASE IF EXISTS "${dbName}"`);
|
||||
} catch {
|
||||
// ignore — may not exist
|
||||
}
|
||||
adminExec(`CREATE DATABASE "${dbName}"`);
|
||||
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
|
||||
const h = createSharedPgTaskStoreTestHarness({ prefix: "fusion_data" });
|
||||
|
||||
// Apply the baseline schema so run_audit_events + tasks exist.
|
||||
const schemaBackend: ResolvedBackend = {
|
||||
mode: "external",
|
||||
runtimeUrl: testUrl,
|
||||
migrationUrl: testUrl,
|
||||
migrationUrlOverridden: false,
|
||||
/**
|
||||
* Register the shared-harness lifecycle inside a pgDescribe block and return a
|
||||
* `ctx` whose `layer`/`adminDb` getters resolve live from the harness, so the
|
||||
* original `ctx.layer` / `ctx.adminDb` test bodies read unchanged.
|
||||
*/
|
||||
function useSharedLayer(): TestLayer {
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(h.beforeEach);
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
return {
|
||||
get layer() {
|
||||
return h.layer();
|
||||
},
|
||||
get adminDb() {
|
||||
return h.adminDb();
|
||||
},
|
||||
};
|
||||
const schemaConnections = await createConnectionSetFromUrl(schemaBackend, {
|
||||
poolMax: 1,
|
||||
connectTimeoutSeconds: 5,
|
||||
});
|
||||
await applySchemaBaseline(schemaConnections.migration);
|
||||
await schemaConnections.close();
|
||||
}
|
||||
|
||||
// Now build the data layer against the migrated database.
|
||||
/** Build a private AsyncDataLayer against the shared harness database. */
|
||||
async function createPrivateLayer(): Promise<AsyncDataLayer> {
|
||||
const testUrl = h.testUrl();
|
||||
const dataBackend: ResolvedBackend = {
|
||||
mode: "external",
|
||||
runtimeUrl: testUrl,
|
||||
@@ -120,32 +98,7 @@ async function setupFreshLayer(): Promise<TestLayer> {
|
||||
poolMax: 5,
|
||||
connectTimeoutSeconds: 5,
|
||||
});
|
||||
const layer = createAsyncDataLayer(connections);
|
||||
|
||||
// Admin connection for direct row inspection (outside the data layer).
|
||||
const adminSql = postgres(testUrl, { max: 2, prepare: false, onnotice: () => {} });
|
||||
const adminDb = drizzle(adminSql);
|
||||
|
||||
return { dbName, testUrl, layer, adminSql, adminDb };
|
||||
}
|
||||
|
||||
async function teardownLayer(ctx: TestLayer | null): Promise<void> {
|
||||
if (!ctx) return;
|
||||
try {
|
||||
await ctx.layer.close();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
await ctx.adminSql.end({ timeout: 5 });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
adminExec(`DROP DATABASE IF EXISTS "${ctx.dbName}"`);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return createAsyncDataLayer(connections);
|
||||
}
|
||||
|
||||
/** Count rows in project.run_audit_events via the admin connection. */
|
||||
@@ -168,15 +121,9 @@ async function readAuditRows(
|
||||
}
|
||||
|
||||
pgDescribe("AsyncDataLayer: VAL-DATA-002 — transaction atomicity (commit)", () => {
|
||||
let ctx: TestLayer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownLayer(ctx);
|
||||
ctx = null;
|
||||
});
|
||||
const ctx = useSharedLayer();
|
||||
|
||||
it("commits a multi-statement mutation with all writes visible after commit", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const runId = "run-commit-multi";
|
||||
const auditA: RunAuditEventInput = {
|
||||
runId,
|
||||
@@ -207,7 +154,6 @@ pgDescribe("AsyncDataLayer: VAL-DATA-002 — transaction atomicity (commit)", ()
|
||||
});
|
||||
|
||||
it("transactionImmediate with a single write commits it", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const runId = "run-commit-single";
|
||||
await ctx.layer.transactionImmediate(async (tx) => {
|
||||
await recordRunAuditEventWithinTransaction(tx, {
|
||||
@@ -225,15 +171,9 @@ pgDescribe("AsyncDataLayer: VAL-DATA-002 — transaction atomicity (commit)", ()
|
||||
});
|
||||
|
||||
pgDescribe("AsyncDataLayer: VAL-DATA-003 — transaction atomicity (rollback)", () => {
|
||||
let ctx: TestLayer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownLayer(ctx);
|
||||
ctx = null;
|
||||
});
|
||||
const ctx = useSharedLayer();
|
||||
|
||||
it("rolls back all writes when the callback throws, including the audit row", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const runId = "run-rollback-throw";
|
||||
const before = await countAuditRows(ctx.adminDb);
|
||||
expect(before).toBe(0);
|
||||
@@ -259,7 +199,6 @@ pgDescribe("AsyncDataLayer: VAL-DATA-003 — transaction atomicity (rollback)",
|
||||
});
|
||||
|
||||
it("rolls back when a constraint is violated mid-transaction (primary-key collision)", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const runId = "run-rollback-pk";
|
||||
const before = await countAuditRows(ctx.adminDb);
|
||||
expect(before).toBe(0);
|
||||
@@ -313,15 +252,9 @@ pgDescribe("AsyncDataLayer: VAL-DATA-003 — transaction atomicity (rollback)",
|
||||
});
|
||||
|
||||
pgDescribe("AsyncDataLayer: VAL-DATA-004 — concurrent transactions do not observe partial writes", () => {
|
||||
let ctx: TestLayer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownLayer(ctx);
|
||||
ctx = null;
|
||||
});
|
||||
const ctx = useSharedLayer();
|
||||
|
||||
it("a concurrent reader outside the writer's transaction does not see uncommitted writes", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const runId = "run-concurrent-iso";
|
||||
|
||||
// Hold a transaction open with an uncommitted write, then verify a
|
||||
@@ -349,7 +282,6 @@ pgDescribe("AsyncDataLayer: VAL-DATA-004 — concurrent transactions do not obse
|
||||
});
|
||||
|
||||
it("a concurrent read via a separate pool transaction does not see uncommitted writes", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const runId = "run-concurrent-iso-2";
|
||||
|
||||
// Use a barrier to coordinate: the writer holds its transaction open until
|
||||
@@ -378,7 +310,6 @@ pgDescribe("AsyncDataLayer: VAL-DATA-004 — concurrent transactions do not obse
|
||||
});
|
||||
|
||||
it("two concurrent writers both commit their own rows without cross-contamination", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const runA = "run-concurrent-A";
|
||||
const runB = "run-concurrent-B";
|
||||
|
||||
@@ -413,15 +344,9 @@ pgDescribe("AsyncDataLayer: VAL-DATA-004 — concurrent transactions do not obse
|
||||
});
|
||||
|
||||
pgDescribe("AsyncDataLayer: run-audit-event-within-transaction behavior", () => {
|
||||
let ctx: TestLayer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownLayer(ctx);
|
||||
ctx = null;
|
||||
});
|
||||
const ctx = useSharedLayer();
|
||||
|
||||
it("the standalone recordRunAuditEvent wraps the insert in its own transaction", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const event = await recordRunAuditEvent(ctx.layer, {
|
||||
runId: "run-standalone",
|
||||
agentId: "agent-standalone",
|
||||
@@ -440,7 +365,6 @@ pgDescribe("AsyncDataLayer: run-audit-event-within-transaction behavior", () =>
|
||||
});
|
||||
|
||||
it("records metadata as jsonb and round-trips it", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const metadata = { filesChanged: 5, nested: { deep: [1, 2, 3] }, flag: true };
|
||||
await recordRunAuditEvent(ctx.layer, {
|
||||
runId: "run-metadata",
|
||||
@@ -459,7 +383,6 @@ pgDescribe("AsyncDataLayer: run-audit-event-within-transaction behavior", () =>
|
||||
});
|
||||
|
||||
it("an audit row paired with a task-like mutation rolls back together", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const runId = "run-paired-rollback";
|
||||
|
||||
// Simulate the atomicWriteTaskJsonWithAudit pattern: a "task mutation"
|
||||
@@ -495,20 +418,13 @@ pgDescribe("AsyncDataLayer: run-audit-event-within-transaction behavior", () =>
|
||||
});
|
||||
|
||||
pgDescribe("AsyncDataLayer: interface stability and connectivity", () => {
|
||||
let ctx: TestLayer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownLayer(ctx);
|
||||
ctx = null;
|
||||
});
|
||||
const ctx = useSharedLayer();
|
||||
|
||||
it("ping() succeeds against a healthy backend", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
await expect(ctx.layer.ping()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("the db member executes a raw query", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
const result = (await ctx.layer.db.execute(
|
||||
sql`SELECT 1 AS val`,
|
||||
)) as unknown as Array<{ val: number }>;
|
||||
@@ -516,26 +432,14 @@ pgDescribe("AsyncDataLayer: interface stability and connectivity", () => {
|
||||
});
|
||||
|
||||
it("close() releases the pool without error", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
await expect(ctx.layer.close()).resolves.toBeUndefined();
|
||||
// Prevent teardownLayer from double-closing.
|
||||
const captured = ctx;
|
||||
ctx = null;
|
||||
// The admin connection is still ours to close.
|
||||
try {
|
||||
await captured!.adminSql.end({ timeout: 5 });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
adminExec(`DROP DATABASE IF EXISTS "${captured!.dbName}"`);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
// FNXC:AsyncDataLayer 2026-08-15-03:52: close a PRIVATE layer built
|
||||
// against the shared database — closing the harness's pooled layer would
|
||||
// break every later test in the file.
|
||||
const layer = await createPrivateLayer();
|
||||
await expect(layer.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("exposes the stable AsyncDataLayer contract (db, transaction, transactionImmediate, ping, close)", async () => {
|
||||
ctx = await setupFreshLayer();
|
||||
expect(typeof ctx.layer.db).toBe("object");
|
||||
expect(typeof ctx.layer.transaction).toBe("function");
|
||||
expect(typeof ctx.layer.transactionImmediate).toBe("function");
|
||||
|
||||
@@ -28,7 +28,6 @@ import { describe, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
@@ -43,6 +42,10 @@ import {
|
||||
type MigrationProgressEvent,
|
||||
} from "../../postgres/sqlite-migrator.js";
|
||||
import { applySchemaBaseline } from "../../postgres/schema-applier.js";
|
||||
import {
|
||||
createBaselinedPgTestDatabase,
|
||||
createEmptyPgTestDatabase,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
|
||||
const PG_TEST_URL_BASE =
|
||||
process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432";
|
||||
@@ -80,25 +83,13 @@ describe("SQLite migration CLI progress", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* FNXC:PostgresMigration 2026-06-24-09:05:
|
||||
* Create a uniquely-named fresh PostgreSQL database. Mirrors the
|
||||
* schema-applier test harness.
|
||||
*/
|
||||
function uniqueDbName(): string {
|
||||
return `fusion_migrate_test_${process.pid}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PgTestAuthFix 2026-07-14-00:00:
|
||||
The inline adminExec used process.env.USER for the psql -U flag, which is 'runner' on GitHub Actions (not 'postgres'). Use the PG_TEST_URL_BASE connection string instead so credentials are always correct.
|
||||
FNXC:PostgresMigration 2026-08-15-03:52:
|
||||
Database naming/creation/drop now lives in the shared pg-test-harness
|
||||
(createBaselinedPgTestDatabase); the former inline psql adminExec (see
|
||||
FNXC:PgTestAuthFix 2026-07-14-00:00) is gone with it, which also removes this
|
||||
file's only execSync shellout.
|
||||
*/
|
||||
function adminExec(statement: string): void {
|
||||
execSync(
|
||||
`psql "${PG_TEST_URL_BASE}/postgres" -v ON_ERROR_STOP=1 -c "${statement.replace(/"/g, '\\"')}"`,
|
||||
{ stdio: "pipe", env: process.env },
|
||||
);
|
||||
}
|
||||
|
||||
/** A subset of the tasks table schema (the columns the migration tests touch). */
|
||||
const TASKS_SQLITE_DDL = `
|
||||
@@ -440,24 +431,28 @@ interface TestCtx {
|
||||
sqlConn: ReturnType<typeof postgres>;
|
||||
db: ReturnType<typeof drizzle>;
|
||||
fusionDir: string;
|
||||
dropDb: () => Promise<void>;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigration 2026-08-15-03:52:
|
||||
Slow-test fix: the per-test target used to be an EMPTY database, so every test
|
||||
paid the migrator's full internal applySchemaBaseline DDL run (~3s). Clone the
|
||||
target from the harness's run-shared golden template instead — that template IS
|
||||
the applySchemaBaseline end-state (schema + version markers), so the migrator's
|
||||
idempotent baseline call degrades to a marker-check no-op while every assertion,
|
||||
including the explicit applySchemaBaseline() calls below, sees the identical
|
||||
target state.
|
||||
*/
|
||||
async function setupCtx(): Promise<TestCtx> {
|
||||
const fusionDir = mkdtempSync(join(tmpdir(), "fusion-migrate-"));
|
||||
buildPopulatedSqliteProject(fusionDir);
|
||||
buildPopulatedSqliteArchive(fusionDir);
|
||||
|
||||
const dbName = uniqueDbName();
|
||||
try {
|
||||
adminExec(`DROP DATABASE IF EXISTS "${dbName}"`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
adminExec(`CREATE DATABASE "${dbName}"`);
|
||||
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
|
||||
const sqlConn = postgres(testUrl, { max: 3, prepare: false, onnotice: () => {} });
|
||||
const baselined = await createBaselinedPgTestDatabase("fusion_migrate_test");
|
||||
const sqlConn = postgres(baselined.testUrl, { max: 3, prepare: false, onnotice: () => {} });
|
||||
const db = drizzle(sqlConn);
|
||||
return { dbName, sqlConn, db, fusionDir };
|
||||
return { dbName: baselined.dbName, sqlConn, db, fusionDir, dropDb: baselined.drop };
|
||||
}
|
||||
|
||||
async function teardownCtx(ctx: TestCtx | null): Promise<void> {
|
||||
@@ -468,7 +463,7 @@ async function teardownCtx(ctx: TestCtx | null): Promise<void> {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
adminExec(`DROP DATABASE IF EXISTS "${ctx.dbName}"`);
|
||||
await ctx.dropDb();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
@@ -1876,8 +1871,18 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => {
|
||||
|
||||
// VAL-MIGRATE-005 — dry-run reports without writing
|
||||
it("dry-run reports the plan without modifying PostgreSQL", async () => {
|
||||
/*
|
||||
FNXC:PostgresMigration 2026-08-15-03:52:
|
||||
This test's contract is a PRISTINE external target (no schemas, no marker
|
||||
table), so it cannot use the shared golden-template ctx.db whose baseline is
|
||||
pre-applied — it provisions its own empty database.
|
||||
*/
|
||||
const empty = await createEmptyPgTestDatabase("fusion_migrate_pristine");
|
||||
const emptyConn = postgres(empty.testUrl, { max: 3, prepare: false, onnotice: () => {} });
|
||||
const emptyDb = drizzle(emptyConn);
|
||||
try {
|
||||
const report = await migrateTest(
|
||||
ctx!.db,
|
||||
emptyDb,
|
||||
[{ sqlitePath: join(ctx!.fusionDir, "fusion.db"), pgSchema: "project" as const }],
|
||||
{ dryRun: true },
|
||||
);
|
||||
@@ -1892,7 +1897,7 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => {
|
||||
FNXC:PostgresMigration 2026-07-14-23:47:
|
||||
VAL-MIGRATE-005 applies to catalog state as well as copied rows. A preview against a pristine external target must leave no schemas, tables, or migration marker behind after it reports the plan.
|
||||
*/
|
||||
const catalog = (await ctx!.db.execute(sql`
|
||||
const catalog = (await emptyDb.execute(sql`
|
||||
SELECT
|
||||
to_regnamespace('project')::text AS project_schema,
|
||||
to_regclass('project.tasks')::text AS tasks_table,
|
||||
@@ -1913,6 +1918,10 @@ pgDescribe("SQLite-to-PostgreSQL migrator", () => {
|
||||
|
||||
// No sequences should have been bumped in dry-run.
|
||||
expect(report.sequenceBumps).toHaveLength(0);
|
||||
} finally {
|
||||
await emptyConn.end({ timeout: 5 }).catch(() => {});
|
||||
await empty.drop().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// VAL-SEARCH-002 (search_vector population) — generated column auto-populates
|
||||
|
||||
@@ -20,15 +20,13 @@
|
||||
* gate stays green without a running server.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import { execSync } from "node:child_process";
|
||||
import { createAsyncDataLayer, type AsyncDataLayer } from "../../postgres/data-layer.js";
|
||||
import { createConnectionSetFromUrl } from "../../postgres/connection.js";
|
||||
import type { ResolvedBackend } from "../../postgres/backend-resolver.js";
|
||||
import { applySchemaBaseline } from "../../postgres/schema-applier.js";
|
||||
import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
|
||||
import {
|
||||
pgDescribe,
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
import * as schema from "../../postgres/schema/index.js";
|
||||
import { insertTaskRow, softDeleteTaskRow } from "../../task-store/async/async-persistence.js";
|
||||
import {
|
||||
@@ -49,87 +47,16 @@ import {
|
||||
import { recordRunAuditEventWithinTransaction } from "../../postgres/data-layer.js";
|
||||
import type { MergeQueueRow } from "../../task-store/row-types.js";
|
||||
|
||||
const PG_TEST_URL_BASE =
|
||||
process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432";
|
||||
const PG_AVAILABLE =
|
||||
process.env.FUSION_PG_TEST_SKIP !== "1" && Boolean(PG_TEST_URL_BASE);
|
||||
|
||||
const pgDescribe = PG_AVAILABLE ? describe : describe.skip;
|
||||
|
||||
function uniqueDbName(): string {
|
||||
return `fusion_u13_test_${process.pid}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PgTestAuthFix 2026-07-14-00:00:
|
||||
The inline adminExec used process.env.USER for the psql -U flag, which is 'runner' on GitHub Actions (not 'postgres'). Use the PG_TEST_URL_BASE connection string instead so credentials are always correct.
|
||||
FNXC:TaskStoreLifecycle 2026-08-15-03:52:
|
||||
Slow-test fix: this file hand-rolled CREATE DATABASE + full applySchemaBaseline
|
||||
PER TEST (~4.4s/test, 70s for the file). The helpers under test write only data
|
||||
(no DDL), so the shared per-file harness (one golden-template DB + per-test
|
||||
reset) preserves isolation with the schema built once. `ctx` keeps its original
|
||||
shape so every test body is byte-identical.
|
||||
*/
|
||||
function adminExec(statement: string): void {
|
||||
execSync(
|
||||
`psql "${PG_TEST_URL_BASE}/postgres" -v ON_ERROR_STOP=1 -c "${statement.replace(/"/g, '\\"')}"`,
|
||||
{ stdio: "pipe", env: process.env },
|
||||
);
|
||||
}
|
||||
|
||||
interface TestCtx {
|
||||
dbName: string;
|
||||
testUrl: string;
|
||||
layer: AsyncDataLayer;
|
||||
adminSql: ReturnType<typeof postgres>;
|
||||
adminDb: ReturnType<typeof drizzle>;
|
||||
}
|
||||
|
||||
async function setupCtx(): Promise<TestCtx> {
|
||||
const dbName = uniqueDbName();
|
||||
try {
|
||||
adminExec(`DROP DATABASE IF EXISTS "${dbName}"`);
|
||||
} catch {
|
||||
// may not exist
|
||||
}
|
||||
adminExec(`CREATE DATABASE "${dbName}"`);
|
||||
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
|
||||
|
||||
const schemaBackend: ResolvedBackend = {
|
||||
mode: "external",
|
||||
runtimeUrl: testUrl,
|
||||
migrationUrl: testUrl,
|
||||
migrationUrlOverridden: false,
|
||||
};
|
||||
const schemaConnections = await createConnectionSetFromUrl(schemaBackend, {
|
||||
poolMax: 1,
|
||||
connectTimeoutSeconds: 5,
|
||||
});
|
||||
await applySchemaBaseline(schemaConnections.migration);
|
||||
await schemaConnections.close();
|
||||
|
||||
const connections = await createConnectionSetFromUrl(schemaBackend, {
|
||||
poolMax: 5,
|
||||
connectTimeoutSeconds: 5,
|
||||
});
|
||||
const layer = createAsyncDataLayer(connections);
|
||||
|
||||
const adminSql = postgres(testUrl, { max: 2, prepare: false, onnotice: () => {} });
|
||||
const adminDb = drizzle(adminSql);
|
||||
return { dbName, testUrl, layer, adminSql, adminDb };
|
||||
}
|
||||
|
||||
async function teardownCtx(ctx: TestCtx | null): Promise<void> {
|
||||
if (!ctx) return;
|
||||
try {
|
||||
await ctx.layer.close();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
await ctx.adminSql.end({ timeout: 5 });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
adminExec(`DROP DATABASE IF EXISTS "${ctx.dbName}"`);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal task record with the NOT NULL columns filled. */
|
||||
@@ -162,17 +89,20 @@ async function seedTaskWithParent(
|
||||
}
|
||||
|
||||
pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
let ctx: TestCtx | null = null;
|
||||
const h = createSharedPgTaskStoreTestHarness({ prefix: "fusion_u13" });
|
||||
let ctx!: TestCtx;
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownCtx(ctx);
|
||||
ctx = null;
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(async () => {
|
||||
await h.beforeEach();
|
||||
ctx = { layer: h.layer() };
|
||||
});
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
// ── VAL-DATA-010: Lineage-integrity gate blocks parent delete with live children ──
|
||||
|
||||
it("findLiveLineageChildren returns live children of a parent (VAL-DATA-010)", async () => {
|
||||
ctx = await setupCtx();
|
||||
// Parent + two live children + one archived child.
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-PARENT"), { lineageId: null });
|
||||
await seedTaskWithParent(ctx.layer, "KB-CHILD-1", "KB-PARENT", "todo");
|
||||
@@ -186,7 +116,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("lineage gate blocks parent delete when live children exist (VAL-DATA-010)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-PARENT"), { lineageId: null });
|
||||
await seedTaskWithParent(ctx.layer, "KB-LIVE", "KB-PARENT", "todo");
|
||||
|
||||
@@ -206,7 +135,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
// ── VAL-DATA-011: removeLineageReferences clears children ──
|
||||
|
||||
it("removeLineageReferences clears lineage edges so parent can be deleted (VAL-DATA-011)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-PARENT"), { lineageId: null });
|
||||
await seedTaskWithParent(ctx.layer, "KB-CHILD", "KB-PARENT", "todo");
|
||||
|
||||
@@ -247,7 +175,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
// ── VAL-DATA-012: Archived/soft-deleted children do not block parent delete ──
|
||||
|
||||
it("archived children do not block parent delete (VAL-DATA-012)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-PARENT"), { lineageId: null });
|
||||
// An archived child (column = 'archived' but not soft-deleted).
|
||||
await seedTaskWithParent(ctx.layer, "KB-ARCHIVED", "KB-PARENT", "archived");
|
||||
@@ -267,7 +194,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("soft-deleted children do not block parent delete (VAL-DATA-012)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-PARENT"), { lineageId: null });
|
||||
// A live child that we then soft-delete.
|
||||
await seedTaskWithParent(ctx.layer, "KB-SOFTDEL", "KB-PARENT", "todo");
|
||||
@@ -290,7 +216,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
// ── VAL-DATA-013: Handoff-to-review mergeQueue transactional invariant ──
|
||||
|
||||
it("handoff-to-review: column move + mergeQueue insert + audit are atomic (VAL-DATA-013)", async () => {
|
||||
ctx = await setupCtx();
|
||||
// Seed a task in a non-review column.
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-HANDOFF", "in-progress"), {
|
||||
lineageId: null,
|
||||
@@ -343,7 +268,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("handoff-to-review: a failing audit rolls back the column move and queue insert (VAL-DATA-013)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-ROLLBACK", "in-progress"), {
|
||||
lineageId: null,
|
||||
});
|
||||
@@ -400,7 +324,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
// ── VAL-DATA-014: Merge-queue lease semantics ──
|
||||
|
||||
it("merge-queue lease is acquired priority-first (urgent before normal)", async () => {
|
||||
ctx = await setupCtx();
|
||||
// Seed three tasks in-review, enqueued at slightly different times so the
|
||||
// priority ordering is deterministic regardless of FIFO tiebreak.
|
||||
const t0 = "2026-01-01T00:00:00Z";
|
||||
@@ -443,7 +366,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("merge-queue lease is FIFO within the same priority", async () => {
|
||||
ctx = await setupCtx();
|
||||
const t0 = "2026-01-01T00:00:00Z";
|
||||
const t1 = "2026-01-01T00:00:01Z";
|
||||
const t2 = "2026-01-01T00:00:02Z";
|
||||
@@ -476,7 +398,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("expired leases recover without incrementing attemptCount (VAL-DATA-014)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-EXPIRE", "in-review"), { lineageId: null });
|
||||
await enqueueMergeQueue(ctx.layer, "KB-EXPIRE");
|
||||
|
||||
@@ -509,7 +430,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("failure release increments attemptCount, success removes the row (VAL-DATA-014)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-OK", "in-review"), { lineageId: null });
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-FAIL", "in-review"), { lineageId: null });
|
||||
// Enqueue OK first so it is the queue head (FIFO within same priority).
|
||||
@@ -547,7 +467,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("release by a non-holder is rejected (ownership check)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-OWN", "in-review"), { lineageId: null });
|
||||
await enqueueMergeQueue(ctx.layer, "KB-OWN");
|
||||
await acquireMergeQueueLease(ctx.layer, "worker-1", { leaseDurationMs: 60_000 });
|
||||
@@ -558,7 +477,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("cleanupStaleMergeQueueRows removes entries whose task left in-review", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-STALE", "in-review"), { lineageId: null });
|
||||
await enqueueMergeQueue(ctx.layer, "KB-STALE");
|
||||
|
||||
@@ -580,7 +498,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("enqueue rejects a task not in in-review column", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-REJECT", "todo"), { lineageId: null });
|
||||
|
||||
await expect(enqueueMergeQueue(ctx.layer, "KB-REJECT")).rejects.toThrow();
|
||||
@@ -592,7 +509,6 @@ pgDescribe("U13 taskstore-lifecycle (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("peekMergeQueue orders priority-first then FIFO", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-A", "in-review"), { lineageId: null });
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-B", "in-review"), { lineageId: null });
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-C", "in-review"), { lineageId: null });
|
||||
|
||||
@@ -20,15 +20,13 @@
|
||||
* gate stays green without a running server.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { execSync } from "node:child_process";
|
||||
import { createAsyncDataLayer, type AsyncDataLayer } from "../../postgres/data-layer.js";
|
||||
import { createConnectionSetFromUrl } from "../../postgres/connection.js";
|
||||
import type { ResolvedBackend } from "../../postgres/backend-resolver.js";
|
||||
import { applySchemaBaseline } from "../../postgres/schema-applier.js";
|
||||
import { it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
|
||||
import {
|
||||
pgDescribe,
|
||||
createSharedPgTaskStoreTestHarness,
|
||||
} from "../../__test-utils__/pg-test-harness.js";
|
||||
import * as schema from "../../postgres/schema/index.js";
|
||||
import { insertTaskRow, softDeleteTaskRow } from "../../task-store/async/async-persistence.js";
|
||||
import {
|
||||
@@ -95,112 +93,22 @@ import {
|
||||
countSearchTasksLike,
|
||||
} from "../../task-store/async/async-search.js";
|
||||
|
||||
const PG_TEST_URL_BASE =
|
||||
process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432";
|
||||
const PG_AVAILABLE =
|
||||
process.env.FUSION_PG_TEST_SKIP !== "1" && Boolean(PG_TEST_URL_BASE);
|
||||
|
||||
const pgDescribe = PG_AVAILABLE ? describe : describe.skip;
|
||||
|
||||
/** FNXC:MultiProjectIsolation 2026-07-16-00:05: the project every harness row is owned by. */
|
||||
const TEST_PROJECT_ID = "proj_test_u14";
|
||||
|
||||
function uniqueDbName(): string {
|
||||
return `fusion_u14_test_${process.pid}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PgTestAuthFix 2026-07-14-00:00:
|
||||
The inline adminExec used process.env.USER for the psql -U flag, which is 'runner' on GitHub Actions (not 'postgres'). Use the PG_TEST_URL_BASE connection string instead so credentials are always correct.
|
||||
FNXC:TaskStoreRemaining 2026-08-15-03:52:
|
||||
Slow-test fix: this file hand-rolled CREATE DATABASE + full applySchemaBaseline
|
||||
PER TEST (~1.9s/test, 52s for the file). The helpers under test write only data,
|
||||
so the shared per-file harness (one golden-template DB + per-test reset) keeps
|
||||
isolation with the schema built once. The FNXC:MultiProjectIsolation 2026-07-16
|
||||
production-shape binding is preserved by passing `projectId: TEST_PROJECT_ID`
|
||||
to the harness, which threads the `fusion.project_id` GUC into the runtime
|
||||
connections and the layer exactly as the old inline setup did. `ctx` keeps its
|
||||
original `{ layer }` shape so test bodies stay byte-identical.
|
||||
*/
|
||||
function adminExec(statement: string): void {
|
||||
execSync(
|
||||
`psql "${PG_TEST_URL_BASE}/postgres" -v ON_ERROR_STOP=1 -c "${statement.replace(/"/g, '\\"')}"`,
|
||||
{ stdio: "pipe", env: process.env },
|
||||
);
|
||||
}
|
||||
|
||||
interface TestCtx {
|
||||
dbName: string;
|
||||
testUrl: string;
|
||||
layer: AsyncDataLayer;
|
||||
adminSql: ReturnType<typeof postgres>;
|
||||
adminDb: ReturnType<typeof drizzle>;
|
||||
}
|
||||
|
||||
async function setupCtx(): Promise<TestCtx> {
|
||||
const dbName = uniqueDbName();
|
||||
try {
|
||||
adminExec(`DROP DATABASE IF EXISTS "${dbName}"`);
|
||||
} catch {
|
||||
// may not exist
|
||||
}
|
||||
adminExec(`CREATE DATABASE "${dbName}"`);
|
||||
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
|
||||
|
||||
const schemaBackend: ResolvedBackend = {
|
||||
mode: "external",
|
||||
runtimeUrl: testUrl,
|
||||
migrationUrl: testUrl,
|
||||
migrationUrlOverridden: false,
|
||||
};
|
||||
const schemaConnections = await createConnectionSetFromUrl(schemaBackend, {
|
||||
poolMax: 1,
|
||||
connectTimeoutSeconds: 5,
|
||||
});
|
||||
await applySchemaBaseline(schemaConnections.migration);
|
||||
await schemaConnections.close();
|
||||
|
||||
/*
|
||||
FNXC:MultiProjectIsolation 2026-07-16-00:05:
|
||||
Bind the layer to a project, as production does. createConnectionSetFromUrl sets the
|
||||
`fusion.project_id` GUC per connection when given a projectId, and falls back to
|
||||
`fusion.project_bypass=on` when not; an unbound harness therefore ran with RLS bypassed and
|
||||
wrote blank project_ids that the migration-0006 trigger rewrote to '__legacy_unscoped__', so
|
||||
helpers scoping on `layer.projectId ?? ""` never found the rows they had just written. That is
|
||||
a shape production forbids -- AgentStore.backendProjectId throws on an unbound id -- so the
|
||||
tests, not the product, were wrong.
|
||||
*/
|
||||
const connections = await createConnectionSetFromUrl(schemaBackend, {
|
||||
poolMax: 5,
|
||||
connectTimeoutSeconds: 5,
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
const layer = createAsyncDataLayer(connections, { projectId: TEST_PROJECT_ID });
|
||||
|
||||
/*
|
||||
FNXC:MultiProjectIsolation 2026-07-16-00:05:
|
||||
The admin connection seeds and inspects rows the bound layer then reads, so it must sit in the
|
||||
SAME partition. Without the GUC its writes are blank, the migration-0006 trigger stamps them
|
||||
__legacy_unscoped__, and the bound layer scoping on TEST_PROJECT_ID cannot see its own fixtures.
|
||||
*/
|
||||
const adminSql = postgres(testUrl, {
|
||||
max: 2,
|
||||
prepare: false,
|
||||
onnotice: () => {},
|
||||
connection: { "fusion.project_id": TEST_PROJECT_ID },
|
||||
});
|
||||
const adminDb = drizzle(adminSql);
|
||||
return { dbName, testUrl, layer, adminSql, adminDb };
|
||||
}
|
||||
|
||||
async function teardownCtx(ctx: TestCtx | null): Promise<void> {
|
||||
if (!ctx) return;
|
||||
try {
|
||||
await ctx.layer.close();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
await ctx.adminSql.end({ timeout: 5 });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
adminExec(`DROP DATABASE IF EXISTS "${ctx.dbName}"`);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal task record with the NOT NULL columns filled. */
|
||||
@@ -217,17 +125,23 @@ function makeMinimalTask(id: string, column = "todo"): Record<string, unknown> {
|
||||
}
|
||||
|
||||
pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
let ctx: TestCtx | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownCtx(ctx);
|
||||
ctx = null;
|
||||
const h = createSharedPgTaskStoreTestHarness({
|
||||
prefix: "fusion_u14",
|
||||
projectId: TEST_PROJECT_ID,
|
||||
});
|
||||
let ctx!: TestCtx;
|
||||
|
||||
beforeAll(h.beforeAll);
|
||||
beforeEach(async () => {
|
||||
await h.beforeEach();
|
||||
ctx = { layer: h.layer() };
|
||||
});
|
||||
afterEach(h.afterEach);
|
||||
afterAll(h.afterAll);
|
||||
|
||||
// ── VAL-CROSS-014: Soft-deleting a child task allows parent deletion ──
|
||||
|
||||
it("soft-deleting a child allows parent deletion (VAL-CROSS-014)", async () => {
|
||||
ctx = await setupCtx();
|
||||
// Seed a parent + a live child.
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-PARENT"), { lineageId: null });
|
||||
await insertTaskRow(
|
||||
@@ -259,7 +173,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
// ── VAL-CROSS-015: Archive scopes docs/artifacts out of live views ──
|
||||
|
||||
it("archiving a parent scopes documents out of live views but preserves them (VAL-CROSS-015)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-DOC-PARENT"), { lineageId: null });
|
||||
|
||||
// Create a document on the live task.
|
||||
@@ -288,7 +201,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("archiving a parent scopes artifacts out of live views but preserves them (VAL-CROSS-015)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-ART-PARENT"), { lineageId: null });
|
||||
|
||||
// Register an artifact on the live task.
|
||||
@@ -323,7 +235,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
// ── Comments/attachments round-trip on active tasks ──
|
||||
|
||||
it("task documents round-trip on active tasks (upsert + read + update)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-DOC-RT"), { lineageId: null });
|
||||
|
||||
// Initial create.
|
||||
@@ -355,7 +266,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("enforces task-document CAS atomically for creates and updates", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-DOC-CAS"), { lineageId: null });
|
||||
|
||||
expect(taskDocumentContentHash("line 1\r\nline 2")).toMatch(/^sha256:[0-9a-f]{64}$/);
|
||||
@@ -452,7 +362,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("artifacts round-trip on active tasks (register + read)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-ART-RT"), { lineageId: null });
|
||||
|
||||
const artifact = await insertArtifactRow(ctx.layer, {
|
||||
@@ -478,7 +387,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("document upsert is rejected against archived tasks", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-ARCH-DOC"), { lineageId: null });
|
||||
await softDeleteTaskRow(ctx.layer, "KB-ARCH-DOC", new Date().toISOString());
|
||||
|
||||
@@ -491,7 +399,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("reads retained archived documents and serializes exactly one additive CAS publisher", async () => {
|
||||
ctx = await setupCtx();
|
||||
const taskId = "KB-ARCH-PUBLISH";
|
||||
const task = makeMinimalTask(taskId);
|
||||
await insertTaskRow(ctx.layer, task, { lineageId: null });
|
||||
@@ -578,7 +485,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("rejects malformed, live, missing, and inconsistent archived publication parents", async () => {
|
||||
ctx = await setupCtx();
|
||||
const taskId = "KB-ARCH-REJECT";
|
||||
const task = makeMinimalTask(taskId);
|
||||
await insertTaskRow(ctx.layer, task, { lineageId: null });
|
||||
@@ -611,7 +517,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
// ── Audit mutations and run-audit events commit/roll back together ──
|
||||
|
||||
it("activity log entries round-trip (record + query)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-ACT"), { lineageId: null });
|
||||
|
||||
await recordActivityLogEntry(ctx.layer.db, ctx.layer.projectId ?? "", {
|
||||
@@ -629,7 +534,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("run-audit events query by taskId", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-AUDIT"), { lineageId: null });
|
||||
|
||||
// Record a run-audit event directly.
|
||||
@@ -656,7 +560,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
// ── Branch groups ──
|
||||
|
||||
it("branch groups round-trip (create + read + update + list)", async () => {
|
||||
ctx = await setupCtx();
|
||||
const created = await createBranchGroup(ctx.layer.db, {
|
||||
sourceType: "mission",
|
||||
sourceId: "miss-1",
|
||||
@@ -686,7 +589,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("ensureBranchGroupForSource reuses existing group for same branch", async () => {
|
||||
ctx = await setupCtx();
|
||||
const g1 = await ensureBranchGroupForSource(
|
||||
ctx.layer.db,
|
||||
"mission",
|
||||
@@ -704,7 +606,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("PR entities round-trip (ensure + update + list active)", async () => {
|
||||
ctx = await setupCtx();
|
||||
const created = await ensurePrEntityForSource(ctx.layer.db, {
|
||||
sourceType: "task",
|
||||
sourceId: "task-1",
|
||||
@@ -743,7 +644,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("PR thread outcomes round-trip (record + read)", async () => {
|
||||
ctx = await setupCtx();
|
||||
const pr = await ensurePrEntityForSource(ctx.layer.db, {
|
||||
sourceType: "task",
|
||||
sourceId: "task-thread",
|
||||
@@ -761,7 +661,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
// ── Workflow work-items ──
|
||||
|
||||
it("workflow work items round-trip (upsert + transition + terminal guard)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-WF"), { lineageId: null });
|
||||
|
||||
const item = await upsertWorkflowWorkItem(ctx.layer, {
|
||||
@@ -790,7 +689,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("workflow work item upsert is idempotent on composite key", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-WF-IDEM"), { lineageId: null });
|
||||
|
||||
const item1 = await upsertWorkflowWorkItem(ctx.layer, {
|
||||
@@ -812,7 +710,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("completion handoff markers round-trip (record + read)", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-HANDOFF"), { lineageId: null });
|
||||
|
||||
await recordCompletionHandoff(ctx.layer.db, "KB-HANDOFF", "engine");
|
||||
@@ -821,7 +718,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("listDueWorkflowWorkItems returns items with expired/null leases", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-DUE"), { lineageId: null });
|
||||
|
||||
await upsertWorkflowWorkItem(ctx.layer, {
|
||||
@@ -839,7 +735,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
// ── Goal citations / usage events / plugin activations ──
|
||||
|
||||
it("goal citations dedup on (goalId, surface, sourceRef)", async () => {
|
||||
ctx = await setupCtx();
|
||||
const inserted1 = await recordGoalCitations(ctx.layer.db, [
|
||||
{ goalId: "g1", agentId: "a1", surface: "task_document", sourceRef: "doc:1", snippet: "cite 1" },
|
||||
]);
|
||||
@@ -862,7 +757,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("usage events round-trip (emit + query)", async () => {
|
||||
ctx = await setupCtx();
|
||||
const inserted = await emitUsageEvent(ctx.layer.db, ctx.layer.projectId ?? "", {
|
||||
kind: "tool_call",
|
||||
taskId: "KB-USAGE",
|
||||
@@ -880,7 +774,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("usage events fail-soft on unknown kind", async () => {
|
||||
ctx = await setupCtx();
|
||||
const inserted = await emitUsageEvent(ctx.layer.db, ctx.layer.projectId ?? "", {
|
||||
// @ts-expect-error — intentionally invalid kind
|
||||
kind: "bogus_kind",
|
||||
@@ -889,7 +782,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("plugin activations round-trip (record)", async () => {
|
||||
ctx = await setupCtx();
|
||||
const activation = await recordPluginActivation(ctx.layer.db, {
|
||||
pluginId: "roadmap",
|
||||
source: "npm",
|
||||
@@ -902,7 +794,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
// ── Archive snapshots ──
|
||||
|
||||
it("archived task snapshots round-trip (upsert + find + list + filter)", async () => {
|
||||
ctx = await setupCtx();
|
||||
const entry = {
|
||||
id: "KB-ARCH-SNAP",
|
||||
lineageId: "lineage-1",
|
||||
@@ -937,7 +828,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("searchTasksLike finds tasks by token and respects soft-delete", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(
|
||||
ctx.layer,
|
||||
{ ...makeMinimalTask("KB-SEARCH-1"), title: "implement auth" },
|
||||
@@ -963,7 +853,6 @@ pgDescribe("U14 taskstore-remaining (PostgreSQL)", () => {
|
||||
});
|
||||
|
||||
it("searchTasksLike returns empty for empty queries", async () => {
|
||||
ctx = await setupCtx();
|
||||
await insertTaskRow(ctx.layer, makeMinimalTask("KB-EMPTY"), { lineageId: null });
|
||||
|
||||
const results = await searchTasksLike(ctx.layer.db, "");
|
||||
|
||||
Reference in New Issue
Block a user