ObjectStackObjectStack

Plugin Package Specification

Manifest structure, directory layout, dependency management, and distribution format

Plugin Package Specification

Protocol spec. This page describes the target plugin packaging specification — an ergonomic manifest wrapper, semantic-version dependency resolution, provider/consumer contracts. The current runtime implements plugins as classes/objects matching the Plugin interface from @objectstack/core, and manifests as plain objects matching ManifestSchema from @objectstack/spec/kernel. There is no definePlugin() helper today — that wrapper is the proposed ergonomic shape. Treat the snippets below as design intent for the manifest spec, not paste-ready code.

A plugin is the unit of distribution in ObjectStack. It packages ObjectQL schemas, ObjectUI layouts, business logic, and configuration into a self-contained module that can be installed, upgraded, and removed independently.

Plugin Manifest

Every plugin must have a manifest file that declares its identity, dependencies, and capabilities.

Manifest Location

my-plugin/
  src/manifest.ts            ← TypeScript source: ManifestSchema.parse({ … })
  objectstack.config.ts      ← stack entry: defineStack({ manifest, objects, … })
  objectstack.plugin.json    ← distributable manifest read by `os plugin build`

There is no plugin.manifest.ts / .yml / .json — that filename appears nowhere in the implementation. The shape is always ManifestSchema from @objectstack/spec/kernel. os plugin build reads the JSON form (objectstack.plugin.json, exported as MANIFEST_FILENAME from packages/cli/src/utils/osplugin.ts) and validates it with ManifestSchema.safeParse; TypeScript authoring runs the same object through ManifestSchema.parse(...) and hands it to defineStack({ manifest }).

Manifest Schema

// src/manifest.ts — proposed authoring wrapper (see the callout below for the
// fields ManifestSchema actually declares today)
import { definePlugin } from '@objectstack/core';

export default definePlugin({
  // IDENTITY
  name: '@mycompany/crm',
  version: '1.5.0',
  displayName: 'Customer Relationship Management',
  description: 'Complete CRM with accounts, contacts, and opportunities',
  author: 'MyCompany <dev@mycompany.com>',
  license: 'MIT',
  homepage: 'https://github.com/mycompany/crm',
  
  // DEPENDENCIES
  dependencies: {
    '@objectstack/core': '^2.0.0',
    '@mycompany/base': '1.0.0 - 2.0.0',
  },
  
  // OPTIONAL DEPENDENCIES (plugin works without these)
  optionalDependencies: {
    '@vendor/email': '^3.0.0',
  },
  
  // PEER DEPENDENCIES (consumer must install)
  peerDependencies: {
    '@objectstack/ui': '^2.0.0',
  },
  
  // METADATA REGISTRATION
  metadata: {
    // ObjectQL objects
    objects: [
      'src/objects/**/*.object.ts',
    ],
    
    // ObjectUI views
    views: [
      'src/views/**/*.view.ts',
    ],
    
    // Business logic
    triggers: [
      'src/triggers/**/*.trigger.ts',
    ],
    
    workflows: [
      'src/workflows/**/*.workflow.ts',
    ],
    
    // Internationalization
    translations: [
      'i18n/**/*.json',
    ],
    
    // Configuration schema
    configSchema: 'src/config.schema.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 — proposed shape only. The real block has no `system` or
  // `objects` key; see "Permissions" below for what ManifestSchema accepts.
  permissions: {
    system: [
      'network.http',
      'storage.database',
      'storage.cache',
    ],
    objects: [
      'account',
      'contact',
      'opportunity',
    ],
  },
  
  // CONFIGURATION
  config: {
    // Default values
    defaults: {
      maxAccountsPerUser: 1000,
      enableOpportunityScoring: true,
    },
    
    // Secrets (encrypted at rest)
    secrets: [
      'apiKey',
      'webhookSecret',
    ],
  },
  
  // MARKETPLACE METADATA
  marketplace: {
    // Category for marketplace listing
    category: 'crm',
    
    // Screenshots
    screenshots: [
      'assets/screenshot-1.png',
      'assets/screenshot-2.png',
    ],
    
    // Pricing
    pricing: {
      model: 'subscription',
      price: 29.99,
      currency: 'USD',
      interval: 'month',
    },
    
    // Compatibility
    compatibility: {
      minObjectStackVersion: '17.0.0',
      maxObjectStackVersion: '18.0.0',
      nodeVersion: '>=22.0.0',
    },
  },
});

What ManifestSchema actually declares today (packages/spec/src/kernel/manifest.zod.ts): id, version, type and name are required; the optional fields are namespace, defaultDatasource, scope, description, permissions, objects, datasources, dependencies, configuration, contributes, data, capabilities, extensions, navigationContributions, loading, engine, engines, runtime, packaging and integrity.

The displayName / author / license / homepage / optionalDependencies / peerDependencies / metadata / config / marketplace keys above are proposal-only — the schema declares none of them. Notably: compatibility ranges live in engines: { platform, protocol } (or the legacy engine: { objectstack }), not marketplace.compatibility; config defaults live in configuration: { title, properties } (a simplified JSON-Schema map with a per-key secret flag), not config.defaults / config.secrets; and metadata globs are the top-level objects / datasources arrays, not a metadata block.

Directory Structure

A well-organized plugin follows this standard structure:

@mycompany/crm/
├── objectstack.plugin.json     # Plugin manifest — ManifestSchema (required)
├── package.json                # NPM package metadata
├── README.md                   # Documentation
├── CHANGELOG.md                # Version history
├── LICENSE                     # License text

├── src/
│   ├── objects/                # ObjectQL schemas
│   │   ├── account.object.ts
│   │   ├── contact.object.ts
│   │   └── opportunity.object.ts
│   │
│   ├── views/                  # ObjectUI layouts
│   │   ├── account_list.view.ts
│   │   ├── account_detail.view.ts
│   │   └── opportunity_kanban.view.ts
│   │
│   ├── triggers/               # Business logic triggers
│   │   ├── account_validation.trigger.ts
│   │   └── opportunity_scoring.trigger.ts
│   │
│   ├── workflows/              # Visual workflows
│   │   └── opportunity_approval.workflow.ts
│   │
│   ├── actions/                # Custom actions
│   │   └── send_proposal.action.ts
│   │
│   ├── api/                    # Custom API endpoints
│   │   └── sync.endpoint.ts
│   │
│   ├── jobs/                   # Background jobs
│   │   └── cleanup.job.ts
│   │
│   └── config.schema.ts        # Configuration schema (Zod)
│                               # (no src/lifecycle/ — the install/boot/upgrade
│                               #  hook family was retired, #4212)

├── i18n/                       # Translations
│   ├── en.json
│   ├── de.json
│   └── es.json

├── migrations/                 # Database migrations
│   ├── 001_create_objects.ts
│   └── 002_add_indexes.ts

├── tests/                      # Unit and integration tests
│   ├── objects/
│   ├── triggers/
│   └── integration/

├── assets/                     # Static files (icons, images)
│   ├── icon.svg
│   └── screenshots/

└── dist/                       # Compiled output (generated)

Metadata Definitions

ObjectQL Schemas

Define database objects using ObjectQL schema syntax:

// src/objects/account.object.ts
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Account = ObjectSchema.create({
  name: 'account',
  label: 'Account',
  pluralLabel: 'Accounts',
  icon: 'building',
  
  fields: {
    name: Field.text({
      label: 'Account Name',
      required: true,
      maxLength: 255,
    }),
    
    industry: Field.select({
      label: 'Industry',
      options: [
        { label: 'Technology', value: 'technology' },
        { label: 'Finance', value: 'finance' },
        { label: 'Healthcare', value: 'healthcare' },
      ],
    }),
    
    annual_revenue: Field.currency({
      label: 'Annual Revenue',
      scale: 2,
    }),
    
    primary_contact: Field.lookup('contact', {
      label: 'Primary Contact',
    }),
  },
  
  enable: {
    trackHistory: true,
    searchable: true,
    apiEnabled: true,
  },
});

ObjectUI Views

Define user interfaces using ObjectUI layout DSL:

// src/views/account_list.view.ts
import { defineView } from '@objectstack/spec';

// `defineView` takes a view *container* keyed by slot (`list`, `form`,
// `listViews`, `formViews`). A flat `{ name, object, type, layout }` object is
// rejected at build time. The target object goes under `data` (provider
// `object`), and `type` is one of grid | kanban | gallery | calendar |
// timeline | gantt | map | chart | tree — a data table is `grid` (there is no
// `list` type).
export default defineView({
  list: {
    type: 'grid',
    data: { provider: 'object', object: 'account' },
    columns: [
      { field: 'name', width: 200 },
      { field: 'industry', width: 150 },
      { field: 'annual_revenue', width: 150 },
      { field: 'primary_contact', width: 200 },
      { field: 'created_at', width: 150 },
    ],
  },
});

Triggers

Define business logic that executes on data changes. (Like definePlugin() above, defineTrigger() is part of the proposed ergonomic authoring surface — there is no such helper today. Record-triggered logic currently lives in a lifecycle hook module under src/objects/<name>.hook.ts. Treat the snippet below as design intent, not paste-ready code.)

// src/triggers/account_validation.trigger.ts (proposed shape)
import { defineTrigger } from '@objectstack/core';

export default defineTrigger({
  name: 'account_validation',
  object: 'account',
  when: 'beforeInsert',
  
  execute: async ({ record, context }) => {
    // Validation: Annual revenue must be positive
    if (record.annual_revenue < 0) {
      throw new Error('Annual revenue cannot be negative');
    }
    
    // Auto-populate: Generate account number
    if (!record.account_number) {
      record.account_number = await generateAccountNumber(context);
    }
    
    // Enrichment: Fetch company data from external API
    if (record.website) {
      const companyData = await enrichCompanyData(record.website);
      record.industry = record.industry || companyData.industry;
      record.employee_count = companyData.employeeCount;
    }
    
    return record;
  },
});

Configuration Schema

Define plugin configuration with Zod for validation:

// src/config.schema.ts
import { z } from 'zod';

export const configSchema = z.object({
  // API keys (marked as secret)
  apiKey: z.string()
    .describe('API Key for external service')
    .meta({ secret: true }),
  
  // Feature flags
  enableOpportunityScoring: z.boolean()
    .default(true)
    .describe('Enable AI-powered opportunity scoring'),
  
  // Limits
  maxAccountsPerUser: z.number()
    .min(1)
    .max(10000)
    .default(1000)
    .describe('Maximum accounts a user can own'),
  
  // URLs
  webhookUrl: z.string()
    .url()
    .optional()
    .describe('Webhook URL for account changes'),
  
  // Enums
  syncInterval: z.enum(['hourly', 'daily', 'weekly'])
    .default('daily')
    .describe('Data sync frequency'),
});

export type PluginConfig = z.infer<typeof configSchema>;

Dependency Management

Dependency Types

Only dependencies exists in ManifestSchema today (a Record<packageId, versionRange>). optionalDependencies and peerDependencies below are proposal-only — the schema declares neither, so nothing resolves them.

1. Dependencies (Required)

Plugin cannot function without these.

dependencies: {
  '@objectstack/core': '^2.0.0',
  '@mycompany/base': '1.0.0 - 2.0.0',
}

2. Optional Dependencies

Plugin can function without these, but features are enhanced if present.

optionalDependencies: {
  '@vendor/email': '^3.0.0',
}

// In plugin code — `PluginContext` (packages/core/src/types.ts) has no
// `plugins` namespace and no `isInstalled()`. Probe the service registry:
if (ctx.getServices().has('email')) {
  const email = ctx.getService<EmailService>('email');
  await email.send({ to: contact.email, subject: 'Welcome' });
}

3. Peer Dependencies

Plugin expects consumer to install these (not bundled).

peerDependencies: {
  '@objectstack/ui': '^2.0.0',
}

Use for large dependencies (React, Vue) that should be shared across plugins.

Version Constraints

ObjectStack uses semantic versioning (semver). SemanticVersionManager.satisfies() (packages/core/src/dependency-resolver.ts) accepts exactly these forms:

dependencies: {
  // Exact version
  'plugin-a': '1.0.0',
  
  // Patch updates allowed (1.0.x)
  'plugin-b': '~1.0.0',
  
  // Minor updates allowed (1.x.x)
  'plugin-c': '^1.0.0',
  
  // Single bound: >= , > , <= , <
  'plugin-d': '>=1.0.0',
  
  // Inclusive range — the hyphen form is the ONLY two-bound form the
  // resolver parses. A space-separated `>=1.0.0 <2.0.0` is not supported:
  // `parse()` anchors its semver regex, so the trailing bound throws
  // "Invalid semantic version".
  'plugin-e': '1.0.0 - 2.0.0',
  
  // Latest version (`*` and `latest` both match everything)
  'plugin-f': '*',        // ⚠️ Not recommended for production
}

Dependency Resolution

ObjectStack builds a dependency graph and loads plugins in topological order:

@objectstack/core (no deps)

@mycompany/base (depends on core)

@mycompany/crm (depends on base)

@mycompany/sales (depends on crm)

Load Order: core → base → crm → sales

Conflict Resolution: When two plugins require incompatible versions of the same dependency, DependencyResolver.detectConflicts() reports it as a structured DependencyConflict record rather than a formatted CLI banner:

{
  type: 'version-mismatch',   // | missing-dependency | circular-dependency
                              // | incompatible-versions | conflicting-interfaces
  severity: 'error',
  description: 'Version mismatch for @objectstack/core: detected 1 unsatisfied requirements',
  plugins: [
    { pluginId: '@objectstack/core', version: '2.4.0' },
    { pluginId: '@vendor/analytics', version: '^3.0.0' },
  ],
  resolutions: [
    // strategy: upgrade | downgrade | replace | disable | manual
    { strategy: 'upgrade', description: 'Upgrade @objectstack/core to satisfy all constraints' },
  ],
}

Circular dependencies are reported separately with severity: 'critical'; the kernel also throws on them directly during bootstrap() ([Kernel] Circular dependency detected: …).

Runtime Contract

A runtime plugin implements exactly three lifecycle methods (packages/core/src/types.ts); init is required, the rest optional:

export class CRMPlugin implements Plugin {
  name = 'plugin.crm';
  version = '2.0.0';

  // Phase 1 — sequential, dependency-topological order. Register services,
  // schemas, routes. Other plugins' services may not exist yet.
  async init(ctx: PluginContext) {
    ctx.registerService('crm', new CRMService());
  }

  // Phase 2 — after every plugin's init. All services resolvable; begin work.
  async start(ctx: PluginContext) {
    ctx.logger.info('CRM sync running');
  }

  // Shutdown (reverse registration order), and rollback when a later
  // plugin's start() fails.
  async destroy() {
    /* stop timers, close connections */
  }
}

A plugin may also expose healthCheck(), which the kernel invokes on demand (kernel.checkPluginHealth) rather than at boot.

Execution Order

During boot (kernel.bootstrap()):

1. use(plugin)      — validate structure + version, store (no code runs)
2. init             — every plugin, sequentially, in dependency-topological
                      order (kernel.resolveDependencies() walks each plugin's
                      `dependencies`; registration order when none declared)
3. start            — same order, every plugin that defines it
4. kernel:ready → kernel:bootstrapped → kernel:listening events

During shutdown:

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. That file-map never existedManifestSchema has no lifecycle key and the kernel never invoked any of these hooks, so code written against them silently never ran. PluginLifecycleSchema (packages/spec/src/kernel/plugin.zod.ts) still declares onInstall, onEnable, onDisable, onUninstall and onUpgrade as optional functions, but nothing calls them; onBoot appears nowhere in the implementation at all. The single live exception is the module-level onEnable export of an app bundle described above, which AppPlugin.start() calls at boot — that is a stack-entry export, not a manifest hook. The family was retired from the protocol (#4212, ADR-0049 enforce-or-remove).

Permissions

Plugins declare permissions in the manifest. The declaration is the install-time consent request; what the runtime enforces is the granted set.

Declared Permissions

ManifestSchema.permissions accepts either the legacy flat string[] or the structured PluginPermissionsSchema block — four keys, and no system list (packages/spec/src/kernel/manifest.zod.ts):

permissions: {
  services: ['object', 'http'],       // services the plugin may resolve
  hooks: ['record.beforeInsert'],     // lifecycle hooks it may register
  network: ['api.acme.com'],          // hosts it may reach
  fs: [],                             // filesystem paths it may access
}

Dotted capability strings like network.http, network.websocket and storage.database are values of ResourceTypeSchema in the plugin-sandbox permission descriptor (packages/spec/src/kernel/plugin-security-advanced.zod.ts) — they are not manifest keys. storage.cache, storage.filesystem, system.cron, system.email, system.events, security.encrypt and security.sign do not exist anywhere in the implementation.

Enforcement is narrower than a sandbox. PluginPermissionEnforcer (packages/core/src/security/plugin-permission-enforcer.ts) derives canAccessService / canTriggerHook / canReadFile / canWriteFile / canNetworkRequest from the granted set, but only service access is wired into a live code path: SecurePluginContext.getService() and .replaceService() call enforceServiceAccess. enforceNetworkRequest, enforceFileRead and enforceFileWrite have no call sites outside the enforcer module itself, and nothing patches global fetch — a plugin that calls fetch() directly is not intercepted today. Treat network / fs declarations as consent metadata, not a runtime jail.

Declaring Objects

A package declares the objects it ships through the manifest's top-level objects glob list — ManifestSchema has no permissions.objects key:

objects: [
  './src/objects/*.object.ts',
]

Ownership is tracked by the package registry rather than by a permission declaration: install records the package and its metadata (registry.installPackage()sys_packages), and uninstall unregisters it and runs the data-plane cleanups domain plugins registered via protocol.registerUninstallCleanup(name, cleanup) — that is how plugin-security revokes its package-owned permission sets. There is no runtime rule preventing one plugin from writing to another plugin's objects.

Distribution Format

NPM Package

Plugins are distributed as NPM packages for easy versioning and distribution.

// package.json
{
  "name": "@mycompany/crm",
  "version": "1.5.0",
  "description": "Customer Relationship Management",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  
  "scripts": {
    "build": "tsc",
    "test": "jest",
    "lint": "eslint src/",
    "package": "os plugin build"
  },
  
  "files": [
    "dist/",
    "objectstack.plugin.json",
    "i18n/",
    "assets/",
    "README.md",
    "LICENSE"
  ],
  
  "dependencies": {
    "@objectstack/core": "^2.0.0"
  },
  
  "devDependencies": {
    "@objectstack/cli": "^2.0.0",
    "typescript": "^5.0.0"
  },
  
  "keywords": [
    "objectstack",
    "objectstack-plugin",
    "crm"
  ],
  
  "repository": {
    "type": "git",
    "url": "https://github.com/mycompany/crm"
  }
}

Publishing to NPM

# Build plugin
npm run build

# Build the plugin artifact (validates manifest, bundles entry)
os plugin build

# Sign the artifact (separate step; emits a detached .sig publisher signature)
os plugin sign crm-1.5.0.osplugin --key ./publisher.key.pem

# Publish to the ObjectStack package registry
os plugin publish

Plugin Archive (.osplugin)

For air-gapped environments or marketplaces, plugins are packaged as .osplugin files:

# Create the .osplugin artifact (defaults to <id>-<version>.osplugin)
os plugin build --out crm-1.5.0.osplugin

# Air-gapped install: `os package install` reads a compiled JSON artifact
# (e.g. ./dist/objectstack.json) into a running runtime — it does not ingest the
# .osplugin tarball, which is uploaded to the marketplace via `os plugin publish`.
os package install ./dist/objectstack.json

.osplugin format: Reproducible ustar+gzip tarball containing:

  • Compiled entry (dist/index.mjs) and bundled assets
  • Manifest (objectstack.plugin.json)
  • Per-file content digests (sha256-<base64> integrity map)
  • Signature placeholder (SIGNATURE, written by os plugin build); the real publisher signature is a detached <id>-<version>.osplugin.sig sidecar produced by os plugin sign (signing never modifies the artifact bytes)

Plugin Testing

The @objectstack/testing package and its createTestContext() / createTestEnvironment() helpers shown below are proposed — there is no such published package today. The snippets illustrate the intended test ergonomics for the plugin spec, not a shipping API.

Unit Tests

// tests/triggers/account_validation.test.ts
import { createTestContext } from '@objectstack/testing';
import accountValidation from '../../src/triggers/account_validation';

describe('Account Validation Trigger', () => {
  it('should reject negative revenue', async () => {
    const context = createTestContext();
    const record = { annual_revenue: -1000 };
    
    await expect(
      accountValidation.execute({ record, context })
    ).rejects.toThrow('Annual revenue cannot be negative');
  });
  
  it('should generate account number', async () => {
    const context = createTestContext();
    const record = { name: 'Acme Corp' };
    
    const result = await accountValidation.execute({ record, context });
    
    expect(result.account_number).toMatch(/^ACC-\d{6}$/);
  });
});

Integration Tests

// tests/integration/crm_workflow.test.ts
import { createTestEnvironment } from '@objectstack/testing';

describe('CRM Workflow', () => {
  let env;
  
  beforeAll(async () => {
    env = await createTestEnvironment({
      plugins: ['@mycompany/crm'],
    });
  });
  
  it('should create account with contacts', async () => {
    // Create account
    const account = await env.objectQL.create('account', {
      name: 'Test Corp',
      industry: 'technology',
    });
    
    // Create contact
    const contact = await env.objectQL.create('contact', {
      first_name: 'John',
      last_name: 'Doe',
      account: account.id,
    });
    
    // Verify relationship
    const accountWithContacts = await env.objectQL.findById('account', account.id, {
      include: ['contacts'],
    });
    
    expect(accountWithContacts.contacts).toHaveLength(1);
    expect(accountWithContacts.contacts[0].id).toBe(contact.id);
  });
});

Plugin Development Workflow

1. Scaffold Plugin

os create plugin crm

📁 Creating plugin: crm
📂 Location: packages/plugins/plugin-crm

 Created package.json
 Created tsconfig.json
 Created src/index.ts
 Created README.md

 Project created successfully!

Next steps:
  cd packages/plugins/plugin-crm
  pnpm install
  pnpm build

2. Develop Locally

cd packages/plugins/plugin-crm

# Watch mode (auto-rebuild on changes)
npm run dev

# In another terminal, run the dev server with hot-reload.
# Pass --ui to also serve the bundled Console portal at /_console/
os dev --ui

3. Test Plugin

# Run unit tests
npm test

# Run linter
npm run lint

# Build the artifact (validates the manifest against ManifestSchema)
os plugin build

4. Build & Publish

# Build production bundle
npm run build

# Build + sign the .osplugin artifact
os plugin build
os plugin sign crm-1.5.0.osplugin --key ./publisher.key.pem

# Publish to the ObjectStack package registry
os plugin publish

Best Practices

1. Use Semantic Versioning Strictly

  • Patch (1.0.x): Bug fixes, no breaking changes
  • Minor (1.x.0): New features, backward compatible
  • Major (x.0.0): Breaking changes

2. Document Breaking Changes

Always include migration guide in CHANGELOG.md for major versions.

## v2.0.0 (Breaking Changes)

### Removed
- `account.owner` field (use `account.owner_id` instead)

### Migration
Ship a metadata migration with the upgrade (ADR-0087) that renames the field on
existing records. There is no `onUpgrade` hook to hang this on: the schema
declares one (`PluginLifecycleSchema`) but nothing in the kernel calls it.

3. Pin Core Dependencies

Use exact version for @objectstack/core to avoid surprises:

dependencies: {
  '@objectstack/core': '2.0.0',  // Not '^2.0.0'
}

4. Validate Configuration Early

Parse config with your Zod schema in init() — the first phase that runs — so a bad value fails the boot instead of the first request. There is no onBoot hook (the name appears nowhere in the implementation), and PluginContext has no config member:

export class CRMPlugin implements Plugin {
  name = 'plugin.crm';
  constructor(private readonly options: unknown) {}

  async init(ctx: PluginContext) {
    // Throws a clear error before anything starts, not on the first request
    const config = configSchema.parse(this.options);
    ctx.registerService('crm', new CRMService(config));
  }
}

5. Release Resources in destroy()

destroy() is the only cleanup seam the kernel invokes — on shutdown (reverse registration order) and on rollback when a later plugin's start() fails. There is no onUninstall hook: PluginLifecycleSchema declares one but nothing calls it, and PluginContext exposes no db or scheduler member.

async destroy() {
  clearInterval(this.syncTimer);
  await this.connection.close();
}

For data-plane cleanup at package-uninstall time, register a named cleanup with the metadata protocol instead — protocol.registerUninstallCleanup('crm-archive', fn), which protocol.deletePackage() runs and whose outcome rides on the response.

Summary

Plugin packages in ObjectStack:

  • Manifest-driven: ManifestSchema is the source of truth (objectstack.plugin.json inside a built artifact)
  • Self-contained: Bundle objects, views, logic, and config
  • Dependency-managed: Semantic versioning with conflict detection
  • Lifecycle-aware: initstartdestroy on the plugin class — no install/upgrade/boot/uninstall hooks
  • NPM-compatible: Distribute via NPM or .osplugin archives

Next: Learn how configuration is resolved in Configuration Resolution.

On this page