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
13 changes: 13 additions & 0 deletions .changeset/package-manifest-edit.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` };
}
}
30 changes: 30 additions & 0 deletions packages/objectql/src/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

// ==========================================
Expand Down
25 changes: 25 additions & 0 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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;
pkg.updatedAt = new Date().toISOString();
this.log(`[Registry] Updated package manifest: ${id}`);
return pkg;
}

// ==========================================
// App Helpers
// ==========================================
Expand Down
71 changes: 71 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
38 changes: 38 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down