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:
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"];
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user