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
159 changes: 135 additions & 24 deletions content/docs/kernel/runtime-services/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,153 @@ description: Practical examples for flow nodes, hooks, and plugin event subscrip

# Runtime Service Examples

## 1) Flow custom node: read related records
<Callout type="warn" title="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](/docs/kernel/runtime-services)).
The examples below therefore use the channel each runtime surface is really handed:

| Surface | Data 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` function | none — a function is **pure** by contract (`handlerContract: 'pure'`), so its record I/O lives on the flow graph |
| Plugin | the 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](https://github.com/objectstack-ai/objectstack/issues/5720)).

</Callout>

## 1) Flow: read related records, then compute in a pure function

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](/docs/automation/flows) for why that purity rule keeps a run's record counts honest.

{/* os:check */}
```ts
export async function run(ctx: any) {
const { record: order } = await ctx.services.data.get('sales_order', ctx.input.orderId);
const lines = await ctx.services.data.find('sales_order_line', {
where: { sales_order_id: order.id },
orderBy: [{ field: 'line_no', order: 'asc' }],
limit: 200,
});
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 {
order,
lines: lines.records ?? [],
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: check sharing permission before mutation
## 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`](/docs/kernel/runtime-services/sharing-service)). What a
hook adds is the **business** rule the engine cannot know.

{/* os:check */}
```ts
export async function beforeUpdate(ctx: any) {
const ok = await ctx.services?.sharing?.canEdit('contract', ctx.input.id, {
userId: ctx.session?.userId,
// Read the caller's org under `organizationId` (the `session.tenantId` alias
// was removed in v11, #3290); it feeds the sharing context's `tenantId`.
tenantId: ctx.session?.organizationId,
positions: ctx.session?.positions,
});

if (!ok) {
throw new Error('PERMISSION_DENIED');
}
}
import { defineHook, type HookContext } from '@objectstack/spec/data';

/**
* The one call this hook makes on `ctx.api`. The contract declares
* `HookContext.api` opaque (`api: unknown`) because the object the engine binds is
* ObjectQL's `ScopedContext`, so a typed handler names the slice it uses.
*/
type CrossObjectApi = {
object(name: string): {
findOne(query: { where: Record<string, unknown> }): Promise<{ credit_limit?: number } | null>;
};
};

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;

const api = ctx.api as CrossObjectApi;
const account = await api.object('crm_account').findOne({ where: { id: accountId } });

const limit = 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
Expand Down
45 changes: 37 additions & 8 deletions content/docs/kernel/runtime-services/sharing-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,44 @@ mask AND-ed with object CRUD, not a fourth `access_level`.
- `CONFLICT` (409) — `revoke` on a rule-materialised share (`source != 'manual'`); the next rule reconciliation would silently re-grant it. Deactivate or edit the sharing rule instead.
- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult (public sharing model, no `owner_id` field, a bypass object, or `controlled_by_parent`).

## Enforcement is automatic — do not re-check it in a hook

With `@objectstack/plugin-sharing` installed, the gates run **inside the engine**: its
middleware picks the gate by verb — `canEdit` before a by-id update, `canDelete` before a
delete — and throws `FORBIDDEN` before the hook chain could ask anything. A hook that
re-checks adds nothing, and it cannot ask this service at all: a hook context is built key
by key by the engine (`object` / `event` / `input` / `session` / `provenance` / `user` /
`api` / `transaction` / `ql`) and carries **no `services` key**, so
`ctx.services?.sharing?.canEdit(…)` is `undefined` there and `if (!ok) throw …` rejects
every write ([#5720](https://github.com/objectstack-ai/objectstack/issues/5720)). A hook's
own channel is `ctx.api` — use it for *business* rules
([examples](/docs/kernel/runtime-services/examples)).

## Example

Call `canEdit` only from code that **holds** the service — a plugin that resolved it from
the kernel service registry, or a managed runtime's `services.sharing` binding — for
example to pre-flight an affordance before offering it:

{/* os:check */}
```ts
const allowed = await services.sharing.canEdit('contract', ctx.input.id, {
userId: ctx.session?.userId,
// The hook exposes the caller's org as `organizationId` (the `session.tenantId`
// alias was removed in v11, #3290); it feeds the sharing context's `tenantId`.
tenantId: ctx.session?.organizationId,
positions: ctx.session?.positions,
});
if (!allowed) throw new Error('PERMISSION_DENIED');
import type { ISharingService } from '@objectstack/spec/contracts';

export async function mayEditContract(
sharing: ISharingService,
recordId: string,
session: { userId?: string; organizationId?: string; positions?: string[] },
): Promise<boolean> {
return sharing.canEdit('contract', recordId, {
userId: session.userId,
// `SharingExecutionContext` names the org `tenantId`; a session exposes the
// same value as `organizationId` (the `session.tenantId` alias was removed in
// v11, #3290).
tenantId: session.organizationId,
positions: session.positions,
});
}
```

`canEdit` returns `false` rather than throwing, so a caller decides what a denial means —
hiding a button, or raising its own error.
Loading