From 2758fea754fc68ee218df84ceb1f0953a1a7e93a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:20:30 +0800 Subject: [PATCH 1/2] chore: bump objectui to 3b2e4d98d904 fix(list): route remaining system-field groupings through shared classifier (#2706) objectui@3b2e4d98d904d695a8372c394d46b81673011270 --- .changeset/console-3b2e4d98d904.md | 11 +++++++++++ .objectui-sha | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/console-3b2e4d98d904.md diff --git a/.changeset/console-3b2e4d98d904.md b/.changeset/console-3b2e4d98d904.md new file mode 100644 index 0000000000..eaaabf3961 --- /dev/null +++ b/.changeset/console-3b2e4d98d904.md @@ -0,0 +1,11 @@ +--- +"@objectstack/console": minor +--- + +Console (objectui) refreshed to `3b2e4d98d904`. Frontend changes in this range: + +- fix(list): route remaining system-field groupings through shared classifier (#2706) +- feat(console): user-import wizard defaults to the `auto` password policy (tracks framework#3236) (#2701) +- feat(flow-designer): schema-driven keyValue + numberList mapping (#3304) (#2708) + +objectui range: `0318118e02fd...3b2e4d98d904` diff --git a/.objectui-sha b/.objectui-sha index deb881863e..0ccbc49f39 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -0318118e02fd0ef377492f3b09b687a84cccc908 +3b2e4d98d904d695a8372c394d46b81673011270 From 2b4a475f890693eef44768fdb5e0922a60d30290 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:09:03 +0800 Subject: [PATCH 2/2] feat(discovery): broadcast transactionalBatch capability so clients negotiate atomic batch declaratively (#3298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...iscovery-transactional-batch-capability.md | 20 ++++++ content/docs/references/api/discovery.mdx | 1 + packages/client/src/client.test.ts | 26 +++++++ packages/metadata-protocol/src/protocol.ts | 9 +++ .../objectql/src/protocol-discovery.test.ts | 25 +++++++ .../src/hono-plugin.test.ts | 28 ++++++++ .../plugin-hono-server/src/hono-plugin.ts | 10 +++ packages/rest/src/rest-server.ts | 21 ++++++ packages/rest/src/rest.test.ts | 69 +++++++++++++++++++ packages/spec/src/api/discovery.test.ts | 15 ++++ packages/spec/src/api/discovery.zod.ts | 18 +++++ 11 files changed, 242 insertions(+) create mode 100644 .changeset/discovery-transactional-batch-capability.md diff --git a/.changeset/discovery-transactional-batch-capability.md b/.changeset/discovery-transactional-batch-capability.md new file mode 100644 index 0000000000..64afb764c1 --- /dev/null +++ b/.changeset/discovery-transactional-batch-capability.md @@ -0,0 +1,20 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +"@objectstack/rest": minor +"@objectstack/plugin-hono-server": minor +"@objectstack/client": minor +--- + +**Broadcast a `transactionalBatch` capability bit in discovery so clients negotiate the atomic cross-object batch declaratively, instead of runtime-probing 404/405/501 (#3298).** + +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. + +`WellKnownCapabilitiesSchema` gains a required `transactionalBatch: boolean`, and **every** discovery producer fills it honestly (`declared === enforced`), so it never becomes a declared-but-unpopulated bit: + +- **`@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. +- **`@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). +- **`@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. +- **`@objectstack/client`** — already normalizes hierarchical `capabilities` to flat booleans, so `client.capabilities.transactionalBatch` is exposed (and now typed) for declarative consumers. + +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. diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx index 10035b7513..92b753b1b2 100644 --- a/content/docs/references/api/discovery.mdx +++ b/content/docs/references/api/discovery.mdx @@ -160,6 +160,7 @@ Well-known capability flags for frontend intelligent adaptation | **search** | `boolean` | ✅ | Whether the backend supports full-text search | | **export** | `boolean` | ✅ | Whether the backend supports async export | | **chunkedUpload** | `boolean` | ✅ | Whether the backend supports chunked (multipart) uploads | +| **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. | --- diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 4661ad9fa4..8c9abb3b25 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -890,6 +890,32 @@ describe('ObjectStackClient.automation', () => { expect(client.capabilities!.automation).toBe(false); expect(client.capabilities!.search).toBe(true); }); + + it('should expose the transactionalBatch capability (hierarchical → flat) for declarative negotiation (#3298)', async () => { + // A real backend serves the hierarchical shape `{ key: { enabled } }`. + // The client normalizes it to a flat boolean so callers can decide at + // connect time whether to send an atomic batch or fall back to + // non-atomic simulation — instead of runtime-probing POST /batch. + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + version: 'v1', + apiName: 'ObjectStack API', + capabilities: { + comments: { enabled: false }, + transactionalBatch: { enabled: true }, + }, + }), + }); + + const client = new ObjectStackClient({ + baseUrl: 'http://localhost:3000', + fetch: fetchMock, + }); + + await client.connect(); + expect(client.capabilities!.transactionalBatch).toBe(true); + }); }); // ========================================== diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 36b582e14a..fce493ebe6 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1422,6 +1422,15 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { search: registeredServices.has('search'), export: registeredServices.has('automation') || registeredServices.has('queue'), chunkedUpload: registeredServices.has('file-storage'), + // Atomic cross-object batch (#3298 / #1604 / ADR-0034 item 4): the + // REST /batch endpoint runs its ops inside `engine.transaction()`, + // which only opens a real (all-or-nothing) transaction when the + // engine exposes one — otherwise it degrades to a non-atomic + // passthrough. Advertise the capability iff the runtime engine can + // honour a transaction, so `declared === enforced` (Prime Directive + // #10). The rest-server producer ANDs this with `api.enableBatch` so + // a server that doesn't mount the route reports `false` at its layer. + transactionalBatch: typeof (this.engine as { transaction?: unknown })?.transaction === 'function', }; // Convert flat booleans → hierarchical capability objects diff --git a/packages/objectql/src/protocol-discovery.test.ts b/packages/objectql/src/protocol-discovery.test.ts index 5d00ce6916..51791bbf50 100644 --- a/packages/objectql/src/protocol-discovery.test.ts +++ b/packages/objectql/src/protocol-discovery.test.ts @@ -238,6 +238,31 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => expect(discovery.capabilities!.comments).toEqual({ enabled: false }); }); + // ── Atomic cross-object batch capability (#3298 / #1604 / ADR-0034) ───────── + // The bit tracks whether the runtime engine can honour a transaction (the + // REST /batch endpoint runs inside `engine.transaction()`), NOT a registered + // service — so it is independent of the services map. + it('advertises transactionalBatch when the engine exposes transaction() — the real ObjectQL engine (#3298)', async () => { + protocol = new ObjectStackProtocolImplementation(engine); + const discovery = await protocol.getDiscovery(); + + // ObjectQL always implements transaction() (ADR-0034), so a real engine + // reports the capability regardless of which services are registered. + expect(typeof (engine as unknown as { transaction?: unknown }).transaction).toBe('function'); + expect(discovery.capabilities!.transactionalBatch).toEqual({ enabled: true }); + }); + + it('does NOT advertise transactionalBatch when the engine has no transaction() (declared === enforced, #3298)', async () => { + // A minimal engine without transaction(): the /batch endpoint would 501, so + // discovery must report false — never let a client drop its non-atomic + // fallback against a runtime that cannot honour an atomic batch. + const noTxEngine = { registry: { getObject: () => undefined } } as any; + protocol = new ObjectStackProtocolImplementation(noTxEngine); + const discovery = await protocol.getDiscovery(); + + expect(discovery.capabilities!.transactionalBatch).toEqual({ enabled: false }); + }); + it('should enable comments capability when the sys_comment object is registered (#3180)', async () => { // comments/chatter are served by the sys_comment object via the data API // (ADR-0052 §5), not a dedicated service — so the capability tracks that diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts index 3086646038..47fa61a7f3 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -103,6 +103,34 @@ describe('HonoServerPlugin', () => { await expect(plugin.start(context as PluginContext)).resolves.not.toThrow(); }); + it('standalone discovery advertises transactionalBatch=false — the /batch route is not mounted here (#3298)', async () => { + // This standalone surface registers CRUD + auth only; the cross-object + // /batch endpoint ships with @objectstack/rest. declared === enforced: + // discovery must report the capability as disabled so a client never + // drops its non-atomic fallback against this backend. + const plugin = new HonoServerPlugin({ registerStandardEndpoints: true }); + await plugin.init(context as PluginContext); + + // Capture the routes the producer registers on the raw app. + const routes: Record = {}; + const rawApp = { + get: vi.fn((path: string, h: any) => { routes[`GET ${path}`] = h; }), + post: vi.fn((path: string, h: any) => { routes[`POST ${path}`] = h; }), + use: vi.fn(), + }; + (plugin as any).server.getRawApp = () => rawApp; + (plugin as any).registerDiscoveryAndCrudEndpoints(context); + + const handler = routes['GET /api/v1/discovery']; + expect(handler).toBeDefined(); + const c = { json: vi.fn((x: any) => x) }; + const res = handler(c); + expect(res.data.capabilities.transactionalBatch).toEqual({ enabled: false }); + // Sanity: the standalone surface really has no /batch route (so `false` + // is honest, not merely conservative). + expect(routes['POST /api/v1/batch']).toBeUndefined(); + }); + it('should configure static files and SPA fallback when enabled', async () => { const plugin = new HonoServerPlugin({ staticRoot: './public', diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 53d4de9d1d..debdc580da 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -690,6 +690,16 @@ export class HonoServerPlugin implements Plugin { storage: `${prefix}/storage`, ui: `${prefix}/ui`, }, + capabilities: { + // This standalone Hono surface registers CRUD + auth only (see + // below) — it does NOT mount the cross-object `/batch` route, + // which ships with `@objectstack/rest`. `declared === enforced` + // (#3298): report `transactionalBatch: false` so a client never + // drops its non-atomic fallback against a backend that lacks the + // endpoint. When `@objectstack/rest` is mounted it serves its own + // discovery, which reports the real value from the runtime engine. + transactionalBatch: { enabled: false }, + }, }; // Discovery endpoints diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 5886d13e15..258c9b641e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -1860,6 +1860,27 @@ export class RestServer { } } + // Cross-object atomic batch capability (#3298). `declared === + // enforced`: advertise it only when THIS server actually mounts + // the `/batch` route (`api.enableBatch`, gated in + // registerBatchEndpoints) AND the runtime engine can honour a + // transaction (the protocol derived that from `engine.transaction`). + // AND-ing the two keeps us from advertising an endpoint that would + // 404 (batch disabled) or 501 (engine without `transaction()`), so + // a client can safely drop its non-atomic fallback on `true`. + const caps = ((discovery as any).capabilities ??= {}) as Record< + string, + { enabled: boolean; description?: string } + >; + const runtimeSupportsTx = !!caps.transactionalBatch?.enabled; + caps.transactionalBatch = { + enabled: runtimeSupportsTx && this.config.api.enableBatch !== false, + description: + 'Atomic cross-object batch endpoint (POST {basePath}/batch): all-or-nothing ' + + 'create/update/delete across objects in one transaction, with intra-batch ' + + '{ $ref: } parent references (#1604 / ADR-0034).', + }; + // Attach scoping metadata so clients can detect dual-mode routing. (discovery as any).scoping = { enabled: this.config.api.enableProjectScoping, diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 9c63ccff3c..53fee51568 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2381,6 +2381,75 @@ describe('discovery — routes.mcp (ADR-0036, #152)', () => { }); }); +// ────────────────────────────────────────────────────────────────────────── +// discovery — capabilities.transactionalBatch (#3298 / #1604 / ADR-0034) +// +// The atomic cross-object batch bit must be broadcast so clients negotiate +// declaratively instead of runtime-probing POST /batch for 404/405/501. The +// rest-server producer ANDs the runtime-engine signal (from the protocol, +// derived from `engine.transaction`) with whether IT mounts the route +// (`api.enableBatch`) — declared === enforced. +// ────────────────────────────────────────────────────────────────────────── +describe('discovery — capabilities.transactionalBatch (#3298)', () => { + /** + * Build the /discovery handler for a server whose protocol advertises the + * given runtime batch support, under the given api config. + */ + function discoveryHandler(opts: { runtimeTx?: boolean; api?: any } = {}) { + const server = createMockServer(); + const protocol = createMockProtocol(); + const capabilities = + opts.runtimeTx === undefined + ? undefined + : { transactionalBatch: { enabled: opts.runtimeTx } }; + (protocol.getDiscovery as any) = vi.fn().mockResolvedValue({ + routes: { data: '', metadata: '' }, + ...(capabilities ? { capabilities } : {}), + }); + const rest = new RestServer( + server as any, + protocol as any, + { api: opts.api ?? { requireAuth: false } } as any, + ); + rest.registerRoutes(); + const entry = rest.getRouteManager().get('GET', '/api/v1/discovery'); + if (!entry) throw new Error('discovery route not registered'); + return entry.handler as (req: any, res: any) => Promise; + } + + async function invoke(handler: (req: any, res: any) => Promise) { + let body: any; + const res: any = { json: (b: any) => { body = b; }, status: () => res }; + await handler({ params: {} }, res); + return body; + } + + it('advertises transactionalBatch=true when the runtime supports it and /batch is mounted (default)', async () => { + const body = await invoke(discoveryHandler({ runtimeTx: true })); + expect(body.capabilities.transactionalBatch.enabled).toBe(true); + // The description is carried so the capability is self-documenting. + expect(body.capabilities.transactionalBatch.description).toContain('POST {basePath}/batch'); + }); + + it('reports transactionalBatch=false when api.enableBatch is off — the /batch route is not mounted', async () => { + const body = await invoke( + discoveryHandler({ runtimeTx: true, api: { requireAuth: false, enableBatch: false } }), + ); + expect(body.capabilities.transactionalBatch.enabled).toBe(false); + }); + + it('reports transactionalBatch=false when the runtime engine cannot honour a transaction', async () => { + const body = await invoke(discoveryHandler({ runtimeTx: false })); + expect(body.capabilities.transactionalBatch.enabled).toBe(false); + }); + + it('always populates the bit even if the protocol omitted capabilities (no declared-but-unpopulated gap)', async () => { + const body = await invoke(discoveryHandler({ /* protocol returns no capabilities */ })); + expect(body.capabilities).toBeDefined(); + expect(body.capabilities.transactionalBatch.enabled).toBe(false); + }); +}); + // ────────────────────────────────────────────────────────────────────────── // Metadata translation — envelope unwrapping // diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts index 9000fea503..08a2d4aa51 100644 --- a/packages/spec/src/api/discovery.test.ts +++ b/packages/spec/src/api/discovery.test.ts @@ -709,6 +709,7 @@ describe('WellKnownCapabilitiesSchema', () => { search: true, export: true, chunkedUpload: true, + transactionalBatch: true, }; expect(() => WellKnownCapabilitiesSchema.parse(caps)).not.toThrow(); }); @@ -721,14 +722,26 @@ describe('WellKnownCapabilitiesSchema', () => { search: false, export: false, chunkedUpload: false, + transactionalBatch: false, }); expect(caps.comments).toBe(false); expect(caps.chunkedUpload).toBe(false); + expect(caps.transactionalBatch).toBe(false); }); it('should reject missing required fields', () => { expect(() => WellKnownCapabilitiesSchema.parse({ comments: true })).toThrow(); expect(() => WellKnownCapabilitiesSchema.parse({})).toThrow(); + // transactionalBatch is required — a payload missing only it must fail so a + // producer can never silently omit the batch capability bit (#3298). + expect(() => WellKnownCapabilitiesSchema.parse({ + comments: true, + automation: true, + cron: true, + search: true, + export: true, + chunkedUpload: true, + })).toThrow(); }); it('should reject non-boolean values', () => { @@ -739,6 +752,7 @@ describe('WellKnownCapabilitiesSchema', () => { search: true, export: true, chunkedUpload: true, + transactionalBatch: true, })).toThrow(); }); @@ -750,6 +764,7 @@ describe('WellKnownCapabilitiesSchema', () => { expect(shape.search.description).toBeDefined(); expect(shape.export.description).toBeDefined(); expect(shape.chunkedUpload.description).toBeDefined(); + expect(shape.transactionalBatch.description).toBeDefined(); }); }); diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index 0a1889e4b8..2598eaeb40 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -289,6 +289,24 @@ export const WellKnownCapabilitiesSchema = lazySchema(() => z.object({ export: z.boolean().describe('Whether the backend supports async export'), /** Whether the backend supports chunked (multipart) uploads */ chunkedUpload: z.boolean().describe('Whether the backend supports chunked (multipart) uploads'), + /** + * Whether the backend exposes the atomic cross-object batch endpoint + * (`POST {basePath}/batch`, issue #1604 / ADR-0034 item 4): heterogeneous + * create/update/delete across objects that all commit or all roll back in a + * single transaction, with intra-batch `{ $ref: }` parent references. + * + * This lets a client decide **at connection time** whether to send an atomic + * batch or fall back to non-atomic client-side simulation — replacing the + * runtime probe (fire a `/batch` and read 404/405/501). `true` means the route + * is mounted AND the runtime engine can honour a transaction; a backend that + * would 404 (no route) or 501 (no `transaction()`) MUST report `false` + * (declared === enforced). + */ + transactionalBatch: z.boolean().describe( + '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.' + ), }).describe('Well-known capability flags for frontend intelligent adaptation')); export type WellKnownCapabilities = z.infer;