Skip to content

Commit 5f9a987

Browse files
baozhoutaoclaudeos-zhuang
authored
fix(approvals,rest): tell the truth about approval writes — lock policy, dropped fields, and the batch create ingress (#3794, #3835) (#3834)
* fix(approvals,rest): surface the node's lock policy and the batch's dropped fields (#3794) An approval flow reported record writability wrong in both directions: what the user could change said "locked", and what they couldn't said "updated successfully". Both halves were missing signal, not wrong behaviour — the server did the right thing and told nobody. `rowFromRequest` now emits `locks_record`, read from the same `node_config_json` snapshot the `beforeUpdate` lock hook reads, with the same default-true. A client could previously only see "a request is pending" and had to guess whether that locked the record; the Console guessed "locked" every time, which hides the whole point of a `lockRecord: false` node. `POST /batch` (cross-object transactional batch) never wired `onFieldsDropped`, so the one write path the Console's master-detail record form takes was also the one path with no write-observability — a `readonlyWhen` strip there was completely silent. It now collects per-op events and returns them as a top-level `droppedFields` list tagged with each operation's index; omitted when empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(spec): regenerate the batch reference for CrossObjectBatchDroppedFields (#3794) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(spec): refresh the API-surface snapshot for CrossObjectBatchDroppedFields (#3794) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(rest): route batch creates through the create ingress so `readonly` is enforced (#3835) `POST /batch` called `ql.insert` directly, and the engine's INSERT path is static-`readonly`-exempt by design (#3413) — the strip that stops a non-system caller from seeding a read-only column lives at the protocol's create ingress (#3043). So the same forged value was dropped on `POST /data/:object` and written through on `/batch`: one rule, two answers. Create ops now go through `p.createData`, the ingress itself, rather than a second copy of the strip at the REST layer. One ingress means a future change to its policy covers the batch for free, and the carve-outs it already encodes stay intact — the platform-object exemption (a `sys_`/`managedBy` object's own guard must REJECT a forged value, not silently swallow it) and the `isSystem` exemption. `trxCtx` is passed as the context, so the insert still joins the batch transaction and `$ref` resolution is unaffected; the ingress's `droppedFields` fold into the batch's per-op list. Update ops are untouched — the engine enforces both strips on its update path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent b1614d6 commit 5f9a987

8 files changed

Lines changed: 279 additions & 7 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
fix(rest): a batch create goes through the same create ingress as a single create (#3835)
6+
7+
`readonly` meant two different things depending on which create endpoint you
8+
used. `POST /data/:object` runs the #3043 ingress strip, so a non-system caller
9+
cannot seed a read-only column — the field is dropped and reported. The
10+
cross-object transactional batch (`POST /batch`) called `ql.insert` directly and
11+
skipped that ingress entirely, and the engine's INSERT path is
12+
static-`readonly`-exempt **by design** (#3413, the strip lives one layer up), so
13+
nothing enforced it: the same forged `readonly` value that was rejected on one
14+
route was written through on the other.
15+
16+
Measured on the showcase (`showcase_contact.lead_score`, `readonly: true`, same
17+
signed-in non-system user):
18+
19+
| | before | after |
20+
|---|---|---|
21+
| `POST /data/showcase_contact` | `lead_score = null`, reported | unchanged |
22+
| `POST /batch` create | **`lead_score = 999` written, silent** | `null`, reported |
23+
24+
The fix routes the batch's create ops through the protocol's `createData` rather
25+
than re-implementing the strip at the REST layer. That keeps **one** create
26+
ingress: a future change to its policy covers the batch for free, and the
27+
carve-outs already encoded there stay intact — notably the platform-object
28+
exemption (a `sys_`/`managedBy` object's own guard must *reject* a forged value
29+
with 403, not have it silently swallowed) and the `isSystem` exemption. The
30+
context passed through is the transaction context, so the insert still joins the
31+
batch transaction and rolls back with it, and `{ $ref: <opIndex> }` resolution is
32+
unaffected.
33+
34+
`createData`'s `droppedFields` are folded into the batch response's per-op
35+
`droppedFields` list (#3794), so a batch create now reports its strips the same
36+
way an update does.
37+
38+
Update ops are untouched: the engine enforces `readonly` and `readonlyWhen` on
39+
its own update path.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": minor
4+
---
5+
6+
fix(rest): report `droppedFields` from the cross-object batch, so a silent strip stops reading as a clean save (#3794)
7+
8+
The engine strips writes to `readonly` (#2948) and `readonlyWhen`-locked (#3042)
9+
fields and completes the write without them. Every write path already reported
10+
which fields it dropped (#3431/#3455) — except the cross-object transactional
11+
batch, which never wired `onFieldsDropped` at all.
12+
13+
That path is the Console record form's save for a master-detail record, so it is
14+
exactly where a *user* edits a `readonlyWhen` field: they changed it, the form
15+
said "updated successfully", the value never moved, and nothing anywhere said
16+
so.
17+
18+
`POST /batch` responses now carry a top-level `droppedFields` list, each event
19+
tagged with the `index` of the operation that produced it (`results` entries are
20+
bare record echoes, with no envelope to hang a per-row list on). Omitted
21+
entirely when nothing was dropped, so the shape stays backward-compatible; the
22+
batch still commits either way — a strip is legal semantics, not an error.
23+
24+
The Console half ships in objectui: the write-warning toast now fires on batch
25+
saves too.

content/docs/references/api/batch.mdx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ Industry alignment: Salesforce Bulk API, Microsoft Dynamics Bulk Operations
3030
## TypeScript Usage
3131

3232
```typescript
33-
import { BatchConfig, BatchOperationResult, BatchOperationType, BatchOptions, BatchRecord, BatchUpdateRequest, BatchUpdateResponse, CrossObjectBatchOperation, CrossObjectBatchRequest, CrossObjectBatchResponse, DeleteManyRequest, UpdateManyRequest } from '@objectstack/spec/api';
34-
import type { BatchConfig, BatchOperationResult, BatchOperationType, BatchOptions, BatchRecord, BatchUpdateRequest, BatchUpdateResponse, CrossObjectBatchOperation, CrossObjectBatchRequest, CrossObjectBatchResponse, DeleteManyRequest, UpdateManyRequest } from '@objectstack/spec/api';
33+
import { BatchConfig, BatchOperationResult, BatchOperationType, BatchOptions, BatchRecord, BatchUpdateRequest, BatchUpdateResponse, CrossObjectBatchDroppedFields, CrossObjectBatchOperation, CrossObjectBatchRequest, CrossObjectBatchResponse, DeleteManyRequest, UpdateManyRequest } from '@objectstack/spec/api';
34+
import type { BatchConfig, BatchOperationResult, BatchOperationType, BatchOptions, BatchRecord, BatchUpdateRequest, BatchUpdateResponse, CrossObjectBatchDroppedFields, CrossObjectBatchOperation, CrossObjectBatchRequest, CrossObjectBatchResponse, DeleteManyRequest, UpdateManyRequest } from '@objectstack/spec/api';
3535

3636
// Validate data
3737
const result = BatchConfig.parse(data);
@@ -136,6 +136,22 @@ const result = BatchConfig.parse(data);
136136
| **results** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; details?: any; … }[]; data?: Record<string, any>; … }[]` || Detailed results for each record |
137137

138138

139+
---
140+
141+
## CrossObjectBatchDroppedFields
142+
143+
A cross-object batch strip event: dropped fields plus the operation index
144+
145+
### Properties
146+
147+
| Property | Type | Required | Description |
148+
| :--- | :--- | :--- | :--- |
149+
| **object** | `string` || Object the write targeted (resolved object name) |
150+
| **fields** | `string[]` || Caller-supplied field names the engine removed from the write payload |
151+
| **reason** | `Enum<'readonly' \| 'readonly_when'>` || Why the fields were dropped: static readonly (#2948) or a TRUE readonlyWhen predicate (#3042) |
152+
| **index** | `integer` || Index of the operation in the request `operations` array |
153+
154+
139155
---
140156

141157
## CrossObjectBatchOperation
@@ -171,6 +187,7 @@ const result = BatchConfig.parse(data);
171187
| Property | Type | Required | Description |
172188
| :--- | :--- | :--- | :--- |
173189
| **results** | `any[]` || Per-operation result, index-aligned with the request operations |
190+
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'>; index: integer }[]` | optional | Write-observability (#3407/#3431/#3455/#3794): caller-supplied fields the engine LEGALLY stripped from an operation before it was written — static `readonly` (#2948) or a TRUE `readonlyWhen` predicate (#3042). This endpoint is the console record form's save path (master-detail writes parent + children in one transaction), so without it the ONE surface where a user edits a `readonlyWhen` field reported plain success while the value never landed. Each event carries the `index` of its operation. Present ONLY when ≥1 field was dropped; the batch still committed without them (results/success semantics unchanged). Optional — omit-when-empty keeps the shape backward-compatible. |
174191

175192

176193
---

packages/rest/src/rest-batch-endpoint.test.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,42 @@ function makeQl(overrides: Partial<Record<'insert' | 'update' | 'delete', any>>
4242
return ql;
4343
}
4444

45+
/**
46+
* A fake `createData` standing in for the protocol's create ingress (#3835):
47+
* the batch's create ops go through it rather than calling `ql.insert`, so the
48+
* #3043 static-`readonly` strip applies to a batch create exactly as it does to
49+
* `POST /data/:object`. Delegates to the same `ql.insert` the other ops use, so
50+
* assertions about what reached the engine still read off `ql.insert`.
51+
*/
52+
function makeCreateData(ql: any, opts: { readonlyFields?: string[] } = {}) {
53+
const readonlyFields = opts.readonlyFields ?? [];
54+
return vi.fn(async (request: any) => {
55+
const supplied = request?.data ?? {};
56+
const stripped: string[] = readonlyFields.filter((f) => f in supplied);
57+
const data = { ...supplied };
58+
for (const f of stripped) delete data[f];
59+
const record = await ql.insert(request.object, data, { context: request.context });
60+
return {
61+
object: request.object,
62+
id: record?.id,
63+
record,
64+
...(stripped.length > 0
65+
? { droppedFields: [{ object: request.object, fields: stripped, reason: 'readonly' }] }
66+
: {}),
67+
};
68+
});
69+
}
70+
4571
/** Build a RestServer with an optional ObjectQL provider and object metadata. */
46-
function buildServer(opts: { ql?: any; objects?: any[] } = {}) {
72+
function buildServer(opts: { ql?: any; objects?: any[]; readonlyFields?: string[] } = {}) {
4773
const server = mockServer();
4874
const protocol: any = {
4975
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', endpoints: {} }),
5076
getMetaTypes: vi.fn().mockResolvedValue([]),
5177
getMetaItems: vi.fn().mockResolvedValue(opts.objects ?? []),
78+
createData: opts.ql
79+
? makeCreateData(opts.ql, { readonlyFields: opts.readonlyFields ?? [] })
80+
: vi.fn(),
5281
};
5382
const objectQLProvider = opts.ql ? async () => opts.ql : undefined;
5483
const rest = new RestServer(
@@ -219,6 +248,103 @@ describe('POST {basePath}/batch — cross-object transactional batch', () => {
219248
expect(ql.transaction).not.toHaveBeenCalled();
220249
});
221250

251+
// ── write observability (#3794) ───────────────────────────────────────────
252+
//
253+
// The console's record form saves a master-detail record through THIS route,
254+
// so a `readonlyWhen`-locked field edited in that form was stripped by the
255+
// engine and the response said nothing — the user saw "updated successfully"
256+
// over a value that never changed. Every other write path already reports its
257+
// strips (#3431/#3455); this one now does too.
258+
259+
it('surfaces engine-stripped fields as droppedFields tagged with the op index', async () => {
260+
const ql = makeQl({
261+
update: vi.fn(async (_object: string, data: any, opts: any) => {
262+
opts?.onFieldsDropped?.({ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when' });
263+
const { tax_rate: _stripped, ...kept } = data;
264+
return kept;
265+
}),
266+
});
267+
const { route } = buildServer({ ql });
268+
const res = await post(route, {
269+
operations: [
270+
{ object: 'account', action: 'create', data: { name: 'Acme' } },
271+
{ object: 'invoice', action: 'update', id: 'inv_1', data: { status: 'paid', tax_rate: 9 } },
272+
],
273+
});
274+
expect(res.statusCode).toBe(200);
275+
// The batch still committed — a strip is legal semantics, not an error.
276+
expect(res.body.results).toHaveLength(2);
277+
expect(res.body.droppedFields).toEqual([
278+
{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when', index: 1 },
279+
]);
280+
});
281+
282+
it('omits droppedFields entirely when nothing was stripped', async () => {
283+
const ql = makeQl();
284+
const { route } = buildServer({ ql });
285+
const res = await post(route, { operations: [{ object: 'account', data: { name: 'Acme' } }] });
286+
expect(res.statusCode).toBe(200);
287+
expect(res.body).not.toHaveProperty('droppedFields');
288+
});
289+
290+
// ── create ingress parity (#3835) ─────────────────────────────────────────
291+
//
292+
// The engine's INSERT path is static-`readonly`-exempt by design (#3413), so
293+
// the #3043 strip that stops a non-system caller from seeding a read-only
294+
// column lives at the protocol's create ingress. This route used to call
295+
// `ql.insert` directly and skip it, so `readonly` meant two different things
296+
// depending on which create endpoint you used.
297+
298+
it('routes create ops through the protocol create ingress, not ql.insert', async () => {
299+
const ql = makeQl();
300+
const { route, protocol } = buildServer({ ql });
301+
const res = await post(route, {
302+
operations: [{ object: 'account', action: 'create', data: { name: 'Acme' } }],
303+
});
304+
expect(res.statusCode).toBe(200);
305+
expect(protocol.createData).toHaveBeenCalledTimes(1);
306+
const request = protocol.createData.mock.calls[0][0];
307+
expect(request).toMatchObject({ object: 'account', data: { name: 'Acme' } });
308+
// The ingress must run INSIDE this transaction — same context the other ops
309+
// bind, or the create would commit independently of the rollback.
310+
expect(request.context).toMatchObject({ __trx: true });
311+
});
312+
313+
it('strips a caller-forged readonly column on a batch create and reports it', async () => {
314+
const ql = makeQl();
315+
const { route } = buildServer({ ql, readonlyFields: ['approval_status'] });
316+
const res = await post(route, {
317+
operations: [
318+
{ object: 'account', action: 'create', data: { name: 'Acme', approval_status: 'approved' } },
319+
],
320+
});
321+
expect(res.statusCode).toBe(200);
322+
// Never reached the engine…
323+
expect(ql.insert).toHaveBeenCalledWith('account', { name: 'Acme' }, expect.anything());
324+
// …and the caller is told, tagged with the op that dropped it.
325+
expect(res.body.droppedFields).toEqual([
326+
{ object: 'account', fields: ['approval_status'], reason: 'readonly', index: 0 },
327+
]);
328+
});
329+
330+
it('keeps $ref resolution working through the ingress', async () => {
331+
// `results` now holds the ingress's echoed record rather than the raw
332+
// engine return — a later op must still resolve `{ $ref: 0 }` to its id.
333+
const ql = makeQl();
334+
const { route } = buildServer({ ql });
335+
const res = await post(route, {
336+
operations: [
337+
{ object: 'project', action: 'create', data: { name: 'Apollo' } },
338+
{ object: 'task', action: 'create', data: { title: 'Kickoff', project: { $ref: 0 } } },
339+
],
340+
});
341+
expect(res.statusCode).toBe(200);
342+
expect(res.body.results[0].id).toBe('id_1');
343+
expect(ql.insert).toHaveBeenLastCalledWith(
344+
'task', { title: 'Kickoff', project: 'id_1' }, expect.anything(),
345+
);
346+
});
347+
222348
// ── request validation ────────────────────────────────────────────────────
223349

224350
it('rejects a non-atomic request (this endpoint is always atomic)', async () => {

packages/rest/src/rest-server.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6670,23 +6670,61 @@ export class RestServer {
66706670
return result;
66716671
};
66726672

6673+
// [#3794] Write-observability on THIS surface too. The engine
6674+
// strips `readonly` / `readonlyWhen` writes silently, and every
6675+
// other write path already reports what it dropped (#3431/#3455)
6676+
// — but this one did not, and it is precisely the path the
6677+
// console's record form takes for a master-detail save. Result:
6678+
// a user edited a `readonlyWhen`-locked field, got "updated
6679+
// successfully", and the value never changed with nothing said
6680+
// (#3794 problem 2). Each event is tagged with its operation
6681+
// index, since `results` entries are bare record echoes with no
6682+
// envelope to hang a per-row list on.
6683+
const dropped: Array<DroppedFieldsEvent & { index: number }> = [];
66736684
const results = await ql.transaction(async (trxCtx: any) => {
66746685
const out: any[] = [];
6675-
for (const op of ops) {
6686+
for (const [index, op] of ops.entries()) {
66766687
const data = resolveRefs(op.data, out);
66776688
if (op.action === 'create') {
6678-
out.push(await ql.insert(op.object, data, { context: trxCtx }));
6689+
// [#3835] Go through the protocol's create ingress —
6690+
// the SAME one `POST /data/:object` uses — rather than
6691+
// calling `ql.insert` directly. The engine's INSERT path
6692+
// is static-`readonly`-exempt by design (#3413), so the
6693+
// #3043 strip that stops a non-system caller from seeding
6694+
// a read-only column lives at that ingress. Bypassing it
6695+
// here made `readonly` mean two different things on two
6696+
// create paths: rejected on the single route, written
6697+
// through the batch. `createData` also owns the platform-
6698+
// object carve-out (a `sys_`/`managedBy` object's own
6699+
// guard must REJECT a forged value, not silently swallow
6700+
// it), which is why this routes to the ingress instead of
6701+
// re-implementing the strip here — one create ingress,
6702+
// and a future change to its policy covers the batch for
6703+
// free. `trxCtx` carries the caller's context (including
6704+
// `isSystem`) plus the open transaction, so the strip
6705+
// decides exactly as it does on the single route and the
6706+
// insert still joins this transaction.
6707+
const created: any = await p.createData({ object: op.object, data, context: trxCtx } as any);
6708+
for (const e of (created?.droppedFields ?? []) as DroppedFieldsEvent[]) {
6709+
dropped.push({ ...e, index });
6710+
}
6711+
out.push(created?.record);
66796712
} else if (op.action === 'update') {
6713+
// Update needs no ingress detour: the engine enforces
6714+
// both static `readonly` (#2948) and `readonlyWhen`
6715+
// (#3042) on its own update path, and reports them
6716+
// through this listener.
6717+
const onFieldsDropped = (e: DroppedFieldsEvent) => { dropped.push({ ...e, index }); };
66806718
const id = op.id ?? data?.id;
6681-
out.push(await ql.update(op.object, { ...data, id }, { context: trxCtx }));
6719+
out.push(await ql.update(op.object, { ...data, id }, { context: trxCtx, onFieldsDropped }));
66826720
} else { // 'delete'
66836721
out.push(await ql.delete(op.object, { where: { id: op.id }, context: trxCtx }));
66846722
}
66856723
}
66866724
return out;
66876725
}, context);
66886726

6689-
res.json({ results });
6727+
res.json({ results, ...(dropped.length > 0 ? { droppedFields: dropped } : {}) });
66906728
} catch (error: any) {
66916729
// Log only genuine server faults; client 4xx (validation,
66926730
// unresolved ref, atomic rollback of a bad op) are expected.

packages/spec/api-surface.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2388,6 +2388,8 @@
23882388
"CreateViewRequestSchema (const)",
23892389
"CreateViewResponse (type)",
23902390
"CreateViewResponseSchema (const)",
2391+
"CrossObjectBatchDroppedFields (type)",
2392+
"CrossObjectBatchDroppedFieldsSchema (const)",
23912393
"CrossObjectBatchOperation (type)",
23922394
"CrossObjectBatchOperationSchema (const)",
23932395
"CrossObjectBatchRequest (type)",

packages/spec/json-schema.manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@
160160
"api/CreateRequest",
161161
"api/CreateViewRequest",
162162
"api/CreateViewResponse",
163+
"api/CrossObjectBatchDroppedFields",
163164
"api/CrossObjectBatchOperation",
164165
"api/CrossObjectBatchRequest",
165166
"api/CrossObjectBatchResponse",

0 commit comments

Comments
 (0)