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 — plugin.manifest.ts, semantic-version dependency resolution, provider/consumer contracts, lifecycle hooks. 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/
  plugin.manifest.ts     ← TypeScript (recommended)
  plugin.manifest.yml    ← YAML (alternative)
  plugin.manifest.json   ← JSON (alternative)

Recommendation: Use TypeScript manifest for type safety and validation. ObjectStack auto-generates JSON schema from TypeScript.

Manifest Schema

// plugin.manifest.ts
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',
  },
  
  // 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',
  },
  
  // PERMISSIONS
  permissions: {
    // System capabilities plugin requires
    system: [
      'network.http',      // Make HTTP requests
      'storage.database',  // Database access
      'storage.cache',     // Redis/cache access
    ],
    
    // Objects plugin creates/manages
    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: {
      objectstack: '>=2.0.0 <3.0.0',
      node: '>=18.0.0',
    },
  },
});

Directory Structure

A well-organized plugin follows this standard structure:

@mycompany/crm/
├── plugin.manifest.ts          # Plugin manifest (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)
│   │
│   └── lifecycle/              # Lifecycle hooks
│       ├── install.ts
│       ├── boot.ts
│       └── upgrade.ts

├── 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';

export default defineView({
  name: 'account_list',
  object: 'account',
  type: 'list',
  
  layout: {
    type: 'grid',
    columns: [
      { field: 'name', width: 200 },
      { field: 'industry', width: 150 },
      { field: 'annual_revenue', width: 150 },
      { field: 'primary_contact', width: 200 },
      { field: 'created_at', width: 150 },
    ],
    
    filters: [
      { field: 'industry', operator: 'equals' },
      { field: 'annual_revenue', operator: 'greaterThan' },
    ],
    
    actions: [
      { type: 'create', label: 'New Account' },
      { type: 'export', label: 'Export to CSV' },
    ],
  },
});

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

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
if (context.plugins.isInstalled('@vendor/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) with these operators:

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',
  
  // Version range
  'plugin-d': '>=1.0.0 <2.0.0',
  
  // Latest version
  'plugin-e': '*',        // ⚠️ 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: If two plugins require incompatible versions of the same dependency, installation fails with error:

Error: Dependency conflict
  @mycompany/sales requires @objectstack/core@^2.0.0
  @vendor/analytics requires @objectstack/core@^3.0.0
  
Cannot satisfy both constraints.

Solutions:
  1. Upgrade @mycompany/sales to version compatible with core@3.x
  2. Downgrade @vendor/analytics to version compatible with core@2.x
  3. Contact plugin authors to update dependencies

Lifecycle Hooks

Plugins can define hooks that execute at specific lifecycle events:

Hook Signatures

// 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');
}

// 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 });
  }
}

// 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');
  }
  
  // Example: Register custom services
  context.services.register('crm', new CRMService(config));
}

Hook Execution Order

During installation:

1. onInstall
2. Register metadata (objects, views, etc.)
3. onEnable (if --enable flag)

During upgrade:

1. onUpgrade
2. Apply migrations
3. Update metadata

During boot:

1. Load all plugins
2. Resolve dependencies
3. onBoot (for each plugin in dependency order)
4. Start services

Permissions

Plugins must declare permissions they require. ObjectStack enforces these at runtime.

System Permissions

permissions: {
  system: [
    // Network access
    'network.http',          // Make HTTP requests
    'network.websocket',     // Use WebSockets
    
    // Storage access
    'storage.database',      // Query database
    'storage.cache',         // Use Redis/cache
    'storage.filesystem',    // Read/write files
    
    // System capabilities
    'system.cron',           // Schedule jobs
    'system.email',          // Send emails
    'system.events',         // Publish/subscribe events
    
    // Security
    'security.encrypt',      // Encrypt data
    'security.sign',         // Sign JWTs
  ],
}

If plugin attempts operation without permission, ObjectStack throws error:

// Plugin tries to make HTTP request without permission
await fetch('https://api.example.com/data');

// Error: PermissionDenied
// Plugin @mycompany/crm does not have permission 'network.http'
// Add to plugin.manifest.ts:
//   permissions: { system: ['network.http'] }

Object Permissions

Declare which objects plugin creates and manages:

permissions: {
  objects: [
    'account',       // Full control
    'contact',
    'opportunity',
  ],
}

ObjectStack uses this for:

  • Dependency tracking: Can't uninstall plugin if other plugins reference its objects
  • Security: Plugin can only modify its own objects (not objects from other plugins)
  • Cleanup: Uninstalling plugin can optionally remove its 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 publisher signature)
os plugin sign crm-1.5.0.osplugin

# 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

# Install from a local artifact into a running runtime (air-gapped)
os package install ./crm-1.5.0.osplugin

.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 (SIGNATURE, added by os plugin sign)

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

# 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
The `onUpgrade` lifecycle hook runs automatically when the package is upgraded
to v2.0.0 and renames the field on existing records.

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

Use Zod schema to validate config during boot, not at runtime:

export async function onBoot({ context }) {
  const config = context.config.get('crm', configSchema);
  // Throws clear error if config is invalid
}

5. Clean Up on Uninstall

Always provide onUninstall hook to clean up resources:

export async function onUninstall({ context, transaction }) {
  // Stop jobs
  await context.scheduler.delete('crm-sync', { transaction });
  
  // Archive data (don't delete!)
  await context.db.update('account', 
    { plugin: 'crm' },
    { archived: true },
    { transaction }
  );
}

Summary

Plugin packages in ObjectStack:

  • Manifest-driven: plugin.manifest.ts is the source of truth
  • Self-contained: Bundle objects, views, logic, and config
  • Dependency-managed: Semantic versioning with conflict detection
  • Lifecycle-aware: Hooks for install, upgrade, boot, and uninstall
  • NPM-compatible: Distribute via NPM or .osplugin archives

Next: Learn how configuration is resolved in Configuration Resolution.

On this page