Skip to content

fix(docker): install pg and mysql2 in the official runtime image, and pin the published driver list to it - #14699

Merged
os-trump merged 3 commits into
mainfrom
claude/issue-14510-runtime-image-sql-drivers
Sep 2, 2026
Merged

fix(docker): install pg and mysql2 in the official runtime image, and pin the published driver list to it#14699
os-trump merged 3 commits into
mainfrom
claude/issue-14510-runtime-image-sql-drivers

Conversation

@os-trump

@os-trump os-trump commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14510

Implements the maintainer ruling recorded in comment 5511800846B′, directions 1 + 3: the official runtime image installs pg and mysql2, and docker/README.md publishes 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:

Route Result
Build the image and run node -e "require('pg'); require('mysql2')" in it NOT MEASUREDdocker is on PATH at /usr/bin/docker and docker info fails; no daemon in this container.
The same for docker run … -e OS_DATABASE_URL=postgres://… reaching Driver: SqlDriver(pg) NOT MEASURED — same reason.
Read the published image's layers from ghcr.io NOT MEASURED — manifests fetched fine (HTTP 200); every blob 307-redirects to pkg-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.
Reproduce the image's only install command MEASURED.

The manifest half did establish one useful fact: 17.2.0 and latest resolve to the same amd64 manifest digest sha256:9d771f2d1aa94a07e02a1aaa7403e2c2adf4bf8efee986d242e5138c647195d1, so the tag the README pins and the tag it advertises are one image.

docker/Dockerfile installs nothing but npm install -g @objectstack/cli@${OS_CLI_VERSION} on node:22-slim. Reproduced at the pinned version against the live npm registry:

npm install -g --prefix SCRATCHDIR @objectstack/cli@17.2.0     # exit 0, "added 423 packages in 1m"
module dirs found require.resolve
pg 0 NOT FOUND (MODULE_NOT_FOUND)
mysql2 0 NOT FOUND (MODULE_NOT_FOUND)
knex 1 RESOLVED
better-sqlite3 1 RESOLVED
mongodb 1

The 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.0 has no pg/mysql2 in dependencies, optionalDependencies is {better-sqlite3} only, no postinstall; @objectstack/driver-sql@17.2.0 declares pg ^8.0.0 / mysql2 ^3.0.0 / tedious ^18.0.0 as peers with all three optional: 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:

RUN npm install -g \
      @objectstack/cli@${OS_CLI_VERSION} \
      "pg@^8.0.0" \
      "mysql2@^3.0.0" \
 && npm cache clean --force

The ranges are copied verbatim from driver-sql's peerDependencies, so the image satisfies the driver's own contract rather than a second one invented here. tedious and @objectstack/driver-turso are 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 binding OS_DATABASE_URL schemes 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 is docker-publish.yml's existing smoke step, which already does docker run --rm "$IMAGE:$VERSION" os --version on 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 comparing docker/Dockerfile's install line against docker/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:

GATE_EXIT_BEFORE_DOCKER_EDIT=1
check-docs-image-tag: 1 problem(s) -- 1 DRIVER-ANCHOR-LOST
  • [DRIVER-ANCHOR-LOST] docker/README.md
      carries no '## Database drivers in the image' heading, so the drivers docker/Dockerfile
      installs are not published anywhere a deployer reads. …

After the two edits, on the same limb:

GATE_EXIT_AFTER_DOCKER_EDIT=0
check-docs-image-tag: OK (… 2 driver(s) installed by docker/Dockerfile compared against
2 promised in docker/README.md (2 matched by name)).

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 TERM trap holding absolute paths:

HEAD blobs: Dockerfile=7533c8e5408da6451540319173107594c23a9ca1 README=50177e6cdf4ce66ba85fb9a10952f4d64f7c80a4

LEG 1: mutate docker/Dockerfile (remove the mysql2 install token)
MUTATION ON DISK BY CONTENT: '"mysql2@^3.0.0"' occurrences 1 -> 0
LEG1_EXIT=1 · check-docs-image-tag: 1 problem(s) -- 1 DRIVER-OVERPROMISED
RESTORE PROVEN BY BLOB IDENTITY: Dockerfile 7533c8e5… == HEAD 7533c8e5…

LEG 2: mutate docker/README.md (remove the pg promise row)
MUTATION ON DISK BY CONTENT: the pg promise row occurrences 1 -> 0
LEG2_EXIT=1 · check-docs-image-tag: 1 problem(s) -- 1 DRIVER-UNDOCUMENTED
RESTORE PROVEN BY BLOB IDENTITY: README 50177e6c… == HEAD 50177e6c…

FINAL STATE: (empty diff = tree back at HEAD) · RESTORED_GATE_EXIT=0

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:

✓ check-docs-image-tag --self-test: 94 assertions … undocumented driver, overpromised driver,
driver range mismatch, unranged driver install, lost driver anchor on either side, empty driver
list on either side -- observed FAILING, and the X.Y.Z metavariable, …, the interpolated CLI pin,
the `npm cache clean` tail, the backticked URL schemes and the "not in the image" prose
observed EXCLUDED.

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 under docker/: 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, no package.json entry, and no lint.yml step — the alternative (a new gate script plus wiring) would have touched two additional and much hotter files. SURFACES and PATTERNS, which sync-docs-image-tags.mjs, release.yml and cut-rc.yml import, 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's OS_DATABASE_URL row already carries the optional-package callout for postgres:// and mysql:// on current main), no packages/**, no scaffolder templates, no content/docs/releases/**, no docs/adr/**.

Changeset

None, and skip-changeset is applied. The Check Changeset gate in pr-automation.yml has no path-based exemption — its only exits are a changeset or that label — and this diff publishes nothing from any package: docker/ and scripts/ are outside every pnpm-workspace.yaml glob. This matches the ruling's own instruction, "No changeset for a published package unless the image build reads one": docker-publish.yml reads a published CLI version, never a changeset.

Verification — head 7efc496c60

Gate 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 --ran against the byte-for-byte command strings:

✓ dispatch-gates --ran: 16 derived famil(ies) accounted for — 16 run, 0 NOT-MEASURED.

15 of the 16 exited 0. The sixteenth is NOT MEASURED, in the gate's own words:

check-test-completeness: PREREQUISITE NOT MET — this gate grades a saved `turbo run test`
log, and no log was named.
  ...
  ⚠ Arrived here from the gate family `scripts/pm/dispatch-gates.mjs` derives?
  That list names this script with NO argument, which is this branch. There is no
  local log to hand it, so the local reading for this gate is NOT MEASURED.
  ⛔ It is not a red, and there is nothing here to fix.

Exit code 3, which that gate defines as distinct from a finding's 1. It is recorded as executed in the --ran record 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-sync and pnpm 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-gates also 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

`@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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation labels Sep 2, 2026
@os-trump os-trump added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed documentation Improvements or additions to documentation labels Sep 2, 2026 — with Claude
@os-trump
os-trump marked this pull request as ready for review September 2, 2026 19:26
@os-trump
os-trump enabled auto-merge September 2, 2026 19:26
@os-trump
os-trump added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 0e01dde Sep 2, 2026
41 checks passed
@os-trump
os-trump deleted the claude/issue-14510-runtime-image-sql-drivers branch September 2, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Postgres self-host path is missing the pg driver: CLI install has no pg, boot fails fast

2 participants