diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..c3cfa46 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "enabledManagers": ["github-actions", "npm"], + "dependencyDashboard": true, + "timezone": "America/Los_Angeles", + "schedule": ["* * * * 1"], + "vulnerabilityAlerts": { + "description": [ + "Security fixes must not wait for the Monday window, so this overrides the schedule only.", + "`minimumReleaseAge` is set here EXPLICITLY. Renovate force-applies `minimumReleaseAge: null` to this block, so omitting it drops the cooldown instead of inheriting it from packageRules. The cooldown guards against a compromised release that gets yanked within a day." + ], + "enabled": true, + "schedule": [], + "minimumReleaseAge": "1 day", + "labels": ["security", "dependencies"] + }, + "packageRules": [ + { + "description": "Tooling and examples track new releases in the lockfile without rewriting their ranges", + "matchManagers": ["npm"], + "rangeStrategy": "update-lockfile" + }, + { + "description": "A published package's private dependency range starts at the version CI tested, so re-vendoring or publishing carries that floor into every app (PACKAGES.md -> Dependencies)", + "matchManagers": ["npm"], + "matchFileNames": ["packages/*/package.json"], + "matchDepTypes": ["dependencies"], + "rangeStrategy": "bump" + }, + { + "description": "Apps own their peer versions. An in-range release only moves the devDependency copy that CI tests; an out-of-range release widens the peer so apps can upgrade on their own schedule (PACKAGES.md -> Dependencies)", + "matchManagers": ["npm"], + "matchDepTypes": ["peerDependencies"], + "rangeStrategy": "widen" + }, + { + "matchManagers": ["github-actions"], + "groupName": "github-actions", + "groupSlug": "github-actions", + "separateMajorMinor": false + }, + { + "description": "Require explicit approval for npm majors so API and runtime compatibility get a human review before a branch is created", + "matchManagers": ["npm"], + "matchUpdateTypes": ["major"], + "dependencyDashboardApproval": true + }, + { + "description": "kysely-codegen peers on kysely; generated declarations and the query builder move together", + "matchManagers": ["npm"], + "matchPackageNames": ["kysely", "kysely-codegen"], + "groupName": "kysely", + "groupSlug": "kysely", + "separateMajorMinor": false + }, + { + "description": "The @hono adapters peer on hono", + "matchManagers": ["npm"], + "matchPackageNames": ["hono", "@hono/**"], + "groupName": "hono", + "groupSlug": "hono", + "separateMajorMinor": false + }, + { + "matchManagers": ["npm"], + "matchUpdateTypes": ["patch"], + "minimumReleaseAge": "1 day" + }, + { + "matchManagers": ["npm"], + "matchUpdateTypes": ["minor"], + "minimumReleaseAge": "3 days" + }, + { + "matchManagers": ["npm"], + "matchUpdateTypes": ["major"], + "minimumReleaseAge": "14 days" + }, + { + "description": "@types/node must not expose APIs newer than the Node major in .nvmrc and CI", + "matchManagers": ["npm"], + "matchPackageNames": ["@types/node"], + "allowedVersions": ">=24 <25" + }, + { + "description": "Node is governed by engines, .nvmrc and the CI setup-node version, and is bumped by hand", + "matchPackageNames": ["node"], + "enabled": false + } + ] +} diff --git a/PACKAGES.md b/PACKAGES.md index 935e983..eb90cd4 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -4,9 +4,15 @@ Three packages share version 0.1.0: `pgstencil` (infrastructure and test primiti 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. -Copy the tarballs to a consumer's `vendor/` directory, depend on them using `file:` paths, and override all three package names to those same paths in the consumer's pnpm configuration. The override for `pgstencil` ensures auth and billing's transitive dependency also resolves locally. Commit the archives and lockfile together for a reproducible temporary distribution. Once public npm is configured, replace these paths with exact registry versions and remove the overrides. +Copy the tarballs to a consumer's `vendor/` directory, depend on them using `file:` paths, and override all three package names to those same paths in the consumer's pnpm configuration. The override for `pgstencil` ensures auth and billing's peer also resolves locally. Pack a committed revision in a clean checkout, because an untracked file under `migrations` would otherwise ship in the archive. Commit the archives and lockfile together for a reproducible temporary distribution. Once public npm is configured, replace these paths with exact registry versions and remove the overrides. -`pnpm packages:verify` builds, packs, installs into an unrelated temporary pnpm project, checks TypeScript declarations, and runs an email login and required-card trial against a real cloned database. It shares this repository's Docker service state, but loads all code and SQL from installed archives. The consumer directory is printed for inspection. +`pnpm packages:verify` builds, packs, installs into an unrelated temporary pnpm project, checks TypeScript declarations, and runs an email login and required-card trial against a real cloned database. The project declares each peer at the version this workspace tests, and fails on an unmet peer. It shares this repository's Docker service state, but loads all code and SQL from installed archives. The consumer directory is printed for inspection. + +## Dependencies + +Applications declare pgstencil's peer dependencies themselves: `kysely` for every package, `hono` for `@pgstencil/auth`, and `stripe` for `@pgstencil/stripe`. Auth and billing also peer on the `pgstencil` released with them. A library is a peer when the application and pgstencil must share one copy. Either objects cross the boundary, or the library holds module-level state. Kysely and Hono classes have private fields, so two copies are incompatible types. `pgstencil/diagnostics` keeps its request scope in `AsyncLocalStorage`. The application's Renovate updates each shared library once, and pgstencil uses that copy. pnpm reports a release outside a peer range; pgstencil must widen the range first. + +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 its range admits patches only. 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 re-vendoring carries it into each application. ## Application composition diff --git a/examples/better-auth/package.json b/examples/better-auth/package.json index 6e9929b..d468678 100644 --- a/examples/better-auth/package.json +++ b/examples/better-auth/package.json @@ -6,7 +6,7 @@ "@hono/node-server": "1.19.17", "better-auth": "1.7.3", "hono": "^4.13.7", - "kysely": "0.29.5", + "kysely": "^0.29.5", "pg": "^8.16.0", "pgstencil": "workspace:*", "@pgstencil/auth": "workspace:*" diff --git a/examples/login/package.json b/examples/login/package.json index 3b8a425..f70a120 100644 --- a/examples/login/package.json +++ b/examples/login/package.json @@ -5,9 +5,11 @@ "type": "module", "dependencies": { "@pgstencil/stripe": "workspace:*", - "kysely": "0.29.5", + "kysely": "^0.29.5", "pg": "^8.16.0", "pgstencil": "workspace:*", - "@pgstencil/auth": "workspace:*" + "@pgstencil/auth": "workspace:*", + "hono": "^4.13.7", + "stripe": "^22.6.1" } } diff --git a/examples/workers/package.json b/examples/workers/package.json index 3e96a30..a9781ce 100644 --- a/examples/workers/package.json +++ b/examples/workers/package.json @@ -3,7 +3,10 @@ "private": true, "type": "module", "dependencies": { - "@pgstencil/auth": "workspace:*" + "@pgstencil/auth": "workspace:*", + "hono": "^4.13.7", + "kysely": "^0.29.5", + "pgstencil": "workspace:*" }, "scripts": { "build": "wrangler deploy --dry-run --outdir dist" diff --git a/packages/auth/package.json b/packages/auth/package.json index 63832e1..173635b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -24,11 +24,18 @@ "./better-auth-workers": "./src/better-auth-workers.ts" }, "dependencies": { - "better-auth": "1.7.3", + "better-auth": "~1.7.3", + "jose": "^6.2.12", + "openid-client": "^6.8.8" + }, + "peerDependencies": { + "hono": "^4.13.7", + "kysely": "^0.29.5", + "pgstencil": "workspace:^" + }, + "devDependencies": { "hono": "^4.13.7", - "jose": "6.2.12", - "kysely": "0.29.5", - "openid-client": "6.8.8", + "kysely": "^0.29.5", "pgstencil": "workspace:*" }, "engines": { diff --git a/packages/pgstencil/package.json b/packages/pgstencil/package.json index 0bec142..c24c253 100644 --- a/packages/pgstencil/package.json +++ b/packages/pgstencil/package.json @@ -13,12 +13,17 @@ }, "dependencies": { "pg": "^8.16.0", - "kysely": "0.29.5", - "node-pg-migrate": "9.0.0", - "testcontainers": "12.1.0", + "node-pg-migrate": "^9.0.0", + "testcontainers": "^12.1.0", "cheerio": "^1.1.0", "turndown": "^7.2.0" }, + "peerDependencies": { + "kysely": "^0.29.5" + }, + "devDependencies": { + "kysely": "^0.29.5" + }, "engines": { "node": ">=24" }, diff --git a/packages/stripe/package.json b/packages/stripe/package.json index bf26541..cd947e0 100644 --- a/packages/stripe/package.json +++ b/packages/stripe/package.json @@ -8,10 +8,15 @@ "./testing": "./src/testing.ts", "./migrations": "./src/migrations.ts" }, - "dependencies": { + "peerDependencies": { + "kysely": "^0.29.5", + "pgstencil": "workspace:^", + "stripe": "^22.6.1" + }, + "devDependencies": { + "kysely": "^0.29.5", "pgstencil": "workspace:*", - "kysely": "0.29.5", - "stripe": "22.6.1" + "stripe": "^22.6.1" }, "engines": { "node": ">=24" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 902904e..09718ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,7 @@ importers: specifier: ^4.13.7 version: 4.13.7 kysely: - specifier: 0.29.5 + specifier: ^0.29.5 version: 0.29.5 pg: specifier: ^8.16.0 @@ -80,8 +80,11 @@ importers: '@pgstencil/stripe': specifier: workspace:* version: link:../../packages/stripe + hono: + specifier: ^4.13.7 + version: 4.13.7 kysely: - specifier: 0.29.5 + specifier: ^0.29.5 version: 0.29.5 pg: specifier: ^8.16.0 @@ -89,30 +92,43 @@ importers: pgstencil: specifier: workspace:* version: link:../../packages/pgstencil + stripe: + specifier: ^22.6.1 + version: 22.6.1(@types/node@24.13.3) examples/workers: dependencies: '@pgstencil/auth': specifier: workspace:* version: link:../../packages/auth + hono: + specifier: ^4.13.7 + version: 4.13.7 + kysely: + specifier: ^0.29.5 + version: 0.29.5 + pgstencil: + specifier: workspace:* + version: link:../../packages/pgstencil packages/auth: dependencies: better-auth: - specifier: 1.7.3 + specifier: ~1.7.3 version: 1.7.3(pg@8.23.0)(vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) + jose: + specifier: ^6.2.12 + version: 6.2.12 + openid-client: + specifier: ^6.8.8 + version: 6.8.8 + devDependencies: hono: specifier: ^4.13.7 version: 4.13.7 - jose: - specifier: 6.2.12 - version: 6.2.12 kysely: - specifier: 0.29.5 + specifier: ^0.29.5 version: 0.29.5 - openid-client: - specifier: 6.8.8 - version: 6.8.8 pgstencil: specifier: workspace:* version: link:../pgstencil @@ -122,32 +138,33 @@ importers: cheerio: specifier: ^1.1.0 version: 1.2.0 - kysely: - specifier: 0.29.5 - version: 0.29.5 node-pg-migrate: - specifier: 9.0.0 + specifier: ^9.0.0 version: 9.0.0(@types/pg@8.23.1)(pg@8.23.0) pg: specifier: ^8.16.0 version: 8.23.0 testcontainers: - specifier: 12.1.0 + specifier: ^12.1.0 version: 12.1.0 turndown: specifier: ^7.2.0 version: 7.2.4 + devDependencies: + kysely: + specifier: ^0.29.5 + version: 0.29.5 packages/stripe: - dependencies: + devDependencies: kysely: - specifier: 0.29.5 + specifier: ^0.29.5 version: 0.29.5 pgstencil: specifier: workspace:* version: link:../pgstencil stripe: - specifier: 22.6.1 + specifier: ^22.6.1 version: 22.6.1(@types/node@24.13.3) packages: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c154da8..da56c8c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,9 @@ packages: onlyBuiltDependencies: - esbuild - workerd + +# Ignore package versions published less than 1 day (1440 min) ago, so a +# freshly published (possibly compromised or broken) release cannot enter an +# install before it has had time to be caught. Mirrors the Renovate +# minimumReleaseAge gating in .github/renovate.json. +minimumReleaseAge: 1440 diff --git a/scripts/verify-packages.ts b/scripts/verify-packages.ts index cc22f2b..77a5ecb 100644 --- a/scripts/verify-packages.ts +++ b/scripts/verify-packages.ts @@ -7,6 +7,8 @@ import { projectRoot } from '../packages/pgstencil/src/paths.ts'; const directory = await mkdtemp(join(tmpdir(), 'pgstencil-packed-')); await mkdir(join(directory, 'vendor')); const dependencies: Record = {}; +// The consumer owns every shared library, at the version this workspace tests. +const peers: Record = {}; for (const name of ['pgstencil', '@pgstencil/auth', '@pgstencil/stripe']) { const packageDirectory = name === 'pgstencil' ? name : name.split('/')[1]!; const manifest = JSON.parse( @@ -14,13 +16,30 @@ for (const name of ['pgstencil', '@pgstencil/auth', '@pgstencil/stripe']) { join(projectRoot, 'packages', packageDirectory, 'package.json'), 'utf8', ), - ) as { version: string }; + ) as { version: string; peerDependencies?: Record }; const file = `${name.replace('@', '').replace('/', '-')}-${manifest.version}.tgz`; await cp( join(projectRoot, 'dist/packages', file), join(directory, 'vendor', file), ); 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; + } } await writeFile( join(directory, 'package.json'), @@ -30,7 +49,7 @@ await writeFile( private: true, type: 'module', packageManager: 'pnpm@10.30.1', - dependencies, + dependencies: { ...dependencies, ...peers }, pnpm: { overrides: dependencies }, devDependencies: { '@types/node': '24.13.3', typescript: '5.9.3' }, }, @@ -114,6 +133,12 @@ await writeFile( include: ['verify.ts'], }), ); +// A shared library outside pgstencil's range, or one nothing provides, fails +// the install instead of only warning. +await writeFile( + join(directory, '.npmrc'), + 'auto-install-peers=false\nstrict-peer-dependencies=true\n', +); execFileSync('pnpm', ['install', '--ignore-scripts'], { cwd: directory, stdio: 'inherit',