Skip to content

Commit 0ea5036

Browse files
authored
refactor(data-objectstack): route batchTransaction through the client SDK only (#2694)
ObjectStackAdapter.batchTransaction now calls the typed SDK method client.data.batchTransaction(operations) directly; the transitional hand-rolled fetch('/api/v1/batch') branch is removed. Guaranteed present by the @objectstack/client@^16 dependency floor (framework #3271). Composes with the #2755 capability gate: a declared-atomic backend treats any failure as a real error; otherwise the SDK's status-decorated throw (404/405/501) still degrades to the non-atomic emulateBatchTransaction. Fallback tests reworked to drive via the SDK method. Docs + changeset updated. Closes #2694.
1 parent 8c1e415 commit 0ea5036

5 files changed

Lines changed: 120 additions & 95 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@object-ui/data-objectstack": patch
3+
---
4+
5+
refactor(data-objectstack): route `batchTransaction` through the client SDK only, drop the raw-fetch branch
6+
7+
`@objectstack/client@^16` (framework #3271, the current ObjectUI dependency
8+
floor) ships `data.batchTransaction`, so `ObjectStackAdapter.batchTransaction`
9+
now calls the typed SDK method directly. The transitional hand-rolled
10+
`fetch('/api/v1/batch')` branch — a feature-detect shim kept while the SDK
11+
method was unreleased — is removed (#2694). Per AGENTS.md §7, adapter data
12+
always flows through `@objectstack/client`, never a raw `fetch`.
13+
14+
No behavior change: the SDK still drives the server's atomic `POST /api/v1/batch`,
15+
one `MutationEvent` is emitted per committed op (no double-fire), and the adapter
16+
still degrades to the non-atomic `emulateBatchTransaction` when this backend lacks
17+
the endpoint (404/405) or its runtime can't do transactions (501). Every other
18+
status still surfaces to the caller.

content/docs/guide/data-source.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,10 @@ Master-detail saves (a parent record plus its child line items) go through
178178
`dataSource.batchTransaction(operations)` — one ordered list of cross-object
179179
create/update/delete operations, where a child's foreign key can be
180180
`{ $ref: <parent op index> }` to point at a parent created in the same batch.
181-
The `@object-ui/data-objectstack` adapter maps this to the server's atomic
182-
`POST /api/v1/batch` endpoint (commit-all-or-roll-back-all). Adapters without a
181+
The `@object-ui/data-objectstack` adapter maps this to the published
182+
`@objectstack/client` `data.batchTransaction` SDK method, which drives the
183+
server's atomic `POST /api/v1/batch` endpoint (commit-all-or-roll-back-all).
184+
Adapters without a
183185
transactional endpoint don't need to hand-write orchestration: call
184186
`emulateBatchTransaction(dataSource, operations)` from `@object-ui/core`, which
185187
executes the operations sequentially (resolving `$ref`s) with best-effort

docs/adr/0001-master-detail-subform.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,3 +347,21 @@ item 4):
347347
endpoint is absent (404/405) or the runtime can't do transactions (501).
348348
- Hard removal of the emulation is gated on the server advertising a batch
349349
capability via discovery (not yet advertised), so the fallback stays for now.
350+
351+
## Addendum (2026-07, #2694): batch transport goes through the client SDK
352+
353+
Follow-up to the #2679 unification above, now that `@objectstack/client@^16`
354+
(framework #3271) ships `data.batchTransaction` and is the ObjectUI dependency
355+
floor:
356+
357+
- `ObjectStackAdapter.batchTransaction` calls the typed SDK method
358+
`client.data.batchTransaction(operations)` directly. The transitional
359+
hand-rolled `fetch('/api/v1/batch')` branch — a feature-detect shim kept while
360+
the SDK method was unreleased — has been removed. Per AGENTS.md §7, adapter
361+
data always flows through `@objectstack/client`, never a raw `fetch`.
362+
- Behavior is unchanged: the SDK still drives the server's atomic
363+
`POST /api/v1/batch`, and the adapter still degrades to
364+
`emulateBatchTransaction` when this backend lacks the endpoint (404/405) or its
365+
runtime can't do transactions (501). Every other status surfaces to the caller.
366+
- The non-atomic emulation itself is untouched (still gated on a
367+
discovery-advertised batch capability, not yet advertised).

packages/data-objectstack/src/index.ts

Lines changed: 35 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -448,10 +448,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
448448
// failing requests on every record open / panel render.
449449
private missingResources = new Set<string>();
450450
// Set once the server has told us it can't do a cross-object transactional
451-
// batch (POST /api/v1/batch answered 404/405/501). After that, batchTransaction
452-
// stops probing the endpoint and serves every call via the client-side
453-
// emulation — the non-atomic fallback lives HERE, isolated to the one adapter
454-
// that has to cope with a backend lacking server atomicity (#2679).
451+
// batch (the client SDK's data.batchTransaction threw HTTP 404/405/501). After
452+
// that, batchTransaction skips the SDK call and serves every call via the
453+
// client-side emulation — the non-atomic fallback lives HERE, isolated to the
454+
// one adapter that has to cope with a backend lacking server atomicity (#2679).
455455
private batchUnsupported = false;
456456
// The server's declared cross-object atomic-batch capability, read from
457457
// discovery at connect() (framework #3298 / objectui #2693). `true` → the
@@ -996,20 +996,22 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
996996
* created id, so a child can reference its parent created earlier in the same
997997
* batch (master-detail).
998998
*
999-
* Transport preference: the published `@objectstack/client` SDK method when
1000-
* available (framework #3271), else a raw `POST /api/v1/batch`.
999+
* Transport: the published `@objectstack/client` SDK method
1000+
* `data.batchTransaction` (framework #3271; shipped since client v16, our
1001+
* dependency floor). Per AGENTS.md §7 data always flows through the client —
1002+
* never a hand-rolled `fetch('/api/v1/batch')`.
10011003
*
10021004
* Fallback decision — declarative capability negotiation (framework #3298 /
10031005
* objectui #2693). At connect() we read `capabilities.transactionalBatch`
10041006
* from discovery:
10051007
* - Declared `true` → the backend GUARANTEES atomicity (declared ===
10061008
* enforced). We TRUST it: any batch failure — including 404/405/501 —
1007-
* surfaces as a real error. No runtime probe, no non-atomic client-side
1008-
* compensation. This is the path modern backends take.
1009+
* surfaces as a real error. No non-atomic client-side compensation. This
1010+
* is the path modern backends take.
10091011
* - Declared `false`, or ABSENT (backend predates #3298) → we can't rely on
1010-
* server atomicity, so we keep the legacy behaviour: try `/batch`, and on
1011-
* 404/405 (no endpoint) or 501 (runtime without transactions) degrade to
1012-
* the client-side, NON-atomic {@link emulateBatchTransaction} so a save is
1012+
* server atomicity, so we keep the legacy behaviour: on 404/405 (no
1013+
* endpoint) or 501 (runtime without transactions) degrade to the
1014+
* client-side, NON-atomic {@link emulateBatchTransaction} so a save is
10131015
* still possible. Removing that here would regress older backends from
10141016
* "saves, less safe" to "no save path" (#2679 compatibility constraint).
10151017
* The non-atomic fallback stays isolated to THIS adapter.
@@ -1023,66 +1025,37 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
10231025

10241026
// When the backend declares atomic batch support we never degrade: a
10251027
// failure is a real error, not a cue to fall back. Otherwise (declared
1026-
// false, or capability absent on a pre-#3298 backend) the runtime-probe +
1027-
// emulation fallback below stays active.
1028+
// false, or capability absent on a pre-#3298 backend) the emulation
1029+
// fallback below stays active.
10281030
const guaranteed = this.atomicBatchCapability === true;
10291031

1030-
// Already probed-and-degraded on a non-declaring backend — skip the probe.
1032+
// Already degraded on a non-declaring backend — skip the SDK call.
10311033
// (Unreachable once `guaranteed`: that path never sets `batchUnsupported`.)
10321034
if (!guaranteed && this.batchUnsupported) {
10331035
return emulateBatchTransaction(this, operations);
10341036
}
10351037

1036-
// Prefer the typed SDK method when the installed client exposes it
1037-
// (feature-detect, same pattern as updateMany/import). Older clients that
1038-
// predate it fall through to the raw-fetch mainline below.
1039-
const sdkBatch = (this.client.data as any).batchTransaction;
1040-
if (typeof sdkBatch === 'function') {
1041-
try {
1042-
const payload: { results: any[] } = await sdkBatch.call(this.client.data, operations);
1043-
this.emitBatchMutations(operations, payload?.results);
1044-
return payload;
1045-
} catch (err) {
1046-
const status = this.errorStatusOf(err);
1047-
if (!guaranteed && this.batchStatusUnsupported(status)) {
1048-
return this.fallbackToEmulation(operations, status);
1049-
}
1050-
throw err;
1051-
}
1052-
}
1053-
1054-
const url = `${this.baseUrl}/api/v1/batch`;
1055-
const response = await this.fetchImpl(url, {
1056-
method: 'POST',
1057-
headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
1058-
// Send the session cookie too: in the browser console auth is cookie-based
1059-
// (no bearer `token`), so without `credentials: 'include'` this raw fetch
1060-
// is unauthenticated — every master-detail batch save would 401. Bearer
1061-
// (server-to-server) and cookie (console) auth now both work.
1062-
credentials: 'include',
1063-
body: JSON.stringify({ operations }),
1064-
});
1065-
if (!response.ok) {
1066-
// On a non-declaring backend, endpoint missing (404/405) or runtime can't
1067-
// do transactions (the framework rest-server answers 501 "Transactional
1068-
// batch not supported by this runtime") → degrade to the non-atomic client
1069-
// emulation. When the backend DECLARED support (`guaranteed`), even these
1070-
// are hard errors — a server that advertised the capability must honour it.
1071-
// Every other status (400 validation, 401/403 auth, 409 conflict, 500
1072-
// fault) is a real error the caller must see — never silently retried.
1073-
if (!guaranteed && this.batchStatusUnsupported(response.status)) {
1074-
return this.fallbackToEmulation(operations, response.status);
1038+
try {
1039+
// Typed SDK method — guaranteed present by the `@objectstack/client@^16`
1040+
// dependency floor (framework #3271). No hand-rolled POST /api/v1/batch.
1041+
const payload = await this.client.data.batchTransaction(operations);
1042+
this.emitBatchMutations(operations, payload?.results);
1043+
return payload;
1044+
} catch (err) {
1045+
// On a non-declaring backend, endpoint missing (404/405) or a runtime that
1046+
// can't do transactions (the framework rest-server answers 501
1047+
// "Transactional batch not supported by this runtime") → degrade to the
1048+
// non-atomic client emulation so the save still goes through. When the
1049+
// backend DECLARED support (`guaranteed`), even these are hard errors — a
1050+
// server that advertised the capability must honour it. Every other status
1051+
// (400 validation, 401/403 auth, 409 conflict, 500 fault) is a real error
1052+
// the caller must see — never silently retried.
1053+
const status = this.errorStatusOf(err);
1054+
if (!guaranteed && this.batchStatusUnsupported(status)) {
1055+
return this.fallbackToEmulation(operations, status);
10751056
}
1076-
const error = await response.json().catch(() => ({ message: response.statusText }));
1077-
throw new ObjectStackError(
1078-
error.error || error.message || `Batch failed with status ${response.status}`,
1079-
'BATCH_ERROR',
1080-
response.status,
1081-
);
1057+
throw err;
10821058
}
1083-
const payload: { results: any[] } = await response.json();
1084-
this.emitBatchMutations(operations, payload?.results);
1085-
return payload;
10861059
}
10871060

10881061
/** True for statuses that mean "this backend can't do a transactional batch". */

packages/data-objectstack/src/onMutation.test.ts

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
246249
describe('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

Comments
 (0)