From 3aab49b241e11c02f26cc608c57d7590aff32097 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:50:26 +0000 Subject: [PATCH] refactor(spec)!: retire the plugin lifecycle-hook family the kernel never implemented (#4212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PluginLifecycleSchema declared onInstall / onEnable / onDisable / onUninstall / onUpgrade, each with confident TSDoc. The kernel calls none of them, and never has. Its plugin contract is init / start / destroy (packages/core/src/types.ts): use() validates and stores, bootstrap() runs init then start, shutdown runs destroy in reverse order. There is no install phase and no first-time-only path. Repo-wide, the schema's only importer was its own test — and the docs built on it sent authors at a dead seam (#4216 fixed the metadata-type registry's advice; this removes what the advice pointed at). Removed, with zero consumers verified across objectstack, objectui and cloud: - PluginLifecycleSchema + PluginLifecycleHooks, and the five hook members on PluginSchema (now a plain descriptor object — and, with the function members gone, JSON-representable: it publishes a kernel/Plugin JSON schema for the first time). - UpgradeContextSchema + UpgradeContext, which existed solely to serve onUpgrade. - The @objectstack/spec/system ./types module: the ObjectStackPlugin interface and its PluginContext / PluginLogger / ObjectQLClient / IKernel / ObjectOSKernel companions — seeded by the aspirational spec in issue #2, referenced by no file in any repo since. Route: plain deletion, not retiredKey() tombstones — nothing parses plugin objects through these schemas (stack.zod carries plugins as z.array(z.unknown())), so a prescription nobody can receive is noise; the plugin-runtime.zod.ts precedent. Per that precedent the key-vanish guard's baseline entries (kernel/UpgradeContext:* in authorable-surface.json) are dropped deliberately, and kernel/UpgradeContext leaves json-schema.manifest.json in the same PR. No ADR-0087 conversion: function members on runtime objects are not authorable stack metadata; there is no source for os migrate meta to rewrite. The kernel docs that taught the fiction now teach the real contract: protocol/kernel/lifecycle.mdx (five-hook example → init/start/destroy class, plus the app-bundle onEnable distinction), protocol/kernel/index.mdx (the "kernel calls onInstall/onEnable" claim and a fictional Kernel.installPlugin API → the real package-install flow), protocol/kernel/plugin-spec.mdx (a manifest `lifecycle:` file-map ManifestSchema never declared + a six-hook section with an invented context API → the runtime contract and real execution order), and AGENTS.md's own plugin example. Each page carries a callout naming what was fiction, so an author who wrote against the old docs finds the correction where the fiction was. Pin tests: PluginSchema declares none of the five names; an authored hook is stripped by the parse; the retired exports are gone from the module. The stack-level lint keeps reporting onDisable and friends as undeclared keys, so authors who still write them hear about it at load. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HrRNgrWaRtggzmrHpbomyh --- .changeset/plugin-lifecycle-hooks-retired.md | 60 ++ AGENTS.md | 15 +- content/docs/protocol/kernel/index.mdx | 49 +- content/docs/protocol/kernel/lifecycle.mdx | 78 +-- content/docs/protocol/kernel/plugin-spec.mdx | 147 ++--- content/docs/references/kernel/plugin.mdx | 29 +- content/docs/releases/v17.mdx | 1 + packages/cli/src/commands/serve.ts | 4 +- packages/cli/src/utils/config.ts | 2 +- packages/cli/src/utils/graft-runtime-hooks.ts | 6 +- packages/spec/api-surface.json | 10 - packages/spec/authorable-surface.json | 13 +- packages/spec/json-schema.manifest.json | 2 +- packages/spec/src/data/authoring-key-lint.ts | 7 +- .../kernel/metadata-authoring-lint.test.ts | 7 +- .../spec/src/kernel/metadata-type-schemas.ts | 8 +- packages/spec/src/kernel/plugin.test.ts | 601 +++--------------- packages/spec/src/kernel/plugin.zod.ts | 67 +- packages/spec/src/system/index.ts | 9 +- packages/spec/src/system/types/index.ts | 9 - packages/spec/src/system/types/plugin.ts | 132 ---- .../objectstack-platform/references/_index.md | 2 +- 22 files changed, 339 insertions(+), 919 deletions(-) create mode 100644 .changeset/plugin-lifecycle-hooks-retired.md delete mode 100644 packages/spec/src/system/types/index.ts delete mode 100644 packages/spec/src/system/types/plugin.ts diff --git a/.changeset/plugin-lifecycle-hooks-retired.md b/.changeset/plugin-lifecycle-hooks-retired.md new file mode 100644 index 0000000000..8ad77518e9 --- /dev/null +++ b/.changeset/plugin-lifecycle-hooks-retired.md @@ -0,0 +1,60 @@ +--- +"@objectstack/spec": major +--- + +refactor(spec)!: retire the plugin lifecycle-hook family the kernel never implemented (#4212) + +`PluginLifecycleSchema` declared `onInstall` / `onEnable` / `onDisable` / +`onUninstall` / `onUpgrade`, each with confident TSDoc ("Called when plugin is +installed", …). **The kernel calls none of them, and never has.** Its plugin +contract is `init` / `start` / `destroy` (`packages/core/src/types.ts`): +`kernel.use()` validates and stores, `bootstrap()` runs `init` then `start`, +shutdown runs `destroy` in reverse order. There is no install phase and no +first-time-only path. Repo-wide, the schema's only importer was its own test — +and the docs built on it sent authors at a dead seam (`registerMetadataTypeSchema` +told plugins to register custom metadata types "from their `onInstall` hook"; +a plugin that obeyed registered nothing, with no error saying so). + +Removed, with zero consumers verified across `objectstack`, `objectui` and +`cloud`: + +- `PluginLifecycleSchema` + `PluginLifecycleHooks`, and the five hook members + on `PluginSchema` (now a plain descriptor object). +- `UpgradeContextSchema` + `UpgradeContext` — existed solely to serve + `onUpgrade`. +- The `@objectstack/spec/system` `./types` module: the `ObjectStackPlugin` + interface (the same three hooks as a TS contract, referenced by no file in + any repo) and its companions `PluginContext` (interface duplicate), + `PluginLogger`, `ObjectQLClient`, `IKernel`, `ObjectOSKernel` — seeded by + the aspirational spec in issue #2 and never consumed since. + +FROM → TO: + +- `onInstall` → there is no install-time code hook; installation is a + package-registry state transition (`registry.installPackage()` → + `sys_packages`). Install-shaped setup (registering services, schemas, + metadata types) belongs in the plugin's **`init(ctx)`**. +- `onEnable` (kernel plugin) → `init(ctx)` / `start(ctx)`. The *app-bundle* + `onEnable` module export is a different, real contract and is unchanged — + `AppPlugin` invokes it at boot (`STACK_RUNTIME_MEMBERS`). +- `onDisable` / `onUninstall` → `destroy()` for runtime teardown; package + uninstall is a registry transition. +- `onUpgrade` / `UpgradeContext` → package upgrades apply metadata migrations + (ADR-0087); no plugin code runs. + +One-line fix: replace the hook object with a class implementing +`Plugin` — `name` + `async init(ctx)` (+ optional `start`/`destroy`). + +Plain deletion rather than `retiredKey()` tombstones because nothing parses +plugin objects through these schemas (`stack.zod` carries `plugins` as +`z.array(z.unknown())`) — a prescription nobody can receive is noise (the +`plugin-runtime.zod.ts` precedent). Per that precedent, the key-vanish guard's +baseline entries (`kernel/UpgradeContext:*` in `authorable-surface.json`) are +dropped deliberately in this PR. A pleasant side effect: with the function +members gone, `PluginSchema` became JSON-representable and now publishes a +`kernel/Plugin` JSON schema for the first time. No ADR-0087 conversion: these are function +members on runtime objects, not authorable stack metadata; there is no source +file for `os migrate meta` to rewrite. The kernel docs +(`protocol/kernel/{index,lifecycle,plugin-spec}.mdx`) that documented the +fictional lifecycle — including a manifest `lifecycle:` file-map ManifestSchema +never declared — now document the real contract. diff --git a/AGENTS.md b/AGENTS.md index 1e22db8e66..e94c222473 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -346,13 +346,16 @@ export const FieldSchema = z.object({ export type Field = z.infer; ``` -**Plugin:** +**Plugin** (the kernel contract is `init`/`start`/`destroy` — +`packages/core/src/types.ts`; the old `onInstall`/`onEnable`/`onDisable` +example described hooks nothing ever called, retired in #4212): ```ts -export default { - async onInstall(ctx: PluginContext) { /* migrations */ }, - async onEnable(ctx: PluginContext) { /* register routes/services */ }, - async onDisable(ctx: PluginContext) { /* cleanup */ }, -}; +export class MyPlugin implements Plugin { + name = 'plugin.my-feature'; + async init(ctx: PluginContext) { /* register services, schemas, routes */ } + async start(ctx: PluginContext) { /* begin work that needs every service up */ } + async destroy() { /* cleanup */ } +} ``` --- diff --git a/content/docs/protocol/kernel/index.mdx b/content/docs/protocol/kernel/index.mdx index df82cdfdda..47bc2c5855 100644 --- a/content/docs/protocol/kernel/index.mdx +++ b/content/docs/protocol/kernel/index.mdx @@ -214,7 +214,7 @@ const config = context.config.resolve('stripe.apiKey', { **Kernel Solution:** - **Package Manifest:** The `manifest` block in `objectstack.config.ts` (compiled to `objectstack.json`) declares what a package provides and needs - **Dependency Resolution:** Automatic validation that plugin versions are compatible with core platform -- **Lifecycle Hooks:** `onInstall`, `onEnable`, `onDisable` hooks for setup/teardown +- **Lifecycle:** every plugin implements `init` / `start` / `destroy`, driven by `kernel.bootstrap()` and shutdown - **Sandboxing:** Plugins can't access each other's data or crash each other **Results:** @@ -272,8 +272,9 @@ await kernel.use(new AppPlugin(stack)); await kernel.bootstrap(); ``` -The kernel resolves plugin dependencies, calls each plugin's `onInstall` / `onEnable` -hooks in order, and exposes the resulting services through DI. +The kernel validates each plugin at `use()`, then `bootstrap()` runs every +plugin's `init` in registration order followed by every plugin's `start`, and +exposes the resulting services through DI. ### 2. Request Handling ```typescript @@ -307,35 +308,23 @@ async function handleRequest(req) { } ``` -### 3. Plugin Installation +### 3. Package Installation ```typescript // Install a package: os package install @vendor/salesforce-sync -await Kernel.installPlugin({ - source: '@vendor/salesforce-sync@1.5.0', - - // Step 1: Validate dependencies - validate: (manifest) => { - PluginRegistry.checkDependencies(manifest.dependencies); - // Ensures '@objectstack/core@^2.0.0' is installed - }, - - // Step 2: Run onInstall hook - onInstall: async (plugin) => { - // Plugin may create custom objects (ObjectQL) - await ObjectQL.createObjects(plugin.objects); - - // Plugin may register UI views (ObjectUI) - await ObjectUI.registerViews(plugin.views); - - // Plugin may set default config - await ConfigStore.set(plugin.defaultConfig); - }, - - // Step 3: Enable plugin - enable: () => { - PluginRegistry.enable('@vendor/salesforce-sync'); - } -}); +// +// A package is METADATA — no package code executes at install time. +// What actually happens: +// +// 1. Validate — manifest shape, engines.protocol compatibility (ADR-0087), +// artifact signature. +// 2. Register — registry.installPackage() gates the namespace and records +// the InstalledPackage; the durable copy lands in `sys_packages` and is +// rehydrated on every boot. +// 3. Materialize — metadata is hot-registered, `syncSchemas` applies the +// schema, and declared side effects (translations, seed data) run. +// +// Executable members of an app bundle (`onEnable`, `functions`) run at BOOT, +// invoked by AppPlugin — installation only stores them. ``` ## Philosophy: Infrastructure as Code diff --git a/content/docs/protocol/kernel/lifecycle.mdx b/content/docs/protocol/kernel/lifecycle.mdx index 5365835707..356bf33322 100644 --- a/content/docs/protocol/kernel/lifecycle.mdx +++ b/content/docs/protocol/kernel/lifecycle.mdx @@ -299,51 +299,57 @@ async function resolveDependencies( } ``` -### Plugin Lifecycle Hooks +### Plugin Runtime Contract -Plugins can define hooks that run at specific lifecycle events: +Installation registers **metadata** — no plugin code runs at install time. +Executable behaviour belongs to the runtime contract, which has exactly three +methods (`packages/core/src/types.ts`), invoked by the kernel: ```typescript -// plugin.ts — a plugin is a plain default-exported object (there is no -// `definePlugin` helper). Lifecycle hooks are top-level methods, each -// receiving a single PluginContext ({ ql, os, logger, config, pluginId }). -export default { - name: '@vendor/salesforce', - version: '3.2.1', - - // Runs once, when the plugin is installed - onInstall: async (context) => { - // Validate environment. `config` is a plain record, not a store. - if (!context.config['salesforce.apiKey']) { +// plugin.ts — a runtime plugin implements Plugin: `name` plus +// init / start / destroy. `init` is required; the other two are optional. +export class SalesforceSyncPlugin implements Plugin { + name = 'plugin.salesforce-sync'; + version = '3.2.1'; + + // Phase 1 — runs for every plugin, sequentially, in registration order. + // Register services, schemas and routes here; other plugins' services + // may not exist yet. + async init(ctx: PluginContext) { + if (!process.env.SALESFORCE_API_KEY) { throw new Error('Salesforce API key not configured'); } - context.logger.info('Salesforce plugin installed'); - }, + } - // Runs when the plugin is enabled (at startup or manually) - onEnable: async (context) => { - context.logger.info('Salesforce sync enabled'); - }, + // Phase 2 — runs after EVERY plugin's init has completed, so every + // registered service is resolvable. Begin actual work here. + async start(ctx: PluginContext) { + ctx.logger.info('Salesforce sync started'); + } - // Runs when the plugin is disabled (at shutdown or manually) - onDisable: async (context) => { - context.logger.info('Salesforce sync disabled'); - }, + // Shutdown — invoked in reverse registration order, and as rollback for + // plugins already started when a later plugin's start() fails. + async destroy() { + /* stop timers, close connections */ + } +} +``` - // Runs once, when the plugin is uninstalled - onUninstall: async (context) => { - context.logger.info('Removing Salesforce plugin data'); - }, +An **authored app bundle** (`objectstack.config.ts`) is not a kernel plugin +and has its own single code seam: a module-level `export const onEnable`, +which `AppPlugin` invokes at its own `start()` with the host context. That is +the place apps register action handlers — see the worked examples in +`examples/app-todo` and `examples/app-showcase`. - // Runs when the plugin version changes. Receives an UpgradeContext with - // previousVersion, newVersion, and isMajorUpgrade. - onUpgrade: async (context) => { - context.logger.info( - `Salesforce ${context.previousVersion} → ${context.newVersion}`, - ); - }, -}; -``` + +Earlier revisions of this page documented an `onInstall` / `onEnable` / +`onDisable` / `onUninstall` / `onUpgrade` hook family on plugins. **The kernel +never called any of them** — code written against them silently never ran — +and the family was retired from the protocol (#4212, ADR-0049 +enforce-or-remove). Install/uninstall/upgrade are package-registry state +transitions (this page, above), not code hooks; boot-time code goes in +`init`/`start`. + ### Installation CLI diff --git a/content/docs/protocol/kernel/plugin-spec.mdx b/content/docs/protocol/kernel/plugin-spec.mdx index d2fbf5d0fd..9fe567b07a 100644 --- a/content/docs/protocol/kernel/plugin-spec.mdx +++ b/content/docs/protocol/kernel/plugin-spec.mdx @@ -97,16 +97,11 @@ export default definePlugin({ configSchema: 'src/config.schema.ts', }, - // LIFECYCLE HOOKS - lifecycle: { - onInstall: 'src/lifecycle/install.ts', - onUninstall: 'src/lifecycle/uninstall.ts', - onEnable: 'src/lifecycle/enable.ts', - onDisable: 'src/lifecycle/disable.ts', - onUpgrade: 'src/lifecycle/upgrade.ts', - onBoot: 'src/lifecycle/boot.ts', - }, - + // NOTE: there is no `lifecycle` file-map. Earlier revisions of this page + // showed one (`onInstall: 'src/lifecycle/install.ts'`, …) — ManifestSchema + // never declared it and nothing ever loaded those files (#4212). Runtime + // behaviour lives on the plugin class (`init`/`start`/`destroy`, below). + // PERMISSIONS permissions: { // System capabilities plugin requires @@ -492,109 +487,71 @@ Solutions: 3. Contact plugin authors to update dependencies ``` -## Lifecycle Hooks +## Runtime Contract -Plugins can define hooks that execute at specific lifecycle events: - -### Hook Signatures +A runtime plugin implements exactly three lifecycle methods +(`packages/core/src/types.ts`); `init` is required, the rest optional: ```typescript -// src/lifecycle/install.ts -export async function onInstall({ context, transaction }) { - // Runs after plugin files are extracted, before metadata is registered - - // Example: Create default data - await context.db.insert('crm_settings', { - maxAccountsPerUser: 1000, - enableScoring: true, - }, { transaction }); -} - -// src/lifecycle/uninstall.ts -export async function onUninstall({ context, transaction }) { - // Runs before plugin is removed - - // Example: Archive plugin data instead of deleting - await context.db.update('account', - { where: { plugin: 'crm' }}, - { archived: true }, - { transaction } - ); -} - -// src/lifecycle/enable.ts -export async function onEnable({ context }) { - // Runs when plugin is enabled (after install or manual enable) - - // Example: Start background sync - await context.scheduler.create({ - name: 'crm-sync', - schedule: '0 * * * *', // Every hour - handler: 'crm.sync', - }); -} - -// src/lifecycle/disable.ts -export async function onDisable({ context }) { - // Runs when plugin is disabled (before uninstall or manual disable) - - // Example: Stop background jobs - await context.scheduler.pause('crm-sync'); -} +export class CRMPlugin implements Plugin { + name = 'plugin.crm'; + version = '2.0.0'; + + // Phase 1 — sequential, registration order. Register services, schemas, + // routes. Other plugins' services may not exist yet. + async init(ctx: PluginContext) { + ctx.registerService('crm', new CRMService()); + } -// src/lifecycle/upgrade.ts -export async function onUpgrade({ context, fromVersion, toVersion, transaction }) { - // Runs during plugin upgrade - - // Example: Data migration - if (fromVersion === '1.0.0' && toVersion === '2.0.0') { - await context.db.query(` - UPDATE account - SET account_number = CONCAT('ACC-', id::text) - WHERE account_number IS NULL - `, { transaction }); + // Phase 2 — after every plugin's init. All services resolvable; begin work. + async start(ctx: PluginContext) { + ctx.logger.info('CRM sync running'); } -} -// src/lifecycle/boot.ts -export async function onBoot({ context }) { - // Runs every time ObjectStack boots (after plugin is loaded) - - // Example: Validate configuration - const config = context.config.get('crm'); - if (!config.apiKey) { - context.logger.warn('CRM API key not configured'); + // Shutdown (reverse registration order), and rollback when a later + // plugin's start() fails. + async destroy() { + /* stop timers, close connections */ } - - // Example: Register custom services - context.services.register('crm', new CRMService(config)); } ``` -### Hook Execution Order +A plugin may also expose `healthCheck()`, which the kernel invokes on demand +(`kernel.checkPluginHealth`) rather than at boot. -During **installation**: -``` -1. onInstall -2. Register metadata (objects, views, etc.) -3. onEnable (if --enable flag) -``` +### Execution Order -During **upgrade**: +During **boot** (`kernel.bootstrap()`): ``` -1. onUpgrade -2. Apply migrations -3. Update metadata +1. use(plugin) — validate structure + version, store (no code runs) +2. init — every plugin, sequentially, registration order +3. start — every plugin that defines it +4. kernel:ready → kernel:bootstrapped → kernel:listening events ``` -During **boot**: +During **shutdown**: ``` -1. Load all plugins -2. Resolve dependencies -3. onBoot (for each plugin in dependency order) -4. Start services +1. kernel:shutdown event +2. destroy — every plugin, REVERSE registration order ``` +During **installation / uninstallation / upgrade** — no plugin code runs. +A package is metadata: install validates the manifest and signature, records +the package (`registry.installPackage()` → `sys_packages`), hot-registers +metadata and syncs schemas; upgrade applies metadata migrations (ADR-0087); +uninstall removes the registration. The one authored-code seam an app bundle +has is its module-level `onEnable` export, invoked by `AppPlugin.start()` at +boot — not at install. + + +Earlier revisions of this page documented an `onInstall` / `onEnable` / +`onDisable` / `onUninstall` / `onUpgrade` / `onBoot` hook family loaded from a +manifest `lifecycle` file-map. **None of it existed** — ManifestSchema never +declared the map and the kernel never called the hooks, so code written +against them silently never ran. The family was retired from the protocol +(#4212, ADR-0049 enforce-or-remove). + + ## Permissions Plugins must **declare permissions** they require. ObjectStack enforces these at runtime. diff --git a/content/docs/references/kernel/plugin.mdx b/content/docs/references/kernel/plugin.mdx index 615ce1ee1d..59cec88786 100644 --- a/content/docs/references/kernel/plugin.mdx +++ b/content/docs/references/kernel/plugin.mdx @@ -5,13 +5,9 @@ description: Plugin protocol schemas {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -Upgrade Context Schema +Shared Plugin Types -Provides version migration context to the `onUpgrade` lifecycle hook. - -Enables developers to write conditional migration logic based on - -the previous and new versions. +These are the specialized plugin types common between Manifest (Package) and Plugin (Runtime). **Source:** `packages/spec/src/kernel/plugin.zod.ts` @@ -20,8 +16,8 @@ the previous and new versions. ## TypeScript Usage ```typescript -import { ActivationEvent, UpgradeContext } from '@objectstack/spec/kernel'; -import type { ActivationEvent, UpgradeContext } from '@objectstack/spec/kernel'; +import { ActivationEvent, Plugin } from '@objectstack/spec/kernel'; +import type { ActivationEvent, Plugin } from '@objectstack/spec/kernel'; // Validate data const result = ActivationEvent.parse(data); @@ -43,18 +39,21 @@ Lazy activation trigger for a dynamic plugin --- -## UpgradeContext - -Version migration context for onUpgrade hook +## Plugin ### Properties | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **previousVersion** | `string` | ✅ | Version before upgrade | -| **newVersion** | `string` | ✅ | Version after upgrade | -| **isMajorUpgrade** | `boolean` | ✅ | Whether this is a major version bump | -| **previousMetadata** | `Record` | optional | Metadata snapshot before upgrade | +| **id** | `string` | optional | Unique Plugin ID (e.g. com.example.crm) | +| **type** | `Enum<'standard' \| 'ui' \| 'driver' \| 'server' \| 'app' \| 'theme' \| 'agent' \| 'objectql'>` | optional | Plugin Type categorization for runtime behavior | +| **staticPath** | `string` | optional | Absolute path to static assets (Required for type="ui-plugin") | +| **slug** | `string` | optional | URL path segment (Required for type="ui-plugin") | +| **default** | `boolean` | optional | Serve at root path (Only one "ui-plugin" can be default) | +| **version** | `string` | optional | Semantic Version | +| **description** | `string` | optional | | +| **author** | `string` | optional | | +| **homepage** | `string` | optional | | --- diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index 6db900cf5f..8dedc279c5 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -903,6 +903,7 @@ import or the authored key. | view: list `responsive`/`performance`, form `defaultSort`/`aria` | no renderer read any of them; list `aria`/`data` **and form `data`** stay live — `defineForm` writes `data: { provider: 'schema', schemaId }` onto every metadata form (#3896 close-out) | | `dashboard.aria` / `.performance` / widget `performance` (+ `PerformanceConfigSchema`) | no renderer applied them; virtual scrolling is the live top-level `virtualScroll` (#3896 close-out) | | `agent.knowledge` (+ `AIKnowledgeSchema`) | declaring sources/indexes never scoped retrieval — `search_knowledge` takes `sourceIds` from the LLM's tool-call arguments (#3896 close-out) | +| `PluginLifecycleSchema` (`onInstall`/`onEnable`/`onDisable`/`onUninstall`/`onUpgrade`) + `UpgradeContextSchema` + the `ObjectStackPlugin` interface family (`@objectstack/spec/system`) | a plugin lifecycle the kernel never implemented — the real contract is `init`/`start`/`destroy`; code written against the hooks silently never ran (#4212) | | `skill.triggerPhrases` | phrases were never matched; activation is `triggerConditions` + the agent's `skills[]` allowlist (#3896 close-out) | | `DEFAULT_DISPATCHER_ROUTES` | dead route table | | Aspirational config on Theme / Translation / Webhook | still-dead after #3494 | diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index ca37774327..d1e8713635 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -673,8 +673,8 @@ export default class Serve extends Command { throw new Error(`No default export found in ${args.config}`); } - // Preserve module-level named exports (e.g. `onEnable`, `onDisable` - // lifecycle hooks) that would otherwise be dropped when we unwrap + // Preserve module-level named exports (e.g. the `onEnable` runtime hook + // and `functions`) that would otherwise be dropped when we unwrap // `mod.default`. Without this AppPlugin can never invoke runtime hooks // declared as `export const onEnable = ...` alongside the default // `defineStack(...)` export. diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 5777904307..d0c122ced6 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -90,7 +90,7 @@ export async function loadConfig(source?: string): Promise { throw new Error(`No default export found in ${path.basename(absolutePath)}`); } - // Preserve named exports (e.g. `onEnable`, `onDisable` lifecycle hooks) + // Preserve named exports (e.g. the `onEnable` runtime hook and `functions`) // alongside the default-exported stack. Module-namespace named exports are // otherwise dropped when we unwrap `mod.default`, which prevents AppPlugin // from invoking runtime hooks. diff --git a/packages/cli/src/utils/graft-runtime-hooks.ts b/packages/cli/src/utils/graft-runtime-hooks.ts index 09b4299766..53918ce3f5 100644 --- a/packages/cli/src/utils/graft-runtime-hooks.ts +++ b/packages/cli/src/utils/graft-runtime-hooks.ts @@ -39,9 +39,9 @@ import { STACK_RUNTIME_MEMBERS } from '@objectstack/spec'; * - `functions` — the name → handler map that string-named hook and job * handlers (`Hook.handler: 'foo'`) resolve against. * - * `onDisable` is deliberately absent: it is declared in `packages/spec` but no - * kernel, runtime or service ever calls it, so grafting it would wire a hook - * nothing runs. + * `onDisable` is deliberately absent: no kernel, runtime or service ever + * calls it (`packages/spec` declared it until #4212 retired the uninvoked + * lifecycle family), so grafting it would wire a hook nothing runs. * * **Derived, not restated** (framework#4167). The same list decides what the * undeclared-top-level-key lint stays quiet about: these members are not diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 6e3aeef13f..5e51babafc 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -881,7 +881,6 @@ "HttpServerConfig (type)", "HttpServerConfigInput (type)", "HttpServerConfigSchema (const)", - "IKernel (interface)", "ISettingsCapability (interface)", "ISettingsClient (interface)", "InAppNotification (type)", @@ -1047,9 +1046,6 @@ "ObjectLike (interface)", "ObjectMetadata (type)", "ObjectMetadataSchema (const)", - "ObjectOSKernel (interface)", - "ObjectQLClient (interface)", - "ObjectStackPlugin (interface)", "ObjectStorageConfig (type)", "ObjectStorageConfigSchema (const)", "ObjectTranslationData (type)", @@ -1082,8 +1078,6 @@ "Plan (type)", "PlanInput (type)", "PlanSchema (const)", - "PluginContext (interface)", - "PluginLogger (interface)", "PresignedUrlConfig (type)", "PresignedUrlConfigSchema (const)", "ProvisioningStep (type)", @@ -1699,10 +1693,8 @@ "PluginInterface (type)", "PluginInterfaceSchema (const)", "PluginLifecycleEventType (type)", - "PluginLifecycleHooks (type)", "PluginLifecyclePhaseEvent (type)", "PluginLifecyclePhaseEventSchema (const)", - "PluginLifecycleSchema (const)", "PluginLoadingConfig (type)", "PluginLoadingConfigSchema (const)", "PluginLoadingEvent (type)", @@ -1840,8 +1832,6 @@ "UninstallPackageRequestSchema (const)", "UninstallPackageResponse (type)", "UninstallPackageResponseSchema (const)", - "UpgradeContext (type)", - "UpgradeContextSchema (const)", "UpgradeImpactLevel (type)", "UpgradeImpactLevelSchema (const)", "UpgradePackageRequest (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index cb2ebf9981..d4246319ef 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -5124,6 +5124,15 @@ "kernel/PackageDependencyResolutionResult:installOrder", "kernel/PackageDependencyResolutionResult:resolvedIn", "kernel/PackageDependencyResolutionResult:status", + "kernel/Plugin:author", + "kernel/Plugin:default", + "kernel/Plugin:description", + "kernel/Plugin:homepage", + "kernel/Plugin:id", + "kernel/Plugin:slug", + "kernel/Plugin:staticPath", + "kernel/Plugin:type", + "kernel/Plugin:version", "kernel/PluginCaching:compression", "kernel/PluginCaching:enabled", "kernel/PluginCaching:invalidateOn", @@ -5561,10 +5570,6 @@ "kernel/UninstallPackageResponse:id", "kernel/UninstallPackageResponse:message", "kernel/UninstallPackageResponse:success", - "kernel/UpgradeContext:isMajorUpgrade", - "kernel/UpgradeContext:newVersion", - "kernel/UpgradeContext:previousMetadata", - "kernel/UpgradeContext:previousVersion", "kernel/UpgradePackageRequest:createSnapshot", "kernel/UpgradePackageRequest:dryRun", "kernel/UpgradePackageRequest:manifest", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 1bbbc4427e..e9e4e0104a 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1059,6 +1059,7 @@ "kernel/PackageStatusEnum", "kernel/PermissionAction", "kernel/PermissionScope", + "kernel/Plugin", "kernel/PluginCaching", "kernel/PluginCapability", "kernel/PluginCapabilityManifest", @@ -1144,7 +1145,6 @@ "kernel/TenantRuntimeContext", "kernel/UninstallPackageRequest", "kernel/UninstallPackageResponse", - "kernel/UpgradeContext", "kernel/UpgradeImpactLevel", "kernel/UpgradePackageRequest", "kernel/UpgradePackageResponse", diff --git a/packages/spec/src/data/authoring-key-lint.ts b/packages/spec/src/data/authoring-key-lint.ts index 3f9f73f979..c553deffa1 100644 --- a/packages/spec/src/data/authoring-key-lint.ts +++ b/packages/spec/src/data/authoring-key-lint.ts @@ -178,9 +178,10 @@ export const STACK_KEY_GUIDANCE: Readonly< * * `functions` is listed for the same reason (a name → handler map the runtime * resolves string-named hooks against); the schema happens to declare it too, - * so it self-excludes. `onDisable` is deliberately ABSENT: it is declared in - * the protocol but no kernel, runtime or service ever calls it, so a value - * written there really does go nowhere and the lint should say so. + * so it self-excludes. `onDisable` is deliberately ABSENT: no kernel, runtime + * or service ever called it (the protocol declared it until #4212 retired the + * whole uninvoked lifecycle family), so a value written there really does go + * nowhere and the lint should say so. * * Single source of truth — the CLI's `GRAFTABLE_RUNTIME_MEMBERS` is derived * from this, so the list that decides what gets grafted and the list that diff --git a/packages/spec/src/kernel/metadata-authoring-lint.test.ts b/packages/spec/src/kernel/metadata-authoring-lint.test.ts index b2a48c6f30..296549347d 100644 --- a/packages/spec/src/kernel/metadata-authoring-lint.test.ts +++ b/packages/spec/src/kernel/metadata-authoring-lint.test.ts @@ -188,9 +188,10 @@ describe('top-level stack keys (#4167)', () => { }); it('still reports `onDisable`, which really does go nowhere', () => { - // The distinction the exclusion list has to preserve: `onDisable` is - // declared in the protocol but no kernel, runtime or service calls it, so - // a value written there IS lost and the author should hear about it. + // The distinction the exclusion list has to preserve: no kernel, runtime + // or service ever called `onDisable` (the protocol declared it until + // #4212 retired the uninvoked lifecycle family), so a value written there + // IS lost and the author should hear about it. const [finding, ...rest] = lint({ onDisable: () => {} }); expect(rest).toEqual([]); expect(finding).toMatchObject({ path: 'stack.onDisable', key: 'onDisable' }); diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 68a587c129..6d3005cd7e 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -138,11 +138,11 @@ export function getMetadataTypeSchema(type: string): z.ZodType | undefined { * plugin's **`init(ctx)`**, so `GET /api/v1/meta` starts emitting a real JSON * Schema for them. Idempotent. * - * This used to say "from their `onInstall` hook", pointing at a hook that does - * not run (#4212). The kernel's `Plugin` contract is `init` / `start` / + * This used to say "from their `onInstall` hook", pointing at a hook that + * never ran (#4212). The kernel's `Plugin` contract is `init` / `start` / * `destroy` (`packages/core/src/types.ts`); the `onInstall` / `onEnable` / - * `onDisable` / `onUninstall` / `onUpgrade` family declared on - * `PluginLifecycleSchema` has no invocation site anywhere in the runtime, so a + * `onDisable` / `onUninstall` / `onUpgrade` family had no invocation site + * anywhere in the runtime and was retired from the protocol by #4212, so a * plugin that followed the old advice registered nothing and got no error. * `init` is what the one real caller of the sibling * {@link registerMetadataTypeActions} uses — see diff --git a/packages/spec/src/kernel/plugin.test.ts b/packages/spec/src/kernel/plugin.test.ts index 35219b36f2..f82e819592 100644 --- a/packages/spec/src/kernel/plugin.test.ts +++ b/packages/spec/src/kernel/plugin.test.ts @@ -1,13 +1,11 @@ import { describe, it, expect } from 'vitest'; +import * as pluginZod from './plugin.zod'; import { PluginContextSchema, - PluginLifecycleSchema, PluginSchema, - UpgradeContextSchema, isConsumerInstallable, CONSUMER_INSTALLABLE_TYPES, type PluginContextData, - type PluginLifecycleHooks, type PluginDefinition, } from './plugin.zod'; @@ -26,261 +24,68 @@ describe('isConsumerInstallable (ADR-0019 — App as the consumer unit)', () => }); describe('PluginContextSchema', () => { - it('should accept valid plugin context', () => { - const context: PluginContextData = { - ql: { - object: () => ({}), - query: async () => ({}) - }, - os: { - getCurrentUser: async () => ({ id: 'test-user' }), - getConfig: async () => 'test-config' - }, - logger: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - }, - storage: { - get: async () => null, - set: async () => {}, - delete: async () => {} - }, - i18n: { - t: () => '', - getLocale: () => 'en' - }, - metadata: {}, - events: {}, - app: { - router: { - get: () => {}, - post: () => {}, - use: () => {} - } - }, - drivers: { - register: () => {} - } - }; - - expect(() => PluginContextSchema.parse(context)).not.toThrow(); + const validContext = (): PluginContextData => ({ + ql: { + object: () => ({}), + query: async () => ({}), + }, + os: { + getCurrentUser: async () => ({ id: 'test-user' }), + getConfig: async () => 'test-config', + }, + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + storage: { + get: async () => null, + set: async () => {}, + delete: async () => {}, + }, + i18n: { + t: () => '', + getLocale: () => 'en', + }, + metadata: {}, + events: {}, + app: { + router: { + get: () => {}, + post: () => {}, + use: () => {}, + }, + }, + drivers: { + register: () => {}, + }, }); - it('should accept context with all required properties', () => { - const completeContext = { - ql: { - object: () => ({}), - query: async () => ({}) - }, - os: { - getCurrentUser: async () => ({ id: 'test-user' }), - getConfig: async () => 'test-config' - }, - logger: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - }, - storage: { - get: async () => null, - set: async () => {}, - delete: async () => {} - }, - i18n: { - t: () => '', - getLocale: () => 'en' - }, - metadata: {}, - events: {}, - app: { - router: { - get: () => {}, - post: () => {}, - use: () => {} - } - }, - drivers: { - register: () => {} - } - }; - - const result = PluginContextSchema.safeParse(completeContext); - expect(result.success).toBe(true); + it('should accept valid plugin context', () => { + expect(() => PluginContextSchema.parse(validContext())).not.toThrow(); + expect(PluginContextSchema.safeParse(validContext()).success).toBe(true); }); it('should accept context with actual implementations', () => { const context = { - ql: { - object: (name: string) => ({ - find: async () => [], - create: async (data: any) => data, - }), - query: async (soql: string) => ({ records: [] }) - }, - os: { - getCurrentUser: async () => ({ id: 'user123' }), - getConfig: async (key: string) => 'value', - }, - logger: { - debug: (message: string) => console.log(message), - info: (message: string) => console.log(message), - warn: (message: string) => console.warn(message), - error: (message: string, error?: any) => console.error(message, error), - }, - storage: { - get: async (key: string) => null, - set: async (key: string, value: any) => {}, - delete: async (key: string) => {} - }, - i18n: { - t: (key: string, params?: any) => key, - getLocale: () => 'en-US' - }, + ...validContext(), metadata: { - getObject: async (name: string) => ({}), - getFields: async (object: string) => [], + getObject: async () => ({}), + getFields: async () => [], }, events: { - on: (event: string, handler: Function) => {}, - emit: (event: string, data?: any) => {}, - }, - app: { - router: { - get: (path: string, handler: Function) => {}, - post: (path: string, handler: Function) => {}, - use: (pathOrHandler: string | Function, handler?: Function) => {} - } + on: () => {}, + emit: () => {}, }, - drivers: { - register: (driver: any) => {} - } }; - expect(() => PluginContextSchema.parse(context)).not.toThrow(); }); }); -describe('PluginLifecycleSchema', () => { - it('should accept empty lifecycle (all hooks optional)', () => { - const lifecycle: PluginLifecycleHooks = {}; - - expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); - }); - - it('should accept lifecycle with onInstall hook', () => { - const lifecycle: PluginLifecycleHooks = { - onInstall: async (context: PluginContextData) => { - // Installation logic - }, - }; - - expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); - }); - - it('should accept lifecycle with onEnable hook', () => { - const lifecycle: PluginLifecycleHooks = { - onEnable: async (context: PluginContextData) => { - // Enable logic - }, - }; - - expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); - }); - - it('should accept lifecycle with onDisable hook', () => { - const lifecycle: PluginLifecycleHooks = { - onDisable: async (context: PluginContextData) => { - // Disable logic - }, - }; - - expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); - }); - - it('should accept lifecycle with onUninstall hook', () => { - const lifecycle: PluginLifecycleHooks = { - onUninstall: async (context: PluginContextData) => { - // Uninstall logic - }, - }; - - expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); - }); - - it('should accept lifecycle with onUpgrade hook', () => { - const lifecycle: PluginLifecycleHooks = { - onUpgrade: async (context: PluginContextData, fromVersion: string, toVersion: string) => { - // Upgrade logic - }, - }; - - expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); - }); - - it('should accept lifecycle with all hooks', () => { - const lifecycle: PluginLifecycleHooks = { - onInstall: async (context: PluginContextData) => { - await context.ql.object('plugin_data').syncSchema(); - }, - onEnable: async (context: PluginContextData) => { - context.logger.info('Plugin enabled'); - }, - onDisable: async (context: PluginContextData) => { - context.logger.info('Plugin disabled'); - }, - onUninstall: async (context: PluginContextData) => { - await context.ql.object('plugin_data').dropTable(); - }, - onUpgrade: async (context: PluginContextData, from: string, to: string) => { - context.logger.info(`Upgrading from ${from} to ${to}`); - }, - }; - - expect(() => PluginLifecycleSchema.parse(lifecycle)).not.toThrow(); - }); -}); - -describe('UpgradeContextSchema', () => { - it('should accept valid upgrade context', () => { - const context = { - previousVersion: '1.0.0', - newVersion: '2.0.0', - isMajorUpgrade: true, - }; - const parsed = UpgradeContextSchema.parse(context); - expect(parsed.previousVersion).toBe('1.0.0'); - expect(parsed.newVersion).toBe('2.0.0'); - expect(parsed.isMajorUpgrade).toBe(true); - }); - - it('should accept upgrade context with metadata snapshot', () => { - const context = { - previousVersion: '1.2.0', - newVersion: '1.3.0', - isMajorUpgrade: false, - previousMetadata: { - 'objects': { count: 5 }, - 'views': { count: 10 }, - }, - }; - const parsed = UpgradeContextSchema.parse(context); - expect(parsed.previousMetadata).toBeDefined(); - expect(parsed.isMajorUpgrade).toBe(false); - }); - - it('should reject missing required fields', () => { - expect(() => UpgradeContextSchema.parse({})).toThrow(); - expect(() => UpgradeContextSchema.parse({ previousVersion: '1.0.0' })).toThrow(); - }); -}); - -describe('PluginSchema', () => { +describe('PluginSchema (descriptor only)', () => { it('should accept plugin with minimal metadata', () => { const plugin: PluginDefinition = {}; - expect(() => PluginSchema.parse(plugin)).not.toThrow(); }); @@ -289,297 +94,45 @@ describe('PluginSchema', () => { id: 'com.example.plugin', version: '1.0.0', }; - - expect(() => PluginSchema.parse(plugin)).not.toThrow(); - }); - - it('should accept complete plugin definition', () => { - const plugin: PluginDefinition = { - id: 'com.example.bi-plugin', - version: '2.1.0', - onInstall: async (context) => { - await context.ql.object('bi_report').create({ - name: 'Default Report', - type: 'chart', - }); - }, - onEnable: async (context) => { - context.events.on('record.created', async (data: any) => { - context.logger.info('Record created', data); - }); - }, - onDisable: async (context) => { - // Cleanup event handlers - }, - onUninstall: async (context) => { - await context.ql.object('bi_report').delete({ plugin_id: 'com.example.bi-plugin' }); - }, - onUpgrade: async (context, from, to) => { - if (from === '1.0.0' && to === '2.0.0') { - // Migrate data - await context.ql.object('bi_report').update( - {}, - { migrated: true } - ); - } - }, - }; - expect(() => PluginSchema.parse(plugin)).not.toThrow(); }); }); -describe('Plugin Lifecycle Scenarios', () => { - describe('Installation Flow', () => { - it('should handle plugin installation', async () => { - let installed = false; - - const plugin: PluginDefinition = { - id: 'test.plugin', - version: '1.0.0', - onInstall: async (context) => { - installed = true; - await context.ql.object('test_object').syncSchema(); - }, - }; - - const parsed = PluginSchema.parse(plugin); - expect(parsed.onInstall).toBeDefined(); - - // Simulate installation - if (parsed.onInstall) { - await parsed.onInstall({ - ql: { - object: () => ({ syncSchema: async () => {} }), - query: async () => ({}) - }, - os: { - getCurrentUser: async () => ({ id: 'test-user' }), - getConfig: async () => 'test-config' - }, - logger: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - }, - storage: { - get: async () => null, - set: async () => {}, - delete: async () => {} - }, - i18n: { - t: () => '', - getLocale: () => 'en' - }, - metadata: {}, - events: {}, - app: { - router: { - get: () => {}, - post: () => {}, - use: () => {} - } - }, - drivers: { - register: () => {} - } - } as any); - } - - expect(installed).toBe(true); - }); - }); - - describe('Enable/Disable Flow', () => { - it('should handle plugin enable and disable', async () => { - let enabled = false; - - const plugin: PluginDefinition = { - onEnable: async (context) => { - enabled = true; - context.logger.info('Plugin enabled'); - }, - onDisable: async (context) => { - enabled = false; - context.logger.info('Plugin disabled'); - }, - }; - - const parsed = PluginSchema.parse(plugin); - - const mockContext = { - ql: { - object: () => ({}), - query: async () => ({}) - }, - os: { - getCurrentUser: async () => ({ id: 'test-user' }), - getConfig: async () => 'test-config' - }, - logger: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - }, - storage: { - get: async () => null, - set: async () => {}, - delete: async () => {} - }, - i18n: { - t: () => '', - getLocale: () => 'en' - }, - metadata: {}, - events: {}, - app: { - router: { - get: () => {}, - post: () => {}, - use: () => {} - } - }, - drivers: { - register: () => {} - } - } as any; - - // Enable - if (parsed.onEnable) { - await parsed.onEnable(mockContext); - } - expect(enabled).toBe(true); - - // Disable - if (parsed.onDisable) { - await parsed.onDisable(mockContext); - } - expect(enabled).toBe(false); - }); +describe('the lifecycle-hook family stays retired (#4212)', () => { + it('PluginSchema declares none of the five hooks', () => { + // The kernel's plugin contract is `init`/`start`/`destroy` + // (`packages/core/src/types.ts`). The `onInstall` family was declared here + // for years with no invocation site anywhere in the runtime — a plugin + // authoring them shipped code that never ran. If one of these names comes + // back it must arrive WITH a kernel that calls it, and this test is where + // that conversation starts. + const declared = new Set(Object.keys(PluginSchema.shape)); + for (const hook of ['onInstall', 'onEnable', 'onDisable', 'onUninstall', 'onUpgrade']) { + expect(declared, `'${hook}' is back on PluginSchema — is there a caller this time?`).not.toContain(hook); + } }); - describe('Upgrade Flow', () => { - it('should handle version upgrade', async () => { - let upgradeCalled = false; - let upgradeFrom = ''; - let upgradeTo = ''; - - const plugin: PluginDefinition = { - onUpgrade: async (context, from, to) => { - upgradeCalled = true; - upgradeFrom = from; - upgradeTo = to; - }, - }; - - const parsed = PluginSchema.parse(plugin); - - if (parsed.onUpgrade) { - await parsed.onUpgrade( - { - ql: { - object: () => ({}), - query: async () => ({}) - }, - os: { - getCurrentUser: async () => ({ id: 'test-user' }), - getConfig: async () => 'test-config' - }, - logger: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - }, - storage: { - get: async () => null, - set: async () => {}, - delete: async () => {} - }, - i18n: { - t: () => '', - getLocale: () => 'en' - }, - metadata: {}, - events: {}, - app: { - router: { - get: () => {}, - post: () => {}, - use: () => {} - } - }, - drivers: { - register: () => {} - } - } as any, - '1.0.0', - '2.0.0' - ); - } - - expect(upgradeCalled).toBe(true); - expect(upgradeFrom).toBe('1.0.0'); - expect(upgradeTo).toBe('2.0.0'); + it('an authored hook is stripped by the parse, not honoured', () => { + // Non-strict schema → unknown keys strip silently. Pin the strip so the + // retirement's observable behaviour (the value goes nowhere) is stated + // rather than assumed. The authoring-side diagnostic for this family + // lives in the stack-level lint (`lintUnknownStackKeys`), which reports + // `onDisable` and friends as undeclared keys. + const parsed = PluginSchema.parse({ + id: 'com.example.plugin', + onInstall: async () => {}, + onUpgrade: async () => {}, }); + expect(parsed).not.toHaveProperty('onInstall'); + expect(parsed).not.toHaveProperty('onUpgrade'); }); - describe('Uninstallation Flow', () => { - it('should handle plugin uninstallation', async () => { - let uninstalled = false; - - const plugin: PluginDefinition = { - onUninstall: async (context) => { - uninstalled = true; - await context.ql.object('test_object').dropTable(); - }, - }; - - const parsed = PluginSchema.parse(plugin); - - if (parsed.onUninstall) { - await parsed.onUninstall({ - ql: { - object: () => ({ dropTable: async () => {} }), - query: async () => ({}) - }, - os: { - getCurrentUser: async () => ({ id: 'test-user' }), - getConfig: async () => 'test-config' - }, - logger: { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {} - }, - storage: { - get: async () => null, - set: async () => {}, - delete: async () => {} - }, - i18n: { - t: () => '', - getLocale: () => 'en' - }, - metadata: {}, - events: {}, - app: { - router: { - get: () => {}, - post: () => {}, - use: () => {} - } - }, - drivers: { - register: () => {} - } - } as any); - } - - expect(uninstalled).toBe(true); - }); + it('the retired exports are gone from the module', () => { + // `PluginLifecycleSchema`, `PluginLifecycleHooks` and `UpgradeContextSchema` + // existed solely for the five hooks (`UpgradeContext` served `onUpgrade`). + // Their only importer repo-wide was this very test file — which is the + // measure of how dead the surface was. + expect(pluginZod).not.toHaveProperty('PluginLifecycleSchema'); + expect(pluginZod).not.toHaveProperty('UpgradeContextSchema'); }); }); diff --git a/packages/spec/src/kernel/plugin.zod.ts b/packages/spec/src/kernel/plugin.zod.ts index 9b03c8388c..26eed0016c 100644 --- a/packages/spec/src/kernel/plugin.zod.ts +++ b/packages/spec/src/kernel/plugin.zod.ts @@ -53,43 +53,34 @@ export const PluginContextSchema = lazySchema(() => z.object({ export type PluginContextData = z.infer; export type PluginContext = PluginContextData; -/** - * Upgrade Context Schema - * - * Provides version migration context to the `onUpgrade` lifecycle hook. - * Enables developers to write conditional migration logic based on - * the previous and new versions. - */ -export const UpgradeContextSchema = lazySchema(() => z.object({ - /** Version before upgrade */ - previousVersion: z.string().describe('Version before upgrade'), - - /** Version after upgrade */ - newVersion: z.string().describe('Version after upgrade'), - - /** Whether this is a major version bump */ - isMajorUpgrade: z.boolean().describe('Whether this is a major version bump'), - - /** Metadata snapshot before upgrade (for migration logic) */ - previousMetadata: z.record(z.string(), z.unknown()).optional() - .describe('Metadata snapshot before upgrade'), -}).describe('Version migration context for onUpgrade hook')); - -export type UpgradeContext = z.infer; - -export const PluginLifecycleSchema = lazySchema(() => z.object({ - onInstall: z.function().optional().describe('Called when plugin is installed'), - - onEnable: z.function().optional().describe('Called when plugin is enabled'), - - onDisable: z.function().optional().describe('Called when plugin is disabled'), - - onUninstall: z.function().optional().describe('Called when plugin is uninstalled'), - - onUpgrade: z.function().optional().describe('Called when plugin is upgraded. Receives UpgradeContext with previousVersion, newVersion, and isMajorUpgrade'), -})); - -export type PluginLifecycleHooks = z.infer; +// --------------------------------------------------------------------------- +// RETIRED — the `onInstall` / `onEnable` / `onDisable` / `onUninstall` / +// `onUpgrade` lifecycle family, and the `UpgradeContextSchema` that existed to +// serve `onUpgrade` (#4212, ADR-0049 enforce-or-remove). +// +// The kernel never called any of them. Its plugin contract is `init(ctx)` / +// `start(ctx)` / `destroy()` (`packages/core/src/types.ts`): `kernel.use()` +// validates and stores, `bootstrap()` runs `init` then `start`, shutdown runs +// `destroy` in reverse order. The five hooks were declared here with no +// invocation site anywhere in the runtime — their only importer repo-wide was +// this file's own test — so a plugin authoring them shipped code that never +// ran, and the docs that recommended them sent authors at a dead seam +// (the #4212 report: a plugin following the documented advice registered its +// custom metadata types into nothing, with no error saying so). +// +// What owns each concern instead: +// - install-time work → the package-install subsystem +// (`registry.installPackage`, `sys_packages`, the marketplace flow); +// - boot-time code → `init`/`start`, or for authored app bundles the +// `onEnable` STACK runtime member `AppPlugin` invokes off the bundle +// (`STACK_RUNTIME_MEMBERS` — same name, different and real contract); +// - teardown → `destroy()`. +// +// Plain deletion, not `retiredKey()` tombstones: nothing parses plugin +// objects through these schemas (`stack.zod` carries plugins as +// `z.array(z.unknown())`), so a tombstone prescription could never be +// received — the `plugin-runtime.zod.ts` precedent. +// --------------------------------------------------------------------------- /** * Shared Plugin Types @@ -125,7 +116,7 @@ export function isConsumerInstallable(type: string | undefined): boolean { return type != null && (CONSUMER_INSTALLABLE_TYPES as readonly string[]).includes(type); } -export const PluginSchema = lazySchema(() => PluginLifecycleSchema.extend({ +export const PluginSchema = lazySchema(() => z.object({ id: z.string().min(1).optional().describe('Unique Plugin ID (e.g. com.example.crm)'), type: z.enum([ 'standard', // Default: General purpose backend logic (Service, Hook, etc.) diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index acbf9842be..3ec4130945 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -85,5 +85,10 @@ export * from './environment-artifact.zod'; // Constants export * from './constants'; -// Types -export * from './types'; +// `./types` (the `ObjectStackPlugin` lifecycle interface and its +// `PluginContext` / `PluginLogger` / `ObjectQLClient` / `IKernel` / +// `ObjectOSKernel` companions) was retired in #4212: it described an +// `onInstall`/`onEnable`/`onDisable` contract the kernel never implemented — +// the real plugin contract is `init`/`start`/`destroy` in +// `packages/core/src/types.ts` — and nothing in any repo imported it. It was +// seeded by the aspirational spec in issue #2 and survived as fiction. diff --git a/packages/spec/src/system/types/index.ts b/packages/spec/src/system/types/index.ts deleted file mode 100644 index 9866c3e231..0000000000 --- a/packages/spec/src/system/types/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * ObjectStack Plugin Runtime Interfaces - * - * This package defines the contract that every plugin must implement to be loaded by the ObjectStack runtime. - */ - -export * from './plugin'; diff --git a/packages/spec/src/system/types/plugin.ts b/packages/spec/src/system/types/plugin.ts deleted file mode 100644 index b5e804739e..0000000000 --- a/packages/spec/src/system/types/plugin.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Runtime interfaces for ObjectStack plugins. - * These define the contract that every plugin must implement to be loaded by the ObjectStack runtime. - */ - -/** - * Logger interface provided to plugins for structured logging. - */ -export interface PluginLogger { - /** Log an informational message */ - info(message: string, meta?: Record): void; - /** Log a warning message */ - warn(message: string, meta?: Record): void; - /** Log an error message */ - error(message: string, error?: Error, meta?: Record): void; - /** Log a debug message */ - debug(message: string, meta?: Record): void; -} - -/** - * ObjectQL Client interface for database operations. - * Provides a GraphQL-like query interface to the ObjectStack data layer. - */ -export interface ObjectQLClient { - /** - * Execute a query against the ObjectQL engine. - * @param query - The ObjectQL query string - * @param variables - Optional variables for the query - * @returns Promise resolving to query results - */ - query(query: string, variables?: Record): Promise; - - /** - * Execute a mutation against the ObjectQL engine. - * @param mutation - The ObjectQL mutation string - * @param variables - Optional variables for the mutation - * @returns Promise resolving to mutation results - */ - mutate(mutation: string, variables?: Record): Promise; -} - -/** - * Kernel interface. - * Provides access to core operating system services. - */ -export interface IKernel { - /** - * Get a reference to another installed plugin by its ID. - * @param pluginId - The unique identifier of the plugin - * @returns The plugin instance or null if not found - */ - getPlugin(pluginId: string): any | null; - - /** - * Emit an event to the system event bus. - * @param event - The event name - * @param data - The event payload - */ - emit(event: string, data?: any): void; - - /** - * Subscribe to system events. - * @param event - The event name to listen for - * @param handler - Callback function to handle the event - * @returns Unsubscribe function - */ - on(event: string, handler: (data: any) => void): () => void; -} - -/** @deprecated Renamed — use {@link IKernel}. The "ObjectOS" layer name is retired; ObjectOS now names the commercial runtime environment. Kept as an empty interface extension (not a type alias) so declaration merging and the published API surface stay interface-compatible. */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface ObjectOSKernel extends IKernel {} - -/** - * Plugin Context provided to plugin lifecycle methods. - * This context gives plugins access to the ObjectStack runtime environment. - */ -export interface PluginContext { - /** - * ObjectQL client for database operations. - * Use this to query and mutate data in the ObjectStack data layer. - */ - ql: ObjectQLClient; - - /** - * Kernel for system-level operations. - * Use this to interact with other plugins and system services. - */ - os: IKernel; - - /** - * Logger instance for structured logging. - * All logs are automatically tagged with the plugin ID. - */ - logger: PluginLogger; - - /** - * The unique identifier of this plugin. - */ - pluginId: string; - - /** - * Plugin configuration values (from manifest or runtime config). - */ - config: Record; -} - -/** - * Plugin Lifecycle Interface. - * Every ObjectStack plugin must default export an object implementing this interface. - */ -export interface ObjectStackPlugin { - /** - * Called when the plugin is installed for the first time. - * Use this to run one-time setup tasks (e.g. creating database tables). - */ - onInstall?: (context: PluginContext) => Promise; - - /** - * Called when the plugin is enabled (at startup or manually). - * Use this to register event listeners, start background tasks, etc. - */ - onEnable?: (context: PluginContext) => Promise; - - /** - * Called when the plugin is disabled (at shutdown or manually). - * Use this to cleanup resources, stop tasks, remove listeners. - */ - onDisable?: (context: PluginContext) => Promise; -} diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md index 10b768f85e..60a957d474 100644 --- a/skills/objectstack-platform/references/_index.md +++ b/skills/objectstack-platform/references/_index.md @@ -17,7 +17,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/kernel/plugin-capability.zod.ts` — Plugin Capability Protocol - `node_modules/@objectstack/spec/src/kernel/plugin-lifecycle-events.zod.ts` — Plugin Lifecycle Events Protocol - `node_modules/@objectstack/spec/src/kernel/plugin-loading.zod.ts` — Plugin Loading Protocol -- `node_modules/@objectstack/spec/src/kernel/plugin.zod.ts` — Upgrade Context Schema +- `node_modules/@objectstack/spec/src/kernel/plugin.zod.ts` — Shared Plugin Types - `node_modules/@objectstack/spec/src/kernel/service-registry.zod.ts` — Service Registry Protocol ## Transitive dependencies