Skip to content

Commit 1dd5dfd

Browse files
os-zhuangclaude
andauthored
feat(packages): PATCH /packages/:id to edit a package manifest (#2646)
* feat(packages): PATCH /packages/:id to edit a package manifest Packages could be created (POST /packages) and acted on via lifecycle sub-routes, but a runtime/base package's manifest — name, description, version — could never be edited after creation. The Studio "Package info & settings" sheet had no edit affordance because there was no endpoint behind it. Add the edit path end to end: - `SchemaRegistry.updatePackageManifest(id, patch)` — merges name / description / version into the in-memory manifest, preserving lifecycle state (enabled / status / installedAt). It is a metadata edit, not a reinstall, so a disabled package stays disabled. - `protocol.updatePackage({ packageId, patch })` — merges via the registry then re-persists the manifest to `sys_packages` (best-effort, non-fatal, matching installPackage) so the edit survives a restart. - `PATCH /packages/:id` in `handlePackages` — partial patch (only the sent fields change), accepts flat or `{ manifest }`-wrapped bodies, validates a non-empty name and semantic version, 404s an unknown package, and falls back to the registry when no protocol service. `id` / `scope` / `type` remain identity/structure and are not editable here. Tests: registry merge (preserve lifecycle, partial patch, unknown id); dispatcher PATCH (protocol path, manifest wrapper, empty-patch 400, bad-version 400, registry-fallback 404). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DxVTSDSXrdnfqHNBkHB6yi * chore(changeset): package manifest edit endpoint --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 97ad570 commit 1dd5dfd

6 files changed

Lines changed: 221 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/objectql": minor
3+
"@objectstack/metadata-protocol": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
feat(packages): edit a package manifest via `PATCH /packages/:id`
8+
9+
Adds an editable path for a package's `name` / `description` / `version` after
10+
creation: `SchemaRegistry.updatePackageManifest` (merges in-memory, preserving
11+
lifecycle state), `protocol.updatePackage` (re-persists to `sys_packages`), and
12+
the `PATCH /packages/:id` route in the HTTP dispatcher. `id` / `scope` / `type`
13+
remain immutable.

packages/metadata-protocol/src/protocol.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5957,4 +5957,48 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
59575957

59585958
return { package: pkg as any, message: `Installed package: ${manifest?.id}` };
59595959
}
5960+
5961+
/**
5962+
* Edit an installed package's manifest (name / description / version) — the
5963+
* durable half of `PATCH /packages/:id`. Merges the patch into the registry
5964+
* (preserving lifecycle state — see {@link SchemaRegistry.updatePackageManifest})
5965+
* then re-persists the merged manifest to `sys_packages` via the `package`
5966+
* service so the edit survives a restart. Persistence is best-effort and
5967+
* non-fatal (matching `installPackage`): the registry write already
5968+
* succeeded, so a persist failure is logged, never thrown.
5969+
*/
5970+
async updatePackage(request: {
5971+
packageId: string;
5972+
patch: { name?: string; description?: string; version?: string };
5973+
}): Promise<{ package: any; message: string }> {
5974+
const pkg = this.engine.registry.updatePackageManifest(request.packageId, request.patch);
5975+
if (!pkg) {
5976+
throw Object.assign(new Error(`Package '${request.packageId}' not found`), { statusCode: 404 });
5977+
}
5978+
try {
5979+
const services = this.getServicesRegistry?.();
5980+
const pkgSvc = services?.get('package') as
5981+
| { publish?: (data: { manifest: unknown; metadata: unknown }) => Promise<{ success?: boolean; error?: string } | unknown> }
5982+
| undefined;
5983+
if (pkgSvc?.publish) {
5984+
const out = (await pkgSvc.publish({ manifest: (pkg as any).manifest, metadata: {} })) as
5985+
| { success?: boolean; error?: string }
5986+
| undefined;
5987+
if (out && out.success === false) {
5988+
console.warn(
5989+
`[protocol.updatePackage] sys_packages persist FAILED for '${request.packageId}': ${out.error ?? 'unknown error'} — the edit will not survive a restart`,
5990+
);
5991+
}
5992+
} else {
5993+
console.warn(
5994+
`[protocol.updatePackage] no 'package' service — '${request.packageId}' edited in-memory only (will not survive a restart)`,
5995+
);
5996+
}
5997+
} catch (e) {
5998+
console.warn(
5999+
`[protocol.updatePackage] sys_packages persist skipped for '${request.packageId}': ${(e as Error)?.message}`,
6000+
);
6001+
}
6002+
return { package: pkg as any, message: `Updated package: ${request.packageId}` };
6003+
}
59606004
}

packages/objectql/src/registry.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,36 @@ describe('SchemaRegistry', () => {
425425
expect(registry.getPackage('com.test')).toBeUndefined();
426426
expect(registry.getNamespaceOwner('test')).toBeUndefined();
427427
});
428+
429+
it('updatePackageManifest merges editable fields, preserving lifecycle state', () => {
430+
registry.installPackage({ id: 'com.test', name: 'Old', version: '1.0.0' } as any);
431+
registry.disablePackage('com.test'); // lifecycle state that must survive an edit
432+
433+
const updated = registry.updatePackageManifest('com.test', {
434+
name: 'New Name',
435+
description: 'now with a description',
436+
version: '2.0.0',
437+
});
438+
439+
expect(updated?.manifest.name).toBe('New Name');
440+
expect((updated?.manifest as any).description).toBe('now with a description');
441+
expect(updated?.manifest.version).toBe('2.0.0');
442+
// Editing metadata must NOT re-enable a disabled package.
443+
expect(updated?.enabled).toBe(false);
444+
expect(updated?.status).toBe('disabled');
445+
});
446+
447+
it('updatePackageManifest only overwrites the fields present in the patch', () => {
448+
registry.installPackage({ id: 'com.test', name: 'Keep', version: '1.0.0' } as any);
449+
const updated = registry.updatePackageManifest('com.test', { description: 'only this' });
450+
expect(updated?.manifest.name).toBe('Keep');
451+
expect(updated?.manifest.version).toBe('1.0.0');
452+
expect((updated?.manifest as any).description).toBe('only this');
453+
});
454+
455+
it('updatePackageManifest returns undefined for an unknown package', () => {
456+
expect(registry.updatePackageManifest('nope', { name: 'x' })).toBeUndefined();
457+
});
428458
});
429459

430460
// ==========================================

packages/objectql/src/registry.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,6 +1262,31 @@ export class SchemaRegistry {
12621262
return pkg;
12631263
}
12641264

1265+
/**
1266+
* Merge editable manifest fields (name / description / version) into an
1267+
* installed package — the in-memory half of `PATCH /packages/:id`.
1268+
*
1269+
* Only the human-editable fields are touched: `id` is identity (never
1270+
* changes here), and lifecycle facets (`status` / `enabled` / `installedAt`)
1271+
* are preserved — this is a metadata edit, NOT a reinstall (which
1272+
* `installPackage` would be, resetting exactly those). Undefined patch
1273+
* fields are ignored so a partial PATCH only overwrites what it sends.
1274+
*/
1275+
updatePackageManifest(
1276+
id: string,
1277+
patch: { name?: string; description?: string; version?: string },
1278+
): InstalledPackage | undefined {
1279+
const pkg = this.getPackage(id);
1280+
if (!pkg) return undefined;
1281+
const manifest = pkg.manifest as Record<string, unknown>;
1282+
if (patch.name !== undefined) manifest.name = patch.name;
1283+
if (patch.description !== undefined) manifest.description = patch.description;
1284+
if (patch.version !== undefined) manifest.version = patch.version;
1285+
pkg.updatedAt = new Date().toISOString();
1286+
this.log(`[Registry] Updated package manifest: ${id}`);
1287+
return pkg;
1288+
}
1289+
12651290
// ==========================================
12661291
// App Helpers
12671292
// ==========================================

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,77 @@ describe('HttpDispatcher', () => {
991991
expect(result.response?.status).toBe(503);
992992
});
993993

994+
it('PATCH /packages/:id edits the manifest via protocol.updatePackage', async () => {
995+
const updatePackage = vi.fn().mockResolvedValue({
996+
package: { manifest: { id: 'com.acme.crm', name: 'Acme CRM v2', version: '1.2.0' } },
997+
message: 'Updated package: com.acme.crm',
998+
});
999+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1000+
if (name === 'protocol') return Promise.resolve({ updatePackage });
1001+
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
1002+
return null;
1003+
});
1004+
1005+
const result = await dispatcher.handlePackages(
1006+
'/com.acme.crm',
1007+
'PATCH',
1008+
{ name: ' Acme CRM v2 ', version: '1.2.0' },
1009+
{},
1010+
{ request: {} },
1011+
);
1012+
expect(result.handled).toBe(true);
1013+
expect(result.response?.status).toBe(200);
1014+
// name is trimmed; only sent fields are in the patch.
1015+
expect(updatePackage).toHaveBeenCalledWith({
1016+
packageId: 'com.acme.crm',
1017+
patch: { name: 'Acme CRM v2', version: '1.2.0' },
1018+
});
1019+
expect(result.response?.body?.data?.manifest?.name).toBe('Acme CRM v2');
1020+
});
1021+
1022+
it('PATCH /packages/:id accepts a { manifest } wrapper too', async () => {
1023+
const updatePackage = vi.fn().mockResolvedValue({ package: { manifest: { id: 'a.b' } } });
1024+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1025+
if (name === 'protocol') return Promise.resolve({ updatePackage });
1026+
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
1027+
return null;
1028+
});
1029+
1030+
await dispatcher.handlePackages('/a.b', 'PATCH', { manifest: { description: 'hi' } }, {}, { request: {} });
1031+
expect(updatePackage).toHaveBeenCalledWith({ packageId: 'a.b', patch: { description: 'hi' } });
1032+
});
1033+
1034+
it('PATCH /packages/:id rejects an empty patch with 400', async () => {
1035+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1036+
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
1037+
return null;
1038+
});
1039+
const result = await dispatcher.handlePackages('/a.b', 'PATCH', {}, {}, { request: {} });
1040+
expect(result.response?.status).toBe(400);
1041+
});
1042+
1043+
it('PATCH /packages/:id rejects a non-semantic version with 400', async () => {
1044+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1045+
if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } });
1046+
return null;
1047+
});
1048+
const result = await dispatcher.handlePackages('/a.b', 'PATCH', { version: '1.2' }, {}, { request: {} });
1049+
expect(result.response?.status).toBe(400);
1050+
});
1051+
1052+
it('PATCH /packages/:id falls back to the registry and 404s an unknown package', async () => {
1053+
const updatePackageManifest = vi.fn().mockReturnValue(undefined);
1054+
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
1055+
// No protocol service → fallback path.
1056+
if (name === 'objectql')
1057+
return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]), updatePackageManifest } });
1058+
return null;
1059+
});
1060+
const result = await dispatcher.handlePackages('/nope', 'PATCH', { name: 'x' }, {}, { request: {} });
1061+
expect(updatePackageManifest).toHaveBeenCalledWith('nope', { name: 'x' });
1062+
expect(result.response?.status).toBe(404);
1063+
});
1064+
9941065
it('POST /packages/:id/publish-drafts routes to protocol.publishPackageDrafts', async () => {
9951066
const publishPackageDrafts = vi.fn().mockResolvedValue({
9961067
success: true, publishedCount: 3, failedCount: 0, published: [], failed: [],

packages/runtime/src/http-dispatcher.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2406,6 +2406,44 @@ export class HttpDispatcher {
24062406
return { handled: true, response: this.success(pkg) };
24072407
}
24082408

2409+
// PATCH /packages/:id → edit the manifest (name / description /
2410+
// version). A partial patch: only the fields present are changed;
2411+
// lifecycle state (enabled / status / installedAt) is preserved.
2412+
// `id` / `scope` / `type` are identity/structure and are NOT editable
2413+
// here. Body accepts the fields flat or under a `manifest` wrapper.
2414+
if (parts.length === 1 && m === 'PATCH') {
2415+
const id = decodeURIComponent(parts[0]);
2416+
const src = (body?.manifest && typeof body.manifest === 'object' ? body.manifest : body) ?? {};
2417+
const patch: { name?: string; description?: string; version?: string } = {};
2418+
if (typeof src.name === 'string') patch.name = src.name.trim();
2419+
if (typeof src.description === 'string') patch.description = src.description;
2420+
if (typeof src.version === 'string') patch.version = src.version.trim();
2421+
2422+
if (patch.name !== undefined && patch.name === '') {
2423+
return { handled: true, response: this.error('name must not be empty', 400) };
2424+
}
2425+
if (patch.version !== undefined && !/^\d+\.\d+\.\d+$/.test(patch.version)) {
2426+
return { handled: true, response: this.error('version must be semantic (e.g. 1.0.0)', 400) };
2427+
}
2428+
if (patch.name === undefined && patch.description === undefined && patch.version === undefined) {
2429+
return { handled: true, response: this.error('Body { name?, description?, version? } — nothing to update', 400) };
2430+
}
2431+
2432+
const protocol = await this.resolveService('protocol');
2433+
if (protocol && typeof (protocol as any).updatePackage === 'function') {
2434+
try {
2435+
const updated = await (protocol as any).updatePackage({ packageId: id, patch });
2436+
return { handled: true, response: this.success((updated as any)?.package ?? updated) };
2437+
} catch (e: any) {
2438+
return { handled: true, response: this.error(e.message, e.statusCode || 500) };
2439+
}
2440+
}
2441+
// Fallback: no protocol service — in-memory registry only.
2442+
const pkg = registry.updatePackageManifest(id, patch);
2443+
if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
2444+
return { handled: true, response: this.success(pkg) };
2445+
}
2446+
24092447
// DELETE /packages/:id → delete the package. Unregisters it from the
24102448
// in-memory registry AND removes its persisted sys_metadata rows
24112449
// (active + draft), tearing down each object's physical table by

0 commit comments

Comments
 (0)