Skip to content

Commit 6274a1a

Browse files
os-trumpclaude
andauthored
feat(client): wire meta.saveItem's ifMatch option to the If-Match header (#12189)
`saveItem`'s docstring named the ADR-0008 OCC protocol — echo the resolved `version` back as `If-Match` — while neither declaration accepted a header or anything that became one. Both REST PUT doors read `if-match` and thread it as `parentVersion`, so the instruction was true of a raw-HTTP caller and false of a first-party SDK caller, whose concurrent edit overwrote and was answered 200. `ifMatch?: string` joins the `SaveMetaItemOptions` bag on BOTH declarations, through one shared `metaSaveHeaders` builder beside the existing shared `metaSaveQuery` — the twins cannot drift. Only a non-empty token reaches the wire; unset, the `init` carries no `headers` key at all, so every existing call is byte-identical and last-write-wins stays the default. Pins assert the built HEADERS in both directions (present with the token, absent without) on both clients — the pre-existing #11391 pins measure the URL only, and a URL pin cannot see a value that goes nowhere. Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8064e6d commit 6274a1a

3 files changed

Lines changed: 342 additions & 11 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
feat(client): `meta.saveItem` can send the `If-Match` OCC header it already told callers to send (#11713)
6+
7+
`saveItem`'s own docstring has always named the ADR-0008 optimistic-concurrency
8+
protocol: the resolved `version` is the token, echo it back as the `If-Match`
9+
request header on the next write to the same item, and a concurrent edit is
10+
reported as `409 METADATA_CONFLICT` instead of silently overwriting. Both REST
11+
`PUT` doors read `if-match` and thread it as `parentVersion`, so that sentence
12+
was true of a raw-HTTP caller. It was **false** of a first-party SDK caller:
13+
neither `saveItem` declaration accepted a header, an `ifMatch`, or anything
14+
that became one — so an SDK caller who did exactly what the docstring said had
15+
nowhere to put the token and their concurrent edit overwrote anyway, answered
16+
`200`. Declared, not enforced, with no signal at the call site.
17+
18+
**What is new:** `ifMatch?: string` joins the `SaveMetaItemOptions` bag that
19+
`#11391` landed, on **both** `saveItem` declarations — the unscoped
20+
`ObjectStackClient.meta` and the environment-scoped
21+
`ScopedProjectClient.meta` — wired to the `If-Match` request header through a
22+
single shared builder, the same way the three query parameters go through one
23+
shared query builder. The twins cannot drift.
24+
25+
```ts
26+
const saved = await client.meta.saveItem('object', 'customer', doc);
27+
// …later, guarded against a concurrent edit:
28+
await client.meta.saveItem('object', 'customer', next, { ifMatch: saved.version });
29+
// a stale token now answers 409 METADATA_CONFLICT instead of overwriting
30+
```
31+
32+
Purely additive and opt-in. Only a non-empty token reaches the wire:
33+
`undefined` and `''` both omit the header entirely — the `init` handed to
34+
`fetch` carries no `headers` key at all — so every existing call is
35+
byte-identical and last-write-wins remains the default, on the wire and on the
36+
door. Unlike the bag's `mode`, `ifMatch` reaches **both** save doors: the
37+
compound-name twin `PUT /meta/:type/:section/:name` reads `if-match` and strips
38+
ETag-style quotes exactly as the single-segment door does.
39+
40+
Aligned deliberately with the other first-party client: the same member name,
41+
the same header, and the same truthy guard as `MetadataClient.save` in
42+
`@object-ui/data-objectstack` — two first-party clients, one behaviour.

packages/client/src/client.test.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2764,3 +2764,214 @@ describe('[#11391] the destructive-409 remedy loop is now closed for an SDK call
27642764
expect(saved).toEqual({ success: true, version: 3 });
27652765
});
27662766
});
2767+
2768+
// ----------------------------------------------------------------------
2769+
// [#11713] `meta.saveItem`'s `If-Match` HEADER — the ADR-0008 OCC token the
2770+
// method's own docstring told callers to echo back, on BOTH clients.
2771+
//
2772+
// The docstring above `saveItem` has always said: the resolved `version` is
2773+
// the optimistic-concurrency token, echo it back as `If-Match` and a
2774+
// concurrent edit answers 409 instead of silently overwriting. Both REST PUT
2775+
// doors read `if-match` and thread it as `parentVersion`, so that sentence was
2776+
// true of a raw-HTTP caller. It was FALSE of a first-party SDK caller: neither
2777+
// declaration accepted a header, an `ifMatch`, or anything that became one, so
2778+
// following the instruction was impossible and the concurrent edit overwrote.
2779+
//
2780+
// ⭐ These pins are on the HEADERS the client BUILDS. The pre-existing #11391
2781+
// pins measure the URL only — and a URL pin cannot see this defect, because
2782+
// the whole defect is that the value goes NOWHERE: an `ifMatch` swallowed
2783+
// silently leaves the URL byte-identical, which is exactly what those pins
2784+
// assert. So each case here asserts both directions — the header PRESENT with
2785+
// the caller's token when supplied, and ABSENT when it is not.
2786+
// ----------------------------------------------------------------------
2787+
2788+
/** Pull the headers object the client handed `fetch` on its Nth call. */
2789+
function headersOfCall(fetchMock: ReturnType<typeof vi.fn>, i = 0): Record<string, string> {
2790+
return (fetchMock.mock.calls[i]?.[1]?.headers ?? {}) as Record<string, string>;
2791+
}
2792+
2793+
/**
2794+
* The header NAMES that call put on the wire, sorted.
2795+
*
2796+
* The byte-identity claim has to be spelled this way rather than as "the
2797+
* `init` has no `headers` key": the mock here is `fetchImpl`, and the client's
2798+
* private `fetch` always hands it a merged header object (`Content-Type`, plus
2799+
* auth / environment / locale when configured). `metaSaveHeaders` returning
2800+
* `undefined` is what keeps that merge byte-identical to an un-pinned save —
2801+
* this is where that shows up.
2802+
*/
2803+
function headerNamesOf(fetchMock: ReturnType<typeof vi.fn>, i = 0): string[] {
2804+
return Object.keys(headersOfCall(fetchMock, i)).sort();
2805+
}
2806+
2807+
/** What an un-pinned save sends on this bare mock client: nothing but the body type. */
2808+
const BASELINE_HEADERS = ['Content-Type'];
2809+
2810+
const OCC_TOKEN = 'sha256:' + 'b'.repeat(64);
2811+
2812+
describe('[#11713] meta.saveItem sends the If-Match header (unscoped client)', () => {
2813+
it('sends `ifMatch` as the If-Match request header, verbatim', async () => {
2814+
const { client, fetchMock } = createMockClient({ success: true, version: 'sha256:next' });
2815+
await client.meta.saveItem('object', 'customer', { name: 'customer' }, { ifMatch: OCC_TOKEN });
2816+
// THE assertion this card exists for: the token reaches the wire.
2817+
expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN);
2818+
// Verbatim and unquoted — the sibling first-party client
2819+
// (`@object-ui/data-objectstack` MetadataClient.save) sends exactly
2820+
// these bytes, and the door strips ETag quotes rather than requiring
2821+
// them.
2822+
expect(headersOfCall(fetchMock)['If-Match']).not.toContain('"');
2823+
});
2824+
2825+
it('ABSENT when the caller does not pin: no If-Match, and no `headers` key at all', async () => {
2826+
const { client, fetchMock } = createMockClient({ success: true });
2827+
await client.meta.saveItem('object', 'customer', { name: 'customer' });
2828+
expect(headersOfCall(fetchMock)['If-Match']).toBeUndefined();
2829+
// Byte-identity, not merely "no If-Match": the builder returns
2830+
// `undefined`, so the header set this save puts on the wire is exactly
2831+
// the one an un-pinned save always sent. Last-write-wins stays default.
2832+
expect(headerNamesOf(fetchMock)).toEqual(BASELINE_HEADERS);
2833+
});
2834+
2835+
it('ABSENT when the bag is present but carries no token', async () => {
2836+
const { client, fetchMock } = createMockClient({ success: true });
2837+
await client.meta.saveItem('object', 'customer', {}, { force: true });
2838+
expect(headersOfCall(fetchMock)['If-Match']).toBeUndefined();
2839+
expect(headerNamesOf(fetchMock)).toEqual(BASELINE_HEADERS);
2840+
});
2841+
2842+
it("ABSENT for an empty token — `''` never reaches the wire", async () => {
2843+
const { client, fetchMock } = createMockClient({ success: true });
2844+
await client.meta.saveItem('object', 'customer', {}, { ifMatch: '' });
2845+
// An empty `If-Match` is not a no-op on the door: presence means "pin
2846+
// this write", so an emitted empty header would pin against the empty
2847+
// string and refuse a save the caller never asked to pin.
2848+
expect(headersOfCall(fetchMock)['If-Match']).toBeUndefined();
2849+
expect(headerNamesOf(fetchMock)).toEqual(BASELINE_HEADERS);
2850+
});
2851+
2852+
it('is a HEADER, not a query parameter: the URL is byte-identical to an unpinned save', async () => {
2853+
const { client, fetchMock } = createMockClient({ success: true });
2854+
await client.meta.saveItem('object', 'customer', {}, { ifMatch: OCC_TOKEN });
2855+
// `?ifMatch=` is read by neither PUT door. If it ever appeared here it
2856+
// would look set at the call site and protect nothing.
2857+
expect(String(fetchMock.mock.calls[0][0])).toBe(
2858+
'http://localhost:3000/api/v1/meta/object/customer',
2859+
);
2860+
expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN);
2861+
});
2862+
2863+
it('rides alongside the #11391 query parameters without disturbing them', async () => {
2864+
const { client, fetchMock } = createMockClient({ success: true });
2865+
await client.meta.saveItem('object', 'customer', { name: 'customer' }, {
2866+
ifMatch: OCC_TOKEN,
2867+
force: true,
2868+
packageId: 'app.crm',
2869+
mode: 'draft',
2870+
});
2871+
expect(String(fetchMock.mock.calls[0][0])).toBe(
2872+
'http://localhost:3000/api/v1/meta/object/customer?force=true&package=app.crm&mode=draft',
2873+
);
2874+
expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN);
2875+
const init = fetchMock.mock.calls[0][1];
2876+
expect(init.method).toBe('PUT');
2877+
// The token is not a field on the document being saved.
2878+
expect(JSON.parse(init.body)).toEqual({ name: 'customer' });
2879+
});
2880+
2881+
it('OCC-guards a COMPOUND name too — unlike `mode`, this reaches both doors', async () => {
2882+
const { client, fetchMock } = createMockClient({ success: true });
2883+
await client.meta.saveItem('object', 'views/all_leads', { label: 'All leads' }, { ifMatch: OCC_TOKEN });
2884+
// The compound-name door `PUT /meta/:type/:section/:name` reads
2885+
// `if-match` and strips ETag quotes exactly as the single-segment door
2886+
// does — measured in rest-server.ts. `mode` is the member that does NOT
2887+
// reach it; this one does, so the slash must survive AND the pin must
2888+
// ride along.
2889+
expect(String(fetchMock.mock.calls[0][0])).toBe(
2890+
'http://localhost:3000/api/v1/meta/object/views/all_leads',
2891+
);
2892+
expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN);
2893+
});
2894+
});
2895+
2896+
describe('[#11713] meta.saveItem sends the If-Match header (environment-scoped twin)', () => {
2897+
it('sends the header on the scoped client too', async () => {
2898+
const { client, fetchMock } = createMockClient({ success: true });
2899+
await client.project('proj-123').meta.saveItem(
2900+
'object', 'customer', { name: 'customer' }, { ifMatch: OCC_TOKEN },
2901+
);
2902+
expect(String(fetchMock.mock.calls[0][0])).toBe(
2903+
'http://localhost:3000/api/v1/environments/proj-123/meta/object/customer',
2904+
);
2905+
expect(headersOfCall(fetchMock)['If-Match']).toBe(OCC_TOKEN);
2906+
});
2907+
2908+
it('ABSENT on the scoped client when the caller does not pin', async () => {
2909+
const { client, fetchMock } = createMockClient({ success: true });
2910+
await client.project('proj-123').meta.saveItem('object', 'customer', { name: 'customer' });
2911+
expect(headersOfCall(fetchMock)['If-Match']).toBeUndefined();
2912+
expect(headerNamesOf(fetchMock)).toEqual(BASELINE_HEADERS);
2913+
});
2914+
2915+
it('IN STEP with the unscoped twin: identical If-Match for identical options', async () => {
2916+
// The divergence this card is about is a fix landing on one twin only.
2917+
// Comparing the two headers keeps holding if either path changes,
2918+
// rather than restating a literal on both sides.
2919+
const { client, fetchMock } = createMockClient({ success: true });
2920+
const opts = { ifMatch: OCC_TOKEN, force: true } as const;
2921+
await client.meta.saveItem('object', 'customer', {}, opts);
2922+
await client.project('proj-123').meta.saveItem('object', 'customer', {}, opts);
2923+
expect(headersOfCall(fetchMock, 1)['If-Match']).toBe(headersOfCall(fetchMock, 0)['If-Match']);
2924+
expect(headersOfCall(fetchMock, 0)['If-Match']).toBe(OCC_TOKEN);
2925+
});
2926+
2927+
it('IN STEP when unpinned too: neither twin adds a header', async () => {
2928+
const { client, fetchMock } = createMockClient({ success: true });
2929+
await client.meta.saveItem('object', 'customer', {}, { force: true });
2930+
await client.project('proj-123').meta.saveItem('object', 'customer', {}, { force: true });
2931+
expect(headerNamesOf(fetchMock, 0)).toEqual(BASELINE_HEADERS);
2932+
expect(headerNamesOf(fetchMock, 1)).toEqual(BASELINE_HEADERS);
2933+
});
2934+
});
2935+
2936+
describe('[#11713] the docstring\'s OCC instruction is now executable end to end', () => {
2937+
it('save → pin the resolved `version` → the stale write is REFUSED, not silently applied', async () => {
2938+
// Round 1 answers a real save body; round 2 answers the real conflict
2939+
// envelope the door emits when `parentVersion` does not match
2940+
// (`{ code: 'METADATA_CONFLICT' }` at HTTP 409 — pinned in
2941+
// packages/rest/src/rest.test.ts).
2942+
const conflict = {
2943+
error: 'parentVersion mismatch',
2944+
code: 'METADATA_CONFLICT',
2945+
};
2946+
const responses: any[] = [
2947+
{
2948+
ok: true, status: 200, statusText: 'OK',
2949+
json: async () => ({ success: true, version: OCC_TOKEN, seq: 4, state: 'active' }),
2950+
headers: new Headers(),
2951+
},
2952+
{ ok: false, status: 409, statusText: 'Conflict', json: async () => conflict, headers: new Headers() },
2953+
];
2954+
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(responses.shift()));
2955+
const client = new ObjectStackClient({ baseUrl: 'http://localhost:3000', fetch: fetchMock });
2956+
2957+
// 1. A save resolves the OCC carrier the docstring names.
2958+
const saved = await client.meta.saveItem('object', 'customer', { name: 'customer' });
2959+
expect(saved.version).toBe(OCC_TOKEN);
2960+
// Un-pinned, so no token was sent — this is the before-state.
2961+
expect(headersOfCall(fetchMock, 0)['If-Match']).toBeUndefined();
2962+
2963+
// 2. Do literally what the docstring prescribes. THIS is the
2964+
// acceptance criterion: before #11713 there was no argument to
2965+
// pass here, so the instruction could not be followed at all.
2966+
const err: any = await client.meta
2967+
.saveItem('object', 'customer', { name: 'customer v2' }, { ifMatch: saved.version })
2968+
.then(() => { throw new Error('expected the stale save to be refused'); }, (e) => e);
2969+
// Assert the ENVELOPE the caller branches on, not merely that
2970+
// something threw: a bare `.toThrow()` stays green against any error,
2971+
// including one from a client that never sent the header.
2972+
expect(err.code).toBe('METADATA_CONFLICT');
2973+
expect(err.httpStatus).toBe(409);
2974+
// And the pin really rode the second request.
2975+
expect(headersOfCall(fetchMock, 1)['If-Match']).toBe(OCC_TOKEN);
2976+
});
2977+
});

0 commit comments

Comments
 (0)