From e8ad520a132b311c93333a9f2d10fce4a0372f73 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 12:42:58 +0000 Subject: [PATCH] fix(client): `meta.getItem` / `meta.saveItem` declare their spec response types on both surfaces (#5545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectStackClient.meta` and `ScopedProjectClient.meta` each had a `getItem` and a `saveItem` with no return-type annotation, so `unwrapResponse` / `_unwrap` resolved with no type argument and callers got `unknown` — while the `getItems` one line above returned `GetMetaItemsResponse`. - `getItem` -> `Promise< GetMetaItemResponse >` (the `{ type, name, item }` envelope). Honest only since #5563 converged the route's cached and non-cached paths on that one shape. - `saveItem` -> `Promise< SaveMetaItemResponse >`, including the ADR-0008 OCC token `version`. Nameable only since #5745 completed that schema. Both types are re-exported from `@objectstack/client`. `client.test.ts`'s getItem assertion becomes typed field reads (`result.type` / `result.name`) with its `as any` dropped, and a new test pins the save response's OCC carriers. Reverse-verified: stripping the four annotations turns those reads red with TS18046. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DWUR56YsttL5sTF72Q75TQ --- ...ient-meta-getitem-saveitem-return-types.md | 30 ++++++++++++++ packages/client/src/client.test.ts | 40 +++++++++++++++---- packages/client/src/index.ts | 36 +++++++++++++---- 3 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 .changeset/client-meta-getitem-saveitem-return-types.md diff --git a/.changeset/client-meta-getitem-saveitem-return-types.md b/.changeset/client-meta-getitem-saveitem-return-types.md new file mode 100644 index 0000000000..82f5e79434 --- /dev/null +++ b/.changeset/client-meta-getitem-saveitem-return-types.md @@ -0,0 +1,30 @@ +--- +"@objectstack/client": patch +--- + +client SDK: `meta.getItem` / `meta.saveItem` declare their spec response types on both surfaces + +`ObjectStackClient.meta` and `ScopedProjectClient.meta` each carried a `getItem` +and a `saveItem` with no return-type annotation, so `unwrapResponse` / `_unwrap` +resolved without a type argument and every caller received `unknown` — while the +`getItems` sitting one line above returned `GetMetaItemsResponse`. Two adjacent +methods on one surface, unequal typing (#5545). + +Both now name the type `@objectstack/spec` already declares for their route, and +both types are re-exported from `@objectstack/client` so a caller can name what +it received: + +- `getItem` → `Promise< GetMetaItemResponse >` — the `{ type, name, item }` + envelope. This became the only honest annotation with #5563: before it, the + route's default (cached) path answered the bare document and the non-cached + path the envelope, so no single type described both. +- `saveItem` → `Promise< SaveMetaItemResponse >` — including `version`, the + ADR-0008 optimistic-concurrency token a caller echoes back as `If-Match`. + Nameable only since #5745 completed that schema; against the older + `{ success, message }` declaration the annotation would have hidden the OCC + carrier. + +`patch`, not `minor`: this only narrows `unknown` on existing public signatures. +`unknown` admits no property read and no assignment to a typed binding, so every +expression that compiled before still compiles — nothing is removed, and no new +method or option appears. diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 6d2b7a2409..157a34f621 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -112,16 +112,42 @@ describe('ObjectStackClient', () => { fetch: fetchMock }); - const result = await client.meta.getItem('object', 'customer') as any; + const result = await client.meta.getItem('object', 'customer'); expect(fetchMock).toHaveBeenCalledWith('http://localhost:3000/api/v1/meta/object/customer', expect.any(Object)); - // `meta.getItem` has no declared return type (unlike the `getItems` - // beside it — #5545), so its unwrapped payload is `unknown`. Asserted - // structurally rather than cast: same assertion strength, without - // pretending this surface is typed (#5449). - expect(result).toMatchObject({ type: 'object', name: 'customer' }); + // #5545: `meta.getItem` now declares `Promise< GetMetaItemResponse >`, + // matching the `getItems` beside it. These are TYPED field reads, not a + // `toMatchObject` shape probe over an `unknown` payload — `result.type` + // and `result.name` compile only while the annotation is there, so + // removing it turns these two lines red (TS18046) instead of silently + // weakening the assertion. No cast: the `as any` this test carried + // (#5449) existed solely because the surface was untyped. + expect(result.type).toBe('object'); + expect(result.name).toBe('customer'); // Load-bearing: the document lives under `item`, not spread at the top // level. A regression to the bare shape fails HERE, not on a missing key. - expect(result.item).toMatchObject({ label: 'Customer' }); + // `item` is `unknown` in the spec schema (the envelope is typed, the + // document it carries is not), so the document's own keys stay a + // structural assertion — that is the schema's shape, not a gap. + expect(result.item).toMatchObject({ name: 'customer', label: 'Customer' }); + }); + + it('meta.saveItem surfaces the ADR-0008 OCC carriers the save response declares (#5545)', async () => { + // The real `PUT /api/v1/meta/:type/:name` body, as + // `SaveMetaItemResponseSchema` has declared it since #5745: `version` + // is the `If-Match` token the optimistic-concurrency chain runs on, + // and it is reachable from the SDK without a cast only because + // `saveItem` names that type. + const { client } = createMockClient({ + success: true, + version: 'sha256:' + 'a'.repeat(64), + seq: 7, + state: 'active', + }); + const saved = await client.meta.saveItem('object', 'customer', { name: 'customer' }); + expect(saved.success).toBe(true); + expect(saved.version).toBe('sha256:' + 'a'.repeat(64)); + expect(saved.seq).toBe(7); + expect(saved.state).toBe('active'); }); it('meta.getView speaks the path-param dialect both surfaces accept (#3611)', async () => { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 34fee22c5c..d2f819c3e8 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -14,6 +14,8 @@ import { GetDiscoveryResponse, GetMetaTypesResponse, GetMetaItemsResponse, + GetMetaItemResponse, + SaveMetaItemResponse, LoginRequest, SessionResponse, GetPresignedUrlRequest, @@ -550,15 +552,21 @@ export class ObjectStackClient { * @param type - Metadata type (e.g., 'object', 'plugin') * @param name - Item name (snake_case identifier) * @param options - Optional filters (e.g., packageId to scope by package) + * + * Answers the spec's `GetMetaItemResponseSchema` envelope: the metadata + * document lives under `item`, NOT spread at the top level. Naming that + * type here is honest only because #5563 converged every serving path on + * it — the cached path (the default one) used to answer the bare document, + * so before that convergence no annotation could describe both (#5545). */ - getItem: async (type: string, name: string, options?: { packageId?: string }) => { + getItem: async (type: string, name: string, options?: { packageId?: string }): Promise => { const route = this.getRoute('metadata'); const params = new URLSearchParams(); if (options?.packageId) params.set('package', options.packageId); const qs = params.toString(); const url = `${this.baseUrl}${route}/${type}/${name}${qs ? `?${qs}` : ''}`; const res = await this.fetch(url); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** @@ -566,14 +574,22 @@ export class ObjectStackClient { * @param type - Metadata type (e.g., 'object', 'plugin') * @param name - Item name * @param item - The metadata content to save + * + * The resolved `version` is the ADR-0008 optimistic-concurrency token: + * echo it back as the `If-Match` request header on the next write to the + * same item and a concurrent edit is reported as 409 `metadata_conflict` + * instead of silently overwriting. It is nameable here only because + * `SaveMetaItemResponseSchema` declares the full body since #5745 — the + * declaration used to stop at `{ success, message }`, and annotating + * against that subset would have hidden the OCC carrier (#5545). */ - saveItem: async (type: string, name: string, item: any) => { + saveItem: async (type: string, name: string, item: any): Promise => { const route = this.getRoute('metadata'); const res = await this.fetch(`${this.baseUrl}${route}/${type}/${name}`, { method: 'PUT', body: JSON.stringify(item) }); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** @@ -4689,19 +4705,21 @@ export class ScopedProjectClient { const res = await this.parent._fetch(this.url(`/meta/${type}${qs ? `?${qs}` : ''}`)); return this.parent._unwrap(res); }, - getItem: async (type: string, name: string, options?: { packageId?: string }) => { + /** Same `{ type, name, item }` envelope as the unscoped surface (#5563). */ + getItem: async (type: string, name: string, options?: { packageId?: string }): Promise => { const params = new URLSearchParams(); if (options?.packageId) params.set('package', options.packageId); const qs = params.toString(); const res = await this.parent._fetch(this.url(`/meta/${type}/${name}${qs ? `?${qs}` : ''}`)); - return this.parent._unwrap(res); + return this.parent._unwrap(res); }, - saveItem: async (type: string, name: string, item: any) => { + /** Carries the ADR-0008 OCC token in `version` — see the unscoped twin. */ + saveItem: async (type: string, name: string, item: any): Promise => { const res = await this.parent._fetch(this.url(`/meta/${type}/${name}`), { method: 'PUT', body: JSON.stringify(item), }); - return this.parent._unwrap(res); + return this.parent._unwrap(res); }, deleteItem: async (type: string, name: string): Promise<{ type: string; name: string; deleted: boolean }> => { const res = await this.parent._fetch(this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}`), { @@ -5043,6 +5061,8 @@ export type { GetDiscoveryResponse, GetMetaTypesResponse, GetMetaItemsResponse, + GetMetaItemResponse, + SaveMetaItemResponse, CheckPermissionRequest, CheckPermissionResponse, GetObjectPermissionsResponse,