|
1 | 1 | # Data Lifecycle Hooks — Reference |
2 | 2 |
|
3 | 3 | Reference companion to `objectstack-data/SKILL.md`. Comprehensive guide to |
4 | | -the 14 data lifecycle events, registration modes, the `HookContext` API, |
5 | | -and common patterns (validation, defaults, audit logging, workflows). |
| 4 | +the 8 data lifecycle events, registration modes (inline `handler` **and** |
| 5 | +sandboxed `body`), the `HookContext` API, and common patterns (validation, |
| 6 | +defaults, audit logging, workflows). |
6 | 7 |
|
7 | 8 |
|
8 | 9 | # Writing Hooks — ObjectStack Data Lifecycle |
@@ -234,8 +235,241 @@ onError: 'log' |
234 | 235 |
|
235 | 236 | --- |
236 | 237 |
|
| 238 | +## Sandboxed Hook Bodies (`body`) — What the Sandbox `ctx` Can Call |
| 239 | + |
| 240 | +A hook can carry its logic in one of two shapes. Everything above this point |
| 241 | +showed the inline **`handler`** function; the section below documents the |
| 242 | +**`body`** form — the one a metadata-only runtime actually executes, and the one |
| 243 | +that was previously undocumented (you had to reverse-engineer |
| 244 | +`@objectstack/runtime` to use it). |
| 245 | + |
| 246 | +### `body` (sandboxed) vs `handler` (inline) — pick one |
| 247 | + |
| 248 | +| | **`body`** — sandboxed script | **`handler`** — inline function | |
| 249 | +|:--|:--|:--| |
| 250 | +| Shape | `body: { language, source, capabilities }` (pure metadata) | `handler: async (ctx) => { … }` | |
| 251 | +| Runs in | An isolated **QuickJS VM** (edge-safe, capability-gated) | The host process (**full Node**) | |
| 252 | +| Ships as | Plain JSON inside the build artifact — travels everywhere | Lowered at build to a string ref + a sibling `.mjs` runtime module | |
| 253 | +| Status | **Preferred for new code** | **Deprecated** (`HookSchema`: *"prefer `body`"*) | |
| 254 | +| Both present? | Runtime uses **`body`** and ignores `handler` | — | |
| 255 | + |
| 256 | +Because a `body` is pure metadata, it is what AI-authored hooks, Studio-authored |
| 257 | +hooks, and any `objectstack build` artifact carry. The rest of this section is |
| 258 | +the contract for that sandbox. |
| 259 | + |
| 260 | +### The `body` shape |
| 261 | + |
| 262 | +```jsonc |
| 263 | +body: { |
| 264 | + language: 'js', // 'js' = L2 sandboxed script | 'expression' = L1 pure formula |
| 265 | + source: "/* function body */", // the FUNCTION BODY only — not a module |
| 266 | + capabilities: ['api.read', 'api.write', 'log'], // default: [] |
| 267 | + timeoutMs: 250, // optional, ≤ 30000 (hook default 250ms, action 5000ms) |
| 268 | + memoryMb: 32, // optional, ≤ 256 (best-effort under QuickJS) |
| 269 | +} |
| 270 | +``` |
| 271 | + |
| 272 | +- `source` is the **function body**, which the runtime wraps as |
| 273 | + `(async (ctx) => { <source> })(ctx)`. Write **statements** against `ctx`; |
| 274 | + `await` is allowed. |
| 275 | +- In a `before*` hook, change the write by assigning `ctx.input.x = …` **or** |
| 276 | + `return { x: … }` (a returned object is shallow-merged onto `ctx.input`). In an |
| 277 | + `after*` hook the body is for side effects (cross-object writes, logging). |
| 278 | +- `language: 'expression'` is a pure CEL formula for a computed value or |
| 279 | + predicate — no IO, no `ctx.api`. |
| 280 | + |
| 281 | +### What lives on the sandbox `ctx` |
| 282 | + |
| 283 | +The sandbox is handed a **JSON snapshot** of these (built by |
| 284 | +`buildSandboxContext`), not live engine objects: |
| 285 | + |
| 286 | +| `ctx.*` | Shape | Notes | |
| 287 | +|:--|:--|:--| |
| 288 | +| `ctx.input` | object | The write payload (mutable). On update, only the **changed** fields plus `id`. | |
| 289 | +| `ctx.previous` | object \| `undefined` | Pre-write record on update/delete. **`undefined` on insert** → use `!ctx.previous` to detect *create*. | |
| 290 | +| `ctx.result` | object \| `undefined` | `after*` only. ⚠️ **partial** on afterUpdate — see gotcha 1. | |
| 291 | +| `ctx.user` | object \| `undefined` | `{ id, name, email, organizationId }`. `undefined` for system / unauthenticated writes. | |
| 292 | +| `ctx.session` | object \| `undefined` | `{ userId, organizationId, roles, … }`. | |
| 293 | +| `ctx.event` | string | e.g. `'afterUpdate'` — dispatch on it when one hook subscribes to several events. | |
| 294 | +| `ctx.object` | string | The target object name. | |
| 295 | +| `ctx.api` | object | Cross-object CRUD. Gated by `api.read` / `api.write` — see below. | |
| 296 | +| `ctx.log` | `{ info, warn, error }` | Gated by `log`. Call **`ctx.log.info(msg, data?)`** — `ctx.log` is an **object, not** callable as `ctx.log(msg)`. Emission is **best-effort** (see Troubleshooting). | |
| 297 | +| `ctx.crypto` | `{ randomUUID }` | Gated by `crypto.uuid`. | |
| 298 | + |
| 299 | +(Action bodies additionally receive `ctx.recordId` and `ctx.record`, and their |
| 300 | +wrap is `(async (input, ctx) => { … })(input, ctx)` — the action params arrive as |
| 301 | +the first arg.) |
| 302 | + |
| 303 | +Because the VM only holds a snapshot, mutating `ctx.previous` / `ctx.result` is |
| 304 | +local and thrown away; the only way to change the persisted write is |
| 305 | +`ctx.input.x = …` or `return { … }`. |
| 306 | + |
| 307 | +### `ctx.api.object(name)` — the cross-object repo |
| 308 | + |
| 309 | +`ctx.api.object('<object>')` returns a repository bound to the current |
| 310 | +org / user / transaction. Methods: |
| 311 | + |
| 312 | +| Method | Capability | Call | |
| 313 | +|:--|:--|:--| |
| 314 | +| `find(opts)` | `api.read` | `find({ where: { … }, fields, sort, limit })` → array | |
| 315 | +| `findOne(opts)` | `api.read` | `findOne({ where: { id } })` → record \| `null` | |
| 316 | +| `count(opts)` | `api.read` | `count({ where: { … } })` → number | |
| 317 | +| `insert(data)` | `api.write` | `insert({ … })` | |
| 318 | +| `update(data, opts?)` | `api.write` | **`update({ id, ...fields })`** — put the id **inside** `data` | |
| 319 | +| `upsert(data, opts?)` | `api.write` | `upsert({ … })` | |
| 320 | +| `delete(opts)` | `api.write` | `delete({ where: { id } })` | |
| 321 | + |
| 322 | +**Query shape — the key is `where`.** It takes an object with `$`-operators, the |
| 323 | +same DSL as the [objectstack-query](../../objectstack-query/SKILL.md) skill: |
| 324 | + |
| 325 | +```js |
| 326 | +await ctx.api.object('candidate').findOne({ where: { id: ctx.result.id } }); |
| 327 | +await ctx.api.object('candidate').find({ where: { stage: 'hired' } }); |
| 328 | +await ctx.api.object('invoice').count({ where: { amount: { $gte: 1000 } } }); |
| 329 | +await ctx.api.object('task').find({ where: { $and: [{ done: false }, { owner: uid }] } }); |
| 330 | +``` |
| 331 | + |
| 332 | +> `filter:` is tolerated as an **object-valued** alias (normalised to `where`), |
| 333 | +> but prefer `where`. Do **not** pass an array-of-triples such as |
| 334 | +> `[['id', '=', x]]` — that is not a supported value shape and silently matches |
| 335 | +> nothing. |
| 336 | +
|
| 337 | +**Update by id.** `update` reads the primary key out of `data`, so the |
| 338 | +single-record form is `update({ id, ...fieldsToChange })` — e.g. |
| 339 | +`update({ id: pos, status: 'filled' })`. |
| 340 | + |
| 341 | +### Capabilities — the complete list |
| 342 | + |
| 343 | +A body may only touch a `ctx` API it declared in `capabilities`. Calling an |
| 344 | +undeclared one **throws inside the VM** — `capability '<token>' not granted to |
| 345 | +hook '<name>' …` — which surfaces as a hook error (see Troubleshooting). The full |
| 346 | +set of legal tokens (`HookBodyCapability`) is exactly six: |
| 347 | + |
| 348 | +| Token | Unlocks | |
| 349 | +|:--|:--| |
| 350 | +| `api.read` | `ctx.api.object(n).find` / `findOne` / `count` / `aggregate` | |
| 351 | +| `api.write` | `ctx.api.object(n).insert` / `update` / `delete` / `upsert` | |
| 352 | +| `api.transaction` | `ctx.api.transaction(async () => { … })` — runs the callback's `ctx.api` ops in **one driver transaction** (commit on return, rollback on throw). Pair it with `api.write`. | |
| 353 | +| `crypto.uuid` | `ctx.crypto.randomUUID()` | |
| 354 | +| `crypto.hash` | *declared token* for `ctx.crypto.hash(algo, data)` — the current WASM runner wires only `randomUUID`, so don't rely on `hash` yet | |
| 355 | +| `log` | `ctx.log.info` / `warn` / `error(msg, data?)` | |
| 356 | + |
| 357 | +There is **no `http.fetch` capability** by design — outbound calls go through |
| 358 | +Connector recipes so they stay auditable and replayable. |
| 359 | + |
| 360 | +### Sandbox restrictions |
| 361 | + |
| 362 | +The body runs in an isolated QuickJS VM, **not** Node: |
| 363 | + |
| 364 | +- **Available:** standard JS built-ins — `Date`, `Math`, `JSON`, `Object`, |
| 365 | + `Array`, `String`, `Number`, `RegExp`, `Map`, `Set`, `Promise`, `parseInt`, |
| 366 | + `encodeURIComponent`, … — plus `ctx.*` and `await`. |
| 367 | +- **Not available** (verified absent from the QuickJS heap): `console` (use |
| 368 | + `ctx.log`), `fetch` (use Connectors), `setTimeout` / `setInterval`, `URL`, |
| 369 | + `TextEncoder` / `TextDecoder`, `structuredClone`, `atob` / `btoa`, `require`, |
| 370 | + Node modules, the filesystem. |
| 371 | +- **Rejected by `objectstack build`:** `import` / `require` / dynamic `import()`, |
| 372 | + `process`, `globalThis`, `eval`, `new Function`, and any **free identifier** — |
| 373 | + a name bound at module scope, e.g. a `const slugify = …` helper sitting next to |
| 374 | + the hook. A `body` must be **self-contained**: inline the helper, or keep that |
| 375 | + hook as a bundled `handler`. |
| 376 | + |
| 377 | +### ⚠️ Gotcha 1 — `ctx.result` is a *partial* record on afterUpdate |
| 378 | + |
| 379 | +On `afterUpdate`, both `ctx.result` and `ctx.input` carry only the fields this |
| 380 | +PATCH touched, plus `id`. A field you did **not** write — a lookup FK, a status |
| 381 | +you want to branch on — is **absent**, not stale. To read the whole record, |
| 382 | +re-query it: |
| 383 | + |
| 384 | +```js |
| 385 | +// afterUpdate on `candidate` |
| 386 | +const full = await ctx.api.object('candidate').findOne({ where: { id: ctx.result.id } }); |
| 387 | +// full.position_id is present even though this PATCH only set `stage`. |
| 388 | +``` |
| 389 | + |
| 390 | +(A declarative `condition` on an un-written field hits the same wall — guard it |
| 391 | +with the missing-key-safe `has(record.x)` macro.) |
| 392 | + |
| 393 | +### ⚠️ Gotcha 2 — cross-object writes obey the *target's* sharing model |
| 394 | + |
| 395 | +A hook's `ctx.api.object('other').update(…)` goes through the engine's normal |
| 396 | +write path, so it is gated by **`other`'s** permission / sharing rules — not by |
| 397 | +whoever is elevated. If the acting user cannot edit the target (e.g. it is |
| 398 | +`public_read`), the write throws: |
| 399 | + |
| 400 | +``` |
| 401 | +FORBIDDEN: insufficient privileges to update <object> <id> |
| 402 | +``` |
| 403 | + |
| 404 | +**An admin is not automatically exempt** — the gate is `canEdit`, driven by the |
| 405 | +sharing model, not a global admin bypass. If a hook must write a target, give the |
| 406 | +acting principal edit access to it (sharing rule / permission set), or drive the |
| 407 | +write from a system-context automation. |
| 408 | + |
| 409 | +### Copy-paste example — afterUpdate + cross-object update + re-query + capabilities |
| 410 | + |
| 411 | +When a `candidate` is marked `hired`, look up its `position` (a lookup FK that |
| 412 | +is **not** in the partial patch) and flip that position to `filled`: |
| 413 | + |
| 414 | +<!-- os:check --> |
| 415 | +```typescript |
| 416 | +import type { Hook } from '@objectstack/spec/data'; |
| 417 | + |
| 418 | +const fillPositionOnHire: Hook = { |
| 419 | + name: 'fill_position_on_hire', |
| 420 | + object: 'candidate', |
| 421 | + events: ['afterUpdate'], |
| 422 | + body: { |
| 423 | + language: 'js', |
| 424 | + source: ` |
| 425 | + // afterUpdate → ctx.result is the PARTIAL patch. Gate on the field this |
| 426 | + // write actually set, then re-query for the lookup FK it does NOT carry. |
| 427 | + if (!ctx.result || ctx.result.stage !== 'hired') return; |
| 428 | + const rec = await ctx.api.object('candidate').findOne({ where: { id: ctx.result.id } }); |
| 429 | + if (!rec || !rec.position_id) return; |
| 430 | + // Cross-object write — needs the acting user to be able to edit 'position'. |
| 431 | + await ctx.api.object('position').update({ id: rec.position_id, status: 'filled' }); |
| 432 | + ctx.log.info('position filled', { position: rec.position_id }); |
| 433 | + `, |
| 434 | + capabilities: ['api.read', 'api.write', 'log'], |
| 435 | + }, |
| 436 | + onError: 'log', |
| 437 | +}; |
| 438 | + |
| 439 | +export default fillPositionOnHire; |
| 440 | +``` |
| 441 | + |
| 442 | +Register it like any hook — add it to `defineStack({ hooks: [fillPositionOnHire] })` |
| 443 | +(the `AppPlugin` binds `body` hooks onto the engine automatically). |
| 444 | + |
| 445 | +### Troubleshooting (`[BodyRunner]` log lines) |
| 446 | + |
| 447 | +- **`[BodyRunner] invalid hook.body shape`** *(warn)* — `body` failed |
| 448 | + `HookBodySchema`; the hook is skipped. Check `language` / `source` / |
| 449 | + `capabilities`. |
| 450 | +- **`[BodyRunner] sandboxed hook threw`** *(error)* — the body raised. The |
| 451 | + wrapped message names the hook; the usual causes are a missing capability, a |
| 452 | + `FORBIDDEN` cross-object write (gotcha 2), or a `ReferenceError` from a free |
| 453 | + identifier or an unavailable global (e.g. `console`). |
| 454 | +- **`ctx.log.*` produced no output** — two independent causes: (1) without the |
| 455 | + `log` capability the call **throws** (surfaces as a hook error); (2) **with** the |
| 456 | + capability it emits only when the runtime wired a logger into the hook context — |
| 457 | + otherwise it is a **silent no-op**. Treat `ctx.log` as best-effort diagnostics, |
| 458 | + not a reliable side-channel or proof a hook ran; to observe an effect, assert on |
| 459 | + the data it writes. (And call `ctx.log.info(msg)` — `ctx.log` is an object, not |
| 460 | + a function, so `ctx.log(msg)` is not callable.) |
| 461 | + |
| 462 | +--- |
| 463 | + |
237 | 464 | ## Hook Context API |
238 | 465 |
|
| 466 | +> **Sandbox vs in-process.** The `ctx` documented below is the **in-process |
| 467 | +> `handler`** context (full Node). A metadata-native **`body`** sees a |
| 468 | +> capability-gated *subset* of it inside an isolated VM — see |
| 469 | +> [Sandboxed Hook Bodies](#sandboxed-hook-bodies-body--what-the-sandbox-ctx-can-call) |
| 470 | +> for exactly what is and isn't available there, including the `where` query |
| 471 | +> shape and the `update({ id, … })` pattern. |
| 472 | +
|
239 | 473 | The `HookContext` passed to your handler provides: |
240 | 474 |
|
241 | 475 | ### Context Properties |
@@ -402,16 +636,20 @@ ctx.previous: { |
402 | 636 |
|
403 | 637 | ### Cross-Object API |
404 | 638 |
|
405 | | -Access other objects within the same transaction: |
| 639 | +Access other objects within the same transaction. `ctx.api.object(name)` is the |
| 640 | +same repository in both forms; the canonical query key is **`where`** (see |
| 641 | +[Sandboxed Hook Bodies](#sandboxed-hook-bodies-body--what-the-sandbox-ctx-can-call) |
| 642 | +for the full method + capability contract). In a sandboxed `body` these calls |
| 643 | +additionally require the `api.read` / `api.write` capabilities. |
406 | 644 |
|
407 | 645 | ```typescript |
408 | 646 | handler: async (ctx: HookContext) => { |
409 | 647 | // Get API for another object |
410 | 648 | const users = ctx.api?.object('user'); |
411 | 649 |
|
412 | | - // Query users |
| 650 | + // Query users — `where` is canonical (`filter` is a tolerated object alias) |
413 | 651 | const admin = await users.findOne({ |
414 | | - filter: { role: 'admin' } |
| 652 | + where: { role: 'admin' } |
415 | 653 | }); |
416 | 654 |
|
417 | 655 | // Create related record |
|
0 commit comments