|
| 1 | +--- |
| 2 | +title: Hook & Action Bodies (L1 / L2) |
| 3 | +description: How hook handlers and script-action bodies travel through ObjectStack as pure metadata, and the spec they must conform to. |
| 4 | +--- |
| 5 | + |
| 6 | +# Hook & Action Bodies |
| 7 | + |
| 8 | +ObjectStack treats every hook handler and every `type: 'script'` action as **pure metadata**. There is no separate `.mjs` file shipped alongside the project artifact, no dynamic `import()` at runtime, and no filesystem dependency on the cloud. A body is either: |
| 9 | + |
| 10 | +- **L1 — Expression** a formula-engine string, side-effect-free. |
| 11 | +- **L2 — Sandboxed JS** a JavaScript source string executed inside an isolated VM with declared capabilities. |
| 12 | + |
| 13 | +A third "compiled module" form (L3) was considered and explicitly **disabled** — it broke the cloud-parity guarantee that every artifact is a single self-contained JSON. |
| 14 | + |
| 15 | +## TL;DR |
| 16 | + |
| 17 | +```ts |
| 18 | +// Authoring (TS source — packages/myapp/src/objects/account.hook.ts) |
| 19 | +export const beforeInsert = defineHook({ |
| 20 | + name: 'normalize_account', |
| 21 | + object: 'account', |
| 22 | + events: ['beforeInsert'], |
| 23 | + handler: async (ctx) => { |
| 24 | + if (ctx.input.website) { |
| 25 | + ctx.input.website = ctx.input.website.toLowerCase(); |
| 26 | + } |
| 27 | + }, |
| 28 | +}); |
| 29 | +``` |
| 30 | + |
| 31 | +```jsonc |
| 32 | +// Build artifact (account.hook.json — what objectos actually loads) |
| 33 | +{ |
| 34 | + "name": "normalize_account", |
| 35 | + "object": "account", |
| 36 | + "events": ["beforeInsert"], |
| 37 | + "body": { |
| 38 | + "language": "js", |
| 39 | + "source": "if (ctx.input.website) ctx.input.website = ctx.input.website.toLowerCase();", |
| 40 | + "capabilities": [] |
| 41 | + } |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +The CLI builder extracts the function body, AST-checks it, and emits the metadata above. No `runtimeModule`, no `bundle.functions[normalize_account]` — the artifact is self-contained. |
| 46 | + |
| 47 | +## Why metadata-only? |
| 48 | + |
| 49 | +| Constraint | Implication | |
| 50 | +|---|---| |
| 51 | +| **Cloud parity.** objectos in production receives projects through the `cloud-artifact-api`. | The transport must be a single JSON. | |
| 52 | +| **Edge runtime support.** objectos must run on Cloudflare Workers, Vercel Edge, Deno Deploy. | No native modules, no Node-only filesystem APIs in the execution path. | |
| 53 | +| **Hot-reloadable.** Studio in-browser editor must save handler edits and have them take effect on next request. | Bodies must be data, not code that requires a build step. | |
| 54 | +| **Audit & multi-tenancy.** Every body should be inspectable, sandboxable, and scoped per tenant. | Bodies travel through the same RBAC pipe as data. | |
| 55 | + |
| 56 | +This is the same trade-off ServiceNow made (Business Rules), Salesforce made (Formulas + Apex Triggers stored as metadata), Retool made (transformer JS strings), and Airtable made (Scripting blocks). It's the standard low-code shape. |
| 57 | + |
| 58 | +## L1 — Expression bodies |
| 59 | + |
| 60 | +Pure formula. No IO, no mutation. Used for: |
| 61 | + |
| 62 | +- Hook `condition` (run-this-hook? predicate) |
| 63 | +- Action `body` for trivial computed values |
| 64 | +- Validation rules |
| 65 | + |
| 66 | +```jsonc |
| 67 | +{ "language": "expression", "source": "input.amount > 1000 && input.status == 'open'" } |
| 68 | +``` |
| 69 | + |
| 70 | +Evaluated by the same formula engine that powers field formulas — see [Formula Reference](./formula). |
| 71 | + |
| 72 | +## L2 — Sandboxed JS bodies |
| 73 | + |
| 74 | +A JavaScript **function body** (not a full module) executed inside QuickJS. |
| 75 | + |
| 76 | +```jsonc |
| 77 | +{ |
| 78 | + "language": "js", |
| 79 | + "source": "const total = await ctx.api.object('opportunity').count({ account_id: ctx.input.id }); ctx.input.opportunity_count = total;", |
| 80 | + "capabilities": ["api.read"], |
| 81 | + "timeoutMs": 250, |
| 82 | + "memoryMb": 32 |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +### Sandbox surface |
| 87 | + |
| 88 | +The script sees only what the surrounding `ctx` object exposes: |
| 89 | + |
| 90 | +| Field | Description | Capability required | |
| 91 | +|---|---|---| |
| 92 | +| `ctx.input` | Mutable record being inserted/updated/etc. | none | |
| 93 | +| `ctx.previous` | Pre-update record (update events only). | none | |
| 94 | +| `ctx.user` / `ctx.session` | Identity context. | none | |
| 95 | +| `ctx.api.object(name).find\|count\|aggregate` | Cross-object reads, scoped to current tenant. | `api.read` | |
| 96 | +| `ctx.api.object(name).insert\|update\|delete` | Cross-object writes. | `api.write` | |
| 97 | +| `ctx.crypto.randomUUID()` | UUID generation. | `crypto.uuid` | |
| 98 | +| `ctx.crypto.hash(algo, data)` | Sha-256/512 etc. | `crypto.hash` | |
| 99 | +| `ctx.log.{info,warn,error}` | Structured logging. | `log` | |
| 100 | +| `ctx.connector(name).<method>(...)` | Outbound HTTP / SaaS calls. | (separate Connector spec) | |
| 101 | + |
| 102 | +### What the sandbox forbids |
| 103 | + |
| 104 | +The CLI builder **rejects** any source that uses: |
| 105 | + |
| 106 | +- `import` / `require` / dynamic `import()` |
| 107 | +- `fetch`, `XMLHttpRequest`, `WebSocket` |
| 108 | +- `process`, `globalThis`, `Buffer`, `setImmediate` |
| 109 | +- `eval`, `new Function`, `Function` constructor |
| 110 | +- references to identifiers from value-only top-level imports |
| 111 | + |
| 112 | +Need outbound HTTP? Define a **Connector recipe** as metadata and call it via `ctx.connector(...)`. (Connector spec is tracked separately and ships after L1+L2 stabilises.) |
| 113 | + |
| 114 | +### Signature conventions |
| 115 | + |
| 116 | +| Surface | TS authoring | Sandbox invocation | |
| 117 | +|---|---|---| |
| 118 | +| Hook | `(ctx: HookContext) => Promise<void>` | `(ctx) => Promise<void>` | |
| 119 | +| Action | `(input: I, ctx: ActionContext) => Promise<O>` | `(input, ctx) => Promise<O>` | |
| 120 | + |
| 121 | +Hooks mutate `ctx.input`/`ctx.result`; actions return their output value explicitly. |
| 122 | + |
| 123 | +### Engine |
| 124 | + |
| 125 | +The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS host. We considered `isolated-vm` but its native dependency disqualifies edge targets. The choice is hidden behind the `ScriptRunner` interface in `packages/runtime/src/sandbox/`, so a node-only deployment can swap in a faster engine later without touching call sites. |
| 126 | + |
| 127 | +Per-invocation timeouts default to **250ms** for hooks and **5000ms** for actions; per-invocation memory caps at **32 MB**. Both are overridable per body. |
| 128 | + |
| 129 | +## L3 — Compiled modules (intentionally disabled) |
| 130 | + |
| 131 | +An earlier design allowed the CLI to emit a sibling `objectstack-runtime.<hash>.mjs` that `objectos` would `import()` at runtime. We removed that path because: |
| 132 | + |
| 133 | +1. It meant cloud-deployed `objectos` had to download a JS module out-of-band, adding another transport, another cache, another vector for tenant cross-talk. |
| 134 | +2. It bypassed the sandbox entirely — a misbehaving module could call any Node API on the host. |
| 135 | +3. It made hot-reload from Studio impossible; you cannot edit a baked `.mjs` from a browser. |
| 136 | + |
| 137 | +If you have a body that genuinely cannot be expressed in L1+L2 (typically: it needs an npm package's behaviour), the right escape hatch is a **plugin** — install it on the host, register a service via DI, and call it from L2 with a capability-gated proxy. Bodies stay metadata; the heavy lifting moves to a place where it's auditable and shared. |
| 138 | + |
| 139 | +## Build pipeline |
| 140 | + |
| 141 | +`objectstack build` walks `*.hook.ts` and `*.action.ts` in your package: |
| 142 | + |
| 143 | +1. Parse with the TypeScript compiler API. |
| 144 | +2. Find each handler arrow / function expression. |
| 145 | +3. AST allow-list check the body (see "What the sandbox forbids" above). |
| 146 | +4. **Pass:** emit `body: { language: 'js', source: <printed body>, capabilities: <inferred> }`. |
| 147 | +5. **Fail:** the build aborts with a precise diagnostic — for example: `account.hook.ts:42 — fetch() is not allowed in hook bodies. Define a Connector recipe instead.` |
| 148 | + |
| 149 | +Capabilities are inferred from the AST (e.g. `ctx.api.object(...).insert(...)` ⇒ `api.write`). You can override with a directive comment when the inference is wrong. |
| 150 | + |
| 151 | +## Migration |
| 152 | + |
| 153 | +If you have an existing project that uses the old `handler: 'function_name'` + `bundle.functions[name]` shape, both forms are accepted during the transition: |
| 154 | + |
| 155 | +| Phase | Status | |
| 156 | +|---|---| |
| 157 | +| Phase 1 *(now)* | Both `handler` and `body` accepted. Loader prefers `body`. | |
| 158 | +| Phase 2 | Build emits a deprecation warning when `handler` is present without `body`. | |
| 159 | +| Phase 3 | `handler` removed. `body` becomes the only accepted form. | |
| 160 | + |
| 161 | +The CLI extractor handles the conversion automatically — you don't need to rewrite TS source files. Run `objectstack build` and your project artifact is in the new shape. |
| 162 | + |
| 163 | +## Bundle format |
| 164 | + |
| 165 | +The compiled artifact `dist/objectstack.json` carries every hook and `type='script'` action body inline. The shape is identical for both: |
| 166 | + |
| 167 | +```json |
| 168 | +{ |
| 169 | + "hooks": [ |
| 170 | + { |
| 171 | + "name": "account_protection", |
| 172 | + "object": "account", |
| 173 | + "events": ["beforeInsert", "beforeUpdate"], |
| 174 | + "priority": 200, |
| 175 | + "handler": "account_protection", |
| 176 | + "body": { |
| 177 | + "language": "js", |
| 178 | + "source": "const { event, input } = ctx; if (event === 'beforeInsert' || event === 'beforeUpdate') { … }", |
| 179 | + "capabilities": ["api.read"] |
| 180 | + } |
| 181 | + } |
| 182 | + ], |
| 183 | + "actions": [ |
| 184 | + { |
| 185 | + "name": "send_quote", |
| 186 | + "type": "script", |
| 187 | + "target": "global_send_quote", |
| 188 | + "body": { |
| 189 | + "language": "js", |
| 190 | + "source": "await ctx.api.object('quote').update(input.id, { sent_at: new Date().toISOString() }); return { ok: true };", |
| 191 | + "capabilities": ["api.write"] |
| 192 | + } |
| 193 | + } |
| 194 | + ] |
| 195 | +} |
| 196 | +``` |
| 197 | + |
| 198 | +`handler` / `target` strings still refer to entries in the sibling `objectstack-runtime.{hash}.mjs` bundle. That bundle is **only used as a back-compat fallback** for runtimes that haven't yet enabled the QuickJS interpreter — once the deprecation phase ends (Phase 3 above) the bundle disappears entirely and the artifact becomes a single self-contained JSON file. This is the cloud-deployable shape: `cloud-artifact-api` ships only the JSON; the QuickJS runtime in `@objectstack/runtime` rehydrates every body inside its sandbox at boot. |
| 199 | + |
| 200 | +### Capability inference |
| 201 | + |
| 202 | +The extractor scans each body for known patterns and adds the matching capability tokens to `body.capabilities`: |
| 203 | + |
| 204 | +| Pattern in source | Inferred capability | |
| 205 | +|---|---| |
| 206 | +| `*.object(…).find / findOne / count / aggregate / get / list` | `api.read` | |
| 207 | +| `*.object(…).insert / update / upsert / delete / patch / remove / create` | `api.write` | |
| 208 | +| `ctx.crypto.randomUUID` | `crypto.uuid` | |
| 209 | +| `ctx.crypto.hash` | `crypto.hash` | |
| 210 | +| `ctx.log.info / warn / error / debug` | `log` | |
| 211 | + |
| 212 | +To override the inference, add a directive comment as the first line of your handler body: |
| 213 | + |
| 214 | +```ts |
| 215 | +handler: async (ctx) => { |
| 216 | + // @capabilities api.read api.write log |
| 217 | + … |
| 218 | +} |
| 219 | +``` |
| 220 | + |
| 221 | +### Build pipeline at a glance |
| 222 | + |
| 223 | +``` |
| 224 | +objectstack.config.ts |
| 225 | + └── defineStack({...}) ← functions live in JS |
| 226 | + └── normalizeStackInput() ← shape normalisation only |
| 227 | + └── lowerCallables() ← extracts body + builds fn map |
| 228 | + ├── body:{...} ← shipped in dist/objectstack.json |
| 229 | + └── handler:"ref" ← bundled into objectstack-runtime.{hash}.mjs |
| 230 | + └── ObjectStackDefinitionSchema.safeParse() |
| 231 | + └── writeFile(dist/objectstack.json) |
| 232 | +``` |
| 233 | + |
| 234 | +## See also |
| 235 | + |
| 236 | +- [Formula Reference](./formula) — the L1 expression engine. |
| 237 | +- [Business Logic](./business-logic) — broader patterns for hooks, actions, flows. |
| 238 | +- [Cloud Deployment](./cloud-deployment) — how artifacts travel from Studio to objectos. |
| 239 | +- `packages/spec/src/data/hook-body.zod.ts` — canonical Zod schema. |
| 240 | +- `packages/runtime/src/sandbox/script-runner.ts` — engine decision rationale. |
| 241 | +- `packages/cli/src/utils/extract-hook-body.ts` — extractor + capability inference. |
0 commit comments