diff --git a/.changeset/package-manifest-edit.md b/.changeset/package-manifest-edit.md new file mode 100644 index 0000000000..9fea6388fc --- /dev/null +++ b/.changeset/package-manifest-edit.md @@ -0,0 +1,13 @@ +--- +"@objectstack/objectql": minor +"@objectstack/metadata-protocol": minor +"@objectstack/runtime": minor +--- + +feat(packages): edit a package manifest via `PATCH /packages/:id` + +Adds an editable path for a package's `name` / `description` / `version` after +creation: `SchemaRegistry.updatePackageManifest` (merges in-memory, preserving +lifecycle state), `protocol.updatePackage` (re-persists to `sys_packages`), and +the `PATCH /packages/:id` route in the HTTP dispatcher. `id` / `scope` / `type` +remain immutable. diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 67c0b5adf8..3a7f653625 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -5957,4 +5957,48 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { return { package: pkg as any, message: `Installed package: ${manifest?.id}` }; } + + /** + * 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. Persistence is best-effort and + * non-fatal (matching `installPackage`): the registry write already + * succeeded, so a persist failure is logged, never thrown. + */ + async updatePackage(request: { + packageId: string; + patch: { name?: string; description?: string; version?: string }; + }): Promise<{ package: any; message: string }> { + const pkg = this.engine.registry.updatePackageManifest(request.packageId, request.patch); + if (!pkg) { + throw Object.assign(new Error(`Package '${request.packageId}' not found`), { statusCode: 404 }); + } + try { + const services = this.getServicesRegistry?.(); + const pkgSvc = services?.get('package') as + | { publish?: (data: { manifest: unknown; metadata: unknown }) => Promise<{ success?: boolean; error?: string } | unknown> } + | undefined; + if (pkgSvc?.publish) { + const out = (await pkgSvc.publish({ manifest: (pkg as any).manifest, metadata: {} })) as + | { success?: boolean; error?: string } + | undefined; + if (out && out.success === false) { + console.warn( + `[protocol.updatePackage] sys_packages persist FAILED for '${request.packageId}': ${out.error ?? 'unknown error'} — the edit will not survive a restart`, + ); + } + } else { + console.warn( + `[protocol.updatePackage] no 'package' service — '${request.packageId}' edited in-memory only (will not survive a restart)`, + ); + } + } catch (e) { + console.warn( + `[protocol.updatePackage] sys_packages persist skipped for '${request.packageId}': ${(e as Error)?.message}`, + ); + } + return { package: pkg as any, message: `Updated package: ${request.packageId}` }; + } } diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index f1730bb8de..85453ef701 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -425,6 +425,36 @@ describe('SchemaRegistry', () => { expect(registry.getPackage('com.test')).toBeUndefined(); expect(registry.getNamespaceOwner('test')).toBeUndefined(); }); + + it('updatePackageManifest merges editable fields, preserving lifecycle state', () => { + registry.installPackage({ id: 'com.test', name: 'Old', version: '1.0.0' } as any); + registry.disablePackage('com.test'); // lifecycle state that must survive an edit + + const updated = registry.updatePackageManifest('com.test', { + name: 'New Name', + description: 'now with a description', + version: '2.0.0', + }); + + expect(updated?.manifest.name).toBe('New Name'); + expect((updated?.manifest as any).description).toBe('now with a description'); + expect(updated?.manifest.version).toBe('2.0.0'); + // Editing metadata must NOT re-enable a disabled package. + expect(updated?.enabled).toBe(false); + expect(updated?.status).toBe('disabled'); + }); + + it('updatePackageManifest only overwrites the fields present in the patch', () => { + registry.installPackage({ id: 'com.test', name: 'Keep', version: '1.0.0' } as any); + const updated = registry.updatePackageManifest('com.test', { description: 'only this' }); + expect(updated?.manifest.name).toBe('Keep'); + expect(updated?.manifest.version).toBe('1.0.0'); + expect((updated?.manifest as any).description).toBe('only this'); + }); + + it('updatePackageManifest returns undefined for an unknown package', () => { + expect(registry.updatePackageManifest('nope', { name: 'x' })).toBeUndefined(); + }); }); // ========================================== diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 175208bd0e..5a19593fc3 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1262,6 +1262,31 @@ export class SchemaRegistry { return pkg; } + /** + * Merge editable manifest fields (name / description / version) 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. + */ + updatePackageManifest( + id: string, + patch: { name?: string; description?: string; version?: string }, + ): InstalledPackage | undefined { + const pkg = this.getPackage(id); + if (!pkg) return undefined; + const manifest = pkg.manifest as Record; + if (patch.name !== undefined) manifest.name = patch.name; + if (patch.description !== undefined) manifest.description = patch.description; + if (patch.version !== undefined) manifest.version = patch.version; + pkg.updatedAt = new Date().toISOString(); + this.log(`[Registry] Updated package manifest: ${id}`); + return pkg; + } + // ========================================== // App Helpers // ========================================== diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 8611124fcd..a60fdce3f9 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -991,6 +991,77 @@ describe('HttpDispatcher', () => { expect(result.response?.status).toBe(503); }); + it('PATCH /packages/:id edits the manifest via protocol.updatePackage', async () => { + const updatePackage = vi.fn().mockResolvedValue({ + package: { manifest: { id: 'com.acme.crm', name: 'Acme CRM v2', version: '1.2.0' } }, + message: 'Updated package: com.acme.crm', + }); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ updatePackage }); + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + + const result = await dispatcher.handlePackages( + '/com.acme.crm', + 'PATCH', + { name: ' Acme CRM v2 ', version: '1.2.0' }, + {}, + { request: {} }, + ); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + // name is trimmed; only sent fields are in the patch. + expect(updatePackage).toHaveBeenCalledWith({ + packageId: 'com.acme.crm', + patch: { name: 'Acme CRM v2', version: '1.2.0' }, + }); + expect(result.response?.body?.data?.manifest?.name).toBe('Acme CRM v2'); + }); + + it('PATCH /packages/:id accepts a { manifest } wrapper too', async () => { + const updatePackage = vi.fn().mockResolvedValue({ package: { manifest: { id: 'a.b' } } }); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ updatePackage }); + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + + await dispatcher.handlePackages('/a.b', 'PATCH', { manifest: { description: 'hi' } }, {}, { request: {} }); + expect(updatePackage).toHaveBeenCalledWith({ packageId: 'a.b', patch: { description: 'hi' } }); + }); + + it('PATCH /packages/:id rejects an empty patch with 400', async () => { + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + const result = await dispatcher.handlePackages('/a.b', 'PATCH', {}, {}, { request: {} }); + expect(result.response?.status).toBe(400); + }); + + it('PATCH /packages/:id rejects a non-semantic version with 400', async () => { + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + const result = await dispatcher.handlePackages('/a.b', 'PATCH', { version: '1.2' }, {}, { request: {} }); + expect(result.response?.status).toBe(400); + }); + + it('PATCH /packages/:id falls back to the registry and 404s an unknown package', async () => { + const updatePackageManifest = vi.fn().mockReturnValue(undefined); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + // No protocol service → fallback path. + if (name === 'objectql') + return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]), updatePackageManifest } }); + return null; + }); + const result = await dispatcher.handlePackages('/nope', 'PATCH', { name: 'x' }, {}, { request: {} }); + expect(updatePackageManifest).toHaveBeenCalledWith('nope', { name: 'x' }); + expect(result.response?.status).toBe(404); + }); + it('POST /packages/:id/publish-drafts routes to protocol.publishPackageDrafts', async () => { const publishPackageDrafts = vi.fn().mockResolvedValue({ success: true, publishedCount: 3, failedCount: 0, published: [], failed: [], diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index cb45311003..b7ae2d5466 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -2406,6 +2406,44 @@ export class HttpDispatcher { return { handled: true, response: this.success(pkg) }; } + // PATCH /packages/:id → edit the manifest (name / description / + // version). A partial patch: only the fields present are changed; + // lifecycle state (enabled / status / installedAt) is preserved. + // `id` / `scope` / `type` are identity/structure and are NOT editable + // here. Body accepts the fields flat or under a `manifest` wrapper. + if (parts.length === 1 && m === 'PATCH') { + const id = decodeURIComponent(parts[0]); + const src = (body?.manifest && typeof body.manifest === 'object' ? body.manifest : body) ?? {}; + const patch: { name?: string; description?: string; version?: string } = {}; + if (typeof src.name === 'string') patch.name = src.name.trim(); + if (typeof src.description === 'string') patch.description = src.description; + if (typeof src.version === 'string') patch.version = src.version.trim(); + + if (patch.name !== undefined && patch.name === '') { + return { handled: true, response: this.error('name must not be empty', 400) }; + } + if (patch.version !== undefined && !/^\d+\.\d+\.\d+$/.test(patch.version)) { + return { handled: true, response: this.error('version must be semantic (e.g. 1.0.0)', 400) }; + } + if (patch.name === undefined && patch.description === undefined && patch.version === undefined) { + return { handled: true, response: this.error('Body { name?, description?, version? } — nothing to update', 400) }; + } + + const protocol = await this.resolveService('protocol'); + if (protocol && typeof (protocol as any).updatePackage === 'function') { + try { + const updated = await (protocol as any).updatePackage({ packageId: id, patch }); + return { handled: true, response: this.success((updated as any)?.package ?? updated) }; + } catch (e: any) { + return { handled: true, response: this.error(e.message, e.statusCode || 500) }; + } + } + // Fallback: no protocol service — in-memory registry only. + const pkg = registry.updatePackageManifest(id, patch); + if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) }; + return { handled: true, response: this.success(pkg) }; + } + // DELETE /packages/:id → delete the package. Unregisters it from the // in-memory registry AND removes its persisted sys_metadata rows // (active + draft), tearing down each object's physical table by