@@ -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