Skip to content

Commit 2b4a475

Browse files
os-zhuangclaude
andcommitted
feat(discovery): broadcast transactionalBatch capability so clients negotiate atomic batch declaratively (#3298)
The atomic cross-object batch endpoint (POST {basePath}/batch, #1604 / ADR-0034 item 4) and its typed SDK surface (client.data.batchTransaction, #3271) shipped, but discovery never told a client whether a backend supports it. Consumers had to probe — fire a /batch, read 404/405 (no route) or 501 (no runtime transaction), then fall back to non-atomic client-side simulation. That is "find out by calling", not capability negotiation, and it blocks hard-deleting ObjectUI's non-atomic fallback (objectui#2679). Add a required `transactionalBatch: boolean` to WellKnownCapabilitiesSchema and fill it honestly in every discovery producer (declared === enforced), so it is never a declared-but-unpopulated bit: - metadata-protocol (getDiscovery): true iff the runtime engine can honour a transaction (typeof engine.transaction === 'function'). engine.transaction() degrades to a non-atomic passthrough / 501 without one. - rest-server (/discovery): ANDs that with api.enableBatch — the gate that mounts the /batch route — so batch-disabled servers report false even on a tx-capable engine (never advertise a route that 404s). - plugin-hono-server (standalone discovery): false — this minimal surface mounts CRUD only, not /batch. Under-reporting is the safe direction (client keeps its correct-but-slower fallback). - client: already normalizes hierarchical capabilities → flat booleans, so client.capabilities.transactionalBatch is exposed and now typed. Tests assert the bit across all producers (spec schema required-field, protocol engine-tx true/false, rest enableBatch AND-ing + always-populated, hono false, client hierarchical→flat). Regenerated content/docs/references/api/discovery.mdx. Additive and behavior-preserving; only the discovery payload gains a field. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2758fea commit 2b4a475

11 files changed

Lines changed: 242 additions & 0 deletions

File tree

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.

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,

packages/rest/src/rest.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2381,6 +2381,75 @@ describe('discovery — routes.mcp (ADR-0036, #152)', () => {
23812381
});
23822382
});
23832383

2384+
// ──────────────────────────────────────────────────────────────────────────
2385+
// discovery — capabilities.transactionalBatch (#3298 / #1604 / ADR-0034)
2386+
//
2387+
// The atomic cross-object batch bit must be broadcast so clients negotiate
2388+
// declaratively instead of runtime-probing POST /batch for 404/405/501. The
2389+
// rest-server producer ANDs the runtime-engine signal (from the protocol,
2390+
// derived from `engine.transaction`) with whether IT mounts the route
2391+
// (`api.enableBatch`) — declared === enforced.
2392+
// ──────────────────────────────────────────────────────────────────────────
2393+
describe('discovery — capabilities.transactionalBatch (#3298)', () => {
2394+
/**
2395+
* Build the /discovery handler for a server whose protocol advertises the
2396+
* given runtime batch support, under the given api config.
2397+
*/
2398+
function discoveryHandler(opts: { runtimeTx?: boolean; api?: any } = {}) {
2399+
const server = createMockServer();
2400+
const protocol = createMockProtocol();
2401+
const capabilities =
2402+
opts.runtimeTx === undefined
2403+
? undefined
2404+
: { transactionalBatch: { enabled: opts.runtimeTx } };
2405+
(protocol.getDiscovery as any) = vi.fn().mockResolvedValue({
2406+
routes: { data: '', metadata: '' },
2407+
...(capabilities ? { capabilities } : {}),
2408+
});
2409+
const rest = new RestServer(
2410+
server as any,
2411+
protocol as any,
2412+
{ api: opts.api ?? { requireAuth: false } } as any,
2413+
);
2414+
rest.registerRoutes();
2415+
const entry = rest.getRouteManager().get('GET', '/api/v1/discovery');
2416+
if (!entry) throw new Error('discovery route not registered');
2417+
return entry.handler as (req: any, res: any) => Promise<void>;
2418+
}
2419+
2420+
async function invoke(handler: (req: any, res: any) => Promise<void>) {
2421+
let body: any;
2422+
const res: any = { json: (b: any) => { body = b; }, status: () => res };
2423+
await handler({ params: {} }, res);
2424+
return body;
2425+
}
2426+
2427+
it('advertises transactionalBatch=true when the runtime supports it and /batch is mounted (default)', async () => {
2428+
const body = await invoke(discoveryHandler({ runtimeTx: true }));
2429+
expect(body.capabilities.transactionalBatch.enabled).toBe(true);
2430+
// The description is carried so the capability is self-documenting.
2431+
expect(body.capabilities.transactionalBatch.description).toContain('POST {basePath}/batch');
2432+
});
2433+
2434+
it('reports transactionalBatch=false when api.enableBatch is off — the /batch route is not mounted', async () => {
2435+
const body = await invoke(
2436+
discoveryHandler({ runtimeTx: true, api: { requireAuth: false, enableBatch: false } }),
2437+
);
2438+
expect(body.capabilities.transactionalBatch.enabled).toBe(false);
2439+
});
2440+
2441+
it('reports transactionalBatch=false when the runtime engine cannot honour a transaction', async () => {
2442+
const body = await invoke(discoveryHandler({ runtimeTx: false }));
2443+
expect(body.capabilities.transactionalBatch.enabled).toBe(false);
2444+
});
2445+
2446+
it('always populates the bit even if the protocol omitted capabilities (no declared-but-unpopulated gap)', async () => {
2447+
const body = await invoke(discoveryHandler({ /* protocol returns no capabilities */ }));
2448+
expect(body.capabilities).toBeDefined();
2449+
expect(body.capabilities.transactionalBatch.enabled).toBe(false);
2450+
});
2451+
});
2452+
23842453
// ──────────────────────────────────────────────────────────────────────────
23852454
// Metadata translation — envelope unwrapping
23862455
//

packages/spec/src/api/discovery.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,7 @@ describe('WellKnownCapabilitiesSchema', () => {
709709
search: true,
710710
export: true,
711711
chunkedUpload: true,
712+
transactionalBatch: true,
712713
};
713714
expect(() => WellKnownCapabilitiesSchema.parse(caps)).not.toThrow();
714715
});
@@ -721,14 +722,26 @@ describe('WellKnownCapabilitiesSchema', () => {
721722
search: false,
722723
export: false,
723724
chunkedUpload: false,
725+
transactionalBatch: false,
724726
});
725727
expect(caps.comments).toBe(false);
726728
expect(caps.chunkedUpload).toBe(false);
729+
expect(caps.transactionalBatch).toBe(false);
727730
});
728731

729732
it('should reject missing required fields', () => {
730733
expect(() => WellKnownCapabilitiesSchema.parse({ comments: true })).toThrow();
731734
expect(() => WellKnownCapabilitiesSchema.parse({})).toThrow();
735+
// transactionalBatch is required — a payload missing only it must fail so a
736+
// producer can never silently omit the batch capability bit (#3298).
737+
expect(() => WellKnownCapabilitiesSchema.parse({
738+
comments: true,
739+
automation: true,
740+
cron: true,
741+
search: true,
742+
export: true,
743+
chunkedUpload: true,
744+
})).toThrow();
732745
});
733746

734747
it('should reject non-boolean values', () => {
@@ -739,6 +752,7 @@ describe('WellKnownCapabilitiesSchema', () => {
739752
search: true,
740753
export: true,
741754
chunkedUpload: true,
755+
transactionalBatch: true,
742756
})).toThrow();
743757
});
744758

@@ -750,6 +764,7 @@ describe('WellKnownCapabilitiesSchema', () => {
750764
expect(shape.search.description).toBeDefined();
751765
expect(shape.export.description).toBeDefined();
752766
expect(shape.chunkedUpload.description).toBeDefined();
767+
expect(shape.transactionalBatch.description).toBeDefined();
753768
});
754769
});
755770

0 commit comments

Comments
 (0)