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
15 changes: 15 additions & 0 deletions .changeset/24670-package-dependencies-patch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/runtime': minor
'@objectstack/objectql': minor
'@objectstack/metadata-protocol': minor
---

feat(runtime): `PATCH /api/v1/packages/:id` edits a package's manifest `dependencies`

Clause-②: yes (widening)

- **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.
- **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.
- **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.
- **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`).
- **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.
14 changes: 8 additions & 6 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32494,11 +32494,13 @@ export class ObjectStackProtocolImplementation implements
}

/**
* Edit an installed package's manifest (name / description / version) — the
* durable half of `PATCH /packages/:id`. Merges the patch into the registry
* (preserving lifecycle state — see {@link SchemaRegistry.updatePackageManifest})
* then re-persists the merged manifest to `sys_packages` via the `package`
* service so the edit survives a restart.
* Edit an installed package's manifest (name / description / version /
* dependencies) — the durable half of `PATCH /packages/:id`. Merges the
* patch into the registry (preserving lifecycle state — see
* {@link SchemaRegistry.updatePackageManifest}; `dependencies` replaces the
* stored row whole) then re-persists the merged manifest to `sys_packages`
* via the `package` service so the edit survives a restart. The
* `dependencies` row arrives judged by the door (`ManifestSchema`'s row).
*
* [#21243] A failed persist FAILS the edit, exactly as it fails
* `installPackage`: the registry row is put back to what it held before the
Expand All @@ -32511,7 +32513,7 @@ export class ObjectStackProtocolImplementation implements
*/
async updatePackage(request: {
packageId: string;
patch: { name?: string; description?: string; version?: string };
patch: { name?: string; description?: string; version?: string; dependencies?: Record<string, string> };
}): Promise<{ package: any; message: string }> {
// [#21243] The row as it stood before the patch — copied, because
// `updatePackageManifest` edits the row and its manifest IN PLACE.
Expand Down
40 changes: 40 additions & 0 deletions packages/objectql/src/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,46 @@ describe('SchemaRegistry', () => {
it('updatePackageManifest returns undefined for an unknown package', () => {
expect(registry.updatePackageManifest('nope', { name: 'x' })).toBeUndefined();
});

// [#24670] `dependencies` is the declaration the save door's closure
// verdict reads, and its refusal prescribes editing it — so the edit
// has to reach the manifest the next save reads.
it('[#24670] updatePackageManifest replaces `dependencies` WHOLE — the row as given, never a deep merge', () => {
registry.installPackage({
id: 'com.test',
name: 'Keep',
description: 'kept',
version: '1.0.0',
dependencies: { 'com.acme.core': '^1.0.0', 'com.acme.old': '^1.0.0' },
} as any, 'authored');
registry.disablePackage('com.test');
const row = { 'com.acme.core': '^2.0.0', 'com.acme.new': '^1.0.0' };

const updated = registry.updatePackageManifest('com.test', { dependencies: row });

// `com.acme.old` is gone: an entry the row leaves out is removed, not merged back.
expect((updated?.manifest as any).dependencies).toEqual(row);
// The registry keeps its own copy, not the caller's object.
row['com.acme.core'] = 'mutated by the caller';
expect((registry.getPackage('com.test')?.manifest as any).dependencies['com.acme.core']).toBe('^2.0.0');
// The keys the patch did not name, and the lifecycle, are untouched.
expect(updated?.manifest.name).toBe('Keep');
expect((updated?.manifest as any).description).toBe('kept');
expect(updated?.manifest.version).toBe('1.0.0');
expect(updated?.enabled).toBe(false);
});

it('[#24670] an empty `dependencies` row clears the declaration; a patch without the key leaves it', () => {
registry.installPackage({
id: 'com.test', name: 'Keep', version: '1.0.0', dependencies: { 'com.acme.core': '^1.0.0' },
} as any, 'authored');

registry.updatePackageManifest('com.test', { description: 'no dependencies key' });
expect((registry.getPackage('com.test')?.manifest as any).dependencies).toEqual({ 'com.acme.core': '^1.0.0' });

registry.updatePackageManifest('com.test', { dependencies: {} });
expect((registry.getPackage('com.test')?.manifest as any).dependencies).toEqual({});
});
});

// ==========================================
Expand Down
18 changes: 15 additions & 3 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6206,25 +6206,37 @@ export class SchemaRegistry {
}

/**
* Merge editable manifest fields (name / description / version) into an
* installed package — the in-memory half of `PATCH /packages/:id`.
* Merge editable manifest fields (name / description / version /
* dependencies) into an installed package — the in-memory half of
* `PATCH /packages/:id`.
*
* Only the human-editable fields are touched: `id` is identity (never
* changes here), and lifecycle facets (`status` / `enabled` / `installedAt`)
* are preserved — this is a metadata edit, NOT a reinstall (which
* `installPackage` would be, resetting exactly those). Undefined patch
* fields are ignored so a partial PATCH only overwrites what it sends.
*
* [#24670] `dependencies` is replaced WHOLE, never deep-merged: the row the
* caller sends is the declaration, so an entry it leaves out is removed and
* `{}` clears it. The registry keeps its own copy of the row. Replacing the
* object (rather than editing the stored one in place) is also what keeps
* the protocol's pre-patch snapshot — a shallow copy of the manifest — able
* to put the prior row back when the durable write fails. The row is judged
* by the door before it gets here (`ManifestSchema`'s `dependencies` row);
* the closure the save door reads off it is resolved per write, so the next
* save sees the edit with nothing cached to retire.
*/
updatePackageManifest(
id: string,
patch: { name?: string; description?: string; version?: string },
patch: { name?: string; description?: string; version?: string; dependencies?: Record<string, string> },
): InstalledPackage | undefined {
const pkg = this.getPackage(id);
if (!pkg) return undefined;
const manifest = pkg.manifest as Record<string, unknown>;
if (patch.name !== undefined) manifest.name = patch.name;
if (patch.description !== undefined) manifest.description = patch.description;
if (patch.version !== undefined) manifest.version = patch.version;
if (patch.dependencies !== undefined) manifest.dependencies = { ...patch.dependencies };
pkg.updatedAt = new Date().toISOString();
this.log(`[Registry] Updated package manifest: ${id}`);
return pkg;
Expand Down
227 changes: 227 additions & 0 deletions packages/runtime/src/domains/packages-patch-dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24670] `PATCH /api/v1/packages/:id` accepts the manifest's `dependencies`
* row, judged by `ManifestSchema`'s own `dependencies` row from
* `@objectstack/spec` and never restated here.
*
* ## The defect
*
* The door built its patch from `name`, `description` and `version` only, so a
* package's `dependencies` could be set at create and never again. The save
* door's closure verdict reads exactly that declaration, and its refusal tells
* the author to "declare a dependency … in the `dependencies` of P's
* manifest" — a remedy no door applied to a package that already exists.
*
* ## What this file pins (the in-memory registry and the door's own judgment)
*
* - the row is persisted: flat or under the `manifest` wrapper, the registry's
* manifest carries it and `GET /packages/:id` serves it; the row replaces
* the stored one whole;
* - a malformed row is refused `400 VALIDATION_ERROR` — the envelope
* `POST /packages` answers for the same row at create — and NOTHING is
* written: not the row, not a valid `name` sent beside it, and on the
* protocol branch the protocol is never called;
* - `name`, `description` and `version` behave as before, and a patch that
* does not send `dependencies` leaves the stored row alone.
*
* The protocol branch's durable half (`sys_packages`, a restart) and the
* remedy end to end are pinned over the shipped pieces in
* `../package-dependencies-patch.integration.test.ts`.
*
* ## Harness
*
* The real `HttpDispatcher` over a real `SchemaRegistry`. `@objectstack/objectql`
* resolves through `exports` to its `dist/`: rebuild it before reading a
* result here after touching `registry.ts`.
*/

import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { SchemaRegistry } from '@objectstack/objectql';
import { HttpDispatcher } from '../http-dispatcher.js';

const PKG_ADMIN = () => ({
request: {},
executionContext: {
userId: 'u_pkg_admin',
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
},
}) as any;

let home: string;
let priorHome: string | undefined;

beforeAll(() => {
priorHome = process.env.OS_HOME;
home = mkdtempSync(join(tmpdir(), 'os-24670-'));
process.env.OS_HOME = home;
});

afterAll(() => {
if (priorHome === undefined) delete process.env.OS_HOME;
else process.env.OS_HOME = priorHome;
rmSync(home, { recursive: true, force: true });
});

const ID = 'com.acme.crm';
const DECLARED = { 'com.acme.core': '^1.0.0' };

/** A complete authoring-stage manifest, declaring one dependency. */
const manifest = () => ({
id: ID,
name: 'Acme CRM',
namespace: 'crm',
version: '1.0.0',
type: 'app',
description: 'before',
dependencies: { ...DECLARED },
});

/**
* One process: a registry holding the package, and the door. With
* `protocol`, the door's protocol branch reaches that double; without it, the
* door takes its in-memory fallback.
*/
function boot(protocol?: { updatePackage: (...args: any[]) => any }) {
const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
(registry as any).logLevel = 'silent';
registry.installPackage(manifest() as any, 'authored');
const kernel: any = {
getService: (name: string) => {
if (name === 'objectql') return Promise.resolve({ registry });
if (name === 'protocol' && protocol) return Promise.resolve(protocol);
return null;
},
context: { getService: () => null },
};
const dispatcher = new HttpDispatcher(kernel);
const call = async (method: string, path: string, body?: unknown) => {
const res = await dispatcher.handlePackages(path, method, body, {}, PKG_ADMIN());
const json = JSON.parse(JSON.stringify(res.response?.body ?? null));
return { status: res.response?.status ?? 0, code: json?.error?.code as string | undefined, body: json };
};
/** The manifest the registry holds, as a plain value. */
const stored = () => JSON.parse(JSON.stringify(registry.getPackage(ID)?.manifest ?? null));
return { registry, call, stored };
}

/** Rows `ManifestSchema`'s `dependencies` row refuses: a non-string version, a list, a scalar, a null. */
const MALFORMED: ReadonlyArray<[string, unknown]> = [
['a non-string version requirement', { 'com.acme.core': 1 }],
['a list of ids', ['com.acme.core']],
['a bare string', 'com.acme.core'],
['null', null],
];

describe('[#24670] PATCH /packages/:id — `dependencies` is persisted (in-memory fallback)', () => {
for (const [form, wrap] of [
['flat', (patch: object) => patch],
['under the `manifest` wrapper', (patch: object) => ({ manifest: patch })],
] as const) {
it(`${form}: the row reaches the registry's manifest and GET /packages/:id serves it`, async () => {
const p = boot();
const row = { 'com.acme.core': '^2.0.0', 'com.acme.billing': '^1.0.0' };

const patched = await p.call('PATCH', `/${ID}`, wrap({ dependencies: row }));

expect(patched.status, JSON.stringify(patched.body)).toBe(200);
expect(p.stored().dependencies).toEqual(row);
const read = await p.call('GET', `/${ID}`);
expect(read.status).toBe(200);
expect(read.body?.data?.manifest?.dependencies).toEqual(row);
});
}

it('the row replaces the stored one whole: an entry it leaves out is removed, and `{}` clears it', async () => {
const p = boot();

await p.call('PATCH', `/${ID}`, { dependencies: { 'com.acme.billing': '^1.0.0' } });
expect(p.stored().dependencies).toEqual({ 'com.acme.billing': '^1.0.0' });

const cleared = await p.call('PATCH', `/${ID}`, { dependencies: {} });
expect(cleared.status).toBe(200);
expect(p.stored().dependencies).toEqual({});
});
});

describe('[#24670] PATCH /packages/:id — a malformed `dependencies` row is refused, and nothing is written', () => {
for (const [what, row] of MALFORMED) {
it(`${what}: 400 VALIDATION_ERROR naming \`dependencies\`, the stored manifest unchanged`, async () => {
const p = boot();
const before = p.stored();

const answer = await p.call('PATCH', `/${ID}`, { dependencies: row });

expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
expect(answer.body?.error?.message).toContain('dependencies');
expect(p.stored()).toEqual(before);
});
}

it('a valid `name` sent beside a malformed row is not written either', async () => {
const p = boot();
const before = p.stored();

const answer = await p.call('PATCH', `/${ID}`, { manifest: { name: 'Renamed', dependencies: { x: 2 } } });

expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
expect(p.stored()).toEqual(before);
});

it('the protocol branch: refused before the protocol is called', async () => {
const updatePackage = vi.fn();
const p = boot({ updatePackage });

const answer = await p.call('PATCH', `/${ID}`, { dependencies: { 'com.acme.core': 1 } });

expect({ status: answer.status, code: answer.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
expect(updatePackage).not.toHaveBeenCalled();
});

it('the same envelope `POST /packages` answers for the same row at create', async () => {
const p = boot();
const row = { 'com.acme.core': 1 };

const atCreate = await p.call('POST', '', { manifest: { ...manifest(), id: 'com.acme.other', namespace: 'other', dependencies: row } });
const atEdit = await p.call('PATCH', `/${ID}`, { dependencies: row });

expect({ status: atCreate.status, code: atCreate.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
expect({ status: atEdit.status, code: atEdit.code }).toEqual({ status: atCreate.status, code: atCreate.code });
});
});

describe('[#24670] PATCH /packages/:id — the three existing keys behave as before', () => {
it('a name / description / version patch persists exactly those and leaves `dependencies` as stored', async () => {
const p = boot();

const answer = await p.call('PATCH', `/${ID}`, { name: ' Renamed ', description: 'after', version: '2.0.0' });

expect(answer.status).toBe(200);
expect(p.stored()).toMatchObject({ name: 'Renamed', description: 'after', version: '2.0.0', dependencies: DECLARED });
});

it('an empty name and a non-SemVer version are still refused 400, and an empty body names `dependencies` among the keys', async () => {
const p = boot();
const before = p.stored();

expect((await p.call('PATCH', `/${ID}`, { name: ' ' })).status).toBe(400);
expect((await p.call('PATCH', `/${ID}`, { version: '1.2' })).status).toBe(400);
const empty = await p.call('PATCH', `/${ID}`, {});
expect({ status: empty.status, code: empty.code }).toEqual({ status: 400, code: 'VALIDATION_ERROR' });
expect(empty.body?.error?.message).toContain('dependencies');
expect(p.stored()).toEqual(before);
});

it('the protocol branch receives the parsed row beside the other keys', async () => {
const updatePackage = vi.fn().mockResolvedValue({ package: { id: ID } });
const p = boot({ updatePackage });

const answer = await p.call('PATCH', `/${ID}`, { manifest: { description: 'after', dependencies: DECLARED } });

expect(answer.status).toBe(200);
expect(updatePackage).toHaveBeenCalledWith({ packageId: ID, patch: { description: 'after', dependencies: DECLARED } });
});
});
Loading
Loading