Files
suaveplan/openspec/archive/2026-05-18-extend-api-foundation-host-services/proposal.md
2026-05-27 23:29:53 +01:00

4.4 KiB

Extend api-plugin + api with host-seeded ServiceRegistry slots

Packages: @suaveplan/api-plugin, @suaveplan/api Status: PROPOSED Type: feature (additive — no breaking changes)

  • Spec deltas: ./specs/api-plugin/spec.md, ./specs/api/spec.md

Why

The just-shipped 5-package api split exposed a foundation-layer gap: PluginContext provides getService<K extends keyof ServiceRegistry>(name) but nothing on the host side ever seeds app, openAPIRegistry, migrationRunner, or db into the registry. The first concrete consumer (the in-flight api-plugin-auth, blocked at scaffold time) cannot resolve those services without forcing operators to register them by hand — which breaks the plug-and-play promise.

Fix: add PluginRegistry.seedServices(map) to @suaveplan/api-plugin, augment ServiceRegistry from @suaveplan/api with the canonical host slots, and seed them in SuaveApp.initialize() BEFORE registry.initialize() runs so every plugin's onRegister / onInit / onReady can resolve them via typed ctx.getService("app"), etc.

What changes

@suaveplan/api-plugin (additive)

  • ADDED: PluginRegistry.seedServices<K extends keyof ServiceRegistry>(map: { [K in keyof ServiceRegistry]?: ServiceRegistry[K] }): void — bulk-registers entries in an internal seeded-services map. Idempotent; later seeds override earlier ones. Throws PluginError(PLUGIN_INVALID_STATE) if called after initialize() has started.
  • MODIFIED: createPluginContext (and the per-plugin context factory) pre-populates the shared service map from the registry's seeded entries before plugin onRegister. Plugins calling ctx.getService("X") see the seeded entry without needing a sibling plugin's registerService to have run first.
  • ADDED: 1 new error code: PLUGIN_INVALID_STATE (already part of the spec's 10 → 11 codes total — used by seedServices post-initialize).
  • No public-surface removals.

@suaveplan/api (additive)

  • ADDED: ServiceRegistry module augmentation declaring canonical host slots:

    // src/services/registry-augmentation.ts (genesis-original)
    declare module "@suaveplan/api-plugin/service-registry" {
      interface ServiceRegistry {
        app: OpenAPIHono;
        openAPIRegistry: OpenAPIRegistry;
        migrationRunner?: MigrationRunner;
        db?: SurrealAdapter;
        logger: LoggerLike;
        eventBus: EventBusLike;
        clock: Clock;
      }
    }
    
  • MODIFIED: SuaveAppOptions gains optional db?: SurrealAdapter field (for plugins that need direct DB access — wired into the seeded slot). Defaults to undefined; no behaviour change for callers that don't supply it.

  • MODIFIED: SuaveApp.initialize() calls this._registry.seedServices({ app: this._hono, openAPIRegistry: this._openAPIRegistry, migrationRunner: this._migrations, db: this._db, logger: this._logger, eventBus: this._events, clock: this._clock }) immediately before this._registry.initialize().

  • ADDED: subpath export ./services/registry-augmentation so consumers can side-effect-import the augmentation if they only consume sub-packages.

  • No public-surface removals.

Out of scope

  • Backwards-compatible runtime shim. This is additive — existing callers of ctx.getService("X") for non-augmented keys continue to return undefined and silently work. No deprecation needed.
  • Per-request services (request-id, tenant, etc.). Those still flow via Hono c.get(key) — they're per-request, not per-plugin.
  • Plugin manifest-side service declarations. Plugins continue to declare what they registerService via the existing surface; this change only adds host-seeded slots.

Source mapping

Genesis-original — surfaced by add-api-plugin-auth's scaffold-time blocker (the subagent stopped because PluginContext.app doesn't exist on the shipped surface, then noticed SuaveApp never seeds app/openAPIRegistry/etc into the registry).

Impact

Affected dependents

  • @suaveplan/api-plugin-auth (in-flight) — unblocks. Resolves app / openAPIRegistry / migrationRunner via typed ctx.getService instead of operator pre-registration.
  • @suaveplan/api-plugin-user, @suaveplan/api-plugin-vault (in-flight) — inherit the same resolution path.
  • Every future @suaveplan/api-plugin-* package — same.

Risk: low. Additive on both packages; no existing API surface changes. Existing api-plugin + api tests stay green (verified by post-patch dependent sweep).