diff --git a/docs/specs/hosted.md b/docs/specs/hosted.md index d44143bc1..df00f3c26 100644 --- a/docs/specs/hosted.md +++ b/docs/specs/hosted.md @@ -9,7 +9,9 @@ **Must run committed Better Auth migrations before deploying code that needs them, never during a Worker request.** Postgres is reached through an uncached Hyperdrive binding. The runtime creates and closes its database pool within each request. -**Must pin locally packed core/auth packages through root pnpm overrides and commit archives, provenance, and lockfile together.** `vendor/build.json` records the source commit, dirty state, and archive hashes. No runtime import depends on a sibling checkout. The auth migrations remain owned by the package. +**Must pin locally packed core/auth packages through root pnpm overrides and commit archives, provenance, and lockfile together.** `vendor/build.json` records the source commit, archive hashes, and `dirty` — true for every `--working-tree` build, which production preflight refuses. No runtime import depends on a sibling checkout. The auth migrations remain owned by the package. + +**Must declare every peer dependency of the pinned archives in `hosted/package.json`**, so they share Hosted's copy and Renovate updates them. Source of truth: `auth` in `hosted/server/worker.ts`; `workerApp` in `hosted/server/worker-app.ts`; `migrations` in `hosted/server/migrations.ts`; `scripts/sync-pgstencil.mjs`. Pinned by `hosted/server/tests/artifacts.test.ts`. diff --git a/hosted/README.md b/hosted/README.md index f3f16dd1d..8f70818c4 100644 --- a/hosted/README.md +++ b/hosted/README.md @@ -38,18 +38,21 @@ does not deploy. ## Refresh private packages ```sh -node scripts/sync-pgstencil.mjs /path/to/pgstencil +node scripts/sync-pgstencil.mjs /path/to/pgstencil [revision] ``` -This runs `pnpm packages:pack` in pgstencil, vendors core/auth, records source -commit/dirty state and SHA-256 hashes in `vendor/build.json`, and installs. The -direct Node command also works before the archives exist (pnpm may otherwise -auto-install first). See `docs/specs/hosted.md` -> "Application boundary" for -what has to be committed together. - -Re-run integration tests after every refresh. The initial vendored pgstencil -manifest is dirty; production preflight rejects it until it is refreshed from an -accepted clean revision with matching archive hashes. +This checks out the pgstencil revision (default `HEAD`) in a temporary clean +worktree, runs `pnpm packages:pack` there, vendors core/auth, records the commit, +`dirty: false` and SHA-256 hashes in `vendor/build.json`, and installs. To try +uncommitted pgstencil changes, pass `--working-tree` instead of a revision; it +packs the checkout as it stands against its local install and always records +`dirty: true`, which production preflight rejects. The direct Node command also works before the +archives exist (pnpm may otherwise auto-install first). See +`docs/specs/hosted.md` -> "Application boundary" for what has to be committed +together. + +Re-run integration tests after every refresh. Vendor an accepted pgstencil +revision before a production release. ## Resource inventory diff --git a/hosted/package.json b/hosted/package.json index 90e6a5bde..a979c5aaf 100644 --- a/hosted/package.json +++ b/hosted/package.json @@ -20,6 +20,7 @@ "dependencies": { "@pgstencil/auth": "file:../vendor/pgstencil-auth-0.1.0.tgz", "pgstencil": "file:../vendor/pgstencil-0.1.0.tgz", + "kysely": "^0.29.5", "hono": "^4.13.8", "@hono/node-server": "^2.0.10", "react": "^19.2.6", diff --git a/hosted/server/tests/artifacts.test.ts b/hosted/server/tests/artifacts.test.ts index 6d749fc42..440063272 100644 --- a/hosted/server/tests/artifacts.test.ts +++ b/hosted/server/tests/artifacts.test.ts @@ -56,3 +56,26 @@ test("both pinned specifiers name the recorded archives", () => { expect(override).toBe(`file:vendor/${filename}`); } }); + +// strictPeerDependencies only rejects an out-of-range peer. pnpm resolves an +// undeclared one itself, where Renovate never sees it and Hosted's own imports +// can get a second copy. +test("Hosted declares every peer of the pinned archives", () => { + const { dependencies } = JSON.parse( + readFileSync("package.json", "utf8"), + ) as { dependencies: Record }; + for (const archive of ["pgstencil-0.1.0.tgz", "pgstencil-auth-0.1.0.tgz"]) { + const manifest = execFileSync( + "tar", + ["-xOf", "../vendor/" + archive, "package/package.json"], + { encoding: "utf8" }, + ); + const { peerDependencies = {} } = JSON.parse(manifest) as { + peerDependencies?: Record; + }; + for (const peer of Object.keys(peerDependencies)) + expect(Object.keys(dependencies), `${archive} peers on ${peer}`).toContain( + peer, + ); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa9a231ce..1d9fd670d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,13 +98,16 @@ importers: version: 2.1.1(hono@4.13.8) '@pgstencil/auth': specifier: file:../vendor/pgstencil-auth-0.1.0.tgz - version: file:vendor/pgstencil-auth-0.1.0.tgz(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(vitest@4.1.11(@types/node@24.13.6)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) + version: file:vendor/pgstencil-auth-0.1.0.tgz(hono@4.13.8)(kysely@0.29.5)(pg@8.23.0)(pgstencil@file:vendor/pgstencil-0.1.0.tgz(kysely@0.29.5)(supports-color@10.2.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@4.1.11(@types/node@24.13.6)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) hono: specifier: ^4.13.8 version: 4.13.8 + kysely: + specifier: ^0.29.5 + version: 0.29.5 pgstencil: specifier: file:../vendor/pgstencil-0.1.0.tgz - version: file:vendor/pgstencil-0.1.0.tgz(supports-color@10.2.2) + version: file:vendor/pgstencil-0.1.0.tgz(kysely@0.29.5)(supports-color@10.2.2) react: specifier: ^19.2.6 version: 19.3.0 @@ -2383,9 +2386,13 @@ packages: os: [win32] '@pgstencil/auth@file:vendor/pgstencil-auth-0.1.0.tgz': - resolution: {integrity: sha512-sEnEGu+U9nLtJeV9qP39SIGf1D8gtgiQpC6apTjnHy+2LwaSxgqLKG4bgURw3mqVaNf4DYm6D8Y6xi+Iy5q+Lw==, tarball: file:vendor/pgstencil-auth-0.1.0.tgz} + resolution: {integrity: sha512-gfnfb5HOlYS7anaPCD3890fLvlloczS7gvb3+Fh6mXldrYNoK0HjtDVit68pQb9kbPivyUSduuoKS/2jAoeHgw==, tarball: file:vendor/pgstencil-auth-0.1.0.tgz} version: 0.1.0 engines: {node: '>=24'} + peerDependencies: + hono: ^4.13.7 + kysely: ^0.29.5 + pgstencil: ^0.1.0 '@phosphor-icons/react@2.1.10': resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} @@ -5197,9 +5204,11 @@ packages: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} pgstencil@file:vendor/pgstencil-0.1.0.tgz: - resolution: {integrity: sha512-I9a0Ohlz62aKqvCiVaoyWQOcx0vl5S9y15g7NvfJKyw7DUM9Xp5vW81wCsuVboe4h8F2wAPq1YCakel5ssPxcA==, tarball: file:vendor/pgstencil-0.1.0.tgz} + resolution: {integrity: sha512-tJncl/ELHO09guw0Mm1kBDC9de8gNOMQLv96MLCoWqDFW7oyHyfNxO/2A8Jy7EeVh/K1PELawu507Ohs3WSXMQ==, tarball: file:vendor/pgstencil-0.1.0.tgz} version: 0.1.0 engines: {node: '>=24'} + peerDependencies: + kysely: ^0.29.5 picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -7670,13 +7679,14 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.21.2': optional: true - '@pgstencil/auth@file:vendor/pgstencil-auth-0.1.0.tgz(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(vitest@4.1.11(@types/node@24.13.6)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))': + '@pgstencil/auth@file:vendor/pgstencil-auth-0.1.0.tgz(hono@4.13.8)(kysely@0.29.5)(pg@8.23.0)(pgstencil@file:vendor/pgstencil-0.1.0.tgz(kysely@0.29.5)(supports-color@10.2.2))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@4.1.11(@types/node@24.13.6)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)))': dependencies: better-auth: 1.7.3(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vitest@4.1.11(@types/node@24.13.6)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.3.0(@types/node@24.13.6)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1))) hono: 4.13.8 + jose: 6.2.12 kysely: 0.29.5 openid-client: 6.8.8 - pgstencil: file:vendor/pgstencil-0.1.0.tgz(supports-color@10.2.2) + pgstencil: file:vendor/pgstencil-0.1.0.tgz(kysely@0.29.5)(supports-color@10.2.2) transitivePeerDependencies: - '@cloudflare/workers-types' - '@lynx-js/react' @@ -7685,9 +7695,6 @@ snapshots: - '@sveltejs/kit' - '@tanstack/react-start' - '@tanstack/solid-start' - - '@types/pg' - - bare-abort-controller - - bare-buffer - better-sqlite3 - drizzle-kit - drizzle-orm @@ -7695,13 +7702,10 @@ snapshots: - mysql2 - next - pg - - pg-native - prisma - react - react-dom - - react-native-b4a - solid-js - - supports-color - svelte - vitest - vue @@ -10556,7 +10560,7 @@ snapshots: dependencies: split2: 4.2.0 - pgstencil@file:vendor/pgstencil-0.1.0.tgz(supports-color@10.2.2): + pgstencil@file:vendor/pgstencil-0.1.0.tgz(kysely@0.29.5)(supports-color@10.2.2): dependencies: cheerio: 1.2.0 kysely: 0.29.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 175666701..e70ea5fb0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,6 +21,10 @@ allowBuilds: keytar: false node-pty: true sharp: true +# An unmet peer fails the install instead of warning, so a bump that leaves a +# shared library outside a dependent's range (pgstencil's kysely/hono, the +# @hono adapters) goes red in its own PR. Widen deliberately below, with a reason. +strictPeerDependencies: true peerDependencyRules: allowedVersions: "react-helmet-async>react": ^19.0.0 diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 9695d7096..ff885e533 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -9,7 +9,7 @@ "docs/specs/dor-cli.md": 5900, "docs/specs/dor-tool.md": 4100, "docs/specs/glossary.md": 2950, - "docs/specs/hosted.md": 1050, + "docs/specs/hosted.md": 1100, "docs/specs/layout.md": 9900, "docs/specs/mobile-terminal-ui.md": 2000, "docs/specs/mouse-and-clipboard.md": 3750, diff --git a/scripts/sync-pgstencil.mjs b/scripts/sync-pgstencil.mjs index eaeef0331..c99642d2d 100644 --- a/scripts/sync-pgstencil.mjs +++ b/scripts/sync-pgstencil.mjs @@ -1,17 +1,38 @@ import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync, copyFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, + copyFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; const root = fileURLToPath(new URL("../", import.meta.url)); -const source = process.argv[2]; -if (!source) - throw new Error("Usage: pnpm pgstencil:sync /path/to/pgstencil [--packed]"); +const usage = + "Usage: pnpm pgstencil:sync /path/to/pgstencil [ | --working-tree]"; +const [source, ...options] = process.argv.slice(2); +const revisions = options.filter((option) => !option.startsWith("--")); +const workingTree = options.includes("--working-tree"); +// A mistyped flag must not fall back to a clean sync of HEAD, which would +// overwrite the archives with something other than what was asked for. +if ( + !source || + options.some( + (option) => option.startsWith("--") && option !== "--working-tree", + ) || + revisions.length > (workingTree ? 0 : 1) +) + throw new Error(usage); const repository = resolve(source); -const run = (command, args, cwd = repository) => - execFileSync(command, args, { cwd, stdio: "inherit" }); -if (!process.argv.includes("--packed")) run("pnpm", ["packages:pack"]); +const revision = revisions[0] ?? "HEAD"; +const run = (command, args, cwd, env = process.env) => + execFileSync(command, args, { cwd, stdio: "inherit", env }); +const git = (...args) => + execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim(); const manifest = JSON.parse( readFileSync(resolve(root, "hosted/package.json"), "utf8"), ); @@ -28,45 +49,66 @@ const overrides = new Map( return entry ? [[entry[1], entry[2]]] : []; }), ); -mkdirSync(resolve(root, "vendor"), { recursive: true }); -const files = []; -for (const [directory, name] of [ +// Pack a committed revision in a temporary worktree after a frozen install, so +// nothing uncommitted, untracked or ignored in the pgstencil checkout (a stray +// migration, editor settings, stale build output, a local node_modules) can +// reach an archive, and build.json truthfully records `dirty: false`. +// --working-tree packs the checkout as it stands, for trying unfinished +// pgstencil changes. That result depends on local state even when git status is +// clean, so it is always recorded dirty and production preflight refuses it. +const commit = git( + "rev-parse", + "--verify", + `${workingTree ? "HEAD" : revision}^{commit}`, +); +const dirty = workingTree; +// Check the pins before the slow install and before any archive is replaced. +const read = (path) => + workingTree + ? readFileSync(resolve(repository, path), "utf8") + : git("show", `${commit}:${path}`); +const archives = [ ["pgstencil", "pgstencil"], ["auth", "@pgstencil/auth"], -]) { - const pkg = JSON.parse( - readFileSync( - resolve(repository, `packages/${directory}/package.json`), - "utf8", - ), - ); - const filename = `${name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`; +].map(([directory, name]) => { + const { version } = JSON.parse(read(`packages/${directory}/package.json`)); + const filename = `${name.replace("@", "").replace("/", "-")}-${version}.tgz`; if (manifest.dependencies[name] !== `file:../vendor/${filename}`) throw new Error(`Update hosted/package.json for ${filename}`); if (overrides.get(name) !== `file:vendor/${filename}`) throw new Error( `Update the pnpm-workspace.yaml override for ${name} to file:vendor/${filename}`, ); - const target = resolve(root, "vendor", filename); - copyFileSync(resolve(repository, "dist/packages", filename), target); - files.push({ - filename, - sha256: createHash("sha256").update(readFileSync(target)).digest("hex"), - }); + return filename; +}); +const checkout = workingTree + ? repository + : mkdtempSync(join(tmpdir(), "pgstencil-sync-")); +// pgstencil's scripts locate their project from this variable before the cwd. +const pgstencil = { ...process.env, PGSTENCIL_PROJECT_ROOT: checkout }; +if (!workingTree) git("worktree", "add", "--detach", checkout, commit); +try { + if (!workingTree) + run("pnpm", ["install", "--frozen-lockfile"], checkout, pgstencil); + run("pnpm", ["packages:pack"], checkout, pgstencil); + mkdirSync(resolve(root, "vendor"), { recursive: true }); + for (const filename of archives) + copyFileSync( + resolve(checkout, "dist/packages", filename), + resolve(root, "vendor", filename), + ); +} finally { + if (!workingTree) git("worktree", "remove", "--force", checkout); } -const git = (...args) => - execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim(); +const files = archives.map((filename) => ({ + filename, + sha256: createHash("sha256") + .update(readFileSync(resolve(root, "vendor", filename))) + .digest("hex"), +})); writeFileSync( resolve(root, "vendor/build.json"), - JSON.stringify( - { - commit: git("rev-parse", "HEAD"), - dirty: !!git("status", "--porcelain"), - files, - }, - null, - 2, - ) + "\n", + JSON.stringify({ commit, dirty, files }, null, 2) + "\n", ); // A changed tarball integrity is resolved by a normal install. --force also // installs foreign-platform optional binaries and distorts dependency disclosure. diff --git a/vendor/build.json b/vendor/build.json index caf4bfb2c..60423f2fa 100644 --- a/vendor/build.json +++ b/vendor/build.json @@ -1,14 +1,14 @@ { - "commit": "c14097cabbbef8d670234354014e41ae4fc9470a", - "dirty": true, + "commit": "297edf6590e61200857b199d69160f0567bbb3c8", + "dirty": false, "files": [ { "filename": "pgstencil-0.1.0.tgz", - "sha256": "88601626740565b3ad8660ebdf8ec06f3d5f553288ec7ea5549f6d3e7c8a293d" + "sha256": "5ed674d0c62619a2eaa09c40ea447b730670b92ba3e32ae784755b70ed8f2c7e" }, { "filename": "pgstencil-auth-0.1.0.tgz", - "sha256": "111892277b8bd4a6ff5b758ccb0e1bce3997ccf5493c8f9fdb98fedf7f6eab5a" + "sha256": "7ada0c3d2031b3d06bec00c623c4f339d4045fd63b1f7a7fe371bd40ca7b7baa" } ] } diff --git a/vendor/pgstencil-0.1.0.tgz b/vendor/pgstencil-0.1.0.tgz index 6a31b152b..6bd1f73e5 100644 Binary files a/vendor/pgstencil-0.1.0.tgz and b/vendor/pgstencil-0.1.0.tgz differ diff --git a/vendor/pgstencil-auth-0.1.0.tgz b/vendor/pgstencil-auth-0.1.0.tgz index 5b7c5bcb8..0ce135362 100644 Binary files a/vendor/pgstencil-auth-0.1.0.tgz and b/vendor/pgstencil-auth-0.1.0.tgz differ