@@ -237,30 +237,33 @@ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
237237} ) ;
238238
239239/**
240- * #2679: when POST /api/v1/batch is unavailable (endpoint missing → 404/405, or
241- * a runtime without transactions → 501), the adapter degrades to a client-side,
242- * NON-atomic emulation instead of failing the save. This is the isolated home of
243- * the non-atomic fallback — the form no longer carries it. Real errors (4xx/5xx
244- * other than those three) must still surface, never be silently downgraded.
240+ * #2679 / #2694: cross-object saves go through the SDK's
241+ * `client.data.batchTransaction` (no hand-rolled POST /api/v1/batch). When the
242+ * SDK reports this backend can't do a transactional batch — it throws an error
243+ * carrying HTTP 404/405 (endpoint missing) or 501 (runtime without
244+ * transactions) — the adapter degrades to a client-side, NON-atomic emulation
245+ * instead of failing the save. This is the isolated home of the non-atomic
246+ * fallback — the form no longer carries it. Real errors (any other 4xx/5xx)
247+ * must still surface, never be silently downgraded.
245248 */
246249describe ( 'ObjectStackAdapter.batchTransaction fallback (no server atomicity)' , ( ) => {
250+ // The SDK's batchTransaction signals "backend can't do a transactional batch"
251+ // by throwing an error decorated with the HTTP status (the @objectstack/client
252+ // convention that `errorStatusOf` reads).
247253 function makeFallbackDS ( opts : { batchStatus : number ; clientData : Record < string , any > } ) {
248- const fetchMock = vi . fn ( async ( ) =>
249- new Response ( JSON . stringify ( { error : 'nope' } ) , {
250- status : opts . batchStatus ,
251- headers : { 'Content-Type' : 'application/json' } ,
252- } ) ,
253- ) ;
254- const ds : any = new ObjectStackAdapter ( { baseUrl : 'http://test.local' , fetch : fetchMock } ) ;
254+ const batchTransaction = vi . fn ( async ( ) => {
255+ throw Object . assign ( new Error ( 'nope' ) , { httpStatus : opts . batchStatus } ) ;
256+ } ) ;
257+ const ds : any = new ObjectStackAdapter ( { baseUrl : 'http://test.local' , fetch : vi . fn ( ) } ) ;
255258 ds . connected = true ;
256259 ds . connectionState = 'connected' ;
257- ds . client = { data : opts . clientData } ;
258- return { ds, fetchMock } ;
260+ ds . client = { data : { batchTransaction , ... opts . clientData } } ;
261+ return { ds, batchTransaction } ;
259262 }
260263
261264 it ( '404 → warns once, saves via the create primitive, and caches the detection' , async ( ) => {
262265 const create = vi . fn ( async ( _o : string , d : any ) => ( { record : { id : 'p1' , ...d } } ) ) ;
263- const { ds, fetchMock } = makeFallbackDS ( { batchStatus : 404 , clientData : { create } } ) ;
266+ const { ds, batchTransaction } = makeFallbackDS ( { batchStatus : 404 , clientData : { create } } ) ;
264267 const warn = vi . spyOn ( console , 'warn' ) . mockImplementation ( ( ) => { } ) ;
265268 const events : MutationEvent [ ] = [ ] ;
266269 ds . onMutation ( ( e : MutationEvent ) => events . push ( e ) ) ;
@@ -274,11 +277,11 @@ describe('ObjectStackAdapter.batchTransaction fallback (no server atomicity)', (
274277 // committed-batch path (no double emission).
275278 expect ( events ) . toEqual ( [ { type : 'create' , resource : 'proj' , record : { id : 'p1' , name : 'A' } } ] ) ;
276279
277- const probes = fetchMock . mock . calls . length ;
278280 // A second batch short-circuits: the endpoint is known-unsupported, so we
279- // never probe POST /api/v1/batch again.
281+ // never call the SDK batch method again.
282+ expect ( batchTransaction ) . toHaveBeenCalledTimes ( 1 ) ;
280283 await ds . batchTransaction ( [ { object : 'proj' , action : 'create' , data : { name : 'B' } } ] ) ;
281- expect ( fetchMock . mock . calls . length ) . toBe ( probes ) ;
284+ expect ( batchTransaction ) . toHaveBeenCalledTimes ( 1 ) ;
282285 warn . mockRestore ( ) ;
283286 } ) ;
284287
@@ -291,7 +294,7 @@ describe('ObjectStackAdapter.batchTransaction fallback (no server atomicity)', (
291294 warn . mockRestore ( ) ;
292295 } ) ;
293296
294- it ( 'a real error (400) surfaces as ObjectStackError — never downgraded to emulation' , async ( ) => {
297+ it ( 'a real error (400) surfaces — never downgraded to emulation' , async ( ) => {
295298 const create = vi . fn ( ) ;
296299 const { ds } = makeFallbackDS ( { batchStatus : 400 , clientData : { create } } ) ;
297300 await expect (
@@ -301,16 +304,20 @@ describe('ObjectStackAdapter.batchTransaction fallback (no server atomicity)', (
301304 expect ( create ) . not . toHaveBeenCalled ( ) ;
302305 } ) ;
303306
304- it ( 'prefers the SDK client.data.batchTransaction when present (no raw fetch) ' , async ( ) => {
307+ it ( 'routes a committed batch through the SDK method and never hand-rolls a fetch' , async ( ) => {
305308 const sdkBatch = vi . fn ( async ( ) => ( { results : [ { id : 'p1' , name : 'A' } ] } ) ) ;
306- const { ds, fetchMock } = makeFallbackDS ( { batchStatus : 500 , clientData : { batchTransaction : sdkBatch } } ) ;
309+ const fetchMock = vi . fn ( ) ;
310+ const ds : any = new ObjectStackAdapter ( { baseUrl : 'http://test.local' , fetch : fetchMock } ) ;
311+ ds . connected = true ;
312+ ds . connectionState = 'connected' ;
313+ ds . client = { data : { batchTransaction : sdkBatch } } ;
307314 const events : MutationEvent [ ] = [ ] ;
308315 ds . onMutation ( ( e : MutationEvent ) => events . push ( e ) ) ;
309316
310317 const res = await ds . batchTransaction ( [ { object : 'proj' , action : 'create' , data : { name : 'A' } } ] ) ;
311318
312319 expect ( sdkBatch ) . toHaveBeenCalledTimes ( 1 ) ;
313- expect ( fetchMock ) . not . toHaveBeenCalled ( ) ; // raw /api/v1/batch not used
320+ expect ( fetchMock ) . not . toHaveBeenCalled ( ) ; // no raw POST /api/v1/batch
314321 expect ( res . results [ 0 ] ) . toMatchObject ( { id : 'p1' } ) ;
315322 // Committed via the SDK → one event per op (emitted once).
316323 expect ( events ) . toEqual ( [ { type : 'create' , resource : 'proj' , record : { id : 'p1' , name : 'A' } } ] ) ;
@@ -400,12 +407,14 @@ describe('ObjectStackAdapter.batchTransaction capability gate (#2693 / framework
400407 /** Value placed at `capabilities.transactionalBatch`; omit to leave it absent. */
401408 capability ?: boolean | { enabled : boolean } ;
402409 batchStatus : number ;
403- /** Response body for the `/ batch` call (defaults to an error envelope) . */
410+ /** Result the SDK batch resolves with on a 2xx `batchStatus` . */
404411 batchBody ?: unknown ;
405412 clientData ?: Record < string , any > ;
406413 } ) {
407414 const capabilities : Record < string , unknown > = { } ;
408415 if ( opts . capability !== undefined ) capabilities . transactionalBatch = opts . capability ;
416+ // connect() still fetches discovery to read the #3298 capability; the batch
417+ // itself now goes through the SDK method (no raw POST /api/v1/batch).
409418 const fetchMock = vi . fn ( async ( input : RequestInfo | URL ) => {
410419 const url = String ( input ) ;
411420 if ( url . includes ( '/api/v1/discovery' ) ) {
@@ -414,20 +423,25 @@ describe('ObjectStackAdapter.batchTransaction capability gate (#2693 / framework
414423 { status : 200 , headers : { 'Content-Type' : 'application/json' } } ,
415424 ) ;
416425 }
417- // POST /api/v1/batch
418- return new Response ( JSON . stringify ( opts . batchBody ?? { error : 'nope' } ) , {
419- status : opts . batchStatus ,
420- headers : { 'Content-Type' : 'application/json' } ,
421- } ) ;
426+ return new Response ( '{}' , { status : 404 , headers : { 'Content-Type' : 'application/json' } } ) ;
427+ } ) ;
428+ // The SDK batch resolves on a 2xx status and otherwise throws a
429+ // status-decorated error (the @objectstack/client convention `errorStatusOf`
430+ // reads) — the signal the capability gate + fallback branch on.
431+ const batchTransaction = vi . fn ( async ( ) => {
432+ if ( opts . batchStatus >= 200 && opts . batchStatus < 300 ) {
433+ return opts . batchBody ?? { results : [ ] } ;
434+ }
435+ throw Object . assign ( new Error ( 'nope' ) , { httpStatus : opts . batchStatus } ) ;
422436 } ) ;
423437 const ds : any = new ObjectStackAdapter ( {
424438 baseUrl : 'http://test.local' ,
425439 autoReconnect : false ,
426440 fetch : fetchMock ,
427441 } ) ;
428- // Feature-detect target for the SDK batch method + emulation primitives.
429- ds . client = { data : opts . clientData ?? { } } ;
430- return { ds, fetchMock } ;
442+ // SDK batch method + emulation primitives live on the stubbed client .
443+ ds . client = { data : { batchTransaction , ... ( opts . clientData ?? { } ) } } ;
444+ return { ds, fetchMock, batchTransaction } ;
431445 }
432446
433447 it ( 'declared transactionalBatch:true → a 404 is a hard error, NOT downgraded to emulation' , async ( ) => {
0 commit comments