Skip to content

Commit ddf68ea

Browse files
authored
Keep the test and snapshot harness out of production installs (0.4.0) (#72)
2 parents afe9dd9 + 6cb1ca7 commit ddf68ea

17 files changed

Lines changed: 369 additions & 47 deletions

File tree

‎PACKAGES.md‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Consuming pgstencil
22

3-
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.
3+
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.
44

55
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.
66

@@ -16,6 +16,23 @@ Applications declare pgstencil's peer dependencies themselves: `kysely` for ever
1616

1717
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.
1818

19+
### Tooling peers
20+
21+
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:
22+
23+
| Entry point | Optional peer | Loaded when |
24+
| --------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------- |
25+
| `pgstencil/database` | `testcontainers` | Local services start or reattach: `ensureServices`, `prepareTemplate`, `allocateDatabase`, `developmentDatabase` |
26+
| `pgstencil/database` | `node-pg-migrate` | `migrate` runs, including inside `allocateDatabase` and `developmentDatabase` |
27+
| `pgstencil/testing` | both of the above | `createTestContext` |
28+
| `pgstencil/snapshots` | `cheerio`, `turndown` | The module is imported |
29+
30+
```sh
31+
pnpm add -D testcontainers node-pg-migrate cheerio turndown
32+
```
33+
34+
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.
35+
1936
## Releasing
2037

2138
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
3451

3552
## Application composition
3653

37-
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`.
54+
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).
3855

3956
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:
4057

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Use `PORT=3000 pnpm dev` for a fixed port. Development uses real time so cooldow
3232
| Email | Injected `EmailSender`, with `EmailDev` capture, waiting, unread checks and preview routes |
3333
| Snapshots | Vitest file snapshots plus local JSON, response, HTML, Markdown and email captures |
3434

35-
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.
35+
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.
3636

3737
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.
3838

‎examples/better-auth/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,9 @@
1010
"pg": "^8.16.0",
1111
"pgstencil": "workspace:*",
1212
"@pgstencil/auth": "workspace:*"
13+
},
14+
"devDependencies": {
15+
"node-pg-migrate": "^9.0.0",
16+
"testcontainers": "^12.2.1"
1317
}
1418
}

‎examples/login/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,9 @@
1111
"@pgstencil/auth": "workspace:*",
1212
"hono": "^4.13.7",
1313
"stripe": "^22.6.1"
14+
},
15+
"devDependencies": {
16+
"node-pg-migrate": "^9.0.0",
17+
"testcontainers": "^12.2.1"
1418
}
1519
}

‎packages/auth/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pgstencil/auth",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"type": "module",
55
"exports": {
66
".": "./src/index.ts",

‎packages/pgstencil/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Postgres schema migrations, isolated test databases, and deterministic snapshot
66
77
```sh
88
pnpm add pgstencil kysely
9+
# Only for pgstencil/database, pgstencil/testing and pgstencil/snapshots:
10+
pnpm add -D testcontainers node-pg-migrate cheerio turndown
911
```
1012

11-
`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).
13+
`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).

‎packages/pgstencil/package.json‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "pgstencil",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"type": "module",
55
"exports": {
66
".": "./src/index.ts",
@@ -11,17 +11,35 @@
1111
"./diagnostics": "./src/diagnostics.ts"
1212
},
1313
"dependencies": {
14-
"pg": "^8.23.1",
14+
"pg": "^8.23.1"
15+
},
16+
"peerDependencies": {
17+
"kysely": "^0.29.5",
1518
"node-pg-migrate": "^9.0.0",
1619
"testcontainers": "^12.2.1",
1720
"cheerio": "^1.2.0",
1821
"turndown": "^7.2.4"
1922
},
20-
"peerDependencies": {
21-
"kysely": "^0.29.5"
23+
"peerDependenciesMeta": {
24+
"node-pg-migrate": {
25+
"optional": true
26+
},
27+
"testcontainers": {
28+
"optional": true
29+
},
30+
"cheerio": {
31+
"optional": true
32+
},
33+
"turndown": {
34+
"optional": true
35+
}
2236
},
2337
"devDependencies": {
24-
"kysely": "^0.29.5"
38+
"kysely": "^0.29.5",
39+
"node-pg-migrate": "^9.0.0",
40+
"testcontainers": "^12.2.1",
41+
"cheerio": "^1.2.0",
42+
"turndown": "^7.2.4"
2543
},
2644
"engines": {
2745
"node": ">=24"

‎packages/pgstencil/src/database.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import { mkdir, readFile, writeFile } from 'node:fs/promises';
22
import { join, dirname, basename } from 'node:path';
3-
import {
4-
DockerComposeEnvironment,
5-
Wait,
6-
type StartedDockerComposeEnvironment,
7-
} from 'testcontainers';
3+
import type { StartedDockerComposeEnvironment } from 'testcontainers';
84
import pg from 'pg';
95
import { queryDatabase, withClient } from './postgres.ts';
106
export { connectDatabase, queryDatabase, withClient } from './postgres.ts';
117
import { migrate, migrationFingerprint, readMigrations } from './migrations.ts';
128
import { withProcessLock } from './lock.ts';
9+
import { importPeer } from './peer.ts';
1310
import {
1411
composeFile,
1512
projectName,
@@ -59,6 +56,12 @@ export function ensureServices(): Promise<Services> {
5956
servicesPromise ??= withProcessLock(
6057
join(stateDirectory, 'services.lock'),
6158
async () => {
59+
// Loaded even when reattaching, so a missing peer fails the same way every run.
60+
const { DockerComposeEnvironment, Wait } = await importPeer(
61+
'testcontainers',
62+
'pgstencil/database',
63+
() => import('testcontainers'),
64+
);
6265
const statePath = join(stateDirectory, 'services.json');
6366
try {
6467
const state = JSON.parse(await readFile(statePath, 'utf8')) as Services;

‎packages/pgstencil/src/migrations.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { readdir, readFile, mkdtemp, writeFile, rm } from 'node:fs/promises';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import pg from 'pg';
6-
import { runner } from 'node-pg-migrate';
76
import { withClient } from './postgres.ts';
7+
import { importPeer } from './peer.ts';
88
export interface MigrationFile {
99
/** Ordering is enforced within each directory; package sources evolve independently. */
1010
source: string;
@@ -118,6 +118,11 @@ export async function migrate(
118118
url: string,
119119
files: MigrationFile[],
120120
): Promise<void> {
121+
const { runner } = await importPeer(
122+
'node-pg-migrate',
123+
'pgstencil/database',
124+
() => import('node-pg-migrate'),
125+
);
121126
const client = new pg.Client({ connectionString: url });
122127
await client.connect();
123128
let temporary: string | undefined;

‎packages/pgstencil/src/peer.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Loads an optional peer dependency. Tooling entry points reach `testcontainers`,
3+
* `node-pg-migrate`, `cheerio` and `turndown` only through here, so a runtime
4+
* install never needs them and a tooling install that lacks one is told which
5+
* package to add. `scripts/runtime-deps.test.mjs` fails on a static import.
6+
*/
7+
export async function importPeer<T>(
8+
name: string,
9+
entry: string,
10+
load: () => Promise<T>,
11+
): Promise<T> {
12+
try {
13+
return await load();
14+
} catch (error) {
15+
// Only the peer itself being absent; a broken install of it surfaces as-is.
16+
const code = (error as { code?: unknown } | null)?.code;
17+
if (
18+
code === 'ERR_MODULE_NOT_FOUND' &&
19+
String((error as Error).message).includes(`'${name}'`)
20+
)
21+
throw new Error(
22+
`${entry} needs the optional peer dependency "${name}". Add it to your project, for example: pnpm add -D ${name}`,
23+
{ cause: error },
24+
);
25+
throw error;
26+
}
27+
}

0 commit comments

Comments
 (0)