From 78aef4f65689e6c2b1df76edb7a9ee054245e348 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 17:57:07 +0000 Subject: [PATCH 1/2] fix(client): read the CRUD data prefix from discovery instead of restating /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 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- .changeset/client-honours-data-prefix.md | 13 + .../client/src/client.data-prefix.test.ts | 228 ++++++++++++++++++ packages/client/src/index.ts | 156 ++++++++++-- 3 files changed, 374 insertions(+), 23 deletions(-) create mode 100644 .changeset/client-honours-data-prefix.md create mode 100644 packages/client/src/client.data-prefix.test.ts diff --git a/.changeset/client-honours-data-prefix.md b/.changeset/client-honours-data-prefix.md new file mode 100644 index 0000000000..7ce48644af --- /dev/null +++ b/.changeset/client-honours-data-prefix.md @@ -0,0 +1,13 @@ +--- +"@objectstack/client": patch +--- + +The client SDK reads the CRUD data prefix off the discovery document instead of restating `/data` as a literal, so a deployment that sets a non-default `crud.dataPrefix` is reachable through the scoped surface. + +`crud.dataPrefix` moves two things together: REST mounts every CRUD route under `${basePath}${crud.dataPrefix}`, and the discovery handler advertises the same value as `routes.data = ${realBase}${crud.dataPrefix}`. The SDK is the third surface describing those same paths, and its scoped half was not reading the value — it wrote `/data` into all seventeen of its data methods. On a deployment that moved the prefix, that half called paths the server does not mount, while the unscoped half of the *same* SDK called the right ones: the unscoped methods build `${baseUrl}${getRoute('data')}` and `routes.data` already carries the prefix. One SDK disagreed with itself about where the data routes are. + +- **`_dataPrefix()` recovers the prefix from the advertised routes.** `routes.data` is one string carrying two unknowns (`{realBase}{dataPrefix}`) and no discovery key carries either half alone, so the split is recovered in two steps. A value that already ends with the conventional `/data` *is* the default prefix — taken first, which 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. Otherwise `routes.metadata` supplies the missing equation, being `{realBase}{metadata.prefix}` over the same base, so the two advertised routes share exactly `realBase` plus whatever their 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. +- **It declines rather than guess.** Where the document does not determine the split — no advertised routes, no `routes.metadata`, or a derived prefix of `/` or empty — the derivation returns the conventional `/data`, which is byte-identical to the previous behaviour. This follows the rule the neighbouring `_apiBase()` already sets in this file, and it is why an unconnected client is unaffected. +- **`_apiBase()` strips the advertised prefix instead of the literal `/data`.** It previously declined whenever the prefix was non-default, because the only suffix it knew how to strip was `/data`. It now strips whatever `_dataPrefix()` read, so the base and the prefix are derived by one rule and cannot disagree. On every default-prefix deployment the result is unchanged. + +No new client option and no new configuration: the value is read from the server that already publishes it. A client that never calls `connect()` builds exactly the URLs it built before. diff --git a/packages/client/src/client.data-prefix.test.ts b/packages/client/src/client.data-prefix.test.ts new file mode 100644 index 0000000000..af4a75b951 --- /dev/null +++ b/packages/client/src/client.data-prefix.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `crud.dataPrefix` is honoured by the SDK, not restated by it (#14879). + * + * THE CONTRACT. `crud.dataPrefix` is a live `RestServerConfig` key: REST mounts + * every CRUD route under `dataPath = ${basePath}${crud.dataPrefix}` and the + * discovery handler advertises the same value as + * `routes.data = ${realBase}${crud.dataPrefix}`. Three surfaces describe one + * set of paths — the mounts, the discovery document, and this SDK — and the + * liveness ledger classifies the key `live` precisely because it "moves the + * mounted paths and the advertised discovery document together". + * + * WHAT WAS WRONG. The SDK's scoped surface restated `/data` as a literal in + * every one of its data methods, so on a deployment that moved the prefix it + * called paths the server does not mount. The unscoped twin of each of those + * methods was already correct — it builds `${baseUrl}${getRoute('data')}` and + * `routes.data` already carries the prefix — so ONE SDK disagreed with itself: + * the unscoped half read the advertised value while the scoped half guessed. + * + * WHY THE FIXTURE CREATES THE CONDITION. Measured on `origin/main`, no in-repo + * caller sets a non-default `dataPrefix`, so no existing fixture exercises + * this and nothing in the tree is broken today; the exposure is external + * deployments. So this suite BOOTS a server on a non-default prefix rather + * than looking for one. + * + * WHY A LIVE SERVER AND A RECORDED URL. A mock that answers 200 to whatever it + * is asked cannot tell a mounted path from an unmounted one — it would go + * green against the very bug this pins. So the server is real, and the suite + * asserts BOTH halves of the claim: that the URL the client puts on the wire + * is the one the server actually mounts, and (`serves nothing at /data`) that + * the old hard-coded path is genuinely dead on this deployment, which is what + * makes the first assertion mean something. + * + * THE POSITIVE CONTROL. The same drive runs 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": the + * default deployment must still be reached at `/data`, byte-for-byte as + * before. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import { createRestApiPlugin } from '@objectstack/runtime'; +import { ObjectStackClient } from './index'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +const ENV_ID = 'proj-alpha'; +const CUSTOM_PREFIX = '/objects'; + +interface Fixture { + baseUrl: string; + kernel: LiteKernel; +} + +/** + * One boot recipe, two prefixes — so the non-default case and the control + * differ in exactly the key under test and nothing else. + */ +async function bootServer(dataPrefix?: string): Promise { + const kernel = new LiteKernel(); + kernel.use(new ObjectQLPlugin()); + // Same reason as the sibling scoping suite (#3963): the anonymous-deny + // gate is unconditional, so a live-server client suite needs a session. + kernel.use({ + metadata: { name: 'test-auth', version: '1.0.0' }, + async init(ctx: any) { + ctx.registerService('auth', { + api: { getSession: async () => ({ user: { id: 'test-user' } }) }, + }); + }, + } as any); + + const honoPlugin = new HonoServerPlugin({ port: 0 }); + kernel.use(honoPlugin); + + kernel.use( + createRestApiPlugin({ + api: { + api: { + // Routing test, no auth stack mounted (ADR-0056 D2). + requireAuth: false, + enableProjectScoping: true, + projectResolution: 'auto', + } as any, + // The key under test. Omitted entirely for the control, so the + // control runs the schema's own `.default('/data')` rather than + // a second literal written here. + ...(dataPrefix ? { crud: { dataPrefix } as any } : {}), + }, + }), + ); + + await kernel.bootstrap(); + + const ql = kernel.getService('objectql'); + ql.registerDriver(new SqliteWasmDriver({ filename: ':memory:' }) as never, true); + ql.registerObject({ + name: 'task', + label: 'Task', + fields: { title: { type: 'text', label: 'Title' } }, + }); + // Registered after bootstrap, so nothing has issued the DDL yet (#4065). + await ql.syncObjectSchema('task'); + + const httpServer = kernel.getService('http.server'); + const port = httpServer.getPort!(); + return { baseUrl: `http://localhost:${port}`, kernel }; +} + +async function shutdown(fixture: Fixture | undefined): Promise { + if (!fixture?.kernel) return; + await Promise.race([ + fixture.kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); +} + +/** + * A client whose every request URL is recorded. The recorder DELEGATES to the + * real fetch, so the recorded URL and the server's real answer are the same + * exchange — the assertion cannot pass on a URL that was never served. + */ +function recordingClient(baseUrl: string): { client: ObjectStackClient; urls: string[] } { + const urls: string[] = []; + const client = new ObjectStackClient({ + baseUrl, + fetch: (input: RequestInfo | URL, init?: RequestInit) => { + urls.push(typeof input === 'string' ? input : String(input)); + return globalThis.fetch(input as any, init); + }, + } as any); + return { client, urls }; +} + +describe('SDK honours crud.dataPrefix (#14879)', () => { + describe(`non-default prefix (${CUSTOM_PREFIX})`, () => { + let fx: Fixture; + + beforeAll(async () => { fx = await bootServer(CUSTOM_PREFIX); }, 30_000); + afterAll(async () => { await shutdown(fx); }, 30_000); + + it('mounts scoped CRUD under the configured prefix, and serves nothing at /data', async () => { + const mounted = await fetch(`${fx.baseUrl}/api/v1/environments/${ENV_ID}${CUSTOM_PREFIX}/task?top=5`); + expect(mounted.status).toBe(200); + + // The half that makes this fixture worth anything: the path the + // SDK used to hard-code is genuinely not mounted here. + const hardCoded = await fetch(`${fx.baseUrl}/api/v1/environments/${ENV_ID}/data/task?top=5`); + expect(hardCoded.status).toBe(404); + }); + + it('advertises the prefix on the discovery document', async () => { + const res = await fetch(`${fx.baseUrl}/api/v1/discovery`); + expect(res.status).toBe(200); + const body = await res.json(); + const routes = (body?.data ?? body)?.routes; + expect(routes?.data).toBe(`/api/v1${CUSTOM_PREFIX}`); + }); + + it('scoped data.find() calls the mounted path, not /data', async () => { + const { client, urls } = recordingClient(fx.baseUrl); + await client.connect(); + + const scoped = client.environment(ENV_ID); + await expect(scoped.data.find('task')).resolves.toBeDefined(); + + const dataCalls = urls.filter((u) => u.includes('/task')); + expect(dataCalls).toHaveLength(1); + expect(dataCalls[0]).toContain(`/api/v1/environments/${ENV_ID}${CUSTOM_PREFIX}/task`); + expect(dataCalls[0]).not.toContain('/data/'); + }); + + it('scoped data.query() calls the mounted path, not /data', async () => { + const { client, urls } = recordingClient(fx.baseUrl); + await client.connect(); + + const scoped = client.environment(ENV_ID); + await expect(scoped.data.query('task', { top: 1 })).resolves.toBeDefined(); + + const queryCalls = urls.filter((u) => u.includes('/task/query')); + expect(queryCalls).toHaveLength(1); + expect(queryCalls[0]).toContain(`/api/v1/environments/${ENV_ID}${CUSTOM_PREFIX}/task/query`); + expect(queryCalls[0]).not.toContain('/data/'); + }); + }); + + describe('positive control — default prefix on the same fixture', () => { + let fx: Fixture; + + beforeAll(async () => { fx = await bootServer(); }, 30_000); + afterAll(async () => { await shutdown(fx); }, 30_000); + + it('advertises /api/v1/data', async () => { + const res = await fetch(`${fx.baseUrl}/api/v1/discovery`); + const body = await res.json(); + const routes = (body?.data ?? body)?.routes; + expect(routes?.data).toBe('/api/v1/data'); + }); + + it('scoped data.find() still calls /data — unchanged by the derivation', async () => { + const { client, urls } = recordingClient(fx.baseUrl); + await client.connect(); + + const scoped = client.environment(ENV_ID); + await expect(scoped.data.find('task')).resolves.toBeDefined(); + + const dataCalls = urls.filter((u) => u.includes('/task')); + expect(dataCalls).toHaveLength(1); + expect(dataCalls[0]).toContain(`/api/v1/environments/${ENV_ID}/data/task`); + }); + + it('an unconnected client declines to the /data convention', async () => { + // No `connect()`, so there is no advertised document to read. The + // derivation must fall back to today's literal rather than invent + // a prefix -- this is the "declines rather than guess" leg. + const { client, urls } = recordingClient(fx.baseUrl); + const scoped = client.environment(ENV_ID); + await expect(scoped.data.find('task')).resolves.toBeDefined(); + + expect(urls).toHaveLength(1); + expect(urls[0]).toContain(`/api/v1/environments/${ENV_ID}/data/task`); + }); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 747de66659..898dd9dd4f 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1399,6 +1399,18 @@ export interface OrganizationTeamMemberRemovedReceipt { message: 'Team member removed successfully.'; } +/** + * The conventional CRUD data prefix — `CrudEndpointsConfigSchema.dataPrefix` + * in `packages/spec` declares `.default('/data')`, and REST mounts every data + * route under `${basePath}${crud.dataPrefix}`. + * + * This is the same kind of value as the `routeMap` conventions below: what the + * SDK falls back to when discovery has not told it otherwise, NOT a competing + * source of truth. `_dataPrefix()` prefers the advertised value in every case + * where it can read one. + */ +const DEFAULT_DATA_PREFIX = '/data'; + export class ObjectStackClient { private baseUrl: string; private token?: string; @@ -3073,6 +3085,77 @@ export class ObjectStackClient { /** @internal */ _isFilterAST(v: unknown): boolean { return this.isFilterAST(v); } + /** + * @internal The CRUD data prefix this client's server actually mounts, read + * off the advertised routes (#14879). + * + * `crud.dataPrefix` moves the mounted CRUD paths and the advertised + * discovery document TOGETHER — REST builds every data route as + * `${basePath}${crud.dataPrefix}` and then advertises the same value as + * `routes.data = ${realBase}${crud.dataPrefix}`. The SDK is the third + * surface that has to describe those same paths, so it must read the value + * rather than restate it: a deployment on a non-default prefix mounts + * nothing at `/data`, and a client that assumes `/data` calls paths that do + * not exist. + * + * The advertised `routes.data` is `{realBase}{dataPrefix}` — ONE string + * carrying TWO unknowns, and no discovery key carries either half alone. + * The split is recovered in the order below, and where it cannot be + * recovered this DECLINES to the conventional `/data` rather than guess, + * exactly as `_apiBase()` declines rather than return a base of unknown + * shape: + * + * 1. If the advertised value already ends with the conventional `/data`, + * that IS the prefix. Taking this first is what makes the change + * incapable of regressing a deployment that works today: every rule + * below can only run in the branch where the current code — which + * knows the single literal `/data` — is ALREADY wrong. + * 2. Otherwise `routes.metadata` supplies the missing equation. It is + * `{realBase}{metadata.prefix}` over the SAME `realBase` (both are + * substituted from one `realBase` in the same discovery handler), so + * the two advertised routes share exactly `realBase` plus whatever + * their two prefixes happen to share. Cutting their common run back to + * its last `/` therefore lands on the `realBase` boundary, and the + * remainder of `routes.data` is the prefix. This is also correct when + * the document was served from the environment-scoped mount, where + * both routes carry the same `/environments/{id}` segment and it + * simply becomes part of the shared run. + * + * A derived prefix of `/` or empty is not a prefix this understands, so it + * declines too, as does the case where the two routes share nothing but the + * leading `/` and therefore share no base at all. The one shape that survives all of it — a deployment that + * moved `dataPrefix` off `/data` AND whose `routes.metadata` is not + * substituted from the same base (i.e. `api.enableMetadata` is off) — is + * one the SDK cannot serve today either; it keeps today's answer. + */ + _dataPrefix(): string { + const data = this.discoveryInfo?.routes?.data; + if (typeof data !== 'string' || !data) return DEFAULT_DATA_PREFIX; + + // (1) The conventional prefix — today's entire rule, kept first. + if (data.endsWith(DEFAULT_DATA_PREFIX)) return DEFAULT_DATA_PREFIX; + + // (2) `routes.metadata` as the second equation over the same `realBase`. + const meta = this.discoveryInfo?.routes?.metadata; + if (typeof meta !== 'string' || !meta || meta === data) return DEFAULT_DATA_PREFIX; + + let shared = 0; + while (shared < data.length && shared < meta.length + && data.charCodeAt(shared) === meta.charCodeAt(shared)) shared++; + + // `boundary === 0` means the two advertised routes have nothing in common + // but the leading `/` — so they do NOT share a `realBase`, and the whole of + // `routes.data` would be mistaken for the prefix. That happens when + // `routes.metadata` was never substituted from this deployment's base (its + // endpoints are off, so it still carries the conventional literal) while + // `routes.data` was. Decline: a wrong prefix is worse than today's. + const boundary = data.lastIndexOf('/', shared - 1); + if (boundary <= 0) return DEFAULT_DATA_PREFIX; + + const derived = data.slice(boundary); + return derived.length > 1 ? derived : DEFAULT_DATA_PREFIX; + } + /** * @internal The unscoped API base this client's server actually serves, * derived from the advertised routes (#6714 face 3). @@ -3082,10 +3165,12 @@ export class ObjectStackClient { * / `environmentId` — no path), so the one derivable source is * `routes.data`: the REST discovery endpoint advertises it as * `{realBase}{dataPrefix}` with `dataPrefix` defaulting to `/data`. This - * derivation strips that conventional suffix; when the deployment customises - * `dataPrefix` away from `/data` the derivation declines and the caller - * falls back to the `/api/v1` convention — exactly today's behavior, so the - * change is strictly "follow the advertised base when it is derivable". + * derivation strips that advertised suffix — `_dataPrefix()` reads which + * suffix it is (#14879), so a deployment that moves `crud.dataPrefix` off + * the default no longer forces this derivation to decline. When the suffix + * is not derivable either, the caller falls back to the `/api/v1` + * convention — exactly today's behavior, so the change is strictly "follow + * the advertised base when it is derivable". * * When the discovery response was served from the environment-scoped mount * (`scoping.scoped`), `routes.data` is `{base}/environments/{id}/data`; the @@ -3106,8 +3191,9 @@ export class ObjectStackClient { */ _apiBase(): string { const data = this.discoveryInfo?.routes?.data; - if (typeof data === 'string' && data.endsWith('/data')) { - let base = data.slice(0, -'/data'.length); + const dataPrefix = this._dataPrefix(); + if (typeof data === 'string' && data.endsWith(dataPrefix)) { + let base = data.slice(0, -dataPrefix.length); const scoping = this.discoveryInfo?.scoping; if (scoping?.scoped) { const advertised = typeof scoping.environmentId === 'string' && scoping.environmentId @@ -6671,6 +6757,30 @@ export class ScopedEnvironmentClient { return `${this.parent._baseUrl()}${this.scope()}${suffix}`; } + /** + * URL for a route mounted under the deployment's CRUD data prefix (#14879). + * + * Every route reached through here is mounted by REST as + * `${dataPath}/...` with `dataPath = ${basePath}${crud.dataPrefix}`, so the + * prefix is deployment state, not a constant. The unscoped twin of each of + * these methods already reads it — it builds `${baseUrl}${getRoute('data')}` + * and `routes.data` IS `{realBase}{dataPrefix}`. This surface restated + * `/data` as a literal instead, so on a deployment that moved + * `crud.dataPrefix` the scoped half of one SDK called paths the server does + * not mount while the unscoped half of the same SDK called the right ones. + * + * The scoped form cannot consume `routes.data` verbatim the way the + * unscoped form does: the environment segment goes BETWEEN the API base and + * the prefix (`{base}/environments/{id}{dataPrefix}`), and the id is this + * client's, which need not be the one discovery resolved. So the two halves + * are taken separately — `_apiBase()` for the base, `_dataPrefix()` for the + * prefix — and both decline to today's conventions when the advertised + * document does not determine them. + */ + private dataUrl(suffix: string): string { + return `${this.parent._baseUrl()}${this.scope()}${this.parent._dataPrefix()}${suffix}`; + } + /** * Metadata operations scoped to this project. */ @@ -6785,7 +6895,7 @@ export class ScopedEnvironmentClient { */ data = { query: async (object: string, query: Partial): Promise> => { - const res = await this.parent._fetch(this.url(`/data/${object}/query`), { + const res = await this.parent._fetch(this.dataUrl(`/${object}/query`), { method: 'POST', body: JSON.stringify(query), }); @@ -6851,22 +6961,22 @@ export class ScopedEnvironmentClient { } const qs = queryParams.toString(); - const res = await this.parent._fetch(this.url(`/data/${object}${qs ? `?${qs}` : ''}`)); + const res = await this.parent._fetch(this.dataUrl(`/${object}${qs ? `?${qs}` : ''}`)); return this.parent._unwrap>(res); }, get: async (object: string, id: string): Promise> => { - const res = await this.parent._fetch(this.url(`/data/${object}/${id}`)); + const res = await this.parent._fetch(this.dataUrl(`/${object}/${id}`)); return this.parent._unwrap>(res); }, create: async (object: string, data: Partial): Promise> => { - const res = await this.parent._fetch(this.url(`/data/${object}`), { + const res = await this.parent._fetch(this.dataUrl(`/${object}`), { method: 'POST', body: JSON.stringify(data), }); return this.parent._unwrap>(res); }, createMany: async (object: string, data: Partial[]): Promise => { - const res = await this.parent._fetch(this.url(`/data/${object}/createMany`), { + const res = await this.parent._fetch(this.dataUrl(`/${object}/createMany`), { method: 'POST', body: JSON.stringify(data), }); @@ -6881,7 +6991,7 @@ export class ScopedEnvironmentClient { * validates + previews without persisting. */ import: async (object: string, request: ImportRequest): Promise => { - const res = await this.parent._fetch(this.url(`/data/${object}/import`), { + const res = await this.parent._fetch(this.dataUrl(`/${object}/import`), { method: 'POST', body: JSON.stringify(request), }); @@ -6893,18 +7003,18 @@ export class ScopedEnvironmentClient { * them in the background while callers poll progress / results / history. */ createImportJob: async (object: string, request: CreateImportJobRequest): Promise => { - const res = await this.parent._fetch(this.url(`/data/${object}/import/jobs`), { + const res = await this.parent._fetch(this.dataUrl(`/${object}/import/jobs`), { method: 'POST', body: JSON.stringify(request), }); return this.parent._unwrap(res); }, getImportJobProgress: async (jobId: string): Promise => { - const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}`)); + const res = await this.parent._fetch(this.dataUrl(`/import/jobs/${encodeURIComponent(jobId)}`)); return this.parent._unwrap(res); }, getImportJobResults: async (jobId: string): Promise => { - const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}/results`)); + const res = await this.parent._fetch(this.dataUrl(`/import/jobs/${encodeURIComponent(jobId)}/results`)); return this.parent._unwrap(res); }, listImportJobs: async (query: Partial = {}): Promise => { @@ -6914,31 +7024,31 @@ export class ScopedEnvironmentClient { if (query.limit != null) qs.set('limit', String(query.limit)); if (query.offset != null) qs.set('offset', String(query.offset)); const suffix = qs.toString() ? `?${qs.toString()}` : ''; - const res = await this.parent._fetch(this.url(`/data/import/jobs${suffix}`)); + const res = await this.parent._fetch(this.dataUrl(`/import/jobs${suffix}`)); const body = await this.parent._unwrap(res); return body.jobs; }, cancelImportJob: async (jobId: string): Promise<{ success: boolean }> => { - const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}/cancel`), { + const res = await this.parent._fetch(this.dataUrl(`/import/jobs/${encodeURIComponent(jobId)}/cancel`), { method: 'POST', }); return this.parent._unwrap<{ success: boolean }>(res); }, undoImportJob: async (jobId: string): Promise => { - const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}/undo`), { + const res = await this.parent._fetch(this.dataUrl(`/import/jobs/${encodeURIComponent(jobId)}/undo`), { method: 'POST', }); return this.parent._unwrap(res); }, update: async (object: string, id: string, data: Partial): Promise> => { - const res = await this.parent._fetch(this.url(`/data/${object}/${id}`), { + const res = await this.parent._fetch(this.dataUrl(`/${object}/${id}`), { method: 'PATCH', body: JSON.stringify(data), }); return this.parent._unwrap>(res); }, batch: async (object: string, request: BatchUpdateRequest): Promise => { - const res = await this.parent._fetch(this.url(`/data/${object}/batch`), { + const res = await this.parent._fetch(this.dataUrl(`/${object}/batch`), { method: 'POST', body: JSON.stringify(request), }); @@ -6965,21 +7075,21 @@ export class ScopedEnvironmentClient { options?: BatchOptions, ): Promise => { const request: UpdateManyRequest = { records, options }; - const res = await this.parent._fetch(this.url(`/data/${object}/updateMany`), { + const res = await this.parent._fetch(this.dataUrl(`/${object}/updateMany`), { method: 'POST', body: JSON.stringify(request), }); return this.parent._unwrap(res); }, delete: async (object: string, id: string): Promise => { - const res = await this.parent._fetch(this.url(`/data/${object}/${id}`), { + const res = await this.parent._fetch(this.dataUrl(`/${object}/${id}`), { method: 'DELETE', }); return this.parent._unwrap(res); }, deleteMany: async (object: string, ids: string[], options?: BatchOptions): Promise => { const request: DeleteManyRequest = { ids, options }; - const res = await this.parent._fetch(this.url(`/data/${object}/deleteMany`), { + const res = await this.parent._fetch(this.dataUrl(`/${object}/deleteMany`), { method: 'POST', body: JSON.stringify(request), }); From 9778ca8a0ffeacb3a2037bacabf55a3ab72c858f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 18:23:58 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(client):=20re-pin=20#6714=20case=20B?= =?UTF-8?q?=20=E2=80=94=20a=20custom=20dataPrefix=20no=20longer=20makes=20?= =?UTF-8?q?the=20base=20underivable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --- packages/client/src/client.test.ts | 56 +++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 1b6d8b3144..231968c30b 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -2129,16 +2129,62 @@ describe('ScopedEnvironmentClient', () => { expect(String(fetchMock.mock.calls[4][0])).toBe(`${base}/batch`); }); - it('[#6714] a custom dataPrefix makes the base underivable — the convention holds, byte-identical (case B)', async () => { + it('[#14879] a custom dataPrefix no longer makes the base underivable — `routes.metadata` is the second equation (case B1)', async () => { const { client, fetchMock } = createMockClient({ types: [] }); - // routes.data does not end with the conventional `/data`, so the base - // cannot be derived honestly; the client must NOT guess (contract-first - // — no lenient re-parsing) and falls back to the convention, - // byte-identical to the pre-#6714 behavior. + // WAS pinned the other way. Until #14879 this case asserted the + // convention `/api/v1/...`, because the only suffix `_apiBase()` knew + // how to strip was the literal `/data`, so a custom `crud.dataPrefix` + // made the base undetectable and the client fell back. + // + // That fallback was never RIGHT on this deployment — it is a 404; it + // was merely honest, which is why it was pinned rather than fixed. + // `_dataPrefix()` now recovers the split without guessing: + // `routes.metadata` is `{realBase}{metadata.prefix}` over the SAME + // base, so the two advertised routes share exactly `realBase` plus + // whatever their prefixes share, and cutting that common run back to + // its last `/` lands on the boundary. Here that yields `/records`, the + // base `/backend/api/v9`, and the path the server actually mounts. + // + // Contract-first is unchanged: this reads a second ADVERTISED value, + // it does not loosen the parse of the first. (client as any)['discoveryInfo'] = { routes: { data: '/backend/api/v9/records', metadata: '/backend/api/v9/meta' }, }; await client.environment('proj-123').meta.getTypes(); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/backend/api/v9/environments/proj-123/meta', + ); + }); + + it('[#14879] a custom dataPrefix with no second equation still declines to the convention (case B2)', async () => { + const { client, fetchMock } = createMockClient({ types: [] }); + // The decline leg the case above used to carry, kept alive on the + // shape that is genuinely still underivable: `routes.data` does not end + // with the conventional `/data` AND there is no `routes.metadata` to + // supply the missing equation, so `{realBase}{dataPrefix}` stays one + // string with two unknowns. The client must NOT guess a split — it + // falls back to the convention, byte-identical to the pre-#14879 + // behavior. + (client as any)['discoveryInfo'] = { + routes: { data: '/backend/api/v9/records' }, + }; + await client.environment('proj-123').meta.getTypes(); + expect(String(fetchMock.mock.calls[0][0])).toBe( + 'http://localhost:3000/api/v1/environments/proj-123/meta', + ); + }); + + it('[#14879] two advertised routes sharing no base decline rather than mistake the whole path for a prefix (case B3)', async () => { + const { client, fetchMock } = createMockClient({ types: [] }); + // `routes.metadata` present but NOT substituted from this deployment's + // base (its endpoints are off, so it still carries the conventional + // literal). The two routes then share nothing but the leading `/`, + // which is not a shared `realBase` — deriving from it would hand back + // the whole of `routes.data` as the prefix. Decline instead. + (client as any)['discoveryInfo'] = { + routes: { data: '/backend/api/v9/records', metadata: '/api/v1/meta' }, + }; + await client.environment('proj-123').meta.getTypes(); expect(String(fetchMock.mock.calls[0][0])).toBe( 'http://localhost:3000/api/v1/environments/proj-123/meta', );