diff --git a/.changeset/client-batch-transaction-sdk.md b/.changeset/client-batch-transaction-sdk.md new file mode 100644 index 0000000000..f505086309 --- /dev/null +++ b/.changeset/client-batch-transaction-sdk.md @@ -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: }` 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. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 7bcd2d2d59..e503559925 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -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: }` 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 diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 999ed1579a..8fce5ef30c 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -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": }` resolves to the id created by an earlier diff --git a/docs/adr/0034-transactional-writes-and-ambient-transaction.md b/docs/adr/0034-transactional-writes-and-ambient-transaction.md index f851defa40..992ed899f7 100644 --- a/docs/adr/0034-transactional-writes-and-ambient-transaction.md +++ b/docs/adr/0034-transactional-writes-and-ambient-transaction.md @@ -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 diff --git a/packages/client/src/client.batch-transaction.test.ts b/packages/client/src/client.batch-transaction.test.ts new file mode 100644 index 0000000000..cddc0b1f2b --- /dev/null +++ b/packages/client/src/client.batch-transaction.test.ts @@ -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'); + 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('http.server'); + baseUrl = `http://localhost:${httpServer.getPort()}`; + client = new ObjectStackClient({ baseUrl }); + }, 30_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([ + kernel.shutdown(), + new Promise((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('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'); + }); +}); diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index dfbd9c95b4..4661ad9fa4 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -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); + }); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ad4b1eb63d..b33002cd0b 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -78,6 +78,9 @@ import { ListImportJobsRequest, ListImportJobsResponse, UndoImportJobResponse, + CrossObjectBatchOperation, + CrossObjectBatchRequest, + CrossObjectBatchResponse, } from '@objectstack/spec/api'; import type { ApprovalRequestRow, @@ -3018,6 +3021,49 @@ export class ObjectStackClient { return this.unwrapResponse(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: }` 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 => { + 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(res); + }, + /** * Update multiple records (simplified batch update) * Convenience method for batch updates without full BatchUpdateRequest @@ -3469,6 +3515,21 @@ export class ScopedProjectClient { }); return this.parent._unwrap(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 => { + 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(res); + }, updateMany: async ( object: string, records: Array<{ id: string; data: Partial }>, @@ -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