Skip to content

Keep the test and snapshot harness out of production installs (0.4.0) - #72

Merged
nedtwigg merged 5 commits into
mainfrom
runtime-deps-off-test-harness
Oct 18, 2026
Merged

nedtwigg merged 5 commits into
mainfrom
runtime-deps-off-test-harness

Conversation

@nedtwigg

Copy link
Copy Markdown
Member

Problem

pgstencil 0.3.0 lists pg, node-pg-migrate, testcontainers, cheerio and turndown in dependencies. Only pg is used at request time. The rest belong to the Docker test-database harness, the migration runner and the HTML-to-Markdown snapshot lens. So any application that imports only the runtime entry points still installs the Docker client stack (dockerode, docker-modem, ssh2, ...), Cheerio and Turndown in production. Dormouse generates its supply-chain disclosure from the lockfile graph, so its Hosted section lists 323 packages while its Workers bundle about 33.

Design

I followed the import graph:

  • Runtime entry points: pgstencil, pgstencil/postgres and pgstencil/diagnostics, plus every @pgstencil/auth and @pgstencil/stripe export. None of them imports a tooling package.
  • Tooling entry points:
    • pgstencil/database uses testcontainers and node-pg-migrate.
    • pgstencil/testing uses both through database.
    • pgstencil/snapshots uses cheerio and turndown.
  • node-pg-migrate is tooling. migrate is a release step, never something a deployed server runs. The docs already say to "apply migrations as a separate release step", the workspace runs it from pnpm db:migrate, and Dormouse runs it from tsx server/db.ts.
  • pg stays runtime. pgstencil/postgres uses it at request time.

What changed:

  1. Optional peers. The four tooling packages move to peerDependencies, each marked optional in peerDependenciesMeta. They keep the same ranges, so the floor is still the version CI tests. pnpm and npm do not install optional peers automatically, so a production install leaves them out. The workspace keeps them as pgstencil devDependencies, the same pattern as kysely.

  2. Loaded on first use, with a clear error. Each tooling package is loaded through a new internal importPeer helper, using dynamic import():

    • testcontainers loads when ensureServices runs.
    • node-pg-migrate loads when migrate runs.
    • snapshots loads cheerio and turndown with a top-level await, so htmlToMarkdown stays synchronous.

    If a peer is missing, the error names the peer and the entry point that needs it, for example: pgstencil/database needs the optional peer dependency "testcontainers". Add it to your project, for example: pnpm add -D testcontainers.

    One side effect helps: migrating a persistent database, or only reading migration sources, no longer loads the Docker client.

I did not split out a separate package. Optional peers fix the install graph without changing the published API.

@pgstencil/auth and @pgstencil/stripe were checked and do not have this problem. Auth's better-auth, jose and openid-client are all used at runtime, and Stripe has no dependencies. Neither package changed beyond the version.

Consumer changes

  • No API change. Every export has the same names and signatures. The .d.ts files do not reference the tooling packages.

  • Install change, so this is a minor bump to 0.4.0. An application that imports pgstencil/database, pgstencil/testing or pgstencil/snapshots must now add the peers it uses, usually as devDependencies:

    pnpm add -D testcontainers node-pg-migrate cheerio turndown

    PACKAGES.md → "Tooling peers" lists which entry point loads which peer, and when. If a release step runs migrate from a production install, it needs node-pg-migrate, which peers on pg, as a regular dependency. It does not need testcontainers.

  • Dormouse Hosted, when it adopts the release, needs testcontainers and node-pg-migrate as devDependencies, for pgstencil/testing in its tests and developmentDatabase in server/dev.ts. Its db:migrate step needs only node-pg-migrate. Nothing in its Workers changes.

Consumer package count

I packed the archives and ran pnpm install --prod on them in a scratch project, counting distinct packages in node_modules/.pnpm:

Consumer 0.3.0 This branch
All three archives + kysely, hono, stripe 235 43
Dormouse's runtime pair: pgstencil + @pgstencil/auth + kysely, hono 233 41

After the change, none of testcontainers, node-pg-migrate, cheerio, turndown, dockerode, docker-modem or ssh2 is installed. In that same production install:

  • pgstencil, /postgres, /diagnostics, @pgstencil/auth/better-auth-workers and @pgstencil/stripe all import.
  • pgstencil/database imports, and readMigrations works.
  • migrate(), ensureServices() and import('pgstencil/snapshots') each fail with the clear message.

Regression check

  • scripts/runtime-deps.test.mjs (run by pnpm test:scripts) fails if:

    • a package lists a tooling package in dependencies or optionalDependencies;
    • pgstencil stops declaring one as an optional peer;
    • any published entry point reaches one through a static import, directly or through workspace or third-party code.

    It bundles each export with esbuild and records each place a tooling package is imported, and how. It also requires that each tooling package is still seen as a dynamic import, so the test cannot pass vacuously. The list lives in scripts/tooling-peers.json.

  • pnpm packages:verify now also installs the packed archives with --prod, with peers auto-installed, into a separate runtime-only consumer. It fails if any of the listed packages lands there. The existing full consumer now installs the tooling peers as devDependencies and still runs the login and billing flow end to end.

Mutation results (each reverted afterwards):

Mutation Result
testcontainers moved back to dependencies the test fails, and so does packages:verify ("A production install of the packages includes testcontainers")
Static node-pg-migrate import in migrations.ts, re-exported from postgres.ts fails, listing pgstencil/postgres, pgstencil/database, pgstencil/testing and three auth entries that reach it through pgstencil/postgres
import 'cheerio' in diagnostics.ts fails, listing pgstencil/diagnostics and three auth entries
cheerio dynamic import turned non-literal the vacuity guard fails

Test results

All of these ran locally on Node 26 with Docker running, and all passed:

  • pnpm check: format, typecheck, 30 script tests, and 24 Vitest files / 187 tests, including the Docker integration suite
  • pnpm db:verify
  • pnpm --filter @pgstencil/example-workers build
  • pnpm packages:verify

CI runs Node 24.

Release

The last commit raises all three packages to 0.4.0, the same way #54 cut 0.3.0. After merge, release.yml stages the packages, and a human approves them with 2FA. Nothing has been published or tagged.

🤖 Generated with Claude Code

nedtwigg and others added 5 commits October 18, 2026 16:24
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@nedtwigg
nedtwigg marked this pull request as ready for review October 18, 2026 23:56
@nedtwigg
nedtwigg merged commit ddf68ea into main Oct 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant