From 5c202ba21e2273bec789849a7a34cd690b6120e4 Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 11:14:14 +0000 Subject: [PATCH 1/3] feat: add database-postgres driver boundary package (E00-S03-T02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/database-postgres (@personal-blog/database-postgres): the single workspace package allowed to import the PostgreSQL driver — declares pg and kysely as exact dependencies (@types/pg for types) and its src/index.ts imports and re-exports the driver pieces (Pool, Kysely, PostgresDialect) so the isolation is real, not a placeholder - pnpm-lock.yaml: importer for packages/database-postgres plus the resolved pg/kysely dependency tree (frozen-lockfile install keeps working) - .gitea/workflows/ci.yml: new database-postgres-imports job runs tests/database-postgres-imports.test.mjs on every PR so the isolation criterion gates merges --- .gitea/workflows/ci.yml | 16 +++ packages/database-postgres/package.json | 26 +++++ packages/database-postgres/src/index.ts | 20 ++++ packages/database-postgres/tsconfig.json | 8 ++ pnpm-lock.yaml | 135 +++++++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 packages/database-postgres/package.json create mode 100644 packages/database-postgres/src/index.ts create mode 100644 packages/database-postgres/tsconfig.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 249222b..abc625a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -37,6 +37,22 @@ jobs: - name: Run secrets-not-embedded test suite run: node --test tests/secrets-not-embedded.test.mjs + # E00-S03-T02: the static assertions of tests/database-postgres-imports.test.mjs + # gate every PR — the scan proves pg/Kysely imports live only in + # packages/database-postgres and the mutation probes prove the scan catches + # a driver import injected into any other package. + database-postgres-imports: + name: Database-postgres import isolation (E00-S03-T02) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Run database-postgres import isolation suite + run: node --test tests/database-postgres-imports.test.mjs + # E00-S03-T01: the static assertions of tests/compose-config.test.mjs (db # image pinned to postgres:18.6-bookworm, health gate, volume persistence, # build platforms) gate every PR (the docker-gated real-stack probes inside diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json new file mode 100644 index 0000000..95e9e54 --- /dev/null +++ b/packages/database-postgres/package.json @@ -0,0 +1,26 @@ +{ + "name": "@personal-blog/database-postgres", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "EPPP PostgreSQL database adapter package. The single workspace package allowed to import the pg driver and Kysely (E00-S03-T02); the concrete adapter (migration ledger, advisory lock) lands in later stories.", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "kysely": "0.29.5", + "pg": "8.23.0" + }, + "devDependencies": { + "@types/pg": "8.23.1" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} diff --git a/packages/database-postgres/src/index.ts b/packages/database-postgres/src/index.ts new file mode 100644 index 0000000..c7a8780 --- /dev/null +++ b/packages/database-postgres/src/index.ts @@ -0,0 +1,20 @@ +/** + * @personal-blog/database-postgres — EPPP PostgreSQL adapter package. + * + * [E00-S03-T02] this package is the single owner of the PostgreSQL driver + * stack: it is the only workspace package allowed to import `pg` and Kysely. + * Every piece of the workspace that talks to the database goes through this + * boundary — the pool, the query builder dialect and the adapter API this + * package will expose — so no other package ever imports the driver directly. + * + * This module is the driver boundary: it imports the PostgreSQL driver (`pg`) + * and Kysely and re-exports the pieces later stories build the adapter on + * (migration ledger E00-S03-T03, advisory lock E00-S03-T04). Until then the + * re-exports keep the driver reachable only from here — the isolation is real, + * not a placeholder. + */ + +import { Pool } from 'pg'; +import { Kysely, PostgresDialect } from 'kysely'; + +export { Pool, Kysely, PostgresDialect }; diff --git a/packages/database-postgres/tsconfig.json b/packages/database-postgres/tsconfig.json new file mode 100644 index 0000000..5285d28 --- /dev/null +++ b/packages/database-postgres/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 007743e..6630a77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,11 +22,85 @@ importers: packages/core: {} + packages/database-postgres: + dependencies: + kysely: + specifier: 0.29.5 + version: 0.29.5 + pg: + specifier: 8.23.0 + version: 8.23.0 + devDependencies: + '@types/pg': + specifier: 8.23.1 + version: 8.23.1 + packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + + kysely@0.29.5: + resolution: {integrity: sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==} + engines: {node: '>=22.0.0'} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -35,12 +109,73 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + snapshots: '@types/node@24.13.3': dependencies: undici-types: 7.18.2 + '@types/pg@8.23.1': + dependencies: + '@types/node': 24.13.3 + pg-protocol: 1.16.0 + pg-types: 2.2.0 + + kysely@0.29.5: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + + pg-protocol@1.16.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + split2@4.2.0: {} + typescript@6.0.3: {} undici-types@7.18.2: {} + + xtend@4.0.2: {} -- 2.54.0 From 97c530676844779297247650aa92f1d6a54c70b6 Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 11:14:15 +0000 Subject: [PATCH 2/3] test: lock in pg/Kysely import isolation to database-postgres (E00-S03-T02) - database-postgres-imports.test.mjs: static scan of every workspace package source proves pg/kysely import specifiers resolve only to packages/database-postgres; owner manifest pins the driver and no other package declares it; mutation probes prove the scan catches a driver import injected into apps/server/src/index.ts; comment-stripping and specifier matcher unit probes; CI-enforcement assertion - workspace-layout / workspace-config / strict-tsconfig / typescript-pin: package-set fixtures updated to include packages/database-postgres - docs/development/non-container.md: workspace package table and build expectations updated for the new package --- docs/development/non-container.md | 10 +- tests/database-postgres-imports.test.mjs | 490 +++++++++++++++++++++++ tests/strict-tsconfig.test.mjs | 2 +- tests/typescript-pin.test.mjs | 2 +- tests/workspace-config.test.mjs | 2 +- tests/workspace-layout.test.mjs | 1 + 6 files changed, 500 insertions(+), 7 deletions(-) create mode 100644 tests/database-postgres-imports.test.mjs diff --git a/docs/development/non-container.md b/docs/development/non-container.md index 57254b7..10aea97 100644 --- a/docs/development/non-container.md +++ b/docs/development/non-container.md @@ -17,6 +17,7 @@ The workspace is a pnpm monorepo with three package groups: | --- | --- | --- | | `apps/` | `apps/server` (`@personal-blog/server`) | Public server application. Serves the application health endpoint (E00-S02-T03); the Fastify 5 application shell lands in a later story. | | `packages/` | `packages/core` (`@personal-blog/core`) | Application core (site identity, content primitives). Bootstrap placeholder. | +| `packages/` | `packages/database-postgres` (`@personal-blog/database-postgres`) | PostgreSQL database adapter package. Single owner of the `pg`/Kysely driver imports (E00-S03-T02); the concrete adapter lands in later stories. | | `extensions/` | `extensions/example` (`@personal-blog/example-extension`) | Example extension exercising the `extensions/` group. Bootstrap placeholder. | A dependency-boundary rule (`dependency-boundaries.json`, enforced by @@ -74,8 +75,9 @@ pnpm typecheck `pnpm build` runs `tsc -p tsconfig.json` in each package in topological order and emits `dist/` (JavaScript + type declarations + source maps) per package. -Expected result: `apps/server/dist/`, `packages/core/dist/` and -`extensions/example/dist/` are produced, all packages report `Done`, exit 0. +Expected result: `apps/server/dist/`, `packages/core/dist/`, +`packages/database-postgres/dist/` and `extensions/example/dist/` are +produced, all packages report `Done`, exit 0. `dist/` is git-ignored; rebuild whenever you change `src/`. ## Run @@ -119,8 +121,8 @@ This is also the suite that enforces the dependency-boundary rule. git clone https://git.stevanovic.co.uk/Fabrika/PersonalBlog.git && cd PersonalBlog corepack enable pnpm install --frozen-lockfile # exit 0, lockfile untouched -pnpm build # 3/3 packages emit dist/, exit 0 -pnpm typecheck # 3/3 packages pass --noEmit, exit 0 +pnpm build # 4/4 packages emit dist/, exit 0 +pnpm typecheck # 4/4 packages pass --noEmit, exit 0 pnpm test # 10/10 pass, exit 0 pnpm --filter @personal-blog/server start # serves GET /health on port 3000, stays up ``` diff --git a/tests/database-postgres-imports.test.mjs b/tests/database-postgres-imports.test.mjs new file mode 100644 index 0000000..1117f67 --- /dev/null +++ b/tests/database-postgres-imports.test.mjs @@ -0,0 +1,490 @@ +/** + * Database-postgres import isolation test — locks in the [E00-S03-T02] rule + * that `pg`/Kysely imports live only in the `database-postgres` package. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "pg/Kysely imports are isolated to database-postgres" -> the workspace + * package `packages/database-postgres` (`@personal-blog/database-postgres`) + * exists, declares the driver as its own dependencies (`pg`, `kysely` + * exact pins, `@types/pg` for types) and its source really imports the + * driver; a static scan of every workspace package's source proves that + * every `pg`/`kysely` import specifier (static import/export-from, dynamic + * import, require) resolves to a file inside `packages/database-postgres` + * — and no other package's manifest declares the driver either. + * - "no other package imports the database driver directly" -> the same + * scan finds zero driver imports outside the owner package, and the + * committed state is non-vacuous: a mutation probe injects a `pg` import + * into `apps/server/src/index.ts` in a temp copy of the committed tree + * and the scan fails, naming the offending file and the driver; a clean + * copy passes (probe sanity). + * + * Run: `node --test tests/database-postgres-imports.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, writeFileSync, mkdirSync, cpSync, mkdtempSync, rmSync } from 'node:fs'; +import { readdir } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The workspace package allowed to import the PostgreSQL driver (E00-S03-T02). */ +const OWNER_DIR = 'packages/database-postgres'; + +/** The root test glob (root `scripts.test`, E00-S01-T12) that runs every suite. */ +const ROOT_TEST_GLOB = 'tests/**/*.test.mjs'; + +/** The CI job that gates the isolation criterion on every PR. */ +const CI_JOB = 'database-postgres-imports'; + +/** Directories never scanned as package source. */ +const IGNORED_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git', '.pnpm-store']); + +/** The workspace groups scanned for source files (mirrors pnpm-workspace.yaml). */ +const GROUP_DIRS = ['apps', 'packages', 'extensions']; + +/** Extensions scanned as package source. */ +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']); + +/** + * True when a module specifier names the PostgreSQL driver: the `pg` package + * or Kysely (including their subpaths, e.g. `pg/connection-string` or + * `kysely/plugin/dialect`). `@types/pg` is a types-only package — never + * imported at runtime — so it is not a driver import. + */ +function isDriverSpecifier(specifier) { + return ( + specifier === 'pg' || + specifier.startsWith('pg/') || + specifier === 'kysely' || + specifier.startsWith('kysely/') + ); +} + +// --------------------------------------------------------------------------- +// Comment stripping (string/comment aware, preserves line count) +// --------------------------------------------------------------------------- + +/** + * Replaces comments with whitespace while preserving line structure, so + * specifier extraction never fires on commented-out imports and reported + * line numbers still match the original source. Mirrors the stripper in + * tests/architecture-import.test.mjs. + */ +function stripComments(source) { + let out = ''; + let i = 0; + const n = source.length; + let state = 'code'; // code | line | block | sq | dq | tpl + const tplStack = []; // template states to resume after `${...}` closes + let braceDepth = 0; + + while (i < n) { + const c = source[i]; + const next = source[i + 1]; + + if (state === 'code') { + if (tplStack.length > 0) { + if (c === '{') { + braceDepth += 1; + out += c; + i += 1; + continue; + } + if (c === '}') { + braceDepth -= 1; + out += c; + i += 1; + if (braceDepth === 0) state = tplStack.pop(); + continue; + } + } + if (c === '/' && next === '/') { + out += ' '; + i += 2; + state = 'line'; + continue; + } + if (c === '/' && next === '*') { + out += ' '; + i += 2; + state = 'block'; + continue; + } + if (c === "'") { + out += c; + i += 1; + state = 'sq'; + continue; + } + if (c === '"') { + out += c; + i += 1; + state = 'dq'; + continue; + } + if (c === '`') { + out += c; + i += 1; + state = 'tpl'; + continue; + } + out += c; + i += 1; + continue; + } + + if (state === 'line') { + if (c === '\n') { + out += c; + i += 1; + state = 'code'; + } else { + out += ' '; + i += 1; + } + continue; + } + + if (state === 'block') { + if (c === '*' && next === '/') { + out += ' '; + i += 2; + state = 'code'; + } else { + out += c === '\n' ? c : ' '; + i += 1; + } + continue; + } + + if (state === 'sq' || state === 'dq') { + const quote = state === 'sq' ? "'" : '"'; + out += c; + if (c === '\\' && next !== undefined) { + out += next; + i += 2; + } else { + if (c === quote) state = 'code'; + i += 1; + } + continue; + } + + // template literal + out += c; + if (c === '\\' && next !== undefined) { + out += next; + i += 2; + continue; + } + if (c === '`') { + state = 'code'; + i += 1; + continue; + } + if (c === '$' && next === '{') { + out += next; + i += 2; + tplStack.push('tpl'); + braceDepth = 1; + state = 'code'; + continue; + } + i += 1; + } + return out; +} + +// --------------------------------------------------------------------------- +// Driver-import discovery +// --------------------------------------------------------------------------- + +const STATIC_IMPORT = /\b(?:import|export)\s+(?:type\s+)?(?:[\w*{},\s]*?\s+from\s*)?(['"])([^'"]+)\1/g; +const REQUIRE_CALL = /\brequire\s*\(\s*(['"])([^'"]+)\1\s*\)/g; +const DYNAMIC_IMPORT = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/g; + +/** Returns [{ specifier, index }] for every module specifier in the (stripped) source. */ +function extractSpecifiers(stripped) { + const found = []; + for (const re of [STATIC_IMPORT, REQUIRE_CALL, DYNAMIC_IMPORT]) { + re.lastIndex = 0; + let m; + while ((m = re.exec(stripped)) !== null) { + found.push({ specifier: m[2], index: m.index }); + } + } + found.sort((a, b) => a.index - b.index); + return found; +} + +function lineOf(stripped, index) { + return stripped.slice(0, index).split('\n').length; +} + +/** + * Returns [{ relPath, line, specifier }] for every pg/Kysely import found in + * the given source files. `relPath` is the file path relative to the scan + * root (posix), so reports read the same for the real workspace and for temp + * copies. + */ +function findDriverImports(files) { + const imports = []; + for (const file of files) { + const stripped = stripComments(file.content); + for (const { specifier, index } of extractSpecifiers(stripped)) { + if (isDriverSpecifier(specifier)) { + imports.push({ + relPath: file.relPath, + line: lineOf(stripped, index), + specifier, + }); + } + } + } + return imports; +} + +/** True when a scan-relative posix path sits inside the owner package. */ +function isInsideOwner(relPath) { + return relPath === OWNER_DIR || relPath.startsWith(`${OWNER_DIR}/`); +} + +/** Walks the workspace groups under a root and returns [{ relPath, content }] for source files. */ +async function collectSourceFiles(rootDir) { + const files = []; + async function walk(dir, relDir) { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const abs = path.join(dir, entry.name); + const rel = `${relDir}/${entry.name}`; + if (entry.isDirectory()) { + if (!IGNORED_DIRS.has(entry.name)) await walk(abs, rel); + continue; + } + if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) { + files.push({ relPath: rel, content: readFileSync(abs, 'utf8') }); + } + } + } + for (const group of GROUP_DIRS) { + await walk(path.join(rootDir, group), group); + } + return files; +} + +// --------------------------------------------------------------------------- +// Manifest helpers +// --------------------------------------------------------------------------- + +/** Returns [{ relPath, name, driverDeps }] for every workspace package manifest under a root. */ +async function collectManifests(rootDir) { + const manifests = []; + for (const group of GROUP_DIRS) { + let entries; + try { + entries = await readdir(path.join(rootDir, group), { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const manifestPath = path.join(rootDir, group, entry.name, 'package.json'); + let manifest; + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + } catch { + continue; + } + const allDeps = { + ...(manifest.dependencies ?? {}), + ...(manifest.devDependencies ?? {}), + }; + manifests.push({ + relPath: `${group}/${entry.name}/package.json`, + name: manifest.name, + driverDeps: Object.keys(allDeps).filter(isDriverSpecifier), + }); + } + } + return manifests; +} + +/** A single driver import, formatted for violation reports. */ +function formatImport(driverImport) { + return ` ${driverImport.relPath}:${driverImport.line} imports "${driverImport.specifier}"`; +} + +// --------------------------------------------------------------------------- +// Temp-copy helpers (mutation probes over the committed tree) +// --------------------------------------------------------------------------- + +/** Copies the committed tree (minus ignored dirs) into a fresh temp dir. */ +function copyCommittedTree() { + const dir = mkdtempSync(path.join(os.tmpdir(), 'eppp-db-imports-')); + for (const entry of readdirSync(REPO_ROOT, { withFileTypes: true })) { + if (IGNORED_DIRS.has(entry.name) || entry.name === 'tests') continue; + cpSync(path.join(REPO_ROOT, entry.name), path.join(dir, entry.name), { + recursive: true, + }); + } + // The isolation test file itself is part of the scan surface in a real + // checkout; copying it keeps the temp copy's package graph identical. + mkdirSync(path.join(dir, 'tests'), { recursive: true }); + cpSync(path.join(REPO_ROOT, 'tests'), path.join(dir, 'tests'), { recursive: true }); + return dir; +} + +/** Injects a driver import into a package source inside a tree copy. */ +function injectDriverImport(rootDir, relPath, line) { + const abs = path.join(rootDir, relPath); + const original = readFileSync(abs, 'utf8'); + const injected = `${line}\n${original}`; + writeFileSync(abs, injected); + return abs; +} + +/** Runs the isolation scan over a root and returns the misplaced imports. */ +async function scanRoot(rootDir) { + const files = await collectSourceFiles(rootDir); + return findDriverImports(files).filter((d) => !isInsideOwner(d.relPath)); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test('packages/database-postgres exists, is the driver owner, and its source imports the driver', () => { + const manifest = JSON.parse(read(path.join(OWNER_DIR, 'package.json'))); + assert.equal(manifest.name, '@personal-blog/database-postgres'); + // The driver is owned here: exact pins, never ranges (reproducibility, + // same policy as typescript@6.0.3 at the root). + assert.equal(manifest.dependencies?.pg, '8.23.0', 'owner must pin pg exactly'); + assert.equal(manifest.dependencies?.kysely, '0.29.5', 'owner must pin kysely exactly'); + assert.equal(manifest.devDependencies?.['@types/pg'], '8.23.1', 'owner must pin @types/pg exactly'); + + // The boundary module really imports the driver — the isolation is + // exercised, not just declared. + const source = read(path.join(OWNER_DIR, 'src', 'index.ts')); + assert.match(source, /from\s+'pg'/, 'packages/database-postgres/src/index.ts must import pg'); + assert.match(source, /from\s+'kysely'/, 'packages/database-postgres/src/index.ts must import kysely'); +}); + +test('pg/Kysely imports are isolated to database-postgres in the real workspace', async () => { + const files = await collectSourceFiles(REPO_ROOT); + const imports = findDriverImports(files); + + // Non-vacuous: the driver is actually imported somewhere — and that + // somewhere is the owner package. + assert.ok(imports.length > 0, 'the workspace must contain pg/Kysely imports (in the owner package)'); + const ownerImports = imports.filter((d) => isInsideOwner(d.relPath)); + assert.ok( + ownerImports.some((d) => d.specifier === 'pg'), + 'the owner package must import pg', + ); + assert.ok( + ownerImports.some((d) => d.specifier === 'kysely'), + 'the owner package must import kysely', + ); + + const misplaced = imports.filter((d) => !isInsideOwner(d.relPath)); + assert.deepEqual( + misplaced, + [], + `no package other than database-postgres may import pg/Kysely:\n${misplaced.map(formatImport).join('\n')}`, + ); +}); + +test('no other package declares the database driver in its manifest', async () => { + const manifests = await collectManifests(REPO_ROOT); + assert.ok( + manifests.some((m) => m.relPath.startsWith(`${OWNER_DIR}/`)), + 'the owner package manifest must be discovered', + ); + const offenders = manifests.filter((m) => !m.relPath.startsWith(`${OWNER_DIR}/`) && m.driverDeps.length > 0); + assert.deepEqual( + offenders, + [], + `only ${OWNER_DIR} may declare pg/kysely as a dependency:\n` + + offenders + .map((m) => ` ${m.relPath} (${m.name}) declares ${m.driverDeps.join(', ')}`) + .join('\n'), + ); +}); + +test('the isolation scan catches a driver import injected into another package (mutation probe)', async () => { + const dir = copyCommittedTree(); + try { + injectDriverImport(dir, 'apps/server/src/index.ts', "import { Pool } from 'pg';"); + const misplaced = await scanRoot(dir); + assert.equal(misplaced.length, 1, `expected exactly one violation, got:\n${misplaced.map(formatImport).join('\n')}`); + assert.equal(misplaced[0].relPath, 'apps/server/src/index.ts'); + assert.equal(misplaced[0].specifier, 'pg'); + assert.equal(misplaced[0].line, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a copy of the committed tree without misplaced driver imports passes the scan (probe sanity)', async () => { + const dir = copyCommittedTree(); + try { + const misplaced = await scanRoot(dir); + assert.deepEqual( + misplaced, + [], + `a clean copy of the committed tree must pass the isolation scan:\n${misplaced.map(formatImport).join('\n')}`, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the specifier matcher flags pg/kysely imports and ignores other packages (unit probe)', () => { + for (const spec of ['pg', 'pg/connection-string', 'kysely', 'kysely/plugin/dialect']) { + assert.ok(isDriverSpecifier(spec), `"${spec}" must be flagged as a driver import`); + } + for (const spec of ['fastify', 'node:http', '@types/pg', 'pg-native', 'pg-pool', 'postgres-array', './internal']) { + assert.equal(isDriverSpecifier(spec), false, `"${spec}" must not be flagged as a driver import`); + } +}); + +test('comment stripping ignores commented-out driver imports (unit probe)', () => { + const src = [ + '// import { Pool } from \'pg\';', + '/* import { Kysely } from "kysely"; */', + "import { Pool } from 'pg';", + '', + ].join('\n'); + const imports = findDriverImports([{ relPath: 'apps/server/src/index.ts', content: src }]); + assert.equal(imports.length, 1); + assert.equal(imports[0].specifier, 'pg'); + assert.equal(imports[0].line, 3, 'the stripped line number must match the original source'); +}); + +test('the isolation criterion is enforced in CI', () => { + // Picked up by the root test command (root `scripts.test` glob). + const scripts = JSON.parse(read('package.json')).scripts ?? {}; + assert.equal( + scripts.test, + `node --test "${ROOT_TEST_GLOB}"`, + `root scripts.test must run the "${ROOT_TEST_GLOB}" glob so this suite runs with the rest`, + ); + // And a dedicated CI job gates it on every PR. + const workflow = read('.gitea/workflows/ci.yml'); + assert.ok( + workflow.includes(`node --test tests/database-postgres-imports.test.mjs`), + `CI must run the isolation suite (job "${CI_JOB}") on every PR`, + ); +}); diff --git a/tests/strict-tsconfig.test.mjs b/tests/strict-tsconfig.test.mjs index 467d0b9..965e25a 100644 --- a/tests/strict-tsconfig.test.mjs +++ b/tests/strict-tsconfig.test.mjs @@ -37,7 +37,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.. const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); /** The workspace packages that must compile under the strict base config. */ -const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'extensions/example']; +const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example']; /** The strict-family flags the committed base config must set. */ const STRICT_FAMILY_FLAGS = [ diff --git a/tests/typescript-pin.test.mjs b/tests/typescript-pin.test.mjs index 4908c1b..431a6e2 100644 --- a/tests/typescript-pin.test.mjs +++ b/tests/typescript-pin.test.mjs @@ -33,7 +33,7 @@ const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); const PINNED_TYPESCRIPT = '6.0.3'; /** Every workspace package that must resolve the pinned TypeScript version. */ -const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'extensions/example']; +const WORKSPACE_PACKAGES = ['apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example']; // --------------------------------------------------------------------------- // Tests diff --git a/tests/workspace-config.test.mjs b/tests/workspace-config.test.mjs index 8046402..45b95a5 100644 --- a/tests/workspace-config.test.mjs +++ b/tests/workspace-config.test.mjs @@ -28,7 +28,7 @@ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.. const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); const WORKSPACE_GROUPS = ['apps/*', 'packages/*', 'extensions/*']; -const WORKSPACE_PACKAGES = ['.', 'apps/server', 'packages/core', 'extensions/example']; +const WORKSPACE_PACKAGES = ['.', 'apps/server', 'packages/core', 'packages/database-postgres', 'extensions/example']; const PINNED_PNPM = '11.23.0'; // --------------------------------------------------------------------------- diff --git a/tests/workspace-layout.test.mjs b/tests/workspace-layout.test.mjs index 1014863..6c1eaa0 100644 --- a/tests/workspace-layout.test.mjs +++ b/tests/workspace-layout.test.mjs @@ -38,6 +38,7 @@ const TOP_LEVEL_GROUPS = ['apps', 'packages', 'extensions']; const EXPECTED_PACKAGES = { '@personal-blog/server': 'apps/server', '@personal-blog/core': 'packages/core', + '@personal-blog/database-postgres': 'packages/database-postgres', '@personal-blog/example-extension': 'extensions/example', }; -- 2.54.0 From 09b7a40d007c4575ca52f05ef2a53dc34a190eaa Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 11:27:29 +0000 Subject: [PATCH 3/3] fix: pin database-postgres driver to the golden tuple (pg 8.22.0, Kysely 0.29.4) Review finding on PR #391: packages/database-postgres pinned pg@8.23.0 and kysely@0.29.5, but the architecture doc's golden tuple (Technology-Stack section 5.2 / section 7) pins pg@8.22.0 and Kysely@0.29.4. Reproducibility requires the exact documented versions. - packages/database-postgres/package.json: pg 8.23.0 -> 8.22.0, kysely 0.29.5 -> 0.29.4, @types/pg 8.23.1 -> 8.21.0 (no 8.22.x of @types/pg is published; 8.21.0 is the closest matching release, types for the immediately preceding pg minor) - pnpm-lock.yaml: regenerated with pnpm 11.23.0 (Node 24); the resolved pg dependency tree is unchanged apart from the driver version itself - tests/database-postgres-imports.test.mjs: exact-pin assertions updated to the corrected versions, with a comment noting the @types/pg choice --- packages/database-postgres/package.json | 6 ++-- pnpm-lock.yaml | 36 ++++++++++++------------ tests/database-postgres-imports.test.mjs | 9 ++++-- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/database-postgres/package.json b/packages/database-postgres/package.json index 95e9e54..5205781 100644 --- a/packages/database-postgres/package.json +++ b/packages/database-postgres/package.json @@ -9,11 +9,11 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "kysely": "0.29.5", - "pg": "8.23.0" + "kysely": "0.29.4", + "pg": "8.22.0" }, "devDependencies": { - "@types/pg": "8.23.1" + "@types/pg": "8.21.0" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6630a77..dbef49b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,26 +25,26 @@ importers: packages/database-postgres: dependencies: kysely: - specifier: 0.29.5 - version: 0.29.5 + specifier: 0.29.4 + version: 0.29.4 pg: - specifier: 8.23.0 - version: 8.23.0 + specifier: 8.22.0 + version: 8.22.0 devDependencies: '@types/pg': - specifier: 8.23.1 - version: 8.23.1 + specifier: 8.21.0 + version: 8.21.0 packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} - '@types/pg@8.23.1': - resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/pg@8.21.0': + resolution: {integrity: sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==} - kysely@0.29.5: - resolution: {integrity: sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==} + kysely@0.29.4: + resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==} engines: {node: '>=22.0.0'} pg-cloudflare@1.4.0: @@ -69,8 +69,8 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.23.0: - resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -119,13 +119,13 @@ snapshots: dependencies: undici-types: 7.18.2 - '@types/pg@8.23.1': + '@types/pg@8.21.0': dependencies: '@types/node': 24.13.3 pg-protocol: 1.16.0 pg-types: 2.2.0 - kysely@0.29.5: {} + kysely@0.29.4: {} pg-cloudflare@1.4.0: optional: true @@ -134,9 +134,9 @@ snapshots: pg-int8@1.0.1: {} - pg-pool@3.14.0(pg@8.23.0): + pg-pool@3.14.0(pg@8.22.0): dependencies: - pg: 8.23.0 + pg: 8.22.0 pg-protocol@1.16.0: {} @@ -148,10 +148,10 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.23.0: + pg@8.22.0: dependencies: pg-connection-string: 2.14.0 - pg-pool: 3.14.0(pg@8.23.0) + pg-pool: 3.14.0(pg@8.22.0) pg-protocol: 1.16.0 pg-types: 2.2.0 pgpass: 1.0.5 diff --git a/tests/database-postgres-imports.test.mjs b/tests/database-postgres-imports.test.mjs index 1117f67..b857c8a 100644 --- a/tests/database-postgres-imports.test.mjs +++ b/tests/database-postgres-imports.test.mjs @@ -370,9 +370,12 @@ test('packages/database-postgres exists, is the driver owner, and its source imp assert.equal(manifest.name, '@personal-blog/database-postgres'); // The driver is owned here: exact pins, never ranges (reproducibility, // same policy as typescript@6.0.3 at the root). - assert.equal(manifest.dependencies?.pg, '8.23.0', 'owner must pin pg exactly'); - assert.equal(manifest.dependencies?.kysely, '0.29.5', 'owner must pin kysely exactly'); - assert.equal(manifest.devDependencies?.['@types/pg'], '8.23.1', 'owner must pin @types/pg exactly'); + // Golden tuple (Technology-Stack §5.2 / §7): pg 8.22.0, Kysely 0.29.4. + // @types/pg has no 8.22.x release; 8.21.0 is the closest published match + // (types for the immediately preceding pg minor). + assert.equal(manifest.dependencies?.pg, '8.22.0', 'owner must pin pg exactly'); + assert.equal(manifest.dependencies?.kysely, '0.29.4', 'owner must pin kysely exactly'); + assert.equal(manifest.devDependencies?.['@types/pg'], '8.21.0', 'owner must pin @types/pg exactly'); // The boundary module really imports the driver — the isolation is // exercised, not just declared. -- 2.54.0