Skip to content

Commit bfa3c3f

Browse files
os-zhuangclaude
andauthored
feat(discovery): broadcast transactionalBatch capability bit for declarative atomic-batch negotiation (#3298) (#3345)
Add a required `transactionalBatch` bit to discovery so clients negotiate the atomic cross-object batch declaratively instead of runtime-probing 404/405/501. Filled honestly by every producer (metadata-protocol via engine.transaction, rest-server ANDed with api.enableBatch, hono standalone false, client exposes it). Closes #3298. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 631deba commit bfa3c3f

13 files changed

Lines changed: 254 additions & 1 deletion

File tree

.changeset/console-3b2e4d98d904.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/console": minor
3+
---
4+
5+
Console (objectui) refreshed to `3b2e4d98d904`. Frontend changes in this range:
6+
7+
- fix(list): route remaining system-field groupings through shared classifier (#2706)
8+
- feat(console): user-import wizard defaults to the `auto` password policy (tracks framework#3236) (#2701)
9+
- feat(flow-designer): schema-driven keyValue + numberList mapping (#3304) (#2708)
10+
11+
objectui range: `0318118e02fd...3b2e4d98d904`
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": minor
4+
"@objectstack/rest": minor
5+
"@objectstack/plugin-hono-server": minor
6+
"@objectstack/client": minor
7+
---
8+
9+
**Broadcast a `transactionalBatch` capability bit in discovery so clients negotiate the atomic cross-object batch declaratively, instead of runtime-probing 404/405/501 (#3298).**
10+
11+
The atomic cross-object batch endpoint (`POST {basePath}/batch`, #1604 / ADR-0034 item 4) and its typed SDK surface (`client.data.batchTransaction`, #3271) already shipped, but discovery never told a client whether a backend actually supports it. Consumers (notably ObjectUI's `ObjectStackAdapter`) had to *probe*: fire a `/batch`, read `404`/`405` (no route) or `501` (no runtime transaction), and only then fall back to non-atomic client-side simulation. That is "find out by calling", not capability negotiation — it cannot be decided at connect time and cannot serve as the "minimum backend supports `/batch`" gate that blocks hard-deleting the non-atomic fallback downstream.
12+
13+
`WellKnownCapabilitiesSchema` gains a required `transactionalBatch: boolean`, and **every** discovery producer fills it honestly (`declared === enforced`), so it never becomes a declared-but-unpopulated bit:
14+
15+
- **`@objectstack/metadata-protocol`** (`getDiscovery`) — reports whether the runtime engine can honour a transaction (`typeof engine.transaction === 'function'`). The `/batch` handler runs its ops inside `engine.transaction()`, which degrades to a non-atomic passthrough (or 501) without one.
16+
- **`@objectstack/rest`** (`/discovery`) — ANDs the engine signal with whether it actually mounts the route (`api.enableBatch`), so a server with batch disabled reports `false` even on a transaction-capable engine (never advertise an endpoint that would 404).
17+
- **`@objectstack/plugin-hono-server`** (standalone discovery) — reports `false`: this minimal surface registers CRUD only and does not mount `/batch` (that ships with `@objectstack/rest`). Under-reporting is the safe direction — a client keeps its correct-but-slower fallback rather than losing atomicity.
18+
- **`@objectstack/client`** — already normalizes hierarchical `capabilities` to flat booleans, so `client.capabilities.transactionalBatch` is exposed (and now typed) for declarative consumers.
19+
20+
The bit follows the existing capability semantics: `true` ⟺ the `/batch` route is mounted **and** the runtime can honour a transaction — the exact condition under which the endpoint returns `200` rather than `404`/`405`/`501`. Additive and behavior-preserving; only the discovery payload gains a field.

.objectui-sha

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0318118e02fd0ef377492f3b09b687a84cccc908
1+
3b2e4d98d904d695a8372c394d46b81673011270

content/docs/references/api/discovery.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ Well-known capability flags for frontend intelligent adaptation
160160
| **search** | `boolean` || Whether the backend supports full-text search |
161161
| **export** | `boolean` || Whether the backend supports async export |
162162
| **chunkedUpload** | `boolean` || Whether the backend supports chunked (multipart) uploads |
163+
| **transactionalBatch** | `boolean` || Whether the backend exposes the atomic cross-object batch endpoint (POST `{basePath}`/batch, #1604/ADR-0034): all ops commit or roll back together in one transaction. Lets clients skip non-atomic client-side simulation instead of runtime-probing 404/405/501. True ⟺ the /batch route is mounted AND the runtime can honour a transaction. |
163164

164165

165166
---

packages/client/src/client.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,32 @@ describe('ObjectStackClient.automation', () => {
890890
expect(client.capabilities!.automation).toBe(false);
891891
expect(client.capabilities!.search).toBe(true);
892892
});
893+
894+
it('should expose the transactionalBatch capability (hierarchical → flat) for declarative negotiation (#3298)', async () => {
895+
// A real backend serves the hierarchical shape `{ key: { enabled } }`.
896+
// The client normalizes it to a flat boolean so callers can decide at
897+
// connect time whether to send an atomic batch or fall back to
898+
// non-atomic simulation — instead of runtime-probing POST /batch.
899+
const fetchMock = vi.fn().mockResolvedValue({
900+
ok: true,
901+
json: async () => ({
902+
version: 'v1',
903+
apiName: 'ObjectStack API',
904+
capabilities: {
905+
comments: { enabled: false },
906+
transactionalBatch: { enabled: true },
907+
},
908+
}),
909+
});
910+
911+
const client = new ObjectStackClient({
912+
baseUrl: 'http://localhost:3000',
913+
fetch: fetchMock,
914+
});
915+
916+
await client.connect();
917+
expect(client.capabilities!.transactionalBatch).toBe(true);
918+
});
893919
});
894920

895921
// ==========================================

packages/metadata-protocol/src/protocol.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,6 +1422,15 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
14221422
search: registeredServices.has('search'),
14231423
export: registeredServices.has('automation') || registeredServices.has('queue'),
14241424
chunkedUpload: registeredServices.has('file-storage'),
1425+
// Atomic cross-object batch (#3298 / #1604 / ADR-0034 item 4): the
1426+
// REST /batch endpoint runs its ops inside `engine.transaction()`,
1427+
// which only opens a real (all-or-nothing) transaction when the
1428+
// engine exposes one — otherwise it degrades to a non-atomic
1429+
// passthrough. Advertise the capability iff the runtime engine can
1430+
// honour a transaction, so `declared === enforced` (Prime Directive
1431+
// #10). The rest-server producer ANDs this with `api.enableBatch` so
1432+
// a server that doesn't mount the route reports `false` at its layer.
1433+
transactionalBatch: typeof (this.engine as { transaction?: unknown })?.transaction === 'function',
14251434
};
14261435

14271436
// Convert flat booleans → hierarchical capability objects

packages/objectql/src/protocol-discovery.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,31 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
238238
expect(discovery.capabilities!.comments).toEqual({ enabled: false });
239239
});
240240

241+
// ── Atomic cross-object batch capability (#3298 / #1604 / ADR-0034) ─────────
242+
// The bit tracks whether the runtime engine can honour a transaction (the
243+
// REST /batch endpoint runs inside `engine.transaction()`), NOT a registered
244+
// service — so it is independent of the services map.
245+
it('advertises transactionalBatch when the engine exposes transaction() — the real ObjectQL engine (#3298)', async () => {
246+
protocol = new ObjectStackProtocolImplementation(engine);
247+
const discovery = await protocol.getDiscovery();
248+
249+
// ObjectQL always implements transaction() (ADR-0034), so a real engine
250+
// reports the capability regardless of which services are registered.
251+
expect(typeof (engine as unknown as { transaction?: unknown }).transaction).toBe('function');
252+
expect(discovery.capabilities!.transactionalBatch).toEqual({ enabled: true });
253+
});
254+
255+
it('does NOT advertise transactionalBatch when the engine has no transaction() (declared === enforced, #3298)', async () => {
256+
// A minimal engine without transaction(): the /batch endpoint would 501, so
257+
// discovery must report false — never let a client drop its non-atomic
258+
// fallback against a runtime that cannot honour an atomic batch.
259+
const noTxEngine = { registry: { getObject: () => undefined } } as any;
260+
protocol = new ObjectStackProtocolImplementation(noTxEngine);
261+
const discovery = await protocol.getDiscovery();
262+
263+
expect(discovery.capabilities!.transactionalBatch).toEqual({ enabled: false });
264+
});
265+
241266
it('should enable comments capability when the sys_comment object is registered (#3180)', async () => {
242267
// comments/chatter are served by the sys_comment object via the data API
243268
// (ADR-0052 §5), not a dedicated service — so the capability tracks that

packages/plugins/plugin-hono-server/src/hono-plugin.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,34 @@ describe('HonoServerPlugin', () => {
103103
await expect(plugin.start(context as PluginContext)).resolves.not.toThrow();
104104
});
105105

106+
it('standalone discovery advertises transactionalBatch=false — the /batch route is not mounted here (#3298)', async () => {
107+
// This standalone surface registers CRUD + auth only; the cross-object
108+
// /batch endpoint ships with @objectstack/rest. declared === enforced:
109+
// discovery must report the capability as disabled so a client never
110+
// drops its non-atomic fallback against this backend.
111+
const plugin = new HonoServerPlugin({ registerStandardEndpoints: true });
112+
await plugin.init(context as PluginContext);
113+
114+
// Capture the routes the producer registers on the raw app.
115+
const routes: Record<string, any> = {};
116+
const rawApp = {
117+
get: vi.fn((path: string, h: any) => { routes[`GET ${path}`] = h; }),
118+
post: vi.fn((path: string, h: any) => { routes[`POST ${path}`] = h; }),
119+
use: vi.fn(),
120+
};
121+
(plugin as any).server.getRawApp = () => rawApp;
122+
(plugin as any).registerDiscoveryAndCrudEndpoints(context);
123+
124+
const handler = routes['GET /api/v1/discovery'];
125+
expect(handler).toBeDefined();
126+
const c = { json: vi.fn((x: any) => x) };
127+
const res = handler(c);
128+
expect(res.data.capabilities.transactionalBatch).toEqual({ enabled: false });
129+
// Sanity: the standalone surface really has no /batch route (so `false`
130+
// is honest, not merely conservative).
131+
expect(routes['POST /api/v1/batch']).toBeUndefined();
132+
});
133+
106134
it('should configure static files and SPA fallback when enabled', async () => {
107135
const plugin = new HonoServerPlugin({
108136
staticRoot: './public',

packages/plugins/plugin-hono-server/src/hono-plugin.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,16 @@ export class HonoServerPlugin implements Plugin {
690690
storage: `${prefix}/storage`,
691691
ui: `${prefix}/ui`,
692692
},
693+
capabilities: {
694+
// This standalone Hono surface registers CRUD + auth only (see
695+
// below) — it does NOT mount the cross-object `/batch` route,
696+
// which ships with `@objectstack/rest`. `declared === enforced`
697+
// (#3298): report `transactionalBatch: false` so a client never
698+
// drops its non-atomic fallback against a backend that lacks the
699+
// endpoint. When `@objectstack/rest` is mounted it serves its own
700+
// discovery, which reports the real value from the runtime engine.
701+
transactionalBatch: { enabled: false },
702+
},
693703
};
694704

695705
// Discovery endpoints

packages/rest/src/rest-server.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,6 +1860,27 @@ export class RestServer {
18601860
}
18611861
}
18621862

1863+
// Cross-object atomic batch capability (#3298). `declared ===
1864+
// enforced`: advertise it only when THIS server actually mounts
1865+
// the `/batch` route (`api.enableBatch`, gated in
1866+
// registerBatchEndpoints) AND the runtime engine can honour a
1867+
// transaction (the protocol derived that from `engine.transaction`).
1868+
// AND-ing the two keeps us from advertising an endpoint that would
1869+
// 404 (batch disabled) or 501 (engine without `transaction()`), so
1870+
// a client can safely drop its non-atomic fallback on `true`.
1871+
const caps = ((discovery as any).capabilities ??= {}) as Record<
1872+
string,
1873+
{ enabled: boolean; description?: string }
1874+
>;
1875+
const runtimeSupportsTx = !!caps.transactionalBatch?.enabled;
1876+
caps.transactionalBatch = {
1877+
enabled: runtimeSupportsTx && this.config.api.enableBatch !== false,
1878+
description:
1879+
'Atomic cross-object batch endpoint (POST {basePath}/batch): all-or-nothing '
1880+
+ 'create/update/delete across objects in one transaction, with intra-batch '
1881+
+ '{ $ref: <opIndex> } parent references (#1604 / ADR-0034).',
1882+
};
1883+
18631884
// Attach scoping metadata so clients can detect dual-mode routing.
18641885
(discovery as any).scoping = {
18651886
enabled: this.config.api.enableProjectScoping,

0 commit comments

Comments
 (0)