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) <noreply@anthropic.com>
This commit is contained in:
Angelo B. J. Luidens
2026-06-03 15:05:31 -04:00
parent 339801966e
commit 7543366a15
4 changed files with 172 additions and 1 deletions
+1
View File
@@ -104,6 +104,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"drizzle-orm": "^0.36.0", "drizzle-orm": "^0.36.0",
"postgres": "^3.4.5",
"zod": "^3.23.0", "zod": "^3.23.0",
}, },
"devDependencies": { "devDependencies": {
+3 -1
View File
@@ -8,6 +8,7 @@
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./db": "./src/db.ts", "./db": "./src/db.ts",
"./client": "./src/client.ts",
"./frontmatter": "./src/frontmatter.ts" "./frontmatter": "./src/frontmatter.ts"
}, },
"scripts": { "scripts": {
@@ -20,7 +21,8 @@
}, },
"dependencies": { "dependencies": {
"zod": "^3.23.0", "zod": "^3.23.0",
"drizzle-orm": "^0.36.0" "drizzle-orm": "^0.36.0",
"postgres": "^3.4.5"
}, },
"devDependencies": { "devDependencies": {
"drizzle-kit": "^0.28.0", "drizzle-kit": "^0.28.0",
+19
View File
@@ -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<typeof createDb>;
export type Db = DbHandle["db"];
+149
View File
@@ -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");
});
});