chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Agent enrollment schema witnesses — M4-4a, the schema-level half of the
|
||||
* witness list in docs/plans/2026-08-29-agent-enrollment-command-design.md §5.
|
||||
*
|
||||
* Witnesses the guarantees migration 0021's tables themselves carry: the
|
||||
* event-type CHECK, monotonic per-agent append order (`seq`), deletion-safe
|
||||
* linkage (no foreign key from the events or fence tables into `agents` —
|
||||
* rows survive a legacy CRUD DELETE of the agent), the causation self-FK,
|
||||
* the outbox's FK/uniqueness/status shape, the fence's UNIQUE
|
||||
* (operation, key) and replay-mode CHECK, and the nullable enrollment
|
||||
* columns on `agents` (legacy rows insert without them). The command-level
|
||||
* witnesses (never-echo, same-tx atomicity, replay semantics, correlation,
|
||||
* CLI parity, fail-closed) belong to the M4-4b implementation slice.
|
||||
*
|
||||
* Two legs run the same witness body:
|
||||
* - PGlite (WASM Postgres): always runs.
|
||||
* - Real PostgreSQL: runs when DATABASE_URL is set — the binding witness;
|
||||
* CI migrates ci-postgres before `pnpm test`.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createDb } from './client.js';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import { agentAuditEvents, agentIdempotencyFence, agentOutbox, agents } from './schema.js';
|
||||
|
||||
type AnyDb = {
|
||||
db: {
|
||||
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
|
||||
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
|
||||
};
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Match a constraint failure anywhere along drizzle's cause chain. */
|
||||
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
|
||||
let err: unknown;
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err, label || 'expected the statement to be refused').toBeDefined();
|
||||
const messages: string[] = [];
|
||||
let cur: unknown = err;
|
||||
while (cur instanceof Error) {
|
||||
messages.push(cur.message);
|
||||
cur = (cur as { cause?: unknown }).cause;
|
||||
}
|
||||
expect(messages.join(' | '), label).toMatch(re);
|
||||
}
|
||||
|
||||
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
|
||||
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
|
||||
const T = `agent-e-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
type EventInsert = typeof agentAuditEvents.$inferInsert;
|
||||
|
||||
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
|
||||
return {
|
||||
eventType: 'agent.enrolled',
|
||||
actorId: `${T}-actor`,
|
||||
agentId: randomUUID(),
|
||||
correlationId: `${T}-corr-${randomUUID()}`,
|
||||
payload: {
|
||||
harness: 'claude-code',
|
||||
provider: 'anthropic',
|
||||
name: 'x',
|
||||
credentialMode: 'reference',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type FenceInsert = typeof agentIdempotencyFence.$inferInsert;
|
||||
|
||||
function fenceRow(overrides: Partial<FenceInsert> = {}): FenceInsert {
|
||||
return {
|
||||
operation: 'agent.enroll',
|
||||
idempotencyKey: `${T}-${randomUUID()}`,
|
||||
actorId: `${T}-actor`,
|
||||
authorizationScope: 'platform-user',
|
||||
payloadDigest: `${T}-digest`,
|
||||
outcomeAgentId: randomUUID(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function witnessSuite(getHandle: () => AnyDb): void {
|
||||
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
|
||||
|
||||
afterAll(async () => {
|
||||
const d = db();
|
||||
await d.execute(sql`DELETE FROM agent_outbox WHERE correlation_id LIKE ${T + '%'}`);
|
||||
// Caused events first: the causation self-FK is RESTRICT.
|
||||
await d.execute(
|
||||
sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'} AND causation_id IS NOT NULL`,
|
||||
);
|
||||
await d.execute(sql`DELETE FROM agent_audit_events WHERE correlation_id LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM agent_idempotency_fence WHERE actor_id LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM agents WHERE name LIKE ${T + '%'}`);
|
||||
});
|
||||
|
||||
// ── agents: nullable enrollment columns (no backfill semantics) ────────────
|
||||
|
||||
it('legacy agent rows insert without enrollment columns; enrolled rows carry both', async () => {
|
||||
const legacyId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({
|
||||
id: legacyId,
|
||||
name: `${T}-legacy`,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-fable-5',
|
||||
});
|
||||
const legacy = rows(
|
||||
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${legacyId}`),
|
||||
)[0]!;
|
||||
expect(legacy['harness']).toBeNull();
|
||||
expect(legacy['enrolled_at']).toBeNull();
|
||||
|
||||
const enrolledId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({
|
||||
id: enrolledId,
|
||||
name: `${T}-enrolled`,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-fable-5',
|
||||
harness: 'claude-code',
|
||||
enrolledAt: new Date(),
|
||||
});
|
||||
const enrolled = rows(
|
||||
await db().execute(sql`SELECT harness, enrolled_at FROM agents WHERE id = ${enrolledId}`),
|
||||
)[0]!;
|
||||
expect(enrolled['harness']).toBe('claude-code');
|
||||
expect(enrolled['enrolled_at']).not.toBeNull();
|
||||
});
|
||||
|
||||
// ── agent_audit_events: CHECK, ordering, deletion-safe linkage ─────────────
|
||||
|
||||
it('accepts both declared event types and refuses an undeclared one', async () => {
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ eventType: 'agent.enrolled' }));
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ eventType: 'agent.enrollment.replayed' }));
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ eventType: 'agent.deleted' })),
|
||||
/type_check|violates check/i,
|
||||
'undeclared event type must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('assigns strictly increasing seq in insert order for one agent', async () => {
|
||||
const agentId = randomUUID();
|
||||
const c1 = `${T}-seq-1-${randomUUID()}`;
|
||||
const c2 = `${T}-seq-2-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ agentId, correlationId: c1 }));
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ agentId, eventType: 'agent.enrollment.replayed', correlationId: c2 }));
|
||||
const res = rows(
|
||||
await db().execute(
|
||||
sql`SELECT correlation_id, seq FROM agent_audit_events WHERE agent_id = ${agentId} ORDER BY seq ASC`,
|
||||
),
|
||||
);
|
||||
expect(res.map((r) => r['correlation_id'])).toEqual([c1, c2]);
|
||||
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
|
||||
});
|
||||
|
||||
it('has no foreign key into agents, and events survive agent deletion', async () => {
|
||||
const fks = rows(
|
||||
await db().execute(sql`
|
||||
SELECT ccu.table_name AS referenced_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_audit_events'
|
||||
`),
|
||||
);
|
||||
// The causation self-FK is the ONLY foreign key on the events table.
|
||||
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['agent_audit_events']);
|
||||
|
||||
const agentId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({ id: agentId, name: `${T}-doomed`, provider: 'anthropic', model: 'claude-fable-5' });
|
||||
const corr = `${T}-survive-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ agentId, correlationId: corr }));
|
||||
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
|
||||
const after = rows(
|
||||
await db().execute(
|
||||
sql`SELECT agent_id FROM agent_audit_events WHERE correlation_id = ${corr}`,
|
||||
),
|
||||
);
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]!['agent_id']).toBe(agentId);
|
||||
});
|
||||
|
||||
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ causationId: randomUUID() })),
|
||||
/foreign key/i,
|
||||
'causation must reference an existing event',
|
||||
);
|
||||
const causeCorr = `${T}-cause-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ correlationId: causeCorr }));
|
||||
const cause = rows(
|
||||
await db().execute(
|
||||
sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${causeCorr}`,
|
||||
),
|
||||
)[0]!;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(
|
||||
eventRow({
|
||||
eventType: 'agent.enrollment.replayed',
|
||||
causationId: cause['id'] as string,
|
||||
}),
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${cause['id'] as string}`),
|
||||
/foreign key/i,
|
||||
'a cause with dependent events must not be deletable',
|
||||
);
|
||||
});
|
||||
|
||||
// ── agent_outbox shape ─────────────────────────────────────────────────────
|
||||
|
||||
it('outbox rows require an existing event, one outbox row per event, closed status enum', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentOutbox)
|
||||
.values({ eventId: randomUUID(), correlationId: `${T}-corr` }),
|
||||
/foreign key/i,
|
||||
'outbox must reference an existing event',
|
||||
);
|
||||
const corr = `${T}-ob-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ correlationId: corr }));
|
||||
const event = rows(
|
||||
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
|
||||
)[0]!;
|
||||
const eventId = event['id'] as string;
|
||||
await db().insert(agentOutbox).values({ eventId, correlationId: corr });
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentOutbox)
|
||||
.values({ eventId, correlationId: `${T}-ob2` }),
|
||||
/duplicate key|unique/i,
|
||||
'one outbox record per event',
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO agent_outbox (event_id, correlation_id, status)
|
||||
VALUES (${eventId}, ${`${T}-ob3`}, 'failed')`,
|
||||
),
|
||||
/invalid input value for enum|22P02/i,
|
||||
'status outside pending/processing/delivered must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
|
||||
const corr = `${T}-obr-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentAuditEvents)
|
||||
.values(eventRow({ correlationId: corr }));
|
||||
const event = rows(
|
||||
await db().execute(sql`SELECT id FROM agent_audit_events WHERE correlation_id = ${corr}`),
|
||||
)[0]!;
|
||||
await db()
|
||||
.insert(agentOutbox)
|
||||
.values({ eventId: event['id'] as string, correlationId: corr });
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM agent_audit_events WHERE id = ${event['id'] as string}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── agent_idempotency_fence: (operation, key) uniqueness, mode CHECK ───────
|
||||
|
||||
it('refuses a duplicate (operation, key) pair but allows the same key under another operation', async () => {
|
||||
const key = `${T}-fence-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key }));
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key })),
|
||||
/duplicate key|unique/i,
|
||||
'fence uniqueness is (operation, key)',
|
||||
);
|
||||
// Same key, different operation identifier: a distinct fence.
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key, operation: 'agent.other' }));
|
||||
});
|
||||
|
||||
it('defaults replay mode to actor-bound and refuses an undeclared mode', async () => {
|
||||
const key = `${T}-mode-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key }));
|
||||
const row = rows(
|
||||
await db().execute(
|
||||
sql`SELECT replay_mode FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
|
||||
),
|
||||
)[0]!;
|
||||
expect(row['replay_mode']).toBe('actor-bound');
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ replayMode: 'unbound' as 'actor-bound' })),
|
||||
/replay_mode_check|violates check/i,
|
||||
'a mode outside actor-bound/shared must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('fence has no foreign key at all, and rows survive agent deletion', async () => {
|
||||
const fks = rows(
|
||||
await db().execute(sql`
|
||||
SELECT ccu.table_name AS referenced_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'agent_idempotency_fence'
|
||||
`),
|
||||
);
|
||||
expect(fks).toHaveLength(0);
|
||||
|
||||
const agentId = randomUUID();
|
||||
await db()
|
||||
.insert(agents)
|
||||
.values({
|
||||
id: agentId,
|
||||
name: `${T}-fdoomed`,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-fable-5',
|
||||
});
|
||||
const key = `${T}-fsurvive-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(agentIdempotencyFence)
|
||||
.values(fenceRow({ idempotencyKey: key, outcomeAgentId: agentId }));
|
||||
await db().execute(sql`DELETE FROM agents WHERE id = ${agentId}`);
|
||||
const after = rows(
|
||||
await db().execute(
|
||||
sql`SELECT outcome_agent_id FROM agent_idempotency_fence WHERE idempotency_key = ${key}`,
|
||||
),
|
||||
);
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]!['outcome_agent_id']).toBe(agentId);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
|
||||
|
||||
describe('agent enrollment schema witnesses — PGlite', () => {
|
||||
let dir: string;
|
||||
let handle: ReturnType<typeof createPgliteDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'agent-enroll-witness-'));
|
||||
handle = createPgliteDb(dir);
|
||||
await runPgliteMigrations(handle);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
|
||||
// ── Leg 2: real PostgreSQL (binding witness, ci-postgres in CI) ──────────────
|
||||
|
||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||
|
||||
describe.skipIf(!hasPostgres)('agent enrollment schema witnesses — real PostgreSQL', () => {
|
||||
let handle: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(() => {
|
||||
handle = createDb(process.env['DATABASE_URL']!);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import type { DbHandle } from './client.js';
|
||||
import { BacklogService } from './backlog.js';
|
||||
import { backlog } from './schema.js';
|
||||
|
||||
// Helper: backdate a claim's claimed_at by 1 hour so it is past any short TTL.
|
||||
function sqlBackdate(id: string) {
|
||||
return sql`UPDATE ${backlog} SET claimed_at = now() - interval '1 hour' WHERE ${backlog.id} = ${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Postgres semantics, no external server: embedded in-memory PGlite.
|
||||
* The migration path creates the `backlog` table (and every other table) so the
|
||||
* service runs against the actual generated schema, including the row locks the
|
||||
* atomic-claim path depends on.
|
||||
*/
|
||||
async function freshService(): Promise<{ handle: DbHandle; svc: BacklogService }> {
|
||||
const handle = createPgliteDb('memory://');
|
||||
await runPgliteMigrations(handle);
|
||||
return { handle, svc: new BacklogService(handle.db) };
|
||||
}
|
||||
|
||||
describe('BacklogService', () => {
|
||||
let handle: DbHandle;
|
||||
let svc: BacklogService;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ handle, svc } = await freshService());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it('create then list returns the card', async () => {
|
||||
await svc.create({ id: 'c1', title: 'First card', phase: 'M1', priority: 5 });
|
||||
const all = await svc.list();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]).toMatchObject({ id: 'c1', title: 'First card', phase: 'M1', status: 'ready' });
|
||||
});
|
||||
|
||||
it('idempotency_key dedups create', async () => {
|
||||
const a = await svc.create({ id: 'c1', title: 'one', idempotencyKey: 'k-1' });
|
||||
const b = await svc.create({ id: 'c2', title: 'two', idempotencyKey: 'k-1' });
|
||||
expect(b.id).toBe(a.id);
|
||||
const all = await svc.list();
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('list filters by status and phase', async () => {
|
||||
await svc.create({ id: 'c1', title: 'a', phase: 'M1' });
|
||||
await svc.create({ id: 'c2', title: 'b', phase: 'M2' });
|
||||
await svc.block('c2');
|
||||
expect(await svc.list({ phase: 'M1' })).toHaveLength(1);
|
||||
expect(await svc.list({ status: 'blocked' })).toHaveLength(1);
|
||||
expect((await svc.list({ status: 'blocked' }))[0]!.id).toBe('c2');
|
||||
});
|
||||
|
||||
describe('atomic claim', () => {
|
||||
it('two concurrent claimers on one card => exactly one wins', async () => {
|
||||
await svc.create({ id: 'only', title: 'the one', priority: 10 });
|
||||
|
||||
// Two independent claimers race for the single ready card on the same db.
|
||||
// The atomic claim path (`FOR UPDATE SKIP LOCKED` inside a transaction)
|
||||
// guarantees the loser's locked row is skipped, so it can never also flip
|
||||
// the card to claimed — it gets the next candidate (none) and returns null.
|
||||
const svcA = new BacklogService(handle.db);
|
||||
const svcB = new BacklogService(handle.db);
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
svcA.claim({ owner: 'worker-A' }),
|
||||
svcB.claim({ owner: 'worker-B' }),
|
||||
]);
|
||||
|
||||
const winners = [a, b].filter((c) => c !== null);
|
||||
expect(winners).toHaveLength(1);
|
||||
expect(winners[0]!.id).toBe('only');
|
||||
expect(winners[0]!.status).toBe('claimed');
|
||||
expect(['worker-A', 'worker-B']).toContain(winners[0]!.claimOwner);
|
||||
|
||||
const card = await svc.get('only');
|
||||
expect(card!.status).toBe('claimed');
|
||||
expect(card!.attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('many concurrent claimers on N cards => no card is double-claimed', async () => {
|
||||
// 5 ready cards, 8 concurrent claimers. Exactly 5 win, all distinct.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await svc.create({ id: `card-${i}`, title: `card ${i}`, priority: i });
|
||||
}
|
||||
const claimers = Array.from({ length: 8 }, (_, i) =>
|
||||
new BacklogService(handle.db).claim({ owner: `w-${i}` }),
|
||||
);
|
||||
const results = await Promise.all(claimers);
|
||||
const won = results.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
const wonIds = won.map((c) => c.id);
|
||||
expect(won).toHaveLength(5);
|
||||
expect(new Set(wonIds).size).toBe(5); // all distinct — no double-claim
|
||||
});
|
||||
|
||||
it('N concurrent claimers on N ready cards => every claimer wins a distinct card (no starvation)', async () => {
|
||||
// This is the direct benefit of locking exactly ONE ready row per claim
|
||||
// (`FOR UPDATE SKIP LOCKED LIMIT 1`): with as many ready cards as
|
||||
// claimers, NONE should starve. The old "lock the whole ready set"
|
||||
// behaviour let one claimer lock every row, forcing the rest to null even
|
||||
// though cards were free.
|
||||
const N = 6;
|
||||
for (let i = 0; i < N; i++) {
|
||||
await svc.create({ id: `n-${i}`, title: `card ${i}`, priority: i });
|
||||
}
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: N }, (_, i) =>
|
||||
new BacklogService(handle.db).claim({ owner: `w-${i}` }),
|
||||
),
|
||||
);
|
||||
const won = results.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
// No claimer starved: all N won.
|
||||
expect(won).toHaveLength(N);
|
||||
// Each won a distinct card.
|
||||
expect(new Set(won.map((c) => c.id)).size).toBe(N);
|
||||
// Every ready card was consumed.
|
||||
expect(await svc.list({ status: 'ready' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sequential claims drain ready cards in priority order and never null while ready remain', async () => {
|
||||
// PGlite-stable fallback assertion of the same property without relying on
|
||||
// true parallelism or wall-clock timing: each claim returns the next
|
||||
// highest-priority distinct card and never spuriously returns null while
|
||||
// ready cards remain.
|
||||
const N = 4;
|
||||
for (let i = 0; i < N; i++) {
|
||||
await svc.create({ id: `s-${i}`, title: `card ${i}`, priority: i });
|
||||
}
|
||||
const order: string[] = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const claimed = await svc.claim({ owner: `w-${i}` });
|
||||
expect(claimed).not.toBeNull();
|
||||
order.push(claimed!.id);
|
||||
}
|
||||
// Highest priority first, all distinct.
|
||||
expect(order).toEqual(['s-3', 's-2', 's-1', 's-0']);
|
||||
expect(new Set(order).size).toBe(N);
|
||||
// Now nothing ready remains => null.
|
||||
expect(await svc.claim({ owner: 'late' })).toBeNull();
|
||||
});
|
||||
|
||||
it('claim picks the highest-priority ready card', async () => {
|
||||
await svc.create({ id: 'low', title: 'low', priority: 1 });
|
||||
await svc.create({ id: 'high', title: 'high', priority: 9 });
|
||||
const claimed = await svc.claim({ owner: 'w' });
|
||||
expect(claimed!.id).toBe('high');
|
||||
});
|
||||
|
||||
it('claim of a specific --id', async () => {
|
||||
await svc.create({ id: 'a', title: 'a', priority: 9 });
|
||||
await svc.create({ id: 'b', title: 'b', priority: 1 });
|
||||
const claimed = await svc.claim({ owner: 'w', id: 'b' });
|
||||
expect(claimed!.id).toBe('b');
|
||||
});
|
||||
|
||||
it('claim returns null when nothing is ready', async () => {
|
||||
const claimed = await svc.claim({ owner: 'w' });
|
||||
expect(claimed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deps DAG gate', () => {
|
||||
it('card with an unfinished dep is not claimable and not ready', async () => {
|
||||
await svc.create({ id: 'dep', title: 'dependency' });
|
||||
await svc.create({ id: 'main', title: 'depends on dep', dependsOn: ['dep'] });
|
||||
|
||||
// `main` should NOT be claimable while `dep` is not done — `dep` wins.
|
||||
const first = await svc.claim({ owner: 'w' });
|
||||
expect(first!.id).toBe('dep');
|
||||
|
||||
// With dep claimed (not done), main still cannot be claimed.
|
||||
const second = await svc.claim({ owner: 'w' });
|
||||
expect(second).toBeNull();
|
||||
|
||||
// ready-only list excludes main while its dep is unfinished.
|
||||
const ready = await svc.list({ readyOnly: true });
|
||||
expect(ready.map((c) => c.id)).not.toContain('main');
|
||||
|
||||
// Once dep is done, main becomes ready and claimable.
|
||||
await svc.complete('dep');
|
||||
const readyAfter = await svc.list({ readyOnly: true });
|
||||
expect(readyAfter.map((c) => c.id)).toContain('main');
|
||||
const third = await svc.claim({ owner: 'w' });
|
||||
expect(third!.id).toBe('main');
|
||||
});
|
||||
|
||||
it('link adds a depends_on edge', async () => {
|
||||
await svc.create({ id: 'a', title: 'a' });
|
||||
await svc.create({ id: 'b', title: 'b' });
|
||||
const linked = await svc.link('a', 'b');
|
||||
expect(linked.dependsOn).toEqual(['b']);
|
||||
// a is now gated on b
|
||||
const claimed = await svc.claim({ owner: 'w' });
|
||||
expect(claimed!.id).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reclaim TTL', () => {
|
||||
it('reclaim returns expired claims to ready', async () => {
|
||||
await svc.create({ id: 'c1', title: 'c1' });
|
||||
const claimed = await svc.claim({ owner: 'w', ttlSeconds: 60 });
|
||||
expect(claimed!.status).toBe('claimed');
|
||||
|
||||
// Backdate the claim so it is well past its TTL.
|
||||
await handle.db.execute(sqlBackdate('c1'));
|
||||
|
||||
const result = await svc.reclaim();
|
||||
expect(result.reclaimed).toEqual(['c1']);
|
||||
const card = await svc.get('c1');
|
||||
expect(card!.status).toBe('ready');
|
||||
expect(card!.claimOwner).toBeNull();
|
||||
expect(card!.claimedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('reclaim does not touch a fresh (unexpired) claim', async () => {
|
||||
await svc.create({ id: 'c1', title: 'c1' });
|
||||
await svc.claim({ owner: 'w', ttlSeconds: 3600 });
|
||||
const result = await svc.reclaim();
|
||||
expect(result.reclaimed).toEqual([]);
|
||||
expect((await svc.get('c1'))!.status).toBe('claimed');
|
||||
});
|
||||
|
||||
it('reclaim --id releases a specific claim regardless of expiry', async () => {
|
||||
await svc.create({ id: 'c1', title: 'c1' });
|
||||
await svc.claim({ owner: 'w', ttlSeconds: 3600 });
|
||||
const result = await svc.reclaim({ id: 'c1' });
|
||||
expect(result.reclaimed).toEqual(['c1']);
|
||||
expect((await svc.get('c1'))!.status).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
it('computes counts, oldest-ready age, and expired-claim count', async () => {
|
||||
await svc.create({ id: 'r1', title: 'r1' });
|
||||
await svc.create({ id: 'r2', title: 'r2' });
|
||||
await svc.create({ id: 'b1', title: 'b1' });
|
||||
await svc.block('b1');
|
||||
await svc.create({ id: 'd1', title: 'd1' });
|
||||
await svc.complete('d1');
|
||||
await svc.create({ id: 'cl1', title: 'cl1' });
|
||||
await svc.claim({ owner: 'w', id: 'cl1', ttlSeconds: 60 });
|
||||
await handle.db.execute(sqlBackdate('cl1'));
|
||||
|
||||
const stats = await svc.stats();
|
||||
expect(stats.counts.ready).toBe(2);
|
||||
expect(stats.counts.blocked).toBe(1);
|
||||
expect(stats.counts.done).toBe(1);
|
||||
expect(stats.counts.claimed).toBe(1);
|
||||
expect(stats.total).toBe(5);
|
||||
expect(stats.expiredClaimCount).toBe(1);
|
||||
expect(stats.oldestReadyAgeSeconds).not.toBeNull();
|
||||
expect(stats.oldestReadyAgeSeconds!).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Mosaic-native backlog-of-record service (card A4).
|
||||
*
|
||||
* This is the backlog Mosaic owns end-to-end on its OWN Postgres storage layer.
|
||||
* It REPLACES the former Hermes adapter — there is NO runtime dependency on
|
||||
* Hermes here or anywhere downstream.
|
||||
*
|
||||
* The service takes a `Db` handle, so it works identically against:
|
||||
* - `createDb()` — server Postgres (DATABASE_URL / config), and
|
||||
* - `createPgliteDb()` — embedded Postgres (file or in-memory).
|
||||
* Same code, same semantics — PGlite gives real Postgres behaviour (including
|
||||
* row locks), so the atomic-claim path is exercised by the in-memory tests.
|
||||
*
|
||||
* Atomic claim: `claim()` selects the highest-priority, deps-satisfied, ready
|
||||
* card with `SELECT ... FOR UPDATE SKIP LOCKED` and flips it to `claimed` inside
|
||||
* one transaction. Two concurrent claimers can therefore NEVER both win the same
|
||||
* card — the loser's locked row is skipped and it picks the next candidate (or
|
||||
* gets null).
|
||||
*/
|
||||
|
||||
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
||||
import type { Db } from './client.js';
|
||||
import { backlog } from './schema.js';
|
||||
|
||||
export type BacklogStatus = 'ready' | 'claimed' | 'blocked' | 'done';
|
||||
|
||||
export interface BacklogCard {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
phase: string | null;
|
||||
priority: number;
|
||||
status: BacklogStatus;
|
||||
dependsOn: string[];
|
||||
claimOwner: string | null;
|
||||
claimTtlSeconds: number | null;
|
||||
claimedAt: Date | null;
|
||||
attempts: number;
|
||||
idempotencyKey: string | null;
|
||||
acceptance: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateCardInput {
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
phase?: string | null;
|
||||
priority?: number;
|
||||
dependsOn?: string[];
|
||||
acceptance?: unknown;
|
||||
idempotencyKey?: string | null;
|
||||
status?: BacklogStatus;
|
||||
}
|
||||
|
||||
export interface ListFilter {
|
||||
status?: BacklogStatus;
|
||||
phase?: string;
|
||||
/** When true, return only cards that are `ready` AND have all deps `done`. */
|
||||
readyOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ClaimOptions {
|
||||
owner: string;
|
||||
/** Claim time-to-live in seconds (default 900). */
|
||||
ttlSeconds?: number;
|
||||
/** Claim a specific card by id instead of the highest-priority ready one. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface ReclaimResult {
|
||||
reclaimed: string[];
|
||||
}
|
||||
|
||||
export interface BacklogStats {
|
||||
counts: Record<BacklogStatus, number>;
|
||||
total: number;
|
||||
oldestReadyAgeSeconds: number | null;
|
||||
expiredClaimCount: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLAIM_TTL_SECONDS = 900;
|
||||
|
||||
type Row = typeof backlog.$inferSelect;
|
||||
|
||||
/**
|
||||
* Row shape as returned by the raw `SELECT * ... FOR UPDATE SKIP LOCKED` path.
|
||||
* That path bypasses drizzle's column-name mapping, so JSON columns arrive as
|
||||
* the snake_case `depends_on` (and may be a JSON string under some drivers).
|
||||
*/
|
||||
interface RawRow extends Row {
|
||||
depends_on?: unknown;
|
||||
}
|
||||
|
||||
function toCard(row: Row): BacklogCard {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
phase: row.phase,
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
dependsOn: row.dependsOn ?? [],
|
||||
claimOwner: row.claimOwner,
|
||||
claimTtlSeconds: row.claimTtlSeconds,
|
||||
claimedAt: row.claimedAt,
|
||||
attempts: row.attempts,
|
||||
idempotencyKey: row.idempotencyKey,
|
||||
acceptance: row.acceptance,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The backlog repository/service. Construct with any `Db` handle.
|
||||
*/
|
||||
export class BacklogService {
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
/**
|
||||
* Create a card. If `idempotencyKey` is provided and a card already exists
|
||||
* with that key, the existing card is returned unchanged (no duplicate).
|
||||
*/
|
||||
async create(input: CreateCardInput): Promise<BacklogCard> {
|
||||
if (input.idempotencyKey) {
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(backlog)
|
||||
.where(eq(backlog.idempotencyKey, input.idempotencyKey))
|
||||
.limit(1);
|
||||
if (existing[0]) return toCard(existing[0]);
|
||||
}
|
||||
|
||||
const inserted = await this.db
|
||||
.insert(backlog)
|
||||
.values({
|
||||
id: input.id,
|
||||
title: input.title,
|
||||
body: input.body ?? null,
|
||||
phase: input.phase ?? null,
|
||||
priority: input.priority ?? 0,
|
||||
status: input.status ?? 'ready',
|
||||
dependsOn: input.dependsOn ?? [],
|
||||
acceptance: input.acceptance ?? null,
|
||||
idempotencyKey: input.idempotencyKey ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toCard(inserted[0]!);
|
||||
}
|
||||
|
||||
/** Fetch a single card by id, or null. */
|
||||
async get(id: string): Promise<BacklogCard | null> {
|
||||
const rows = await this.db.select().from(backlog).where(eq(backlog.id, id)).limit(1);
|
||||
return rows[0] ? toCard(rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List cards with optional filters. `readyOnly` enforces the DAG gate:
|
||||
* a card is "ready" only when its own status is `ready` AND every card in
|
||||
* `depends_on` exists and is `done`.
|
||||
*/
|
||||
async list(filter: ListFilter = {}): Promise<BacklogCard[]> {
|
||||
const conditions = [];
|
||||
if (filter.status) conditions.push(eq(backlog.status, filter.status));
|
||||
if (filter.phase) conditions.push(eq(backlog.phase, filter.phase));
|
||||
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(backlog)
|
||||
.where(conditions.length ? and(...conditions) : undefined)
|
||||
.orderBy(desc(backlog.priority), asc(backlog.createdAt));
|
||||
|
||||
const cards = rows.map(toCard);
|
||||
if (!filter.readyOnly) return cards;
|
||||
|
||||
const doneIds = await this.doneIdSet();
|
||||
return cards.filter(
|
||||
(c) => c.status === 'ready' && c.dependsOn.every((dep) => doneIds.has(dep)),
|
||||
);
|
||||
}
|
||||
|
||||
private async doneIdSet(): Promise<Set<string>> {
|
||||
const done = await this.db
|
||||
.select({ id: backlog.id })
|
||||
.from(backlog)
|
||||
.where(eq(backlog.status, 'done'));
|
||||
return new Set(done.map((d) => d.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a card.
|
||||
*
|
||||
* Strategy: inside ONE transaction we lock the candidate row with
|
||||
* `FOR UPDATE SKIP LOCKED LIMIT 1`. A concurrent claimer that already holds
|
||||
* the lock on a row has that row skipped for us, so two claimers can never
|
||||
* both win the same card — and, crucially, each claimer locks exactly ONE
|
||||
* row, so concurrent claimers fan out across distinct ready cards instead of
|
||||
* one claimer locking the whole ready set and starving the rest.
|
||||
*
|
||||
* Candidate selection (when no explicit `id`):
|
||||
* - status = 'ready'
|
||||
* - all deps satisfied (every id in depends_on is currently 'done')
|
||||
* - ordered by priority DESC, created_at ASC
|
||||
*
|
||||
* Returns the claimed card, or null if nothing is claimable.
|
||||
*/
|
||||
async claim(opts: ClaimOptions): Promise<BacklogCard | null> {
|
||||
const ttl = opts.ttlSeconds ?? DEFAULT_CLAIM_TTL_SECONDS;
|
||||
|
||||
return this.db.transaction(async (tx) => {
|
||||
// Specific-id path: lock that one ready row (if free) and apply the
|
||||
// deps-satisfied gate in JS, exactly as before.
|
||||
if (opts.id) {
|
||||
const doneRows = await tx
|
||||
.select({ id: backlog.id })
|
||||
.from(backlog)
|
||||
.where(eq(backlog.status, 'done'));
|
||||
const doneIds = new Set(doneRows.map((r) => r.id));
|
||||
|
||||
const result = await tx.execute(
|
||||
sql`SELECT * FROM ${backlog}
|
||||
WHERE ${backlog.id} = ${opts.id} AND ${backlog.status} = 'ready'
|
||||
FOR UPDATE SKIP LOCKED`,
|
||||
);
|
||||
const candidate = rowsOf(result).find((row) =>
|
||||
normalizeDeps(row.depends_on).every((dep) => doneIds.has(dep)),
|
||||
);
|
||||
if (!candidate) return null;
|
||||
|
||||
const updated = await tx
|
||||
.update(backlog)
|
||||
.set({
|
||||
status: 'claimed',
|
||||
claimOwner: opts.owner,
|
||||
claimTtlSeconds: ttl,
|
||||
claimedAt: new Date(),
|
||||
attempts: sql`${backlog.attempts} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(backlog.id, candidate.id))
|
||||
.returning();
|
||||
|
||||
return toCard(updated[0]!);
|
||||
}
|
||||
|
||||
// No-id path: claim the single highest-priority, deps-satisfied ready
|
||||
// card. We lock exactly ONE row in the inner SELECT (`FOR UPDATE SKIP
|
||||
// LOCKED LIMIT 1`) so concurrent claimers grab distinct cards rather than
|
||||
// one claimer locking every ready row and forcing the others to null.
|
||||
//
|
||||
// The deps-satisfied gate is pushed into SQL so `LIMIT 1` lands on the
|
||||
// next genuinely-eligible card: a card is eligible iff none of its
|
||||
// depends_on ids is absent from the set of 'done' card ids.
|
||||
const updated = await tx.execute(
|
||||
sql`UPDATE ${backlog}
|
||||
SET status = 'claimed',
|
||||
claim_owner = ${opts.owner},
|
||||
claim_ttl_seconds = ${ttl},
|
||||
claimed_at = now(),
|
||||
attempts = ${backlog.attempts} + 1,
|
||||
updated_at = now()
|
||||
WHERE ${backlog.id} = (
|
||||
SELECT b.id FROM ${backlog} AS b
|
||||
WHERE b.status = 'ready'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(b.depends_on) AS dep
|
||||
WHERE dep NOT IN (
|
||||
SELECT d.id FROM ${backlog} AS d WHERE d.status = 'done'
|
||||
)
|
||||
)
|
||||
ORDER BY b.priority DESC, b.created_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING *`,
|
||||
);
|
||||
|
||||
const row = rowsOf(updated)[0];
|
||||
return row ? toCard(rawToRow(row)) : null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Release expired claims (claimed_at + ttl < now) back to `ready`, OR release
|
||||
* a specific card by id regardless of expiry. Cleared claim fields.
|
||||
* Returns the ids that were released.
|
||||
*/
|
||||
async reclaim(opts: { id?: string } = {}): Promise<ReclaimResult> {
|
||||
if (opts.id) {
|
||||
const released = await this.db
|
||||
.update(backlog)
|
||||
.set({
|
||||
status: 'ready',
|
||||
claimOwner: null,
|
||||
claimTtlSeconds: null,
|
||||
claimedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(backlog.id, opts.id), eq(backlog.status, 'claimed')))
|
||||
.returning({ id: backlog.id });
|
||||
return { reclaimed: released.map((r) => r.id) };
|
||||
}
|
||||
|
||||
// Expired = status claimed AND claimed_at + (ttl seconds) < now().
|
||||
const released = await this.db
|
||||
.update(backlog)
|
||||
.set({
|
||||
status: 'ready',
|
||||
claimOwner: null,
|
||||
claimTtlSeconds: null,
|
||||
claimedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(backlog.status, 'claimed'),
|
||||
sql`${backlog.claimedAt} + make_interval(secs => ${backlog.claimTtlSeconds}) < now()`,
|
||||
),
|
||||
)
|
||||
.returning({ id: backlog.id });
|
||||
return { reclaimed: released.map((r) => r.id) };
|
||||
}
|
||||
|
||||
/** Add a `depends_on` edge (from → depends on → to). Idempotent. */
|
||||
async link(from: string, to: string): Promise<BacklogCard> {
|
||||
const card = await this.get(from);
|
||||
if (!card) throw new Error(`backlog card not found: ${from}`);
|
||||
const target = await this.get(to);
|
||||
if (!target) throw new Error(`backlog dependency not found: ${to}`);
|
||||
if (from === to) throw new Error('a card cannot depend on itself');
|
||||
|
||||
if (card.dependsOn.includes(to)) return card;
|
||||
const nextDeps = [...card.dependsOn, to];
|
||||
const updated = await this.db
|
||||
.update(backlog)
|
||||
.set({ dependsOn: nextDeps, updatedAt: new Date() })
|
||||
.where(eq(backlog.id, from))
|
||||
.returning();
|
||||
return toCard(updated[0]!);
|
||||
}
|
||||
|
||||
/** Mark a card blocked. */
|
||||
async block(id: string): Promise<BacklogCard | null> {
|
||||
return this.setStatus(id, 'blocked');
|
||||
}
|
||||
|
||||
/** Mark a card done (releasing any claim). */
|
||||
async complete(id: string): Promise<BacklogCard | null> {
|
||||
const updated = await this.db
|
||||
.update(backlog)
|
||||
.set({
|
||||
status: 'done',
|
||||
claimOwner: null,
|
||||
claimTtlSeconds: null,
|
||||
claimedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(backlog.id, id))
|
||||
.returning();
|
||||
return updated[0] ? toCard(updated[0]) : null;
|
||||
}
|
||||
|
||||
private async setStatus(id: string, status: BacklogStatus): Promise<BacklogCard | null> {
|
||||
const updated = await this.db
|
||||
.update(backlog)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(eq(backlog.id, id))
|
||||
.returning();
|
||||
return updated[0] ? toCard(updated[0]) : null;
|
||||
}
|
||||
|
||||
/** Counts by status, oldest-ready age (seconds), and expired-claim count. */
|
||||
async stats(): Promise<BacklogStats> {
|
||||
const all = await this.db.select().from(backlog);
|
||||
const counts: Record<BacklogStatus, number> = {
|
||||
ready: 0,
|
||||
claimed: 0,
|
||||
blocked: 0,
|
||||
done: 0,
|
||||
};
|
||||
let oldestReady: Date | null = null;
|
||||
let expiredClaimCount = 0;
|
||||
const now = Date.now();
|
||||
|
||||
for (const row of all) {
|
||||
counts[row.status] += 1;
|
||||
if (row.status === 'ready') {
|
||||
if (oldestReady === null || row.createdAt < oldestReady) oldestReady = row.createdAt;
|
||||
}
|
||||
if (row.status === 'claimed' && row.claimedAt && row.claimTtlSeconds != null) {
|
||||
const expiry = row.claimedAt.getTime() + row.claimTtlSeconds * 1000;
|
||||
if (expiry < now) expiredClaimCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counts,
|
||||
total: all.length,
|
||||
oldestReadyAgeSeconds:
|
||||
oldestReady === null ? null : Math.max(0, Math.floor((now - oldestReady.getTime()) / 1000)),
|
||||
expiredClaimCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract rows from a drizzle `.execute()` result across drivers (pg / pglite). */
|
||||
function rowsOf(result: unknown): RawRow[] {
|
||||
if (Array.isArray(result)) return result as RawRow[];
|
||||
const maybe = result as { rows?: unknown };
|
||||
if (maybe && Array.isArray(maybe.rows)) return maybe.rows as RawRow[];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw `RETURNING *` row (snake_case columns, possibly string-encoded
|
||||
* timestamps/JSON depending on the driver) onto the drizzle `Row` shape that
|
||||
* `toCard` consumes. Mirrors the column ↔ property mapping in `schema.ts`.
|
||||
*/
|
||||
function rawToRow(raw: RawRow): Row {
|
||||
const r = raw as unknown as Record<string, unknown>;
|
||||
const toDate = (v: unknown): Date => (v instanceof Date ? v : new Date(v as string));
|
||||
return {
|
||||
id: r.id as string,
|
||||
title: r.title as string,
|
||||
body: (r.body ?? null) as string | null,
|
||||
phase: (r.phase ?? null) as string | null,
|
||||
priority: Number(r.priority),
|
||||
status: r.status as BacklogStatus,
|
||||
dependsOn: normalizeDeps(r.depends_on),
|
||||
claimOwner: (r.claim_owner ?? null) as string | null,
|
||||
claimTtlSeconds: r.claim_ttl_seconds == null ? null : Number(r.claim_ttl_seconds),
|
||||
claimedAt: r.claimed_at == null ? null : toDate(r.claimed_at),
|
||||
attempts: Number(r.attempts),
|
||||
idempotencyKey: (r.idempotency_key ?? null) as string | null,
|
||||
acceptance: r.acceptance ?? null,
|
||||
createdAt: toDate(r.created_at),
|
||||
updatedAt: toDate(r.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
/** A raw SQL row returns snake_case `depends_on`; normalize to string[]. */
|
||||
function normalizeDeps(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value as string[];
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PGlite } from '@electric-sql/pglite';
|
||||
import { vector } from '@electric-sql/pglite/vector';
|
||||
import { drizzle } from 'drizzle-orm/pglite';
|
||||
import * as schema from './schema.js';
|
||||
import type { DbHandle } from './client.js';
|
||||
|
||||
export function createPgliteDb(dataDir: string): DbHandle {
|
||||
// pgvector extension is required by migration 0001 (insights.embedding column).
|
||||
const client = new PGlite(dataDir, { extensions: { vector } });
|
||||
const db = drizzle(client, { schema });
|
||||
return {
|
||||
db: db as unknown as DbHandle['db'],
|
||||
close: async () => {
|
||||
await client.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema.js';
|
||||
import { DEFAULT_DATABASE_URL } from './defaults.js';
|
||||
|
||||
export type Db = PostgresJsDatabase<typeof schema>;
|
||||
|
||||
export interface DbHandle {
|
||||
db: Db;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createDb(url?: string): DbHandle {
|
||||
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
||||
const sql = postgres(connectionString, {
|
||||
// Pool sizing: allow up to 20 concurrent connections per gateway instance.
|
||||
// Each NestJS module (brain, preferences, memory, coord) shares this pool.
|
||||
max: Number(process.env['DB_POOL_MAX'] ?? 20),
|
||||
// Recycle idle connections after 30 s to avoid stale TCP state.
|
||||
idle_timeout: Number(process.env['DB_IDLE_TIMEOUT'] ?? 30),
|
||||
// Fail fast (5 s) on connection problems rather than hanging indefinitely.
|
||||
connect_timeout: Number(process.env['DB_CONNECT_TIMEOUT'] ?? 5),
|
||||
});
|
||||
const db = drizzle(sql, { schema });
|
||||
return { db, close: () => sql.end() };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_DATABASE_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* FED-M2-01 — Integration test: federation DB schema (peers / grants / audit_log).
|
||||
*
|
||||
* Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||
* (or any postgres with the mosaic schema already applied)
|
||||
* Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/db test src/federation.integration.test.ts
|
||||
*
|
||||
* Skipped when FEDERATED_INTEGRATION !== '1'.
|
||||
*
|
||||
* Strategy:
|
||||
* - Applies the federation migration SQL directly (idempotent: CREATE TYPE/TABLE
|
||||
* with IF NOT EXISTS guards applied via inline SQL before the migration DDL).
|
||||
* - Assumes the base schema (users table etc.) already exists in the target DB.
|
||||
* - All test rows use the `fed-m2-01-` prefix; cleanup in afterAll.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Federation tables + enums apply cleanly against the existing schema.
|
||||
* 2. Insert a sample user + peer + grant + audit row; verify round-trip.
|
||||
* 3. FK cascade: deleting the user cascades to federation_grants.
|
||||
* 4. FK set-null: deleting the peer sets federation_audit_log.peer_id to NULL.
|
||||
* 5. Enum constraint: inserting an invalid status/state value throws a DB error.
|
||||
* 6. Unique constraint: duplicate cert_serial throws a DB error.
|
||||
*/
|
||||
|
||||
import postgres from 'postgres';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const run = process.env['FEDERATED_INTEGRATION'] === '1';
|
||||
|
||||
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
|
||||
|
||||
/** Recognisable test-row prefix for safe cleanup without full-table truncation. */
|
||||
const T = 'fed-m2-01';
|
||||
|
||||
// Deterministic IDs (UUID format required for uuid PK columns: 8-4-4-4-12 hex digits).
|
||||
const PEER1_ID = `f2000001-0000-4000-8000-000000000001`;
|
||||
const PEER2_ID = `f2000002-0000-4000-8000-000000000002`;
|
||||
const USER1_ID = `${T}-user-1`;
|
||||
|
||||
let sql: ReturnType<typeof postgres> | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!run) return;
|
||||
sql = postgres(PG_URL, { max: 1, connect_timeout: 10, idle_timeout: 10 });
|
||||
|
||||
// Apply the federation enums and tables idempotently.
|
||||
// This mirrors the migration file but uses IF NOT EXISTS guards so it can run
|
||||
// against a DB that may not have had drizzle migrations tracked.
|
||||
await sql`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE peer_state AS ENUM ('pending', 'active', 'suspended', 'revoked');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$
|
||||
`;
|
||||
await sql`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE grant_status AS ENUM ('active', 'revoked', 'expired');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS federation_peers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
common_name text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
cert_pem text NOT NULL,
|
||||
cert_serial text NOT NULL,
|
||||
cert_not_after timestamp with time zone NOT NULL,
|
||||
client_key_pem text,
|
||||
state peer_state NOT NULL DEFAULT 'pending',
|
||||
endpoint_url text,
|
||||
last_seen_at timestamp with time zone,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
revoked_at timestamp with time zone,
|
||||
CONSTRAINT federation_peers_common_name_unique UNIQUE (common_name),
|
||||
CONSTRAINT federation_peers_cert_serial_unique UNIQUE (cert_serial)
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS federation_peers_cert_serial_idx ON federation_peers (cert_serial)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS federation_peers_state_idx ON federation_peers (state)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS federation_grants (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subject_user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
peer_id uuid NOT NULL REFERENCES federation_peers(id) ON DELETE CASCADE,
|
||||
scope jsonb NOT NULL,
|
||||
status grant_status NOT NULL DEFAULT 'active',
|
||||
expires_at timestamp with time zone,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
revoked_at timestamp with time zone,
|
||||
revoked_reason text
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS federation_grants_subject_status_idx ON federation_grants (subject_user_id, status)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS federation_grants_peer_status_idx ON federation_grants (peer_id, status)
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS federation_audit_log (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
request_id text NOT NULL,
|
||||
peer_id uuid REFERENCES federation_peers(id) ON DELETE SET NULL,
|
||||
subject_user_id text REFERENCES users(id) ON DELETE SET NULL,
|
||||
grant_id uuid REFERENCES federation_grants(id) ON DELETE SET NULL,
|
||||
verb text NOT NULL,
|
||||
resource text NOT NULL,
|
||||
status_code integer NOT NULL,
|
||||
result_count integer,
|
||||
denied_reason text,
|
||||
latency_ms integer,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
query_hash text,
|
||||
outcome text,
|
||||
bytes_out integer
|
||||
)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS federation_audit_log_peer_created_at_idx
|
||||
ON federation_audit_log (peer_id, created_at DESC NULLS LAST)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS federation_audit_log_subject_created_at_idx
|
||||
ON federation_audit_log (subject_user_id, created_at DESC NULLS LAST)
|
||||
`;
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS federation_audit_log_created_at_idx
|
||||
ON federation_audit_log (created_at DESC NULLS LAST)
|
||||
`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!sql) return;
|
||||
|
||||
// Cleanup in FK-safe order (children before parents).
|
||||
await sql`DELETE FROM federation_audit_log WHERE request_id LIKE ${T + '%'}`.catch(() => {});
|
||||
await sql`
|
||||
DELETE FROM federation_grants
|
||||
WHERE subject_user_id LIKE ${T + '%'}
|
||||
OR revoked_reason LIKE ${T + '%'}
|
||||
`.catch(() => {});
|
||||
await sql`DELETE FROM federation_peers WHERE common_name LIKE ${T + '%'}`.catch(() => {});
|
||||
await sql`DELETE FROM users WHERE id LIKE ${T + '%'}`.catch(() => {});
|
||||
await sql.end({ timeout: 3 }).catch(() => {});
|
||||
});
|
||||
|
||||
describe.skipIf(!run)('federation schema — integration', () => {
|
||||
// ── 1. Insert sample rows ──────────────────────────────────────────────────
|
||||
|
||||
it('inserts a user, peer, grant, and audit row without constraint violation', async () => {
|
||||
const certPem = '-----BEGIN CERTIFICATE-----\nMIItest\n-----END CERTIFICATE-----';
|
||||
|
||||
// User — BetterAuth users.id is text (any string, not uuid).
|
||||
await sql!`
|
||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES (${USER1_ID}, ${'M2-01 Test User'}, ${USER1_ID + '@example.com'}, false, now(), now())
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`;
|
||||
|
||||
// Peer
|
||||
await sql!`
|
||||
INSERT INTO federation_peers
|
||||
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||
VALUES (
|
||||
${PEER1_ID},
|
||||
${T + '-gateway-example-com'},
|
||||
${'Test Peer'},
|
||||
${certPem},
|
||||
${T + '-serial-001'},
|
||||
now() + interval '1 year',
|
||||
${'active'},
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`;
|
||||
|
||||
// Grant — scope is jsonb; pass as JSON string and cast server-side.
|
||||
const scopeJson = JSON.stringify({
|
||||
resources: ['tasks', 'notes'],
|
||||
operations: ['list', 'get'],
|
||||
});
|
||||
const grants = await sql!`
|
||||
INSERT INTO federation_grants
|
||||
(subject_user_id, peer_id, scope, status, created_at)
|
||||
VALUES (
|
||||
${USER1_ID},
|
||||
${PEER1_ID},
|
||||
${scopeJson}::jsonb,
|
||||
${'active'},
|
||||
now()
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
expect(grants).toHaveLength(1);
|
||||
const grantId = grants[0]!['id'] as string;
|
||||
|
||||
// Audit log row
|
||||
await sql!`
|
||||
INSERT INTO federation_audit_log
|
||||
(request_id, peer_id, subject_user_id, grant_id, verb, resource, status_code, created_at)
|
||||
VALUES (
|
||||
${T + '-req-001'},
|
||||
${PEER1_ID},
|
||||
${USER1_ID},
|
||||
${grantId},
|
||||
${'list'},
|
||||
${'tasks'},
|
||||
${200},
|
||||
now()
|
||||
)
|
||||
`;
|
||||
|
||||
// Verify the audit row is present and has correct data.
|
||||
const auditRows = await sql!`
|
||||
SELECT * FROM federation_audit_log WHERE request_id = ${T + '-req-001'}
|
||||
`;
|
||||
expect(auditRows).toHaveLength(1);
|
||||
expect(auditRows[0]!['status_code']).toBe(200);
|
||||
expect(auditRows[0]!['verb']).toBe('list');
|
||||
expect(auditRows[0]!['resource']).toBe('tasks');
|
||||
}, 30_000);
|
||||
|
||||
// ── 2. FK cascade: user delete cascades grants ─────────────────────────────
|
||||
|
||||
it('cascade-deletes federation_grants when the subject user is deleted', async () => {
|
||||
const cascadeUserId = `${T}-cascade-user`;
|
||||
await sql!`
|
||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES (${cascadeUserId}, ${'Cascade User'}, ${cascadeUserId + '@example.com'}, false, now(), now())
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`;
|
||||
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
||||
await sql!`
|
||||
INSERT INTO federation_grants
|
||||
(subject_user_id, peer_id, scope, status, revoked_reason, created_at)
|
||||
VALUES (
|
||||
${cascadeUserId},
|
||||
${PEER1_ID},
|
||||
${scopeJson}::jsonb,
|
||||
${'active'},
|
||||
${T + '-cascade-test'},
|
||||
now()
|
||||
)
|
||||
`;
|
||||
|
||||
const before = await sql!`
|
||||
SELECT count(*)::int AS cnt FROM federation_grants WHERE subject_user_id = ${cascadeUserId}
|
||||
`;
|
||||
expect(before[0]!['cnt']).toBe(1);
|
||||
|
||||
// Delete user → grants should cascade-delete.
|
||||
await sql!`DELETE FROM users WHERE id = ${cascadeUserId}`;
|
||||
|
||||
const after = await sql!`
|
||||
SELECT count(*)::int AS cnt FROM federation_grants WHERE subject_user_id = ${cascadeUserId}
|
||||
`;
|
||||
expect(after[0]!['cnt']).toBe(0);
|
||||
}, 15_000);
|
||||
|
||||
// ── 3. FK set-null: peer delete sets audit_log.peer_id to NULL ────────────
|
||||
|
||||
it('sets federation_audit_log.peer_id to NULL when the peer is deleted', async () => {
|
||||
// Insert a throwaway peer for this specific cascade test.
|
||||
await sql!`
|
||||
INSERT INTO federation_peers
|
||||
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||
VALUES (
|
||||
${PEER2_ID},
|
||||
${T + '-gateway-throwaway-com'},
|
||||
${'Throwaway Peer'},
|
||||
${'cert-pem-placeholder'},
|
||||
${T + '-serial-002'},
|
||||
now() + interval '1 year',
|
||||
${'active'},
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`;
|
||||
const reqId = `${T}-req-setnull`;
|
||||
await sql!`
|
||||
INSERT INTO federation_audit_log
|
||||
(request_id, peer_id, subject_user_id, verb, resource, status_code, created_at)
|
||||
VALUES (
|
||||
${reqId},
|
||||
${PEER2_ID},
|
||||
${USER1_ID},
|
||||
${'get'},
|
||||
${'tasks'},
|
||||
${200},
|
||||
now()
|
||||
)
|
||||
`;
|
||||
|
||||
await sql!`DELETE FROM federation_peers WHERE id = ${PEER2_ID}`;
|
||||
|
||||
const rows = await sql!`
|
||||
SELECT peer_id FROM federation_audit_log WHERE request_id = ${reqId}
|
||||
`;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!['peer_id']).toBeNull();
|
||||
}, 15_000);
|
||||
|
||||
// ── 4. Enum constraint: invalid grant_status rejected ─────────────────────
|
||||
|
||||
it('rejects an invalid grant_status value with a DB error', async () => {
|
||||
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
||||
await expect(
|
||||
sql!`
|
||||
INSERT INTO federation_grants
|
||||
(subject_user_id, peer_id, scope, status, created_at)
|
||||
VALUES (
|
||||
${USER1_ID},
|
||||
${PEER1_ID},
|
||||
${scopeJson}::jsonb,
|
||||
${'invalid_status'},
|
||||
now()
|
||||
)
|
||||
`,
|
||||
).rejects.toThrow();
|
||||
}, 10_000);
|
||||
|
||||
// ── 5. Enum constraint: invalid peer_state rejected ───────────────────────
|
||||
|
||||
it('rejects an invalid peer_state value with a DB error', async () => {
|
||||
await expect(
|
||||
sql!`
|
||||
INSERT INTO federation_peers
|
||||
(common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||
VALUES (
|
||||
${'bad-state-peer'},
|
||||
${'Bad State'},
|
||||
${'pem'},
|
||||
${'bad-serial-999'},
|
||||
now() + interval '1 year',
|
||||
${'invalid_state'},
|
||||
now()
|
||||
)
|
||||
`,
|
||||
).rejects.toThrow();
|
||||
}, 10_000);
|
||||
|
||||
// ── 6. Unique constraint: duplicate cert_serial rejected ──────────────────
|
||||
|
||||
it('rejects a duplicate cert_serial with a unique constraint violation', async () => {
|
||||
await expect(
|
||||
sql!`
|
||||
INSERT INTO federation_peers
|
||||
(common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||
VALUES (
|
||||
${T + '-dup-cn'},
|
||||
${'Dup Peer'},
|
||||
${'pem'},
|
||||
${T + '-serial-001'},
|
||||
now() + interval '1 year',
|
||||
${'pending'},
|
||||
now()
|
||||
)
|
||||
`,
|
||||
).rejects.toThrow();
|
||||
}, 10_000);
|
||||
|
||||
// ── 7. FK cascade: peer delete cascades to federation_grants ─────────────
|
||||
|
||||
it('cascade-deletes federation_grants when the owning peer is deleted', async () => {
|
||||
const PEER3_ID = `f2000003-0000-4000-8000-000000000003`;
|
||||
const cascadeGrantUserId = `${T}-cascade-grant-user`;
|
||||
|
||||
// Insert a dedicated user and peer for this test.
|
||||
await sql!`
|
||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES (${cascadeGrantUserId}, ${'Cascade Grant User'}, ${cascadeGrantUserId + '@example.com'}, false, now(), now())
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`;
|
||||
await sql!`
|
||||
INSERT INTO federation_peers
|
||||
(id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at)
|
||||
VALUES (
|
||||
${PEER3_ID},
|
||||
${T + '-gateway-cascade-peer'},
|
||||
${'Cascade Peer'},
|
||||
${'cert-pem-cascade'},
|
||||
${T + '-serial-003'},
|
||||
now() + interval '1 year',
|
||||
${'active'},
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`;
|
||||
|
||||
const scopeJson = JSON.stringify({ resources: ['tasks'] });
|
||||
await sql!`
|
||||
INSERT INTO federation_grants
|
||||
(subject_user_id, peer_id, scope, status, created_at)
|
||||
VALUES (
|
||||
${cascadeGrantUserId},
|
||||
${PEER3_ID},
|
||||
${scopeJson}::jsonb,
|
||||
${'active'},
|
||||
now()
|
||||
)
|
||||
`;
|
||||
|
||||
const before = await sql!`
|
||||
SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID}
|
||||
`;
|
||||
expect(before[0]!['cnt']).toBe(1);
|
||||
|
||||
// Delete peer → grants should cascade-delete.
|
||||
await sql!`DELETE FROM federation_peers WHERE id = ${PEER3_ID}`;
|
||||
|
||||
const after = await sql!`
|
||||
SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID}
|
||||
`;
|
||||
expect(after[0]!['cnt']).toBe(0);
|
||||
|
||||
// Cleanup
|
||||
await sql!`DELETE FROM users WHERE id = ${cascadeGrantUserId}`.catch(() => {});
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Federation schema re-exports.
|
||||
*
|
||||
* The actual table and enum definitions live in schema.ts (alongside all other
|
||||
* Drizzle tables) to avoid CJS/ESM cross-import issues when drizzle-kit loads
|
||||
* schema files via esbuild-register. Application code that wants named imports
|
||||
* for federation symbols should import from this file.
|
||||
*
|
||||
* M2-01: DB tables and enums only. No business logic.
|
||||
* M2-03 will add JSON schema validation for the `scope` column.
|
||||
* M4 will write rows to federation_audit_log.
|
||||
*/
|
||||
|
||||
export {
|
||||
peerStateEnum,
|
||||
grantStatusEnum,
|
||||
federationPeers,
|
||||
federationGrants,
|
||||
federationAuditLog,
|
||||
federationEnrollmentTokens,
|
||||
} from './schema.js';
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Hierarchy audit event + outbox schema witnesses — contract 1 §5.2 and the
|
||||
* schema-level half of §6.4.
|
||||
*
|
||||
* Witnesses the guarantees the tables themselves carry: verb/target-kind/
|
||||
* transfer CHECK constraints, idempotency uniqueness (REQ-AUD-001 duplicate
|
||||
* suppression at the database level), monotonic append order (`seq`),
|
||||
* deletion-safe linkage (no foreign key from the events table into any class
|
||||
* table — events survive the deletion of their target), the causation
|
||||
* self-FK, and the outbox's FK/uniqueness/status shape. The repository-level
|
||||
* half of §6.4 (same-transaction atomicity, rollback, replay) is witnessed in
|
||||
* apps/gateway/src/hierarchy/hierarchy-audit.integration.test.ts.
|
||||
*
|
||||
* Two legs run the same witness body:
|
||||
* - PGlite (WASM Postgres): always runs.
|
||||
* - Real PostgreSQL (§6.8): runs when DATABASE_URL is set — the binding
|
||||
* witness; CI migrates ci-postgres before `pnpm test`.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createDb } from './client.js';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import { companies, hierarchyAuditEvents, hierarchyOutbox } from './schema.js';
|
||||
|
||||
type AnyDb = {
|
||||
db: {
|
||||
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
|
||||
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
|
||||
};
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Match a constraint failure anywhere along drizzle's cause chain. */
|
||||
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
|
||||
let err: unknown;
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err, label || 'expected the statement to be refused').toBeDefined();
|
||||
const messages: string[] = [];
|
||||
let cur: unknown = err;
|
||||
while (cur instanceof Error) {
|
||||
messages.push(cur.message);
|
||||
cur = (cur as { cause?: unknown }).cause;
|
||||
}
|
||||
expect(messages.join(' | '), label).toMatch(re);
|
||||
}
|
||||
|
||||
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
|
||||
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
|
||||
const T = `hier-a-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
/** Minimal valid event row; overrides compose the negative cases. */
|
||||
type EventInsert = typeof hierarchyAuditEvents.$inferInsert;
|
||||
|
||||
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
|
||||
return {
|
||||
actorId: `${T}-actor`,
|
||||
verb: 'create',
|
||||
targetKind: 'company',
|
||||
targetId: randomUUID(),
|
||||
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
|
||||
correlationId: `${T}-corr`,
|
||||
idempotencyKey: `${T}-${randomUUID()}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function witnessSuite(getHandle: () => AnyDb): void {
|
||||
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
|
||||
|
||||
afterAll(async () => {
|
||||
const d = db();
|
||||
await d.execute(sql`DELETE FROM hierarchy_outbox WHERE idempotency_key LIKE ${T + '%'}`);
|
||||
// Caused events first: the causation self-FK is RESTRICT.
|
||||
await d.execute(
|
||||
sql`DELETE FROM hierarchy_audit_events WHERE idempotency_key LIKE ${T + '%'} AND causation_id IS NOT NULL`,
|
||||
);
|
||||
await d.execute(sql`DELETE FROM hierarchy_audit_events WHERE idempotency_key LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM companies WHERE slug LIKE ${T + '%'}`);
|
||||
});
|
||||
|
||||
// ── CHECK constraints ──────────────────────────────────────────────────────
|
||||
|
||||
it('accepts every declared verb and refuses an undeclared one', async () => {
|
||||
for (const verb of [
|
||||
'create',
|
||||
'rename',
|
||||
'delete',
|
||||
'grant_create',
|
||||
'grant_change',
|
||||
'grant_revoke',
|
||||
]) {
|
||||
await db().insert(hierarchyAuditEvents).values(eventRow({ verb }));
|
||||
}
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ verb: 'update' })),
|
||||
/verb_check|violates check/i,
|
||||
'undeclared verb must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an undeclared target kind', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ targetKind: 'workspace' })),
|
||||
/target_kind_check|violates check/i,
|
||||
'workspace is not an audited target kind (workspace mutation is SOT-side)',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires transfer snapshots exactly on transfers', async () => {
|
||||
const parent = { kind: 'company', id: randomUUID(), slug: 'p' };
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(
|
||||
eventRow({
|
||||
verb: 'transfer',
|
||||
targetKind: 'estate',
|
||||
transferFrom: parent,
|
||||
transferTo: { ...parent, id: randomUUID() },
|
||||
}),
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ verb: 'transfer' })),
|
||||
/transfer_check|violates check/i,
|
||||
'transfer without source/destination snapshots must be refused',
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ verb: 'transfer', transferFrom: parent })),
|
||||
/transfer_check|violates check/i,
|
||||
'transfer with only the source snapshot must be refused',
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ verb: 'create', transferFrom: parent, transferTo: parent })),
|
||||
/transfer_check|violates check/i,
|
||||
'non-transfer with transfer snapshots must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Idempotency and ordering ───────────────────────────────────────────────
|
||||
|
||||
it('refuses a duplicate idempotency key', async () => {
|
||||
const key = `${T}-dup-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ idempotencyKey: key }));
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ idempotencyKey: key })),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('assigns strictly increasing seq in insert order for one target', async () => {
|
||||
const targetId = randomUUID();
|
||||
const k1 = `${T}-seq-1-${randomUUID()}`;
|
||||
const k2 = `${T}-seq-2-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ targetId, idempotencyKey: k1 }));
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ targetId, verb: 'rename', idempotencyKey: k2 }));
|
||||
const res = rows(
|
||||
await db().execute(
|
||||
sql`SELECT idempotency_key, seq FROM hierarchy_audit_events WHERE target_id = ${targetId} ORDER BY seq ASC`,
|
||||
),
|
||||
);
|
||||
expect(res.map((r) => r['idempotency_key'])).toEqual([k1, k2]);
|
||||
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
|
||||
});
|
||||
|
||||
// ── Deletion-safe linkage (§5.2) ───────────────────────────────────────────
|
||||
|
||||
it('has no foreign key into any class table, and events survive target deletion', async () => {
|
||||
const fks = rows(
|
||||
await db().execute(sql`
|
||||
SELECT ccu.table_name AS referenced_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'hierarchy_audit_events'
|
||||
`),
|
||||
);
|
||||
// The causation self-FK is the ONLY foreign key on the events table.
|
||||
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['hierarchy_audit_events']);
|
||||
|
||||
const companyId = randomUUID();
|
||||
await db()
|
||||
.insert(companies)
|
||||
.values({ id: companyId, name: 'Doomed', slug: `${T}-doomed` });
|
||||
const key = `${T}-survive-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(
|
||||
eventRow({
|
||||
verb: 'delete',
|
||||
targetId: companyId,
|
||||
targetSnapshot: { id: companyId, slug: `${T}-doomed`, name: 'Doomed', parentChain: [] },
|
||||
idempotencyKey: key,
|
||||
}),
|
||||
);
|
||||
await db().execute(sql`DELETE FROM companies WHERE id = ${companyId}`);
|
||||
const after = rows(
|
||||
await db().execute(
|
||||
sql`SELECT target_snapshot FROM hierarchy_audit_events WHERE idempotency_key = ${key}`,
|
||||
),
|
||||
);
|
||||
expect(after).toHaveLength(1);
|
||||
expect((after[0]!['target_snapshot'] as { id: string }).id).toBe(companyId);
|
||||
});
|
||||
|
||||
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ causationId: randomUUID() })),
|
||||
/foreign key/i,
|
||||
'causation must reference an existing event',
|
||||
);
|
||||
const causeKey = `${T}-cause-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ verb: 'delete', idempotencyKey: causeKey }));
|
||||
const cause = rows(
|
||||
await db().execute(
|
||||
sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${causeKey}`,
|
||||
),
|
||||
)[0]!;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(
|
||||
eventRow({ verb: 'grant_revoke', targetKind: 'grant', causationId: cause['id'] as string }),
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM hierarchy_audit_events WHERE id = ${cause['id'] as string}`),
|
||||
/foreign key/i,
|
||||
'a cause with dependent events must not be deletable',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Outbox shape ───────────────────────────────────────────────────────────
|
||||
|
||||
it('outbox rows require an existing event, one outbox row per event, unique idempotency', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyOutbox)
|
||||
.values({
|
||||
eventId: randomUUID(),
|
||||
idempotencyKey: `${T}-ob-${randomUUID()}`,
|
||||
correlationId: `${T}-corr`,
|
||||
}),
|
||||
/foreign key/i,
|
||||
'outbox must reference an existing event',
|
||||
);
|
||||
const key = `${T}-ob-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ idempotencyKey: key }));
|
||||
const event = rows(
|
||||
await db().execute(sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${key}`),
|
||||
)[0]!;
|
||||
const eventId = event['id'] as string;
|
||||
await db()
|
||||
.insert(hierarchyOutbox)
|
||||
.values({ eventId, idempotencyKey: key, correlationId: `${T}-corr` });
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyOutbox)
|
||||
.values({ eventId, idempotencyKey: `${T}-ob2-${randomUUID()}`, correlationId: `${T}-c` }),
|
||||
/duplicate key|unique/i,
|
||||
'one outbox record per event',
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO hierarchy_outbox (event_id, idempotency_key, correlation_id, status)
|
||||
VALUES (${eventId}, ${`${T}-ob3-${randomUUID()}`}, 'c', 'failed')`,
|
||||
),
|
||||
/invalid input value for enum|22P02/i,
|
||||
'status outside pending/processing/delivered must be refused',
|
||||
);
|
||||
});
|
||||
|
||||
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
|
||||
const key = `${T}-obr-${randomUUID()}`;
|
||||
await db()
|
||||
.insert(hierarchyAuditEvents)
|
||||
.values(eventRow({ idempotencyKey: key }));
|
||||
const event = rows(
|
||||
await db().execute(sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${key}`),
|
||||
)[0]!;
|
||||
await db()
|
||||
.insert(hierarchyOutbox)
|
||||
.values({
|
||||
eventId: event['id'] as string,
|
||||
idempotencyKey: key,
|
||||
correlationId: `${T}-corr`,
|
||||
});
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM hierarchy_audit_events WHERE id = ${event['id'] as string}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
|
||||
|
||||
describe('hierarchy audit witnesses — PGlite', () => {
|
||||
let dir: string;
|
||||
let handle: ReturnType<typeof createPgliteDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'hier-audit-witness-'));
|
||||
handle = createPgliteDb(dir);
|
||||
await runPgliteMigrations(handle);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
|
||||
// ── Leg 2: real PostgreSQL (§6.8 — binding witness, ci-postgres in CI) ───────
|
||||
|
||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||
|
||||
describe.skipIf(!hasPostgres)('hierarchy audit witnesses — real PostgreSQL', () => {
|
||||
let handle: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(() => {
|
||||
handle = createDb(process.env['DATABASE_URL']!);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* Hierarchy schema witnesses — contract 1 (docs/requirements/hierarchy-schema.md) §6.
|
||||
*
|
||||
* Witnesses §6.1 (chain construction, slug scoping, grant CHECKs, grant
|
||||
* uniqueness, NOT NULLs), §6.2 (column allowlist), the database-level parts of
|
||||
* §6.6 (RESTRICT/cascade deletion behavior), and §6.7's catalog half (no
|
||||
* foreign keys from outside the class into class tables).
|
||||
*
|
||||
* Two legs run the same witness body:
|
||||
* - PGlite (WASM Postgres): always runs, so the witnesses execute locally
|
||||
* with no database configured.
|
||||
* - Real PostgreSQL (§6.8): runs when DATABASE_URL is set — in CI that is
|
||||
* the ci-postgres service, migrated by the pipeline before `pnpm test`.
|
||||
* This leg is the contract's binding witness; the PGlite leg is the local
|
||||
* development signal.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createDb } from './client.js';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import {
|
||||
companies,
|
||||
estates,
|
||||
hierarchyGrants,
|
||||
platformProjects,
|
||||
workspaces,
|
||||
teams,
|
||||
users,
|
||||
} from './schema.js';
|
||||
|
||||
type AnyDb = {
|
||||
db: {
|
||||
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
|
||||
delete: (t: unknown) => { where?: unknown } & PromiseLike<unknown>;
|
||||
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
|
||||
};
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Column allowlist — the exact declared sets of §2/§3. Nothing else. */
|
||||
const COLUMN_ALLOWLIST: Record<string, string[]> = {
|
||||
companies: ['id', 'name', 'slug', 'visibility', 'created_at', 'updated_at'],
|
||||
estates: ['id', 'name', 'slug', 'company_id'],
|
||||
platform_projects: ['id', 'name', 'slug', 'estate_id'],
|
||||
workspaces: ['id', 'name', 'slug', 'platform_project_id'],
|
||||
hierarchy_grants: [
|
||||
'id',
|
||||
'user_id',
|
||||
'team_id',
|
||||
'company_id',
|
||||
'estate_id',
|
||||
'platform_project_id',
|
||||
'role',
|
||||
'granted_by',
|
||||
'created_at',
|
||||
],
|
||||
};
|
||||
|
||||
const NODE_TABLES = ['companies', 'estates', 'platform_projects', 'workspaces'];
|
||||
const CLASS_TABLES = [...NODE_TABLES, 'hierarchy_grants'];
|
||||
|
||||
/**
|
||||
* Drizzle wraps constraint failures ("Failed query: ...") with the driver
|
||||
* error attached as `cause`. Match the pattern anywhere along the cause chain.
|
||||
*/
|
||||
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
|
||||
let err: unknown;
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err, label || 'expected the statement to be refused').toBeDefined();
|
||||
const messages: string[] = [];
|
||||
let cur: unknown = err;
|
||||
while (cur instanceof Error) {
|
||||
messages.push(cur.message);
|
||||
cur = (cur as { cause?: unknown }).cause;
|
||||
}
|
||||
expect(messages.join(' | '), label).toMatch(re);
|
||||
}
|
||||
|
||||
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
|
||||
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
|
||||
const T = `hier-w-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
function witnessSuite(getHandle: () => AnyDb): void {
|
||||
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
|
||||
|
||||
const userA = `${T}-user-a`;
|
||||
const userB = `${T}-user-b`;
|
||||
let teamId: string;
|
||||
let companyId: string;
|
||||
let company2Id: string;
|
||||
let estateId: string;
|
||||
let estate2Id: string;
|
||||
let ppId: string;
|
||||
let workspaceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await db()
|
||||
.insert(users)
|
||||
.values([
|
||||
{ id: userA, name: 'Witness A', email: `${userA}@example.com` },
|
||||
{ id: userB, name: 'Witness B', email: `${userB}@example.com` },
|
||||
]);
|
||||
teamId = randomUUID();
|
||||
await db()
|
||||
.insert(teams)
|
||||
.values({
|
||||
id: teamId,
|
||||
name: `${T}-team`,
|
||||
slug: `${T}-team`,
|
||||
ownerId: userA,
|
||||
managerId: userA,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Bottom-up, fail-closed order; grants cascade with their targets.
|
||||
const d = db();
|
||||
await d.execute(sql`DELETE FROM hierarchy_grants WHERE granted_by LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM workspaces WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM platform_projects WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM estates WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM companies WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM teams WHERE slug LIKE ${T + '%'}`);
|
||||
await d.execute(sql`DELETE FROM users WHERE id LIKE ${T + '%'}`);
|
||||
});
|
||||
|
||||
// ── §6.1 chain construction ────────────────────────────────────────────────
|
||||
|
||||
it('accepts a full valid chain: company → estate → platform-project → workspace', async () => {
|
||||
companyId = randomUUID();
|
||||
estateId = randomUUID();
|
||||
ppId = randomUUID();
|
||||
workspaceId = randomUUID();
|
||||
await db()
|
||||
.insert(companies)
|
||||
.values({ id: companyId, name: 'Acme', slug: `${T}-acme` });
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: estateId, name: 'Estate 1', slug: `${T}-e1`, companyId });
|
||||
await db()
|
||||
.insert(platformProjects)
|
||||
.values({ id: ppId, name: 'PP 1', slug: `${T}-pp1`, estateId });
|
||||
await db()
|
||||
.insert(workspaces)
|
||||
.values({ id: workspaceId, name: 'WS 1', slug: `${T}-ws1`, platformProjectId: ppId });
|
||||
});
|
||||
|
||||
it('accepts two siblings under one parent (the §2.5 control)', async () => {
|
||||
estate2Id = randomUUID();
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: estate2Id, name: 'Estate 2', slug: `${T}-e2`, companyId });
|
||||
});
|
||||
|
||||
it('refuses inserts with a null parent FK', async () => {
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO estates (id, name, slug, company_id) VALUES (${randomUUID()}, 'x', ${T + '-null-e'}, NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO platform_projects (id, name, slug, estate_id) VALUES (${randomUUID()}, 'x', ${T + '-null-p'}, NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO workspaces (id, name, slug, platform_project_id) VALUES (${randomUUID()}, 'x', ${T + '-null-w'}, NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses inserts with a dangling parent FK', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(estates)
|
||||
.values({ id: randomUUID(), name: 'x', slug: `${T}-dangle`, companyId: randomUUID() }),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('catalog: each child table has exactly one parent-FK column and no parentage edge table exists', async () => {
|
||||
const res = rows(
|
||||
await db().execute(sql`
|
||||
SELECT tc.table_name, kcu.column_name, ccu.table_name AS ref_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON tc.constraint_name = ccu.constraint_name AND tc.table_schema = ccu.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
|
||||
`),
|
||||
);
|
||||
const nodeSet = new Set(NODE_TABLES);
|
||||
// Exactly one parent FK per child node table.
|
||||
for (const [child, parent] of [
|
||||
['estates', 'companies'],
|
||||
['platform_projects', 'estates'],
|
||||
['workspaces', 'platform_projects'],
|
||||
] as const) {
|
||||
const parentFks = res.filter(
|
||||
(r) => r['table_name'] === child && nodeSet.has(String(r['ref_table'])),
|
||||
);
|
||||
expect(parentFks.map((r) => `${r['column_name']}->${r['ref_table']}`)).toEqual([
|
||||
`${{ estates: 'company_id', platform_projects: 'estate_id', workspaces: 'platform_project_id' }[child]}->${parent}`,
|
||||
]);
|
||||
}
|
||||
// No table outside the class references a node table (also §6.7's catalog
|
||||
// half for companies/estates/platform_projects/workspaces), and the only
|
||||
// multi-FK referencer is hierarchy_grants (grant attachment, not
|
||||
// parentage).
|
||||
const referencers = new Map<string, number>();
|
||||
for (const r of res) {
|
||||
if (nodeSet.has(String(r['ref_table']))) {
|
||||
const t = String(r['table_name']);
|
||||
referencers.set(t, (referencers.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const [table, count] of referencers) {
|
||||
expect(CLASS_TABLES, `unexpected referencer of a node table: ${table}`).toContain(table);
|
||||
if (count > 1) expect(table).toBe('hierarchy_grants');
|
||||
}
|
||||
// No FK anywhere references hierarchy_grants.
|
||||
expect(res.filter((r) => r['ref_table'] === 'hierarchy_grants')).toEqual([]);
|
||||
});
|
||||
|
||||
// ── §6.1 slug scoping ──────────────────────────────────────────────────────
|
||||
|
||||
it('refuses a duplicate slug under the same parent, accepts it under another parent', async () => {
|
||||
company2Id = randomUUID();
|
||||
await db()
|
||||
.insert(companies)
|
||||
.values({ id: company2Id, name: 'Beta', slug: `${T}-beta` });
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(estates)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-e1`, companyId }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
// Same slug, different company — accepted.
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: randomUUID(), name: 'ok', slug: `${T}-e1`, companyId: company2Id });
|
||||
// companies.slug is unique per deployment.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(companies)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-acme` }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes platform_projects and workspaces slugs per parent (refuse same-parent duplicate, accept cross-parent)', async () => {
|
||||
// Dedicated parent estate so this test leaves estate2 a leaf (the §3.4
|
||||
// cascade witness depends on that).
|
||||
const estate3Id = randomUUID();
|
||||
await db()
|
||||
.insert(estates)
|
||||
.values({ id: estate3Id, name: 'Estate 3', slug: `${T}-e3`, companyId });
|
||||
// platform_projects: (estate_id, slug) unique.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(platformProjects)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-pp1`, estateId }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
const pp2Id = randomUUID();
|
||||
await db()
|
||||
.insert(platformProjects)
|
||||
.values({ id: pp2Id, name: 'ok', slug: `${T}-pp1`, estateId: estate3Id });
|
||||
// workspaces: (platform_project_id, slug) unique.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(workspaces)
|
||||
.values({ id: randomUUID(), name: 'dup', slug: `${T}-ws1`, platformProjectId: ppId }),
|
||||
/duplicate key|unique/i,
|
||||
);
|
||||
await db()
|
||||
.insert(workspaces)
|
||||
.values({ id: randomUUID(), name: 'ok', slug: `${T}-ws1`, platformProjectId: pp2Id });
|
||||
});
|
||||
|
||||
// ── §6.2 column allowlist ──────────────────────────────────────────────────
|
||||
|
||||
it('column allowlist: each class table has exactly its declared columns (no payload, no owner_id)', async () => {
|
||||
for (const [table, allow] of Object.entries(COLUMN_ALLOWLIST)) {
|
||||
const res = rows(
|
||||
await db().execute(
|
||||
sql`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ${table}`,
|
||||
),
|
||||
);
|
||||
const actual = res.map((r) => String(r['column_name'])).sort();
|
||||
expect(actual, `column set of ${table}`).toEqual([...allow].sort());
|
||||
}
|
||||
});
|
||||
|
||||
// ── §6.1 grant CHECKs ──────────────────────────────────────────────────────
|
||||
|
||||
it('accepts one valid grant per subject×target form', async () => {
|
||||
// All six forms; also the base rows for the §6.1 uniqueness witness below.
|
||||
const forms = [
|
||||
{ userId: userA, companyId },
|
||||
{ userId: userA, estateId },
|
||||
{ userId: userA, platformProjectId: ppId },
|
||||
{ teamId, companyId },
|
||||
{ teamId, estateId },
|
||||
{ teamId, platformProjectId: ppId },
|
||||
];
|
||||
for (const form of forms) {
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ ...form, role: 'owner', grantedBy: userA });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a grant with zero or two subjects (exactly-one-of CHECK)', async () => {
|
||||
await expectViolation(
|
||||
db().insert(hierarchyGrants).values({ companyId, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, teamId, companyId, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a grant with zero or two targets (exactly-one-of CHECK)', async () => {
|
||||
await expectViolation(
|
||||
db().insert(hierarchyGrants).values({ userId: userA, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, companyId, estateId, role: 'viewer', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.1 grant uniqueness (NULLS NOT DISTINCT) ─────────────────────────────
|
||||
|
||||
it('refuses a duplicate (subject, target, role) for each of the six forms', async () => {
|
||||
const forms = [
|
||||
{ userId: userA, companyId },
|
||||
{ userId: userA, estateId },
|
||||
{ userId: userA, platformProjectId: ppId },
|
||||
{ teamId, companyId },
|
||||
{ teamId, estateId },
|
||||
{ teamId, platformProjectId: ppId },
|
||||
];
|
||||
for (const form of forms) {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ ...form, role: 'owner', grantedBy: userB }),
|
||||
/duplicate key|unique/i,
|
||||
`duplicate form ${JSON.stringify(form)} must be refused`,
|
||||
);
|
||||
}
|
||||
// Control: same subject and target with a different role is a new grant.
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userA, companyId, role: 'member', grantedBy: userA });
|
||||
});
|
||||
|
||||
// ── §2.6 role vocabulary CHECK ─────────────────────────────────────────────
|
||||
|
||||
it('refuses a grant role outside the ratified vocabulary, accepts each ratified role', async () => {
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, companyId, role: 'superuser', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
// Serialized namespaced forms are storage-invalid too: rows hold bare roles.
|
||||
await expectViolation(
|
||||
db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, companyId, role: 'hierarchy:owner', grantedBy: userA }),
|
||||
/check constraint/i,
|
||||
);
|
||||
for (const role of ['viewer', 'member', 'owner'] as const) {
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, estateId, role, grantedBy: userA });
|
||||
}
|
||||
await db().execute(
|
||||
sql`DELETE FROM hierarchy_grants WHERE user_id = ${userB} AND estate_id = ${estateId}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §2.8 visibility column ─────────────────────────────────────────────────
|
||||
|
||||
it('defaults companies.visibility to private and refuses values outside the class', async () => {
|
||||
const visId = randomUUID();
|
||||
await db().execute(
|
||||
sql`INSERT INTO companies (id, name, slug) VALUES (${visId}, 'Vis', ${T + '-vis'})`,
|
||||
);
|
||||
const res = rows(await db().execute(sql`SELECT visibility FROM companies WHERE id = ${visId}`));
|
||||
expect(res[0]!['visibility']).toBe('private');
|
||||
await db().execute(
|
||||
sql`INSERT INTO companies (id, name, slug, visibility) VALUES (${randomUUID()}, 'Vis D', ${T + '-vis-d'}, 'directory')`,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO companies (id, name, slug, visibility) VALUES (${randomUUID()}, 'Vis X', ${T + '-vis-x'}, 'public')`,
|
||||
),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`UPDATE companies SET visibility = 'hidden' WHERE id = ${visId}`),
|
||||
/check constraint/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`UPDATE companies SET visibility = NULL WHERE id = ${visId}`),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.1 NOT NULLs ─────────────────────────────────────────────────────────
|
||||
|
||||
it('refuses null role, granted_by, and null name/slug columns', async () => {
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, NULL, ${userA})`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO hierarchy_grants (user_id, company_id, role, granted_by) VALUES (${userA}, ${companyId}, 'viewer', NULL)`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`INSERT INTO companies (name, slug) VALUES (NULL, ${T + '-nn'})`),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`INSERT INTO companies (name, slug) VALUES ('x', NULL)`),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(
|
||||
sql`INSERT INTO estates (name, slug, company_id) VALUES ('x', NULL, ${companyId})`,
|
||||
),
|
||||
/null value|not-null/i,
|
||||
);
|
||||
});
|
||||
|
||||
// ── §6.6 deletion (database-level witnesses) ───────────────────────────────
|
||||
|
||||
it('refuses deleting a node with children (fail-closed bottom-up)', async () => {
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM companies WHERE id = ${companyId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM estates WHERE id = ${estateId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM platform_projects WHERE id = ${ppId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('cascades a deleted leaf node’s grants and nothing else', async () => {
|
||||
// estate2 is a leaf (no platform-projects). Attach one grant to it.
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, estateId: estate2Id, role: 'viewer', grantedBy: userA });
|
||||
const grantCount = async () =>
|
||||
Number(
|
||||
rows(
|
||||
await db().execute(
|
||||
sql`SELECT count(*)::int AS n FROM hierarchy_grants WHERE granted_by LIKE ${T + '%'}`,
|
||||
),
|
||||
)[0]!['n'],
|
||||
);
|
||||
const before = await grantCount();
|
||||
await db().execute(sql`DELETE FROM estates WHERE id = ${estate2Id}`);
|
||||
// Exactly the one grant on the deleted estate is gone.
|
||||
expect(await grantCount()).toBe(before - 1);
|
||||
});
|
||||
|
||||
it('refuses deleting a user or team that is a grant subject or granted_by referent (RESTRICT)', async () => {
|
||||
await expectViolation(db().execute(sql`DELETE FROM users WHERE id = ${userA}`), /foreign key/i);
|
||||
// userB is only a subject (its estate2 grant cascaded away above, but it
|
||||
// still holds no grants — re-create one to witness subject RESTRICT).
|
||||
await db()
|
||||
.insert(hierarchyGrants)
|
||||
.values({ userId: userB, companyId: company2Id, role: 'viewer', grantedBy: userA });
|
||||
await expectViolation(db().execute(sql`DELETE FROM users WHERE id = ${userB}`), /foreign key/i);
|
||||
await expectViolation(
|
||||
db().execute(sql`DELETE FROM teams WHERE id = ${teamId}`),
|
||||
/foreign key/i,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
|
||||
|
||||
describe('hierarchy schema witnesses — PGlite', () => {
|
||||
let dir: string;
|
||||
let handle: ReturnType<typeof createPgliteDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'hier-witness-'));
|
||||
handle = createPgliteDb(dir);
|
||||
await runPgliteMigrations(handle);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
|
||||
// ── Leg 2: real PostgreSQL (§6.8 — binding witness, ci-postgres in CI) ───────
|
||||
|
||||
const hasPostgres = Boolean(process.env['DATABASE_URL']);
|
||||
|
||||
describe.skipIf(!hasPostgres)('hierarchy schema witnesses — real PostgreSQL', () => {
|
||||
let handle: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(() => {
|
||||
handle = createDb(process.env['DATABASE_URL']!);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
witnessSuite(() => handle as unknown as AnyDb);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
export { createDb, type Db, type DbHandle } from './client.js';
|
||||
export { createPgliteDb } from './client-pglite.js';
|
||||
export {
|
||||
runMigrations,
|
||||
runPgliteMigrations,
|
||||
getMigrationStatus,
|
||||
readJournalTags,
|
||||
applyMigrationsByHash,
|
||||
type HashLedgerDeps,
|
||||
type MigrationPlanEntry,
|
||||
type MigrationStatus,
|
||||
} from './migrate.js';
|
||||
export * from './schema.js';
|
||||
export * from './federation.js';
|
||||
export {
|
||||
BacklogService,
|
||||
DEFAULT_CLAIM_TTL_SECONDS,
|
||||
type BacklogCard,
|
||||
type BacklogStatus,
|
||||
type BacklogStats,
|
||||
type ClaimOptions,
|
||||
type CreateCardInput,
|
||||
type ListFilter,
|
||||
type ReclaimResult,
|
||||
} from './backlog.js';
|
||||
export {
|
||||
eq,
|
||||
and,
|
||||
or,
|
||||
desc,
|
||||
asc,
|
||||
sql,
|
||||
inArray,
|
||||
isNull,
|
||||
isNotNull,
|
||||
gt,
|
||||
lt,
|
||||
gte,
|
||||
lte,
|
||||
ilike,
|
||||
count,
|
||||
} from 'drizzle-orm';
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
applyMigrationsByHash,
|
||||
readJournalTags,
|
||||
type HashLedgerDeps,
|
||||
type MigrationPlanEntry,
|
||||
} from './migrate.js';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* In-memory hash-ledger harness */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface LedgerHarness extends HashLedgerDeps {
|
||||
ledger: Map<string, number>;
|
||||
/** Recorded statement executions in order: `${hashPrefix}:${stmtIndex}`. */
|
||||
executed: string[];
|
||||
/** Optional: statements that should throw when executed. */
|
||||
failOn?: (migrationHash: string, stmtIdx: number) => boolean;
|
||||
}
|
||||
|
||||
function makeHarness(plan: MigrationPlanEntry[]): LedgerHarness {
|
||||
const hashToEntry = new Map(plan.map((p) => [p.hash, p]));
|
||||
const h: LedgerHarness = {
|
||||
ledger: new Map(),
|
||||
executed: [],
|
||||
ensureLedger: async () => {},
|
||||
appliedHashes: async () => [...h.ledger.keys()],
|
||||
recordApplied: async (hash, folderMillis) => {
|
||||
h.ledger.set(hash, folderMillis);
|
||||
},
|
||||
runStatement: async (statement) => {
|
||||
void statement;
|
||||
// runStatement does not know which migration it belongs to; the
|
||||
// executed log is filled by the wrapper below.
|
||||
},
|
||||
};
|
||||
// Wrap runStatement so the executed log records migration context. We
|
||||
// reconstruct context by tracking a cursor the core advances per migration.
|
||||
let cursor = 0;
|
||||
const flat: Array<{ hash: string; idx: number }> = [];
|
||||
for (const m of plan)
|
||||
for (const [i] of m.statements.entries()) flat.push({ hash: m.hash, idx: i });
|
||||
h.runStatement = async () => {
|
||||
const at = flat[cursor] ?? { hash: '??', idx: -1 };
|
||||
cursor += 1;
|
||||
if (h.failOn && at.hash !== '??' && h.failOn(at.hash, at.idx)) {
|
||||
throw new Error(`simulated failure in ${at.hash} #${at.idx.toString()}`);
|
||||
}
|
||||
h.executed.push(`${at.hash.slice(0, 6)}:${at.idx.toString()}`);
|
||||
};
|
||||
void hashToEntry;
|
||||
return h;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Fixtures */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// Reproduces the REAL journal defect shape (#1402 D1): 0009/0010 carry
|
||||
// `when` timestamps BELOW 0008's. Under the old drizzle postgres-js
|
||||
// migrator these were silently skipped on any upgrade whose ledger was
|
||||
// last stamped in the 0008 era.
|
||||
const JOURNAL_FIXTURE: MigrationPlanEntry[] = [
|
||||
{ hash: 'aaaa0000', folderMillis: 1773368153122, statements: ['CREATE TABLE a (id int)'] },
|
||||
{ hash: 'bbbb0008', folderMillis: 1776822435828, statements: ['CREATE TABLE b (id int)'] },
|
||||
// Backdated entries, exactly as shipped:
|
||||
{
|
||||
hash: 'cccc0009',
|
||||
folderMillis: 1745280000000,
|
||||
statements: ['ALTER TYPE t ADD VALUE', "CREATE TABLE c (s t DEFAULT 'pending')"],
|
||||
},
|
||||
{ hash: 'dddd0010', folderMillis: 1745366400000, statements: ['CREATE TABLE d (id int)'] },
|
||||
];
|
||||
|
||||
/** A ledger last stamped at the 0008 era: only pre-0009 hashes recorded. */
|
||||
const LEDGER_AT_0008_ERA = new Map<string, number>([
|
||||
['aaaa0000', 1773368153122],
|
||||
['bbbb0008', 1776822435828],
|
||||
]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The core: apply-by-hash in journal order */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe('applyMigrationsByHash', () => {
|
||||
it('applies backdated journal entries that a timestamp-based migrator would skip (#1402 D1)', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
h.ledger = new Map(LEDGER_AT_0008_ERA);
|
||||
|
||||
const result = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
|
||||
// D1 in one sentence: 0009 and 0010 applied despite folderMillis < 0008.
|
||||
expect(result).toEqual({ applied: 2, skipped: 2 });
|
||||
expect(h.ledger.has('cccc0009')).toBe(true);
|
||||
expect(h.ledger.has('dddd0010')).toBe(true);
|
||||
});
|
||||
|
||||
it('executes statements individually (ALTER TYPE visibility, #1402 D2 shape)', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
// 0009's two statements recorded as separate executions, in order.
|
||||
expect(h.executed).toContain('cccc00:0');
|
||||
expect(h.executed).toContain('cccc00:1');
|
||||
expect(h.executed.indexOf('cccc00:0')).toBeLessThan(h.executed.indexOf('cccc00:1'));
|
||||
});
|
||||
|
||||
it('is idempotent: a fully-applied ledger applies nothing', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
const first = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
const second = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
expect(first.applied).toBe(4);
|
||||
expect(second).toEqual({ applied: 0, skipped: 4 });
|
||||
expect(h.executed).toHaveLength(5); // 5 statements; second run executed NONE (not 10)
|
||||
});
|
||||
|
||||
it('records no ledger row when a statement fails (crash prefix replays loudly)', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
h.failOn = (hash, idx) => hash === 'cccc0009' && idx === 1;
|
||||
|
||||
await expect(applyMigrationsByHash(h, JOURNAL_FIXTURE)).rejects.toThrow(
|
||||
/cccc0009 statement #1 failed: simulated failure/,
|
||||
);
|
||||
// Statement 0 of 0009 executed, but NO ledger row for 0009: the next run
|
||||
// replays it and fails loudly on "already exists" instead of silently
|
||||
// believing 0009 applied.
|
||||
expect(h.ledger.has('cccc0009')).toBe(false);
|
||||
expect(h.executed).toContain('cccc00:0');
|
||||
});
|
||||
|
||||
it('applies in JOURNAL order, not timestamp order', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
// 5 statements total (0009 has two); prefix per migration: journal order,
|
||||
// so 0009's pair sits between 0008 and 0010.
|
||||
const order = h.executed.map((e) => e.slice(0, 4));
|
||||
expect(order).toEqual(['aaaa', 'bbbb', 'cccc', 'cccc', 'dddd']);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Journal integrity against the shipped folder */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe('readJournalTags', () => {
|
||||
it('reads the shipped journal in order and sees the known backdated pair', () => {
|
||||
const folder = resolve(__dirname, '../drizzle');
|
||||
const tags = readJournalTags(folder);
|
||||
expect(tags.length).toBeGreaterThan(0);
|
||||
// The shipped defect (#1402 D1): these two entries carry April-2025
|
||||
// timestamps below 0008's June-2026 one. If this assertion ever fails
|
||||
// because the journal was FIXED (timestamps corrected or drizzle-kit
|
||||
// regenerated), update #1402 — the hash-ledger core stays correct either
|
||||
// way; this test pins the shipped reality the core was built for.
|
||||
const t9 = tags.find((t) => t.startsWith('0009_'));
|
||||
const t10 = tags.find((t) => t.startsWith('0010_'));
|
||||
const t8 = tags.find((t) => t.startsWith('0008_'));
|
||||
expect([t8, t9, t10]).toBeDefined();
|
||||
const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as {
|
||||
entries: Array<{ tag: string; when: number }>;
|
||||
};
|
||||
const when = new Map(journal.entries.map((e) => [e.tag, e.when]));
|
||||
if (t8 && t9 && t10) {
|
||||
expect(when.get(t9)!).toBeLessThan(when.get(t8)!); // backdated below 0008
|
||||
expect(when.get(t10)!).toBeLessThan(when.get(t8)!); // backdated below 0008
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import type { DbHandle } from './client.js';
|
||||
|
||||
interface PgliteExec {
|
||||
exec(query: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
describe('runPgliteMigrations', () => {
|
||||
let dataDir: string;
|
||||
let handle: DbHandle;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'mosaic-db-migrate-test-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle.close();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates the BetterAuth tables required by the gateway', async () => {
|
||||
await runPgliteMigrations(handle);
|
||||
|
||||
const result = (await handle.db.execute(sql`
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name
|
||||
`)) as unknown as { rows: Array<{ table_name: string }> };
|
||||
|
||||
const tables = result.rows.map((r) => r.table_name);
|
||||
|
||||
// Auth tables — required for sign-in / bootstrap to function.
|
||||
expect(tables).toContain('users');
|
||||
expect(tables).toContain('sessions');
|
||||
expect(tables).toContain('accounts');
|
||||
expect(tables).toContain('verifications');
|
||||
|
||||
// Schema sanity check — admin token table consumed by mosaic gateway config.
|
||||
expect(tables).toContain('admin_tokens');
|
||||
});
|
||||
|
||||
it('is idempotent — running twice does not error', async () => {
|
||||
await runPgliteMigrations(handle);
|
||||
await expect(runPgliteMigrations(handle)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives accounts an issuer column (#1395) — better-auth >=1.7 requires it', async () => {
|
||||
await runPgliteMigrations(handle);
|
||||
|
||||
const result = (await handle.db.execute(sql`
|
||||
SELECT column_name, is_nullable, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'accounts' AND column_name = 'issuer'
|
||||
`)) as unknown as {
|
||||
rows: Array<{ column_name: string; is_nullable: string; data_type: string }>;
|
||||
};
|
||||
|
||||
// Nullable by design: the 1.5.x line this repo's lockfile resolves to does
|
||||
// not write the field; 1.7+ populates it. One schema serves both.
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0]?.is_nullable).toBe('YES');
|
||||
expect(result.rows[0]?.data_type).toBe('text');
|
||||
});
|
||||
|
||||
it('backfills ONLY credential rows with the synthetic issuer (#1395 upgrade path)', async () => {
|
||||
// Simulate an upgraded install: migrate through 0016 only, seed pre-issuer
|
||||
// rows (one credential, one oauth), then apply 0017 and discriminate.
|
||||
const client = (handle.db as unknown as { $client: PgliteExec }).$client;
|
||||
|
||||
// Migrate to 0016 by replaying every ledger file except 0017 — the ledger
|
||||
// table gates re-application, so a plain replay of 0000..0016 is enough.
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.join(import.meta.dirname, '..', 'drizzle');
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => /^\d{4}_.*\.sql$/.test(f) && f < '0017')
|
||||
.sort();
|
||||
for (const f of files) {
|
||||
const raw = fs.readFileSync(path.join(dir, f), 'utf-8');
|
||||
for (const stmt of raw.split('--> statement-breakpoint')) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) await client.exec(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
await client.exec(`
|
||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES ('u1', 'Legacy User', '[email protected]', true, now(), now());
|
||||
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
|
||||
VALUES
|
||||
('a1', '[email protected]', 'credential', 'u1', now(), now()),
|
||||
('a2', 'oauth-provider-1', 'google', 'u1', now(), now());
|
||||
`);
|
||||
|
||||
// Apply 0017 (column + backfill).
|
||||
const sql0017 = fs.readFileSync(path.join(dir, '0017_accounts_issuer.sql'), 'utf-8');
|
||||
for (const stmt of sql0017.split('--> statement-breakpoint')) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) await client.exec(trimmed);
|
||||
}
|
||||
|
||||
const rows = (await handle.db.execute(sql`
|
||||
SELECT provider_id, issuer FROM accounts ORDER BY id
|
||||
`)) as unknown as { rows: Array<{ provider_id: string; issuer: string | null }> };
|
||||
|
||||
const byProvider = new Map(rows.rows.map((r) => [r.provider_id, r.issuer]));
|
||||
// Credential rows get better-auth's synthetic local issuer — the value
|
||||
// sign-in filters on (better-auth dist createLocalAccountIssuer).
|
||||
expect(byProvider.get('credential')).toBe('local:credential');
|
||||
// OAuth rows are LEFT NULL: better-auth owns their issuer semantics going
|
||||
// forward (each provider's real issuer on its next flow).
|
||||
expect(byProvider.get('google')).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces statement-level error context on failure and leaves no ledger row', async () => {
|
||||
// Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE,
|
||||
// forcing it to fail without IF NOT EXISTS.
|
||||
const client = (handle.db as unknown as { $client: PgliteExec }).$client;
|
||||
await client.exec('CREATE TABLE users (sentinel text)');
|
||||
|
||||
await expect(runPgliteMigrations(handle)).rejects.toThrow(
|
||||
/migration hash=[a-f0-9]+ statement #\d+ failed/,
|
||||
);
|
||||
|
||||
// Ledger should be empty — partial application must not pretend to be complete.
|
||||
const ledger = (await handle.db.execute(
|
||||
sql`SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations`,
|
||||
)) as unknown as { rows: Array<{ count: number }> };
|
||||
expect(ledger.rows[0]?.count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { readMigrationFiles } from 'drizzle-orm/migrator';
|
||||
import postgres from 'postgres';
|
||||
import { DEFAULT_DATABASE_URL } from './defaults.js';
|
||||
import type { DbHandle } from './client.js';
|
||||
|
||||
interface PgliteExecutor {
|
||||
exec(query: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ExecuteRows<T> {
|
||||
rows: T[];
|
||||
}
|
||||
|
||||
function migrationsFolder(): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
return resolve(here, '../drizzle');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared hash-ledger migration core (#1392 / #1402) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
//
|
||||
// Both tiers migrate through this single core, which applies migrations in
|
||||
// JOURNAL ORDER, one statement at a time, and skips by HASH — never by
|
||||
// folderMillis timestamp. The previous postgres path delegated to drizzle's
|
||||
// postgres-js migrator, which:
|
||||
//
|
||||
// * applies only migrations with folderMillis > last-applied, silently
|
||||
// skipping journal entries whose `when` is older than the ledger's newest
|
||||
// stamp — 0009/0010 carry April-2025 timestamps below 0008's June-2026
|
||||
// one, so any database last migrated in the 0008 era silently loses
|
||||
// 0009/0010 forever (#1402 D1); and
|
||||
// * wraps each migration in ONE transaction, which breaks migrations that
|
||||
// do `ALTER TYPE ADD VALUE` and then reference the new value in the same
|
||||
// migration (0009) — Postgres' check_safe_enum_use rejects it (#1402 D2).
|
||||
//
|
||||
// Per-statement execution (each statement autocommits) and skip-by-hash fix
|
||||
// both. The PGlite path has run this way since it was written; this is the
|
||||
// TODO it left behind, now shared instead of duplicated.
|
||||
|
||||
/** One migration as loaded from the shipped drizzle/ folder. */
|
||||
export interface MigrationPlanEntry {
|
||||
hash: string;
|
||||
folderMillis: number;
|
||||
statements: string[];
|
||||
}
|
||||
|
||||
/** The persistence operations the hash-ledger core needs, per tier. */
|
||||
export interface HashLedgerDeps {
|
||||
/** Create the drizzle schema + ledger table if absent (idempotent). */
|
||||
ensureLedger(): Promise<void>;
|
||||
/** Hashes already recorded in the ledger. */
|
||||
appliedHashes(): Promise<string[]>;
|
||||
/** Record one fully-applied migration in the ledger. */
|
||||
recordApplied(hash: string, folderMillis: number): Promise<void>;
|
||||
/** Execute one SQL statement, autocommitting (never inside a wider tx). */
|
||||
runStatement(statement: string): Promise<void>;
|
||||
}
|
||||
|
||||
function loadPlan(): MigrationPlanEntry[] {
|
||||
return readMigrationFiles({ migrationsFolder: migrationsFolder() }).map((m) => ({
|
||||
hash: m.hash,
|
||||
folderMillis: m.folderMillis,
|
||||
statements: m.sql.map((s) => s.trim()).filter((s) => s.length > 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply every unapplied migration in journal order, skipping by hash.
|
||||
*
|
||||
* Failure model: each statement autocommits, and the ledger row is written
|
||||
* only after all statements of a migration succeed. A crash mid-migration
|
||||
* leaves the prefix applied with no ledger entry, so the next boot replays
|
||||
* those statements and fails loudly on "already exists". Recovery: drop the
|
||||
* partially-applied objects, or insert the migration's hash into
|
||||
* `drizzle.__drizzle_migrations` manually. The thrown error identifies the
|
||||
* statement and migration that failed.
|
||||
*/
|
||||
export async function applyMigrationsByHash(
|
||||
deps: HashLedgerDeps,
|
||||
plan: MigrationPlanEntry[] = loadPlan(),
|
||||
): Promise<{ applied: number; skipped: number }> {
|
||||
await deps.ensureLedger();
|
||||
const alreadyApplied = new Set(await deps.appliedHashes());
|
||||
|
||||
let applied = 0;
|
||||
let skipped = 0;
|
||||
for (const migration of plan) {
|
||||
if (alreadyApplied.has(migration.hash)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
for (const [stmtIdx, stmt] of migration.statements.entries()) {
|
||||
try {
|
||||
await deps.runStatement(stmt);
|
||||
} catch (err) {
|
||||
const cause = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` +
|
||||
`Statement: ${stmt.slice(0, 200)}${stmt.length > 200 ? '…' : ''}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
await deps.recordApplied(migration.hash, migration.folderMillis);
|
||||
applied += 1;
|
||||
}
|
||||
return { applied, skipped };
|
||||
}
|
||||
|
||||
const LEDGER_DDL = [
|
||||
'CREATE SCHEMA IF NOT EXISTS drizzle',
|
||||
`CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at bigint
|
||||
)`,
|
||||
];
|
||||
|
||||
function connectionString(url?: string): string {
|
||||
return url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Drizzle migrations against a postgres database, hash-ledger style.
|
||||
* Idempotent: re-running against a fully-migrated database applies nothing.
|
||||
*/
|
||||
export async function runMigrations(url?: string): Promise<void> {
|
||||
const sqlClient = postgres(connectionString(url), { max: 1 });
|
||||
try {
|
||||
await applyMigrationsByHash({
|
||||
ensureLedger: async () => {
|
||||
for (const ddl of LEDGER_DDL) await sqlClient.unsafe(ddl);
|
||||
},
|
||||
appliedHashes: async () => {
|
||||
const rows = (await sqlClient.unsafe(
|
||||
'SELECT hash FROM drizzle.__drizzle_migrations',
|
||||
)) as Array<{ hash: string }>;
|
||||
return rows.map((r) => String(r.hash));
|
||||
},
|
||||
recordApplied: async (hash, folderMillis) => {
|
||||
await sqlClient.unsafe(
|
||||
'INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)',
|
||||
[hash, folderMillis],
|
||||
);
|
||||
},
|
||||
runStatement: async (stmt) => {
|
||||
await sqlClient.unsafe(stmt);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await sqlClient.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Drizzle migrations against an embedded PGlite database.
|
||||
*
|
||||
* We don't reuse drizzle's pglite migrator for the same reasons as the
|
||||
* postgres path (single-transaction wrap; folderMillis skip). PGlite's
|
||||
* `exec()` runs each statement under the Simple Query protocol,
|
||||
* autocommitting between them — exactly the semantics the shared core needs.
|
||||
*
|
||||
* The ledger rows this writes are interoperable with the postgres path (same
|
||||
* schema, same hashes), because both consume the same shipped migrations.
|
||||
*/
|
||||
export async function runPgliteMigrations(handle: DbHandle): Promise<void> {
|
||||
const client = (handle.db as unknown as { $client?: PgliteExecutor }).$client;
|
||||
if (!client || typeof client.exec !== 'function') {
|
||||
throw new Error('runPgliteMigrations: handle.db is not backed by a PGlite client');
|
||||
}
|
||||
|
||||
await applyMigrationsByHash({
|
||||
ensureLedger: async () => {
|
||||
for (const ddl of LEDGER_DDL) await client.exec(ddl);
|
||||
},
|
||||
appliedHashes: async () => {
|
||||
const rows = (await handle.db.execute(
|
||||
sql`SELECT hash FROM drizzle.__drizzle_migrations`,
|
||||
)) as unknown as ExecuteRows<{ hash: string }>;
|
||||
return rows.rows.map((r) => String(r.hash));
|
||||
},
|
||||
recordApplied: async (hash, folderMillis) => {
|
||||
await handle.db.execute(
|
||||
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${hash}, ${folderMillis})`,
|
||||
);
|
||||
},
|
||||
runStatement: async (stmt) => {
|
||||
await client.exec(stmt);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Migration status (#1392: the installer must VERIFY, not assume) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Read the journal tags (migration folder names) in journal order. */
|
||||
export function readJournalTags(folder: string = migrationsFolder()): string[] {
|
||||
const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as {
|
||||
entries?: Array<{ tag?: string }>;
|
||||
};
|
||||
return (journal.entries ?? []).map((e) => e.tag ?? '').filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
export interface MigrationStatus {
|
||||
/** Hashes recorded in the database's ledger (0 if no ledger exists). */
|
||||
appliedCount: number;
|
||||
/** Migrations shipped in this package's drizzle/ folder. */
|
||||
expectedCount: number;
|
||||
/** The tag (folder name) of the last journal entry, for error messages. */
|
||||
expectedLastTag: string;
|
||||
/** True iff every shipped migration's hash is in the ledger. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report whether a postgres database carries the full shipped schema.
|
||||
*
|
||||
* Read-only apart from `ensureLedger` semantics: it never creates the ledger
|
||||
* (unlike the migrators), so a database with NO ledger reports
|
||||
* appliedCount=0 / complete=false — the exact #1392/#1389 signature (an
|
||||
* install whose dependency set shipped no migrations at all).
|
||||
*/
|
||||
export async function getMigrationStatus(url?: string): Promise<MigrationStatus> {
|
||||
const sqlClient = postgres(connectionString(url), { max: 1 });
|
||||
try {
|
||||
const plan = loadPlan();
|
||||
const tags = readJournalTags();
|
||||
const expectedHashes = new Set(plan.map((m) => m.hash));
|
||||
|
||||
let appliedHashes: string[] = [];
|
||||
const regRows = (await sqlClient.unsafe(
|
||||
"SELECT to_regclass('drizzle.__drizzle_migrations') AS reg",
|
||||
)) as Array<{ reg: string | null }>;
|
||||
if (regRows[0]?.reg) {
|
||||
const rows = (await sqlClient.unsafe(
|
||||
'SELECT hash FROM drizzle.__drizzle_migrations',
|
||||
)) as Array<{ hash: string }>;
|
||||
appliedHashes = rows.map((r) => String(r.hash));
|
||||
}
|
||||
|
||||
const appliedSet = new Set(appliedHashes);
|
||||
return {
|
||||
appliedCount: appliedHashes.length,
|
||||
expectedCount: plan.length,
|
||||
expectedLastTag: tags[tags.length - 1] ?? '',
|
||||
complete:
|
||||
plan.length > 0 &&
|
||||
plan.every((m) => appliedSet.has(m.hash)) &&
|
||||
// A ledger with entries OUTSIDE the shipped plan means the database
|
||||
// came from a different (e.g. newer) build — not "complete" either.
|
||||
appliedHashes.every((h) => expectedHashes.has(h)),
|
||||
};
|
||||
} finally {
|
||||
await sqlClient.end();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user