@@ -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+
743870export 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