chore: consolidate new foundation and archive v1 (#1495)

This commit is contained in:
2026-09-07 12:32:57 -05:00
3511 changed files with 727899 additions and 10 deletions
@@ -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);
});
+263
View File
@@ -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);
});
});
});
+457
View File
@@ -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 [];
}
+17
View File
@@ -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();
},
};
}
+26
View File
@@ -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() };
}
+1
View File
@@ -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);
});
+21
View File
@@ -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 nodes 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
+42
View File
@@ -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';
+169
View File
@@ -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
}
});
});
+139
View File
@@ -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);
});
});
+262
View File
@@ -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