ObjectStackObjectStack

Runtime Service Examples

Practical examples for flow nodes, hooks, and plugin event subscriptions.

Runtime Service Examples

Which data channel each surface actually gets

These pages document the services.* contract surface — the signatures, not a binding every surface receives (see the binding note). The examples below therefore use the channel each runtime surface is really handed:

SurfaceData channel
Data hook (beforeInsert, beforeUpdate, …)ctx.api — the scoped cross-object API the engine binds per operation (buildHookApi, packages/objectql/src/engine.ts)
Flow script functionnone — a function is pure by contract (handlerContract: 'pure'), so its record I/O lives on the flow graph
Pluginthe plugin context (ctx.hook, the kernel service registry)

A hook context is built key by key by the engine and carries no services key, so ctx.services?.sharing?.canEdit(…) there evaluates to undefined — and a guard written on it (if (!ok) throw new Error('PERMISSION_DENIED')) rejects every write instead of checking anything (#5720).

The read is a declarative get_record node that binds its rows to a flow variable; the script node maps that variable into a registered function's inputs, and the function returns its result for a later declarative node to persist. A flow function is handed input / variables / automation / logger and no data engine — see Flows for why that purity rule keeps a run's record counts honest.

import { defineFlow, defineStack } from '@objectstack/spec';

interface OrderTotalsInput {
  lines: Array<{ amount?: number }>;
}

/** Pure: it computes from its mapped `inputs` and returns — no data handle needed. */
function orderTotals(ctx: { input: OrderTotalsInput }) {
  const lines = ctx.input.lines ?? [];
  return {
    line_count: lines.length,
    total: lines.reduce((sum, line) => sum + (line.amount ?? 0), 0),
  };
}

export const stack = defineStack({
  functions: { 'sales.orderTotals': orderTotals },
});

export const RollUpOrderTotals = defineFlow({
  name: 'sales_order_roll_up_totals',
  label: 'Roll up order line totals',
  type: 'autolaunched',
  status: 'active',
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'On Order Update',
      config: { objectName: 'sales_order', triggerType: 'record-after-update' },
    },
    {
      id: 'read_lines',
      type: 'get_record',
      label: 'Read the order lines',
      config: {
        objectName: 'sales_order_line',
        filter: { sales_order_id: '{record.id}' },
        fields: ['amount'],
        limit: 200,
        outputVariable: 'lines',
      },
    },
    {
      id: 'totals',
      type: 'script',
      label: 'Sum the lines',
      config: {
        function: 'sales.orderTotals',
        inputs: { lines: '{lines}' },
        outputVariable: 'totals',
      },
    },
    {
      id: 'apply',
      type: 'update_record',
      label: 'Write the totals back',
      config: {
        objectName: 'sales_order',
        filter: { id: '{record.id}' },
        fields: { line_count: '{totals.line_count}', amount_total: '{totals.total}' },
      },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'read_lines' },
    { id: 'e2', source: 'read_lines', target: 'totals' },
    { id: 'e3', source: 'totals', target: 'apply' },
    { id: 'e4', source: 'apply', target: 'end' },
  ],
});

2) Hook: validate a write against another object

A before* hook reaches other objects through ctx.api, bound to the caller's execution context and transaction, and rejects the write by throwing.

Record-level sharing is not a hook's job: when @objectstack/plugin-sharing is installed its engine middleware gates every by-id write itself — canEdit before an update, canDelete before a delete — and throws FORBIDDEN on denial, before any hook could re-ask (services.sharing). What a hook adds is the business rule the engine cannot know.

import { defineHook, type HookContext } from '@objectstack/spec/data';

export const ContractWithinCreditLimit = defineHook({
  name: 'contract_within_credit_limit',
  object: 'contract',
  events: ['beforeInsert', 'beforeUpdate'],
  handler: async (ctx: HookContext) => {
    const accountId = ctx.input.account_id;
    if (typeof accountId !== 'string') return;

    // `ctx.api` is typed (`IScopedContext`) — no cast. It is optional because a
    // context can be built without a live engine, so reach it with `?.`.
    const account = await ctx.api?.object('crm_account').findOne({ where: { id: accountId } });

    const limit = Number(account?.credit_limit ?? 0);
    const amount = Number(ctx.input.amount ?? 0);
    if (limit > 0 && amount > limit) {
      throw new Error('VALIDATION_FAILED: contract amount exceeds the account credit limit');
    }
  },
});

3) Plugin: subscribe to kernel lifecycle events

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

export const ExamplePlugin: Plugin = {
  name: 'example-plugin',
  async init(ctx) {
    ctx.hook('kernel:ready', async () => {
      ctx.logger.info('Kernel ready, initializing subscriptions');
    });

    ctx.hook('kernel:shutdown', async () => {
      ctx.logger.info('Shutdown signal received, cleaning up');
    });
  },
};

On this page