Skip to content

Commit f7005cb

Browse files
feat(runtime): PATCH /api/v1/packages/:id edits a package's manifest dependencies (#24703)
Fixes #24670 Clause-②: yes (widening) ## What changed `PATCH /api/v1/packages/:id` now accepts the manifest's `dependencies` row beside `name`, `description` and `version`, flat or under the `manifest` wrapper. - **The door** (`packages/runtime/src/domains/packages.ts`, the `PATCH /packages/:id` arm only). The row is judged by `ManifestSchema.shape.dependencies` from `@objectstack/spec`, the row `POST /packages` parses at create. Nothing is restated. A malformed row is refused `400 VALIDATION_ERROR`, the envelope the create door answers for the same row, before the protocol or the registry is reached. So nothing is written, including a valid key sent beside it. The "nothing to update" 400 now names `dependencies` among the accepted keys. - **The registry** (`packages/objectql/src/registry.ts`, `SchemaRegistry.updatePackageManifest` only). It copies `dependencies` when present. The row replaces the stored one whole, never a deep merge, and the registry keeps its own copy. An entry the row leaves out is removed, and `{}` clears the declaration. Replacing the object, rather than editing it in place, also keeps the protocol's shallow pre-patch snapshot able to put the prior row back when the durable write fails. - **The protocol** (`packages/metadata-protocol/src/protocol.ts`, `updatePackage` only). The `patch` type gains `dependencies`. Persistence needed no change: `updatePackage` already re-persists the whole merged manifest through `persistPackageManifest`. The objectui half (Studio's edit dialog sends only the three keys) is not in this PR. Triage `6279169921` routes it to objectui once this door accepts the row. ## Mechanism assumptions, measured 1. **The widened registry merge carries `dependencies` into `sys_packages`.** Holds on both branches. On the protocol branch, pin (a) reads the `sys_packages` row through the real `PackageServicePlugin` and re-reads it after a restart. On the in-memory fallback, the door test reads the registry and `GET /packages/:id`. 2. **The closure reader reads `manifest.dependencies` live.** Holds. The one runtime reader is `resolveWritePackageScope` (`protocol.ts`). It walks `registry.getPackage(id).manifest.dependencies` on every write and caches nothing. `git grep` found no other runtime reader of `manifest.dependencies` in `metadata-protocol`, `objectql`, `runtime`, `services/*` or `lint`. Pin (d) shows the next save sees the edit with nothing retired. 3. **A malformed row takes the create door's envelope.** Holds. `POST /packages` with `dependencies: { "com.acme.core": 1 }` answers `400 VALIDATION_ERROR`. The PATCH answers the same `status` and `code`, and a parity test pins this. 4. **The new key follows the arm's flat or wrapped reading.** Holds. Both forms are pinned, and the refusal locates the key as `dependencies.KEY` or `manifest.dependencies.KEY`, following the form the caller wrote. ## Removing a dependency: readings for the seat The PATCH applies the row as given, and no in-use check was built. Measured over the integration harness below, which runs the real `ObjectQL`, `SqlDriver` (better-sqlite3), `PackageServicePlugin`, protocol on the default authoring channel and `HttpDispatcher`. Package P declares D. A list view stored in P binds to D's object. Then `PATCH /packages/P` sends `{ "dependencies": {} }`, which answers `200`, and the steps below were measured with a one-off probe that is not committed: | step | answer | |:--|:--| | read, `protocol.getMetaItem` | resolves; `_diagnostics: { valid: true }` | | read, `GET /meta/view/NAME` (dispatcher) | `200`, no `_diagnostics` on the item | | registry after removal | still serves the view | | re-save of the unchanged body, `protocol.saveMetaItem` | `422 INVALID_METADATA`, rule `list-view-object-outside-closure`; the stored row is unchanged | | re-save, `PUT /meta/view/NAME?package=P` (dispatcher) | `422 INVALID_METADATA`, rule `list-view-object-outside-closure` | | re-hydrate in process, `loadMetaFromDb()` | `{ loaded: 2, errors: 0, invalid: 0, storeUnavailable: false }`; the view stays registered | | restart (same database), package row | `dependencies: {}` served | | restart, `loadMetaFromDb()` | `{ loaded: 2, errors: 0, invalid: 0, storeUnavailable: false }`; the view is registered | | restart, read `GET /meta/view/NAME` | `200` | | restart, re-save | `422 INVALID_METADATA`, `list-view-object-outside-closure` | So an item stored before the removal keeps loading and serving, and neither the boot count nor the read badge flags it. Its next save is refused with the closure verdict, which names the remedy (declare the dependency again). ## Tests All runs are on head `0fcd8e42c` unless noted. `@objectstack/objectql` and `@objectstack/metadata-protocol` resolve to `dist/` in the runtime suites, so both were rebuilt before every reading. - **Red before the fix** (commit `7a83ce21f`, pre-fix build): 13 of 16 runtime cases and both registry cases failed. The `(d)` refusal step already passed (`422 INVALID_METADATA`, `list-view-object-outside-closure`) and the case failed at the PATCH: `400 "Body { name?, description?, version? } — nothing to update"`. A valid `name` sent beside a malformed row answered `200` and was written. - **New pins, green:** - `packages/runtime/src/domains/packages-patch-dependencies.test.ts`: real `HttpDispatcher` over a real `SchemaRegistry`. Covers the row persisted flat and wrapped, whole-row replace, four malformed rows refused with nothing written, refusal before the protocol is called, parity with `POST /packages`, and (c) the three existing keys as before. - `packages/runtime/src/package-dependencies-patch.integration.test.ts`: (a) persisted, served, stored in `sys_packages` and served after a restart; (b) malformed refused with neither store moving, and a `sys_packages`-refused edit (a trigger) answers `500` with the prior row back in the registry, `GET` and the store; (d) the remedy end to end. - `packages/objectql/src/registry.test.ts`: two `[#24670]` cases. - Result: `Tests 17 passed (17)` and `Tests 103 passed (103)`. - **Package suites:** - `@objectstack/objectql` (`--project local`): `Test Files 448 passed (448)`, `Tests 8449 passed (8449)`. - `@objectstack/metadata-protocol`: `Test Files 249 passed | 3 skipped (252)`, `Tests 20781 passed | 19 skipped (20800)`. - `@objectstack/runtime` (`--project local`): `Test Files 427 passed (427)`, `Tests 6695 passed | 19 skipped (6714)`. - Each `VERDICT command-exit 0`. - **Typecheck:** `pnpm --filter` on the three packages exits 0, including each package's `check:test-typecheck`. - **Ablation** (commit `c88492672`). Through `scripts/ablation-replace.mjs`, the line `if (patch.dependencies !== undefined) manifest.dependencies = { ...patch.dependencies };` was deleted: anchor 1 to 0, blob `0d0948aec118` to `d2dd6f3e9d62`. - `objectql` was rebuilt, and `ablation-dist-preflight.mjs @objectstack/objectql "manifest.dependencies = {" --absent` found the marker absent from all 14 built files. Before the mutation it was present in 4 (`core.js`, `core.mjs`, `index.js`, `index.mjs`). - Red under the ablation: (a), (d), the rollback case, the three fallback-persistence cases and both registry cases. The door's refusal cases stayed green, as expected: they do not reach the registry. - Restore: blob back to `0d0948aec118`, `git diff HEAD` empty. After a rebuild, the preflight found the marker in 4 files, the tree was clean, and the 17 + 2 cases were green. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands` at `0fcd8e42c` derived 72 commands. `--ran` reconciliation: 72 accounted, 71 exit 0, 1 NOT MEASURED. - **NOT MEASURED:** `pnpm check:dual-build-cjs-loads` exits 3 with PREREQUISITE NOT MET. It reads the whole workspace's `dist/`, and only this card's closure was built. CI's `Lint & Repo Gates` runs it. - `check-engine-split-ratio --days 90` and `check-plugin-teardown-shape --self-test` refused on the shallow clone. After deepening (`git fetch --shallow-since=2026-07-14`), both exit 0. - The roster families whose roster is in a touched directory (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:route-ledger-census`) exit 0. - `pnpm lint` (`eslint . --no-inline-config`) exits 0 at `0fcd8e42c`. ## Export measurement (sets the bumps) The `dist` `.d.ts` was compared before and after. - **`@objectstack/objectql`:** `index.d.ts` and `core.d.ts` are identical, apart from the chunk hash. One signature changed: `SchemaRegistry.updatePackageManifest`'s `patch` gains `dependencies?: Record(string, string)`. - **`@objectstack/metadata-protocol`:** one signature changed: `updatePackage`'s `request.patch` gains the same optional key. No export was added, removed or renamed. Both are accept-set widenings, so the changeset takes `minor` on all three packages (runtime, objectql, metadata-protocol). ## Acceptance notes - **The spec contract still declares three keys.** `EngineSchemaRegistryView.updatePackageManifest` (`packages/spec/src/contracts/objectql-engine.ts:131`) types the patch as `name`, `description` and `version`. The door's in-memory fallback calls the registry through that contract type, and it compiles because the patch is a declared variable, not a fresh literal. `packages/spec` is held off in this dispatch. Carrier: the `domain:spec` seat. - **The ADR-0070 read-only gate is on `main` (#24705, PR #24719 → `9b2953d62`) and runs before this PR's body read.** So a package the deployment ships answers `422 WRITABLE_PACKAGE_REQUIRED` for a `dependencies` edit too, with nothing written. #24719's pins cover a `{ dependencies: {} }` body on all four read-only kinds, on both limbs, and stay green on this branch after it merged `main` (merge `7a8557cff`). _(This note replaces the round-1 note that said the arm asked no read-only question. Edited by the `domain:cli` seat 2 after the merge.)_ - **`updatePackage` is typed `any` in this file.** `PackagesDomainProtocol.updatePackage` is still `(request: any)`, in the "undeclared request shapes" group of `packages.ts`. Typing it from the producer's own signature, as `revertStoredPackage` is, would make a key this door sends that the producer does not declare a compile error. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 39e93b9 commit f7005cb

7 files changed

Lines changed: 645 additions & 17 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/metadata-protocol': minor
5+
---
6+
7+
feat(runtime): `PATCH /api/v1/packages/:id` edits a package's manifest `dependencies`
8+
9+
Clause-②: yes (widening)
10+
11+
- **The door.** `PATCH /api/v1/packages/:id` accepts `dependencies` beside `name`, `description` and `version`, flat or under the `manifest` wrapper. The row is judged by `ManifestSchema`'s own `dependencies` row (package ids mapped to version requirements), the row `POST /api/v1/packages` judges at create. A malformed row (a non-string version, a list, a bare string, `null`) is refused `400 VALIDATION_ERROR`, the envelope the create door answers for it, and nothing is written, a valid key sent beside it included.
12+
- **The row replaces the stored one whole.** An entry the row leaves out is removed, and `{}` clears the declaration. On a host with the `package` service the edit is written to `sys_packages` and survives a restart; when that write is refused, the prior row is put back and the door answers the failure.
13+
- **Why.** A save into a package is judged against the package's closure, its declared `dependencies` included, and a binding outside it is refused with the remedy "declare a dependency on the package that defines it in the `dependencies` of the package's manifest". Before this change `dependencies` could be set only at create, so for a package that already existed that remedy had no door. The closure is read per save, so the next save sees the edit.
14+
- **Removing an entry is applied as given.** No in-use check is made. An item already stored that binds to an object the edit takes out of the closure keeps loading and serving after a restart; its next save is refused with the closure verdict (`422 INVALID_METADATA`; for a list view, `list-view-object-outside-closure`).
15+
- **Published signatures.** `SchemaRegistry.updatePackageManifest` (`@objectstack/objectql`) and `ObjectStackProtocolImplementation.updatePackage` (`@objectstack/metadata-protocol`) each accept an optional `dependencies` row in their patch. No export is added, removed or renamed.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32494,11 +32494,13 @@ export class ObjectStackProtocolImplementation implements
3249432494
}
3249532495

3249632496
/**
32497-
* Edit an installed package's manifest (name / description / version) — the
32498-
* durable half of `PATCH /packages/:id`. Merges the patch into the registry
32499-
* (preserving lifecycle state — see {@link SchemaRegistry.updatePackageManifest})
32500-
* then re-persists the merged manifest to `sys_packages` via the `package`
32501-
* service so the edit survives a restart.
32497+
* Edit an installed package's manifest (name / description / version /
32498+
* dependencies) — the durable half of `PATCH /packages/:id`. Merges the
32499+
* patch into the registry (preserving lifecycle state — see
32500+
* {@link SchemaRegistry.updatePackageManifest}; `dependencies` replaces the
32501+
* stored row whole) then re-persists the merged manifest to `sys_packages`
32502+
* via the `package` service so the edit survives a restart. The
32503+
* `dependencies` row arrives judged by the door (`ManifestSchema`'s row).
3250232504
*
3250332505
* [#21243] A failed persist FAILS the edit, exactly as it fails
3250432506
* `installPackage`: the registry row is put back to what it held before the
@@ -32511,7 +32513,7 @@ export class ObjectStackProtocolImplementation implements
3251132513
*/
3251232514
async updatePackage(request: {
3251332515
packageId: string;
32514-
patch: { name?: string; description?: string; version?: string };
32516+
patch: { name?: string; description?: string; version?: string; dependencies?: Record<string, string> };
3251532517
}): Promise<{ package: any; message: string }> {
3251632518
// [#21243] The row as it stood before the patch — copied, because
3251732519
// `updatePackageManifest` edits the row and its manifest IN PLACE.

‎packages/objectql/src/registry.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,46 @@ describe('SchemaRegistry', () => {
578578
it('updatePackageManifest returns undefined for an unknown package', () => {
579579
expect(registry.updatePackageManifest('nope', { name: 'x' })).toBeUndefined();
580580
});
581+
582+
// [#24670] `dependencies` is the declaration the save door's closure
583+
// verdict reads, and its refusal prescribes editing it — so the edit
584+
// has to reach the manifest the next save reads.
585+
it('[#24670] updatePackageManifest replaces `dependencies` WHOLE — the row as given, never a deep merge', () => {
586+
registry.installPackage({
587+
id: 'com.test',
588+
name: 'Keep',
589+
description: 'kept',
590+
version: '1.0.0',
591+
dependencies: { 'com.acme.core': '^1.0.0', 'com.acme.old': '^1.0.0' },
592+
} as any, 'authored');
593+
registry.disablePackage('com.test');
594+
const row = { 'com.acme.core': '^2.0.0', 'com.acme.new': '^1.0.0' };
595+
596+
const updated = registry.updatePackageManifest('com.test', { dependencies: row });
597+
598+
// `com.acme.old` is gone: an entry the row leaves out is removed, not merged back.
599+
expect((updated?.manifest as any).dependencies).toEqual(row);
600+
// The registry keeps its own copy, not the caller's object.
601+
row['com.acme.core'] = 'mutated by the caller';
602+
expect((registry.getPackage('com.test')?.manifest as any).dependencies['com.acme.core']).toBe('^2.0.0');
603+
// The keys the patch did not name, and the lifecycle, are untouched.
604+
expect(updated?.manifest.name).toBe('Keep');
605+
expect((updated?.manifest as any).description).toBe('kept');
606+
expect(updated?.manifest.version).toBe('1.0.0');
607+
expect(updated?.enabled).toBe(false);
608+
});
609+
610+
it('[#24670] an empty `dependencies` row clears the declaration; a patch without the key leaves it', () => {
611+
registry.installPackage({
612+
id: 'com.test', name: 'Keep', version: '1.0.0', dependencies: { 'com.acme.core': '^1.0.0' },
613+
} as any, 'authored');
614+
615+
registry.updatePackageManifest('com.test', { description: 'no dependencies key' });
616+
expect((registry.getPackage('com.test')?.manifest as any).dependencies).toEqual({ 'com.acme.core': '^1.0.0' });
617+
618+
registry.updatePackageManifest('com.test', { dependencies: {} });
619+
expect((registry.getPackage('com.test')?.manifest as any).dependencies).toEqual({});
620+
});
581621
});
582622

583623
// ==========================================

‎packages/objectql/src/registry.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6206,25 +6206,37 @@ export class SchemaRegistry {
62066206
}
62076207

62086208
/**
6209-
* Merge editable manifest fields (name / description / version) into an
6210-
* installed package — the in-memory half of `PATCH /packages/:id`.
6209+
* Merge editable manifest fields (name / description / version /
6210+
* dependencies) into an installed package — the in-memory half of
6211+
* `PATCH /packages/:id`.
62116212
*
62126213
* Only the human-editable fields are touched: `id` is identity (never
62136214
* changes here), and lifecycle facets (`status` / `enabled` / `installedAt`)
62146215
* are preserved — this is a metadata edit, NOT a reinstall (which
62156216
* `installPackage` would be, resetting exactly those). Undefined patch
62166217
* fields are ignored so a partial PATCH only overwrites what it sends.
6218+
*
6219+
* [#24670] `dependencies` is replaced WHOLE, never deep-merged: the row the
6220+
* caller sends is the declaration, so an entry it leaves out is removed and
6221+
* `{}` clears it. The registry keeps its own copy of the row. Replacing the
6222+
* object (rather than editing the stored one in place) is also what keeps
6223+
* the protocol's pre-patch snapshot — a shallow copy of the manifest — able
6224+
* to put the prior row back when the durable write fails. The row is judged
6225+
* by the door before it gets here (`ManifestSchema`'s `dependencies` row);
6226+
* the closure the save door reads off it is resolved per write, so the next
6227+
* save sees the edit with nothing cached to retire.
62176228
*/
62186229
updatePackageManifest(
62196230
id: string,
6220-
patch: { name?: string; description?: string; version?: string },
6231+
patch: { name?: string; description?: string; version?: string; dependencies?: Record<string, string> },
62216232
): InstalledPackage | undefined {
62226233
const pkg = this.getPackage(id);
62236234
if (!pkg) return undefined;
62246235
const manifest = pkg.manifest as Record<string, unknown>;
62256236
if (patch.name !== undefined) manifest.name = patch.name;
62266237
if (patch.description !== undefined) manifest.description = patch.description;
62276238
if (patch.version !== undefined) manifest.version = patch.version;
6239+
if (patch.dependencies !== undefined) manifest.dependencies = { ...patch.dependencies };
62286240
pkg.updatedAt = new Date().toISOString();
62296241
this.log(`[Registry] Updated package manifest: ${id}`);
62306242
return pkg;
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24670] `PATCH /api/v1/packages/:id` accepts the manifest's `dependencies`
5+
* row, judged by `ManifestSchema`'s own `dependencies` row from
6+
* `@objectstack/spec` and never restated here.
7+
*
8+
* ## The defect
9+
*
10+
* The door built its patch from `name`, `description` and `version` only, so a
11+
* package's `dependencies` could be set at create and never again. The save
12+
* door's closure verdict reads exactly that declaration, and its refusal tells
13+
* the author to "declare a dependency … in the `dependencies` of P's
14+
* manifest" — a remedy no door applied to a package that already exists.
15+
*
16+
* ## What this file pins (the in-memory registry and the door's own judgment)
17+
*
18+
* - the row is persisted: flat or under the `manifest` wrapper, the registry's
19+
* manifest carries it and `GET /packages/:id` serves it; the row replaces
20+
* the stored one whole;
21+
* - a malformed row is refused `400 VALIDATION_ERROR` — the envelope
22+
* `POST /packages` answers for the same row at create — and NOTHING is
23+
* written: not the row, not a valid `name` sent beside it, and on the
24+
* protocol branch the protocol is never called;
25+
* - `name`, `description` and `version` behave as before, and a patch that
26+
* does not send `dependencies` leaves the stored row alone.
27+
*
28+
* The protocol branch's durable half (`sys_packages`, a restart) and the
29+
* remedy end to end are pinned over the shipped pieces in
30+
* `../package-dependencies-patch.integration.test.ts`.
31+
*
32+
* ## Harness
33+
*
34+
* The real `HttpDispatcher` over a real `SchemaRegistry`. `@objectstack/objectql`
35+
* resolves through `exports` to its `dist/`: rebuild it before reading a
36+
* result here after touching `registry.ts`.
37+
*/
38+
39+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
40+
import { mkdtempSync, rmSync } from 'node:fs';
41+
import { tmpdir } from 'node:os';
42+
import { join } from 'node:path';
43+
import { SchemaRegistry } from '@objectstack/objectql';
44+
import { HttpDispatcher } from '../http-dispatcher.js';
45+
46+
const PKG_ADMIN = () => ({
47+
request: {},
48+
executionContext: {
49+
userId: 'u_pkg_admin',
50+
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
51+
},
52+
}) as any;
53+
54+
let home: string;
55+
let priorHome: string | undefined;
56+
57+
beforeAll(() => {
58+
priorHome = process.env.OS_HOME;
59+
home = mkdtempSync(join(tmpdir(), 'os-24670-'));
60+
process.env.OS_HOME = home;
61+
});
62+
63+
afterAll(() => {
64+
if (priorHome === undefined) delete process.env.OS_HOME;
65+
else process.env.OS_HOME = priorHome;
66+
rmSync(home, { recursive: true, force: true });
67+
});
68+
69+
const ID = 'com.acme.crm';
70+
const DECLARED = { 'com.acme.core': '^1.0.0' };
71+
72+
/** A complete authoring-stage manifest, declaring one dependency. */
73+
const manifest = () => ({
74+
id: ID,
75+
name: 'Acme CRM',
76+
namespace: 'crm',
77+
version: '1.0.0',
78+
type: 'app',
79+
description: 'before',
80+
dependencies: { ...DECLARED },
81+
});
82+
83+
/**
84+
* One process: a registry holding the package, and the door. With
85+
* `protocol`, the door's protocol branch reaches that double; without it, the
86+
* door takes its in-memory fallback.
87+
*/
88+
function boot(protocol?: { updatePackage: (...args: any[]) => any }) {
89+
const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
90+
(registry as any).logLevel = 'silent';
91+
registry.installPackage(manifest() as any, 'authored');
92+
const kernel: any = {
93+
getService: (name: string) => {
94+
if (name === 'objectql') return Promise.resolve({ registry });
95+
if (name === 'protocol' && protocol) return Promise.resolve(protocol);
96+
return null;
97+
},
98+
context: { getService: () => null },
99+
};
100+
const dispatcher = new HttpDispatcher(kernel);
101+
const call = async (method: string, path: string, body?: unknown) => {
102+
const res = await dispatcher.handlePackages(path, method, body, {}, PKG_ADMIN());
103+
const json = JSON.parse(JSON.stringify(res.response?.body ?? null));
104+
return { status: res.response?.status ?? 0, code: json?.error?.code as string | undefined, body: json };
105+
};
106+
/** The manifest the registry holds, as a plain value. */
107+
const stored = () => JSON.parse(JSON.stringify(registry.getPackage(ID)?.manifest ?? null));
108+
return { registry, call, stored };
109+
}
110+
111+
/** Rows `ManifestSchema`'s `dependencies` row refuses: a non-string version, a list, a scalar, a null. */
112+
const MALFORMED: ReadonlyArray<[string, unknown]> = [
113+
['a non-string version requirement', { 'com.acme.core': 1 }],
114+
['a list of ids', ['com.acme.core']],
115+
['a bare string', 'com.acme.core'],
116+
['null', null],
117+
];
118+
119+
describe('[#24670] PATCH /packages/:id — `dependencies` is persisted (in-memory fallback)', () => {
120+
for (const [form, wrap] of [
121+
['flat', (patch: object) => patch],
122+
['under the `manifest` wrapper', (patch: object) => ({ manifest: patch })],
123+
] as const) {
124+
it(`${form}: the row reaches the registry's manifest and GET /packages/:id serves it`, async () => {
125+
const p = boot();
126+
const row = { 'com.acme.core': '^2.0.0', 'com.acme.billing': '^1.0.0' };
127+
128+
const patched = await p.call('PATCH', `/${ID}`, wrap({ dependencies: row }));
129+
130+
expect(patched.status, JSON.stringify(patched.body)).toBe(200);
131+
expect(p.stored().dependencies).toEqual(row);
132+
const read = await p.call('GET', `/${ID}`);
133+
expect(read.status).toBe(200);
134+
expect(read.body?.data?.manifest?.dependencies).toEqual(row);
135+
});
136+
}
137+
138+
it('the row replaces the stored one whole: an entry it leaves out is removed, and `{}` clears it', async () => {
139+
const p = boot();
140+
141+
await p.call('PATCH', `/${ID}`, { dependencies: { 'com.acme.billing': '^1.0.0' } });
142+
expect(p.stored().dependencies).toEqual({ 'com.acme.billing': '^1.0.0' });
143+
144+
const cleared = await p.call('PATCH', `/${ID}`, { dependencies: {} });
145+
expect(cleared.status).toBe(200);
146+
expect(p.stored().dependencies).toEqual({});
147+
});
148+
});
149+
150+
describe('[#24670] PATCH /packages/:id — a malformed `dependencies` row is refused, and nothing is written', () => {
151+
for (const [what, row] of MALFORMED) {
152+
it(`${what}: 400 VALIDATION_ERROR naming \`dependencies\`, the stored manifest unchanged`, async () => {
153+
const p = boot();
154+
const before = p.stored();
155+
156+
const answer = await p.call('PATCH', `/${ID}`, { dependencies: row });
157+
158+
expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
159+
expect(answer.body?.error?.message).toContain('dependencies');
160+
expect(p.stored()).toEqual(before);
161+
});
162+
}
163+
164+
it('a valid `name` sent beside a malformed row is not written either', async () => {
165+
const p = boot();
166+
const before = p.stored();
167+
168+
const answer = await p.call('PATCH', `/${ID}`, { manifest: { name: 'Renamed', dependencies: { x: 2 } } });
169+
170+
expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
171+
expect(p.stored()).toEqual(before);
172+
});
173+
174+
it('the protocol branch: refused before the protocol is called', async () => {
175+
const updatePackage = vi.fn();
176+
const p = boot({ updatePackage });
177+
178+
const answer = await p.call('PATCH', `/${ID}`, { dependencies: { 'com.acme.core': 1 } });
179+
180+
expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
181+
expect(updatePackage).not.toHaveBeenCalled();
182+
});
183+
184+
it('the same envelope `POST /packages` answers for the same row at create', async () => {
185+
const p = boot();
186+
const row = { 'com.acme.core': 1 };
187+
188+
const atCreate = await p.call('POST', '', { manifest: { ...manifest(), id: 'com.acme.other', namespace: 'other', dependencies: row } });
189+
const atEdit = await p.call('PATCH', `/${ID}`, { dependencies: row });
190+
191+
expect({ status: atCreate.status, code: atCreate.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
192+
expect({ status: atEdit.status, code: atEdit.code }).toEqual({ status: atCreate.status, code: atCreate.code });
193+
});
194+
});
195+
196+
describe('[#24670] PATCH /packages/:id — the three existing keys behave as before', () => {
197+
it('a name / description / version patch persists exactly those and leaves `dependencies` as stored', async () => {
198+
const p = boot();
199+
200+
const answer = await p.call('PATCH', `/${ID}`, { name: ' Renamed ', description: 'after', version: '2.0.0' });
201+
202+
expect(answer.status).toBe(200);
203+
expect(p.stored()).toMatchObject({ name: 'Renamed', description: 'after', version: '2.0.0', dependencies: DECLARED });
204+
});
205+
206+
it('an empty name and a non-SemVer version are still refused 400, and an empty body names `dependencies` among the keys', async () => {
207+
const p = boot();
208+
const before = p.stored();
209+
210+
expect((await p.call('PATCH', `/${ID}`, { name: ' ' })).status).toBe(400);
211+
expect((await p.call('PATCH', `/${ID}`, { version: '1.2' })).status).toBe(400);
212+
const empty = await p.call('PATCH', `/${ID}`, {});
213+
expect({ status: empty.status, code: empty.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
214+
expect(empty.body?.error?.message).toContain('dependencies');
215+
expect(p.stored()).toEqual(before);
216+
});
217+
218+
it('the protocol branch receives the parsed row beside the other keys', async () => {
219+
const updatePackage = vi.fn().mockResolvedValue({ package: { id: ID } });
220+
const p = boot({ updatePackage });
221+
222+
const answer = await p.call('PATCH', `/${ID}`, { manifest: { description: 'after', dependencies: DECLARED } });
223+
224+
expect(answer.status).toBe(200);
225+
expect(updatePackage).toHaveBeenCalledWith({ packageId: ID, patch: { description: 'after', dependencies: DECLARED } });
226+
});
227+
});

0 commit comments

Comments
 (0)