Skip to content

Commit a2f0e53

Browse files
committed
feat(client): typed data.batchTransaction() — atomic cross-object batch SDK surface (#1604 / ADR-0034 item 4)
master-detail 保存统一走原子 batchTransaction:给 @objectstack/client 增加 data.batchTransaction(operations)(含环境作用域镜像),类型化封装 POST {basePath}/batch 全有或全无跨对象事务端点,支持 { $ref: <opIndex> } 批内父引用。方法恒原子、不暴露 atomic 参数——非原子 per-object 批量保留在 data.batch()/createMany/updateMany,best-effort 回退只能隔离在调用方 (ObjectUI masterDetailTx)适配器里,SDK 不承载。 - 复用并 re-export spec 契约类型 CrossObjectBatchOperation/Request/Response - 单测 6 例(URL/payload、$ref 透传、双响应形态解包、discovery 路由推导、 错误整形、scoped 镜像)+ 端到端 4 例(client→hono→rest→engine 全链路, 含 BATCH_UNRESOLVED_REF 错误映射与 BATCH_NOT_ATOMIC 契约 pin) - 文档:client-sdk.mdx / http-protocol.mdx 互链;ADR-0034 §4 补状态注 (framework 侧完成,objectui 重指 masterDetailTx 为跨仓库 follow-up) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCrTB7b5qFX2yyHJEGB5xa
1 parent 46e876c commit a2f0e53

7 files changed

Lines changed: 343 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/client': minor
3+
---
4+
5+
feat(client): typed `data.batchTransaction()` for the atomic cross-object batch (#1604 / ADR-0034 item 4)
6+
7+
Adds `client.data.batchTransaction(operations)` (and the environment-scoped
8+
`client.project(id).data.batchTransaction`) — a typed SDK surface for
9+
`POST {basePath}/batch`, the all-or-nothing cross-object transactional batch
10+
that master-detail saves go through. Reuses `CrossObjectBatchOperation` /
11+
`CrossObjectBatchRequest` / `CrossObjectBatchResponse` from
12+
`@objectstack/spec/api` (also re-exported from the client for convenience);
13+
supports `{ $ref: <opIndex> }` intra-batch parent references.
14+
15+
The method is always atomic and deliberately exposes no `atomic` flag — the
16+
endpoint rejects `atomic: false` with `400 BATCH_NOT_ATOMIC`. Non-atomic
17+
per-object bulk writes stay on `data.batch()` / `createMany` / `updateMany`,
18+
so any best-effort fallback is isolated in the caller's adapter (the ObjectUI
19+
`masterDetailTx` adapter), not in the SDK.

content/docs/api/client-sdk.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,21 @@ await client.data.updateMany('account', [
206206
{ id: '2', data: { status: 'closed' } },
207207
]);
208208
await client.data.deleteMany('account', ['1', '2', '3']);
209+
210+
// Atomic cross-object batch (master-detail save) — runs every operation in
211+
// ONE server-side transaction (POST /api/v1/batch): commit all or roll back
212+
// all. `{ $ref: <op index> }` lets a child reference a parent created earlier
213+
// in the same request. Always atomic — unlike per-object `data.batch()`,
214+
// there is no partial-success mode.
215+
const { results } = await client.data.batchTransaction([
216+
{ object: 'project', action: 'create', data: { name: 'Apollo' } },
217+
{ object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } },
218+
]);
209219
```
210220

221+
See [Batch Operations](/docs/protocol/kernel/http-protocol#batch-operations)
222+
for the underlying endpoint contract.
223+
211224
### `client.analytics` — BI Queries
212225

213226
```typescript

content/docs/protocol/kernel/http-protocol.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,9 @@ atomic transaction.
607607
POST /api/v1/batch
608608
```
609609

610+
The typed SDK surface for this route is
611+
[`client.data.batchTransaction(operations)`](/docs/api/client-sdk#clientdata--crud-operations).
612+
610613
Each operation specifies an `action` (`create`, `update`, or `delete`), the
611614
target `object`, and the relevant `data` / `id`. A field value of
612615
`{ "$ref": <earlier op index> }` resolves to the id created by an earlier

docs/adr/0034-transactional-writes-and-ambient-transaction.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ Once (1)+(2) land and a multi-write transaction test suite is green:
118118
- Point ObjectUI `masterDetailTx` at it and delete the client-side best-effort
119119
cleanup (a smell that exists only because server atomicity was missing).
120120

121+
> **Status (framework side: done).** The endpoint landed as
122+
> `POST {basePath}/batch` (`rest-server.ts` `registerBatchEndpoints`, spec
123+
> contract `CrossObjectBatchRequestSchema` in `spec/src/api/batch.zod.ts`),
124+
> and the SDK exposes it as `client.data.batchTransaction(operations)`
125+
> (plus the environment-scoped mirror). The SDK method is always atomic and
126+
> exposes no `atomic` flag — non-atomic per-object bulk writes stay on
127+
> `data.batch()`/`createMany`/`updateMany`, so any best-effort fallback must
128+
> be isolated in the caller's adapter. Remaining (objectui repo): repoint
129+
> `masterDetailTx` at `batchTransaction` and delete the client-side
130+
> best-effort cleanup, keeping the non-atomic fallback inside the
131+
> `data-objectstack` adapter only.
132+
121133
---
122134

123135
## Consequences
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Integration test — atomic cross-object batch through the typed SDK
5+
* (`client.data.batchTransaction`, issue #1604 / ADR-0034 item 4).
6+
*
7+
* Boots a real Hono server (LiteKernel + ObjectQLPlugin + createRestApiPlugin,
8+
* same pattern as client.environment-scoping.test.ts) and proves the full
9+
* client → hono → rest → engine chain:
10+
* 1. Parent + child with `{ $ref: 0 }` commit in one request and the child's
11+
* FK equals the parent's generated id.
12+
* 2. The environment-scoped mirror resolves `/environments/:id/batch`.
13+
* 3. An unresolvable `$ref` rejects and the client error carries
14+
* `code: 'BATCH_UNRESOLVED_REF'` (error-mapping proof).
15+
* 4. `atomic: false` is rejected with 400 BATCH_NOT_ATOMIC — the contract
16+
* reason the SDK method exposes no `atomic` flag.
17+
*
18+
* NOTE on atomicity: the InMemoryDriver's `transaction()` is a passthrough
19+
* (no rollback), so all-or-nothing semantics are NOT asserted here — they are
20+
* covered by objectql/src/engine-ambient-transaction.test.ts and
21+
* rest/src/rest-batch-endpoint.test.ts against transactional drivers.
22+
*/
23+
24+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
25+
import { LiteKernel } from '@objectstack/core';
26+
import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql';
27+
import { InMemoryDriver } from '@objectstack/driver-memory';
28+
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
29+
import { createRestApiPlugin } from '@objectstack/runtime';
30+
import { ObjectStackClient } from './index';
31+
32+
describe('data.batchTransaction (live Hono, #1604)', () => {
33+
let baseUrl: string;
34+
let kernel: LiteKernel;
35+
let client: ObjectStackClient;
36+
37+
beforeAll(async () => {
38+
kernel = new LiteKernel();
39+
kernel.use(new ObjectQLPlugin());
40+
41+
kernel.use(
42+
new HonoServerPlugin({
43+
port: 0,
44+
// Skip hardcoded hono CRUD routes so createRestApiPlugin owns
45+
// route registration (including the root /batch route).
46+
registerStandardEndpoints: false,
47+
}),
48+
);
49+
50+
kernel.use(
51+
createRestApiPlugin({
52+
api: {
53+
api: {
54+
// Routing test, no auth stack mounted — opt out of the
55+
// secure-by-default anonymous deny (ADR-0056 D2).
56+
requireAuth: false,
57+
enableProjectScoping: true,
58+
projectResolution: 'auto',
59+
} as any,
60+
},
61+
}),
62+
);
63+
64+
await kernel.bootstrap();
65+
66+
const ql = kernel.getService<ObjectQL>('objectql');
67+
ql.registerDriver(new InMemoryDriver(), true);
68+
69+
ql.registerObject({
70+
name: 'project',
71+
label: 'Project',
72+
fields: {
73+
name: { type: 'text', label: 'Name' },
74+
},
75+
});
76+
ql.registerObject({
77+
name: 'task',
78+
label: 'Task',
79+
fields: {
80+
title: { type: 'text', label: 'Title' },
81+
project: { type: 'lookup', reference_to: 'project', label: 'Project' },
82+
},
83+
});
84+
85+
const httpServer = kernel.getService<any>('http.server');
86+
baseUrl = `http://localhost:${httpServer.getPort()}`;
87+
client = new ObjectStackClient({ baseUrl });
88+
}, 30_000);
89+
90+
afterAll(async () => {
91+
if (kernel) {
92+
await Promise.race([
93+
kernel.shutdown(),
94+
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
95+
]);
96+
}
97+
}, 30_000);
98+
99+
it('creates parent + child in one request; { $ref: 0 } resolves to the parent id', async () => {
100+
const { results } = await client.data.batchTransaction([
101+
{ object: 'project', action: 'create', data: { name: 'Apollo' } },
102+
{ object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } },
103+
]);
104+
105+
expect(results).toHaveLength(2);
106+
const parent = results[0] as any;
107+
const child = results[1] as any;
108+
expect(parent.id).toBeDefined();
109+
expect(child.project).toBe(parent.id);
110+
111+
// Both rows are readable afterwards through the ordinary data API.
112+
const storedChild = await client.data.get<any>('task', child.id);
113+
expect((storedChild as any).project ?? (storedChild as any).record?.project).toBeDefined();
114+
});
115+
116+
it('is mirrored on the environment-scoped client (/environments/:id/batch)', async () => {
117+
const scoped = client.project('proj-alpha');
118+
const { results } = await scoped.data.batchTransaction([
119+
{ object: 'project', action: 'create', data: { name: 'Scoped' } },
120+
]);
121+
expect((results[0] as any).id).toBeDefined();
122+
});
123+
124+
it('rejects an unresolvable $ref with BATCH_UNRESOLVED_REF surfaced on the client error', async () => {
125+
let caught: any;
126+
try {
127+
await client.data.batchTransaction([
128+
{ object: 'task', action: 'create', data: { title: 'orphan', project: { $ref: 5 } } },
129+
]);
130+
} catch (e) {
131+
caught = e;
132+
}
133+
expect(caught).toBeDefined();
134+
expect(caught.httpStatus).toBe(400);
135+
expect(caught.code).toBe('BATCH_UNRESOLVED_REF');
136+
});
137+
138+
it('rejects atomic:false with 400 BATCH_NOT_ATOMIC (why the SDK has no atomic flag)', async () => {
139+
const res = await fetch(`${baseUrl}/api/v1/batch`, {
140+
method: 'POST',
141+
headers: { 'Content-Type': 'application/json' },
142+
body: JSON.stringify({
143+
operations: [{ object: 'project', action: 'create', data: { name: 'nope' } }],
144+
atomic: false,
145+
}),
146+
});
147+
expect(res.status).toBe(400);
148+
const body = await res.json();
149+
expect(body.code).toBe('BATCH_NOT_ATOMIC');
150+
});
151+
});

packages/client/src/client.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,3 +1203,84 @@ describe('packages.install', () => {
12031203
expect(body.overwrite).toBe(true);
12041204
});
12051205
});
1206+
1207+
// ==========================================
1208+
// Atomic cross-object batch (#1604 / ADR-0034 item 4)
1209+
// ==========================================
1210+
1211+
describe('data.batchTransaction', () => {
1212+
const OPS = [
1213+
{ object: 'project', action: 'create' as const, data: { name: 'Apollo' } },
1214+
{ object: 'task', action: 'create' as const, data: { title: 'Kickoff', project: { $ref: 0 } } },
1215+
];
1216+
1217+
it('POSTs { operations, atomic: true } to the root /batch route', async () => {
1218+
const { client, fetchMock } = createMockClient({ results: [{ id: 'p1' }, { id: 't1' }] });
1219+
const res = await client.data.batchTransaction(OPS);
1220+
1221+
expect(fetchMock).toHaveBeenCalledWith(
1222+
'http://localhost:3000/api/v1/batch',
1223+
expect.objectContaining({ method: 'POST' }),
1224+
);
1225+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
1226+
expect(body.operations).toHaveLength(2);
1227+
// Always atomic — the SDK never exposes a non-atomic variant; the
1228+
// server 400s on `atomic: false` (BATCH_NOT_ATOMIC).
1229+
expect(body.atomic).toBe(true);
1230+
expect(res.results).toEqual([{ id: 'p1' }, { id: 't1' }]);
1231+
});
1232+
1233+
it('serializes intra-batch { $ref } parent references verbatim', async () => {
1234+
const { client, fetchMock } = createMockClient({ results: [] });
1235+
await client.data.batchTransaction(OPS);
1236+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
1237+
expect(body.operations[1].data.project).toEqual({ $ref: 0 });
1238+
});
1239+
1240+
it('unwraps both the bare and the enveloped response shape', async () => {
1241+
// Bare `{ results }` (what the endpoint sends today)
1242+
const bare = createMockClient({ results: [{ id: 'a' }] });
1243+
expect((await bare.client.data.batchTransaction(OPS)).results).toEqual([{ id: 'a' }]);
1244+
1245+
// `{ success, data }` envelope (BaseResponse-wrapped variant)
1246+
const wrapped = createMockClient({ success: true, data: { results: [{ id: 'b' }] } });
1247+
expect((await wrapped.client.data.batchTransaction(OPS)).results).toEqual([{ id: 'b' }]);
1248+
});
1249+
1250+
it('derives the batch route from a discovery-overridden data route', async () => {
1251+
const { client, fetchMock } = createMockClient({ results: [] });
1252+
(client as any)['discoveryInfo'] = { routes: { data: '/custom/v9/data' } };
1253+
await client.data.batchTransaction(OPS);
1254+
expect(fetchMock).toHaveBeenCalledWith(
1255+
'http://localhost:3000/custom/v9/batch',
1256+
expect.any(Object),
1257+
);
1258+
});
1259+
1260+
it('surfaces server rejections with code/status attached (BATCH_UNRESOLVED_REF)', async () => {
1261+
const { client } = createMockClient(
1262+
{ error: "Unresolved $ref 5 on field 'project'", code: 'BATCH_UNRESOLVED_REF' },
1263+
400,
1264+
);
1265+
let caught: any;
1266+
try {
1267+
await client.data.batchTransaction(OPS);
1268+
} catch (e) {
1269+
caught = e;
1270+
}
1271+
expect(caught).toBeDefined();
1272+
expect(caught.code).toBe('BATCH_UNRESOLVED_REF');
1273+
expect(caught.httpStatus).toBe(400);
1274+
});
1275+
1276+
it('is mirrored on the environment-scoped client', async () => {
1277+
const { client, fetchMock } = createMockClient({ results: [] });
1278+
await client.project('proj-1').data.batchTransaction(OPS);
1279+
expect(fetchMock).toHaveBeenCalledWith(
1280+
'http://localhost:3000/api/v1/environments/proj-1/batch',
1281+
expect.objectContaining({ method: 'POST' }),
1282+
);
1283+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
1284+
expect(body.atomic).toBe(true);
1285+
});
1286+
});

packages/client/src/index.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ import {
7878
ListImportJobsRequest,
7979
ListImportJobsResponse,
8080
UndoImportJobResponse,
81+
CrossObjectBatchOperation,
82+
CrossObjectBatchRequest,
83+
CrossObjectBatchResponse,
8184
} from '@objectstack/spec/api';
8285
import type {
8386
ApprovalRequestRow,
@@ -3018,6 +3021,49 @@ export class ObjectStackClient {
30183021
return this.unwrapResponse<BatchUpdateResponse>(res);
30193022
},
30203023

3024+
/**
3025+
* Atomic cross-object batch — the canonical master-detail save path
3026+
* (issue #1604 / ADR-0034 item 4).
3027+
*
3028+
* Executes heterogeneous create/update/delete operations across MULTIPLE
3029+
* objects in ONE server-side engine transaction: commit all or roll back
3030+
* all. A field value of `{ $ref: <earlier op index> }` resolves to the id
3031+
* created by that earlier operation, so a child row can reference a
3032+
* parent created in the same request:
3033+
*
3034+
* ```ts
3035+
* const { results } = await client.data.batchTransaction([
3036+
* { object: 'project', action: 'create', data: { name: 'Apollo' } },
3037+
* { object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } },
3038+
* ]);
3039+
* ```
3040+
*
3041+
* This method is always atomic and deliberately exposes no `atomic`
3042+
* flag — the endpoint rejects `atomic: false` with `400 BATCH_NOT_ATOMIC`.
3043+
* Non-atomic, partial-success bulk writes stay on the per-object
3044+
* {@link batch} / {@link createMany} / {@link updateMany} surface;
3045+
* callers that need a best-effort fallback (e.g. against servers
3046+
* predating this route, which respond 404 — check `error.httpStatus`)
3047+
* must isolate it in their own adapter rather than here.
3048+
*
3049+
* URL note: the server mounts this route at the PARENT of the data
3050+
* prefix (`POST {basePath}/batch` vs `{basePath}/data/...`, see
3051+
* rest-server `registerBatchEndpoints`), so the path is derived by
3052+
* dropping the last segment of the resolved data route.
3053+
*/
3054+
batchTransaction: async (
3055+
operations: CrossObjectBatchOperation[],
3056+
): Promise<CrossObjectBatchResponse> => {
3057+
const dataRoute = this.getRoute('data');
3058+
const base = dataRoute.replace(/\/[^/]+\/?$/, '') || '/api/v1';
3059+
const request: CrossObjectBatchRequest = { operations, atomic: true };
3060+
const res = await this.fetch(`${this.baseUrl}${base}/batch`, {
3061+
method: 'POST',
3062+
body: JSON.stringify(request),
3063+
});
3064+
return this.unwrapResponse<CrossObjectBatchResponse>(res);
3065+
},
3066+
30213067
/**
30223068
* Update multiple records (simplified batch update)
30233069
* Convenience method for batch updates without full BatchUpdateRequest
@@ -3469,6 +3515,21 @@ export class ScopedProjectClient {
34693515
});
34703516
return this.parent._unwrap<BatchUpdateResponse>(res);
34713517
},
3518+
/**
3519+
* Atomic cross-object batch, environment-scoped
3520+
* (`POST /api/v1/environments/:id/batch`).
3521+
* See {@link ObjectStackClient} `data.batchTransaction` for semantics.
3522+
*/
3523+
batchTransaction: async (
3524+
operations: CrossObjectBatchOperation[],
3525+
): Promise<CrossObjectBatchResponse> => {
3526+
const request: CrossObjectBatchRequest = { operations, atomic: true };
3527+
const res = await this.parent._fetch(this.url('/batch'), {
3528+
method: 'POST',
3529+
body: JSON.stringify(request),
3530+
});
3531+
return this.parent._unwrap<CrossObjectBatchResponse>(res);
3532+
},
34723533
updateMany: async <T = any>(
34733534
object: string,
34743535
records: Array<{ id: string; data: Partial<T> }>,
@@ -3631,6 +3692,9 @@ export type {
36313692
ListImportJobsRequest,
36323693
ListImportJobsResponse,
36333694
UndoImportJobResponse,
3695+
CrossObjectBatchOperation,
3696+
CrossObjectBatchRequest,
3697+
CrossObjectBatchResponse,
36343698
} from '@objectstack/spec/api';
36353699

36363700
// Approval runtime types (ADR-0019) — surfaced so SDK consumers can type the

0 commit comments

Comments
 (0)