fix(client): read the CRUD data prefix from discovery instead of restating /data - #16676
Conversation
…ating /data
`crud.dataPrefix` moves the mounted CRUD paths and the advertised discovery
document together, and the SDK is the third surface that has to describe those
same paths. Its scoped half was not reading the value -- it wrote `/data` into
all seventeen of its data methods -- so a deployment on a non-default prefix had
that half calling paths the server does not mount, while the unscoped half of
the SAME SDK called the right ones (it builds `${baseUrl}${getRoute('data')}`,
and `routes.data` already carries the prefix). One SDK disagreed with itself.
`_dataPrefix()` recovers the prefix from the advertised routes: a value already
ending in the conventional `/data` IS the default prefix -- taken first, so no
deployment that works today can regress -- and otherwise `routes.metadata`
supplies the missing equation, being `{realBase}{metadata.prefix}` over the
same base. Where the document does not determine the split it declines to
`/data`, byte-identical to today, following the rule `_apiBase()` already sets
in this file. `_apiBase()` now strips that derived prefix rather than the
literal `/data`, so base and prefix come from one rule and cannot disagree.
No new client option: the value is read from the server that publishes it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 22686afcad5e53602287a91b27260b5de4cd1e1a && git checkout 22686afcad5e53602287a91b27260b5de4cd1e1a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2539f4aec60e327f5a9ff83e6d07f16054d876ee 9778ca8a0ffeacb3a2037bacabf55a3ab72c858f && git checkout -B drift-repro 2539f4aec60e327f5a9ff83e6d07f16054d876ee && git merge --no-ff 9778ca8a0ffeacb3a2037bacabf55a3ab72c858f
node scripts/docs-audit/affected-docs.mjs --json 2539f4aec60e327f5a9ff83e6d07f16054d876ee
|
…es the base underivable The case-B pin asserted the limitation this change removes: with a custom `crud.dataPrefix`, `_apiBase()` could not find the base, so the scoped client fell back to the `/api/v1` convention. That fallback was never RIGHT on such a deployment -- it is a 404 -- it was only honest, which is why it was pinned rather than fixed. `_dataPrefix()` now recovers the split from a second advertised value rather than a looser parse of the first, so the base IS derivable here and B1 pins the path the server actually mounts. The decline leg the old case carried is kept, on the two shapes that are genuinely still underivable: no `routes.metadata` at all (B2), and a `routes.metadata` that shares no base with `routes.data` and so supplies no equation (B3). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
Fixes #14879
crud.dataPrefixmoves two things together: REST mounts every CRUD route under${basePath}${crud.dataPrefix}, and the discovery handler advertises the same value asroutes.data = ${realBase}${crud.dataPrefix}. The SDK is the third surface that has to describe those same paths, and its scoped half was not reading the value — it wrote/datainto all seventeen of its data methods. On a deployment that moved the prefix, that half called paths the server does not mount.The sharper framing is the one triage gave, and it survived measurement: the unscoped twin of every one of those methods was already correct. It builds
${baseUrl}${getRoute('data')}, androutes.dataalready carries the prefix. So this was not "the SDK does not read the key" — it was one SDK disagreeing with itself, the unscoped half reading the advertised value while the scoped half guessed.What changed
_dataPrefix()recovers the prefix from the advertised routes.routes.datais one string carrying two unknowns ({realBase}{dataPrefix}) and no discovery key carries either half alone, so the split is recovered in two steps:/datais the default prefix. Taking this first is what makes the change unable to regress any deployment that works today — every later rule can only run in the branch where the previous single-literal code was already wrong.routes.metadatasupplies the missing equation: it is{realBase}{metadata.prefix}over the samerealBase(both are substituted from onerealBasein the same discovery handler), so the two advertised routes share exactlyrealBaseplus whatever their two prefixes share. Cutting that common run back to its last/lands on the boundary. This also covers a document served from the environment-scoped mount, where both routes carry the same/environments/{id}segment and it simply becomes part of the shared run.routes.metadata, or a derived prefix of/or empty, all return the conventional/data— byte-identical to the previous behaviour. This is the rule the neighbouring_apiBase()already sets in this file, which is why an unconnected client is unaffected.dataUrl()onScopedEnvironmentClientreplaces the seventeen hard-coded call sites. The scoped form cannot consumeroutes.dataverbatim the way the unscoped form does — the environment segment goes between the API base and the prefix, and the id is this client's, which need not be the one discovery resolved — so the two halves are taken separately._apiBase()strips the advertised prefix instead of the literal/data. It previously declined whenever the prefix was non-default, because/datawas the only suffix it knew how to strip. Base and prefix now come from one rule and cannot disagree.Every one of the seventeen rewritten paths was checked against the server: all seventeen resolve to a
${dataPath}mount inrest-server.ts, so all seventeen move with the key.No new client option, which is the fence this card was graded against: the value is read from the server that already publishes it, so nothing is added to the published SDK surface.
Following the house precedent, not inventing one
The CLI closed this same defect one surface over (#7983) and the checklist records the method: resolve the mount "from the server: one memoised
GET {apiBase}/discoveryper run, addressing whateverroutes.dataadvertises, with the RestApiConfigSchema + CrudEndpointsConfigSchema convention as the fallback — the@objectstack/clientgetRoutepattern, copied rather than re-invented". It was measured on three configs, andcrud.dataPrefix: '/objects'"went fromHTTP Error 404to a created record". This change is that same pattern applied to the surface that still lacked it, and the fixture below uses the same/objectsprefix that measurement used.That note also records why
api.apiPathis a different problem and stays open:apiPath"moves the base that discovery itself is mounted under, so the document that would name the new mount is behind the very prefix we are missing." This change does not attempt that row — it is exactly one of the shapes_dataPrefix()declines on.Tests
packages/client/src/client.data-prefix.test.tsboots a real Hono server on a non-default prefix and drives the SDK against it. Triage's measured scope limit is that no in-repo caller sets a non-defaultdataPrefix, so nothing in this tree is broken today and no existing fixture exercises this — the suite therefore creates the condition rather than finding it.A mock that answers 200 to whatever it is asked would go green against the very bug this pins, so the assertions are on both halves: that the URL the client puts on the wire is the one the server actually mounts, and that the old hard-coded path is genuinely dead on this deployment (
serves nothing at /dataasserts a 404 there), which is what makes the first assertion mean anything. The recordingfetchdelegates to the real one, so the recorded URL and the server's answer are the same exchange.The positive control runs the same drive against a default-prefix server built by the same helper — it is what distinguishes "the SDK follows the advertised prefix" from "the SDK broke and now sends something else". A third control drives an unconnected client, pinning the decline leg.
验收备注
Two findings outside this card's face, both filed rather than fixed here:
crud.dataPrefix:routes.datafollows the key,services.data.routestays hard-coded at/api/v1/data#16674 — the discovery document contradicts itself under a non-default prefix:routes.datafollows the key whileservices.data.routestays hard-coded at/api/v1/data(packages/metadata-protocol/src/protocol.ts, theservicesblock the REST substitution pass never reaches). Measured on the fixture in this PR: 3 of 3 connects advertised/api/v1/datainservices, 0 advertised/api/v1/objects. Not fixed here — different package, andpackages/rest/src/rest-server.ts(which owns the substitution pass a fix would extend) is held by open PR fix(rest): the dispatcher's /packages domain is the one implementation of the package read and delete routes (#14503) #16628./metawhilemetadata.prefixis live and discovery advertisesroutes.metadata = base + prefix— the #14879 defect, one key over #16675 — the same defect as this card, one key over: the scoped surface hard-codes/metain 5 sites whilemetadata.prefixis live and advertised the same way. Stated from reading, not from a failing probe — unlike the data half, no fixture was driven against a non-defaultmetadata.prefix. A second key needs its own derivation and its own fixture, which is verification surface this card does not own.One existing pin was re-pinned, deliberately — and it is why the first head was red
client.test.ts's[#6714] ... case Basserted the limitation this change removes: with a customcrud.dataPrefix,_apiBase()could not find the base and the scoped client fell back to the/api/v1convention. That fallback was never right on such a deployment — it is a 404 — it was only honest, which is why it was pinned rather than fixed.On the first pushed head (
78aef4f6, the fix without this re-pin) that pin failed in CI, exactly as it had failed locally:The received value is the mounted path on that fixture's deployment, and the expected value is the 404 — so the assertion was inverted by the fix, not broken by it. That was the only genuine failure on that head:
Test Files 1 failed | 34 passed (35),Tests 1 failed | 450 passed (451). The other six non-success check-runs were roll-up and fail-fast cascade —Test CoreandTypeScript Type Checkare aggregators, and the latter's log shows its three real lanes (typecheck-source-gates,typecheck-debt,typecheck-consumers) all concludedsuccesswhiletypecheck-workspacewascancelled, which is what the aggregator refused to call green.The pin is now three cases:
routes.metadatasupplies the second equation. Contract-first is intact: this reads a second advertised value, it does not loosen the parse of the first.routes.metadataat all, so{realBase}{dataPrefix}stays one string with two unknowns. Still declines to the convention, byte-identical.routes.metadatathat shares no base withroutes.data. The two share nothing but the leading/, which is not a shared base; deriving from it would mistake the whole ofroutes.datafor the prefix. Declines.B2 and B3 keep the decline leg the old case carried, on the shapes that are genuinely still underivable. The neighbouring case-A, case-C and scoped-discovery pins are untouched and pass.
Verification
Every reading below is named with the head it ran on, and every exit code was captured before any pipe.
pnpm --filter @objectstack/client test9778ca8a0f0pnpm --filter @objectstack/client exec tsc --noEmit9778ca8a0f0pnpm --filter @objectstack/client run check:test-typecheck9778ca8a0f09778ca8a0f0Ablation. The new pin was ablated by restoring the hard-coded
/dataon the scoped surface. The mutation was proved to reach disk in both directions before the run was believed (derived=1 literal=0before,derived=0 literal=1after, mutated blob hash differing from theHEADblob), and the restore proved bygit diff HEADempty plus a blob hash equal toHEAD's. The suite went 2 failed / 5 passed — and for the right reason: the client requested/api/v1/environments/proj-alpha/data/taskand the server answered 404ENDPOINT_NOT_FOUND, which is precisely the failure this card describes. The five still-green include all three controls, so the pin is not vacuously red. The test imports the client by relative source path (./index), not through a packageexportstodist/, so no build sits between the mutation and the run.The two NOT-MEASURED families are not gaps — CI measured both on a built tree:
pnpm check:dual-build-cjs-loads— locally exit 3, PREREQUISITE NOT MET: it reads built output and 35 packages have nodist/in this worktree; it prints "⛔ This is NOT a pass: nothing was measured." CI runs it in Build Core, which builds first.pnpm --filter @objectstack/spec run check:skill-examples— locally exit 1 for the same reason in a different shape: it refuses to readpackages/client-react/distrather than judge against a build that does not match src. It is a step of thetypecheck-consumersjob (lint.yml:5601, "runs after the build step"), whose check-runType Check · consumer gatesiscompleted/successon this head. Building two packages locally to re-measure what CI already measured on a fully built tree would buy nothing.One more family is worth naming because its first reading was not a finding:
pnpm check:type-check-debtfirst returned exit 3 — a V8 OOM under contention on this shared box, which is NOT MEASURED, not red. Re-run with--max-old-space-size=8192it is exit 0, fully measured: "5 ledger entries re-measured, 55 raw tsc errors total, none above its recorded number."The derived family count is 56, not the 47 derived before the changeset existed — adding
.changeset/client-honours-data-prefix.mdbrought in six changeset families. The list was re-derived after the real changes landed rather than trusted from the first reading. One caveat stated plainly:dispatch-gatesreports this branch as a stale tree (3 gate-source files —check-wildcard-fallthrough.mjs,engine-double-contract.pinned.json,import-prerequisite.mjs— changed onorigin/mainsince the branch point), so the local readings used this branch's copies of them. My diff touches none of those; CI runs the merge result, and it is green.Documentation
No docs change, considered and declined.
content/docs/api/environment-routing.mdxnames the/api/v1/environments/:environmentId/data/:objectanchor this change makes derived rather than literal, but the page is not falsified: the defaultcrud.dataPrefixis still/data, and on a default deployment the SDK's output is byte-identical — which is what the positive control and the untouched case-A/case-C pins assert. The page's subject is the scope segment andprojectResolution, not the data prefix, and it never mentionscrud.dataPrefix, so it makes no claim to correct. Searched more widely: no doc asserts that the SDK hard-codes or ignores the key; the only prose about a client honouring it iscontent/docs/deployment/cli.mdx, describing the CLI's own #7983 fix on a different surface, which stays true. Both docs-drift gates (scripts/docs-audit/check-affected-docs.mjs,check-drift-comment.mjs) are exit 0 on this change set. Documenting the non-default-prefix client story is a real but separate change — it adjoins the client-option branch triage fenced off — andcontent/docs/releases/is release-owned in any case; this PR's input there is its changeset.Generated by Claude Code