Skip to content

Commit a900841

Browse files
os-justinclaude
andauthored
fix(rest): /discovery no longer contradicts itself — services.*.route follows the mounted paths (#17399)
* fix(rest): mirror the mounted paths onto discovery's `services.*.route` `/discovery` states each service address twice — `routes.X` and `services.Y.route` — and the REST discovery handler rewrote only the first half to the paths this server mounts. A deployment that moved a prefix got a document that contradicted itself, with the `services` half addressing a path nothing is mounted on. The `services` half is now written as a projection of the finished `routes` map, key by key, so the two cannot state different answers whatever a future substitution does to `routes`. Three guards keep a default deployment byte-identical: only a slot the producer emitted, only one that already declares a route, and only from a route key that survived the pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt * test(rest): reduce the #9292 per-request pin past the mount projection `discovery-per-request-protocol.test.ts` compares the served document's `services` map against the raw `getDiscovery()` of the environment's own protocol, to prove `/discovery` follows the REQUEST's kernel. Mirroring the mounted paths onto `services.*.route` adds a handler overlay to that value: on a scoped request `services.data.route` now reads `/api/v1/environments/tenant-a/data`, which the producer — who cannot see a mount — will never say. `substance()` already reduces `capabilities` to its `enabled` bits for exactly this reason ("those overlays are a different contract with its own pins"). The same reduction now applies to the route VALUE, keeping the kernel-determined fact the pin is about: whether the slot advertises an address at all. The overlay itself is pinned next door in `discovery-services-route-follows-mount.test.ts`, against `routes.X` in the same served document. ⛔ Nothing is skipped, disabled or quarantined: all four `toEqual` / `not.toEqual` halves still run, and the negative halves still separate the three protocols. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 93447bb commit a900841

4 files changed

Lines changed: 489 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): `/discovery` no longer contradicts itself — `services.*.route` follows the mounted paths, like `routes.*` already did (#16674)
6+
7+
The `/discovery` document states each service's address twice: once in `routes.X` (the flat convenience map) and once in `services.Y.route` (the per-slot entry). The REST discovery handler rewrote only the first half to the paths this server actually mounts, so any deployment that moved a prefix received a document that disagreed with itself — and the `services` half pointed at a path with nothing mounted on it.
8+
9+
Measured on a boot with `crud: { dataPrefix: '/objects' }`, reading `GET /api/v1/discovery`:
10+
11+
- before — `routes.data` = `/api/v1/objects` (the mounted path), `services.data.route` = `/api/v1/data` (unmounted)
12+
- after — both answer `/api/v1/objects`
13+
14+
The same split opened on four keys at once for an `apiPath` deployment: `data`, `metadata`, `ui` and `auth`. All four now follow the mount. `services.*.route` is written as a projection of the finished `routes` map, so the two halves cannot state different answers whatever a future substitution does to `routes`.
15+
16+
**A default deployment's document does not move by a byte.** With `crud.dataPrefix` at its `/data` default and `metadata.prefix` at `/meta`, the values the correction writes are the values that were already there; only a deployment that had moved a prefix sees a change, and there the old value addressed nothing. Route-less slots (`cache`, `queue`, `job`, and an in-process `realtime` bus) never gain a route, and no advertisement is withdrawn.
17+
18+
If you have been reading `services.data.route` on a moved-prefix deployment and compensating for it — by re-deriving the path from `routes.data`, or by hard-coding the prefix — that workaround can go: the field now answers the mounted path directly.

‎packages/rest/src/discovery-per-request-protocol.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,33 @@ const API_CONFIG = {
119119
* make this file assert them too, and fail the day either description is
120120
* reworded. The `enabled` bit is what the environment's own kernel decides,
121121
* and it is what a client reads.
122+
*
123+
* [#16674] `services.*.route` is reduced the same way, and for the same
124+
* reason: since the substitution pass mirrors the mounted paths onto the
125+
* `services` half, that VALUE is a handler overlay carrying THIS SERVER's base
126+
* — on a scoped request `services.data.route` reads
127+
* `/api/v1/environments/tenant-a/data`, which the producer, who cannot see a
128+
* mount, will never say. Comparing it here would assert the mount projection
129+
* rather than the kernel's answer, and it has its own pins next door
130+
* (`discovery-services-route-follows-mount.test.ts`, which asserts it equals
131+
* `routes.X` in the served document). What this file still asserts is the part
132+
* the environment's own kernel decides: WHETHER the slot advertises an address
133+
* at all — a fact the mirror never changes, since it neither invents a route
134+
* on a route-less slot nor withdraws one.
122135
*/
123136
function substance(doc: any) {
124137
return {
125138
capabilities: Object.fromEntries(
126139
Object.entries(doc.capabilities as Record<string, { enabled: boolean }>)
127140
.map(([k, v]) => [k, v.enabled]),
128141
),
129-
services: doc.services,
142+
services: Object.fromEntries(
143+
Object.entries(doc.services as Record<string, Record<string, unknown>>)
144+
.map(([slot, info]) => {
145+
const { route, ...kernelDetermined } = info;
146+
return [slot, { ...kernelDetermined, advertisesRoute: typeof route === 'string' }];
147+
}),
148+
),
130149
locale: doc.locale,
131150
};
132151
}

0 commit comments

Comments
 (0)