Skip to content

Latest commit

 

History

History
462 lines (366 loc) · 13.5 KB

File metadata and controls

462 lines (366 loc) · 13.5 KB
title Plugin Development
description Step-by-step guide to creating, testing, and publishing ObjectStack plugins

Plugin Development Tutorial

This guide walks you through creating an ObjectStack plugin from scratch — from project setup to testing and registration.

**Source:** `packages/core/src/types.ts` (the runtime `Plugin` interface) **Import:** `import type { Plugin, PluginContext } from '@objectstack/core'`

What is a Plugin?

A plugin is a self-contained module that extends the ObjectStack kernel with:

  • Services — Add new capabilities (email, payment, analytics)
  • Hooks — React to lifecycle events (before/after record create, update, delete)
  • Objects — Register new data objects and fields
  • UI Components — Add custom views, widgets, or actions
  • API Endpoints — Expose new REST endpoints

Step 1: Create the Plugin Project

mkdir objectstack-plugin-hello
cd objectstack-plugin-hello
npm init -y
npm install @objectstack/core @objectstack/spec
npm install -D typescript vitest @types/node

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "declaration": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

Step 2: Define the Package Manifest

A plugin package describes itself with a manifest validated by ManifestSchema. Create src/manifest.ts:

{/* os:check */}

import { ManifestSchema } from '@objectstack/spec/kernel';

export const manifest = ManifestSchema.parse({
  id: 'com.example.hello',          // required — reverse-domain package id
  name: 'objectstack-plugin-hello', // required — human-readable name
  version: '1.0.0',
  description: 'A simple example plugin that adds a greeting service.',
  type: 'plugin',
});
A runtime plugin's behaviour (services, hooks, objects) is declared in code via the `Plugin` interface — see Step 3. The manifest only describes package metadata.

Step 3: Implement the Plugin

Create src/index.ts. A runtime plugin implements the Plugin interface: services are registered inside init(ctx) via ctx.registerService. Kernel lifecycle hooks (kernel:ready, custom events) are wired with ctx.hook(...); record lifecycle hooks (beforeInsert, afterUpdate, …) are registered on the data engine — see Events & Hooks for the two hook systems.

import type { Plugin, PluginContext } from '@objectstack/core';

export interface GreetingService {
  greet(name: string): string;
}

export function createHelloPlugin(): Plugin {
  const greetingService: GreetingService = {
    greet(name: string): string {
      return `Hello, ${name}! Welcome to ObjectStack.`;
    }
  };

  return {
    name: 'hello_world',
    version: '1.0.0',
    // Ensure the data engine plugin initializes before this one
    dependencies: ['com.objectstack.engine.objectql'],

    init(ctx: PluginContext) {
      // Register services so other plugins can consume them
      ctx.registerService('greeting', greetingService);

      // Hook into the record lifecycle via the data engine.
      // Data hooks receive a single HookContext. The input shape is
      // `{ data }` for inserts (`{ id, data }` for updates) — mutate
      // `hookCtx.input.data` in before* hooks to change the operation.
      const engine = ctx.getService<any>('data');
      engine.registerHook('beforeInsert', async (hookCtx: any) => {
        const data = hookCtx.input.data;
        if (data?.first_name) {
          // Auto-generate a greeting field
          data.welcome_message = greetingService.greet(data.first_name as string);
        }
      }, { object: 'contact' }); // the engine only runs this hook for 'contact'
    }
  };
}

Step 4: Add Custom Objects (Optional)

Plugins can register new objects:

{/* os:check */}

import { ObjectSchema } from '@objectstack/spec/data';

export const greetingLogObject = ObjectSchema.create({
  name: 'greeting_log',
  label: 'Greeting Log',
  fields: {
    recipient: { label: 'Recipient', type: 'text', required: true },
    message: { label: 'Message', type: 'text', required: true },
    sent_at: { label: 'Sent At', type: 'datetime' },
    channel: { label: 'Channel', type: 'select', options: [
      { label: 'Email', value: 'email' },
      { label: 'SMS', value: 'sms' },
      { label: 'In-App', value: 'in_app', default: true }
    ]}
  }
});

Step 5: Write Tests

Create src/index.test.ts:

import { describe, it, expect } from 'vitest';
import type { PluginContext } from '@objectstack/core';
import { createHelloPlugin } from './index';

// Minimal fake PluginContext with a stub data engine that captures hooks.
function createFakeContext() {
  const services: Record<string, unknown> = {};
  const hooks: Record<string, (hookCtx: any) => void | Promise<void>> = {};
  const hookOptions: Record<string, unknown> = {};
  const engine = {
    registerHook: (event: string, handler: (hookCtx: any) => void, options?: unknown) => {
      hooks[event] = handler;
      hookOptions[event] = options;
    },
  };
  const ctx = {
    registerService: (name: string, service: unknown) => { services[name] = service; },
    getService: (name: string) => (name === 'data' ? engine : services[name]),
  } as unknown as PluginContext;
  return { ctx, services, hooks, hookOptions };
}

describe('HelloPlugin', () => {
  it('should have the correct name', () => {
    expect(createHelloPlugin().name).toBe('hello_world');
  });

  it('should register the greeting service', () => {
    const { ctx, services } = createFakeContext();
    createHelloPlugin().init(ctx);
    expect(services).toHaveProperty('greeting');
  });

  it('should greet by name', () => {
    const { ctx, services } = createFakeContext();
    createHelloPlugin().init(ctx);
    const greeting = services.greeting as { greet: (name: string) => string };
    expect(greeting.greet('Alice')).toBe('Hello, Alice! Welcome to ObjectStack.');
  });

  it('should add welcome message on contact create', async () => {
    const { ctx, hooks } = createFakeContext();
    createHelloPlugin().init(ctx);
    const hookCtx = { object: 'contact', input: { data: { first_name: 'Bob' } as Record<string, unknown> } };
    await hooks['beforeInsert'](hookCtx);
    expect(hookCtx.input.data.welcome_message).toBe('Hello, Bob! Welcome to ObjectStack.');
  });

  it('should scope the hook to the contact object', () => {
    // The engine (not the handler) filters by object — assert the option
    const { ctx, hookOptions } = createFakeContext();
    createHelloPlugin().init(ctx);
    expect(hookOptions['beforeInsert']).toEqual({ object: 'contact' });
  });
});

Run tests:

npx vitest run

Step 6: Register with the Kernel

In your application's objectstack.config.ts:

import { defineStack } from '@objectstack/spec';
import { createHelloPlugin } from 'objectstack-plugin-hello';

export default defineStack({
  objects: [/* your objects */],
  plugins: [
    createHelloPlugin()
  ]
});

The kernel loads plugins in dependency order, calling each plugin's init(ctx) first (where services and hooks are registered) and then start(ctx) once all plugins have initialized.


Plugin Best Practices

Naming Conventions

Item Convention Example
Plugin name snake_case hello_world
Service name snake_case greeting
Object names snake_case greeting_log
npm package kebab-case objectstack-plugin-hello

Permission Model

Declare the capabilities your plugin needs in its manifest's permissions block (the structured PluginPermissionsSchema, ADR-0025 §3.2). Request only the minimum:

Grant Description
services Platform services the plugin may resolve (e.g. object, http)
hooks Lifecycle hooks the plugin may register (e.g. record.beforeInsert)
network Network hosts the plugin may reach (e.g. api.acme.com)
fs Filesystem paths the plugin may access

Error Handling

Always use structured errors:

{/* os:check */}

import type { EnhancedApiError } from '@objectstack/spec/api';

function handlePluginError(error: unknown): EnhancedApiError {
  return {
    code: 'INTERNAL_ERROR',
    message: `Hello plugin error: ${String(error)}`,
    category: 'server',
    httpStatus: 500,
    retryable: false
  };
}

Testing Checklist

  • [ ] Manifest validates against ManifestSchema
  • [ ] Services register correctly
  • [ ] Hooks fire on expected events
  • [ ] Custom objects validate against ObjectSchema
  • [ ] Error cases are handled gracefully
  • [ ] No side effects in service constructors

Plugin Directory Structure

objectstack-plugin-hello/
├── src/
│   ├── index.ts              # Plugin entry point
│   ├── index.test.ts         # Tests
│   ├── manifest.ts           # Plugin manifest
│   └── services/
│       └── greeting.ts       # Service implementation
├── package.json
├── tsconfig.json
└── README.md

Studio Plugin Manifests with defineStudioPlugin

For plugins that extend the ObjectStack Studio IDE, use defineStudioPlugin to declare UI contribution points — metadata viewers, sidebar groups, toolbar actions, panels, and commands. This follows a VS Code-like extension model.

**Source:** `packages/spec/src/studio/plugin.zod.ts` **Import:** `import { defineStudioPlugin } from '@objectstack/spec/studio'`

Basic Studio Plugin

{/* os:check */}

import { defineStudioPlugin } from '@objectstack/spec/studio';

export const manifest = defineStudioPlugin({
  id: 'mycompany.crm-designer',
  name: 'CRM Designer',
  version: '1.0.0',
  description: 'Custom object designer for CRM modules',
  author: 'Your Name',
  contributes: {
    metadataViewers: [{
      id: 'crm-object-explorer',
      metadataTypes: ['object', 'objects'],
      label: 'CRM Object Explorer',
      priority: 100,
      modes: ['preview', 'design', 'data'],
    }],
  },
});

Plugin ID Format

Plugin IDs use reverse-domain notation and must match the pattern ^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$:

{/* os:check */}

// ✅ Valid IDs
'objectstack.flow-designer'
'mycompany.crm-tools'
'acme.billing-plugin'

// ❌ Invalid IDs
'MyPlugin'         // uppercase
'my plugin'        // spaces
'my_plugin'        // underscores (use hyphens)

Contribution Points

Studio plugins can declare six types of contributions:

Contribution Purpose Example
metadataViewers Custom viewers/designers for metadata types Object explorer, Flow canvas
sidebarGroups Sidebar navigation groups "CRM Objects", "Automation"
actions Toolbar, context menu, and command palette actions "Deploy", "Validate Schema"
metadataIcons Icons and labels for metadata types Database icon for objects
panels Auxiliary panels (bottom, right, modal) Output log, Problems panel
commands Command palette entries with keyboard shortcuts "Open Settings" (Ctrl+,)

Full Example with All Contributions

{/* os:check */}

import { defineStudioPlugin } from '@objectstack/spec/studio';

export const manifest = defineStudioPlugin({
  id: 'objectstack.flow-designer',
  name: 'Flow Designer',
  version: '2.0.0',
  description: 'Visual flow builder for automation workflows',

  contributes: {
    metadataViewers: [{
      id: 'flow-canvas',
      metadataTypes: ['flow', 'flows'],
      label: 'Flow Canvas',
      priority: 100,
      modes: ['design', 'code'],
    }],

    sidebarGroups: [{
      key: 'automation',
      label: 'Automation',
      icon: 'workflow',
      metadataTypes: ['flow', 'job'], // ADR-0088: 'trigger' is a retired kind ('flow' and 'job' are canonical)
      order: 30,
    }],

    actions: [{
      id: 'validate-flow',
      label: 'Validate Flow',
      icon: 'check-circle',
      location: 'toolbar',
      metadataTypes: ['flow'],
    }],

    metadataIcons: [{
      metadataType: 'flow',
      label: 'Flow',
      icon: 'git-branch',
    }],

    panels: [{
      id: 'flow-debug',
      label: 'Flow Debugger',
      icon: 'bug',
      location: 'bottom',
    }],

    commands: [{
      id: 'objectstack.flow-designer.run',
      label: 'Run Flow',
      shortcut: 'Ctrl+Shift+R',
      icon: 'play',
    }],
  },
});

Activation

Every Studio plugin loads and activates immediately on registration — activate() runs at registration time, unconditionally. The former activationEvents manifest key was removed in v17 (#4657, ADR-0049): it declared lazy activation that no Studio host ever implemented, so a manifest that still carries it now fails the parse with the upgrade prescription. Delete the key.

View Modes

Metadata viewers declare which modes they support:

Mode Description
preview Read-only rendered preview
design Visual drag-and-drop designer
code Raw schema/code editor
data Live data browser
history Version/change history view

Next Steps