diff --git a/PACKAGES.md b/PACKAGES.md index 30575e1..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. @@ -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/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/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/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). diff --git a/packages/pgstencil/package.json b/packages/pgstencil/package.json index c06a0e4..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", @@ -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/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/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", 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/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 e98c092..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'; @@ -15,17 +22,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,22 +54,70 @@ 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; } } +// 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( @@ -60,6 +128,7 @@ await writeFile( packageManager, dependencies: { ...dependencies, ...peers }, devDependencies: { + ...toolingPeers, '@types/node': '24.13.3', typescript: typescriptVersion, }, 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); +});