Skip to content

Commit cf71d73

Browse files
os-litantclaude
andauthored
meta.deleteItem sends the reset door's If-Match pin and ?state=draft, on both declarations (#13026)
* wip: deleteItem carriers * feat(client): meta.deleteItem sends If-Match and ?state=draft on both declarations * test(client): pin both deleteItem carriers against the real reset door; changeset * chore(client): resolve the new test's producer imports from source (vitest alias + tsc paths) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8cb96ec commit cf71d73

7 files changed

Lines changed: 904 additions & 8 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
feat(client): `meta.deleteItem` can pin a reset (`If-Match`) and discard only the pending draft (`?state=draft`) (#12181)
6+
7+
Accept-set widening on a published SDK surface: both `deleteItem` declarations
8+
— the unscoped `ObjectStackClient.meta` and the environment-scoped
9+
`ScopedEnvironmentClient.meta` twin — take a third, optional
10+
`DeleteMetaItemOptions` argument. Existing calls are unchanged: with the bag
11+
omitted, the request is byte-identical to what this method has always sent
12+
(no header key, no query string).
13+
14+
FROM → TO:
15+
16+
```ts
17+
// FROM — the only reset a first-party SDK caller could express
18+
await client.meta.deleteItem('view', 'shared_grid');
19+
20+
// TO — pin the reset against the version you read (ADR-0008 OCC)
21+
const saved = await client.meta.saveItem('view', 'shared_grid', spec);
22+
await client.meta.deleteItem('view', 'shared_grid', { ifMatch: saved.version });
23+
// concurrent edit → 409 metadata_conflict, instead of silently resetting it
24+
25+
// TO — discard ONLY the pending draft; the published overlay keeps serving
26+
await client.meta.deleteItem('view', 'shared_grid', { state: 'draft' });
27+
```
28+
29+
Why it matters: `DELETE /meta/:type/:name` has always read the `If-Match`
30+
header and threaded it as `parentVersion` (the spec's own
31+
`DeleteMetaItemRequest.parentVersion` describes the pin), and the sibling
32+
first-party client `@object-ui/data-objectstack` `MetadataClient.reset`
33+
already sent it — but this client had no argument for it, so every SDK reset
34+
was last-write-wins on the one verb whose whole job is destroying an overlay
35+
row. `state: 'draft'` reaches the NARROWER reset; without it the only
36+
reachable reset was the full one, which drops the published overlay too.
37+
38+
`?dropStorage=true` is deliberately NOT part of this bag. It is the one
39+
carrier the reset door reads that ADDS destructive reach — it drops the
40+
object's physical table — no caller was measured needing it from this client,
41+
and the door's repeated-parameter refusal exists because of that
42+
destructiveness. A caller that needs it is a separate, separately reviewable
43+
widening.
44+
45+
`state: 'active'` is the explicit spelling of the default and deliberately
46+
sends nothing; an empty `ifMatch` (`''`) omits the header rather than pinning
47+
against the empty string.

packages/client/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
"@hono/node-server": "^2.1.1",
3030
"@objectstack/driver-sqlite-wasm": "workspace:*",
3131
"@objectstack/hono": "workspace:*",
32+
"@objectstack/metadata-core": "workspace:*",
33+
"@objectstack/metadata-protocol": "workspace:*",
3234
"@objectstack/objectql": "workspace:*",
3335
"@objectstack/plugin-hono-server": "workspace:*",
3436
"@objectstack/runtime": "workspace:*",

packages/client/src/index.ts

Lines changed: 180 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,133 @@ function metaSaveHeaders(options?: SaveMetaItemOptions): Record<string, string>
740740
return { 'If-Match': String(options.ifMatch) };
741741
}
742742

743+
/**
744+
* Request options for `meta.deleteItem` — the carriers the REST reset door
745+
* reads, made reachable from the SDK (#12181).
746+
*
747+
* `DELETE /meta/:type/:name` ("reset metadata item to artifact default")
748+
* reads THREE carriers. This bag declares TWO of them, and the third's
749+
* absence is a ruling rather than an oversight:
750+
*
751+
* - {@link DeleteMetaItemOptions.ifMatch} — the ADR-0008 OCC pin. The door
752+
* already reads `If-Match` and threads it as `parentVersion`, and
753+
* `DeleteMetaItemRequest.parentVersion` names that header in the spec text
754+
* itself. Without an argument for it, every SDK reset was last-write-wins
755+
* on the one verb whose whole job is destroying an overlay row.
756+
* - {@link DeleteMetaItemOptions.state} — `?state=draft`, the NARROWER
757+
* reset: discard the pending draft and leave the published overlay
758+
* serving. Its absence did not make the SDK safer, it made the SDK's only
759+
* reachable reset the full one.
760+
*
761+
* ⛔ `?dropStorage=true` is deliberately NOT a member, and adding it "for
762+
* completeness" reverses a decision. It is the one carrier of the three that
763+
* ADDS destructive reach — it drops the object's physical table after the
764+
* metadata row goes — no caller was measured needing it from this client,
765+
* and the door's repeated-parameter refusal exists because of that
766+
* destructiveness. Maintainer-seat ruling on #12181: a destructive surface
767+
* with no measured pull is not published. A caller that needs it is a
768+
* separate, separately reviewable widening.
769+
*
770+
* Same bag on BOTH `deleteItem` declarations — the unscoped client and the
771+
* environment-scoped twin — for the reason #7019 states: the scoped mount is
772+
* not a second implementation, it is one `registerForBase` call replayed
773+
* against `/environments/:environmentId`, so a bag on one client only would
774+
* be a fresh divergence, not half a fix.
775+
*/
776+
export interface DeleteMetaItemOptions {
777+
/**
778+
* `If-Match: <version>` — the ADR-0008 optimistic-concurrency pin, and
779+
* the one member here that is NOT a query parameter.
780+
*
781+
* Echo back the `version` a previous `saveItem` (or `publishItem`)
782+
* resolved, and a concurrent edit is refused with `409
783+
* metadata_conflict` instead of silently resetting the row the other
784+
* author just wrote. Omit for the last-write-wins behaviour every reset
785+
* had before this member existed — the pin is opt-in on the door too
786+
* (Studio's "Reset" button is deliberately unpinned).
787+
*
788+
* Opaque: echo it verbatim, never parse it.
789+
*
790+
* Only a non-empty token reaches the wire. `undefined` and `''` both OMIT
791+
* the header rather than sending an empty `If-Match`: the door reads the
792+
* header's PRESENCE as "pin this reset", so an empty spelling would pin
793+
* against the empty string and refuse a reset the caller never asked to
794+
* pin.
795+
*
796+
* Same member name, same header, same truthy guard as the sibling
797+
* first-party `@object-ui/data-objectstack` `MetadataClient.reset`, and
798+
* as {@link SaveMetaItemOptions.ifMatch} on this same client.
799+
*/
800+
ifMatch?: string;
801+
/**
802+
* `?state=draft` — discard ONLY the pending draft overlay, leaving the
803+
* still-active overlay serving.
804+
*
805+
* `'active'` is the explicit spelling of the default and deliberately
806+
* sends NOTHING: the door acts on `state=draft` alone and treats every
807+
* other value as active, so emitting `?state=active` would put a value on
808+
* the wire the server ignores. Same shape as
809+
* {@link SaveMetaItemOptions.mode}, and the same vocabulary the spec
810+
* declares (`DeleteMetaItemRequest.state`: `'active' | 'draft'`).
811+
*
812+
* This is the LESS destructive reset, not a new destructive one: without
813+
* it the only reachable reset is the full one, which drops the published
814+
* overlay too.
815+
*/
816+
state?: 'active' | 'draft';
817+
}
818+
819+
/**
820+
* Compose the `meta.deleteItem` query string — the ONE builder both
821+
* `deleteItem` declarations call, for the same reason
822+
* {@link DeleteMetaItemOptions} is one type: the twins must not be able to
823+
* drift in what they put on the wire.
824+
*
825+
* Returns `''` (not `'?'`) when nothing is set, so an options-less call is
826+
* BYTE-IDENTICAL to what this method sent before the bag existed.
827+
*
828+
* `URLSearchParams.set` (never `append`) is load-bearing: the door REFUSES a
829+
* repeated `state` (#6877), so a builder that could emit the key twice would
830+
* turn a caller's option into a 400.
831+
*/
832+
function metaDeleteQuery(options?: DeleteMetaItemOptions): string {
833+
if (!options) return '';
834+
const params = new URLSearchParams();
835+
// Only `'draft'` is actionable server-side; `'active'` is the default
836+
// said out loud and sends nothing.
837+
if (options.state === 'draft') params.set('state', 'draft');
838+
const qs = params.toString();
839+
return qs ? `?${qs}` : '';
840+
}
841+
842+
/**
843+
* Compose the `meta.deleteItem` request headers — the ONE builder both
844+
* `deleteItem` declarations call, for the same reason {@link metaDeleteQuery}
845+
* is one function.
846+
*
847+
* Returns `undefined` (never `{}`) when nothing is set, so the call site can
848+
* omit the `headers` key entirely and an options-less reset stays
849+
* BYTE-IDENTICAL to what it sent before this existed.
850+
*
851+
* ⛔ `ifMatch` must never be added to {@link metaDeleteQuery}. It is a
852+
* header; the reset door reads no `?ifMatch=` parameter, so a query spelling
853+
* would look set at the call site and protect nothing.
854+
*
855+
* Deliberately a sibling of {@link metaSaveHeaders} rather than a call into
856+
* it: the two methods carry two separately-ruled option bags (#11713 for
857+
* `saveItem`, #12181 for this one), so neither type may quietly acquire the
858+
* other's members. The two builders are pinned IN STEP by a test instead —
859+
* one token in, identical header bytes out.
860+
*/
861+
function metaDeleteHeaders(options?: DeleteMetaItemOptions): Record<string, string> | undefined {
862+
if (!options?.ifMatch) return undefined;
863+
// Verbatim and UNQUOTED — the door tolerates ETag-style quotes by
864+
// stripping them, but the token a save resolves carries none, and
865+
// wrapping it here would put bytes on the wire the sibling first-party
866+
// client does not send.
867+
return { 'If-Match': String(options.ifMatch) };
868+
}
869+
743870
export class ObjectStackClient {
744871
private baseUrl: string;
745872
private token?: string;
@@ -997,14 +1124,39 @@ export class ObjectStackClient {
9971124
},
9981125

9991126
/**
1000-
* Delete a metadata item
1127+
* Delete a metadata item — reset it to its artifact default by removing
1128+
* the ADR-0005 customization overlay row.
1129+
*
10011130
* @param type - Metadata type (e.g., 'object', 'plugin')
10021131
* @param name - Item name (snake_case identifier)
1003-
*/
1004-
deleteItem: async (type: string, name: string): Promise<{ type: string; name: string; deleted: boolean }> => {
1132+
* @param options - {@link DeleteMetaItemOptions}: the ADR-0008 OCC pin
1133+
* (`ifMatch`) and the narrower draft-only discard (`state: 'draft'`).
1134+
*
1135+
* PIN THE RESET. This verb destroys a row: unpinned, a reset issued
1136+
* against a version somebody else has already replaced silently destroys
1137+
* their edit and answers 200. Echo the `version` a previous `saveItem`
1138+
* resolved as `options.ifMatch` and the same situation answers `409
1139+
* metadata_conflict` instead — the door has always read the header
1140+
* (`DeleteMetaItemRequest.parentVersion` describes it), this client just
1141+
* had no argument for it until #12181.
1142+
*/
1143+
deleteItem: async (
1144+
type: string,
1145+
name: string,
1146+
options?: DeleteMetaItemOptions,
1147+
): Promise<{ type: string; name: string; deleted: boolean }> => {
10051148
const route = this.getRoute('metadata');
1006-
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}`, {
1149+
// `query`, not `qs` — it carries its own `?`; see `saveItem`'s note on
1150+
// the three meanings `qs` holds in this file.
1151+
const query = metaDeleteQuery(options);
1152+
// The OCC token rides a HEADER, not the query string — see
1153+
// {@link metaDeleteHeaders}. `undefined` when unset, and the spread
1154+
// then omits the `headers` key altogether, so an unpinned reset hands
1155+
// `fetch` the same `init` it always did.
1156+
const headers = metaDeleteHeaders(options);
1157+
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}${query}`, {
10071158
method: 'DELETE',
1159+
...(headers ? { headers } : {}),
10081160
});
10091161
return this.unwrapResponse(res);
10101162
},
@@ -5729,9 +5881,31 @@ export class ScopedEnvironmentClient {
57295881
});
57305882
return this.parent._unwrap<SaveMetaItemResponse>(res);
57315883
},
5732-
deleteItem: async (type: string, name: string): Promise<{ type: string; name: string; deleted: boolean }> => {
5733-
const res = await this.parent._fetch(this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}`), {
5884+
/**
5885+
* Reset a metadata item to its artifact default, scoped to this
5886+
* environment.
5887+
*
5888+
* `options` is the SAME {@link DeleteMetaItemOptions} bag the unscoped
5889+
* twin takes, and reaches the SAME handler: the scoped mount is not a
5890+
* second implementation, it is one `registerForBase` call replayed
5891+
* against `/environments/:environmentId` (see `RestServer`), so this door
5892+
* reads `?state=` — and the `If-Match` header — byte-identically. A bag
5893+
* on only one of the two clients would be a fresh divergence of the kind
5894+
* #7019 rules against, not half a fix.
5895+
*/
5896+
deleteItem: async (
5897+
type: string,
5898+
name: string,
5899+
options?: DeleteMetaItemOptions,
5900+
): Promise<{ type: string; name: string; deleted: boolean }> => {
5901+
// `query`, not `qs` — it carries its own `?`; see the unscoped twin.
5902+
const query = metaDeleteQuery(options);
5903+
// Header half of the same bag, through the same one builder the twin
5904+
// calls — see {@link metaDeleteHeaders}.
5905+
const headers = metaDeleteHeaders(options);
5906+
const res = await this.parent._fetch(this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}${query}`), {
57345907
method: 'DELETE',
5908+
...(headers ? { headers } : {}),
57355909
});
57365910
return this.parent._unwrap(res);
57375911
},

0 commit comments

Comments
 (0)