From 7470ee6f12e55d8c836ec35abb6e6939e6841e92 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 18 Oct 2026 16:24:56 -0700 Subject: [PATCH 1/5] Load pgstencil's tooling packages only when tooling needs them `pgstencil/database` now imports testcontainers when it starts or reattaches local services and node-pg-migrate when `migrate` runs, so reading migration sources or migrating a persistent database no longer loads the Docker client. `pgstencil/snapshots` loads cheerio and turndown at import. Each goes through `importPeer`, which names the missing package and the entry point that needs it, ready for these packages to become optional peers. Co-Authored-By: Claude Opus 5.5 --- packages/pgstencil/src/database.ts | 13 ++++++++----- packages/pgstencil/src/migrations.ts | 7 ++++++- packages/pgstencil/src/peer.ts | 27 +++++++++++++++++++++++++++ packages/pgstencil/src/snapshots.ts | 7 +++++-- tests/unit/peer.test.ts | 25 +++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 packages/pgstencil/src/peer.ts create mode 100644 tests/unit/peer.test.ts diff --git a/packages/pgstencil/src/database.ts b/packages/pgstencil/src/database.ts index aaf27b3..da5d2f8 100644 --- a/packages/pgstencil/src/database.ts +++ b/packages/pgstencil/src/database.ts @@ -1,15 +1,12 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join, dirname, basename } from 'node:path'; -import { - DockerComposeEnvironment, - Wait, - type StartedDockerComposeEnvironment, -} from 'testcontainers'; +import type { StartedDockerComposeEnvironment } from 'testcontainers'; import pg from 'pg'; import { queryDatabase, withClient } from './postgres.ts'; export { connectDatabase, queryDatabase, withClient } from './postgres.ts'; import { migrate, migrationFingerprint, readMigrations } from './migrations.ts'; import { withProcessLock } from './lock.ts'; +import { importPeer } from './peer.ts'; import { composeFile, projectName, @@ -59,6 +56,12 @@ export function ensureServices(): Promise { servicesPromise ??= withProcessLock( join(stateDirectory, 'services.lock'), async () => { + // Loaded even when reattaching, so a missing peer fails the same way every run. + const { DockerComposeEnvironment, Wait } = await importPeer( + 'testcontainers', + 'pgstencil/database', + () => import('testcontainers'), + ); const statePath = join(stateDirectory, 'services.json'); try { const state = JSON.parse(await readFile(statePath, 'utf8')) as Services; diff --git a/packages/pgstencil/src/migrations.ts b/packages/pgstencil/src/migrations.ts index 7fb5870..ac19b86 100644 --- a/packages/pgstencil/src/migrations.ts +++ b/packages/pgstencil/src/migrations.ts @@ -3,8 +3,8 @@ import { readdir, readFile, mkdtemp, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import pg from 'pg'; -import { runner } from 'node-pg-migrate'; import { withClient } from './postgres.ts'; +import { importPeer } from './peer.ts'; export interface MigrationFile { /** Ordering is enforced within each directory; package sources evolve independently. */ source: string; @@ -118,6 +118,11 @@ export async function migrate( url: string, files: MigrationFile[], ): Promise { + const { runner } = await importPeer( + 'node-pg-migrate', + 'pgstencil/database', + () => import('node-pg-migrate'), + ); const client = new pg.Client({ connectionString: url }); await client.connect(); let temporary: string | undefined; diff --git a/packages/pgstencil/src/peer.ts b/packages/pgstencil/src/peer.ts new file mode 100644 index 0000000..cc3af86 --- /dev/null +++ b/packages/pgstencil/src/peer.ts @@ -0,0 +1,27 @@ +/** + * Loads an optional peer dependency. Tooling entry points reach `testcontainers`, + * `node-pg-migrate`, `cheerio` and `turndown` only through here, so a runtime + * install never needs them and a tooling install that lacks one is told which + * package to add. `scripts/runtime-deps.test.mjs` fails on a static import. + */ +export async function importPeer( + name: string, + entry: string, + load: () => Promise, +): Promise { + try { + return await load(); + } catch (error) { + // Only the peer itself being absent; a broken install of it surfaces as-is. + const code = (error as { code?: unknown } | null)?.code; + if ( + code === 'ERR_MODULE_NOT_FOUND' && + String((error as Error).message).includes(`'${name}'`) + ) + throw new Error( + `${entry} needs the optional peer dependency "${name}". Add it to your project, for example: pnpm add -D ${name}`, + { cause: error }, + ); + throw error; + } +} diff --git a/packages/pgstencil/src/snapshots.ts b/packages/pgstencil/src/snapshots.ts index 1f017fb..657436d 100644 --- a/packages/pgstencil/src/snapshots.ts +++ b/packages/pgstencil/src/snapshots.ts @@ -1,6 +1,9 @@ -import { load } from 'cheerio'; -import TurndownService from 'turndown'; import type { CapturedEmail } from './email.ts'; +import { importPeer } from './peer.ts'; +const [{ load }, { default: TurndownService }] = await Promise.all([ + importPeer('cheerio', 'pgstencil/snapshots', () => import('cheerio')), + importPeer('turndown', 'pgstencil/snapshots', () => import('turndown')), +]); export function stableJson(value: unknown): string { return ( JSON.stringify( diff --git a/tests/unit/peer.test.ts b/tests/unit/peer.test.ts new file mode 100644 index 0000000..1784619 --- /dev/null +++ b/tests/unit/peer.test.ts @@ -0,0 +1,25 @@ +import { test, expect } from 'vitest'; +import { importPeer } from '../../packages/pgstencil/src/peer.ts'; + +test('A missing optional peer names itself and the entry point that needs it', async () => { + const name = 'pgstencil-absent-peer'; + await expect( + importPeer(name, 'pgstencil/database', () => import(name)), + ).rejects.toThrow( + 'pgstencil/database needs the optional peer dependency "pgstencil-absent-peer". Add it to your project, for example: pnpm add -D pgstencil-absent-peer', + ); +}); + +test('A peer that is installed but cannot load its own dependency keeps the original error', async () => { + const broken = Object.assign( + new Error( + "Cannot find package 'docker-modem' imported from testcontainers", + ), + { code: 'ERR_MODULE_NOT_FOUND' }, + ); + await expect( + importPeer('testcontainers', 'pgstencil/database', () => + Promise.reject(broken), + ), + ).rejects.toBe(broken); +}); From 832e08c338ad533245f1fe843e5a2d1c63d9d076 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 18 Oct 2026 16:24:56 -0700 Subject: [PATCH 2/5] Make pgstencil's test and snapshot tooling optional peers testcontainers, node-pg-migrate, cheerio and turndown move from pgstencil's dependencies to optional peerDependencies, so an application that imports only the runtime entry points no longer installs the Docker client stack, the migration runner or the HTML-to-Markdown converter. pg stays a dependency: `pgstencil/postgres` uses it at request time. The workspace keeps them as pgstencil devDependencies, the examples that start local databases declare the two they load, and the packed consumer in `packages:verify` installs the optional peers (and node-pg-migrate's pg peer) as devDependencies, the way a tooling user would. Co-Authored-By: Claude Opus 5.5 --- examples/better-auth/package.json | 4 +++ examples/login/package.json | 4 +++ packages/pgstencil/package.json | 26 +++++++++++++--- pnpm-lock.yaml | 28 ++++++++++++----- scripts/verify-packages.ts | 52 +++++++++++++++++++------------ 5 files changed, 83 insertions(+), 31 deletions(-) diff --git a/examples/better-auth/package.json b/examples/better-auth/package.json index 6e7026c..79d5afc 100644 --- a/examples/better-auth/package.json +++ b/examples/better-auth/package.json @@ -10,5 +10,9 @@ "pg": "^8.16.0", "pgstencil": "workspace:*", "@pgstencil/auth": "workspace:*" + }, + "devDependencies": { + "node-pg-migrate": "^9.0.0", + "testcontainers": "^12.2.1" } } diff --git a/examples/login/package.json b/examples/login/package.json index f70a120..3556c48 100644 --- a/examples/login/package.json +++ b/examples/login/package.json @@ -11,5 +11,9 @@ "@pgstencil/auth": "workspace:*", "hono": "^4.13.7", "stripe": "^22.6.1" + }, + "devDependencies": { + "node-pg-migrate": "^9.0.0", + "testcontainers": "^12.2.1" } } diff --git a/packages/pgstencil/package.json b/packages/pgstencil/package.json index c06a0e4..eca3f13 100644 --- a/packages/pgstencil/package.json +++ b/packages/pgstencil/package.json @@ -11,17 +11,35 @@ "./diagnostics": "./src/diagnostics.ts" }, "dependencies": { - "pg": "^8.23.1", + "pg": "^8.23.1" + }, + "peerDependencies": { + "kysely": "^0.29.5", "node-pg-migrate": "^9.0.0", "testcontainers": "^12.2.1", "cheerio": "^1.2.0", "turndown": "^7.2.4" }, - "peerDependencies": { - "kysely": "^0.29.5" + "peerDependenciesMeta": { + "node-pg-migrate": { + "optional": true + }, + "testcontainers": { + "optional": true + }, + "cheerio": { + "optional": true + }, + "turndown": { + "optional": true + } }, "devDependencies": { - "kysely": "^0.29.5" + "kysely": "^0.29.5", + "node-pg-migrate": "^9.0.0", + "testcontainers": "^12.2.1", + "cheerio": "^1.2.0", + "turndown": "^7.2.4" }, "engines": { "node": ">=24" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c47eba..b15d1c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,13 @@ importers: pgstencil: specifier: workspace:* version: link:../../packages/pgstencil + devDependencies: + node-pg-migrate: + specifier: ^9.0.0 + version: 9.0.0(@types/pg@8.23.1)(pg@8.23.1) + testcontainers: + specifier: ^12.2.1 + version: 12.2.1(supports-color@10.2.2) examples/login: dependencies: @@ -253,6 +260,13 @@ importers: stripe: specifier: ^22.6.1 version: 22.6.2(@types/node@24.19.2) + devDependencies: + node-pg-migrate: + specifier: ^9.0.0 + version: 9.0.0(@types/pg@8.23.1)(pg@8.23.1) + testcontainers: + specifier: ^12.2.1 + version: 12.2.1(supports-color@10.2.2) examples/workers: dependencies: @@ -293,25 +307,25 @@ importers: packages/pgstencil: dependencies: + pg: + specifier: ^8.23.1 + version: 8.23.1 + devDependencies: cheerio: specifier: ^1.2.0 version: 1.2.0 + kysely: + specifier: ^0.29.5 + version: 0.29.6 node-pg-migrate: specifier: ^9.0.0 version: 9.0.0(@types/pg@8.23.1)(pg@8.23.1) - pg: - specifier: ^8.23.1 - version: 8.23.1 testcontainers: specifier: ^12.2.1 version: 12.2.1(supports-color@10.2.2) turndown: specifier: ^7.2.4 version: 7.2.4 - devDependencies: - kysely: - specifier: ^0.29.5 - version: 0.29.6 packages/stripe: devDependencies: diff --git a/scripts/verify-packages.ts b/scripts/verify-packages.ts index e98c092..0d5d80f 100644 --- a/scripts/verify-packages.ts +++ b/scripts/verify-packages.ts @@ -15,17 +15,30 @@ const { version: typescriptVersion } = JSON.parse( 'utf8', ), ) as { version: string }; +interface Manifest { + version: string; + peerDependencies?: Record; + peerDependenciesMeta?: Record; +} +const readManifest = async (path: string) => + JSON.parse(await readFile(join(path, 'package.json'), 'utf8')) as Manifest; const dependencies: Record = {}; // The consumer owns every shared library, at the version this workspace tests. const peers: Record = {}; +// Optional peers only the tooling entry points load (PACKAGES.md -> Dependencies), +// plus the peers they bring. +const toolingPeers: Record = {}; for (const name of ['pgstencil', '@pgstencil/auth', '@pgstencil/stripe']) { const packageDirectory = name === 'pgstencil' ? name : name.split('/')[1]!; - const manifest = JSON.parse( - await readFile( - join(projectRoot, 'packages', packageDirectory, 'package.json'), - 'utf8', - ), - ) as { version: string; peerDependencies?: Record }; + const modules = join( + projectRoot, + 'packages', + packageDirectory, + 'node_modules', + ); + const manifest = await readManifest( + join(projectRoot, 'packages', packageDirectory), + ); const file = `${name.replace('@', '').replace('/', '-')}-${manifest.version}.tgz`; await cp( join(projectRoot, 'dist/packages', file), @@ -34,20 +47,18 @@ for (const name of ['pgstencil', '@pgstencil/auth', '@pgstencil/stripe']) { dependencies[name] = `file:./vendor/${file}`; for (const peer of Object.keys(manifest.peerDependencies ?? {})) { if (peer === 'pgstencil') continue; - const installed = JSON.parse( - await readFile( - join( - projectRoot, - 'packages', - packageDirectory, - 'node_modules', - peer, - 'package.json', - ), - 'utf8', - ), - ) as { version: string }; - peers[peer] = installed.version; + const installed = await readManifest(join(modules, peer)); + if (!manifest.peerDependenciesMeta?.[peer]?.optional) { + peers[peer] = installed.version; + continue; + } + toolingPeers[peer] = installed.version; + // node-pg-migrate peers on pg, which this workspace provides as pgstencil's own. + for (const inner of Object.keys(installed.peerDependencies ?? {})) + if (!installed.peerDependenciesMeta?.[inner]?.optional) + toolingPeers[inner] ??= ( + await readManifest(join(modules, inner)) + ).version; } } await writeFile( @@ -60,6 +71,7 @@ await writeFile( packageManager, dependencies: { ...dependencies, ...peers }, devDependencies: { + ...toolingPeers, '@types/node': '24.13.3', typescript: typescriptVersion, }, From 30de0606735b5ca317589b031a32f9ec7b1a629d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 18 Oct 2026 16:24:56 -0700 Subject: [PATCH 3/5] Fail when tooling packages reach a production install `scripts/runtime-deps.test.mjs` (in `pnpm test:scripts`) fails if any package lists testcontainers, node-pg-migrate, cheerio or turndown in dependencies or optionalDependencies, if pgstencil stops declaring them optional peers, or if any published entry point reaches one by a static import, through workspace or third-party code. It bundles each entry with esbuild and also requires that each tooling package is still seen dynamically, so it cannot pass vacuously. `packages:verify` now also installs the packed archives with `--prod` into a runtime-only consumer, with peers auto-installed, and fails if an optional peer lands there. Co-Authored-By: Claude Opus 5.5 --- scripts/runtime-deps.test.mjs | 130 ++++++++++++++++++++++++++++++++++ scripts/tooling-peers.json | 1 + scripts/verify-packages.ts | 59 ++++++++++++++- 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 scripts/runtime-deps.test.mjs create mode 100644 scripts/tooling-peers.json diff --git a/scripts/runtime-deps.test.mjs b/scripts/runtime-deps.test.mjs new file mode 100644 index 0000000..40201b9 --- /dev/null +++ b/scripts/runtime-deps.test.mjs @@ -0,0 +1,130 @@ +/** + * Keeps pgstencil's Docker, migration and snapshot tooling out of an + * application's production install. See PACKAGES.md -> "Dependencies". + */ + +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const repo = fileURLToPath(new URL('..', import.meta.url)); + +/** + * Only `pgstencil/database`, `pgstencil/testing` and `pgstencil/snapshots` use + * these. `packages:verify` reads the same list for its runtime-only install. + */ +const TOOLING = JSON.parse( + readFileSync(join(repo, 'scripts/tooling-peers.json'), 'utf8'), +); + +const packages = readdirSync(join(repo, 'packages')).map((name) => { + const dir = join(repo, 'packages', name); + return { + dir, + manifest: JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')), + }; +}); + +test('tooling packages are optional peers of pgstencil, never installed with it', () => { + for (const { manifest } of packages) + for (const field of ['dependencies', 'optionalDependencies']) + for (const name of TOOLING) + assert.ok( + !(name in (manifest[field] ?? {})), + `${manifest.name} lists ${name} in ${field}, so every production install gets it; declare it an optional peer`, + ); + const core = packages.find(({ manifest }) => manifest.name === 'pgstencil'); + for (const name of TOOLING) { + assert.ok( + core.manifest.peerDependencies?.[name], + `pgstencil must declare ${name} as a peer`, + ); + assert.equal( + core.manifest.peerDependenciesMeta?.[name]?.optional, + true, + `pgstencil's ${name} peer must be optional`, + ); + } +}); + +/** Every specifier esbuild follows, by kind, from one published entry point. */ +async function tooling(entry) { + const seen = []; + const followed = Symbol('followed'); + await build({ + entryPoints: [entry], + bundle: true, + platform: 'node', + format: 'esm', + write: false, + logLevel: 'silent', + absWorkingDir: repo, + external: ['*.node'], + plugins: [ + { + name: 'tooling-reach', + setup(build) { + const pattern = new RegExp(`^(${TOOLING.join('|')})(/|$)`); + build.onResolve({ filter: pattern }, (args) => { + seen.push({ + name: args.path.match(pattern)[1], + kind: args.kind, + importer: relative(repo, args.importer), + }); + return { path: args.path, external: true }; + }); + // Follow third-party code as well, so a private dependency that + // imports tooling counts. A module it cannot resolve, such as an + // optional database driver, stays external. + build.onResolve({ filter: /^[^./]/ }, async (args) => { + if (args.pluginData === followed) return undefined; + const result = await build.resolve(args.path, { + kind: args.kind, + importer: args.importer, + resolveDir: args.resolveDir, + pluginData: followed, + }); + return result.errors.length + ? { path: args.path, external: true } + : result; + }); + }, + }, + ], + }); + return seen; +} + +const entries = packages.flatMap(({ dir, manifest }) => + Object.entries(manifest.exports).map(([subpath, file]) => ({ + name: join(manifest.name, subpath), + file: join(dir, file), + })), +); + +test('no entry point statically reaches a tooling package', async () => { + const reached = await Promise.all( + entries.map(async ({ name, file }) => ({ + name, + seen: await tooling(file), + })), + ); + const violations = reached.flatMap(({ name, seen }) => + seen + .filter(({ kind }) => kind !== 'dynamic-import') + .map( + ({ name: tool, kind, importer }) => + `${name} reaches ${tool} by ${kind} in ${importer}; load it through importPeer`, + ), + ); + assert.deepEqual(violations, []); + // The guard sees each tooling package from the entry points that need it, + // so an esbuild or layout change cannot turn this test vacuous. + const dynamic = new Set( + reached.flatMap(({ seen }) => seen.map(({ name }) => name)), + ); + assert.deepEqual([...dynamic].sort(), [...TOOLING].sort()); +}); diff --git a/scripts/tooling-peers.json b/scripts/tooling-peers.json new file mode 100644 index 0000000..9c238dd --- /dev/null +++ b/scripts/tooling-peers.json @@ -0,0 +1 @@ +["testcontainers", "node-pg-migrate", "cheerio", "turndown"] diff --git a/scripts/verify-packages.ts b/scripts/verify-packages.ts index 0d5d80f..c7f87fb 100644 --- a/scripts/verify-packages.ts +++ b/scripts/verify-packages.ts @@ -1,5 +1,12 @@ import { execFileSync } from 'node:child_process'; -import { cp, mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { + cp, + mkdtemp, + mkdir, + readdir, + readFile, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { projectRoot } from '../packages/pgstencil/src/paths.ts'; @@ -61,6 +68,56 @@ for (const name of ['pgstencil', '@pgstencil/auth', '@pgstencil/stripe']) { ).version; } } +// An application that imports only runtime entry points, installed the way it +// deploys: none of the tooling may land, even with peers auto-installed. +const runtime = await mkdtemp(join(tmpdir(), 'pgstencil-runtime-')); +await cp(join(directory, 'vendor'), join(runtime, 'vendor'), { + recursive: true, +}); +await writeFile( + join(runtime, 'package.json'), + JSON.stringify({ + name: 'runtime-consumer', + private: true, + type: 'module', + packageManager, + dependencies: { ...dependencies, ...peers }, + }), +); +await writeFile( + join(runtime, 'pnpm-workspace.yaml'), + JSON.stringify({ + overrides: dependencies, + strictPeerDependencies: true, + peerDependencyRules: { allowAny: ['pgstencil'] }, + }), +); +execFileSync('pnpm', ['install', '--prod', '--ignore-scripts'], { + cwd: runtime, + stdio: 'inherit', +}); +// Named independently of the manifests, so a tooling package that moves back to +// dependencies still counts as a leak. +const tooling = JSON.parse( + await readFile(join(projectRoot, 'scripts/tooling-peers.json'), 'utf8'), +) as string[]; +const runtimePackages = new Set( + (await readdir(join(runtime, 'node_modules/.pnpm'))) + .filter((entry) => entry.lastIndexOf('@') > 0) + .map((entry) => entry.replace(/_.*/, '')), +); +const leaked = tooling.filter((peer) => + [...runtimePackages].some((entry) => + entry.startsWith(`${peer.replace('/', '+')}@`), + ), +); +if (leaked.length) + throw new Error( + `A production install of the packages includes ${leaked.join(', ')} (${runtime})`, + ); +console.log( + `Runtime-only consumer: ${runtimePackages.size} packages, none of ${tooling.join(', ')} (${runtime})`, +); await writeFile( join(directory, 'package.json'), JSON.stringify( From 49c320324a1e5ef87cd6ff33a4456008bc74719c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 18 Oct 2026 16:27:07 -0700 Subject: [PATCH 4/5] Document which tooling entry point needs which optional peer PACKAGES.md gains a "Tooling peers" section: the peers `pgstencil/database`, `pgstencil/testing` and `pgstencil/snapshots` load, when each loads, the one install line, and that migrating a persistent database needs node-pg-migrate but not testcontainers. The package README and the root README point at it. Co-Authored-By: Claude Opus 5.5 --- PACKAGES.md | 19 ++++++++++++++++++- README.md | 2 +- packages/pgstencil/README.md | 4 +++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/PACKAGES.md b/PACKAGES.md index 30575e1..abab969 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -16,6 +16,23 @@ Applications declare pgstencil's peer dependencies themselves: `kysely` for ever Every other dependency is private: pgstencil owns its version and applications do not import it. Better Auth is deliberately private. pgstencil imports its internal subpaths and tests login and linking rules against specific releases, so it is pinned to the exact release CI tested. Even a patch can change internals pgstencil reads (1.7.7 renamed its OAuth state rows), so each Better Auth upgrade reaches applications only through a pgstencil release. A new login provider or Better Auth plugin belongs in `@pgstencil/auth`, not in an application. Private ranges start at the version pgstencil's CI tested. [`.github/renovate.json`](.github/renovate.json) raises that floor, and the next release carries it into each application. +### Tooling peers + +pgstencil's test, migration and snapshot tooling loads optional peers, so a production install leaves them out. No entry point imports them statically, and only three load them; `scripts/runtime-deps.test.mjs` and `pnpm packages:verify` fail otherwise. An application using the tooling adds the peers it needs, usually as devDependencies: + +| Entry point | Optional peer | Loaded when | +| --------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `pgstencil/database` | `testcontainers` | Local services start or reattach: `ensureServices`, `prepareTemplate`, `allocateDatabase`, `developmentDatabase` | +| `pgstencil/database` | `node-pg-migrate` | `migrate` runs, including inside `allocateDatabase` and `developmentDatabase` | +| `pgstencil/testing` | both of the above | `createTestContext` | +| `pgstencil/snapshots` | `cheerio`, `turndown` | The module is imported | + +```sh +pnpm add -D testcontainers node-pg-migrate cheerio turndown +``` + +Reading migration sources (`defaultMigrations`, `readMigrations`, `validateMigrations`, `appliedMigrations`) needs none of them. A release step that migrates a persistent database needs `node-pg-migrate`, which peers on `pg`, but not `testcontainers`; make it a regular dependency if that step runs from a production install. A missing peer fails with an error naming the package and the entry point that needs it. The peer ranges start at the versions pgstencil's CI tests, and pgstencil widens them as for any peer. + ## Releasing The three packages share one version. A release is a pull request that raises `version` in all three `package.json` files: a patch for a fix or compatible addition, a minor for a breaking change, since these are 0.x. Nothing else triggers one. @@ -34,7 +51,7 @@ Version 0.2.0 was published by hand from commit `e79cc4d`, after its audit passe ## Application composition -Production imports use `pgstencil`, `pgstencil/postgres`, `@pgstencil/auth` and `@pgstencil/stripe`. Docker startup and test fixtures are opt-in imports through `pgstencil/database`, `pgstencil/testing`, and `@pgstencil/stripe/testing`. +Production imports use `pgstencil`, `pgstencil/postgres`, `pgstencil/diagnostics`, `@pgstencil/auth` and `@pgstencil/stripe`. Docker startup and test fixtures are opt-in imports through `pgstencil/database`, `pgstencil/testing`, and `@pgstencil/stripe/testing`; the first two need the [tooling peers](#tooling-peers). The database tooling uses the current working directory as the project root, or `PGSTENCIL_PROJECT_ROOT` when explicitly set. State goes in that project's `.pgstencil`; its Docker Compose project name derives from the project path. A project can supply `compose.yaml`, otherwise the package's bundled default is used. The default SQL directory is `migrations`, overridable by a `pgstencil.json` file containing a `migrations` directory path inside the project root; a path resolving outside it is refused. Applications composing packages should pass their complete source list explicitly: diff --git a/README.md b/README.md index c787936..74b5fbb 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Use `PORT=3000 pnpm dev` for a fixed port. Development uses real time so cooldow | Email | Injected `EmailSender`, with `EmailDev` capture, waiting, unread checks and preview routes | | Snapshots | Vitest file snapshots plus local JSON, response, HTML, Markdown and email captures | -The workspace contains `pgstencil`, `@pgstencil/auth`, and `@pgstencil/stripe`. They are published to npm as 0.x releases; [PACKAGES.md](PACKAGES.md) explains installing them and consuming unreleased changes through compiled local tarballs. The core package's `pgstencil/postgres` export is the runtime connection layer; `pgstencil/database` and `pgstencil/testing` include local Docker infrastructure. `examples/login` is a complete consumer, using Node's HTTP server and native HTML forms. No frontend framework is required. +The workspace contains `pgstencil`, `@pgstencil/auth`, and `@pgstencil/stripe`. They are published to npm as 0.x releases; [PACKAGES.md](PACKAGES.md) explains installing them and consuming unreleased changes through compiled local tarballs. The core package's `pgstencil/postgres` export is the runtime connection layer; `pgstencil/database` and `pgstencil/testing` include local Docker infrastructure, and with `pgstencil/snapshots` they load [optional peers](PACKAGES.md#tooling-peers) that a production install leaves out. `examples/login` is a complete consumer, using Node's HTTP server and native HTML forms. No frontend framework is required. Hono and Cloudflare Workers are supported through the shared Fetch adapter and request-scoped Hyperdrive connections. See [WORKERS.md](WORKERS.md) for the deployable example, local runtime tests, and deployment preparation. diff --git a/packages/pgstencil/README.md b/packages/pgstencil/README.md index 375b813..3174a75 100644 --- a/packages/pgstencil/README.md +++ b/packages/pgstencil/README.md @@ -6,6 +6,8 @@ Postgres schema migrations, isolated test databases, and deterministic snapshot ```sh pnpm add pgstencil kysely +# Only for pgstencil/database, pgstencil/testing and pgstencil/snapshots: +pnpm add -D testcontainers node-pg-migrate cheerio turndown ``` -`kysely` is a peer dependency: your application owns its version and shares one copy with pgstencil. See [PACKAGES.md](https://github.com/diffplug/pgstencil/blob/main/PACKAGES.md) for usage and the dependency policy, and [SECURITY.md](https://github.com/diffplug/pgstencil/blob/main/SECURITY.md) for the audited security rules. Source: [diffplug/pgstencil](https://github.com/diffplug/pgstencil). +`kysely` is a peer dependency: your application owns its version and shares one copy with pgstencil. The test, migration and snapshot tooling loads optional peers, so a production install of the runtime entry points never carries the Docker client or the HTML converter; [PACKAGES.md](https://github.com/diffplug/pgstencil/blob/main/PACKAGES.md#tooling-peers) lists which entry point needs which. See [PACKAGES.md](https://github.com/diffplug/pgstencil/blob/main/PACKAGES.md) for usage and the dependency policy, and [SECURITY.md](https://github.com/diffplug/pgstencil/blob/main/SECURITY.md) for the audited security rules. Source: [diffplug/pgstencil](https://github.com/diffplug/pgstencil). From 6cb1ca7e6faa54106232cd295a9d1ce58450e14c Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 18 Oct 2026 16:27:07 -0700 Subject: [PATCH 5/5] Release pgstencil packages at 0.4.0 Minor bump: an application that uses `pgstencil/database`, `pgstencil/testing` or `pgstencil/snapshots` must now install testcontainers, node-pg-migrate, cheerio and turndown itself (PACKAGES.md -> Tooling peers). Co-Authored-By: Claude Opus 5.5 --- PACKAGES.md | 2 +- packages/auth/package.json | 2 +- packages/pgstencil/package.json | 2 +- packages/stripe/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PACKAGES.md b/PACKAGES.md index abab969..4d4a9dc 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -1,6 +1,6 @@ # Consuming pgstencil -Three packages share version 0.3.0: `pgstencil` (infrastructure and test primitives), `@pgstencil/auth` (email/OAuth/session services and a JSON HTTP adapter), and `@pgstencil/stripe` (SaaS billing and StripeDev). They are published to npm as 0.x releases, so a minor release may break compatibility. +Three packages share version 0.4.0: `pgstencil` (infrastructure and test primitives), `@pgstencil/auth` (email/OAuth/session services and a JSON HTTP adapter), and `@pgstencil/stripe` (SaaS billing and StripeDev). They are published to npm as 0.x releases, so a minor release may break compatibility. Run `pnpm packages:pack` to produce the three archives in `dist/packages`. They contain ESM JavaScript, TypeScript declarations, the MIT license, and required SQL/Compose assets. Workspace development uses source exports; packing switches the exports to compiled files. Nothing runs migrations during install. diff --git a/packages/auth/package.json b/packages/auth/package.json index 8b78e5d..9bd4e52 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@pgstencil/auth", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/pgstencil/package.json b/packages/pgstencil/package.json index eca3f13..3f69c9f 100644 --- a/packages/pgstencil/package.json +++ b/packages/pgstencil/package.json @@ -1,6 +1,6 @@ { "name": "pgstencil", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/stripe/package.json b/packages/stripe/package.json index 1ddf3b2..2e73a4f 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -1,6 +1,6 @@ { "name": "@pgstencil/stripe", - "version": "0.3.0", + "version": "0.4.0", "type": "module", "exports": { ".": "./src/index.ts",