Skip to content

Commit 99c29f4

Browse files
os-zhuangos-devclaude
authored
docs(kernel): runtime-services 的 hook 示例改教真实通道 —— 去掉不存在的 ctx.services (#5720) (#5938)
hook 上下文由引擎逐键构造(object/event/input/session/provenance/user/api/ transaction/ql),从来没有 `services` 键;`services.*` 是本页记录的**契约签名面** (见 runtime-services 索引页的 binding note),托管运行时才注入。照抄旧示例的 `beforeUpdate` 因可选链短路成 `undefined`,`if (!ok) throw` 会无条件拒掉每一次 写入,失败方向还伪装成"正常拒绝"。 - examples.mdx 第 1 节:实测 flow `script` 函数按契约是纯函数 (`handlerContract: 'pure'`),运行时只交 input/variables/automation/logger, **没有任何数据句柄**——既无 `services` 也无 `ctx.api`。故改写为真实通道: 声明式 `get_record` 读行、`script` 节点把变量映射进函数 `inputs`、函数返回值 由后续声明式节点落库。整块过 `defineFlow` + `defineStack` 真实解析。 - examples.mdx 第 2 节:改为 hook 的真实数据通道 `ctx.api`,示例语义(写前校验 + 拒绝路径)保留,但换成引擎无法代劳的**业务**规则;共享强制由 plugin-sharing 的 引擎中间件按动词自动执行(update → canEdit,delete → canDelete,拒绝抛 FORBIDDEN),hook 手查是冗余教学,故删除。 - 两块示例入参从 `ctx: any` 改 `(ctx: HookContext)`:`any` 让 `{/* os:check */}` 变成空门(块内每次属性访问都不被检查)。反向验证:把旧函数体按 HookContext 如实标注后,两处 `ctx.services` 均报 TS2339 —— 键确实不存在,而门此前是绿的。 - sharing-service.mdx:裸 `services.sharing` 的 Example 实测教的是 hook 语境 (注释自称 "The hook exposes …"、读 ctx.input/ctx.session),不是 action 面。 改为"由持有该服务的代码调用"(契约类型 ISharingService),并新增一节写明强制 自动执行、hook 不得复查;该块补 os:check 标记,首次真正把本页签名钉在契约上。 门禁:check:skill-examples 206 → 207 块全绿(runtime-services 三块从 "标记了但零覆盖"变为真检查);check:doc-authoring 362 文件干净; check:nul-bytes、check:docs-audit-scope 均绿。 Closes #5720 Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE Co-authored-by: os-dev <hr@objectstack.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9ce0ca9 commit 99c29f4

2 files changed

Lines changed: 172 additions & 32 deletions

File tree

content/docs/kernel/runtime-services/examples.mdx

Lines changed: 135 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,42 +5,153 @@ description: Practical examples for flow nodes, hooks, and plugin event subscrip
55

66
# Runtime Service Examples
77

8-
## 1) Flow custom node: read related records
8+
<Callout type="warn" title="Which data channel each surface actually gets">
9+
10+
These pages document the `services.*` **contract surface** — the signatures, not a
11+
binding every surface receives (see the [binding note](/docs/kernel/runtime-services)).
12+
The examples below therefore use the channel each runtime surface is really handed:
13+
14+
| Surface | Data channel |
15+
|:--|:--|
16+
| Data hook (`beforeInsert`, `beforeUpdate`, …) | `ctx.api` — the scoped cross-object API the engine binds per operation (`buildHookApi`, `packages/objectql/src/engine.ts`) |
17+
| Flow `script` function | none — a function is **pure** by contract (`handlerContract: 'pure'`), so its record I/O lives on the flow graph |
18+
| Plugin | the plugin context (`ctx.hook`, the kernel service registry) |
19+
20+
A hook context is built key by key by the engine and carries **no `services` key**, so
21+
`ctx.services?.sharing?.canEdit(…)` there evaluates to `undefined` — and a guard written on
22+
it (`if (!ok) throw new Error('PERMISSION_DENIED')`) rejects **every** write instead of
23+
checking anything ([#5720](https://github.com/objectstack-ai/objectstack/issues/5720)).
24+
25+
</Callout>
26+
27+
## 1) Flow: read related records, then compute in a pure function
28+
29+
The read is a declarative `get_record` node that binds its rows to a flow variable; the
30+
`script` node maps that variable into a registered function's `inputs`, and the function
31+
**returns** its result for a later declarative node to persist. A flow function is handed
32+
`input` / `variables` / `automation` / `logger` and **no data engine** — see
33+
[Flows](/docs/automation/flows) for why that purity rule keeps a run's record counts honest.
934

1035
{/* os:check */}
1136
```ts
12-
export async function run(ctx: any) {
13-
const { record: order } = await ctx.services.data.get('sales_order', ctx.input.orderId);
14-
const lines = await ctx.services.data.find('sales_order_line', {
15-
where: { sales_order_id: order.id },
16-
orderBy: [{ field: 'line_no', order: 'asc' }],
17-
limit: 200,
18-
});
37+
import { defineFlow, defineStack } from '@objectstack/spec';
38+
39+
interface OrderTotalsInput {
40+
lines: Array<{ amount?: number }>;
41+
}
1942

43+
/** Pure: it computes from its mapped `inputs` and returns — no data handle needed. */
44+
function orderTotals(ctx: { input: OrderTotalsInput }) {
45+
const lines = ctx.input.lines ?? [];
2046
return {
21-
order,
22-
lines: lines.records ?? [],
47+
line_count: lines.length,
48+
total: lines.reduce((sum, line) => sum + (line.amount ?? 0), 0),
2349
};
2450
}
51+
52+
export const stack = defineStack({
53+
functions: { 'sales.orderTotals': orderTotals },
54+
});
55+
56+
export const RollUpOrderTotals = defineFlow({
57+
name: 'sales_order_roll_up_totals',
58+
label: 'Roll up order line totals',
59+
type: 'autolaunched',
60+
status: 'active',
61+
nodes: [
62+
{
63+
id: 'start',
64+
type: 'start',
65+
label: 'On Order Update',
66+
config: { objectName: 'sales_order', triggerType: 'record-after-update' },
67+
},
68+
{
69+
id: 'read_lines',
70+
type: 'get_record',
71+
label: 'Read the order lines',
72+
config: {
73+
objectName: 'sales_order_line',
74+
filter: { sales_order_id: '{record.id}' },
75+
fields: ['amount'],
76+
limit: 200,
77+
outputVariable: 'lines',
78+
},
79+
},
80+
{
81+
id: 'totals',
82+
type: 'script',
83+
label: 'Sum the lines',
84+
config: {
85+
function: 'sales.orderTotals',
86+
inputs: { lines: '{lines}' },
87+
outputVariable: 'totals',
88+
},
89+
},
90+
{
91+
id: 'apply',
92+
type: 'update_record',
93+
label: 'Write the totals back',
94+
config: {
95+
objectName: 'sales_order',
96+
filter: { id: '{record.id}' },
97+
fields: { line_count: '{totals.line_count}', amount_total: '{totals.total}' },
98+
},
99+
},
100+
{ id: 'end', type: 'end', label: 'End' },
101+
],
102+
edges: [
103+
{ id: 'e1', source: 'start', target: 'read_lines' },
104+
{ id: 'e2', source: 'read_lines', target: 'totals' },
105+
{ id: 'e3', source: 'totals', target: 'apply' },
106+
{ id: 'e4', source: 'apply', target: 'end' },
107+
],
108+
});
25109
```
26110

27-
## 2) Hook: check sharing permission before mutation
111+
## 2) Hook: validate a write against another object
112+
113+
A `before*` hook reaches other objects through `ctx.api`, bound to the caller's execution
114+
context and transaction, and rejects the write by throwing.
115+
116+
Record-level **sharing is not a hook's job**: when `@objectstack/plugin-sharing` is
117+
installed its engine middleware gates every by-id write itself — `canEdit` before an
118+
update, `canDelete` before a delete — and throws `FORBIDDEN` on denial, before any hook
119+
could re-ask ([`services.sharing`](/docs/kernel/runtime-services/sharing-service)). What a
120+
hook adds is the **business** rule the engine cannot know.
28121

29122
{/* os:check */}
30123
```ts
31-
export async function beforeUpdate(ctx: any) {
32-
const ok = await ctx.services?.sharing?.canEdit('contract', ctx.input.id, {
33-
userId: ctx.session?.userId,
34-
// Read the caller's org under `organizationId` (the `session.tenantId` alias
35-
// was removed in v11, #3290); it feeds the sharing context's `tenantId`.
36-
tenantId: ctx.session?.organizationId,
37-
positions: ctx.session?.positions,
38-
});
39-
40-
if (!ok) {
41-
throw new Error('PERMISSION_DENIED');
42-
}
43-
}
124+
import { defineHook, type HookContext } from '@objectstack/spec/data';
125+
126+
/**
127+
* The one call this hook makes on `ctx.api`. The contract declares
128+
* `HookContext.api` opaque (`api: unknown`) because the object the engine binds is
129+
* ObjectQL's `ScopedContext`, so a typed handler names the slice it uses.
130+
*/
131+
type CrossObjectApi = {
132+
object(name: string): {
133+
findOne(query: { where: Record<string, unknown> }): Promise<{ credit_limit?: number } | null>;
134+
};
135+
};
136+
137+
export const ContractWithinCreditLimit = defineHook({
138+
name: 'contract_within_credit_limit',
139+
object: 'contract',
140+
events: ['beforeInsert', 'beforeUpdate'],
141+
handler: async (ctx: HookContext) => {
142+
const accountId = ctx.input.account_id;
143+
if (typeof accountId !== 'string') return;
144+
145+
const api = ctx.api as CrossObjectApi;
146+
const account = await api.object('crm_account').findOne({ where: { id: accountId } });
147+
148+
const limit = account?.credit_limit ?? 0;
149+
const amount = Number(ctx.input.amount ?? 0);
150+
if (limit > 0 && amount > limit) {
151+
throw new Error('VALIDATION_FAILED: contract amount exceeds the account credit limit');
152+
}
153+
},
154+
});
44155
```
45156

46157
## 3) Plugin: subscribe to kernel lifecycle events

content/docs/kernel/runtime-services/sharing-service.mdx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,44 @@ mask AND-ed with object CRUD, not a fourth `access_level`.
5656
- `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.
5757
- `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`).
5858

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

74+
Call `canEdit` only from code that **holds** the service — a plugin that resolved it from
75+
the kernel service registry, or a managed runtime's `services.sharing` binding — for
76+
example to pre-flight an affordance before offering it:
77+
78+
{/* os:check */}
6179
```ts
62-
const allowed = await services.sharing.canEdit('contract', ctx.input.id, {
63-
userId: ctx.session?.userId,
64-
// The hook exposes the caller's org as `organizationId` (the `session.tenantId`
65-
// alias was removed in v11, #3290); it feeds the sharing context's `tenantId`.
66-
tenantId: ctx.session?.organizationId,
67-
positions: ctx.session?.positions,
68-
});
69-
if (!allowed) throw new Error('PERMISSION_DENIED');
80+
import type { ISharingService } from '@objectstack/spec/contracts';
81+
82+
export async function mayEditContract(
83+
sharing: ISharingService,
84+
recordId: string,
85+
session: { userId?: string; organizationId?: string; positions?: string[] },
86+
): Promise<boolean> {
87+
return sharing.canEdit('contract', recordId, {
88+
userId: session.userId,
89+
// `SharingExecutionContext` names the org `tenantId`; a session exposes the
90+
// same value as `organizationId` (the `session.tenantId` alias was removed in
91+
// v11, #3290).
92+
tenantId: session.organizationId,
93+
positions: session.positions,
94+
});
95+
}
7096
```
97+
98+
`canEdit` returns `false` rather than throwing, so a caller decides what a denial means —
99+
hiding a button, or raising its own error.

0 commit comments

Comments
 (0)