Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/23350-cli-meta-package-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/cli': minor
'@objectstack/client': patch
---

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)

Clause-②: yes (widening)

**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>`:

```bash
os meta delete view shared_grid --package com.acme.crm # removes com.acme.crm's row of the item, no other package's row
os meta register view --data view-def.json --package com.acme.crm # writes com.acme.crm's row of the item
```

**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.

**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.

**`@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.
167 changes: 163 additions & 4 deletions packages/cli/src/commands/meta/delete-reset-carriers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ import {
} from '@objectstack/platform-objects';
import { RestServer } from '@objectstack/runtime';
import { ObjectStackClient } from '@objectstack/client';
import MetaDelete, { metaDeleteOptions, EMPTY_IF_MATCH_REFUSAL } from './delete.js';
import MetaDelete, {
metaDeleteOptions,
EMPTY_IF_MATCH_REFUSAL,
EMPTY_PACKAGE_DELETE_REFUSAL,
} from './delete.js';

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

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

it('`metaDeleteOptions` returns UNDEFINED when neither flag is given', () => {
Expand Down Expand Up @@ -762,3 +767,157 @@ describe('[#13024] the CLI\'s option bag behaves identically through both SDK tw
expect(Object.keys(scoped.headers).sort()).toEqual(['content-type']);
}, 60_000);
});

// ---------------------------------------------------------------------------
// Part 6 — `--package`: the remedy the door's 409 RESOURCE_CONFLICT prescribes
// ---------------------------------------------------------------------------

/**
* [#23350] Since #23165 the reset door refuses a delete that names no package
* of an item two or more packages hold — `409 RESOURCE_CONFLICT`, naming the
* packages, prescribing `?package=` — and since #23293 the SDK can send it
* (`DeleteMetaItemOptions.packageId`). This command declared no flag for it,
* so a CLI operator who met that refusal was told to do something the command
* could not send. These cases drive the same real door as the parts above.
*/
const PKG = 'com.qa.alpha';
const OTHER_PKG = 'com.qa.beta';

/** One item saved into two packages THROUGH THE SDK: one row per package (ADR-0048). */
async function twoPackageItem(client: ObjectStackClient, name: string) {
await client.meta.saveItem('view', name, VIEW(name, 'A'), { packageId: PKG });
await client.meta.saveItem('view', name, VIEW(name, 'B'), { packageId: OTHER_PKG });
}

/** The package each stored row of `name` is bound to, sorted. */
async function packagesOf(engine: any, name: string): Promise<string[]> {
return (await overlayRows(engine, name)).map((r: any) => String(r.package_id)).sort();
}

describe('[#23350] the `--package` flag `os meta delete` declares', () => {
it('declares `--package` as a value flag whose help names the refusal it answers', () => {
const flags = MetaDelete.flags as Record<string, any>;
expect(flags.package.type).toBe('option');
// An operator who meets the 409 has to be able to find the remedy from
// `--help`: the flag says which refusal it answers.
expect(flags.package.description).toMatch(/409/);
expect(flags.package.description).toMatch(/RESOURCE_CONFLICT/);
const examples = (MetaDelete.examples as string[]).join('\n');
expect(examples).toContain('--package');
});

it('`metaDeleteOptions` forwards the id VERBATIM as `packageId`, beside the other two flags', () => {
expect(metaDeleteOptions({ package: PKG })).toEqual({ packageId: PKG });
expect(metaDeleteOptions({ package: PKG, draft: true, 'if-match': 'v1' }))
.toEqual({ packageId: PKG, state: 'draft', ifMatch: 'v1' });
// No reshaping: the door, not this command, decides what an id names.
expect(metaDeleteOptions({ package: ' com.qa.alpha ' })).toEqual({ packageId: ' com.qa.alpha ' });
// Unset stays `undefined`, so the unflagged request is unchanged.
expect(metaDeleteOptions({ package: undefined })).toBeUndefined();
});

it('an empty or blank `--package` is refused, never sent as a package-less delete', () => {
expect(() => metaDeleteOptions({ package: '' })).toThrow(EMPTY_PACKAGE_DELETE_REFUSAL);
expect(() => metaDeleteOptions({ package: ' ' })).toThrow(EMPTY_PACKAGE_DELETE_REFUSAL);
});
});

describe('[#23350] `os meta delete --package` against the real reset door', () => {
it('NAMING NONE (the only delete this command could express before): the command reports the 409 and nothing is deleted', async () => {
const { engine, client, deleteRequests, wire } = await bootDoor();
await twoPackageItem(client, 'two_pkg_probe');
// The two-package state is reachable through the door: one row per package.
expect(await packagesOf(engine, 'two_pkg_probe')).toEqual([PKG, OTHER_PKG].sort());

const run = await runCli(['view', 'two_pkg_probe', '--format', 'json']);

// The refusal reaches the operator in the machine-readable envelope —
// `code` and `httpStatus`, not merely a failure.
expect(run.exitCode).toBe(1);
const payload = JSON.parse(run.out);
expect(payload.success).toBe(false);
expect(payload.code).toBe('RESOURCE_CONFLICT');
expect(payload.httpStatus).toBe(409);
// It names both packages, so the operator can pick one for `--package`.
expect(String(payload.error)).toContain(PKG);
expect(String(payload.error)).toContain(OTHER_PKG);
// Nothing was deleted.
expect(await packagesOf(engine, 'two_pkg_probe')).toEqual([PKG, OTHER_PKG].sort());
// The probe: no package reached the protocol, and none was on the wire.
expect(deleteRequests).toHaveLength(1);
expect(deleteRequests[0]).not.toHaveProperty('packageId');
expect(wire.filter((w) => w.method === 'DELETE').at(-1)!.search).toBe('');
}, 60_000);

it('NAMING ONE: `--package` removes that package\'s row only, and the other package\'s row stays', async () => {
const { engine, client, deleteRequests, wire } = await bootDoor();
await twoPackageItem(client, 'two_pkg_probe');

const run = await runCli(['view', 'two_pkg_probe', '--package', PKG, '--format', 'json']);

expect(run.exitCode).toBe(0);
expect(JSON.parse(run.out)).toMatchObject({ success: true, deleted: true });
expect(await packagesOf(engine, 'two_pkg_probe')).toEqual([OTHER_PKG]);
// POSITIVE CONTROL for the previous case's absence: the same probe sees
// the package arrive, read off `?package=` by the door.
expect(deleteRequests).toHaveLength(1);
expect(deleteRequests[0].packageId).toBe(PKG);
expect(wire.filter((w) => w.method === 'DELETE').at(-1)!.search).toBe(`?package=${PKG}`);
}, 60_000);

it('NAMING A PACKAGE THAT HOLDS NO ROW: nothing is removed — the flag scopes the delete, it does not widen it', async () => {
const { engine, client, deleteRequests } = await bootDoor();
await twoPackageItem(client, 'two_pkg_probe');

const run = await runCli(['view', 'two_pkg_probe', '--package', 'com.qa.gamma', '--format', 'json']);

expect(run.exitCode).toBe(0);
expect(JSON.parse(run.out)).toMatchObject({ success: true, deleted: false });
expect(await packagesOf(engine, 'two_pkg_probe')).toEqual([PKG, OTHER_PKG].sort());
expect(deleteRequests.at(-1).packageId).toBe('com.qa.gamma');
}, 60_000);

it('`--package` composes with `--draft` and `--if-match` on the wire', async () => {
const { client, wire } = await bootDoor();
await client.meta.saveItem('view', 'compose_probe', VIEW('compose_probe', 'A'), { packageId: PKG });
await runCli(['view', 'compose_probe', '--draft', '--package', PKG, '--if-match', 'sha256:probe']);
const sent = wire.filter((w) => w.method === 'DELETE').at(-1)!;
expect(sent.search).toBe(`?state=draft&package=${PKG}`);
expect(sent.headers['if-match']).toBe('sha256:probe');
}, 60_000);

it('an empty `--package` is refused BEFORE the client is built — nothing reaches the door', async () => {
const { engine, client, deleteRequests } = await bootDoor();
await client.meta.saveItem('view', 'empty_pkg', VIEW('empty_pkg', 'A'), { packageId: PKG });
const requestsBefore = deleteRequests.length;
stub.createCalls = 0;

// `--package "$PKG"` with `PKG` unset. Sent as the package-less delete,
// the door would resolve the item's one package and remove its row while
// the operator's command line reads as naming a package.
const run = await runCli(['view', 'empty_pkg', '--package', '']);
expect(run.exitCode).toBe(1);
expect(run.out).toContain(EMPTY_PACKAGE_DELETE_REFUSAL);
expect(stub.createCalls).toBe(0);
expect(deleteRequests).toHaveLength(requestsBefore);
expect(await packagesOf(engine, 'empty_pkg')).toEqual([PKG]);

// POSITIVE CONTROL — the same instruments move on a run that is let through.
const ok = await runCli(['view', 'empty_pkg', '--package', PKG]);
expect(ok.exitCode).toBe(0);
expect(stub.createCalls).toBe(1);
expect(deleteRequests).toHaveLength(requestsBefore + 1);
expect(await packagesOf(engine, 'empty_pkg')).toEqual([]);
}, 60_000);

it('the bag the CLI builds for `--package` sends identical bytes through both SDK twins', async () => {
const { client, wire } = await bootDoor();
const options = metaDeleteOptions({ package: PKG });
await client.meta.deleteItem('view', 'in_step_pkg', options).catch(() => undefined);
await client.environment('env-23350').meta.deleteItem('view', 'in_step_pkg', options).catch(() => undefined);

const [unscoped, scoped] = wire.filter((w) => w.method === 'DELETE');
expect(scoped.search).toBe(unscoped.search);
expect(unscoped.search).toBe(`?package=${PKG}`);
}, 60_000);
});
57 changes: 52 additions & 5 deletions packages/cli/src/commands/meta/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,26 @@ export const EMPTY_IF_MATCH_REFUSAL =
+ 'Echo the `version` a previous `os meta` save or publish resolved, or omit --if-match '
+ 'to accept the unpinned (last-write-wins) reset.';

/**
* [#23350] The refusal an EMPTY `--package` earns, before anything reaches
* the door — the `--if-match` rule above, for the same reason.
*
* The SDK sends nothing for `packageId: ''` (`DeleteMetaItemOptions.packageId`:
* "`undefined` and `''` both send nothing"), and a delete that names no
* package is not a refusal on every item: the door resolves it, removing the
* row of the one package that holds the item, or the package-unbound row when
* none does. So `os meta delete view x --package "$PKG"` with `PKG` unset would
* remove whichever row that resolution picks while the operator reads their
* command line as naming one package — and where the package they meant holds
* no row, the named delete would have removed nothing. The CLI refuses rather
* than degrades. A non-empty id is forwarded verbatim: the door, not this
* command, decides what an id names.
*/
export const EMPTY_PACKAGE_DELETE_REFUSAL =
'--package needs the id of the package whose row to delete, and was given an empty value. '
+ 'Name the package (for example com.acme.crm), or omit --package to delete the row '
+ 'the server resolves when the item belongs to one package.';

/**
* [#13024] The `DeleteMetaItemOptions` bag this command's flags mean — the one
* place the CLI decides what goes on the wire.
Expand All @@ -45,26 +65,43 @@ export const EMPTY_IF_MATCH_REFUSAL =
* `os meta delete <type> <name>` hands the SDK the same argument list it has
* always handed it and the request stays byte-identical.
*
* ⛔ `?dropStorage` is NOT here and gets no flag. Commit cf71d73f8 shipped two of the
* door's three carriers on purpose: the third ADDS destructive reach (it drops
* the object's physical table), no caller was measured needing it, and the
* door's repeated-parameter refusal exists because of that destructiveness.
* Publishing it from the CLI would reverse that ruling from the layer above.
* The reset door (`DELETE /meta/:type/:name`) reads these carriers: the
* `If-Match` header and the `state`, `package`, `dropStorage` and `member`
* query parameters (`META_ITEM_RESET_PARAMS` in `@objectstack/rest` is the
* query half). This command sends the first three, each from its own flag:
* `--if-match`, `--draft` and `--package` (#23350). The other two get no flag:
*
* ⛔ `?dropStorage` is NOT here and gets no flag, on purpose (commit cf71d73f8):
* it ADDS destructive reach (it drops the object's physical table), no caller
* was measured needing it, and the door's repeated-parameter refusal exists
* because of that destructiveness. Publishing it from the CLI would reverse
* that ruling from the layer above.
*
* `?member` (one member of a bundled type) gets no flag because
* `DeleteMetaItemOptions` declares no member for it, and this command sends
* only what the SDK bag carries.
*/
export function metaDeleteOptions(flags: {
'if-match'?: string;
draft?: boolean;
package?: string;
}): DeleteMetaItemOptions | undefined {
const pin = flags['if-match'];
if (pin !== undefined && pin.trim() === '') {
throw new Error(EMPTY_IF_MATCH_REFUSAL);
}
const packageId = flags.package;
if (packageId !== undefined && packageId.trim() === '') {
throw new Error(EMPTY_PACKAGE_DELETE_REFUSAL);
}
const options: DeleteMetaItemOptions = {};
if (pin !== undefined) options.ifMatch = pin;
// `state: 'active'` is never sent: it is the door's default said out loud,
// and the SDK drops it. The flag is therefore a boolean, matching every
// other binary opt-in in this CLI.
if (flags.draft) options.state = 'draft';
// [#23350] Verbatim, as the SDK takes it: `packageId` → `?package=`.
if (packageId !== undefined) options.packageId = packageId;
return Object.keys(options).length > 0 ? options : undefined;
}

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

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

async run(): Promise<void> {
Expand Down
Loading
Loading