Skip to content

Commit 8c1e415

Browse files
os-zhuangclaude
andauthored
feat(data-objectstack): gate non-atomic batch fallback on discovery transactionalBatch capability (#2693) (#2755)
ObjectStackAdapter.batchTransaction now negotiates atomic cross-object batch declaratively via the discovery capability bit (framework #3298) instead of only probing at runtime. - connect() reads capabilities.transactionalBatch from GET /api/v1/discovery and records it as a tri-state (readTransactionalBatchCapability accepts both the hierarchical `{ enabled }` wire shape and the flat boolean the client SDK normalizes to). - When the backend DECLARES support (true), batchTransaction trusts server atomicity: it calls /batch and surfaces any failure — including 404/405/501 — as a real error. No runtime probe, no non-atomic client-side compensation. - When the capability is false or ABSENT (backend predates #3298), the legacy runtime-probe + emulation fallback stays active so a save is still possible; hard-removing it would regress older backends from "saves, less safe" to "no save path" (#2679 compatibility constraint). core's generic emulateBatchTransaction / runBatchTransaction are untouched and remain the fallback for adapters with no server-side transaction. Docs: adapter README + data-source guide document the capability table and the minimum-backend note (atomicity guaranteed only against backends advertising the capability — framework #3298 / #1604). Adds tests for each capability state and a unit test for the shape reader. Picks up #2679 acceptance item 4; unblocked by framework#3298 (merged). Claude-Session: https://claude.ai/code/session_01U1UuBuaurza8a2XWSUaDQr Co-authored-by: Claude <noreply@anthropic.com>
1 parent 21faf18 commit 8c1e415

5 files changed

Lines changed: 372 additions & 20 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@object-ui/data-objectstack": minor
3+
---
4+
5+
feat(data-objectstack): gate the non-atomic batch fallback on the discovery `transactionalBatch` capability (#2693)
6+
7+
`ObjectStackAdapter.batchTransaction` now negotiates atomic cross-object batch
8+
**declaratively** instead of only probing at runtime. At `connect()` the adapter
9+
reads `capabilities.transactionalBatch` from `GET /api/v1/discovery`
10+
(framework #3298`declared === enforced`; the server advertises `true` only
11+
when the `/batch` route is mounted *and* the runtime engine can honour a
12+
transaction):
13+
14+
- **Declared `true`** — the adapter TRUSTS server atomicity: it calls `/batch`
15+
and surfaces any failure (including `404`/`405`/`501`) as a real error. No
16+
runtime probe, no non-atomic client-side compensation.
17+
- **Declared `false`, or absent** (backend predates #3298) — the legacy path is
18+
unchanged: probe `/batch` and, on `404`/`405`/`501`, fall back to the
19+
non-atomic `emulateBatchTransaction`. Keeping this avoids regressing older
20+
backends from "saves, less safe" to "no save path" (#2679 compat constraint).
21+
22+
Both the hierarchical wire shape (`{ transactionalBatch: { enabled: true } }`)
23+
and the flat form the client SDK normalizes to (`{ transactionalBatch: true }`)
24+
are accepted. `@object-ui/core`'s generic `emulateBatchTransaction` /
25+
`runBatchTransaction` are untouched and remain the fallback for adapters with no
26+
server-side transaction (`ValueDataSource`, `MockDataSource`, …).
27+
28+
Docs: the adapter README and the data-source guide now document the capability
29+
table and the minimum-backend note — atomic cross-object saves are guaranteed
30+
only against backends advertising the capability (framework #3298 / #1604).
31+
32+
Picks up #2679 acceptance item 4; unblocked by framework#3298 (merged).

content/docs/guide/data-source.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,16 @@ executes the operations sequentially (resolving `$ref`s) with best-effort
186186
compensation on failure. UI components never branch on atomicity — they call
187187
`runBatchTransaction(dataSource, operations)` (also from `@object-ui/core`),
188188
which uses the adapter's method when present and emulates otherwise.
189+
190+
The `@object-ui/data-objectstack` adapter decides whether it can trust server
191+
atomicity **declaratively**, at connect time: it reads the
192+
`capabilities.transactionalBatch` flag from `GET /api/v1/discovery`
193+
(framework #3298). When the backend advertises `true`, the adapter treats any
194+
`/batch` failure as a real error — no non-atomic client-side compensation. When
195+
the flag is `false` or absent (a backend predating #3298), it keeps the legacy
196+
behaviour: probe `/batch` and fall back to the non-atomic emulation on
197+
`404`/`405`/`501`. Atomic cross-object saves are therefore guaranteed only
198+
against backends that advertise the capability; older ones still save, but
199+
best-effort. See the
200+
[adapter README](../../../packages/data-objectstack/README.md#cross-object-atomic-batch-batchtransaction)
201+
for the full capability table and minimum-backend note.

packages/data-objectstack/README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,68 @@ await dataSource.bulk('users', 'delete', [
272272
- Provides partial success reporting for resilient error handling
273273
- Atomic operations where supported by the backend
274274

275+
## Cross-Object Atomic Batch (`batchTransaction`)
276+
277+
`bulk()` above operates on **one** object. To persist a set of **cross-object**
278+
writes as a single all-or-nothing unit — the master-detail case, where a parent
279+
and its children must commit or roll back together — use `batchTransaction`:
280+
281+
```typescript
282+
// Create a parent and a child that references it, atomically.
283+
// `{ $ref: 0 }` resolves to the id minted by operation 0 (the parent).
284+
await dataSource.batchTransaction([
285+
{ object: 'invoice', action: 'create', data: { no: 'INV-1' } },
286+
{ object: 'invoice_line', action: 'create', data: { invoice: { $ref: 0 }, amount: 10 } },
287+
]);
288+
```
289+
290+
On a supporting backend this is one `POST /api/v1/batch` that commits or rolls
291+
back the whole set in a single server transaction — no orphaned parent if a
292+
child write fails.
293+
294+
### Declarative capability negotiation (`transactionalBatch`)
295+
296+
Whether the adapter can rely on server atomicity is decided **at connect time**,
297+
not by firing a batch and reading the failure. `connect()` reads the
298+
`capabilities.transactionalBatch` flag from `GET /api/v1/discovery`
299+
([framework #3298](https://github.com/objectstack-ai/framework/issues/3298)),
300+
which the server sets to `true` only when the `/batch` route is mounted **and**
301+
the runtime engine can honour a transaction (`declared === enforced`):
302+
303+
| Discovery `capabilities.transactionalBatch` | `batchTransaction` behaviour |
304+
| --- | --- |
305+
| `true` | **Trusts server atomicity.** Calls `/batch`; any failure — including `404`/`405`/`501` — surfaces as a real error. No non-atomic client-side fallback. |
306+
| `false` | Backend can't do an atomic batch (route absent, or a runtime without transactions) → falls back to the non-atomic client-side emulation below. |
307+
| *absent* | Backend predates #3298 and advertises nothing → the legacy runtime probe stays: try `/batch`, and on `404`/`405`/`501` fall back to emulation. |
308+
309+
The hierarchical wire shape (`{ transactionalBatch: { enabled: true } }`) and the
310+
flat form the client SDK normalizes to (`{ transactionalBatch: true }`) are both
311+
accepted.
312+
313+
### Non-atomic fallback
314+
315+
When the capability is `false` or absent, the adapter degrades to a client-side
316+
emulation (`@object-ui/core`'s `emulateBatchTransaction`): the operations run in
317+
order and, on failure, it best-effort deletes the records it created (children
318+
before parent) before rethrowing. This is **not** a transaction — a create's
319+
side effects (hooks, rollups, webhooks) are not undone by a later delete, and a
320+
mid-batch network drop leaves no chance to compensate. It exists only so a save
321+
is still possible against a backend that lacks server atomicity; removing it
322+
would turn "saves, less safe" into "no save path" on older backends
323+
([objectui #2679](https://github.com/objectstack-ai/objectui/issues/2679)).
324+
325+
### Minimum supported backend
326+
327+
Atomic cross-object saves are **guaranteed only against ObjectStack backends on
328+
the 16.x line that advertise `capabilities.transactionalBatch: true`** — the
329+
endpoint landed in [framework #1604](https://github.com/objectstack-ai/framework/issues/1604)
330+
and its discovery capability in
331+
[framework #3298](https://github.com/objectstack-ai/framework/issues/3298).
332+
ObjectUI does not hard-require it: against an older backend a master-detail save
333+
still succeeds, but non-atomically via the fallback above. Treat the advertised
334+
capability as the floor for the atomicity guarantee, not as a connection
335+
prerequisite.
336+
275337
## User-Scoped State Adapter
276338

277339
In addition to the main `DataSource` adapter, this package ships
@@ -348,6 +410,7 @@ new ObjectStackAdapter(config: {
348410
- `update(resource, id, data)` - Update an existing record
349411
- `delete(resource, id)` - Delete a record
350412
- `bulk(resource, operation, data)` - Batch operations (create/update/delete)
413+
- `batchTransaction(operations)` - Cross-object atomic batch (master-detail); atomic when the backend advertises `transactionalBatch`, else non-atomic client-side fallback
351414
- `getObjectSchema(objectName)` - Get schema metadata (cached)
352415
- `getCacheStats()` - Get cache statistics
353416
- `invalidateCache(key?)` - Invalidate cache entries

packages/data-objectstack/src/index.ts

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,44 @@ export function clearSharedDiscoveryCache(): void {
178178
discoveryCache.clear();
179179
}
180180

181+
/**
182+
* Read the cross-object atomic-batch capability from a `discovery` document
183+
* (framework #3298 / objectui #2693). The server advertises it hierarchically
184+
* under `capabilities.transactionalBatch.enabled`; the published
185+
* `@objectstack/client` also accepts the flat `capabilities.transactionalBatch:
186+
* boolean` form and normalizes the two — mirror that here so the adapter reads
187+
* the same bit regardless of which shape reaches it.
188+
*
189+
* Returns:
190+
* - `true` — the backend GUARANTEES an atomic `/batch` (declared === enforced,
191+
* i.e. the route is mounted AND the runtime can honour a transaction): the
192+
* client may drop its non-atomic fallback and treat any batch failure as a
193+
* real error.
194+
* - `false` — the backend explicitly does NOT (route absent, or a runtime that
195+
* can't open a transaction).
196+
* - `undefined` — the capability is absent, i.e. the backend predates #3298;
197+
* the caller must keep the legacy runtime-probe fallback (we can't tell
198+
* whether `/batch` exists without trying it).
199+
*/
200+
export function readTransactionalBatchCapability(
201+
discovery: unknown,
202+
): boolean | undefined {
203+
const caps = (discovery as { capabilities?: unknown } | null | undefined)?.capabilities;
204+
if (!caps || typeof caps !== 'object') return undefined;
205+
const value = (caps as Record<string, unknown>).transactionalBatch;
206+
// Flat form: `{ transactionalBatch: true }`.
207+
if (typeof value === 'boolean') return value;
208+
// Hierarchical form: `{ transactionalBatch: { enabled: true } }`.
209+
if (
210+
value &&
211+
typeof value === 'object' &&
212+
typeof (value as { enabled?: unknown }).enabled === 'boolean'
213+
) {
214+
return (value as { enabled: boolean }).enabled;
215+
}
216+
return undefined;
217+
}
218+
181219
/**
182220
* Detect "missing resource" errors regardless of where they originate.
183221
*
@@ -415,6 +453,15 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
415453
// emulation — the non-atomic fallback lives HERE, isolated to the one adapter
416454
// that has to cope with a backend lacking server atomicity (#2679).
417455
private batchUnsupported = false;
456+
// The server's declared cross-object atomic-batch capability, read from
457+
// discovery at connect() (framework #3298 / objectui #2693). `true` → the
458+
// backend GUARANTEES an atomic `/batch`, so batchTransaction trusts it and
459+
// never degrades to the non-atomic emulation (any failure surfaces as a real
460+
// error). `false` or `undefined` (capability absent → backend predates #3298)
461+
// → keep the legacy runtime-probe + emulation fallback so a save is still
462+
// possible; dropping it there would turn "saves, less safe" into "no save
463+
// path" on older backends (#2679 compatibility constraint).
464+
private atomicBatchCapability: boolean | undefined;
418465
// Subscribers registered via onMutation(). Emitted after each successful
419466
// create/update/delete so data-bound views (ListView, ObjectView, kanban,
420467
// calendar) auto-refresh — the interface ListView relies on to reflect
@@ -490,6 +537,11 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
490537
// helpers continue to work without a redundant fetch.
491538
(this.client as unknown as { discoveryInfo?: unknown }).discoveryInfo = data;
492539

540+
// Record the declared cross-object atomic-batch capability (#3298) so
541+
// batchTransaction can decide declaratively at call time whether it may
542+
// trust server atomicity instead of runtime-probing 404/405/501.
543+
this.atomicBatchCapability = readTransactionalBatchCapability(data);
544+
493545
this.connected = true;
494546
this.reconnectAttempts = 0;
495547
this.setConnectionState('connected');
@@ -945,19 +997,39 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
945997
* batch (master-detail).
946998
*
947999
* Transport preference: the published `@objectstack/client` SDK method when
948-
* available (framework #3271), else a raw `POST /api/v1/batch`. Either way,
949-
* if the backend has no such endpoint (404/405) or a runtime that can't do
950-
* transactions (501), we degrade to a client-side, NON-atomic emulation via
951-
* {@link emulateBatchTransaction} so a save is still possible — this is the
952-
* isolated home of the non-atomic fallback. Hard removal of the emulation is
953-
* gated on the server advertising a batch capability via discovery (the
954-
* current discovery payload does not), so the fallback stays for now.
1000+
* available (framework #3271), else a raw `POST /api/v1/batch`.
1001+
*
1002+
* Fallback decision — declarative capability negotiation (framework #3298 /
1003+
* objectui #2693). At connect() we read `capabilities.transactionalBatch`
1004+
* from discovery:
1005+
* - Declared `true` → the backend GUARANTEES atomicity (declared ===
1006+
* 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+
* - 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
1013+
* still possible. Removing that here would regress older backends from
1014+
* "saves, less safe" to "no save path" (#2679 compatibility constraint).
1015+
* The non-atomic fallback stays isolated to THIS adapter.
9551016
*/
9561017
async batchTransaction(
9571018
operations: BatchTransactionOperation[],
9581019
): Promise<{ results: any[] }> {
959-
// Already known-unsupported on this backend — skip the probe entirely.
960-
if (this.batchUnsupported) {
1020+
// Ensure discovery (and thus the #3298 capability) is loaded so the
1021+
// decision below is declarative, not "fire a batch and read the status".
1022+
await this.connect();
1023+
1024+
// When the backend declares atomic batch support we never degrade: a
1025+
// 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+
const guaranteed = this.atomicBatchCapability === true;
1029+
1030+
// Already probed-and-degraded on a non-declaring backend — skip the probe.
1031+
// (Unreachable once `guaranteed`: that path never sets `batchUnsupported`.)
1032+
if (!guaranteed && this.batchUnsupported) {
9611033
return emulateBatchTransaction(this, operations);
9621034
}
9631035

@@ -966,14 +1038,14 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
9661038
// predate it fall through to the raw-fetch mainline below.
9671039
const sdkBatch = (this.client.data as any).batchTransaction;
9681040
if (typeof sdkBatch === 'function') {
969-
await this.connect();
9701041
try {
9711042
const payload: { results: any[] } = await sdkBatch.call(this.client.data, operations);
9721043
this.emitBatchMutations(operations, payload?.results);
9731044
return payload;
9741045
} catch (err) {
975-
if (this.batchStatusUnsupported(this.errorStatusOf(err))) {
976-
return this.fallbackToEmulation(operations, this.errorStatusOf(err));
1046+
const status = this.errorStatusOf(err);
1047+
if (!guaranteed && this.batchStatusUnsupported(status)) {
1048+
return this.fallbackToEmulation(operations, status);
9771049
}
9781050
throw err;
9791051
}
@@ -991,12 +1063,14 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
9911063
body: JSON.stringify({ operations }),
9921064
});
9931065
if (!response.ok) {
994-
// Endpoint missing (404/405) or runtime can't do transactions (the
995-
// framework rest-server answers 501 "Transactional batch not supported
996-
// by this runtime") → degrade to the non-atomic client emulation. Every
997-
// other status (400 validation, 401/403 auth, 409 conflict, 500 fault)
998-
// is a real error the caller must see — never silently retried.
999-
if (this.batchStatusUnsupported(response.status)) {
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)) {
10001074
return this.fallbackToEmulation(operations, response.status);
10011075
}
10021076
const error = await response.json().catch(() => ({ message: response.statusText }));

0 commit comments

Comments
 (0)