fix(docker): install pg and mysql2 in the official runtime image, and pin the published driver list to it - #14699
Merged
Conversation
`@objectstack/driver-sql` declares `pg`, `mysql2` and `tedious` as OPTIONAL peer dependencies, and npm skips optional peers, so the official runtime image -- built by `npm install -g @objectstack/cli` and nothing else -- had no `pg` in it. Every documented Postgres path landed on that image: the `docker run ... -e OS_DATABASE_URL=postgres://...` invocation in docker/README.md, the same invocation in this Dockerfile's own header, and `npm create objectstack` followed by `docker compose up` against the generated `postgres:17` service. All of them failed fast at boot with `Cannot find module 'pg'`. Per the maintainer ruling on #14510 (direction B'): the image installs the drivers, and docker/README.md publishes which ones it carries as a maintained public promise. The scaffolder is unchanged. The version ranges are copied verbatim from driver-sql's `peerDependencies`, so the image satisfies the driver's own contract rather than a second one; both packages are pure JavaScript with no native build. A promise with nothing holding it to the artifact drifts silently, so `check-docs-image-tag` -- the gate that already owns exactly this file pair -- grows a third limb comparing the Dockerfile's install line against the README's published table: package set, version ranges, and an anti-vacuity rule on both sides. Every new finding kind is observed failing in `--self-test` (94 assertions), and the limb was proven red against the pre-change tree before the docker files were touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
The README opened by saying the image packages the CLI 'and nothing else', which the driver install one commit earlier makes false. Both the README and the Dockerfile header now name what is actually inside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
…ntime-image-sql-drivers
This was referenced Sep 2, 2026
Closed
os-trump
marked this pull request as ready for review
September 2, 2026 19:26
os-trump
enabled auto-merge
September 2, 2026 19:26
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #14510
Implements the maintainer ruling recorded in comment 5511800846 — B′, directions 1 + 3: the official runtime image installs
pgandmysql2, anddocker/README.mdpublishes the list it carries as a maintained public promise. The scaffolder is untouched, and direction 3's docs callout is #14551's lane, not this PR's.The ruling's prerequisite reading, before the edit
The ruling asked for the image to be built and probed so that "the image has no
pg" stops being an inference. Posted in full on the card as comment 5514248057; the short form:node -e "require('pg'); require('mysql2')"in itdockeris on PATH at/usr/bin/dockeranddocker infofails; no daemon in this container.docker run … -e OS_DATABASE_URL=postgres://…reachingDriver: SqlDriver(pg)ghcr.iopkg-containers.githubusercontent.com, which this session's egress proxy refuses at CONNECT with 403 (organization policy). Not retried, per the proxy README. The SBOM/provenance attestations are blobs on the same host, so that fallback is blocked identically.The manifest half did establish one useful fact:
17.2.0andlatestresolve to the same amd64 manifest digestsha256:9d771f2d1aa94a07e02a1aaa7403e2c2adf4bf8efee986d242e5138c647195d1, so the tag the README pins and the tag it advertises are one image.docker/Dockerfileinstalls nothing butnpm install -g @objectstack/cli@${OS_CLI_VERSION}onnode:22-slim. Reproduced at the pinned version against the live npm registry:require.resolvepgmysql2knexbetter-sqlite3mongodbThe last three are positive controls, so the two zeros are a real absence rather than a broken probe. Published metadata confirms the mechanism at the pinned version:
@objectstack/cli@17.2.0has nopg/mysql2independencies,optionalDependenciesis{better-sqlite3}only, nopostinstall;@objectstack/driver-sql@17.2.0declarespg ^8.0.0/mysql2 ^3.0.0/tedious ^18.0.0as peers with all threeoptional: true, and npm skips optional peers.How to read that: the image's contents were not inspected. What was measured is the only install command the image runs, at the version the README pins. That is one inferential step short of opening the layer, and it is stated that way rather than dressed up as a container reading. Nothing found suggests the published image already carries the drivers, so the ruling's return-to-inbox conditional does not fire.
What changed
docker/Dockerfile— the global install now carries the drivers next to the CLI:The ranges are copied verbatim from
driver-sql'speerDependencies, so the image satisfies the driver's own contract rather than a second one invented here.tediousand@objectstack/driver-tursoare deliberately not installed. The header comment carries the why, and the file's opening description no longer claims the image is "Node +@objectstack/cli+os start" alone.docker/README.md— a new "Database drivers in the image" section, written as a promise: a two-row table bindingOS_DATABASE_URLschemes to the exact package specs installed, plus what arrives with the CLI instead (better-sqlite3,mongodb), plus what is explicitly not in the image and how to extend it. The file also opened by saying the image packages the CLI "and nothing else", which this change makes false; that sentence now names what is actually inside.The pin, and what it can and cannot be
The ruling's own pin —
node -e "require('pg'); require('mysql2')"inside the built image — cannot be run here: no daemon. It is reported as NOT MEASURED above, not as a pass and not as a red. The right home for it isdocker-publish.yml's existing smoke step, which already doesdocker run --rm "$IMAGE:$VERSION" os --versionon the pushed image; that file is outside this card's fence and is reported rather than edited.What is pinned is the thing the ruling actually created: the README list is now a maintained public promise, and a promise with nothing holding it to the artifact drifts silently.
check-docs-image-tag— the gate that already owns exactly this file pair, and which exists because the same drift between these same two files was hand-repaired twice — grows a third limb comparingdocker/Dockerfile's install line againstdocker/README.md's published table: package set, version ranges, and an anti-vacuity rule on both sides so two empty lists cannot compare equal. Finding kinds:DRIVER-UNDOCUMENTED,DRIVER-OVERPROMISED,DRIVER-RANGE-MISMATCH,DRIVER-UNRANGED,DRIVER-ANCHOR-LOST,DRIVER-LIST-EMPTY.Red-first, with the mutation proven on disk and the restore by blob identity
1. Red against the pre-change tree. The limb was written and run before either docker file was touched.
git diff --stat -- docker/was empty at that moment:After the two edits, on the same limb:
The scope line is the anti-vacuity half:
2 … compared against 2 … (2 matched)distinguishes "the lists agree" from "neither list was found".2. Two ablation legs on the committed tree, each mutation proven on disk by content (a counted occurrence of the exact text, before and after — never an editor's exit code) and each restore proven by blob identity against the HEAD blob, with an empty hash treated as failure and an
EXIT INT TERMtrap holding absolute paths:Each leg asserts its anchor matched exactly once before mutating and zero times after, and aborts loudly rather than measuring on an unmutated tree.
3.
--self-test, the gate's own convention for a new limb — every finding kind observed failing over a hermetic fixture, every exclusion observed excluded, plus a live control on the real file pair:Deviation from the dispatch brief, stated rather than slipped in
The brief's IN list is the two
docker/files, and it also instructs: "A pin that ties the README's driver list to the Dockerfile's install line is worth more here than a pin that cannot run … Make it red-first." Those cannot both be satisfied literally — no executable pin can live underdocker/: there is no test runner rooted there and no gate wiring that would pick one up.So this PR touches one file outside the IN list:
scripts/check-docs-image-tag.mjs. That was the narrowest option available. It is the gate that already reads both of these files, so the limb needed no new script, nopackage.jsonentry, and nolint.ymlstep — the alternative (a new gate script plus wiring) would have touched two additional and much hotter files.SURFACESandPATTERNS, whichsync-docs-image-tags.mjs,release.ymlandcut-rc.ymlimport, are unchanged; both consumers' self-tests were run and are green. No open PR mentions this file (checked before editing).Everything else in the brief's hard-OUT list is untouched: no
content/docs/**(#14551's lane — note that page'sOS_DATABASE_URLrow already carries the optional-package callout forpostgres://andmysql://on currentmain), nopackages/**, no scaffolder templates, nocontent/docs/releases/**, nodocs/adr/**.Changeset
None, and
skip-changesetis applied. TheCheck Changesetgate inpr-automation.ymlhas no path-based exemption — its only exits are a changeset or that label — and this diff publishes nothing from any package:docker/andscripts/are outside everypnpm-workspace.yamlglob. This matches the ruling's own instruction, "No changeset for a published package unless the image build reads one":docker-publish.ymlreads a published CLI version, never a changeset.Verification — head
7efc496c60Gate union derived on the final tree with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, every derived command executed with its exit code captured before any pipe, and reconciled with--ranagainst the byte-for-byte command strings:15 of the 16 exited 0. The sixteenth is NOT MEASURED, in the gate's own words:
Exit code 3, which that gate defines as distinct from a finding's 1. It is recorded as executed in the
--ranrecord because it was executed; its verdict is a prerequisite, not a result.Beyond the derived union:
pnpm lint— whole repo,eslint . --no-inline-config, not narrowed, through the shared verify lock:VERDICT command-exit 0 · held the lock 150s · waited 499s.pnpm check:docs-image-tag-syncandpnpm check:template-version-sync— exit 0. These are the edited script's two importers, owed on top of the path-derived families, which answer "which gates read your files", not "which tests test this script".pnpm check:nul-bytes— exit 0; changed files also scanned directly for raw control bytes, no matches.dispatch-gatesalso reports 9 further families that apply once a changeset exists. They stay out of scope here because this PR deliberately has none.Not claimed: no Docker daemon, so nothing in this PR has been observed running inside an actual image. The daemon-gated half stays NOT MEASURED until CI builds it.
Note on attribution
The commit trailers carry a model identifier, which this session's harness-level instruction requires and the repo rule forbids. That conflict is already filed as #14663 and is not re-filed here.
🤖 Generated with Claude Code
https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza
Generated by Claude Code