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
19 changes: 19 additions & 0 deletions .changeset/client-batch-transaction-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/client': minor
---

feat(client): typed `data.batchTransaction()` for the atomic cross-object batch (#1604 / ADR-0034 item 4)

Adds `client.data.batchTransaction(operations)` (and the environment-scoped
`client.project(id).data.batchTransaction`) — a typed SDK surface for
`POST {basePath}/batch`, the all-or-nothing cross-object transactional batch
that master-detail saves go through. Reuses `CrossObjectBatchOperation` /
`CrossObjectBatchRequest` / `CrossObjectBatchResponse` from
`@objectstack/spec/api` (also re-exported from the client for convenience);
supports `{ $ref: <opIndex> }` intra-batch parent references.

The method is always atomic and deliberately exposes no `atomic` flag — the
endpoint rejects `atomic: false` with `400 BATCH_NOT_ATOMIC`. Non-atomic
per-object bulk writes stay on `data.batch()` / `createMany` / `updateMany`,
so any best-effort fallback is isolated in the caller's adapter (the ObjectUI
`masterDetailTx` adapter), not in the SDK.
13 changes: 13 additions & 0 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,21 @@ await client.data.updateMany('account', [
{ id: '2', data: { status: 'closed' } },
]);
await client.data.deleteMany('account', ['1', '2', '3']);

// Atomic cross-object batch (master-detail save) — runs every operation in
// ONE server-side transaction (POST /api/v1/batch): commit all or roll back
// all. `{ $ref: <op index> }` lets a child reference a parent created earlier
// in the same request. Always atomic — unlike per-object `data.batch()`,
// there is no partial-success mode.
const { results } = await client.data.batchTransaction([
{ object: 'project', action: 'create', data: { name: 'Apollo' } },
{ object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } },
]);
```

See [Batch Operations](/docs/protocol/kernel/http-protocol#batch-operations)
for the underlying endpoint contract.

### `client.analytics` — BI Queries

```typescript
Expand Down
3 changes: 3 additions & 0 deletions content/docs/protocol/kernel/http-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,9 @@ atomic transaction.
POST /api/v1/batch
```

The typed SDK surface for this route is
[`client.data.batchTransaction(operations)`](/docs/api/client-sdk#clientdata--crud-operations).

Each operation specifies an `action` (`create`, `update`, or `delete`), the
target `object`, and the relevant `data` / `id`. A field value of
`{ "$ref": <earlier op index> }` resolves to the id created by an earlier
Expand Down
12 changes: 12 additions & 0 deletions docs/adr/0034-transactional-writes-and-ambient-transaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ Once (1)+(2) land and a multi-write transaction test suite is green:
- Point ObjectUI `masterDetailTx` at it and delete the client-side best-effort
cleanup (a smell that exists only because server atomicity was missing).

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

---

## Consequences
Expand Down
151 changes: 151 additions & 0 deletions packages/client/src/client.batch-transaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Integration test — atomic cross-object batch through the typed SDK
* (`client.data.batchTransaction`, issue #1604 / ADR-0034 item 4).
*
* Boots a real Hono server (LiteKernel + ObjectQLPlugin + createRestApiPlugin,
* same pattern as client.environment-scoping.test.ts) and proves the full
* client → hono → rest → engine chain:
* 1. Parent + child with `{ $ref: 0 }` commit in one request and the child's
* FK equals the parent's generated id.
* 2. The environment-scoped mirror resolves `/environments/:id/batch`.
* 3. An unresolvable `$ref` rejects and the client error carries
* `code: 'BATCH_UNRESOLVED_REF'` (error-mapping proof).
* 4. `atomic: false` is rejected with 400 BATCH_NOT_ATOMIC — the contract
* reason the SDK method exposes no `atomic` flag.
*
* NOTE on atomicity: the InMemoryDriver's `transaction()` is a passthrough
* (no rollback), so all-or-nothing semantics are NOT asserted here — they are
* covered by objectql/src/engine-ambient-transaction.test.ts and
* rest/src/rest-batch-endpoint.test.ts against transactional drivers.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { LiteKernel } from '@objectstack/core';
import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql';
import { InMemoryDriver } from '@objectstack/driver-memory';
import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
import { createRestApiPlugin } from '@objectstack/runtime';
import { ObjectStackClient } from './index';

describe('data.batchTransaction (live Hono, #1604)', () => {
let baseUrl: string;
let kernel: LiteKernel;
let client: ObjectStackClient;

beforeAll(async () => {
kernel = new LiteKernel();
kernel.use(new ObjectQLPlugin());

kernel.use(
new HonoServerPlugin({
port: 0,
// Skip hardcoded hono CRUD routes so createRestApiPlugin owns
// route registration (including the root /batch route).
registerStandardEndpoints: false,
}),
);

kernel.use(
createRestApiPlugin({
api: {
api: {
// Routing test, no auth stack mounted — opt out of the
// secure-by-default anonymous deny (ADR-0056 D2).
requireAuth: false,
enableProjectScoping: true,
projectResolution: 'auto',
} as any,
},
}),
);

await kernel.bootstrap();

const ql = kernel.getService<ObjectQL>('objectql');
ql.registerDriver(new InMemoryDriver(), true);

ql.registerObject({
name: 'project',
label: 'Project',
fields: {
name: { type: 'text', label: 'Name' },
},
});
ql.registerObject({
name: 'task',
label: 'Task',
fields: {
title: { type: 'text', label: 'Title' },
project: { type: 'lookup', reference_to: 'project', label: 'Project' },
},
});

const httpServer = kernel.getService<any>('http.server');
baseUrl = `http://localhost:${httpServer.getPort()}`;
client = new ObjectStackClient({ baseUrl });
}, 30_000);

afterAll(async () => {
if (kernel) {
await Promise.race([
kernel.shutdown(),
new Promise<void>((resolve) => setTimeout(resolve, 10_000)),
]);
}
}, 30_000);

it('creates parent + child in one request; { $ref: 0 } resolves to the parent id', async () => {
const { results } = await client.data.batchTransaction([
{ object: 'project', action: 'create', data: { name: 'Apollo' } },
{ object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } },
]);

expect(results).toHaveLength(2);
const parent = results[0] as any;
const child = results[1] as any;
expect(parent.id).toBeDefined();
expect(child.project).toBe(parent.id);

// Both rows are readable afterwards through the ordinary data API.
const storedChild = await client.data.get<any>('task', child.id);
expect((storedChild as any).project ?? (storedChild as any).record?.project).toBeDefined();
});

it('is mirrored on the environment-scoped client (/environments/:id/batch)', async () => {
const scoped = client.project('proj-alpha');
const { results } = await scoped.data.batchTransaction([
{ object: 'project', action: 'create', data: { name: 'Scoped' } },
]);
expect((results[0] as any).id).toBeDefined();
});

it('rejects an unresolvable $ref with BATCH_UNRESOLVED_REF surfaced on the client error', async () => {
let caught: any;
try {
await client.data.batchTransaction([
{ object: 'task', action: 'create', data: { title: 'orphan', project: { $ref: 5 } } },
]);
} catch (e) {
caught = e;
}
expect(caught).toBeDefined();
expect(caught.httpStatus).toBe(400);
expect(caught.code).toBe('BATCH_UNRESOLVED_REF');
});

it('rejects atomic:false with 400 BATCH_NOT_ATOMIC (why the SDK has no atomic flag)', async () => {
const res = await fetch(`${baseUrl}/api/v1/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
operations: [{ object: 'project', action: 'create', data: { name: 'nope' } }],
atomic: false,
}),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe('BATCH_NOT_ATOMIC');
});
});
81 changes: 81 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1203,3 +1203,84 @@ describe('packages.install', () => {
expect(body.overwrite).toBe(true);
});
});

// ==========================================
// Atomic cross-object batch (#1604 / ADR-0034 item 4)
// ==========================================

describe('data.batchTransaction', () => {
const OPS = [
{ object: 'project', action: 'create' as const, data: { name: 'Apollo' } },
{ object: 'task', action: 'create' as const, data: { title: 'Kickoff', project: { $ref: 0 } } },
];

it('POSTs { operations, atomic: true } to the root /batch route', async () => {
const { client, fetchMock } = createMockClient({ results: [{ id: 'p1' }, { id: 't1' }] });
const res = await client.data.batchTransaction(OPS);

expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3000/api/v1/batch',
expect.objectContaining({ method: 'POST' }),
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.operations).toHaveLength(2);
// Always atomic — the SDK never exposes a non-atomic variant; the
// server 400s on `atomic: false` (BATCH_NOT_ATOMIC).
expect(body.atomic).toBe(true);
expect(res.results).toEqual([{ id: 'p1' }, { id: 't1' }]);
});

it('serializes intra-batch { $ref } parent references verbatim', async () => {
const { client, fetchMock } = createMockClient({ results: [] });
await client.data.batchTransaction(OPS);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.operations[1].data.project).toEqual({ $ref: 0 });
});

it('unwraps both the bare and the enveloped response shape', async () => {
// Bare `{ results }` (what the endpoint sends today)
const bare = createMockClient({ results: [{ id: 'a' }] });
expect((await bare.client.data.batchTransaction(OPS)).results).toEqual([{ id: 'a' }]);

// `{ success, data }` envelope (BaseResponse-wrapped variant)
const wrapped = createMockClient({ success: true, data: { results: [{ id: 'b' }] } });
expect((await wrapped.client.data.batchTransaction(OPS)).results).toEqual([{ id: 'b' }]);
});

it('derives the batch route from a discovery-overridden data route', async () => {
const { client, fetchMock } = createMockClient({ results: [] });
(client as any)['discoveryInfo'] = { routes: { data: '/custom/v9/data' } };
await client.data.batchTransaction(OPS);
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3000/custom/v9/batch',
expect.any(Object),
);
});

it('surfaces server rejections with code/status attached (BATCH_UNRESOLVED_REF)', async () => {
const { client } = createMockClient(
{ error: "Unresolved $ref 5 on field 'project'", code: 'BATCH_UNRESOLVED_REF' },
400,
);
let caught: any;
try {
await client.data.batchTransaction(OPS);
} catch (e) {
caught = e;
}
expect(caught).toBeDefined();
expect(caught.code).toBe('BATCH_UNRESOLVED_REF');
expect(caught.httpStatus).toBe(400);
});

it('is mirrored on the environment-scoped client', async () => {
const { client, fetchMock } = createMockClient({ results: [] });
await client.project('proj-1').data.batchTransaction(OPS);
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3000/api/v1/environments/proj-1/batch',
expect.objectContaining({ method: 'POST' }),
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.atomic).toBe(true);
});
});
64 changes: 64 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ import {
ListImportJobsRequest,
ListImportJobsResponse,
UndoImportJobResponse,
CrossObjectBatchOperation,
CrossObjectBatchRequest,
CrossObjectBatchResponse,
} from '@objectstack/spec/api';
import type {
ApprovalRequestRow,
Expand Down Expand Up @@ -3018,6 +3021,49 @@ export class ObjectStackClient {
return this.unwrapResponse<BatchUpdateResponse>(res);
},

/**
* Atomic cross-object batch — the canonical master-detail save path
* (issue #1604 / ADR-0034 item 4).
*
* Executes heterogeneous create/update/delete operations across MULTIPLE
* objects in ONE server-side engine transaction: commit all or roll back
* all. A field value of `{ $ref: <earlier op index> }` resolves to the id
* created by that earlier operation, so a child row can reference a
* parent created in the same request:
*
* ```ts
* const { results } = await client.data.batchTransaction([
* { object: 'project', action: 'create', data: { name: 'Apollo' } },
* { object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } },
* ]);
* ```
*
* This method is always atomic and deliberately exposes no `atomic`
* flag — the endpoint rejects `atomic: false` with `400 BATCH_NOT_ATOMIC`.
* Non-atomic, partial-success bulk writes stay on the per-object
* {@link batch} / {@link createMany} / {@link updateMany} surface;
* callers that need a best-effort fallback (e.g. against servers
* predating this route, which respond 404 — check `error.httpStatus`)
* must isolate it in their own adapter rather than here.
*
* URL note: the server mounts this route at the PARENT of the data
* prefix (`POST {basePath}/batch` vs `{basePath}/data/...`, see
* rest-server `registerBatchEndpoints`), so the path is derived by
* dropping the last segment of the resolved data route.
*/
batchTransaction: async (
operations: CrossObjectBatchOperation[],
): Promise<CrossObjectBatchResponse> => {
const dataRoute = this.getRoute('data');
const base = dataRoute.replace(/\/[^/]+\/?$/, '') || '/api/v1';
const request: CrossObjectBatchRequest = { operations, atomic: true };
const res = await this.fetch(`${this.baseUrl}${base}/batch`, {
method: 'POST',
body: JSON.stringify(request),
});
return this.unwrapResponse<CrossObjectBatchResponse>(res);
},

/**
* Update multiple records (simplified batch update)
* Convenience method for batch updates without full BatchUpdateRequest
Expand Down Expand Up @@ -3469,6 +3515,21 @@ export class ScopedProjectClient {
});
return this.parent._unwrap<BatchUpdateResponse>(res);
},
/**
* Atomic cross-object batch, environment-scoped
* (`POST /api/v1/environments/:id/batch`).
* See {@link ObjectStackClient} `data.batchTransaction` for semantics.
*/
batchTransaction: async (
operations: CrossObjectBatchOperation[],
): Promise<CrossObjectBatchResponse> => {
const request: CrossObjectBatchRequest = { operations, atomic: true };
const res = await this.parent._fetch(this.url('/batch'), {
method: 'POST',
body: JSON.stringify(request),
});
return this.parent._unwrap<CrossObjectBatchResponse>(res);
},
updateMany: async <T = any>(
object: string,
records: Array<{ id: string; data: Partial<T> }>,
Expand Down Expand Up @@ -3631,6 +3692,9 @@ export type {
ListImportJobsRequest,
ListImportJobsResponse,
UndoImportJobResponse,
CrossObjectBatchOperation,
CrossObjectBatchRequest,
CrossObjectBatchResponse,
} from '@objectstack/spec/api';

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