Skip to content

Commit 7404925

Browse files
os-trumpclaude
andauthored
fix(spec): DeleteMetaItemResponseSchema declares seq and projectionApplied, matching its two sibling verbs (#13208)
* wip(spec): widen DeleteMetaItemResponseSchema with seq + projectionApplied * test(spec,objectql): pin the widened delete response on both sides * fix(spec): DeleteMetaItemResponseSchema declares seq and projectionApplied (#13155) * chore(spec): regenerate strictness ledger counts from the merged tree The `api/` bucket moved on both sides of the merge — main widened the analytics and automation route response schemas (+4), this branch widened DeleteMetaItemResponseSchema (+1). The counts file carries a `merge=os-regen` driver, so the merge left it un-text-merged; this is the regenerated value, produced by `pnpm --filter @objectstack/spec gen:strictness-ledger`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent afef7d8 commit 7404925

7 files changed

Lines changed: 519 additions & 4 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
Widen `DeleteMetaItemResponseSchema` to parity with the two sibling verbs on the same metadata door (#13155). `DELETE /api/v1/meta/:type/:name` now declares `seq` and `projectionApplied` — the two keys its own branch has always sent, and the two keys `SaveMetaItemResponseSchema` (#5745, by the #5563 maintainer ruling) and `PublishMetaItemResponseSchema` (#7294) already declare. All three verbs run the same ADR-0094 mutation projector and the same history append; two declared the result and the third did not, so this carries an existing ruling to its sibling rather than taking a new decision.
6+
7+
This is an accept-set widening with zero wire change — every payload that parsed before still parses, and the two keys the schema used to silently strip now survive a parse. `projectionApplied` is the channel a caller reads *instead of* trusting the 200 (the projector is best-effort: a failure is reported on the receipt, never thrown), and `seq` is the ordering token the history/audit trail is read by; undeclared, neither was reachable from a `DeleteMetaItemResponse` without an `as any`, and a generated client carried neither.
8+
9+
Both keys are `.optional()`, measured against the producer rather than copied from the siblings: `deleteMetaItem` has **four** success returns and only one appends a history event, so `seq` is required on the siblings but optional here — the two "nothing to delete" no-ops and the control-plane legacy raw-engine delete (which writes no history row and emits no watch event) all answer without it. `seq` absent therefore means "this branch appended no history event", never "nothing was deleted"; `reset` remains the key that answers that. `version` and `advisories` are deliberately *not* declared: a delete mints no new content hash, and the runtime authoring gate runs on the two write doors only.
10+
11+
Nothing in `@objectstack/client` changes — its `meta.deleteItem` binds this exported type rather than transcribing a member list, so it inherits the widening. Pinned on both sides: the spec suite pins what the schema says, and a new producer-side conformance gate in `@objectstack/objectql` drives the real protocol against a real engine and fails if the response and the declaration drift apart again.

content/docs/references/api/protocol.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,8 +688,17 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
688688
| :--- | :--- | :--- | :--- |
689689
| **success** | `boolean` || |
690690
| **reset** | `boolean` | optional | |
691+
| **seq** | `integer` | optional | Monotonic sequence number of the metadata event this delete appended to the item history (sys_metadata_history.event_seq) — the ordering token the history/audit trail is read by, and the same key both write-verb siblings declare. Unlike `version` it is not an OCC token. Optional HERE, unlike on those siblings, because only the repository path's delete-ful branch appends an event: a no-op reset (no overlay row existed) and the control-plane legacy raw-engine path both answer without one. Absence means "this branch appended no history event", never "nothing was deleted" — read `reset` for that. |
692+
| **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model. The same receipt `{@link SaveMetaItemResponseSchema}` and `{@link PublishMetaItemResponseSchema}` carry, because the projector runs on all three verbs of this door: on a delete it re-reads the layered state and either retires the derived record or resets it to the artifact baseline. Present ONLY when a projector is registered for this metadata type AND a row was actually deleted, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. |
691693
| **message** | `string` | optional | |
692694

695+
### Nested Shape: `DeleteMetaItemResponse.projectionApplied`
696+
697+
| Property | Type | Required | Description |
698+
| :--- | :--- | :--- | :--- |
699+
| **success** | `boolean` || False when the projector threw; the metadata delete itself still succeeded. |
700+
| **error** | `string` | optional | Projector failure message, present only when `success` is false. |
701+
693702

694703
---
695704

docs/audits/2026-07-unknown-key-strictness-ledger.counts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ directory rather than per file.
257257
| Dir | Sites |
258258
|---|---|
259259
| `ai/` | 77 |
260-
| `api/` | 448 |
260+
| `api/` | 449 |
261261
| `cloud/` | 83 |
262262
| `identity/` | 32 |
263263
| `integration/` | 10 |
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #13155 — conformance gate: the body `deleteMetaItem` really returns must
5+
* parse through `DeleteMetaItemResponseSchema` with NOTHING stripped.
6+
*
7+
* This is the producer side of the declaration. The spec-side suite
8+
* (`packages/spec/src/api/protocol.test.ts`) pins what the schema says; this
9+
* one pins that the schema still matches what the code emits, driving the REAL
10+
* protocol against a REAL ObjectQL engine. The two together are what makes
11+
* "declared = returned" checkable — a future field added to the response, or an
12+
* existing one dropped, turns this red instead of silently vanishing at parse.
13+
*
14+
* Why the REST layer needs no separate case: the route hands this exact object
15+
* to `res.json()` verbatim (`rest-server.ts`, `DELETE /meta/:type/:name`), so
16+
* the protocol return IS the wire body.
17+
*
18+
* The exact shape of the two sibling gates on the same door
19+
* (`save-meta-response-conformance.test.ts` #5745,
20+
* `publish-meta-response-conformance.test.ts` #7294) — deliberately, because
21+
* the third verb is the same class of surface and its declaration was the
22+
* short one. Before this card the first assertion below was red in the same
23+
* quiet way theirs were: `safeParse` SUCCEEDED and `seq` /
24+
* `projectionApplied` were dropped from the parsed result, so the "stripped
25+
* keys" set was non-empty. That is the direction it must never drift back to.
26+
*
27+
* ## The delete door's own surface: FOUR success returns, not one
28+
*
29+
* Both siblings have a single success return that always sets `seq`, so they
30+
* declare it REQUIRED. `deleteMetaItem` has four, and only one of them appends
31+
* a history event — which is the whole reason `seq` is `.optional()` here and
32+
* why each branch needs its own case:
33+
*
34+
* 1. repository path, row deleted → `seq`, plus `projectionApplied` when a
35+
* projector is registered. The only branch that carries either key.
36+
* 2. repository path, no row ("nothing to delete") → a success/no-op.
37+
* 3. legacy raw-engine path, row deleted (#5264, deliberately alive) → no
38+
* history row, no watch event, so no `seq` even though a row really went
39+
* away. The branch that would make a REQUIRED `seq` a false contract.
40+
* 4. legacy raw-engine path, no row → the same no-op as (2).
41+
*
42+
* Cases 2 and 3 are the ones a mirror-the-siblings declaration gets wrong if
43+
* it is written from the siblings' shape instead of from this producer.
44+
*/
45+
import { describe, it, expect } from 'vitest';
46+
import type { ServiceObject } from '@objectstack/spec/data';
47+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
48+
import { DeleteMetaItemResponseSchema } from '@objectstack/spec/api';
49+
import { ObjectQL } from './engine.js';
50+
51+
const sysMetadataObject: ServiceObject = {
52+
name: 'sys_metadata',
53+
label: 'System Metadata',
54+
fields: {
55+
id: { name: 'id', label: 'ID', type: 'text' as const },
56+
type: { name: 'type', label: 'Type', type: 'text' as const, required: true },
57+
name: { name: 'name', label: 'Name', type: 'text' as const, required: true },
58+
organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const },
59+
// [#8682] Part of the real row's uniqueness key `(type, name,
60+
// organization_id, package_id)` and written by `SysMetadataRepository`
61+
// — the declared-field door judges the payload against this map, so
62+
// omitting it here would be a fixture defect, not a simplification.
63+
package_id: { name: 'package_id', label: 'Package', type: 'text' as const },
64+
metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const },
65+
checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 },
66+
state: { name: 'state', label: 'State', type: 'text' as const },
67+
version: { name: 'version', label: 'Version', type: 'number' as const },
68+
created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const },
69+
updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const },
70+
},
71+
};
72+
73+
function makeMemoryDriver() {
74+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
75+
const storeFor = (obj: string) => {
76+
let s = stores.get(obj);
77+
if (!s) { s = new Map(); stores.set(obj, s); }
78+
return s;
79+
};
80+
let nextId = 0;
81+
// `$and` / `$or` are conjoined WITH their sibling keys, the way a real
82+
// driver ANDs them — see #7620 for what the short-circuiting shape cost.
83+
const matchesWhere = (row: Record<string, unknown>, where: any): boolean => {
84+
if (!where || typeof where !== 'object') return true;
85+
for (const [k, v] of Object.entries(where)) {
86+
if (k === '$and' && Array.isArray(v)) {
87+
if (!v.every((w: any) => matchesWhere(row, w))) return false;
88+
continue;
89+
}
90+
if (k === '$or' && Array.isArray(v)) {
91+
if (!v.some((w: any) => matchesWhere(row, w))) return false;
92+
continue;
93+
}
94+
if (k.startsWith('$')) continue;
95+
const rowVal = row[k];
96+
const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
97+
const a = rowVal === undefined ? null : rowVal;
98+
const b = expected === undefined ? null : expected;
99+
if (a !== b) return false;
100+
}
101+
return true;
102+
};
103+
const driver: any = {
104+
name: 'memory', version: '0.0.0', supports: {} as any,
105+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
106+
async execute() { return null; },
107+
async find(object: string, ast: any) {
108+
const rows = Array.from(storeFor(object).values()).filter((r) => matchesWhere(r, ast?.where));
109+
// Hold the caller's bound, AFTER the filter and by PRESENCE — a
110+
// limit-blind double answers more rows than the caller asked for
111+
// and every assertion about a bounded read passes for the wrong
112+
// reason (`check:objectql-double-limit`). The two sibling
113+
// conformance files predate that gate and sit in its shrink-only
114+
// baseline; a new double conforms.
115+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
116+
},
117+
async findOne(object: string, ast: any) {
118+
for (const r of storeFor(object).values()) if (matchesWhere(r, ast?.where)) return r;
119+
return null;
120+
},
121+
async create(object: string, data: Record<string, unknown>) {
122+
nextId += 1;
123+
const id = (data.id as string) ?? `r_${nextId}`;
124+
const row = { ...data, id };
125+
storeFor(object).set(id, row);
126+
return row;
127+
},
128+
async update(object: string, id: string, data: Record<string, unknown>) {
129+
const s = storeFor(object);
130+
const cur = s.get(id);
131+
if (!cur) throw new Error(`not found: ${object}/${id}`);
132+
const updated = { ...cur, ...data, id };
133+
s.set(id, updated);
134+
return updated;
135+
},
136+
async upsert(object: string, data: Record<string, unknown>) {
137+
const id = data.id as string | undefined;
138+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
139+
return this.create(object, data);
140+
},
141+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
142+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
143+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
144+
return Promise.all(rows.map((r) => this.create(object, r)));
145+
},
146+
async bulkUpdate() { return []; }, async bulkDelete() {},
147+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
148+
async commit() {}, async rollback() {},
149+
};
150+
return { driver, stores };
151+
}
152+
153+
async function makeProtocol() {
154+
const engine = new ObjectQL();
155+
const { driver, stores } = makeMemoryDriver();
156+
engine.registerDriver(driver, true);
157+
await engine.init();
158+
engine.registry.registerObject(sysMetadataObject, 'test-package');
159+
return { p: new ObjectStackProtocolImplementation(engine), stores };
160+
}
161+
162+
// [#7741] the inline arm requires the object binding pair
163+
const viewBody = (label: string) => ({
164+
name: 'cases', type: 'grid', label, columns: ['id'], object: 'case', viewKind: 'list',
165+
});
166+
167+
const ORG = 'org_x';
168+
169+
/** Keys the producer emitted that the schema refused to carry through. */
170+
function strippedKeys(raw: Record<string, unknown>): string[] {
171+
const parsed = DeleteMetaItemResponseSchema.parse(raw) as Record<string, unknown>;
172+
return Object.keys(raw).filter((k) => !(k in parsed));
173+
}
174+
175+
describe('deleteMetaItem response conforms to DeleteMetaItemResponseSchema (#13155)', () => {
176+
it('repository path, row deleted: parses green, strips nothing, and carries seq', async () => {
177+
const { p } = await makeProtocol();
178+
await (p as any).saveMetaItem({
179+
type: 'view', name: 'cases', organizationId: ORG, item: viewBody('A'),
180+
});
181+
182+
const raw: any = await (p as any).deleteMetaItem({
183+
type: 'view', name: 'cases', organizationId: ORG,
184+
});
185+
186+
// The assertion that was red before the declaration: `seq` rode the
187+
// wire and the schema dropped it on the floor.
188+
expect(Object.keys(raw)).toContain('seq');
189+
expect(strippedKeys(raw)).toEqual([]);
190+
191+
const parsed = DeleteMetaItemResponseSchema.parse(raw);
192+
expect(parsed.success).toBe(true);
193+
expect(parsed.reset).toBe(true);
194+
// The ordering token the history/audit trail is read by — an integer,
195+
// and the same value the receipt message's `[seq=…]` suffix quotes.
196+
expect(typeof parsed.seq).toBe('number');
197+
expect(Number.isInteger(parsed.seq)).toBe(true);
198+
expect(parsed.message).toContain(`[seq=${parsed.seq}]`);
199+
});
200+
201+
it('with an ADR-0094 projector registered: projectionApplied is carried through', async () => {
202+
const { p } = await makeProtocol();
203+
await (p as any).saveMetaItem({
204+
type: 'view', name: 'cases', organizationId: ORG, item: viewBody('P'),
205+
});
206+
// Registered AFTER the save so the save's own projection is not what
207+
// this case reads — the delete's is.
208+
(p as any).registerMutationProjector('view', async () => { throw new Error('boom-from-projector'); });
209+
210+
const raw: any = await (p as any).deleteMetaItem({
211+
type: 'view', name: 'cases', organizationId: ORG,
212+
});
213+
214+
expect(Object.keys(raw)).toContain('projectionApplied');
215+
expect(strippedKeys(raw)).toEqual([]);
216+
const parsed = DeleteMetaItemResponseSchema.parse(raw);
217+
// Best-effort by contract: the projector threw, the delete still
218+
// succeeded, and the failure is reported HERE rather than as a non-200.
219+
// This is the channel the card names — a caller that needs the derived
220+
// read model to be live reads it instead of trusting the 200.
221+
expect(parsed.success).toBe(true);
222+
expect(parsed.projectionApplied).toEqual({ success: false, error: 'boom-from-projector' });
223+
});
224+
225+
it('no projector registered → projectionApplied is absent, which is why it is optional', async () => {
226+
const { p } = await makeProtocol();
227+
await (p as any).saveMetaItem({
228+
type: 'view', name: 'cases', organizationId: ORG, item: viewBody('N'),
229+
});
230+
231+
const raw: any = await (p as any).deleteMetaItem({
232+
type: 'view', name: 'cases', organizationId: ORG,
233+
});
234+
235+
expect(raw.projectionApplied).toBeUndefined();
236+
expect(strippedKeys(raw)).toEqual([]);
237+
expect(DeleteMetaItemResponseSchema.safeParse(raw).success).toBe(true);
238+
});
239+
240+
it('repository path, no overlay row: a no-op success that carries no seq', async () => {
241+
const { p } = await makeProtocol();
242+
243+
const raw: any = await (p as any).deleteMetaItem({
244+
type: 'view', name: 'never_written', organizationId: ORG,
245+
});
246+
247+
// This is the branch that makes `seq` optional rather than required.
248+
// Declaring it required would make the producer's own no-op fail its
249+
// own contract — the #5563 defect in mirror image.
250+
expect(raw).not.toHaveProperty('seq');
251+
expect(strippedKeys(raw)).toEqual([]);
252+
const parsed = DeleteMetaItemResponseSchema.parse(raw);
253+
expect(parsed.success).toBe(true);
254+
// `reset`, not the absence of `seq`, is what says nothing was removed.
255+
expect(parsed.reset).toBe(false);
256+
expect(parsed.seq).toBeUndefined();
257+
});
258+
259+
it('seq really is the history event sequence: it advances across the item\'s writes', async () => {
260+
const { p } = await makeProtocol();
261+
const saved: any = await (p as any).saveMetaItem({
262+
type: 'view', name: 'cases', organizationId: ORG, item: viewBody('S'),
263+
});
264+
const raw: any = await (p as any).deleteMetaItem({
265+
type: 'view', name: 'cases', organizationId: ORG,
266+
});
267+
268+
// The delete's tombstone event comes after the save's write event on
269+
// the same item, which is the ordering property a history/audit
270+
// consumer reads the key FOR. A `seq` that did not move would parse
271+
// just as green, so the contract needs this asserted, not assumed.
272+
const parsed = DeleteMetaItemResponseSchema.parse(raw);
273+
expect(parsed.seq).toBeGreaterThan(saved.seq);
274+
});
275+
});

packages/spec/authorable-surface/api.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,9 @@
485485
"api/DeleteMetaItemRequest:state",
486486
"api/DeleteMetaItemRequest:type",
487487
"api/DeleteMetaItemResponse:message",
488+
"api/DeleteMetaItemResponse:projectionApplied",
488489
"api/DeleteMetaItemResponse:reset",
490+
"api/DeleteMetaItemResponse:seq",
489491
"api/DeleteMetaItemResponse:success",
490492
"api/DeleteResponse:error",
491493
"api/DeleteResponse:id",

0 commit comments

Comments
 (0)