Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/client-meta-getitem-saveitem-return-types.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 33 additions & 7 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
36 changes: 28 additions & 8 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
GetDiscoveryResponse,
GetMetaTypesResponse,
GetMetaItemsResponse,
GetMetaItemResponse,
SaveMetaItemResponse,
LoginRequest,
SessionResponse,
GetPresignedUrlRequest,
Expand Down Expand Up @@ -550,30 +552,44 @@ 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<GetMetaItemResponse> => {
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<GetMetaItemResponse>(res);
},

/**
* Save a metadata item
* @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<SaveMetaItemResponse> => {
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<SaveMetaItemResponse>(res);
},

/**
Expand Down Expand Up @@ -4689,19 +4705,21 @@ export class ScopedProjectClient {
const res = await this.parent._fetch(this.url(`/meta/${type}${qs ? `?${qs}` : ''}`));
return this.parent._unwrap<GetMetaItemsResponse>(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<GetMetaItemResponse> => {
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<GetMetaItemResponse>(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<SaveMetaItemResponse> => {
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<SaveMetaItemResponse>(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)}`), {
Expand Down Expand Up @@ -5043,6 +5061,8 @@ export type {
GetDiscoveryResponse,
GetMetaTypesResponse,
GetMetaItemsResponse,
GetMetaItemResponse,
SaveMetaItemResponse,
CheckPermissionRequest,
CheckPermissionResponse,
GetObjectPermissionsResponse,
Expand Down
Loading