From 12a4319a9c92807ef25c182ca98a60cc5d906fc9 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:55:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(packages):=20consolidate=20package=20subsy?= =?UTF-8?q?stem=20(registry=20=E2=86=94=20sys=5Fpackages)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement protocol.installPackage as the single canonical write primitive (registry + best-effort sys_packages), route the dispatcher's POST /packages and the AI's apply_blueprint app-home through it, and reconcile sys_packages back into the registry on boot — so an AI-created app. package surfaces in Studio's package selector/detail and survives restart. - objectql: implement ObjectStackProtocolImplementation.installPackage - runtime: POST /api/v1/packages → protocol.installPackage (registry fallback) - service-package: boot-hydrate sys_packages → registry (no filesystem clobber) - service-ai: ensureAppPackage prefers protocol.installPackage, publish fallback Still the legacy package_id plane; sealed sys_package_version + cross-env promotion remain ADR-0027 follow-ups. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0033-package-consolidation.md | 34 +++++++ .../src/protocol-install-package.test.ts | 94 +++++++++++++++++++ packages/objectql/src/protocol.ts | 44 ++++++++- packages/runtime/src/http-dispatcher.test.ts | 48 ++++++++++ packages/runtime/src/http-dispatcher.ts | 16 +++- .../src/__tests__/blueprint-tools.test.ts | 70 ++++++++++++++ .../service-ai/src/tools/blueprint-tools.ts | 54 +++++++---- .../service-ai/src/tools/metadata-tools.ts | 12 +++ .../service-package/src/hydration.test.ts | 72 ++++++++++++++ .../services/service-package/src/index.ts | 26 +++++ 10 files changed, 447 insertions(+), 23 deletions(-) create mode 100644 .changeset/adr-0033-package-consolidation.md create mode 100644 packages/objectql/src/protocol-install-package.test.ts create mode 100644 packages/services/service-package/src/hydration.test.ts diff --git a/.changeset/adr-0033-package-consolidation.md b/.changeset/adr-0033-package-consolidation.md new file mode 100644 index 000000000..e9cf44cbb --- /dev/null +++ b/.changeset/adr-0033-package-consolidation.md @@ -0,0 +1,34 @@ +--- +"@objectstack/objectql": minor +"@objectstack/runtime": patch +"@objectstack/service-package": patch +"@objectstack/service-ai": patch +--- + +feat(packages): consolidate the package subsystem so AI-built app packages surface in Studio + +The package subsystem was split across two stores that never met: the in-memory +`SchemaRegistry` (what the dispatcher's `/api/v1/packages` list/detail and +`getMetaItems({type:'package'})` read — i.e. Studio's package selector) and the durable +`sys_packages` table (where the AI's auto app package, and any `package`-service publish, +were written). Nothing reconciled the two, so an AI-created `app.` package never +appeared in Studio. + +This unifies them around one write primitive and one read source: + +- **`protocol.installPackage`** is now implemented (it was declared-but-missing). It is the + single canonical write path: it registers the package in the in-memory registry **and** + best-effort persists it to `sys_packages` via the `package` service. Non-fatal when no + `package` service is wired (registry write still succeeds). +- **Dispatcher `POST /api/v1/packages`** routes through `protocol.installPackage` (falling + back to the bare registry write when the protocol is unavailable), so HTTP installs are + durable too. +- **`@objectstack/service-package`** reconciles `sys_packages` back into the registry on + boot, without clobbering filesystem-registered packages — so persisted packages survive a + restart and stay visible in the registry-backed read paths. +- **`@objectstack/service-ai`** `apply_blueprint` now homes an app via + `protocol.installPackage` (falling back to the legacy `package`-service publish), so the + app package lands where Studio reads it. + +Still the *legacy* `package_id` plane — sealed `sys_package_version` versioning and +cross-environment promotion remain ADR-0027 follow-ups. diff --git a/packages/objectql/src/protocol-install-package.test.ts b/packages/objectql/src/protocol-install-package.test.ts new file mode 100644 index 000000000..3e087ab75 --- /dev/null +++ b/packages/objectql/src/protocol-install-package.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +/** + * ADR-0033 package-subsystem consolidation — `protocol.installPackage` is the + * single canonical write primitive. It must land a package in BOTH the + * in-memory registry (what the dispatcher's `/api/v1/packages` and + * `getMetaItems({type:'package'})` read → Studio's selector) AND the durable + * `sys_packages` table via the optional `package` service. + */ +function makeProtocol(opts: { pkgSvc?: unknown } = {}) { + const installed: Array<{ manifest: { id: string } }> = []; + const registry = { + installPackage: vi.fn((manifest: { id: string }) => { + const pkg = { manifest, status: 'installed', enabled: true }; + installed.push(pkg); + return pkg; + }), + getPackage: vi.fn((id: string) => installed.find((p) => p.manifest.id === id)), + }; + const engine = { registry } as never; + const services = new Map(); + if (opts.pkgSvc) services.set('package', opts.pkgSvc); + const protocol = new ObjectStackProtocolImplementation(engine, () => services); + return { protocol, registry }; +} + +describe('protocol.installPackage (ADR-0033 consolidation)', () => { + it('writes the in-memory registry AND persists via the package service', async () => { + const publish = vi.fn(async () => ({ success: true })); + const { protocol, registry } = makeProtocol({ pkgSvc: { publish } }); + const manifest = { id: 'app.demo', name: 'Demo', version: '1.0.0', type: 'application' }; + + const res = (await protocol.installPackage({ manifest } as never)) as { + package: { manifest: { id: string } }; + message: string; + }; + + expect(registry.installPackage).toHaveBeenCalledWith(manifest, undefined); + expect(publish).toHaveBeenCalledTimes(1); + expect((publish.mock.calls[0] as unknown[])[0]).toMatchObject({ manifest }); + expect(res.package.manifest.id).toBe('app.demo'); + expect(res.message).toContain('app.demo'); + }); + + it('forwards install-time settings to the registry', async () => { + const { protocol, registry } = makeProtocol(); + const manifest = { id: 'app.s', name: 'S', version: '1.0.0', type: 'application' }; + await protocol.installPackage({ manifest, settings: { theme: 'dark' } } as never); + expect(registry.installPackage).toHaveBeenCalledWith(manifest, { theme: 'dark' }); + }); + + it('still registers in-memory when no package service is present (non-fatal)', async () => { + const { protocol, registry } = makeProtocol(); + const manifest = { id: 'app.nopkg', name: 'NoPkg', version: '1.0.0', type: 'application' }; + + const res = (await protocol.installPackage({ manifest } as never)) as { + package: { manifest: { id: string } }; + }; + + expect(registry.installPackage).toHaveBeenCalledOnce(); + expect(res.package.manifest.id).toBe('app.nopkg'); + }); + + it('does not throw and keeps the registry write when persistence rejects', async () => { + const publish = vi.fn(async () => { + throw new Error('db down'); + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { protocol, registry } = makeProtocol({ pkgSvc: { publish } }); + const manifest = { id: 'app.err', name: 'Err', version: '1.0.0', type: 'application' }; + + const res = (await protocol.installPackage({ manifest } as never)) as { + package: { manifest: { id: string } }; + }; + + expect(registry.installPackage).toHaveBeenCalledOnce(); + expect(res.package.manifest.id).toBe('app.err'); + warn.mockRestore(); + }); + + it('skips persistence when the manifest has no version (cannot key sys_packages)', async () => { + const publish = vi.fn(async () => ({ success: true })); + const { protocol, registry } = makeProtocol({ pkgSvc: { publish } }); + const manifest = { id: 'app.nov', name: 'NoVer', type: 'application' }; + + await protocol.installPackage({ manifest } as never); + + expect(registry.installPackage).toHaveBeenCalledOnce(); + expect(publish).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 7f85707d1..9db19ab7c 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -10,7 +10,9 @@ import type { BatchUpdateRequest, BatchUpdateResponse, UpdateManyDataRequest, - DeleteManyDataRequest + DeleteManyDataRequest, + InstallPackageRequest, + InstallPackageResponse } from '@objectstack/spec/api'; import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities } from '@objectstack/spec/api'; import type { IFeedService } from '@objectstack/spec/contracts'; @@ -4380,4 +4382,44 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { const unsubscribed = await svc.unsubscribe(request.object, request.recordId, 'current_user'); return { success: true, data: { object: request.object, recordId: request.recordId, unsubscribed } }; } + + /** + * Install a package from a manifest — the single canonical write primitive + * for the package subsystem (ADR-0033 consolidation). + * + * It writes BOTH stores that the runtime keeps for packages, so a package + * surfaces consistently no matter which read path is used: + * 1. the in-memory `SchemaRegistry` (what the dispatcher's + * `/api/v1/packages` list/detail and `getMetaItems({type:'package'})` + * read — i.e. what Studio's package selector shows), and + * 2. the durable `sys_packages` table via the optional `package` service + * (so the package survives a restart; that service re-hydrates these + * rows back into the registry on boot). + * + * The DB write is best-effort and non-fatal: when the `package` service is + * absent (e.g. the `marketplace` capability is off) the package is still + * registered in-memory and visible for the lifetime of the process. + */ + async installPackage(request: InstallPackageRequest): Promise { + const manifest = request.manifest; + const pkg = this.engine.registry.installPackage(manifest as any, request.settings); + + // Best-effort durable persistence to `sys_packages`. + try { + const services = this.getServicesRegistry?.(); + const pkgSvc = services?.get('package') as + | { publish?: (data: { manifest: unknown; metadata: unknown }) => Promise } + | undefined; + if (pkgSvc?.publish && (manifest as any)?.version) { + await pkgSvc.publish({ manifest, metadata: {} }); + } + } catch (e) { + // Non-fatal: registry write already succeeded; log and continue. + console.warn( + `[protocol.installPackage] sys_packages persist skipped for '${(manifest as any)?.id}': ${(e as Error)?.message}`, + ); + } + + return { package: pkg as any, message: `Installed package: ${(manifest as any)?.id}` }; + } } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index a3f9d4f30..82ad7e876 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -954,6 +954,54 @@ describe('HttpDispatcher', () => { }); }); + // ═══════════════════════════════════════════════════════════════ + // Package install — POST /packages routes through protocol.installPackage + // (ADR-0033 consolidation: registry + sys_packages in one primitive) + // ═══════════════════════════════════════════════════════════════ + + describe('POST /packages install', () => { + it('routes through protocol.installPackage and returns the unwrapped package', async () => { + const installPackage = vi.fn().mockResolvedValue({ + package: { manifest: { id: 'app.demo' }, status: 'installed' }, + message: 'Installed package: app.demo', + }); + const mockRegistry = { installPackage: vi.fn(), getAllPackages: vi.fn().mockReturnValue([]) }; + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ installPackage }); + if (name === 'objectql') return Promise.resolve({ registry: mockRegistry }); + return null; + }); + + const manifest = { id: 'app.demo', name: 'Demo', version: '1.0.0', type: 'application' }; + const result = await dispatcher.handlePackages('', 'POST', { manifest, settings: { a: 1 } }, {}, { request: {} }); + + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(201); + expect(installPackage).toHaveBeenCalledWith({ manifest, settings: { a: 1 } }); + expect(mockRegistry.installPackage).not.toHaveBeenCalled(); // primitive owns the write + expect((result.response as any)?.body?.data?.manifest?.id).toBe('app.demo'); + }); + + it('falls back to registry.installPackage when the protocol lacks the method', async () => { + const mockRegistry = { + installPackage: vi.fn().mockReturnValue({ manifest: { id: 'app.fb' }, status: 'installed' }), + getAllPackages: vi.fn().mockReturnValue([]), + }; + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({}); // no installPackage + if (name === 'objectql') return Promise.resolve({ registry: mockRegistry }); + return null; + }); + + const manifest = { id: 'app.fb', name: 'FB', version: '1.0.0', type: 'application' }; + const result = await dispatcher.handlePackages('', 'POST', { manifest }, {}, { request: {} }); + + expect(result.response?.status).toBe(201); + expect(mockRegistry.installPackage).toHaveBeenCalledWith(manifest, undefined); + expect((result.response as any)?.body?.data?.manifest?.id).toBe('app.fb'); + }); + }); + // ═══════════════════════════════════════════════════════════════ // Metadata getPublished Endpoint // ═══════════════════════════════════════════════════════════════ diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 8e78033ed..1355afae2 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1383,9 +1383,21 @@ export class HttpDispatcher { return { handled: true, response: this.success({ packages, total: packages.length }) }; } - // POST /packages → install package + // POST /packages → install package. + // Route through the canonical `protocol.installPackage` primitive so + // the install lands in BOTH the in-memory registry (what this list/detail + // reads) AND the durable `sys_packages` table. Fall back to the bare + // registry write only when the protocol service/method is unavailable. if (parts.length === 0 && m === 'POST') { - const pkg = registry.installPackage(body.manifest || body, body.settings); + const manifest = body.manifest || body; + let pkg: any; + const protocolSvc: any = await this.resolveService('protocol').catch(() => null); + if (protocolSvc && typeof protocolSvc.installPackage === 'function') { + const out = await protocolSvc.installPackage({ manifest, settings: body.settings }); + pkg = out?.package ?? out; + } else { + pkg = registry.installPackage(manifest, body.settings); + } const res = this.success(pkg); res.status = 201; return { handled: true, response: res }; diff --git a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts index 4ba35377a..d1158f55e 100644 --- a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts +++ b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts @@ -462,3 +462,73 @@ describe('apply_blueprint — auto app package', () => { for (const c of proto.saveMetaItem.mock.calls) expect(c[0].packageId).toBeUndefined(); }); }); + +// ═══════════════════════════════════════════════════════════════════ +// ADR-0033 consolidation — when the protocol exposes the canonical +// `installPackage` primitive, the app package is written through it (registry +// + sys_packages) instead of the legacy package-service publish, so the app +// actually surfaces in Studio. +// ═══════════════════════════════════════════════════════════════════ + +function createMockProtocolWithInstall(existingObjects: string[] = []) { + const base = createMockProtocol(existingObjects); + const installPackage = vi.fn(async (req: any) => ({ package: { manifest: req.manifest }, message: 'ok' })); + (base.protocol as any).installPackage = installPackage; + return { ...base, installPackage }; +} + +describe('apply_blueprint — app package via protocol.installPackage', () => { + it('prefers protocol.installPackage over the legacy publish and binds artifacts', async () => { + const registry = new ToolRegistry(); + const proto = createMockProtocolWithInstall(); + const pkg = createMockPackageService(); + registerBlueprintTools(registry, { + ai: createMockAi().ai, protocol: proto.protocol, + metadataService: createMockMetadataService(), packageService: pkg.svc as any, + }); + + const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT }))); + + expect(proto.installPackage).toHaveBeenCalledOnce(); + expect((proto.installPackage.mock.calls[0] as any[])[0].manifest).toMatchObject({ + id: 'app.project_management', type: 'application', namespace: 'project_management', scope: 'environment', + }); + expect(pkg.publish).not.toHaveBeenCalled(); // canonical primitive wins; legacy publish skipped + expect(parsed.package).toEqual({ id: 'app.project_management', name: '项目管理', created: true }); + expect(proto.saveMetaItem.mock.calls.length).toBe(3); // object + view + app + for (const c of proto.saveMetaItem.mock.calls) { + expect(c[0].packageId).toBe('app.project_management'); + expect(c[0].mode).toBe('draft'); + } + }); + + it('installs via protocol.installPackage even with no package service wired', async () => { + const registry = new ToolRegistry(); + const proto = createMockProtocolWithInstall(); + registerBlueprintTools(registry, { + ai: createMockAi().ai, protocol: proto.protocol, metadataService: createMockMetadataService(), + }); + + const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT }))); + + expect(proto.installPackage).toHaveBeenCalledOnce(); + expect(parsed.package).toEqual({ id: 'app.project_management', name: '项目管理', created: true }); + for (const c of proto.saveMetaItem.mock.calls) expect(c[0].packageId).toBe('app.project_management'); + }); + + it('reuses an existing package (get-guard) without calling installPackage', async () => { + const registry = new ToolRegistry(); + const proto = createMockProtocolWithInstall(); + const pkg = createMockPackageService(['app.project_management']); + registerBlueprintTools(registry, { + ai: createMockAi().ai, protocol: proto.protocol, + metadataService: createMockMetadataService(), packageService: pkg.svc as any, + }); + + const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT }))); + + expect(proto.installPackage).not.toHaveBeenCalled(); + expect(pkg.publish).not.toHaveBeenCalled(); + expect(parsed.package).toEqual({ id: 'app.project_management', name: 'app.project_management', created: false }); + }); +}); diff --git a/packages/services/service-ai/src/tools/blueprint-tools.ts b/packages/services/service-ai/src/tools/blueprint-tools.ts index 9c7eb2e3f..b7d9d54fe 100644 --- a/packages/services/service-ai/src/tools/blueprint-tools.ts +++ b/packages/services/service-ai/src/tools/blueprint-tools.ts @@ -301,31 +301,45 @@ function appBody( * sealed `sys_package_version` model from ADR-0027, which is separate. */ async function ensureAppPackage( + protocol: DraftCapableProtocol | undefined, pkgSvc: BlueprintPackageService | undefined, app: { name: string; label?: string; icon?: string }, ): Promise<{ id: string; name: string; created: boolean } | null> { - if (!pkgSvc?.get || !pkgSvc?.publish) return null; const id = `app.${app.name}`; const name = app.label ?? app.name; + const manifest: Record = { + id, + name, + version: '1.0.0', + type: 'application', + namespace: app.name, + // Must NOT be 'system'/'cloud' — Studio's package selector filters those + // out (studio.app.ts optionsSource). 'environment' keeps it visible. + scope: 'environment', + ...(app.icon ? { icon: app.icon } : {}), + }; try { - const existing = await pkgSvc.get(id); - if (existing) return { id, name: existing.manifest?.name ?? name, created: false }; - const res = await pkgSvc.publish({ - manifest: { - id, - name, - version: '1.0.0', - type: 'application', - namespace: app.name, - // Must NOT be 'system'/'cloud' — Studio's package selector filters those - // out (studio.app.ts optionsSource). 'environment' keeps it visible. - scope: 'environment', - ...(app.icon ? { icon: app.icon } : {}), - }, - metadata: { createdBy: 'ai', source: 'database' }, - }); - if (res && res.success === false) return null; // degrade to package-less - return { id, name, created: true }; + // Idempotency: reuse an existing package when we can look one up. + if (pkgSvc?.get) { + const existing = await pkgSvc.get(id); + if (existing) return { id, name: existing.manifest?.name ?? name, created: false }; + } + // Preferred write path: the canonical `protocol.installPackage` primitive + // lands the package in BOTH the in-memory registry (Studio's selector reads + // this) and the durable `sys_packages` table — so the app package actually + // surfaces in Studio (ADR-0033 consolidation). + if (protocol?.installPackage) { + await protocol.installPackage({ manifest }); + return { id, name, created: true }; + } + // Fallback (older/remote protocol): the `package` service writes only + // `sys_packages`. Preserves prior behaviour when the primitive is absent. + if (pkgSvc?.publish) { + const res = await pkgSvc.publish({ manifest, metadata: { createdBy: 'ai', source: 'database' } }); + if (res && res.success === false) return null; // degrade to package-less + return { id, name, created: true }; + } + return null; // no write path available → package-less } catch { return null; // never block the build on packaging } @@ -356,7 +370,7 @@ function createApplyBlueprintHandler(ctx: BlueprintToolContext): ToolHandler { // Zero-package UX: if the blueprint has an app, ensure a writable home // package up front and bind every drafted artifact to it. Best-effort — // `null` (no package service / publish failed) falls back to package-less. - const appPackage = blueprint.app ? await ensureAppPackage(ctx.packageService, blueprint.app) : null; + const appPackage = blueprint.app ? await ensureAppPackage(ctx.protocol, ctx.packageService, blueprint.app) : null; const packageId = appPackage?.id; const drafted: Array<{ type: string; name: string }> = []; diff --git a/packages/services/service-ai/src/tools/metadata-tools.ts b/packages/services/service-ai/src/tools/metadata-tools.ts index 556af44c2..434564566 100644 --- a/packages/services/service-ai/src/tools/metadata-tools.ts +++ b/packages/services/service-ai/src/tools/metadata-tools.ts @@ -232,6 +232,18 @@ export interface MetadataToolContext { mode?: 'draft' | 'publish'; packageId?: string | null; }): Promise; + /** + * Install a package from a manifest — the canonical write primitive that + * lands the package in BOTH the in-memory registry (Studio's selector reads + * this) and the durable `sys_packages` table (ADR-0033 consolidation). The + * runtime object backing `ctx.protocol` is the full + * ObjectStackProtocolImplementation, which provides this; older/remote + * protocols may omit it (callers fall back to the `package` service). + */ + installPackage?(request: { + manifest: Record; + settings?: Record; + }): Promise<{ package?: unknown; message?: string } | unknown>; }; } diff --git a/packages/services/service-package/src/hydration.test.ts b/packages/services/service-package/src/hydration.test.ts new file mode 100644 index 000000000..59ddd1d5c --- /dev/null +++ b/packages/services/service-package/src/hydration.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { PackageServicePlugin } from './index.js'; + +/** + * ADR-0033 package-subsystem consolidation — on boot, the package service + * reconciles durable `sys_packages` rows back into the in-memory registry so a + * persisted package (e.g. an AI-authored app package) survives a restart and + * surfaces in the registry-backed read paths (Studio's selector). It must NOT + * clobber a package already registered (e.g. from the filesystem). + */ +const SEEDED = { id: 'app.seed', name: 'Seed', version: '1.0.0', type: 'application' }; + +function sysPackagesRow() { + return { + id: SEEDED.id, + version: SEEDED.version, + manifest: JSON.stringify(SEEDED), + metadata: '{}', + hash: 'h', + created_at: 't', + updated_at: 't', + }; +} + +function makeCtx(registry: { installPackage: ReturnType; getPackage: ReturnType }) { + const execute = vi.fn(async ({ sql }: { sql: string }) => { + // The latest-per-id list query backs packageService.list() used by hydration. + if (/SELECT \* FROM sys_packages/i.test(sql)) return { rows: [sysPackagesRow()] }; + return { rows: [] }; // CREATE TABLE / INDEX / others + }); + const engine = { execute, registry } as never; + const services = new Map([['objectql', engine]]); + const ctx = { + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + getService: (n: string) => services.get(n), + registerService: (n: string, s: unknown) => services.set(n, s), + } as never; + return { ctx, execute }; +} + +describe('PackageServicePlugin boot hydration (ADR-0033 consolidation)', () => { + it('hydrates a persisted sys_packages row into the registry when absent', async () => { + const registry = { + installPackage: vi.fn(), + getPackage: vi.fn(() => undefined), // not yet in registry + }; + const { ctx } = makeCtx(registry); + + await new PackageServicePlugin().start(ctx); + + expect(registry.getPackage).toHaveBeenCalledWith('app.seed'); + expect(registry.installPackage).toHaveBeenCalledTimes(1); + expect(registry.installPackage).toHaveBeenCalledWith( + expect.objectContaining({ id: 'app.seed', version: '1.0.0' }), + ); + }); + + it('does NOT overwrite a package already registered (e.g. from the filesystem)', async () => { + const registry = { + installPackage: vi.fn(), + getPackage: vi.fn(() => ({ manifest: SEEDED, status: 'installed' })), // already present + }; + const { ctx } = makeCtx(registry); + + await new PackageServicePlugin().start(ctx); + + expect(registry.getPackage).toHaveBeenCalledWith('app.seed'); + expect(registry.installPackage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/services/service-package/src/index.ts b/packages/services/service-package/src/index.ts index f28a7f3e6..c98074987 100644 --- a/packages/services/service-package/src/index.ts +++ b/packages/services/service-package/src/index.ts @@ -195,6 +195,32 @@ export class PackageServicePlugin implements Plugin { ctx.registerService('package', packageService); logger.info('Package service initialized'); + + // Reconcile durable packages back into the in-memory registry (ADR-0033 + // consolidation). Packages persisted to `sys_packages` — AI-authored app + // packages, or anything HTTP-installed in a previous run — must survive a + // restart and surface in the registry-backed read paths (the dispatcher's + // `/api/v1/packages` list/detail and `getMetaItems({type:'package'})`, i.e. + // Studio's package selector). Never clobber a package already registered + // from the filesystem. Best-effort and non-fatal. + try { + const registry = (objectql as unknown as { registry?: any }).registry; + if (registry?.installPackage && registry?.getPackage) { + let hydrated = 0; + for (const rec of await packageService.list()) { + const id = rec?.manifest?.id; + if (id && !registry.getPackage(id)) { + registry.installPackage(rec.manifest); + hydrated++; + } + } + if (hydrated > 0) { + logger.info(`Hydrated ${hydrated} package(s) from sys_packages into registry`); + } + } + } catch (error) { + logger.debug(`Package hydration from sys_packages skipped: ${(error as Error)?.message}`); + } } private async ensureTable(objectql: IDataEngine, logger: any): Promise {