Skip to content

Commit a1285f2

Browse files
feat(cli): os meta delete and os meta register take --package, forwarded as the SDK's packageId (#23350) (#23475)
Fixes #23350 Clause-②: yes (widening) ## What this does `os meta delete` and `os meta register` each take an optional `--package` flag (a package id, for example `com.acme.crm`) and forward it verbatim as the SDK's `packageId`, which the SDK sends as `?package=`: - `os meta delete`: `metaDeleteOptions` maps `--package` to `DeleteMetaItemOptions.packageId`, beside `--if-match` and `--draft`. This is the remedy the reset door's `409 RESOURCE_CONFLICT` prescribes for an item that two or more packages hold. - `os meta register`: the new exported `metaRegisterOptions` maps `--package` to `SaveMetaItemOptions.packageId`, and `run()` passes that bag to `client.meta.saveItem`. Before this change the call took no options. - Without `--package`, both commands send the same request as before (pinned on the wire below). - An empty or blank `--package` is refused before a client exists, the same way an empty `--if-match` already is. For example, `--package "$PKG"` with `PKG` unset expands to an empty value, and the SDK would send that as a package-less request. A package-less request is not always refused: for an item one package holds, the door resolves it to that package's row. So the operator's command line would read as naming one package while the server picks the row. This is the CLI's own input refusal. A non-empty id is never reshaped. - The two docblocks the card names now list the reset door's carriers instead of counting them: `DeleteMetaItemOptions` in `packages/client/src/index.ts`, and `metaDeleteOptions` in `packages/cli/src/commands/meta/delete.ts`. Both state why `dropStorage` and `member` are not sent. ## Measurements (the dispatch's hypotheses) All measured on `origin/main` `08ea7e368` (the base of this branch). - **H1 (delete):** confirmed. The SDK option is `DeleteMetaItemOptions.packageId`, sent by `metaDeleteQuery` as `?package=` behind a truthy guard. The flag forwards the string unchanged: `metaDeleteOptions({ package: ' com.qa.alpha ' })` answers `{ packageId: ' com.qa.alpha ' }`. On the real door, `--package com.qa.alpha` puts `?package=com.qa.alpha` on the wire, and the door hands the protocol `packageId: 'com.qa.alpha'`. - **H2 (register):** confirmed. `SaveMetaItemOptions.packageId` exists and is sent as `?package=` by `metaSaveQuery`. `os meta register` saves **active**: unflagged, it sends `PUT /api/v1/meta/view/NAME` with no query string and no `?mode=`, and the row lands with `state: 'active'`. - **The door's answer to a package-less active save of an item two packages hold:** `409 RESOURCE_CONFLICT`. The CLI's `--format json` envelope carries `code: 'RESOURCE_CONFLICT'` and `httpStatus: 409`, and its message names both packages. Nothing is written: both rows keep their bodies. - This was measured through the real oclif command against the real `RestServer` route, the real `ObjectStackProtocolImplementation` and real SQLite `sys_metadata` tables (`register-package.test.ts`, case 2). - So the active save refuses exactly as the draft save does since PR #23408. - **H3 (carriers):** confirmed, five carriers. The reset door reads the `If-Match` header plus the query parameters `state`, `dropStorage`, `package` and `member`. The query half is `META_ITEM_RESET_PARAMS` in `packages/rest/src/rest-server.ts`. The actor comes from the session, not the request. The docblocks now name all five rather than counting them. - **H4 (generated reference):** nothing reads these flags. - No oclif manifest is generated or committed. - No page under `content/docs/**` other than the release-owned `releases/` tree mentions `os meta delete` or `os meta register` flags. - `check:cli-examples-parity`'s table has no `meta` row. - `scripts/docs-audit/affected-docs.mjs` maps `register.ts` to the phrase `os meta register` as a reviewer prompt, not a generated file. `check-affected-docs.mjs` ran green on this diff. - Nothing to regenerate. ## Where the edits sit All inside the claim's file surface: - `packages/cli/src/commands/meta/delete.ts`: the flag, `metaDeleteOptions`, its docblock, `EMPTY_PACKAGE_DELETE_REFUSAL` and one example. - `packages/cli/src/commands/meta/register.ts`: the flag, `metaRegisterOptions`, `EMPTY_PACKAGE_SAVE_REFUSAL`, the `saveItem` options and one example. - `packages/client/src/index.ts`: the `DeleteMetaItemOptions` header only. Comment text, no type change. - Tests: - `packages/cli/src/commands/meta/delete-reset-carriers.test.ts` gains Part 6, plus one title and comment in Part 1 that stated the old count. - `packages/cli/src/commands/meta/register-package.test.ts` is new and uses the same real-door harness. - `.changeset/23350-cli-meta-package-flag.md`: `@objectstack/cli` minor; `@objectstack/client` patch, because the docblock ships in `dist/index.d.ts` (measured: `grep -c "reads these carriers" packages/client/dist/index.d.ts` answers 1, and `files` lists `dist`). ## Tests Code under test is identical at `7560e7f9e` and the head `7ec2fb6c7`; the only commit between them adds the changeset. **The pins (triage `6157686519`), each with a negative control, all through the real door:** - Delete, no flag, two packages: `--format json` reports `code: 'RESOURCE_CONFLICT'` and `httpStatus: 409`, both rows remain, no `packageId` reaches the protocol, and the wire search is empty. - Delete, `--package com.qa.alpha`: alpha's row is removed and beta's remains. The same probe now sees `packageId` arrive, and the wire reads `?package=com.qa.alpha`. A package that holds no row (`com.qa.gamma`) removes nothing and answers `deleted: false`. - Register, `--package com.qa.alpha`: alpha's row gets the new body and beta's is untouched. The negative control is the unflagged two-package save above, refused 409 with nothing written. - Also covered: - the empty-refusal cases, each with a positive control on the same instruments; - `--package` composed with `--draft` and `--if-match` (`?state=draft&package=com.qa.alpha` plus the header); - identical bytes through both SDK twins; - a save into a new package. **Runs, each under `scripts/pm/os-verify-lock.sh`:** - `vitest run --project integration` on the two meta files at `7ec2fb6c7`: `Test Files 2 passed (2)`, `Tests 37 passed (37)`. Per-file test time on the shared box: `delete-reset-carriers` 8.8 s, `register-package` 7.2 s. - `pnpm --filter @objectstack/cli exec vitest run --project unit` at `7560e7f9e`: `Tests 4278 passed | 29 skipped`, with 285 of 287 files passed. - The 2 failed files were `test/published-subpath-console.pin.test.ts` and `test/published-subpath-hook-body.pin.test.ts`. Both refused with "packages/cli is not built", a prerequisite rather than a verdict. - After `pnpm --filter @objectstack/cli build`, those two files plus `test/vitest-tiers-partition.test.ts`: `Test Files 3 passed (3)`, `Tests 51 passed (51)`. - `pnpm --filter @objectstack/cli typecheck`: exit 0, and `check:test-typecheck: OK`. `tsc --noEmit --listFiles` lists both meta test files. - `pnpm --filter @objectstack/client typecheck`, then `vitest run` in `packages/client`: exit 0, `Test Files 51 passed (51)`, `Tests 663 passed (663)`. - The integration layer beyond the two touched files is declared to CI. No spawn test covers `os meta delete` or `os meta register`: `test/remote-api-commands.test.ts` imports the command classes and is unit-tier. **Ablation (one-off, no file kept), on the delete flag's forwarding:** - The mutation went through `node scripts/ablation-replace.mjs`. It replaced `if (packageId !== undefined) options.packageId = packageId;` with `void packageId; /* ABLATION-23350: forwarding removed */`. - Anchor count 1 to 0; blob `614a82e6f310` to `39e86c3e9afe`. - No build or `dist` step applies: the subject is imported relatively from `src` (`./delete.js`). - The run: `Tests 5 failed | 24 passed (29)`. These five went red: - the `metaDeleteOptions` mapping; - NAMING ONE (exit 1, the 409, where 0 was expected); - NAMING A PACKAGE THAT HOLDS NO ROW; - the composition case; - the in-step twins case. - As predicted, two cases stayed green: NAMING NONE (the negative control) and the empty-refusal case. In the empty-refusal case, a package-less delete of a one-package item resolves to that package anyway. - Restored: blob back to `614a82e6f310`, equal to HEAD, and `git diff HEAD` empty. ## Gates - Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `7ec2fb6c7`: 66 commands, each run and recorded with its exit code before any pipe. - `--ran` reconciliation: "66 derived famil(ies) accounted for — 63 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)". - The three exit-3 gates are `PREREQUISITE NOT MET` in this checkout (packages outside this card are not built). They are declared to CI: - `check:skill-examples` - `check:dual-build-cjs-loads` - `check:i18n-coverage` - The derivation warned that `origin/main` had moved 3 commits (`4e0838d99`, `8d5b7bb94`, `b60eb78d3`) since the base. - They touch `packages/spec/src/stack.zod.ts`, `service-mailbox` and two gate files. None of them is in `packages/cli` or `packages/client`. - So `main` was not merged before opening. CI runs the merge ref. ## Acceptance notes Observations, not filed. - The door reads `?package=all` as naming no package (`metaItemPackageBinding`, shared with the save door; the SDK's `DeleteMetaItemOptions.packageId` docblock states it). So `--package all` reaches the door as a package-less request. The flag forwards verbatim by design, and what an id names is the door's decision. - `packages/client/src/meta-delete-item-carriers.test.ts`'s header still narrates "three carriers" and then "a fourth carrier joined later". That is a historical note in a test file outside this claim's surface, left as is. - `delete-reset-carriers.test.ts` now measures 8.8 s of test time on the shared box, under the 10 s line. --- _Generated by [Claude Code](https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 22fb225 commit a1285f2

6 files changed

Lines changed: 739 additions & 13 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/cli': minor
3+
'@objectstack/client': patch
4+
---
5+
6+
feat(cli): `os meta delete` and `os meta register` take `--package <id>`, so a CLI user can follow the `409 RESOURCE_CONFLICT` remedy the metadata doors prescribe for an item two packages hold (#23350)
7+
8+
Clause-②: yes (widening)
9+
10+
**What is new.** Both commands declare an optional `--package` flag, a package id such as `com.acme.crm`, and forward it verbatim as the SDK's `packageId`, sent as `?package=<id>`:
11+
12+
```bash
13+
os meta delete view shared_grid --package com.acme.crm # removes com.acme.crm's row of the item, no other package's row
14+
os meta register view --data view-def.json --package com.acme.crm # writes com.acme.crm's row of the item
15+
```
16+
17+
**Why.** A metadata item can hold one row per package (ADR-0048). When two or more packages hold an item, the reset door `DELETE /api/v1/meta/:type/:name` and the save door `PUT /api/v1/meta/:type/:name` both refuse a request that names no package, with `409 RESOURCE_CONFLICT`. The refusal names the packages and prescribes `?package=`. Neither command could send it, so a CLI user was told to do something the CLI could not do.
18+
19+
**Unchanged.** Without `--package`, both commands send the same request as before: `os meta delete` adds no query string unless `--draft` is given, and `os meta register` saves active with no query string. `--package` combines with `--draft` and `--if-match` on `os meta delete`. An empty `--package` (for example `--package "$PKG"` with `PKG` unset) is refused before anything is sent, as an empty `--if-match` already is, because a package-less request is not always refused: the server may resolve it to a row the operator did not mean.
20+
21+
**`@objectstack/client` (patch).** The `DeleteMetaItemOptions` docblock now names the reset door's carriers (`If-Match`, `state`, `package`, `dropStorage`, `member`) instead of stating a count that had gone stale. No type or behavior changes.

‎packages/cli/src/commands/meta/delete-reset-carriers.test.ts‎

Lines changed: 163 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ import {
7878
} from '@objectstack/platform-objects';
7979
import { RestServer } from '@objectstack/runtime';
8080
import { ObjectStackClient } from '@objectstack/client';
81-
import MetaDelete, { metaDeleteOptions, EMPTY_IF_MATCH_REFUSAL } from './delete.js';
81+
import MetaDelete, {
82+
metaDeleteOptions,
83+
EMPTY_IF_MATCH_REFUSAL,
84+
EMPTY_PACKAGE_DELETE_REFUSAL,
85+
} from './delete.js';
8286

8387
/**
8488
* The state the `createApiClient` stub reads, and the call counter that makes
@@ -388,17 +392,18 @@ describe('[#13024] the flags `os meta delete` declares', () => {
388392
expect(String((refused.thrown as Error)?.message)).toMatch(/state/i);
389393
}, 60_000);
390394

391-
it('the withheld third carrier has no flag, under either spelling', () => {
395+
it('the withheld `?dropStorage` carrier has no flag, under either spelling', () => {
392396
const names = Object.keys(MetaDelete.flags as Record<string, unknown>);
393-
// Commit cf71d73f8 shipped two of the door's three carriers on purpose; `?dropStorage`
394-
// is the one that ADDS destructive reach. A CLI flag for it would reverse
397+
// `?dropStorage` is withheld on purpose (commit cf71d73f8): it is the
398+
// carrier that ADDS destructive reach. A CLI flag for it would reverse
395399
// that ruling from the layer above.
396400
expect(names).not.toContain('dropStorage');
397401
expect(names).not.toContain('drop-storage');
398402
// POSITIVE CONTROL for those two absences: the same lookup on the same
399403
// object sees the flags that ARE declared.
400404
expect(names).toContain('if-match');
401405
expect(names).toContain('draft');
406+
expect(names).toContain('package');
402407
});
403408

404409
it('`metaDeleteOptions` returns UNDEFINED when neither flag is given', () => {
@@ -762,3 +767,157 @@ describe('[#13024] the CLI\'s option bag behaves identically through both SDK tw
762767
expect(Object.keys(scoped.headers).sort()).toEqual(['content-type']);
763768
}, 60_000);
764769
});
770+
771+
// ---------------------------------------------------------------------------
772+
// Part 6 — `--package`: the remedy the door's 409 RESOURCE_CONFLICT prescribes
773+
// ---------------------------------------------------------------------------
774+
775+
/**
776+
* [#23350] Since #23165 the reset door refuses a delete that names no package
777+
* of an item two or more packages hold — `409 RESOURCE_CONFLICT`, naming the
778+
* packages, prescribing `?package=` — and since #23293 the SDK can send it
779+
* (`DeleteMetaItemOptions.packageId`). This command declared no flag for it,
780+
* so a CLI operator who met that refusal was told to do something the command
781+
* could not send. These cases drive the same real door as the parts above.
782+
*/
783+
const PKG = 'com.qa.alpha';
784+
const OTHER_PKG = 'com.qa.beta';
785+
786+
/** One item saved into two packages THROUGH THE SDK: one row per package (ADR-0048). */
787+
async function twoPackageItem(client: ObjectStackClient, name: string) {
788+
await client.meta.saveItem('view', name, VIEW(name, 'A'), { packageId: PKG });
789+
await client.meta.saveItem('view', name, VIEW(name, 'B'), { packageId: OTHER_PKG });
790+
}
791+
792+
/** The package each stored row of `name` is bound to, sorted. */
793+
async function packagesOf(engine: any, name: string): Promise<string[]> {
794+
return (await overlayRows(engine, name)).map((r: any) => String(r.package_id)).sort();
795+
}
796+
797+
describe('[#23350] the `--package` flag `os meta delete` declares', () => {
798+
it('declares `--package` as a value flag whose help names the refusal it answers', () => {
799+
const flags = MetaDelete.flags as Record<string, any>;
800+
expect(flags.package.type).toBe('option');
801+
// An operator who meets the 409 has to be able to find the remedy from
802+
// `--help`: the flag says which refusal it answers.
803+
expect(flags.package.description).toMatch(/409/);
804+
expect(flags.package.description).toMatch(/RESOURCE_CONFLICT/);
805+
const examples = (MetaDelete.examples as string[]).join('\n');
806+
expect(examples).toContain('--package');
807+
});
808+
809+
it('`metaDeleteOptions` forwards the id VERBATIM as `packageId`, beside the other two flags', () => {
810+
expect(metaDeleteOptions({ package: PKG })).toEqual({ packageId: PKG });
811+
expect(metaDeleteOptions({ package: PKG, draft: true, 'if-match': 'v1' }))
812+
.toEqual({ packageId: PKG, state: 'draft', ifMatch: 'v1' });
813+
// No reshaping: the door, not this command, decides what an id names.
814+
expect(metaDeleteOptions({ package: ' com.qa.alpha ' })).toEqual({ packageId: ' com.qa.alpha ' });
815+
// Unset stays `undefined`, so the unflagged request is unchanged.
816+
expect(metaDeleteOptions({ package: undefined })).toBeUndefined();
817+
});
818+
819+
it('an empty or blank `--package` is refused, never sent as a package-less delete', () => {
820+
expect(() => metaDeleteOptions({ package: '' })).toThrow(EMPTY_PACKAGE_DELETE_REFUSAL);
821+
expect(() => metaDeleteOptions({ package: ' ' })).toThrow(EMPTY_PACKAGE_DELETE_REFUSAL);
822+
});
823+
});
824+
825+
describe('[#23350] `os meta delete --package` against the real reset door', () => {
826+
it('NAMING NONE (the only delete this command could express before): the command reports the 409 and nothing is deleted', async () => {
827+
const { engine, client, deleteRequests, wire } = await bootDoor();
828+
await twoPackageItem(client, 'two_pkg_probe');
829+
// The two-package state is reachable through the door: one row per package.
830+
expect(await packagesOf(engine, 'two_pkg_probe')).toEqual([PKG, OTHER_PKG].sort());
831+
832+
const run = await runCli(['view', 'two_pkg_probe', '--format', 'json']);
833+
834+
// The refusal reaches the operator in the machine-readable envelope —
835+
// `code` and `httpStatus`, not merely a failure.
836+
expect(run.exitCode).toBe(1);
837+
const payload = JSON.parse(run.out);
838+
expect(payload.success).toBe(false);
839+
expect(payload.code).toBe('RESOURCE_CONFLICT');
840+
expect(payload.httpStatus).toBe(409);
841+
// It names both packages, so the operator can pick one for `--package`.
842+
expect(String(payload.error)).toContain(PKG);
843+
expect(String(payload.error)).toContain(OTHER_PKG);
844+
// Nothing was deleted.
845+
expect(await packagesOf(engine, 'two_pkg_probe')).toEqual([PKG, OTHER_PKG].sort());
846+
// The probe: no package reached the protocol, and none was on the wire.
847+
expect(deleteRequests).toHaveLength(1);
848+
expect(deleteRequests[0]).not.toHaveProperty('packageId');
849+
expect(wire.filter((w) => w.method === 'DELETE').at(-1)!.search).toBe('');
850+
}, 60_000);
851+
852+
it('NAMING ONE: `--package` removes that package\'s row only, and the other package\'s row stays', async () => {
853+
const { engine, client, deleteRequests, wire } = await bootDoor();
854+
await twoPackageItem(client, 'two_pkg_probe');
855+
856+
const run = await runCli(['view', 'two_pkg_probe', '--package', PKG, '--format', 'json']);
857+
858+
expect(run.exitCode).toBe(0);
859+
expect(JSON.parse(run.out)).toMatchObject({ success: true, deleted: true });
860+
expect(await packagesOf(engine, 'two_pkg_probe')).toEqual([OTHER_PKG]);
861+
// POSITIVE CONTROL for the previous case's absence: the same probe sees
862+
// the package arrive, read off `?package=` by the door.
863+
expect(deleteRequests).toHaveLength(1);
864+
expect(deleteRequests[0].packageId).toBe(PKG);
865+
expect(wire.filter((w) => w.method === 'DELETE').at(-1)!.search).toBe(`?package=${PKG}`);
866+
}, 60_000);
867+
868+
it('NAMING A PACKAGE THAT HOLDS NO ROW: nothing is removed — the flag scopes the delete, it does not widen it', async () => {
869+
const { engine, client, deleteRequests } = await bootDoor();
870+
await twoPackageItem(client, 'two_pkg_probe');
871+
872+
const run = await runCli(['view', 'two_pkg_probe', '--package', 'com.qa.gamma', '--format', 'json']);
873+
874+
expect(run.exitCode).toBe(0);
875+
expect(JSON.parse(run.out)).toMatchObject({ success: true, deleted: false });
876+
expect(await packagesOf(engine, 'two_pkg_probe')).toEqual([PKG, OTHER_PKG].sort());
877+
expect(deleteRequests.at(-1).packageId).toBe('com.qa.gamma');
878+
}, 60_000);
879+
880+
it('`--package` composes with `--draft` and `--if-match` on the wire', async () => {
881+
const { client, wire } = await bootDoor();
882+
await client.meta.saveItem('view', 'compose_probe', VIEW('compose_probe', 'A'), { packageId: PKG });
883+
await runCli(['view', 'compose_probe', '--draft', '--package', PKG, '--if-match', 'sha256:probe']);
884+
const sent = wire.filter((w) => w.method === 'DELETE').at(-1)!;
885+
expect(sent.search).toBe(`?state=draft&package=${PKG}`);
886+
expect(sent.headers['if-match']).toBe('sha256:probe');
887+
}, 60_000);
888+
889+
it('an empty `--package` is refused BEFORE the client is built — nothing reaches the door', async () => {
890+
const { engine, client, deleteRequests } = await bootDoor();
891+
await client.meta.saveItem('view', 'empty_pkg', VIEW('empty_pkg', 'A'), { packageId: PKG });
892+
const requestsBefore = deleteRequests.length;
893+
stub.createCalls = 0;
894+
895+
// `--package "$PKG"` with `PKG` unset. Sent as the package-less delete,
896+
// the door would resolve the item's one package and remove its row while
897+
// the operator's command line reads as naming a package.
898+
const run = await runCli(['view', 'empty_pkg', '--package', '']);
899+
expect(run.exitCode).toBe(1);
900+
expect(run.out).toContain(EMPTY_PACKAGE_DELETE_REFUSAL);
901+
expect(stub.createCalls).toBe(0);
902+
expect(deleteRequests).toHaveLength(requestsBefore);
903+
expect(await packagesOf(engine, 'empty_pkg')).toEqual([PKG]);
904+
905+
// POSITIVE CONTROL — the same instruments move on a run that is let through.
906+
const ok = await runCli(['view', 'empty_pkg', '--package', PKG]);
907+
expect(ok.exitCode).toBe(0);
908+
expect(stub.createCalls).toBe(1);
909+
expect(deleteRequests).toHaveLength(requestsBefore + 1);
910+
expect(await packagesOf(engine, 'empty_pkg')).toEqual([]);
911+
}, 60_000);
912+
913+
it('the bag the CLI builds for `--package` sends identical bytes through both SDK twins', async () => {
914+
const { client, wire } = await bootDoor();
915+
const options = metaDeleteOptions({ package: PKG });
916+
await client.meta.deleteItem('view', 'in_step_pkg', options).catch(() => undefined);
917+
await client.environment('env-23350').meta.deleteItem('view', 'in_step_pkg', options).catch(() => undefined);
918+
919+
const [unscoped, scoped] = wire.filter((w) => w.method === 'DELETE');
920+
expect(scoped.search).toBe(unscoped.search);
921+
expect(unscoped.search).toBe(`?package=${PKG}`);
922+
}, 60_000);
923+
});

‎packages/cli/src/commands/meta/delete.ts‎

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,26 @@ export const EMPTY_IF_MATCH_REFUSAL =
3030
+ 'Echo the `version` a previous `os meta` save or publish resolved, or omit --if-match '
3131
+ 'to accept the unpinned (last-write-wins) reset.';
3232

33+
/**
34+
* [#23350] The refusal an EMPTY `--package` earns, before anything reaches
35+
* the door — the `--if-match` rule above, for the same reason.
36+
*
37+
* The SDK sends nothing for `packageId: ''` (`DeleteMetaItemOptions.packageId`:
38+
* "`undefined` and `''` both send nothing"), and a delete that names no
39+
* package is not a refusal on every item: the door resolves it, removing the
40+
* row of the one package that holds the item, or the package-unbound row when
41+
* none does. So `os meta delete view x --package "$PKG"` with `PKG` unset would
42+
* remove whichever row that resolution picks while the operator reads their
43+
* command line as naming one package — and where the package they meant holds
44+
* no row, the named delete would have removed nothing. The CLI refuses rather
45+
* than degrades. A non-empty id is forwarded verbatim: the door, not this
46+
* command, decides what an id names.
47+
*/
48+
export const EMPTY_PACKAGE_DELETE_REFUSAL =
49+
'--package needs the id of the package whose row to delete, and was given an empty value. '
50+
+ 'Name the package (for example com.acme.crm), or omit --package to delete the row '
51+
+ 'the server resolves when the item belongs to one package.';
52+
3353
/**
3454
* [#13024] The `DeleteMetaItemOptions` bag this command's flags mean — the one
3555
* place the CLI decides what goes on the wire.
@@ -45,26 +65,43 @@ export const EMPTY_IF_MATCH_REFUSAL =
4565
* `os meta delete <type> <name>` hands the SDK the same argument list it has
4666
* always handed it and the request stays byte-identical.
4767
*
48-
* ⛔ `?dropStorage` is NOT here and gets no flag. Commit cf71d73f8 shipped two of the
49-
* door's three carriers on purpose: the third ADDS destructive reach (it drops
50-
* the object's physical table), no caller was measured needing it, and the
51-
* door's repeated-parameter refusal exists because of that destructiveness.
52-
* Publishing it from the CLI would reverse that ruling from the layer above.
68+
* The reset door (`DELETE /meta/:type/:name`) reads these carriers: the
69+
* `If-Match` header and the `state`, `package`, `dropStorage` and `member`
70+
* query parameters (`META_ITEM_RESET_PARAMS` in `@objectstack/rest` is the
71+
* query half). This command sends the first three, each from its own flag:
72+
* `--if-match`, `--draft` and `--package` (#23350). The other two get no flag:
73+
*
74+
* ⛔ `?dropStorage` is NOT here and gets no flag, on purpose (commit cf71d73f8):
75+
* it ADDS destructive reach (it drops the object's physical table), no caller
76+
* was measured needing it, and the door's repeated-parameter refusal exists
77+
* because of that destructiveness. Publishing it from the CLI would reverse
78+
* that ruling from the layer above.
79+
*
80+
* `?member` (one member of a bundled type) gets no flag because
81+
* `DeleteMetaItemOptions` declares no member for it, and this command sends
82+
* only what the SDK bag carries.
5383
*/
5484
export function metaDeleteOptions(flags: {
5585
'if-match'?: string;
5686
draft?: boolean;
87+
package?: string;
5788
}): DeleteMetaItemOptions | undefined {
5889
const pin = flags['if-match'];
5990
if (pin !== undefined && pin.trim() === '') {
6091
throw new Error(EMPTY_IF_MATCH_REFUSAL);
6192
}
93+
const packageId = flags.package;
94+
if (packageId !== undefined && packageId.trim() === '') {
95+
throw new Error(EMPTY_PACKAGE_DELETE_REFUSAL);
96+
}
6297
const options: DeleteMetaItemOptions = {};
6398
if (pin !== undefined) options.ifMatch = pin;
6499
// `state: 'active'` is never sent: it is the door's default said out loud,
65100
// and the SDK drops it. The flag is therefore a boolean, matching every
66101
// other binary opt-in in this CLI.
67102
if (flags.draft) options.state = 'draft';
103+
// [#23350] Verbatim, as the SDK takes it: `packageId` → `?package=`.
104+
if (packageId !== undefined) options.packageId = packageId;
68105
return Object.keys(options).length > 0 ? options : undefined;
69106
}
70107

@@ -77,6 +114,7 @@ export default class MetaDelete extends Command {
77114
'$ os meta delete object my_custom_object --format json',
78115
'$ os meta delete view shared_grid --if-match "$VERSION"',
79116
'$ os meta delete view shared_grid --draft',
117+
'$ os meta delete view shared_grid --package com.acme.crm',
80118
];
81119

82120
static override args = {
@@ -132,6 +170,15 @@ export default class MetaDelete extends Command {
132170
+ 'Without it the reset is the full one and drops the published overlay too.',
133171
default: false,
134172
}),
173+
// [#23350] The package whose row the delete removes, forwarded verbatim as
174+
// `DeleteMetaItemOptions.packageId` (`?package=`). It is the remedy the
175+
// door's `409 RESOURCE_CONFLICT` prescribes for an item two packages hold.
176+
package: Flags.string({
177+
description:
178+
'Delete only the row of this package (a package id, e.g. com.acme.crm). Required when '
179+
+ 'two or more packages hold the item: without it the server refuses with 409 '
180+
+ 'RESOURCE_CONFLICT and names the packages. Other packages\' rows are left in place.',
181+
}),
135182
};
136183

137184
async run(): Promise<void> {

0 commit comments

Comments
 (0)