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/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', ); 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), });