diff --git a/.changeset/meta-delete-item-return-type.md b/.changeset/meta-delete-item-return-type.md new file mode 100644 index 0000000000..95fe63e26c --- /dev/null +++ b/.changeset/meta-delete-item-return-type.md @@ -0,0 +1,68 @@ +--- +"@objectstack/client": minor +"@objectstack/cli": minor +--- + +fix(client)!: `meta.deleteItem` declares the response the reset door actually sends (#13023) + +**BREAKING** for a typed caller, and it breaks nothing that ever worked. Both +`deleteItem` declarations — the unscoped `ObjectStackClient.meta` and the +environment-scoped `ScopedEnvironmentClient.meta` twin — declared +`Promise<{ type: string; name: string; deleted: boolean }>`. That shape is not +merely imprecise, it is **uninhabited**: `DELETE /meta/:type/:name` ends in +`res.json(result)` with `deleteMetaItem`'s return, and not one of that method's +four return branches carries `type`, `name` or `deleted`. Both twins now declare +`DeleteMetaItemResponse` — the type `@objectstack/spec` already exported. + +### Migration: FROM → TO + +```ts +const r = await client.meta.deleteItem('view', 'shared_grid'); + +// FROM — compiled, and read `undefined` on EVERY reset, including the ones +// that really deleted an overlay row. The branch was never taken. +if (r.deleted) { invalidateCache(); } + +// TO — the truthful flag, and it tells the two successes apart +if (r.reset) { invalidateCache(); } // an overlay row was deleted +else { /* none existed — already at the artifact default */ } +``` + +`r.type` / `r.name` have no replacement: the door never echoed them, and the +caller already holds both — it passed them in. + +⛔ Do not write `r.reset ?? r.deleted`. There is one producer shape, and a +consumer accepting two spellings is what contract-first exists to prevent. No +deprecated `deleted?: boolean` transition key ships either: a transition period +is for keys that *worked*, and this one never did. + +⚠️ The real work is behavioural, not textual. Every `if (r.deleted)` has been +false since it was written, so re-read what each of those branches was supposed +to do — cache invalidation, registry refreshes and UI reloads guarded that way +have **never run**, and moving to `r.reset` turns them on for the first time. +Note also that `r.reset` and `r.success` are different questions: `success` asks +whether the call was accepted, `reset` whether a row actually went away. + +The type name is reachable without a new export from this package — +`import type { DeleteMetaItemResponse } from '@objectstack/spec/api'` — which is +also why no member list is transcribed here. A hand-written local copy of the +schema's members is the very defect this change removes. + +### `os meta delete` + +The CLI read the phantom key too: its `--format json` / `--format yaml` payload +carried `deleted: result.deleted`, which evaluated to `undefined`, and both +`JSON.stringify` and `yaml.stringify` drop undefined values — so the `deleted` +key this command has always declared **never appeared in a single run**. It now +carries `result.reset`, the door's own verdict. Observable change: `os meta +delete --format json` gains `deleted: true` (YAML likewise) when an overlay row +was removed, and `deleted: false` when the item was already at its artifact +default. The key name stays `deleted` deliberately — it is the CLI's output key, +not the protocol's, and the payload's top-level `success` already means +something different (the CLI envelope's "the command completed"). Same treatment +`os data delete` received one door over. + +⛔ The wire is untouched: neither `deleteMetaItem` nor +`DeleteMetaItemResponseSchema` changes. Reality is the contract. + + diff --git a/packages/cli/src/commands/meta/delete.ts b/packages/cli/src/commands/meta/delete.ts index 436cd638ec..414a58fb93 100644 --- a/packages/cli/src/commands/meta/delete.ts +++ b/packages/cli/src/commands/meta/delete.ts @@ -57,10 +57,21 @@ export default class MetaDelete extends Command { const result = await client.meta.deleteItem(args.type, args.name); + // [#13023] `deleted` is THIS COMMAND's output key; its value is the reset + // door's `DeleteMetaItemResponse.reset`. Two different booleans live in + // this payload and must not be conflated — the top-level `success` is the + // CLI envelope's "the command completed", while `deleted` reports whether + // a customization overlay row actually went away (`reset: false` means + // none existed and the item was already at its artifact default). This + // read was `result.deleted` until now — a key no branch of the door has + // ever sent, so it evaluated to `undefined` and `JSON.stringify` / + // `yaml.stringify` dropped it: the key this command has always declared + // never appeared in a single run. Exactly the treatment #5638 gave the + // sibling `os data delete`, one door over. if (flags.format === 'json') { - await formatOutput({ success: true, type: args.type, name: args.name, deleted: result.deleted }, 'json'); + await formatOutput({ success: true, type: args.type, name: args.name, deleted: result.reset }, 'json'); } else if (flags.format === 'yaml') { - await formatOutput({ success: true, type: args.type, name: args.name, deleted: result.deleted }, 'yaml'); + await formatOutput({ success: true, type: args.type, name: args.name, deleted: result.reset }, 'yaml'); } else { printSuccess(`Metadata deleted: ${args.type}/${args.name}`); } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e9339ca1cf..419e2c62ba 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -16,6 +16,13 @@ import { GetMetaItemsResponse, GetMetaItemResponse, SaveMetaItemResponse, + // [#13023] The reset door's response contract. Both `meta.deleteItem` + // declarations BIND this type rather than transcribing its members: a + // hand-written member list is the exact defect this card removes (a local + // declaration that drifts from the wire), and the card's own body + // demonstrated the failure by attributing the IMPLEMENTATION's declared + // return to this schema. + DeleteMetaItemResponse, PublishMetaItemResponse, PublishPackageDraftsResponse, LoginRequest, @@ -1150,12 +1157,24 @@ export class ObjectStackClient { * metadata_conflict` instead — the door has always read the header * (`DeleteMetaItemRequest.parentVersion` describes it), this client just * had no argument for it until #12181. + * + * [#13023] READ `reset`, NEVER `deleted`. This method used to declare + * `{ type, name, deleted }` — an UNINHABITED shape: the door answers + * `res.json(result)` with `deleteMetaItem`'s return, and not one of its + * four branches carries `type`, `name` or `deleted`. So `r.deleted` + * compiled and read `undefined` on EVERY reset, including the ones that + * really removed a row, and the SDK's own tests had to cast through `any` + * to see the truth. The truthful flag is {@link DeleteMetaItemResponse}'s + * `reset`: `true` means an overlay row was deleted, `false` means none + * existed and the item was already at its artifact default — exactly the + * distinction a caller most wants. Same correction #5638 made one door + * over on `DeleteDataResult`. */ deleteItem: async ( type: string, name: string, options?: DeleteMetaItemOptions, - ): Promise<{ type: string; name: string; deleted: boolean }> => { + ): Promise => { const route = this.getRoute('metadata'); // `query`, not `qs` — it carries its own `?`; see `saveItem`'s note on // the three meanings `qs` holds in this file. @@ -1169,7 +1188,11 @@ export class ObjectStackClient { method: 'DELETE', ...(headers ? { headers } : {}), }); - return this.unwrapResponse(res); + // The door answers BARE (`res.json(result)`), and `unwrapResponse` + // strips only a body carrying BOTH a boolean `success` AND a `data` + // key — this one has no `data` — so the caller receives the door's + // whole body and the annotation above describes it. + return this.unwrapResponse(res); }, /** @@ -6013,12 +6036,18 @@ export class ScopedEnvironmentClient { * reads `?state=` — and the `If-Match` header — byte-identically. A bag * on only one of the two clients would be a fresh divergence of the kind * #7019 rules against, not half a fix. + * + * [#13023] Returns {@link DeleteMetaItemResponse} — read `reset`, never + * `deleted`. The phantom `{ type, name, deleted }` declaration was + * TEXTUALLY IDENTICAL on both twins, so correcting one and not the other + * would have been half a fix in the same #11713 direction the bag above + * records. See the unscoped twin for the full account. */ deleteItem: async ( type: string, name: string, options?: DeleteMetaItemOptions, - ): Promise<{ type: string; name: string; deleted: boolean }> => { + ): Promise => { // `query`, not `qs` — it carries its own `?`; see the unscoped twin. const query = metaDeleteQuery(options); // Header half of the same bag, through the same one builder the twin @@ -6028,7 +6057,8 @@ export class ScopedEnvironmentClient { method: 'DELETE', ...(headers ? { headers } : {}), }); - return this.parent._unwrap(res); + // Bare body, same as the unscoped twin — `_unwrap` is `unwrapResponse`. + return this.parent._unwrap(res); }, getHistory: async ( type: string, diff --git a/packages/client/src/meta-delete-item-carriers.test.ts b/packages/client/src/meta-delete-item-carriers.test.ts index af902a6351..ad222aa4c6 100644 --- a/packages/client/src/meta-delete-item-carriers.test.ts +++ b/packages/client/src/meta-delete-item-carriers.test.ts @@ -73,6 +73,14 @@ import { } from '@objectstack/metadata-core'; import { RestServer } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; +// [#13023] The reset door's response contract. Every `deleteItem` result below +// is bound to it instead of `any`: these reads used to be `const r: any` +// PRECISELY because the declared return (`{ type, name, deleted }`) named none +// of the fields the door actually sends, so reading the truth required dodging +// the type. With the declaration corrected the cast is not merely unnecessary, +// it would hide the fix — and `reset`, the flag this file already asserts in +// BOTH directions against the real door, is now a typed read. +import type { DeleteMetaItemResponse } from '@objectstack/spec/api'; // --------------------------------------------------------------------------- // Part 1 — what the CLIENT puts on the wire (both declarations) @@ -467,11 +475,28 @@ describe('[#12181] the real reset door: a concurrent edit is destroyed unpinned, // A resets, holding a version that is no longer current. This is the // BEFORE state of the card: with no options bag there was no other // call to make. - const reset: any = await client.meta.deleteItem('view', 'race_probe'); + const reset: DeleteMetaItemResponse = await client.meta.deleteItem('view', 'race_probe'); // Silently destroyed: success, and B's edit is gone from the store. + // These two are TYPED reads since #13023 — under the phantom + // `{ type, name, deleted }` declaration they were TS2339 and this + // binding had to be `any` to compile at all. expect(reset.success).toBe(true); expect(reset.reset).toBe(true); + + // [#13023] The phantom shape, refuted on the REAL door rather than + // argued from the schema. `deleted` — the flag the declaration told + // every caller to branch on — is not a key on this body, and neither + // are `type` and `name`. A first-party consumer writing + // `if (r.deleted)` took the FALSE branch here, on the reset that + // really did destroy a row. + expect('deleted' in (reset as object)).toBe(false); + expect('type' in (reset as object)).toBe(false); + expect('name' in (reset as object)).toBe(false); + // The positive control that keeps those three absences honest: the + // same instrument, same body, sees the keys that ARE there. + expect('success' in (reset as object)).toBe(true); + expect('reset' in (reset as object)).toBe(true); expect(await overlayRows(engine, 'race_probe')).toHaveLength(0); // The probe: no pin ever reached the protocol. expect(deleteRequests).toHaveLength(1); @@ -514,7 +539,7 @@ describe('[#12181] the real reset door: a concurrent edit is destroyed unpinned, // write. Without this, "always 409" would pass the case above. const { engine, client } = await bootDoor(); const saved: any = await client.meta.saveItem('view', 'fresh_probe', VIEW('fresh_probe', 'A')); - const reset: any = await client.meta.deleteItem('view', 'fresh_probe', { ifMatch: saved.version }); + const reset: DeleteMetaItemResponse = await client.meta.deleteItem('view', 'fresh_probe', { ifMatch: saved.version }); expect(reset.success).toBe(true); expect(await overlayRows(engine, 'fresh_probe')).toHaveLength(0); }, 60_000); @@ -542,7 +567,7 @@ describe('[#12181] the real reset door: a concurrent edit is destroyed unpinned, // …and unpinned, the scoped twin destroys it exactly like the // unscoped one — same handler, same last-write-wins default. - const reset: any = await scoped.deleteItem('view', 'scoped_race'); + const reset: DeleteMetaItemResponse = await scoped.deleteItem('view', 'scoped_race'); expect(reset.success).toBe(true); expect(await overlayRows(engine, 'scoped_race')).toHaveLength(0); }, 60_000); @@ -561,7 +586,7 @@ describe('[#12181] the real reset door: `?state=draft` discards ONLY the pending expect(before.map((r: any) => r.state).sort()).toEqual(['active', 'draft']); // The narrow reset — unreachable from this SDK before this card. - const discarded: any = await client.meta.deleteItem('view', 'draft_probe', { state: 'draft' }); + const discarded: DeleteMetaItemResponse = await client.meta.deleteItem('view', 'draft_probe', { state: 'draft' }); expect(discarded.success).toBe(true); // The door parsed `?state=draft` and threaded it into the protocol // call. (Positive control for the sibling case below, where the same @@ -576,14 +601,14 @@ describe('[#12181] the real reset door: `?state=draft` discards ONLY the pending // A second draft discard has nothing left to discard — the door says // so rather than falling through to the active row. - const again: any = await client.meta.deleteItem('view', 'draft_probe', { state: 'draft' }); + const again: DeleteMetaItemResponse = await client.meta.deleteItem('view', 'draft_probe', { state: 'draft' }); expect(again.reset).toBe(false); expect(await overlayRows(engine, 'draft_probe')).toHaveLength(1); // …and the FULL reset — the only one the SDK could express before — // takes the published overlay with it. This is why withholding // `?state=draft` did not make the client safer. - const full: any = await client.meta.deleteItem('view', 'draft_probe'); + const full: DeleteMetaItemResponse = await client.meta.deleteItem('view', 'draft_probe'); expect(full.reset).toBe(true); expect(await overlayRows(engine, 'draft_probe')).toHaveLength(0); // The probe again: `state` is absent on the full reset — measured on @@ -598,7 +623,7 @@ describe('[#12181] the real reset door: `?state=draft` discards ONLY the pending await scoped.saveItem('view', 'scoped_draft', VIEW('scoped_draft', 'published')); await scoped.saveItem('view', 'scoped_draft', VIEW('scoped_draft', 'pending'), { mode: 'draft' }); - const discarded: any = await scoped.deleteItem('view', 'scoped_draft', { state: 'draft' }); + const discarded: DeleteMetaItemResponse = await scoped.deleteItem('view', 'scoped_draft', { state: 'draft' }); expect(discarded.success).toBe(true); expect(deleteRequests[0].state).toBe('draft'); const after = await overlayRows(engine, 'scoped_draft'); diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 5dd4fb9c75..38ae0b4c80 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -58,6 +58,8 @@ import type { ActionDescriptor, ExecutionLog, FlowParsed } from '@objectstack/sp import type { ExplainDecision } from '@objectstack/spec/security'; import type { InstalledPackage } from '@objectstack/spec/kernel'; import type { + DeleteMetaItemResponse, + DeleteDataResponse, ListDraftsResponse, GetMetaDiagnosticsResponse, FindReferencesToMetaResponse, @@ -536,6 +538,118 @@ export function environmentIsNotTheCloudWireRow(): void { void specEnvironmentRow.display_name; } +/** + * [#13023] `meta.deleteItem` declared a return the reset door has never + * answered — on BOTH twins. + * + * The declaration was `Promise<{ type: string; name: string; deleted: boolean }>`, + * and it is not merely imprecise, it is UNINHABITED. `DELETE /meta/:type/:name` + * ends in `res.json(result)` with `deleteMetaItem`'s return, and none of that + * method's four return branches carries `type`, `name` or `deleted`. So a + * first-party caller who branched on the documented `deleted` flag read + * `undefined` — falsy — on EVERY reset, including the ones that really removed + * a row. The truthful flag is `reset`, and its `false` arm ("no overlay row + * existed, already at artifact default") is exactly the case a caller most + * wants to tell apart. Nothing surfaced this because the types compiled; the + * SDK's own driven tests had to cast through `any` to read the real fields. + * + * The ruled fix (maintainer, 2026-08-29, option 甲) BINDS the response the spec + * already exports rather than transcribing its members — a hand-written member + * list is the same defect one layer up, and the card's own body demonstrated + * that failure by attributing the IMPLEMENTATION's declared return + * (`@objectstack/metadata-protocol`, which does name `seq` and + * `projectionApplied`) to `DeleteMetaItemResponseSchema`, which declares + * neither. ⛔ The wire is NOT touched: reality is the contract. + * + * Every claim is made TWICE, once per client. That is not padding — the two + * declarations were TEXTUALLY IDENTICAL, so a global count could not tell "both + * fixed" from "half the fix landed" (the #11713 twin-divergence trap, the same + * instrument note `meta-delete-item-carriers.test.ts` is shaped around). + * + * Type-level for this file's standing reason: types are erased before vitest + * runs, so a runtime test cannot observe a return-type narrowing at all. The + * BEHAVIOURAL half — `reset` read through the corrected type against a real + * `RestServer` + real protocol + real `sys_metadata` tables, `true` on a row + * that was deleted and `false` on one that was already at artifact default — + * lives in `meta-delete-item-carriers.test.ts`, where those reads stopped being + * `any` in this same change. + */ +export async function returnTypePrecisionPins13023(): Promise { + // ── direction 1: both twins declare the spec's response ─────────────── + expectTypeOf(await client.meta.deleteItem('view', 'account_list')) + .toEqualTypeOf(); + expectTypeOf(await scoped.meta.deleteItem('view', 'account_list')) + .toEqualTypeOf(); + + // `reset` is READABLE now, and with the schema's optionality intact — a + // "narrowing" that made it a required `boolean` would be a fresh false + // declaration, since the door omits it on no branch it declares but the + // contract does not promise it. + expectTypeOf((await client.meta.deleteItem('view', 'account_list')).reset) + .toEqualTypeOf(); + expectTypeOf((await scoped.meta.deleteItem('view', 'account_list')).reset) + .toEqualTypeOf(); + + // ── direction 2: the reads the phantom declaration invited must FAIL ── + // ⚠️ RED BEFORE, all six. While the twins declared `{ type, name, deleted }` + // each of these reads was LEGAL, so every suppression below went unused and + // tsc reported TS2578 — that unused-suppression signal IS the defect stated + // as a compile error. This is the half of the card that is the point: after + // the binding the reads are refused at the call site where a consumer would + // have written them, instead of compiling and evaluating to `undefined`. + // @ts-expect-error the reset door sends no `deleted` on any branch — read `reset` + void (await client.meta.deleteItem('view', 'account_list')).deleted; + // @ts-expect-error …and the scoped twin reaches the same handler, so neither may keep it + void (await scoped.meta.deleteItem('view', 'account_list')).deleted; + // @ts-expect-error the reset body echoes back no `type` + void (await client.meta.deleteItem('view', 'account_list')).type; + // @ts-expect-error the reset body echoes back no `type` (scoped twin) + void (await scoped.meta.deleteItem('view', 'account_list')).type; + // @ts-expect-error the reset body echoes back no `name` + void (await client.meta.deleteItem('view', 'account_list')).name; + // @ts-expect-error the reset body echoes back no `name` (scoped twin) + void (await scoped.meta.deleteItem('view', 'account_list')).name; +} + +/** + * ⚠️ GREEN IN BOTH STATES — regression guards for #13023, recorded as such + * rather than counted as evidence the change was needed. + * + * 1. The near-miss the next sweep will reach for: `DeleteDataResponse` sits one + * import away from `DeleteMetaItemResponse` and is the WRONG contract for the + * reset door — it is the DATA door's `{ object, id, success }`, whose own + * phantom-`deleted` declaration #5638 removed one door over. Binding it here + * would compile and be false in exactly the direction this card just paid to + * close. + * + * 2. THE GAP THIS BLOCK USED TO PIN AS UNDECLARED IS NOW CLOSED, on the spec + * side, which is the only side allowed to close it: #13208 (issue #13155) + * widened `DeleteMetaItemResponseSchema` to declare `seq` and + * `projectionApplied` — the two wire-receipt keys `deleteMetaItem`'s + * repository-delete branch always sent. This file's two `@ts-expect-error` + * pins on those reads were therefore falsified BY DESIGN (a TS2578 unused + * suppression is exactly how the closure was meant to surface here) and + * are re-judged as the positive reads below: the keys are reachable from + * the BOUND type with no local member list, so a later schema regression + * that drops either key reds these reads as TS2339. The ⛔ against + * hand-completing the annotation stands — nothing here writes members; + * the type still comes whole from `@objectstack/spec`. + */ +declare const metaResetBody: DeleteMetaItemResponse; + +export function deleteDataResponseIsNotTheMetaResetShape(): void { + // @ts-expect-error the DATA door's delete body carries `object`/`id`; the reset door's does not + const mismatched: DeleteDataResponse = metaResetBody; + void mismatched; +} + +export function metaResetResponseDeclaresTheWireReceipt(): void { + // #13208 declared both wire-receipt keys on the schema; these positive + // reads red as TS2339 if either is ever dropped from the bound type. + void metaResetBody.seq; + void metaResetBody.projectionApplied; +} + describe('client SDK return-type precision (#8140)', () => { it('exposes the type-level pins to tsc without executing a request', () => { // The assertions above are evaluated by `tsc` under @@ -549,6 +663,9 @@ describe('client SDK return-type precision (#8140)', () => { expect(typeof returnTypePrecisionPins12038).toBe('function'); expect(typeof returnTypePrecisionPins12034).toBe('function'); expect(typeof returnTypePrecisionPins12104).toBe('function'); + expect(typeof returnTypePrecisionPins13023).toBe('function'); + expect(typeof deleteDataResponseIsNotTheMetaResetShape).toBe('function'); + expect(typeof metaResetResponseDeclaresTheWireReceipt).toBe('function'); expect(typeof commitRollbackResponseIsNotTheVersionRollbackShape).toBe('function'); expect(typeof environmentIsNotTheCloudWireRow).toBe('function'); }); diff --git a/packages/spec/src/migrations/entries/semantic/18.client-meta-reset-result-reset.ts b/packages/spec/src/migrations/entries/semantic/18.client-meta-reset-result-reset.ts new file mode 100644 index 0000000000..f7626ce99e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.client-meta-reset-result-reset.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'client-meta-reset-result-reset', + surface: + 'client.meta.deleteItem(...).deleted / .type / .name (the return of ' + + '`client.meta.deleteItem()` and the environment-scoped ' + + '`client.environment(id).meta.deleteItem()`)', + replacement: + '`reset` — `r.deleted` → `r.reset`. Same call, same wire body, declared shape. Both ' + + 'twins now declare `DeleteMetaItemResponse` (`@objectstack/spec/api`); `type` and ' + + '`name` have no replacement because the reset door never echoed them — the caller ' + + 'already holds both, it passed them in', + reason: + 'Both `deleteItem` declarations on `@objectstack/client` declared ' + + '`Promise<{ type: string; name: string; deleted: boolean }>` while ' + + '`DeleteMetaItemResponseSchema` declares `{ success, reset?, message? }`. The ' + + 'declaration was not merely imprecise, it was UNINHABITED: ' + + '`DELETE /meta/:type/:name` ends in `res.json(result)` with `deleteMetaItem`\'s ' + + 'return, and not one of that method\'s four return branches carries `type`, `name` ' + + 'or `deleted`. Both surfaces are pure `unwrapResponse` / `_unwrap` passthroughs — ' + + 'and the reset body carries no `data` key, so nothing is stripped — which makes the ' + + 'declaration a CLAIM about the wire, never a rewrite of it, and the claim was false ' + + 'in the one direction that matters: the compiler endorsed a spelling no server has ' + + 'ever sent. `if (r.deleted)` compiled and read `undefined` on EVERY reset, including ' + + 'the ones that really removed an overlay row; `if (r.reset)` was rejected by the ' + + 'compiler and correct on the wire. So this REVEALS a defect rather than breaking ' + + 'working code — every reader of the old key was already reading `undefined`, on ' + + 'every deployment and not just some. The truthful flag also carries the distinction ' + + 'the phantom one could not express at all: `reset: true` means an overlay row was ' + + 'deleted, `reset: false` means none existed and the item was already at its artifact ' + + 'default. Registered as a semantic entry rather than a mechanical conversion for the ' + + 'reason the rewrite does not capture: a call site that branched on `r.deleted` has ' + + 'been taking the FALSE branch unconditionally since it was written, and whatever ' + + 'that branch did — or skipped — is what has to be re-read. There is no authored ' + + 'source for the chain to rewrite either; this is a published TypeScript surface whose ' + + 'enforced channel is tsc at the call site, and for an untyped JS caller there is no ' + + 'constrained channel at all, which is why this entry is the only notification that ' + + 'reaches them. ⛔ Do not write `r.reset ?? r.deleted`: there is one producer shape, ' + + 'and a consumer accepting two spellings is what contract-first exists to prevent. No ' + + 'deprecated `deleted?: boolean` transition key ships, for the same reason — a ' + + 'transition period is for keys that WORKED, and this one never did. The identical ' + + 'correction one door over is `client-delete-result-success` (#5638); the wire is ' + + 'deliberately untouched here, per the 2026-08-29 ruling that reality is the ' + + 'contract. ADR-0087, #13023.', + acceptanceCriteria: + 'No code reads `.deleted`, `.type` or `.name` off a `client.meta.deleteItem()` / ' + + '`client.environment(id).meta.deleteItem()` result; `tsc` names every site for a ' + + 'typed caller, and an untyped JS caller must be swept by hand because nothing will ' + + 'report it. Nothing about the request, the route, the status codes or the error ' + + 'shapes changes, and no server needs upgrading — the value you may now read is the ' + + 'one that was already arriving. ⚠️ The real work is behavioural: every ' + + '`if (r.deleted)` has been false since it was written, so re-read what each of those ' + + 'branches was supposed to do. Cache invalidation, registry refreshes and UI reloads ' + + 'guarded that way have never run, and switching to `r.reset` turns them ON for the ' + + 'first time — verify that is what you want rather than assuming it restores prior ' + + 'behaviour. Note `reset` is OPTIONAL in the contract and distinguishes two successful ' + + 'outcomes, so `if (r.reset)` and `if (r.success)` are different questions: the former ' + + 'asks whether a row went away, the latter whether the call was accepted. Any test ' + + 'that passed while asserting on `deleted` was asserting on `undefined` and needs ' + + 'rewriting, not renaming.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 02b7716e01..ee92c86a47 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5569,6 +5569,66 @@ const step18: MigrationStep = { + 'never resolved commands from this declaration — commands are ' + 'oclif-auto-discovered, before and after.', }, + { + id: 'client-meta-reset-result-reset', + surface: + 'client.meta.deleteItem(...).deleted / .type / .name (the return of ' + + '`client.meta.deleteItem()` and the environment-scoped ' + + '`client.environment(id).meta.deleteItem()`)', + replacement: + '`reset` — `r.deleted` → `r.reset`. Same call, same wire body, declared shape. Both ' + + 'twins now declare `DeleteMetaItemResponse` (`@objectstack/spec/api`); `type` and ' + + '`name` have no replacement because the reset door never echoed them — the caller ' + + 'already holds both, it passed them in', + reason: + 'Both `deleteItem` declarations on `@objectstack/client` declared ' + + '`Promise<{ type: string; name: string; deleted: boolean }>` while ' + + '`DeleteMetaItemResponseSchema` declares `{ success, reset?, message? }`. The ' + + 'declaration was not merely imprecise, it was UNINHABITED: ' + + '`DELETE /meta/:type/:name` ends in `res.json(result)` with `deleteMetaItem`\'s ' + + 'return, and not one of that method\'s four return branches carries `type`, `name` ' + + 'or `deleted`. Both surfaces are pure `unwrapResponse` / `_unwrap` passthroughs — ' + + 'and the reset body carries no `data` key, so nothing is stripped — which makes the ' + + 'declaration a CLAIM about the wire, never a rewrite of it, and the claim was false ' + + 'in the one direction that matters: the compiler endorsed a spelling no server has ' + + 'ever sent. `if (r.deleted)` compiled and read `undefined` on EVERY reset, including ' + + 'the ones that really removed an overlay row; `if (r.reset)` was rejected by the ' + + 'compiler and correct on the wire. So this REVEALS a defect rather than breaking ' + + 'working code — every reader of the old key was already reading `undefined`, on ' + + 'every deployment and not just some. The truthful flag also carries the distinction ' + + 'the phantom one could not express at all: `reset: true` means an overlay row was ' + + 'deleted, `reset: false` means none existed and the item was already at its artifact ' + + 'default. Registered as a semantic entry rather than a mechanical conversion for the ' + + 'reason the rewrite does not capture: a call site that branched on `r.deleted` has ' + + 'been taking the FALSE branch unconditionally since it was written, and whatever ' + + 'that branch did — or skipped — is what has to be re-read. There is no authored ' + + 'source for the chain to rewrite either; this is a published TypeScript surface whose ' + + 'enforced channel is tsc at the call site, and for an untyped JS caller there is no ' + + 'constrained channel at all, which is why this entry is the only notification that ' + + 'reaches them. ⛔ Do not write `r.reset ?? r.deleted`: there is one producer shape, ' + + 'and a consumer accepting two spellings is what contract-first exists to prevent. No ' + + 'deprecated `deleted?: boolean` transition key ships, for the same reason — a ' + + 'transition period is for keys that WORKED, and this one never did. The identical ' + + 'correction one door over is `client-delete-result-success` (#5638); the wire is ' + + 'deliberately untouched here, per the 2026-08-29 ruling that reality is the ' + + 'contract. ADR-0087, #13023.', + acceptanceCriteria: + 'No code reads `.deleted`, `.type` or `.name` off a `client.meta.deleteItem()` / ' + + '`client.environment(id).meta.deleteItem()` result; `tsc` names every site for a ' + + 'typed caller, and an untyped JS caller must be swept by hand because nothing will ' + + 'report it. Nothing about the request, the route, the status codes or the error ' + + 'shapes changes, and no server needs upgrading — the value you may now read is the ' + + 'one that was already arriving. ⚠️ The real work is behavioural: every ' + + '`if (r.deleted)` has been false since it was written, so re-read what each of those ' + + 'branches was supposed to do. Cache invalidation, registry refreshes and UI reloads ' + + 'guarded that way have never run, and switching to `r.reset` turns them ON for the ' + + 'first time — verify that is what you want rather than assuming it restores prior ' + + 'behaviour. Note `reset` is OPTIONAL in the contract and distinguishes two successful ' + + 'outcomes, so `if (r.reset)` and `if (r.success)` are different questions: the former ' + + 'asks whether a row went away, the latter whether the call was accepted. Any test ' + + 'that passed while asserting on `deleted` was asserting on `undefined` and needs ' + + 'rewriting, not renaming.', + }, { id: 'dashboard-header-modal-target-page-only', surface: