From 7543366a15085da61a2f4d835058f13c7f82e64b Mon Sep 17 00:00:00 2001 From: "Angelo B. J. Luidens" Date: Wed, 3 Jun 2026 15:05:31 -0400 Subject: [PATCH] Stage 2: DB client factory + live integration tests (real Postgres) - packages/schema/src/client.ts: createDb(url) -> { db, sql } drizzle/postgres-js factory. Exposed as the ./client subpath export so the node-only driver never leaks into client bundles. - packages/schema/src/db.integration.test.ts: 6 tests gated behind INTEGRATION=1 (content insert/read, vault_path UNIQUE, idempotency_key UNIQUE no-double-post, content_id FK, audit append, kill-switch upsert). Verified against Postgres 17. - Default CI suite unchanged: integration tests skip without INTEGRATION. Closes the Stage 2.1 DoD against a live database (unblocked by Gate 0.8). Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 1 + packages/schema/package.json | 4 +- packages/schema/src/client.ts | 19 +++ packages/schema/src/db.integration.test.ts | 149 +++++++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 packages/schema/src/client.ts create mode 100644 packages/schema/src/db.integration.test.ts diff --git a/bun.lock b/bun.lock index 5481822..f774696 100644 --- a/bun.lock +++ b/bun.lock @@ -104,6 +104,7 @@ "version": "0.1.0", "dependencies": { "drizzle-orm": "^0.36.0", + "postgres": "^3.4.5", "zod": "^3.23.0", }, "devDependencies": { diff --git a/packages/schema/package.json b/packages/schema/package.json index 6fd4818..6d2c180 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -8,6 +8,7 @@ "exports": { ".": "./src/index.ts", "./db": "./src/db.ts", + "./client": "./src/client.ts", "./frontmatter": "./src/frontmatter.ts" }, "scripts": { @@ -20,7 +21,8 @@ }, "dependencies": { "zod": "^3.23.0", - "drizzle-orm": "^0.36.0" + "drizzle-orm": "^0.36.0", + "postgres": "^3.4.5" }, "devDependencies": { "drizzle-kit": "^0.28.0", diff --git a/packages/schema/src/client.ts b/packages/schema/src/client.ts new file mode 100644 index 0000000..ef82fc0 --- /dev/null +++ b/packages/schema/src/client.ts @@ -0,0 +1,19 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "./db"; + +/** + * Creates a Drizzle client bound to a postgres-js connection. + * + * Returns both the typed `db` (for queries) and the raw `sql` handle (for + * lifecycle control — call `sql.end()` on shutdown / after tests). Callers + * own the lifecycle; this factory does not register process hooks. + */ +export const createDb = (url: string, opts: { max?: number } = {}) => { + const sql = postgres(url, { max: opts.max ?? 5 }); + const db = drizzle(sql, { schema }); + return { db, sql }; +}; + +export type DbHandle = ReturnType; +export type Db = DbHandle["db"]; diff --git a/packages/schema/src/db.integration.test.ts b/packages/schema/src/db.integration.test.ts new file mode 100644 index 0000000..a25cea3 --- /dev/null +++ b/packages/schema/src/db.integration.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { eq } from "drizzle-orm"; +import { createDb, type DbHandle } from "./client"; +import { content, publications, audit, outlet_feature_flags } from "./db"; +import { idempotencyKeyOf } from "./api-contracts"; + +/** + * Stage 2 DB integration tests — run against a real Postgres. + * + * Gated behind INTEGRATION=1 so the default CI suite needs no database. + * Run with: + * docker compose -f infra/docker-compose.yml up -d --wait postgres + * INTEGRATION=1 bun --filter @stargue/schema test + */ +const RUN = !!process.env.INTEGRATION; +const URL = + process.env.DATABASE_URL ?? + "postgres://stargue:stargue_dev@localhost:5433/stargue_publishing_engine"; + +describe.skipIf(!RUN)("DB integration — real Postgres (Stage 2)", () => { + let h: DbHandle; + + beforeAll(() => { + h = createDb(URL); + }); + + afterAll(async () => { + await h.sql.end(); + }); + + beforeEach(async () => { + // Clean slate per test; CASCADE clears FK-dependent rows. + await h.sql`TRUNCATE content, publications, approvals, metrics, audit, outlet_feature_flags RESTART IDENTITY CASCADE`; + }); + + const seedContent = async (slug = "from-fortress-to-hearth") => { + const [row] = await h.db + .insert(content) + .values({ + vault_path: `Stargue/Projects/Publishing Engine/${slug}.md`, + slug, + title: "From Fortress to Hearth", + body_sanitized: "The hearth is open.", + frontmatter_jsonb: { status: "ready", outlets: ["linkedin"] }, + content_hash: "a".repeat(64), + }) + .returning(); + return row!; + }; + + it("inserts and reads content by slug", async () => { + const inserted = await seedContent(); + const found = await h.db + .select() + .from(content) + .where(eq(content.slug, "from-fortress-to-hearth")); + expect(found).toHaveLength(1); + expect(found[0]!.id).toBe(inserted.id); + expect(found[0]!.version).toBe(1); + expect(found[0]!.created_at).toBeInstanceOf(Date); + }); + + it("enforces the content.vault_path UNIQUE constraint", async () => { + await seedContent(); + await expect(seedContent()).rejects.toThrow(/unique|duplicate/i); + }); + + it("enforces publications.idempotency_key UNIQUE (no double-post on retry)", async () => { + const c = await seedContent(); + const key = idempotencyKeyOf(c.id, "linkedin", null); + await h.db.insert(publications).values({ + content_id: c.id, + outlet: "linkedin", + status: "scheduled", + idempotency_key: key, + }); + // Same key (a retry) must be rejected by the DB, not silently duplicated. + await expect( + h.db.insert(publications).values({ + content_id: c.id, + outlet: "linkedin", + status: "scheduled", + idempotency_key: key, + }), + ).rejects.toThrow(/unique|duplicate/i); + + const rows = await h.db + .select() + .from(publications) + .where(eq(publications.idempotency_key, key)); + expect(rows).toHaveLength(1); + }); + + it("enforces the publications.content_id foreign key", async () => { + await expect( + h.db.insert(publications).values({ + content_id: 999_999, + outlet: "linkedin", + status: "scheduled", + idempotency_key: "999999|linkedin|immediate", + }), + ).rejects.toThrow(/foreign key|violates/i); + }); + + it("appends audit rows with correlation id", async () => { + const corr = "corr-0001"; + await h.db.insert(audit).values({ + actor: "operator:angelo", + action: "publications.cancel", + subject_type: "publication", + subject_id: "42", + correlation_id: corr, + payload_jsonb: { reason: "typo in title" }, + }); + const rows = await h.db + .select() + .from(audit) + .where(eq(audit.correlation_id, corr)); + expect(rows).toHaveLength(1); + expect(rows[0]!.ts).toBeInstanceOf(Date); + expect(rows[0]!.payload_jsonb).toEqual({ reason: "typo in title" }); + }); + + it("toggles + reads the outlet kill-switch (upsert)", async () => { + await h.db + .insert(outlet_feature_flags) + .values({ outlet: "linkedin", enabled: true, updated_by: "operator:angelo" }); + + await h.db + .insert(outlet_feature_flags) + .values({ + outlet: "linkedin", + enabled: false, + reason: "auth expired", + updated_by: "system:fail-safe", + }) + .onConflictDoUpdate({ + target: outlet_feature_flags.outlet, + set: { enabled: false, reason: "auth expired", updated_by: "system:fail-safe" }, + }); + + const [flag] = await h.db + .select() + .from(outlet_feature_flags) + .where(eq(outlet_feature_flags.outlet, "linkedin")); + expect(flag!.enabled).toBe(false); + expect(flag!.reason).toBe("auth expired"); + }); +});