Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/console-3b2e4d98d904.md
Original file line number Diff line number Diff line change
@@ -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`
20 changes: 20 additions & 0 deletions .changeset/discovery-transactional-batch-capability.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0318118e02fd0ef377492f3b09b687a84cccc908
3b2e4d98d904d695a8372c394d46b81673011270
1 change: 1 addition & 0 deletions content/docs/references/api/discovery.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |


---
Expand Down
26 changes: 26 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

// ==========================================
Expand Down
9 changes: 9 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/objectql/src/protocol-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-hono-server/src/hono-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {};
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',
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/plugin-hono-server/src/hono-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <opIndex> } parent references (#1604 / ADR-0034).',
};

// Attach scoping metadata so clients can detect dual-mode routing.
(discovery as any).scoping = {
enabled: this.config.api.enableProjectScoping,
Expand Down
69 changes: 69 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

async function invoke(handler: (req: any, res: any) => Promise<void>) {
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
//
Expand Down
15 changes: 15 additions & 0 deletions packages/spec/src/api/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,7 @@ describe('WellKnownCapabilitiesSchema', () => {
search: true,
export: true,
chunkedUpload: true,
transactionalBatch: true,
};
expect(() => WellKnownCapabilitiesSchema.parse(caps)).not.toThrow();
});
Expand All @@ -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', () => {
Expand All @@ -739,6 +752,7 @@ describe('WellKnownCapabilitiesSchema', () => {
search: true,
export: true,
chunkedUpload: true,
transactionalBatch: true,
})).toThrow();
});

Expand All @@ -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();
});
});

Expand Down
18 changes: 18 additions & 0 deletions packages/spec/src/api/discovery.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <opIndex> }` 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<typeof WellKnownCapabilitiesSchema>;
Expand Down
Loading