Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/plugin-lifecycle-hooks-retired.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,13 +346,16 @@ export const FieldSchema = z.object({
export type Field = z.infer<typeof FieldSchema>;
```

**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 */ }
}
```

---
Expand Down
49 changes: 19 additions & 30 deletions content/docs/protocol/kernel/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
78 changes: 42 additions & 36 deletions content/docs/protocol/kernel/lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
},
};
```
<Callout type="warn">
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`.
</Callout>

### Installation CLI

Expand Down
Loading
Loading