Skip to content

Commit ea90179

Browse files
os-zhuangclaude
andauthored
fix(data,runtime,drivers): REST 信封与 $search 字段集 (#4431, #4435, #4436, #4483) (#4496)
* fix(spec): the $search auto field set's lead ORDERS the set, it must not admit one (#4483) `autoDefaultFields` filtered every field through three exclusions (`SEARCH_AUTO_EXCLUDED_FIELDS`, `hidden`, unsearchable type) and then prepended the display/name/title field on an EXISTENCE check alone — so the exclusions did not hold for whichever field happened to lead, and the module's own "system / audit / heavy fields never auto-included" invariant was false. Not a contrived shape: ADR-0079's `provisionPrimary(schema, { synthesize: false })` designates `nameField` at registration, and on a table whose only textual column IS the primary key (system tables, junction tables, append-only logs) it designates `id`. `$search` then expanded to `{ id: { $contains: term } }` — a substring scan over the primary key, returning a narrow and semantically wrong row set. It loosened a second layer too: `resolveSearchFieldResolution` is also the #4254 REST ingress gate's arbiter for "would the engine actually scan this field", so with `id` in `allowed` a `$searchFields=id` override was ACCEPTED rather than refused. The lead's job is to put the primary title FIRST, never to admit it, so it is now chosen from the already-filtered set. An excluded / hidden / unsearchable display field simply does not lead and the set is unchanged; an eligible one still leads, so the ordering intent is intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * wip(drivers): give the uncompilable-filter refusal an ADR-0112 code and drop the driver prefix (#4436) IN PROGRESS — code change complete, regression test not yet written and the real-boot curl repro not yet run. A filter carrying an operator the driver cannot compile is already REFUSED rather than silently matched (#4209/#4029), but the refusal had no wire identity: the thrown `Error` carried no `code`, so `mapDataError`'s default branch served `{"error": "[sql-driver] Unsupported filter operator …"}` — no `error.code` at all, breaking the ADR-0112 contract every sibling rejection on the same route already honours (`INVALID_FIELD`, `INVALID_FILTER`, `RECORD_NOT_FOUND`), and leaking the `[sql-driver]` internal prefix that the #3867 sanitiser exists to keep off the wire. Both drivers now throw through a local `unsupportedFilterError` that stamps `code = StandardErrorCode.enum.INVALID_FILTER` (the same catalogued code `metadata-protocol` emits when a filter fails to parse upstream — one condition, one wire code however the caller reached it) and `status = 400`, which also puts the rejection on `isExpectedQueryRejection` so a client mistake stops being logged as an unhandled server error. The internal prefix is gone from the message; the actionable operator/field/vocabulary detail stays. Applied to every filter-COMPILATION refusal in both backends, not just the one branch the issue names — they are the same envelope defect on adjacent lines, and #3948 made the two drivers agree that an uncompilable filter is a refusal, so their refusal envelopes have to agree too. TODO: regression tests (driver-sql, driver-memory, REST envelope) + boot repro. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(drivers): the uncompilable-filter refusal speaks INVALID_FILTER, without the driver prefix (#4436) Completes the WIP commit: adds the remaining sql-driver throw sites and the regression tests for both backends. #4209/#4029/#3948 settled the POSTURE — a filter carrying an operator the driver cannot compile is refused instead of silently matching every row. What was missing is the refusal's IDENTITY on the wire. The driver threw a bare `Error`, so `mapDataError` fell through to its default branch and served a body whose only key was `error`: GET /api/v1/data/showcase_task?filter={"title":{"$bogusop":"x"}} → 400 {"error":"[sql-driver] Unsupported filter operator \"$bogusop\" …"} Two contract breaks in one body — no `error.code` at all on a route whose sibling rejections all speak the ADR-0112 catalogue, and the driver-internal `[sql-driver]` prefix on the wire, which is what the #3867 sanitiser exists to stop. Fixed at the throw site (PD #12), not by teaching the REST layer to guess: both drivers now refuse through an `unsupportedFilterError` helper that stamps `code = StandardErrorCode.enum.INVALID_FILTER` — the constant, so a catalogue rename breaks the compile — and `status = 400`. `INVALID_FILTER` is the same code `metadata-protocol` already emits when a filter fails to parse upstream (`malformedFilterArrayError` / `unusableFilterError`): one condition, one wire code, however the caller reached it. The `status` also puts the rejection on `isExpectedQueryRejection`, so a client mistake stops being logged as an unhandled server error. Applied to every filter-COMPILATION refusal in both backends, not only the one branch the issue names: unsupported operator ($-object, legacy triple), unrecognised logical keyword, unrecognised element type, and a `between` / `$between` operand that is not a two-element array. They are the same envelope defect on adjacent lines, and #3948 made the two drivers agree that an uncompilable filter is a refusal — so their refusal envelopes have to agree too, or the cross-driver parity this repo relies on is false where it matters. Tests: new `sql-driver-filter-refusal-envelope.test.ts` (8) and `memory-filter-refusal-envelope.test.ts` (5) pin `code`, `status`, the absence of the internal prefix, and that the actionable operator/field/vocabulary detail survives. Full suites green: driver-sql 623 passed, driver-memory 286 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(metadata-protocol): PATCH/DELETE of a nonexistent record answer RECORD_NOT_FOUND, not 200 (#4435) The READ path was already honest — `getData` on an unknown id answers `404 RECORD_NOT_FOUND`. Both single-record WRITE paths reported success for a record that does not exist: PATCH /data/showcase_task/definitely_not_a_row → 200 {"record":null} DELETE /data/showcase_task/definitely_not_a_row → 200 {"success":true} REST is a pass-through here (`res.json(await p.deleteData(...))`), so these are the protocol's answers and this is where they are fixed. What it cost: a client that PATCHed a concurrently deleted record was told the write landed, and had to null-check a SUCCESS payload to find out otherwise; `DELETE` said `success: true` for any string in the path, so a typo'd id, an already-deleted row and a real deletion were indistinguishable — including in bulk, where `deleteMany {"ids":["nonexistent_1"]}` answered `succeeded: 1`. It is the same silent-no-op shape the v17 train removed everywhere else this window (#4240/#4303/#4315, #4169, #4190), one level up. - `updateData` asks existence BEFORE the write, via the same `findOne` + caller context `getData` uses. Deliberately not a post-check on the returned row: the engine returns the post-write READBACK, which is also `null` when the row still exists but the write moved it out of the caller's row scope (reassigning `owner_id` away from yourself under an owner-scoped policy) — reading that as "not found" would 404 a write that succeeded. - `deleteData` and `deleteManyData` read the driver's own answer. The contract (`IDataDriver.delete` — "True if deleted, false if not found") already carried it; the code discarded it and pushed a literal `success: true`. Read as `=== false` on purpose: that is the contract's positive not-found value, while a driver returning the deleted row or an off-contract `undefined` gives no such signal, and inventing a 404 from a falsy return would break deletes against third-party drivers instead of reporting honestly. `success` on the 200 now means what it says. - The 404 envelope is extracted as `recordNotFoundError` so the read and the two write paths cannot drift apart again. Note on the issue's second half: the spec's `DeleteDataResponseSchema` declares `success`, not `deleted`, so the existing key is correct as-is and nothing renames. Tests: new `protocol.record-not-found.test.ts` (12) covers PATCH/DELETE/ deleteMany, the read/write agreement on the same id, delete-twice, mixed batches, the `=== false` reading, and that the existence probe is asked with the caller's context. Three `protocol.dropped-fields.test.ts` fixtures stubbed `findOne → null` while PATCHing — under the new contract that IS a 404, so they now describe an engine that has the row (they are about the strip channel, not about missing records). Suites green: metadata-protocol 169, rest + objectql unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * fix(runtime): a sandbox capability denial is a 500 crash, not a 400 rejection (#4431) The `action-crash-vs-rejection` contract (#3951) pins the table: a `SandboxError` WITH `innerMessage` is a body's deliberate throw → 400; a `SandboxError` with NO `innerMessage` — timeout, capability denial — is a crash → 500. Capability denials were answering 400: POST /api/v1/actions/showcase_task/rc1_crash_probe → 400 {"error":{"code":"VALIDATION_ERROR", "message":"SandboxError: capability 'api.read' not granted to action …"}} Why: the gate throws `SandboxError` synchronously INSIDE a QuickJS host function, which rejects the async IIFE inside the VM, so it returns through the `__error` side-channel — and the pump loop presumed everything arriving there was user code throwing on purpose, setting `innerMessage` unconditionally. The dispatcher's classifier then read that as a deliberate rejection. So every capability denial stayed invisible to gateway error rates, APM and alerting — exactly the blindness #3951 was written to close — and the client also received the `SandboxError: ` debug prefix that belongs only in server logs. `SandboxError`'s own jsdoc already said `innerMessage` is undefined for the sandbox's internal errors; that only held for denials detected OUTSIDE evaluation (a timeout, which takes the separate `budgetError` path). In-VM host-call denials — `ctx.api.*`, `ctx.log`, `ctx.crypto`, `ctx.api.transaction` — were misclassified. Fix: the sandbox's own faults now carry a marker THROUGH the VM. `hostErrorToVm` stamps `__objectstackSandboxFault` on any `SandboxError` it marshals, and the synchronous gates throw the VM handle it builds rather than a raw host error — quickjs-emscripten passes a thrown handle through verbatim while its `newError` path copies only `name`/`message`, which is precisely how the identity was lost. The reject handler reports the marker on the additive `__errorInfo` channel, and the pump loop, seeing it, rethrows with neither the `<kind> '<name>' threw:` wrapper (nothing threw — the sandbox refused) nor an `innerMessage`. The existing classifier then does the rest: name is `SandboxError`, no inner/code/fields ⇒ unexpected fault ⇒ `errorFromThrown(err, 500)`, and the message reaching the client is the capability text with the debug prefix stripped. A marker rather than a match on the flattened `SandboxError: …` text, because the flattening is user-reachable: a body that CATCHES the denial and throws its own business error must keep its 400, and that case is pinned. No ADR or contract was changed — this makes the runtime deliver the contract #3951 already specifies. Tests: new `sandbox/capability-denial-is-a-fault.test.ts` (7) covers all four in-VM gates, the absence of innerMessage/code/fields, the prefix, the caught-and-rethrown rejection, an ordinary deliberate throw, and that a record `ValidationError` crossing `ctx.api` keeps its `code`/`fields` (the marker must not turn every failed write into a 500). Verified failing on all four denial cases before the fix. Runtime suite green: 73 files / 1033 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD * chore: add changeset for the v17 REST envelope defects (#4431, #4435, #4436, #4483) * fix(test): call syncSchema with its real (object, schema) signature (#4436) The #4436 refusal-envelope test passed a single merged object where the driver takes the object name as its own first argument, so the suite could not type-check. Matches the idiom in the sibling memory-driver tests. * fix(metadata-protocol): one probe per PATCH, and the existence gate is not an RLS gate (#4435) Follow-up to 959b838, fixing two defects the first cut introduced. Both were caught by CI (`Test Core` on @objectstack/objectql, `Dogfood Regression Gate 1/2`), and the second is the more serious of the two. ## 1. The existence probe duplicated OCC's read `updateData` called `assertVersionMatch` (which reads the row for its `updated_at`) and then `assertRecordExists` (which reads the same row again). Two round-trips per PATCH — a performance regression no gate reports — and the `protocol-data.test.ts` OCC cases said so directly ("expected to be called once, but got 2 times"). The two gates want the same row, so they now share one read: `probeRecord` fetches it, `assertVersionOf` became a PURE comparison over an already-read row, and `assertVersionMatch` survives only for `deleteData`, which needs no existence probe at all — the driver's own return reports whether a row matched, so a plain DELETE stays at zero extra reads and only an OCC token buys one. ## 2. The probe must ask EXISTENCE, not the caller's visibility The first cut probed with the CALLER's context, reasoning that it should match `getData`. That quietly turned the existence gate into an authorization gate: a row the caller cannot read comes back `null`, so the PATCH answers 404. Two things break. It moves an RLS decision out of the write policy. Whether an unreadable row may be written by id is the #1994 pre-image check's call, made inside `engine.update`. A probe in front of it adds a second, different rule — scope creep into the security model, out of a bug fix about missing records. And it disarms a revert-provable security proof. `@proof: rls-by-id-write` (`qa/dogfood/test/rls-fixture.dogfood.test.ts`, referenced by the `permission.rowLevelSecurity.using` liveness ledger entry) boots a fixture whose member can read nothing and has no write policy, and asserts the runner reports `rls-hole` — the RED half that proves the gate can go red at all. A caller-scoped probe 404s that PATCH and the proof goes green: if #1994 were ever reverted, this probe would MASK it. Accidentally hardening one path is not worth permanently blinding the gate that watches the whole class. So the probe runs as system and answers existence only. Authorization stays exactly where it was, and the sole behaviour added is the 404 the issue asked for: an id that names no row at all. Tests: `protocol-data.test.ts`'s OCC block now asserts the new contract — one probe on every PATCH (the existence probe, no OCC comparison without a token), still exactly one when OCC IS requested (the anti-duplication pin), 404 before any OCC verdict for a missing id, and DELETE without a token issuing no probe. Its fixtures now supply a row, because under this contract a PATCH of an absent record is correctly a 404 and those cases are about OCC. Two cases added to `protocol.record-not-found.test.ts` pin the system-context probe and that an unreadable-but-existing row still reaches the engine for RLS to decide. Green: objectql protocol-data 117, metadata-protocol 170, dogfood shard 1/2 38 files / 235 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c57f3cf commit ea90179

13 files changed

Lines changed: 1178 additions & 74 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/metadata-protocol": minor
5+
"@objectstack/driver-sql": minor
6+
"@objectstack/driver-memory": minor
7+
---
8+
9+
fix(data,runtime,drivers): four ADR-0112 envelope defects found in the v17 verification sweep (#4431, #4435, #4436, #4483)
10+
11+
Four independent surfaces where the answer a caller received contradicted the
12+
contract the surface declares. All four were found driving a real showcase boot
13+
against `17.0.0-rc.1` and are catalogued in the #4482 rollup.
14+
15+
- **#4431 — a sandbox capability denial answered 400.** A denial is the sandbox
16+
refusing to run untrusted code that asked for a capability it does not hold,
17+
which is the crash contract's case (#3951), not a deliberate rejection of a
18+
malformed request. It now answers 500, and the `SandboxError:` debug prefix
19+
no longer reaches the client.
20+
21+
- **#4435 — PATCH/DELETE of a nonexistent record answered 200 success.** The
22+
write path returned `record: null` / `success: true` for an id that resolves
23+
to nothing, while GET on the same id correctly 404s; `deleteMany` reported
24+
every typo'd id as deleted. Both now answer `RECORD_NOT_FOUND`, so a caller
25+
can no longer read a successful envelope as proof the write landed.
26+
27+
- **#4436 — the unsupported-filter-operator refusal shipped without
28+
`error.code`.** A refusal with no code is unmatchable by a client, and the
29+
message leaked the internal `[sql-driver]` prefix. It now speaks
30+
`INVALID_FILTER` without the driver prefix.
31+
32+
- **#4483 — the `$search` auto field set admitted its lead field
33+
unconditionally.** `nameField`/`name`/`title` were prepended without passing
34+
`SEARCH_AUTO_EXCLUDED_FIELDS`, so a search could be aimed at the primary key.
35+
The lead field now only ORDERS the set it is already a member of; it can no
36+
longer admit one.
37+
38+
These change responses that were observably wrong, so callers coded against the
39+
buggy shapes — a 200 on a missing record, a 400 on a capability denial — will
40+
see different status codes. Graded `minor` on that basis rather than `patch`.

packages/metadata-protocol/src/protocol.dropped-fields.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ describe('updateData — forwards engine write strips as droppedFields (#3431)',
3232
options?.onFieldsDropped?.({ object, fields: ['approval_status'], reason: 'readonly' });
3333
return { id: 'rec-1', title: data.title };
3434
}),
35-
findOne: vi.fn(async () => null),
35+
// The row EXISTS — `updateData`'s #4435 existence probe reads this, and a
36+
// PATCH of an id that names no row is now a 404 rather than a 200 with a
37+
// null record. These fixtures are about the strip channel, not about
38+
// missing records, so they describe an engine that has the row.
39+
findOne: vi.fn(async () => ({ id: 'rec-1' })),
3640
};
3741
const p = new ObjectStackProtocolImplementation(engine as any);
3842
const res: any = await p.updateData({
@@ -56,7 +60,7 @@ describe('updateData — forwards engine write strips as droppedFields (#3431)',
5660
options?.onFieldsDropped?.({ object, fields: ['approval_status'], reason: 'readonly' });
5761
return { id: 'rec-1' };
5862
}),
59-
findOne: vi.fn(async () => null),
63+
findOne: vi.fn(async () => ({ id: 'rec-1' })),
6064
};
6165
const p = new ObjectStackProtocolImplementation(engine as any);
6266
const res: any = await p.updateData({ object: 'approval_case', id: 'rec-1', data: {} });
@@ -70,7 +74,7 @@ describe('updateData — forwards engine write strips as droppedFields (#3431)',
7074
const engine = {
7175
registry: { getObject: () => SCHEMA },
7276
update: vi.fn(async (_o: string, data: any) => ({ id: 'rec-1', ...data })),
73-
findOne: vi.fn(async () => null),
77+
findOne: vi.fn(async () => ({ id: 'rec-1' })),
7478
};
7579
const p = new ObjectStackProtocolImplementation(engine as any);
7680
const res: any = await p.updateData({ object: 'approval_case', id: 'rec-1', data: { title: 'B' } });
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#4435] A write that touched zero rows must not report success.
5+
*
6+
* The READ path has always been honest — `getData` on an unknown id answers
7+
* `404 RECORD_NOT_FOUND`. Both single-record WRITE paths disagreed:
8+
*
9+
* PATCH /data/showcase_task/definitely_not_a_row → 200 { record: null }
10+
* DELETE /data/showcase_task/definitely_not_a_row → 200 { success: true }
11+
*
12+
* The REST layer is a pass-through (`res.json(await p.deleteData(...))`), so
13+
* these are the protocol's answers, and this is where they are fixed.
14+
*
15+
* Why it matters beyond symmetry: a client PATCHing a record another session
16+
* just deleted was told the write landed, and had to null-check a SUCCESS
17+
* payload to find out otherwise. `DELETE` said `success: true` for any string in
18+
* the path, so a typo'd id, an already-deleted row and a real deletion were
19+
* indistinguishable — including on the bulk path, where a batch of typo'd ids
20+
* reported every one of them deleted.
21+
*/
22+
23+
import { describe, it, expect, vi } from 'vitest';
24+
import { ObjectStackProtocolImplementation } from './protocol.js';
25+
26+
const SCHEMA = { name: 'task', fields: { title: { name: 'title', type: 'text' } } };
27+
28+
/**
29+
* An engine whose `delete` honours the driver contract
30+
* (`IDataDriver.delete` — "True if deleted, false if not found") and whose
31+
* `findOne` answers from the same row set, so existence means one thing here.
32+
*/
33+
function makeProtocol(rows: Record<string, any> = {}) {
34+
const store = new Map<string, any>(Object.entries(rows));
35+
const findOne = vi.fn(async (_object: string, opts: any) => store.get(String(opts?.where?.id)) ?? null);
36+
const update = vi.fn(async (_object: string, data: any, opts: any) => {
37+
const id = String(opts?.where?.id);
38+
if (!store.has(id)) return null;
39+
const next = { ...store.get(id), ...data };
40+
store.set(id, next);
41+
return next;
42+
});
43+
const del = vi.fn(async (_object: string, opts: any) => store.delete(String(opts?.where?.id)));
44+
const engine = {
45+
registry: { getObject: (n: string) => (n === 'task' ? SCHEMA : undefined) },
46+
findOne, update, delete: del,
47+
};
48+
return { p: new ObjectStackProtocolImplementation(engine as any), findOne, update, del, store };
49+
}
50+
51+
/** Assert the thrown value is the 404 envelope `getData` already produced. */
52+
async function expectRecordNotFound(run: () => Promise<unknown>, id: string) {
53+
let caught: any;
54+
try {
55+
await run();
56+
} catch (e) {
57+
caught = e;
58+
}
59+
expect(caught, 'expected a RECORD_NOT_FOUND rejection, but the call resolved').toBeDefined();
60+
expect(caught.code).toBe('RECORD_NOT_FOUND');
61+
expect(caught.status).toBe(404);
62+
expect(caught.object).toBe('task');
63+
expect(caught.message).toContain(id);
64+
return caught;
65+
}
66+
67+
describe('[#4435] updateData refuses an id that names no row', () => {
68+
it('PATCH of a nonexistent id is 404 RECORD_NOT_FOUND, not 200 { record: null }', async () => {
69+
const { p, update } = makeProtocol({ real: { id: 'real', title: 'x' } });
70+
await expectRecordNotFound(
71+
() => p.updateData({ object: 'task', id: 'definitely_not_a_row', data: { title: 'y' } } as any),
72+
'definitely_not_a_row',
73+
);
74+
// …and the engine was never asked to write. A refused PATCH must not fire
75+
// hooks, automation or an audit row for a record that does not exist.
76+
expect(update).not.toHaveBeenCalled();
77+
});
78+
79+
it('the SAME id answers 404 on the read path — the two verbs agree now', async () => {
80+
const { p } = makeProtocol({ real: { id: 'real' } });
81+
await expectRecordNotFound(
82+
() => p.getData({ object: 'task', id: 'definitely_not_a_row' } as any),
83+
'definitely_not_a_row',
84+
);
85+
});
86+
87+
it('an existing record still updates and returns the row', async () => {
88+
const { p, update } = makeProtocol({ real: { id: 'real', title: 'x' } });
89+
const res: any = await p.updateData({ object: 'task', id: 'real', data: { title: 'y' } } as any);
90+
expect(update).toHaveBeenCalledTimes(1);
91+
expect(res).toMatchObject({ object: 'task', id: 'real', record: { title: 'y' } });
92+
});
93+
94+
it('the existence probe asks EXISTENCE, not the caller\'s visibility', async () => {
95+
// Load-bearing, and the first cut of this fix got it backwards. Probing
96+
// with the CALLER's context turns the existence gate into an authorization
97+
// gate: a row the caller cannot read comes back null and the PATCH 404s.
98+
// That would (a) move an RLS decision out of the write policy where #1994
99+
// put it, and (b) disarm `@proof: rls-by-id-write` — the dogfood fixture
100+
// whose RED half asserts that a member who cannot READ a row but has no
101+
// write policy still mutates it by id. A caller-scoped probe makes that
102+
// proof go green, so the gate could no longer prove it can go red, and a
103+
// future revert of #1994 would be masked by this probe.
104+
//
105+
// So the probe runs as system: "does this row exist", nothing more.
106+
// Authorization stays inside engine.update, exactly where it was.
107+
const { p, findOne } = makeProtocol({ real: { id: 'real' } });
108+
await p.updateData({ object: 'task', id: 'real', data: {}, context: { userId: 'u1' } } as any);
109+
expect(findOne.mock.calls[0][1]).toMatchObject({
110+
where: { id: 'real' },
111+
context: { isSystem: true },
112+
});
113+
});
114+
115+
it('a PATCH the caller may not see is still decided by RLS, not by the probe', async () => {
116+
// The row exists, so the probe passes it through to the engine — where the
117+
// write policy answers, as it always has. The probe must not pre-empt it.
118+
const { p, update } = makeProtocol({ hidden: { id: 'hidden' } });
119+
await p.updateData({ object: 'task', id: 'hidden', data: { title: 'x' }, context: { userId: 'nobody' } } as any);
120+
expect(update).toHaveBeenCalledOnce();
121+
});
122+
});
123+
124+
describe('[#4435] deleteData reports what actually happened', () => {
125+
it('DELETE of a nonexistent id is 404, not 200 { success: true }', async () => {
126+
const { p } = makeProtocol({ real: { id: 'real' } });
127+
await expectRecordNotFound(
128+
() => p.deleteData({ object: 'task', id: 'definitely_not_a_row' } as any),
129+
'definitely_not_a_row',
130+
);
131+
});
132+
133+
it('a real deletion still answers success, and is no longer indistinguishable', async () => {
134+
const { p, store } = makeProtocol({ real: { id: 'real' } });
135+
const res: any = await p.deleteData({ object: 'task', id: 'real' } as any);
136+
expect(res).toEqual({ object: 'task', id: 'real', success: true });
137+
expect(store.has('real')).toBe(false);
138+
});
139+
140+
it('deleting the same id twice: first 200, second 404', async () => {
141+
const { p } = makeProtocol({ real: { id: 'real' } });
142+
await p.deleteData({ object: 'task', id: 'real' } as any);
143+
await expectRecordNotFound(() => p.deleteData({ object: 'task', id: 'real' } as any), 'real');
144+
});
145+
146+
it('a driver return that is not the contract\'s `false` is NOT read as not-found', async () => {
147+
// `=== false` on purpose. A driver that returns the deleted row, or an
148+
// off-contract `undefined`, gives no POSITIVE not-found signal — inventing
149+
// a 404 from a falsy return would break deletes against third-party
150+
// drivers instead of reporting honestly.
151+
const engine = {
152+
registry: { getObject: () => SCHEMA },
153+
delete: vi.fn(async () => undefined),
154+
};
155+
const p = new ObjectStackProtocolImplementation(engine as any);
156+
await expect(p.deleteData({ object: 'task', id: 'whatever' } as any))
157+
.resolves.toMatchObject({ success: true });
158+
});
159+
});
160+
161+
describe('[#4435] deleteManyData reports per id, not per request', () => {
162+
it('a batch of typo\'d ids no longer reports every one of them deleted', async () => {
163+
const { p } = makeProtocol({ real: { id: 'real' } });
164+
const res: any = await p.deleteManyData({
165+
object: 'task',
166+
ids: ['nonexistent_1'],
167+
options: { continueOnError: true },
168+
} as any);
169+
170+
// Pre-#4435: { succeeded: 1, failed: 0, results: [{ success: true }] }.
171+
expect(res).toMatchObject({ success: false, total: 1, succeeded: 0, failed: 1 });
172+
expect(res.results[0]).toMatchObject({ id: 'nonexistent_1', success: false });
173+
expect(res.results[0].error).toContain('nonexistent_1');
174+
});
175+
176+
it('mixed ids are reported individually', async () => {
177+
const { p } = makeProtocol({ a: { id: 'a' }, c: { id: 'c' } });
178+
const res: any = await p.deleteManyData({
179+
object: 'task',
180+
ids: ['a', 'b', 'c'],
181+
options: { continueOnError: true },
182+
} as any);
183+
184+
expect(res).toMatchObject({ success: false, total: 3, succeeded: 2, failed: 1 });
185+
expect(res.results.map((r: any) => [r.id, r.success])).toEqual([
186+
['a', true], ['b', false], ['c', true],
187+
]);
188+
});
189+
190+
it('an all-real batch is unchanged', async () => {
191+
const { p } = makeProtocol({ a: { id: 'a' }, b: { id: 'b' } });
192+
const res: any = await p.deleteManyData({ object: 'task', ids: ['a', 'b'] } as any);
193+
expect(res).toMatchObject({ success: true, succeeded: 2, failed: 0 });
194+
});
195+
196+
it('a missing id stops the run without continueOnError, as a failure always has', async () => {
197+
const { p, del } = makeProtocol({ b: { id: 'b' } });
198+
const res: any = await p.deleteManyData({ object: 'task', ids: ['a', 'b'] } as any);
199+
expect(res).toMatchObject({ success: false, succeeded: 0, failed: 1 });
200+
expect(del).toHaveBeenCalledTimes(1);
201+
});
202+
});

0 commit comments

Comments
 (0)