diff --git a/.changeset/adr0006-d2-client-environments-namespace.md b/.changeset/adr0006-d2-client-environments-namespace.md new file mode 100644 index 0000000000..0efcf62b5e --- /dev/null +++ b/.changeset/adr0006-d2-client-environments-namespace.md @@ -0,0 +1,138 @@ +--- +"@objectstack/client": minor +"@objectstack/cli": minor +--- + +feat(client,cli)!: `client.projects.*` becomes `client.environments.*`, the scoped sub-client becomes `ScopedEnvironmentClient`, and the unwrap keys follow the wire (#12866, #12882, ADR-0006 D2) + + + + +**BREAKING** public-API rename on `@objectstack/client`, and a breaking change to +the `--format json` payload of the `os environments` command family. It lands +after the v17.0.0 cut, so the lockstep launch-window convention ships it as +`minor` (`scripts/check-changeset-no-major.mjs`); the version number is not the +migration signal here, this entry is. + +This is the **SDK half** of one coordinated cross-repo rename. The **producer +half** is the cloud control plane, which renames the same field keys on the same +endpoints. Neither half ships alone: shipping the SDK half by itself is +ADR-0006 D3, permanently declined, as is any mapping layer between the two +spellings. + +## Migration + +**No aliases exist.** The old namespace is gone, not deprecated — there is no +`client.projects` getter, no `res.project ?? res.environment` hedge, and none is +coming (ADR-0006 D3 declined a mapping layer with reasons; the v5.0 rename rule +「no aliases」 is the standing one). Every call site moves in one edit. + +### Method namespace + +| before | after | +| --- | --- | +| `client.projects.list(…)` | `client.environments.list(…)` | +| `client.projects.get(id)` | `client.environments.get(id)` | +| `client.projects.create(req)` | `client.environments.create(req)` | +| `client.projects.update(id, patch)` | `client.environments.update(id, patch)` | +| `client.projects.delete(id, opts)` | `client.environments.delete(id, opts)` | +| `client.projects.activate(id)` | `client.environments.activate(id)` | +| `client.projects.rotateCredential(…)` | `client.environments.rotateCredential(…)` | +| `client.projects.updateHostname(…)` | `client.environments.updateHostname(…)` | +| `client.projects.updateVisibility(…)` | `client.environments.updateVisibility(…)` | +| `client.projects.listRevisions(…)` | `client.environments.listRevisions(…)` | +| `client.projects.listBranches(id)` | `client.environments.listBranches(id)` | +| `client.projects.renameBranch(…)` | `client.environments.renameBranch(…)` | +| `client.projects.deleteBranch(…)` | `client.environments.deleteBranch(…)` | +| `client.projects.retryProvisioning(id)` | `client.environments.retryProvisioning(id)` | +| `client.projects.listDrivers()` | `client.environments.listDrivers()` | +| `client.projects.packages.*` | `client.environments.packages.*` | + +The URL paths are unchanged — they were already on the `environments` spelling +(`/api/v1/cloud/environments/…`). Only the method namespace and the response +field keys move. + +### Response keys + +| before | after | where | +| --- | --- | --- | +| `res.projects` | `res.environments` | `list` (the `total` key is unchanged) | +| `res.project` | `res.environment` | `get`, `update`, `activate`, `updateHostname`, `updateVisibility`, `retryProvisioning` | + +The joined blocks on `get` (`database`, `credential`, `membership`, +`organization`) keep their names, as do every `packages.*` key, the +`delete`/`listBranches`/`renameBranch`/`deleteBranch` payloads (already +`environmentId`-keyed), and `listRevisions`. + +### Two declarations that were false before this change + +Measured 2026-08-28 against the cloud repo's `main`, and corrected here rather +than carried forward under a new spelling: + +- **`create` never answered a `project` key at all.** `POST /api/v1/cloud/environments` + has always answered `{ environment, warnings, durationMs, hostnameAssignment? }`. + The old `{ project: any; database: any }` declaration was not merely + pre-rename, it was wrong against the running control plane — and + `os environments create` read `res.project.id` through it, so the default + `--activate` silently never activated and the table output printed + `undefined`. Both are fixed by this rename. +- **`create` declares no `database` key.** That route does not send one; the key + was declared NON-optional, so `res.database.driver` typechecked and threw. The + method that really answers a `database` block is `get`, which keeps it. + +The keys `create` does send beside `environment` (`warnings`, `durationMs`, +`hostnameAssignment`) are deliberately still undeclared — adding them is new +published surface and a separate decision. + +### The environment-scoped sub-client (#12882) + +The fourth `project`-spelled surface on the same class, folded in by the same +maintainer ruling. ADR-0006's D1 census named three surfaces and missed this one; +it was an oversight, not a deliberate retention. + +| before | after | +| --- | --- | +| `client.project(id)` | `client.environment(id)` | +| `ScopedProjectClient` (exported class) | `ScopedEnvironmentClient` | + +Same no-alias rule: neither old spelling survives. `client.project(id)` is not a +deprecated method, it is gone, and the exported class is gone under its old name +— a `import { ScopedProjectClient }` fails at the import line, which is the +loudest and most precise channel this change has. + +Nothing about the behaviour moves: the scoped client still prefixes +`/api/v1/environments/:environmentId/...`, still exposes the same `data` / `meta` +/ `batch` / `packages` shape, and the thrown guard message becomes +`[ObjectStack] environment(id): environmentId is required`. + +**Deliberately NOT renamed, because each is a different surface needing its own +decision:** `setProjectId` / `getProjectId` on the client — `getProjectId` is a +cross-package protocol contract that `packages/runtime` and +`packages/metadata-protocol` both speak, so it is a coordinated rename, not a +local one — and the REST API config keys `enableProjectScoping` / +`projectResolution`, which are live keys read by `packages/cli/src/commands/serve.ts`. +The docblocks that name them are worded so they stay true. + +Note for whoever compiles the release notes: four other pending changesets in +this release describe methods on `ScopedProjectClient` under its old name +(`client-unannotated-return-erasure`, `client-saveitem-ifmatch-header`, +`client-meta-saveitem-query-options`, `client-precise-sdk-return-types`). They +were accurate when written and are deliberately left alone; this entry is the one +that renames the class. + +### JSDoc + +The `create` docblock claimed the server delegates to +`ProjectProvisioningService.provisionProject`. That spelling has zero hits in the +control plane (measured 2026-08-28). Both this SDK's docblock and +`os environments create`'s now name the **endpoint** instead, which is the one +identifier an in-repo reader can verify — the class lives in a repo this one +never compiles against, so no gate here could ever have caught the rot. + +## CLI + +`os environments list | show | create | switch | bind` follow the same rename. +No flag, argument, exit code or command id changes. `--format json` / `--format yaml` +payloads are `formatOutput(res, …)` straight from the control-plane response, so +their top-level keys change with the wire: a script reading `.projects` or +`.project` from those payloads reads `.environments` / `.environment` instead. diff --git a/content/docs/api/environment-routing.mdx b/content/docs/api/environment-routing.mdx index e20cf466bc..42e922d9fd 100644 --- a/content/docs/api/environment-routing.mdx +++ b/content/docs/api/environment-routing.mdx @@ -105,7 +105,7 @@ const client = new ObjectStackClient({ baseUrl: 'https://api.example.com', }); -const env = client.project('env_prod'); +const env = client.environment('env_prod'); await env.data.find('customer', { top: 20 }); await env.meta.getItems('object'); @@ -153,8 +153,9 @@ one, so they are intentionally excluded from data-plane resolution. 2. Replace `X-Project-Id` with `X-Environment-Id`. 3. Replace `OS_PROJECT_ID` with `OS_ENVIRONMENT_ID`. 4. Store runtime rows under `environment_id`. -5. Keep the SDK `client.project(id)` call only as a compatibility method name; - pass an environment id to it. +5. Replace the SDK `client.project(id)` call with `client.environment(id)`. It + was a compatibility method name until ADR-0006 D2; there is no alias, so the + old spelling does not resolve. --- diff --git a/content/docs/concepts/north-star.mdx b/content/docs/concepts/north-star.mdx index ec5e3eced4..af02971c6f 100644 --- a/content/docs/concepts/north-star.mdx +++ b/content/docs/concepts/north-star.mdx @@ -116,8 +116,11 @@ environments and are not themselves scoped data-plane requests. - Some docs and source comments still use historical `project` wording where current runtime identity is `environment`. -- The SDK method name `client.project(id)` remains as a compatibility surface, - but it accepts an environment id and generates environment-scoped URLs. +- The SDK client still exposes `setProjectId` / `getProjectId`, and the REST API + config still spells its keys `enableProjectScoping` / `projectResolution`. + `getProjectId` is the harder one: it is a cross-package protocol contract that + `packages/runtime` and `packages/metadata-protocol` both speak, so it is a + coordinated rename rather than a local one. - The notification framework pipeline is ahead of the objectui bell cut-over; read-state should move through `sys_notification_receipt`. - Some generated references need regeneration after Zod description updates. diff --git a/packages/cli/src/commands/environments/bind.ts b/packages/cli/src/commands/environments/bind.ts index ddede274a8..2788822623 100644 --- a/packages/cli/src/commands/environments/bind.ts +++ b/packages/cli/src/commands/environments/bind.ts @@ -103,9 +103,9 @@ export default class EnvironmentsBind extends Command { requireAuth(token); // Fetch existing metadata so we don't blow it away. - const current = await client.projects.get(args.environmentId); - const existingMeta: Record = (current?.project?.metadata && typeof current.project.metadata === 'object') - ? { ...current.project.metadata as Record } + const current = await client.environments.get(args.environmentId); + const existingMeta: Record = (current?.environment?.metadata && typeof current.environment.metadata === 'object') + ? { ...current.environment.metadata as Record } : {}; // Drop the prior bind error so the UI doesn't show a stale failure. delete existingMeta.artifactBindError; @@ -114,7 +114,7 @@ export default class EnvironmentsBind extends Command { printKV('Environment', args.environmentId, '🎯'); printKV('Artifact', artifactAbs, '📦'); - const res = await client.projects.update(args.environmentId, { + const res = await client.environments.update(args.environmentId, { metadata: existingMeta, }); diff --git a/packages/cli/src/commands/environments/create.ts b/packages/cli/src/commands/environments/create.ts index 0bfac47d6e..f652d36072 100644 --- a/packages/cli/src/commands/environments/create.ts +++ b/packages/cli/src/commands/environments/create.ts @@ -9,7 +9,14 @@ import { readAuthConfig, writeAuthConfig } from '../../utils/auth-config.js'; /** * `os environments create` — provision a new environment. * - * Delegates to `ProjectProvisioningService.provisionProject` on the server. + * Calls `client.environments.create`, i.e. `POST /api/v1/cloud/environments` on + * the control plane. This docblock names the ENDPOINT rather than a server + * class on purpose: it used to name `ProjectProvisioningService.provisionProject`, + * which the control plane does not have (measured 2026-08-28 — zero hits for + * that spelling in the cloud repo's `packages/service-cloud/src`). The server + * lives in a repo this one never compiles against, so a class name here rots + * with nothing to catch it. + * * On success, optionally activates the new environment for the current session * and persists `activeEnvironmentId` into `~/.objectstack/credentials.json` * (unless `--no-activate` is passed). @@ -81,7 +88,7 @@ export default class EnvironmentsCreate extends Command { metadata = { artifact_path: abs }; } - const res = await client.projects.create({ + const res = await client.environments.create({ organization_id: flags.org, display_name: flags.name, plan: flags.plan, @@ -90,12 +97,12 @@ export default class EnvironmentsCreate extends Command { ...(metadata ? { metadata } : {}), }); - if (flags.activate && res?.project?.id) { + if (flags.activate && res?.environment?.id) { try { - await client.projects.activate(res.project.id); + await client.environments.activate(res.environment.id); const cfg = await readAuthConfig().catch(() => null); if (cfg) { - cfg.activeEnvironmentId = res.project.id; + cfg.activeEnvironmentId = res.environment.id; cfg.lastUsedAt = new Date().toISOString(); await writeAuthConfig(cfg); } @@ -110,7 +117,7 @@ export default class EnvironmentsCreate extends Command { } else if (flags.format === 'yaml') { await formatOutput(res, 'yaml'); } else { - const p = res?.project ?? {}; + const p = res?.environment ?? {}; console.log(`\n✓ Environment created: ${p.display_name ?? p.id} (${p.id})`); if (flags.activate) { console.log(` active environment set to ${p.id}`); diff --git a/packages/cli/src/commands/environments/list.ts b/packages/cli/src/commands/environments/list.ts index a125936f2f..8f71fa58b5 100644 --- a/packages/cli/src/commands/environments/list.ts +++ b/packages/cli/src/commands/environments/list.ts @@ -45,23 +45,23 @@ export default class EnvironmentsList extends Command { requireAuth(token); - const res = await client.projects.list({ + const res = await client.environments.list({ organization_id: flags.org, status: flags.status, }); - const projects = res?.projects ?? []; + const environments = res?.environments ?? []; if (flags.format === 'json') { await formatOutput(res, 'json'); } else if (flags.format === 'yaml') { await formatOutput(res, 'yaml'); } else { - console.log(`\nEnvironments (${projects.length}):\n`); - if (projects.length === 0) { + console.log(`\nEnvironments (${environments.length}):\n`); + if (environments.length === 0) { console.log(' (no environments)'); } else { - for (const p of projects) { + for (const p of environments) { const active = p.id === activeId ? ' ★' : ''; const defaultTag = p.is_default ? ' [default]' : ''; const systemTag = p.is_system ? ' [system]' : ''; diff --git a/packages/cli/src/commands/environments/show.ts b/packages/cli/src/commands/environments/show.ts index 15271573e2..f956828cd8 100644 --- a/packages/cli/src/commands/environments/show.ts +++ b/packages/cli/src/commands/environments/show.ts @@ -9,7 +9,7 @@ import { formatOutput } from '../../utils/output-formatter.js'; * `os environments show ` — show detailed information for a single environment. * * Renders the environment row plus its database, active credential, and - * membership row (same shape as `client.projects.get(id)`). + * membership row (same shape as `client.environments.get(id)`). */ export default class EnvironmentsShow extends Command { static override description = 'Show detailed information for an environment'; @@ -41,14 +41,14 @@ export default class EnvironmentsShow extends Command { const { client, token } = await createApiClient({ url: flags.url, token: flags.token }); requireAuth(token); - const res = await client.projects.get(args.id); + const res = await client.environments.get(args.id); if (flags.format === 'json') { await formatOutput(res, 'json'); } else if (flags.format === 'yaml') { await formatOutput(res, 'yaml'); } else { - const p = res?.project ?? {}; + const p = res?.environment ?? {}; console.log(`\nEnvironment: ${p.display_name ?? p.id}`); console.log('─'.repeat(60)); console.log(` id: ${p.id}`); diff --git a/packages/cli/src/commands/environments/switch.ts b/packages/cli/src/commands/environments/switch.ts index b0e10b293e..e74c7c9d31 100644 --- a/packages/cli/src/commands/environments/switch.ts +++ b/packages/cli/src/commands/environments/switch.ts @@ -44,23 +44,23 @@ export default class EnvironmentsSwitch extends Command { requireAuth(token); // Sanity-check the id resolves — fail fast before writing the cred file - const lookup = await client.projects.get(args.id); - const project = lookup?.project; - if (!project?.id) { + const lookup = await client.environments.get(args.id); + const environment = lookup?.environment; + if (!environment?.id) { throw new Error(`Environment ${args.id} not found`); } if (flags.remote) { - await client.projects.activate(project.id); + await client.environments.activate(environment.id); } const cfg = await readAuthConfig(); - cfg.activeEnvironmentId = project.id; + cfg.activeEnvironmentId = environment.id; cfg.lastUsedAt = new Date().toISOString(); await writeAuthConfig(cfg); - console.log(`\n✓ Active environment: ${project.display_name ?? project.id}`); - console.log(` id: ${project.id}`); + console.log(`\n✓ Active environment: ${environment.display_name ?? environment.id}`); + console.log(` id: ${environment.id}`); if (!flags.remote) { console.log(' (local only — server session unchanged)'); } diff --git a/packages/client/src/client-url-conformance.test.ts b/packages/client/src/client-url-conformance.test.ts index 07e5965c30..a8c4144878 100644 --- a/packages/client/src/client-url-conformance.test.ts +++ b/packages/client/src/client-url-conformance.test.ts @@ -142,9 +142,12 @@ function matches(verb: string, path: string): Pattern | undefined { * `cloud`'s `packages/service-cloud/src/cloud-route-ledger.ts` gives all 90 * routes its artifact API plugin mounts a reviewed disposition, and * `projects-namespace-coverage.test.ts` there drives this very SDK with a - * recording `fetch` and matches every `projects.*` URL against it. That had to + * recording `fetch` and matches every control-plane URL against it. That had to * live in `cloud`: it depends on this repo, never the reverse, so the cloud * repo is the only place the mounted route set and the SDK are both in scope. + * (That file still carries the pre-ADR-0006-D2 name on the cloud side; renaming + * it is a recorded follow-up in the same coordinated window as this rename, and + * belongs to the cloud repo, not to this one.) * The exemption below stays because THIS suite still cannot see those routes — * it is a statement about where the coverage lives, not that there is none. * @@ -159,7 +162,7 @@ function matches(verb: string, path: string): Pattern | undefined { * to park an unmatched URL. */ const CONTROL_PLANE = '/api/v1/cloud/'; -const CONTROL_PLANE_NAMESPACE = 'projects.'; +const CONTROL_PLANE_NAMESPACE = 'environments.'; /** * The AI plane. `/api/v1/ai/*` is served by `service-ai`, a Cloud/EE package in @@ -274,7 +277,7 @@ const NON_HTTP: Record = { 'getRoute': 'pure route-table lookup', 'unwrapResponse': 'pure envelope unwrap', 'isFilterAST': 'pure type predicate', - 'project': 'constructs a ScopedProjectClient; its methods are swept separately', + 'environment': 'constructs a ScopedEnvironmentClient; its methods are swept separately', 'setProjectId': 'local state', 'getProjectId': 'local state', 'setLocale': 'local state', @@ -425,15 +428,15 @@ describe('client URL conformance ↔ the union of all four route ledgers (#3642) 'of the #3584 / #3611 / #3636 class:\n' + unmatched.join('\n'), ).toEqual([]); - // The control-plane hole, bounded from the other end: only `projects.*` may - // use it. Anything else reaching /api/v1/cloud/ is a method that has wandered - // off the data plane, and must not inherit this exemption. + // The control-plane hole, bounded from the other end: only `environments.*` + // may use it. Anything else reaching /api/v1/cloud/ is a method that has + // wandered off the data plane, and must not inherit this exemption. const trespassers = controlPlane.filter((e) => !e.startsWith(CONTROL_PLANE_NAMESPACE)); expect( trespassers, - `non-projects methods targeting the control plane, which no in-repo ledger can vouch for:\n${trespassers.join('\n')}`, + `non-environments methods targeting the control plane, which no in-repo ledger can vouch for:\n${trespassers.join('\n')}`, ).toEqual([]); - expect(controlPlane.length, 'the projects namespace should still be reaching the control plane').toBeGreaterThan(0); + expect(controlPlane.length, 'the environments namespace should still be reaching the control plane').toBeGreaterThan(0); // The AI-plane hole, bounded the same way: only `ai.*` may use it. The // count assertion is the half that matters most here — if the namespace diff --git a/packages/client/src/client.batch-transaction.test.ts b/packages/client/src/client.batch-transaction.test.ts index ae243f3fb7..91a7ab8c50 100644 --- a/packages/client/src/client.batch-transaction.test.ts +++ b/packages/client/src/client.batch-transaction.test.ts @@ -144,7 +144,7 @@ describe('data.batchTransaction (live Hono, #1604)', () => { }); it('is mirrored on the environment-scoped client (/environments/:id/batch)', async () => { - const scoped = client.project('proj-alpha'); + const scoped = client.environment('proj-alpha'); const { results } = await scoped.data.batchTransaction([ { object: 'project', action: 'create', data: { name: 'Scoped' } }, ]); diff --git a/packages/client/src/client.environment-scoping.test.ts b/packages/client/src/client.environment-scoping.test.ts index 460d1d99aa..7069f913b6 100644 --- a/packages/client/src/client.environment-scoping.test.ts +++ b/packages/client/src/client.environment-scoping.test.ts @@ -119,9 +119,9 @@ describe('Project-scoped REST routing (live Hono)', () => { expect(res.status).toBe(200); }); - it('client.project(id).data.find() hits the scoped URL end-to-end', async () => { + it('client.environment(id).data.find() hits the scoped URL end-to-end', async () => { const client = new ObjectStackClient({ baseUrl }); - const scoped = client.project('proj-alpha'); + const scoped = client.environment('proj-alpha'); // Should resolve without throwing — the route must exist on the server. await expect(scoped.data.find('task')).resolves.toBeDefined(); }); diff --git a/packages/client/src/client.environments-namespace.test.ts b/packages/client/src/client.environments-namespace.test.ts new file mode 100644 index 0000000000..0afc4c991f --- /dev/null +++ b/packages/client/src/client.environments-namespace.test.ts @@ -0,0 +1,236 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0006 D2, SDK half — the control-plane namespace is `client.environments`, + * its declared unwrap keys are `environments` / `environment`, and there is NO + * alias for either old spelling. + * + * ## Why this file exists at all + * + * The rename is one half of a cross-repo coordinated change: the cloud control + * plane renames the response FIELD keys (`projects` → `environments`, + * `project` → `environment`) and this SDK renames the METHOD namespace plus the + * shapes it declares. Neither half ships alone — shipping this one alone is + * ADR-0006 D3, permanently declined. + * + * The load-bearing half of that decision is the ABSENCE of a compatibility + * layer, and absence is exactly what no ordinary test observes: every assertion + * about `client.environments` stays green if someone later adds a `projects` + * getter beside it, or a `res.project ?? res.environment` hedge in a consumer. + * ADR-0006 D3 declined that mapping layer with reasons, so it needs a pin that + * fails when one appears, not merely one that passes while none does. + * + * ## What each pin is really asserting + * + * - The RUNTIME pins drive the client with a recording `fetch` answering the + * post-rename envelope the producer half emits, and read the keys back. They + * are the half that would catch a mapping layer rewriting keys in transit: + * `unwrapResponse` is a passthrough today, and these say so out loud. + * - The TYPE pins are `@ts-expect-error` reads of the RETIRED key spellings. + * They are compiled — `packages/client`'s `tsconfig.test.json` includes + * `src/**` and `package.json`'s `typecheck` script names it — so they are + * real checks rather than the phantom class AGENTS.md warns about. Each one + * goes red the moment a declared shape grows the old key back. + * + * Producer half: `objectstack-ai/cloud` — `packages/service-cloud/src/routes/ + * environment-crud.ts` and `environment-lifecycle.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackClient } from './index'; + +type EnvironmentsNamespace = ObjectStackClient['environments']; +type ListShape = Awaited>; +type GetShape = Awaited>; +type CreateShape = Awaited>; +type UpdateShape = Awaited>; +type ActivateShape = Awaited>; +type RetryShape = Awaited>; +type HostnameShape = Awaited>; +type VisibilityShape = Awaited>; + +/** + * The list envelope: the row array is `environments`, and `total` is unchanged + * by the rename (it was never part of it). + */ +export function listEnvelopeCarriesTheWireKeys(): void { + const shape = {} as ListShape; + void shape.environments; + void shape.total; + // @ts-expect-error ADR-0006 D2 renamed the list row key to `environments`; no `projects` alias exists + void shape.projects; +} + +/** + * Every single-row envelope: the key is `environment`. One `@ts-expect-error` + * per method rather than one for the family, so a shape that regressed on its + * own is named by the failure rather than hidden behind a sibling's. + */ +export function singleRowEnvelopesCarryTheWireKey(): void { + const get = {} as GetShape; + void get.environment; + void get.database; + void get.credential; + void get.membership; + // @ts-expect-error the detail envelope's row key is `environment` + void get.project; + + const created = {} as CreateShape; + void created.environment; + // @ts-expect-error create answers `environment` — and has never answered `project` at all + void created.project; + + const updated = {} as UpdateShape; + void updated.environment; + // @ts-expect-error PATCH answers `environment` + void updated.project; + + const activated = {} as ActivateShape; + void activated.environment; + void activated.sessionUpdated; + // @ts-expect-error activate answers `environment` beside `sessionUpdated` + void activated.project; + + const retried = {} as RetryShape; + void retried.environment; + // @ts-expect-error retry answers `environment` + void retried.project; + + const renamedHost = {} as HostnameShape; + void renamedHost.environment; + // @ts-expect-error change-hostname answers `environment` + void renamedHost.project; + + const revisibled = {} as VisibilityShape; + void revisibled.environment; + // @ts-expect-error change-visibility answers `environment` + void revisibled.project; +} + +/** + * `create` declares no `database` key. It never had one on the wire: the + * producer's `POST /cloud/environments` builds its body key by key and forwards + * `environment` / `warnings` / `durationMs` (plus a conditional + * `hostnameAssignment`) and nothing else. The old declaration promised a + * NON-optional `database`, so `res.database.driver` typechecked and threw at + * runtime — the failure direction a declared shape is supposed to prevent. + */ +export function createDeclaresNoDatabaseBlock(): void { + const created = {} as CreateShape; + // @ts-expect-error `POST /cloud/environments` does not answer a `database` block; `get` is the method that does + void created.database; +} + +/** Helper: a client whose `fetch` answers one canned BaseResponse envelope. */ +function clientAnswering(data: unknown) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ success: true, data }), + headers: new Headers(), + }); + const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock }); + return { client, fetchMock }; +} + +describe('[ADR-0006 D2] client.environments — the control-plane namespace after the rename', () => { + it('exposes `environments`, and exposes NO `projects` alias beside it', () => { + const { client } = clientAnswering({}); + + expect(typeof client.environments).toBe('object'); + expect(typeof client.environments.list).toBe('function'); + expect(typeof client.environments.packages.install).toBe('function'); + + // The declined-mapping-layer pin. `in` rather than a truthiness check: + // a getter returning `undefined` would still be a re-exported namespace, + // and this must fail on the SHAPE, not on the value. + expect('projects' in client).toBe(false); + expect((client as unknown as Record).projects).toBeUndefined(); + }); + + it('relays the list envelope untouched — `environments` + `total`', async () => { + const { client, fetchMock } = clientAnswering({ + environments: [{ id: 'env_1', display_name: 'Staging' }], + total: 1, + }); + + const res = await client.environments.list({ organization_id: 'org_1' }); + + expect(res.total).toBe(1); + expect(res.environments).toHaveLength(1); + expect(res.environments[0].id).toBe('env_1'); + // No key rewriting on the way through — the SDK is a passthrough, and a + // mapping layer added later would have to break one of these two. + expect(Object.keys(res).sort()).toEqual(['environments', 'total']); + expect(fetchMock.mock.calls[0][0]).toBe( + 'http://localhost:3000/api/v1/cloud/environments?organizationId=org_1', + ); + }); + + it('relays the detail envelope untouched — `environment` beside its joined blocks', async () => { + const { client } = clientAnswering({ + environment: { id: 'env_1', display_name: 'Staging' }, + database: { driver: 'turso', database_url: 'libsql://x' }, + credential: { id: 'cred_1' }, + membership: { role: 'owner' }, + }); + + const res = await client.environments.get('env_1'); + + expect(res.environment.id).toBe('env_1'); + expect(res.database.driver).toBe('turso'); + expect(res.membership.role).toBe('owner'); + expect('project' in res).toBe(false); + }); + + it('relays the create envelope — `environment`, the key this route has always sent', async () => { + const { client, fetchMock } = clientAnswering({ + environment: { id: 'env_new', display_name: 'Dev' }, + warnings: [], + durationMs: 1234, + }); + + const res = await client.environments.create({ + organization_id: 'org_1', + display_name: 'Dev', + }); + + expect(res.environment.id).toBe('env_new'); + expect('project' in res).toBe(false); + expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/cloud/environments'); + expect(fetchMock.mock.calls[0][1].method).toBe('POST'); + }); + + it('relays the activate envelope — `environment` beside `sessionUpdated`', async () => { + const { client } = clientAnswering({ + environment: { id: 'env_1' }, + sessionUpdated: true, + }); + + const res = await client.environments.activate('env_1'); + + expect(res.environment.id).toBe('env_1'); + expect(res.sessionUpdated).toBe(true); + expect('project' in res).toBe(false); + }); + + it('keeps the environment-scoped `packages` block reachable under the new namespace', async () => { + const { client, fetchMock } = clientAnswering({ packages: [], total: 0 }); + + const res = await client.environments.packages.list('env_1'); + + expect(res.total).toBe(0); + expect(fetchMock.mock.calls[0][0]).toBe( + 'http://localhost:3000/api/v1/cloud/environments/env_1/packages', + ); + }); + + it('anti-vacuity: the type pins above are exported functions that really name the shapes', () => { + // They are compile-time only, so nothing calls them at runtime. This + // asserts they are not dead text: each is a real binding in this module. + expect(typeof listEnvelopeCarriesTheWireKeys).toBe('function'); + expect(typeof singleRowEnvelopesCarryTheWireKey).toBe('function'); + expect(typeof createDeclaresNoDatabaseBlock).toBe('function'); + }); +}); diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 8d203ac7b1..f0764846bd 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1732,7 +1732,7 @@ describe('ObjectStackClient.automation', () => { * `data.find()` transport parameters — ONE expectation table, BOTH copies. * * `find` is implemented twice: `ObjectStackClient.data.find` and - * `ScopedProjectClient.data.find`. They are two faces of ONE wire contract + * `ScopedEnvironmentClient.data.find`. They are two faces of ONE wire contract * (the scoped one differs only in the URL prefix) and were byte-identical * copies of the same normalization — including the same defect. Every row * below is therefore driven through BOTH and compared against the SAME @@ -1762,7 +1762,7 @@ describe('data.find() — canonical/legacy transport parameters (both copies)', const direct = queryOf(a.fetchMock.mock.calls[0][0] as string); const b = createMockClient(body); - await b.client.project('env-1').data.find('task', options); + await b.client.environment('env-1').data.find('task', options); const scoped = queryOf(b.fetchMock.mock.calls[0][0] as string); return { direct, scoped }; @@ -1955,7 +1955,7 @@ describe('data.find() — canonical/legacy transport parameters (both copies)', expect(a.fetchMock).not.toHaveBeenCalled(); const b = createMockClient({ success: true, data: { object: 'task', records: [] } }); - await expect(b.client.project('env-1').data.find('task', nested)).rejects.toThrow( + await expect(b.client.environment('env-1').data.find('task', nested)).rejects.toThrow( /expand\['contact'\] carries a nested query \(fields\)/, ); expect(b.fetchMock).not.toHaveBeenCalled(); @@ -1982,13 +1982,13 @@ describe('QueryBuilder — offset() alias', () => { }); // ---------------------------------------------------------------------- -// ScopedProjectClient — project-scoped sub-client (Phase 2) +// ScopedEnvironmentClient — environment-scoped sub-client (Phase 2) // ---------------------------------------------------------------------- -describe('ScopedProjectClient', () => { - it('prefixes meta.getTypes with /projects/:id', async () => { +describe('ScopedEnvironmentClient', () => { + it('prefixes meta.getTypes with /environments/:id', async () => { const { client, fetchMock } = createMockClient({ types: ['object'] }); - const scoped = client.project('proj-123'); + const scoped = client.environment('proj-123'); await scoped.meta.getTypes(); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:3000/api/v1/environments/proj-123/meta', @@ -1996,9 +1996,9 @@ describe('ScopedProjectClient', () => { ); }); - it('prefixes data.find with /projects/:id', async () => { + it('prefixes data.find with /environments/:id', async () => { const { client, fetchMock } = createMockClient({ records: [] }); - const scoped = client.project('proj-123'); + const scoped = client.environment('proj-123'); await scoped.data.find('task', { top: 5 }); const url = (fetchMock.mock.calls[0] as any[])[0] as string; expect(url.startsWith('http://localhost:3000/api/v1/environments/proj-123/data/task')).toBe(true); @@ -2007,7 +2007,7 @@ describe('ScopedProjectClient', () => { it('prefixes data.get / data.create / data.update / data.delete', async () => { const { client, fetchMock } = createMockClient({ id: 't1' }); - const scoped = client.project('proj-xyz'); + const scoped = client.environment('proj-xyz'); await scoped.data.get('task', 't1'); expect(fetchMock).toHaveBeenLastCalledWith( @@ -2036,7 +2036,7 @@ describe('ScopedProjectClient', () => { it('url-encodes the environmentId', async () => { const { client, fetchMock } = createMockClient({ types: [] }); - const scoped = client.project('proj with space'); + const scoped = client.environment('proj with space'); await scoped.meta.getTypes(); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:3000/api/v1/environments/proj%20with%20space/meta', @@ -2052,12 +2052,12 @@ describe('ScopedProjectClient', () => { // suppressed nothing and reported TS2578 ("unused") the first time a tsc // program read the file. Its own comment said what the test actually // proves: the empty id is rejected at RUNTIME, by the guard below. - expect(() => client.project('')).toThrow(/environmentId is required/); + expect(() => client.environment('')).toThrow(/environmentId is required/); }); it('exposes environmentId via getProjectId()', () => { const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000' }); - const scoped = client.project('00000000-0000-0000-0000-000000000001'); + const scoped = client.environment('00000000-0000-0000-0000-000000000001'); expect(scoped.getProjectId()).toBe('00000000-0000-0000-0000-000000000001'); }); @@ -2071,7 +2071,7 @@ describe('ScopedProjectClient', () => { (client as any)['discoveryInfo'] = { routes: { data: '/backend/api/v9/data', metadata: '/backend/api/v9/meta' }, }; - const scoped = client.project('proj-123'); + const scoped = client.environment('proj-123'); const base = 'http://localhost:3000/backend/api/v9/environments/proj-123'; // All namespaces build off ONE scope() — drive one method from each so @@ -2098,7 +2098,7 @@ describe('ScopedProjectClient', () => { (client as any)['discoveryInfo'] = { routes: { data: '/backend/api/v9/records', metadata: '/backend/api/v9/meta' }, }; - await client.project('proj-123').meta.getTypes(); + await client.environment('proj-123').meta.getTypes(); expect(String(fetchMock.mock.calls[0][0])).toBe( 'http://localhost:3000/api/v1/environments/proj-123/meta', ); @@ -2117,7 +2117,7 @@ describe('ScopedProjectClient', () => { }, scoping: { enabled: true, resolution: 'auto', scoped: true, environmentId: 'env-served' }, }; - await client.project('proj-other').meta.getTypes(); + await client.environment('proj-other').meta.getTypes(); expect(String(fetchMock.mock.calls[0][0])).toBe( 'http://localhost:3000/backend/api/v9/environments/proj-other/meta', ); @@ -2138,7 +2138,7 @@ describe('ScopedProjectClient', () => { }, scoping: { enabled: true, resolution: 'auto', scoped: true }, }; - await client.project('proj-other').meta.getTypes(); + await client.environment('proj-other').meta.getTypes(); expect(String(fetchMock.mock.calls[0][0])).toBe( 'http://localhost:3000/backend/api/v9/environments/proj-other/meta', ); @@ -2155,7 +2155,7 @@ describe('ScopedProjectClient', () => { routes: { data: '/backend/api/v9/tenants/t1/data' }, scoping: { enabled: true, resolution: 'auto', scoped: true }, }; - await client.project('proj-other').meta.getTypes(); + await client.environment('proj-other').meta.getTypes(); expect(String(fetchMock.mock.calls[0][0])).toBe( 'http://localhost:3000/api/v1/environments/proj-other/meta', ); @@ -2163,7 +2163,7 @@ describe('ScopedProjectClient', () => { it('prefixes the screen-flow automation.resume / getScreen calls', async () => { const { client, fetchMock } = createMockClient({ success: true, data: { success: true } }); - const scoped = client.project('proj-123'); + const scoped = client.environment('proj-123'); await scoped.automation.resume('my_flow', 'run_1', { inputs: { note: 'ok' } }); expect(fetchMock).toHaveBeenLastCalledWith( @@ -2603,7 +2603,7 @@ describe('data.batchTransaction', () => { it('is mirrored on the environment-scoped client', async () => { const { client, fetchMock } = createMockClient({ results: [] }); - await client.project('proj-1').data.batchTransaction(OPS); + await client.environment('proj-1').data.batchTransaction(OPS); expect(fetchMock).toHaveBeenCalledWith( 'http://localhost:3000/api/v1/environments/proj-1/batch', expect.objectContaining({ method: 'POST' }), @@ -2756,7 +2756,7 @@ describe('[#11391] meta.saveItem query string (unscoped client)', () => { describe('[#11391] meta.saveItem query string (environment-scoped twin)', () => { it('threads `force: true` on the scoped client too', async () => { const { client, fetchMock } = createMockClient({ success: true }); - await client.project('proj-123').meta.saveItem( + await client.environment('proj-123').meta.saveItem( 'object', 'customer', { name: 'customer' }, { force: true }, ); const [url, init] = fetchMock.mock.calls[0]; @@ -2768,7 +2768,7 @@ describe('[#11391] meta.saveItem query string (environment-scoped twin)', () => it('exposes the same three parameters as the unscoped twin', async () => { const { client, fetchMock } = createMockClient({ success: true }); - await client.project('proj-123').meta.saveItem('object', 'customer', {}, { + await client.environment('proj-123').meta.saveItem('object', 'customer', {}, { force: true, packageId: 'app.crm', mode: 'draft', @@ -2781,7 +2781,7 @@ describe('[#11391] meta.saveItem query string (environment-scoped twin)', () => it('BACKWARD COMPATIBLE: a 3-argument scoped call still sends no query string', async () => { const { client, fetchMock } = createMockClient({ success: true }); - await client.project('proj-123').meta.saveItem('object', 'customer', {}); + await client.environment('proj-123').meta.saveItem('object', 'customer', {}); expect(String(fetchMock.mock.calls[0][0])).toBe( 'http://localhost:3000/api/v1/environments/proj-123/meta/object/customer', ); @@ -2795,7 +2795,7 @@ describe('[#11391] meta.saveItem query string (environment-scoped twin)', () => const { client, fetchMock } = createMockClient({ success: true }); const opts = { force: true, packageId: 'app.crm', mode: 'draft' } as const; await client.meta.saveItem('object', 'customer', {}, opts); - await client.project('proj-123').meta.saveItem('object', 'customer', {}, opts); + await client.environment('proj-123').meta.saveItem('object', 'customer', {}, opts); const queryOf = (u: unknown) => new URL(String(u)).search; expect(queryOf(fetchMock.mock.calls[1][0])).toBe(queryOf(fetchMock.mock.calls[0][0])); expect(queryOf(fetchMock.mock.calls[0][0])).toBe('?force=true&package=app.crm&mode=draft'); @@ -2974,7 +2974,7 @@ describe('[#11713] meta.saveItem sends the If-Match header (unscoped client)', ( describe('[#11713] meta.saveItem sends the If-Match header (environment-scoped twin)', () => { it('sends the header on the scoped client too', async () => { const { client, fetchMock } = createMockClient({ success: true }); - await client.project('proj-123').meta.saveItem( + await client.environment('proj-123').meta.saveItem( 'object', 'customer', { name: 'customer' }, { ifMatch: OCC_TOKEN }, ); expect(String(fetchMock.mock.calls[0][0])).toBe( @@ -2985,7 +2985,7 @@ describe('[#11713] meta.saveItem sends the If-Match header (environment-scoped t it('ABSENT on the scoped client when the caller does not pin', async () => { const { client, fetchMock } = createMockClient({ success: true }); - await client.project('proj-123').meta.saveItem('object', 'customer', { name: 'customer' }); + await client.environment('proj-123').meta.saveItem('object', 'customer', { name: 'customer' }); expect(headersOfCall(fetchMock)['If-Match']).toBeUndefined(); expect(headerNamesOf(fetchMock)).toEqual(BASELINE_HEADERS); }); @@ -2997,7 +2997,7 @@ describe('[#11713] meta.saveItem sends the If-Match header (environment-scoped t const { client, fetchMock } = createMockClient({ success: true }); const opts = { ifMatch: OCC_TOKEN, force: true } as const; await client.meta.saveItem('object', 'customer', {}, opts); - await client.project('proj-123').meta.saveItem('object', 'customer', {}, opts); + await client.environment('proj-123').meta.saveItem('object', 'customer', {}, opts); expect(headersOfCall(fetchMock, 1)['If-Match']).toBe(headersOfCall(fetchMock, 0)['If-Match']); expect(headersOfCall(fetchMock, 0)['If-Match']).toBe(OCC_TOKEN); }); @@ -3005,7 +3005,7 @@ describe('[#11713] meta.saveItem sends the If-Match header (environment-scoped t it('IN STEP when unpinned too: neither twin adds a header', async () => { const { client, fetchMock } = createMockClient({ success: true }); await client.meta.saveItem('object', 'customer', {}, { force: true }); - await client.project('proj-123').meta.saveItem('object', 'customer', {}, { force: true }); + await client.environment('proj-123').meta.saveItem('object', 'customer', {}, { force: true }); expect(headerNamesOf(fetchMock, 0)).toEqual(BASELINE_HEADERS); expect(headerNamesOf(fetchMock, 1)).toEqual(BASELINE_HEADERS); }); diff --git a/packages/client/src/data-delete-result-shape.test.ts b/packages/client/src/data-delete-result-shape.test.ts index 525db91d69..1680c7c023 100644 --- a/packages/client/src/data-delete-result-shape.test.ts +++ b/packages/client/src/data-delete-result-shape.test.ts @@ -5,7 +5,7 @@ * It declared `{ object, id, deleted: boolean }` while * `DeleteDataResponseSchema` declares `{ object, id, success: boolean }`. Both * `delete` surfaces (`client.data.delete` and the project-scoped - * `client.project(id).data.delete`) are pure `unwrapResponse` / `_unwrap` + * `client.environment(id).data.delete`) are pure `unwrapResponse` / `_unwrap` * passthroughs — no runtime rewriting anywhere — so a consumer writing * `r.deleted` compiled fine and read `undefined` against every server path * that exists (#5581 / PR #5641 brought the ObjectQL fallback to `success` @@ -74,7 +74,7 @@ describe('[#5638] DeleteDataResult === DeleteDataResponseSchema', () => { for (const [surface, del] of [ ['client.data.delete', (c: ObjectStackClient) => c.data.delete('task', 't1')], - ['client.project(id).data.delete', (c: ObjectStackClient) => c.project('proj-xyz').data.delete('task', 't1')], + ['client.environment(id).data.delete', (c: ObjectStackClient) => c.environment('proj-xyz').data.delete('task', 't1')], ] as const) { describe(surface, () => { it('hands back the spec body, and `success` is reachable through the declared type', async () => { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 322eb60703..4b978eef5c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -176,11 +176,11 @@ export interface ClientConfig { */ debug?: boolean; /** - * Active project id (UUID of `sys_environment`). When present, the + * Active environment id (UUID of `sys_environment`). When present, the * client injects an `X-Environment-Id` header on every request so the * server's tenant router can resolve the physical data-plane database. * - * @see docs/adr/0002-project-database-isolation.md + * @see docs/adr/0002-environment-database-isolation.md */ environmentId?: string; /** @@ -309,7 +309,7 @@ const QUERY_OPTIONS_V2_ONLY_KEYS: Record = { * Does this options bag speak canonical {@link QueryOptionsV2} vocabulary? * * ONE definition read by both `data.find()` implementations - * (`ObjectStackClient` and `ScopedProjectClient`), which are two faces of one + * (`ObjectStackClient` and `ScopedEnvironmentClient`), which are two faces of one * wire contract and were byte-identical copies of the old inline condition. */ function isCanonicalQueryOptions(options: QueryOptions | QueryOptionsV2): options is QueryOptionsV2 { @@ -563,7 +563,7 @@ function normalizeActionResult(payload: any): { success: boolean; data?: T; e /** * Write options for `meta.saveItem` on BOTH clients — the unscoped - * `ObjectStackClient.meta` and {@link ScopedProjectClient.meta}. + * `ObjectStackClient.meta` and {@link ScopedEnvironmentClient.meta}. * * Named for the WRITE, not for the query string: three members ride the query * string and `ifMatch` rides a request HEADER (#11713). One bag per write @@ -1504,7 +1504,7 @@ export class ObjectStackClient { * an extra `source: 'database' | 'registry' | 'both'` discriminator that is * deliberately NOT declared here — the dispatcher rows have no such key, so * declaring it would be false on that surface. #8140 bound the identical - * scoped sibling (`ScopedProjectClient.packages.list`) the same way. + * scoped sibling (`ScopedEnvironmentClient.packages.list`) the same way. */ list: async (filters?: { status?: string; type?: string; enabled?: boolean }): Promise<{ packages: InstalledPackage[]; total: number }> => { const route = this.getRoute('packages'); @@ -1530,7 +1530,7 @@ export class ObjectStackClient { * where a `package` service is registered. Binding the member here would * harden a claim that is already false on one of the two. * - * Its SCOPED twin `ScopedProjectClient.packages.get` IS bound, because + * Its SCOPED twin `ScopedEnvironmentClient.packages.get` IS bound, because * only the REST registrar serves the scoped mount — one surface, one * shape. */ @@ -1795,19 +1795,19 @@ export class ObjectStackClient { /** * Environment Management Services * - * Environments are the v4.1+ isolation primitive — each project owns a + * Environments are the v4.1+ isolation primitive — each environment owns a * physically separate data-plane database. All Studio-level switching goes * through this API. * * Endpoints: * - GET /api/v1/cloud/environments → list environments * - GET /api/v1/cloud/environments/:id → get one (with database info) - * - POST /api/v1/cloud/environments → provision a new project + * - POST /api/v1/cloud/environments → provision a new environment * - PATCH /api/v1/cloud/environments/:id → update (displayName, plan, status, …) - * - POST /api/v1/cloud/environments/:id/activate → set as session's active project + * - POST /api/v1/cloud/environments/:id/activate → set as session's active environment * - POST /api/v1/cloud/environments/:id/credentials/rotate → rotate credential * - * @see docs/adr/0002-project-database-isolation.md + * @see docs/adr/0002-environment-database-isolation.md */ /** * ⛔ [#11925] Every unannotated method in this namespace, and in the @@ -1830,7 +1830,7 @@ export class ObjectStackClient { * #8140 recorded, at family scale. The control-plane implementation is not * in this repo, so the casing cannot be settled from here. */ - projects = { + environments = { /** * List environments visible to the current session. Optionally filter * by organization (control-plane query — not routed through a data-plane DB). @@ -1843,16 +1843,16 @@ export class ObjectStackClient { const qs = params.toString(); const url = `${this.baseUrl}/api/v1/cloud/environments${qs ? '?' + qs : ''}`; const res = await this.fetch(url); - return this.unwrapResponse<{ projects: any[]; total: number }>(res); + return this.unwrapResponse<{ environments: any[]; total: number }>(res); }, /** - * Get a single project (joined with its database and membership row). + * Get a single environment (joined with its database and membership row). */ get: async (id: string) => { const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(id)}`); return this.unwrapResponse<{ - project: any; + environment: any; database?: any; credential?: any; membership?: any; @@ -1861,16 +1861,31 @@ export class ObjectStackClient { }, /** - * Provision a new project. Delegates to - * `ProjectProvisioningService.provisionProject` on the server. + * Provision a new environment — `POST /api/v1/cloud/environments`. + * + * ⛔ This sentence names the ENDPOINT on purpose, and must keep doing so. + * It used to name a server class (`ProjectProvisioningService`, method + * `provisionProject`) that the control plane does not have: measured + * 2026-08-28 against the cloud repo's `main`, that spelling has ZERO hits + * anywhere in `packages/service-cloud/src`. The class was renamed there + * and this docblock rotted in silence, because the implementation lives in + * a repo this one never compiles against — no gate here could ever have + * caught it. The endpoint is the one identifier this method itself builds, + * so it is the only one an in-repo reader can verify. Do not reintroduce a + * server-class name here (ADR-0006 D1 point 3). + * + * What that endpoint does today: the control plane creates the + * `sys_environment` row, provisions its physically separate data-plane + * database, and answers `201` with the created environment. Environments + * are created EMPTY — starter content is installed afterwards from the App + * Marketplace (`sys_package` with `is_starter = true`), which + * `environments.packages.install` already does. * * No `template_id`: it was removed in #3731 because no control plane has * ever read it — the `blank`/`crm`/`todo` registry it addressed died with * the `apps/server` templates route, and `sys_environment` has no such - * column, so the field was accepted, transmitted, and dropped. Starter - * content is installed from the App Marketplace (`sys_package` with - * `is_starter = true`), which `projects.packages.install` already does. - * Its listing counterpart went the same way in #3702. + * column, so the field was accepted, transmitted, and dropped. Its + * listing counterpart went the same way in #3702. */ create: async (req: { organization_id: string; @@ -1891,24 +1906,43 @@ export class ObjectStackClient { method: 'POST', body: JSON.stringify(req), }); - return this.unwrapResponse<{ project: any; database: any }>(res); + // Two corrections in one declaration, both measured against the cloud + // repo's `main` on 2026-08-28 (the handler is + // `packages/service-cloud/src/routes/environment-lifecycle.ts`, POST + // `/cloud/environments`, which builds its body key by key): + // + // - the single-row key is `environment`. This route has NEVER emitted a + // `project` key — unlike its siblings it was not carrying the old + // spelling, so the old declaration here was not merely pre-rename, it + // was FALSE against the running control plane. + // - there is no `database` key. It was declared non-optional, so every + // caller was told `res.database` is always present when the server + // never sends it at all; reading it is a runtime TypeError the types + // promised could not happen. `get` is the method that really does + // answer a `database` block. + // + // The keys the route DOES send beside `environment` (`warnings`, + // `durationMs`, and a conditional `hostnameAssignment`) are deliberately + // not declared here — adding them is new published surface and a + // separate decision, not part of this rename. + return this.unwrapResponse<{ environment: any }>(res); }, /** - * Update a project (display_name, plan, status, is_default, metadata). + * Update an environment (display_name, plan, status, is_default, metadata). */ update: async (id: string, patch: Record) => { const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(patch), }); - return this.unwrapResponse<{ project: any }>(res); + return this.unwrapResponse<{ environment: any }>(res); }, /** - * Cascade-delete a project: cleans up credential/member/package_installation + * Cascade-delete an environment: cleans up credential/member/package_installation * rows, releases the physical database via the provisioning adapter, and - * removes the `sys_environment` row. Default projects require `force: true`. + * removes the `sys_environment` row. Default environments require `force: true`. */ delete: async (id: string, opts?: { force?: boolean }) => { const qs = opts?.force ? '?force=1' : ''; @@ -1920,19 +1954,19 @@ export class ObjectStackClient { }, /** - * Activate this project for the current session. The server writes + * Activate this environment for the current session. The server writes * `active_environment_id` on the better-auth session; subsequent requests - * are routed to this project's database. + * are routed to this environment's database. */ activate: async (id: string) => { const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(id)}/activate`, { method: 'POST', }); - return this.unwrapResponse<{ project: any; sessionUpdated: boolean }>(res); + return this.unwrapResponse<{ environment: any; sessionUpdated: boolean }>(res); }, /** - * Rotate the active database credential for this project. + * Rotate the active database credential for this environment. */ rotateCredential: async (id: string, plaintext: string) => { const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(id)}/credentials/rotate`, { @@ -1943,7 +1977,7 @@ export class ObjectStackClient { }, /** - * Update the hostname bound to this project. Validates format and + * Update the hostname bound to this environment. Validates format and * uniqueness server-side; invalidates the dispatcher's routing cache. */ updateHostname: async (id: string, hostname: string) => { @@ -1951,14 +1985,14 @@ export class ObjectStackClient { method: 'POST', body: JSON.stringify({ hostname }), }); - return this.unwrapResponse<{ project: any }>(res); + return this.unwrapResponse<{ environment: any }>(res); }, /** - * Update the visibility of this project ('private' | 'public'). - * `private` (default) hides the project from /pub/v1 enumeration but + * Update the visibility of this environment ('private' | 'public'). + * `private` (default) hides the environment from /pub/v1 enumeration but * still allows anonymous artifact downloads when the URL includes an - * exact `?commit=` (share-by-link). `public` lists the project and + * exact `?commit=` (share-by-link). `public` lists the environment and * freely exposes all revisions. */ updateVisibility: async (id: string, visibility: 'private' | 'public') => { @@ -1966,11 +2000,11 @@ export class ObjectStackClient { method: 'PATCH', body: JSON.stringify({ visibility }), }); - return this.unwrapResponse<{ project: any }>(res); + return this.unwrapResponse<{ environment: any }>(res); }, /** - * List published artifact revisions for a project. Each revision has + * List published artifact revisions for an environment. Each revision has * an immutable commitId (content-addressable) and storage_key. * Optional `branch` filter narrows to a single logical branch * (default branch `main` also matches rows with NULL `branch`). @@ -2004,7 +2038,7 @@ export class ObjectStackClient { }, /** - * List logical branches for a project. Each branch has a head commit + * List logical branches for an environment. Each branch has a head commit * (latest published revision on that branch) and a count of revisions. * Branches without a head row (e.g. all rows demoted) are omitted. */ @@ -2057,21 +2091,21 @@ export class ObjectStackClient { }, /** - * Retry provisioning for a project stuck in `failed` (or + * Retry provisioning for an environment stuck in `failed` (or * `provisioning`) state. The server re-runs the driver handshake; on - * success the project flips to `active`, on failure it stays + * success the environment flips to `active`, on failure it stays * `failed` with `metadata.provisioningError` updated. */ retryProvisioning: async (id: string) => { const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(id)}/retry`, { method: 'POST', }); - return this.unwrapResponse<{ project: any }>(res); + return this.unwrapResponse<{ environment: any }>(res); }, /** * List ObjectQL drivers registered on the server. Useful for populating a - * driver selector when provisioning a new project (memory / turso / + * driver selector when provisioning a new environment (memory / turso / * future sql drivers). Returned `name` is the short alias (e.g. `memory`, * `turso`); `driverId` is the full FQN (e.g. `com.objectstack.driver.memory`). */ @@ -2089,17 +2123,17 @@ export class ObjectStackClient { // back when a route exists to back it, with an `sdk` ledger row proving so. /** - * Per-project package installation management (Power Apps "solution" model). + * Per-environment package installation management (Power Apps "solution" model). * Install records are stored in the environment's own database. */ packages: { - /** List all packages installed in a specific project. */ + /** List all packages installed in a specific environment. */ list: async (envId: string) => { const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(envId)}/packages`); return this.unwrapResponse<{ packages: any[]; total: number }>(res); }, - /** Install a package into the project. */ + /** Install a package into the environment. */ install: async (envId: string, body: { packageId: string; version?: string; @@ -2135,7 +2169,7 @@ export class ObjectStackClient { return this.unwrapResponse<{ package: any }>(res); }, - /** Uninstall a package from the project. Forbidden for scope=platform packages. */ + /** Uninstall a package from the environment. Forbidden for scope=platform packages. */ uninstall: async (envId: string, pkgId: string) => { const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/environments/${encodeURIComponent(envId)}/packages/${encodeURIComponent(pkgId)}`, { method: 'DELETE', @@ -2155,11 +2189,16 @@ export class ObjectStackClient { }; /** - * Project-scoped client factory. + * Environment-scoped client factory. * * Returns a thin wrapper around the data / meta / packages namespaces that * prefixes every request with `/api/v1/environments/:environmentId/...`. Use this * when the server has `enableProjectScoping: true` in its REST API config. + * (That config key keeps the old spelling deliberately here: it is a REAL, + * live key read by `packages/cli/src/commands/serve.ts`, on the REST API's + * surface rather than this client's, so renaming it is a different breaking + * change on a different package — naming it correctly is what keeps this + * sentence true.) * * Backward compatibility: `client.data.*`, `client.meta.*`, and * `client.packages.*` continue to work unchanged; they hit unscoped routes @@ -2172,19 +2211,19 @@ export class ObjectStackClient { * * declare const client: ObjectStackClient; * - * const scoped = client.project('00000000-0000-0000-0000-000000000001'); + * const scoped = client.environment('00000000-0000-0000-0000-000000000001'); * const tasks = await scoped.data.find('task', { top: 10 }); * const objects = await scoped.meta.getItems('object'); * ``` */ - project(environmentId: string): ScopedProjectClient { + environment(environmentId: string): ScopedEnvironmentClient { if (!environmentId) { - throw new Error('[ObjectStack] project(id): environmentId is required'); + throw new Error('[ObjectStack] environment(id): environmentId is required'); } - return new ScopedProjectClient(this, environmentId); + return new ScopedEnvironmentClient(this, environmentId); } - // ── Internal accessors exposed to ScopedProjectClient ──────────────── + // ── Internal accessors exposed to ScopedEnvironmentClient ────────────── // The scoped client lives in the same module so using module-level access // works; TypeScript requires these to be accessible, so we expose them via // small protected getters that keep the public surface unchanged. @@ -3667,7 +3706,7 @@ export class ObjectStackClient { }, /** - * Flat aliases mirroring the ScopedProjectClient.automation surface so + * Flat aliases mirroring the ScopedEnvironmentClient.automation surface so * Studio (and other consumers) can use the same call shape regardless of * whether they hold a scoped or unscoped client. * @@ -3832,7 +3871,7 @@ export class ObjectStackClient { * * The path is fixed (`/api/v1/actions`), not discovery-routed: `actions` is * not part of `ApiRoutesSchema`, so `getRoute()` cannot resolve it — same - * precedent as the `projects` surface's `/api/v1/cloud`. + * precedent as the `environments` surface's `/api/v1/cloud`. * * The dispatcher accepts the record id either in the URL or in the body; * this client always sends it in the body (`{ recordId, params }`), which @@ -3909,7 +3948,7 @@ export class ObjectStackClient { * returned exactly once (only its hash is stored; it is never * re-displayable). Until this surface existed the SDK had no way to create * an API key at all. Fixed path — `keys` is not in `ApiRoutesSchema` - * (same precedent as `actions` / `projects`). + * (same precedent as `actions` / `environments`). */ keys = { /** @@ -4951,7 +4990,7 @@ export class ObjectStackClient { // ── Normalize V2 canonical options → HTTP transport params ─── // Detect V2 options by presence of canonical-only keys. The predicate // is derived from QueryOptionsV2 itself and SHARED with the copy of - // this method on ScopedProjectClient — see QUERY_OPTIONS_V2_ONLY_KEYS + // this method on ScopedEnvironmentClient — see QUERY_OPTIONS_V2_ONLY_KEYS // for why an inline hand-written key list is not allowed here (#6322). const v2 = options as QueryOptionsV2; const normalizedOptions: QueryOptions = {} as QueryOptions; @@ -4989,7 +5028,7 @@ export class ObjectStackClient { // `skip=0` is a consistency change only: it already equals the // server's default, so the request means the same either way — but one // emitter must not hold two rules for one pair. - // Mirrored verbatim in `ScopedProjectClient.data.find`. + // Mirrored verbatim in `ScopedEnvironmentClient.data.find`. if (normalizedOptions.top != null) queryParams.set('top', normalizedOptions.top.toString()); if (normalizedOptions.skip != null) queryParams.set('skip', normalizedOptions.skip.toString()); @@ -5595,15 +5634,18 @@ export class ObjectStackClient { * * Wraps an {@link ObjectStackClient} and prefixes every request with * `/api/v1/environments/:environmentId/...` so a single client instance can talk to - * multiple projects without mutating global state. + * multiple environments without mutating global state. * * The scoped client exposes the same shape as the `data`, `meta`, `batch`, * and `packages` namespaces on `ObjectStackClient` — only the URL prefix * differs. The server-side dual-mode route registration (see * `packages/rest/src/rest-server.ts`) accepts both shapes when - * `projectResolution` is `'auto'` or `'optional'`. + * `projectResolution` is `'auto'` or `'optional'`. That config key keeps its + * old spelling on purpose: it is a real key on the REST API's surface, not + * this client's, so renaming it is a separate breaking change on a separate + * package — naming it as it is spelled is what keeps this sentence true. */ -export class ScopedProjectClient { +export class ScopedEnvironmentClient { private readonly parent: ObjectStackClient; private readonly environmentId: string; diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 5d35257880..0348ecae2c 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -32,7 +32,7 @@ */ import { describe, it, expect, expectTypeOf, vi } from 'vitest'; -import { ObjectStackClient, ScopedProjectClient } from './index'; +import { ObjectStackClient, ScopedEnvironmentClient } from './index'; import type { CloneDataResult } from './index'; import type { SearchAllResponse } from '@objectstack/spec/api'; import type { @@ -70,7 +70,7 @@ import type { ResolvedBook } from '@objectstack/spec/system'; import type { Environment } from '@objectstack/spec/cloud'; declare const client: ObjectStackClient; -declare const scoped: ScopedProjectClient; +declare const scoped: ScopedEnvironmentClient; /** * Compiled, never invoked. Every statement is an assertion tsc evaluates; none @@ -133,7 +133,7 @@ export async function returnTypePrecisionPins(): Promise { // @ts-expect-error ExecutionLog does not satisfy `T extends FlowParsed` await client.automation.getFlow('flow_a'); - // ── shape class 6: the ScopedProjectClient MIRROR carries the same types ─ + // ── shape class 6: the ScopedEnvironmentClient MIRROR carries the same types ─ expectTypeOf(await scoped.automation.getFlow('flow_a')).toEqualTypeOf(); expectTypeOf(await scoped.automation.getRun('flow_a', 'run_1')).toEqualTypeOf(); expectTypeOf(await scoped.packages.list()).toEqualTypeOf<{ @@ -230,7 +230,7 @@ export function searchResultIsNotTheGlobalSearchShape(): void { * all, whose published type was inferred from `unwrapResponse< …any… >`. * * Re-measured at `origin/main` the population is **39**, not the 38 the card - * recorded — its own single-line reproducer cannot see `projects.get`, whose + * recorded — its own single-line reproducer cannot see `environments.get`, whose * type argument spans several lines. Of the 39, exactly **three** had a * verifiable published type to bind to; the other 36 are recorded on the * methods themselves and filed (#12034, #12036, #12038). The bar was not "a @@ -376,7 +376,7 @@ export async function returnTypePrecisionPins12038(): Promise { * (`restoredVersion`) whose false declaration this family just paid to * remove. * - * 2. `Environment` is the obvious-looking binding for `client.projects.*` and + * 2. `Environment` is the obvious-looking binding for `client.environments.*` and * is camelCase, while the `/api/v1/cloud/*` control plane those methods * call speaks snake_case (measured from this repo's own CLI consumers: * `p.display_name`, `p.organization_id`, `p.is_default`). Binding it would diff --git a/scripts/check-exported-any-returns.mts b/scripts/check-exported-any-returns.mts index 6ae3724f57..15d5ae1c6e 100644 --- a/scripts/check-exported-any-returns.mts +++ b/scripts/check-exported-any-returns.mts @@ -268,7 +268,7 @@ export function scan(program: ts.Program, entryFile: string): ScanResult { // global visited set. A global one is the obvious spelling and it silently // DROPS population: two paths onto the same type — the shape this SDK produces // whenever a namespace object is shared between `ObjectStackClient` and - // `ScopedProjectClient` — would census the type once, under whichever path the + // `ScopedEnvironmentClient` — would census the type once, under whichever path the // walk happened to reach first, so the second path's sites are invisible to the // ratchet and the ledger keys silently depend on walk order. A per-branch set // still terminates (a cycle must revisit an ancestor) and reports every path.