Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .changeset/33257-skills-hook-refuses-by-declared-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/skills': patch
---

docs(skills): a hook that enforces a business rule declares `refusals` and refuses by code, not with a free-text throw answered `HOOK_ABORTED`

Clause-②: no
Metadata-protocol: none

`objectstack-data/references/data-hooks.md` § Declared refusals now opens with the rule: a hook that enforces a business rule declares a `refusals` catalogue, one code per rule, and its body throws the code; a throw with no code is answered with the platform's `HOOK_ABORTED`, which is for failures nobody planned. The section's example is now a cross-object state guard (only an in-stock asset can be checked out, `ASSET_NOT_IN_STOCK` at 409). It also says which hook form can declare `refusals` today: only a sandboxed `js` `body`; a `handler` hook, an inline TS one included, is refused at save. It says how the refusal reaches the wire: the status's `code` (default 400), with the hook's code as `declaredCode`. Best Practices and the Error Handling intro point to it. Common Patterns' delete guard (`crm_protect_strategic_accounts`) declares `STRATEGIC_ACCOUNT` and `OPEN_OPPORTUNITIES` and throws them instead of two free-text errors; the second message no longer carries the count, since a catalogue message takes no parameters. `objectstack-data/rules/validation.md` § External / Custom Validation now teaches the same thing: its tax-ID hook declares `TAX_ID_BLOCKED` and throws it instead of `new Error('Tax ID is blocked')`. Both files pay for the new lines by deleting restatements in the same file. No schema, accept set or behaviour changes.
70 changes: 39 additions & 31 deletions skills/objectstack-data/references/data-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,16 +211,10 @@ in neither). So:
⚠️ **An unevaluable condition ABORTS the operation.** A typo'd key
(`record.stauts`), a column read off `previous` on an insert, or a comparison
CEL has no overload for does **not** degrade to "the hook did not fire" — it
**fails the write**. Until protocol 17 the gate emitted a `logger.warn` and
returned `false`, which is why the `previous`-on-insert and `has(...)` bullets
above are load-bearing rather than stylistic: a `before*` guard swallowed into
`false` silently let writes through, and an audit hook swallowed into `false`
silently dropped records.
**fails the write**.

Practical consequence when authoring: spell keys against the object's **declared**
fields, and on an insert event read `previous` only against `null` — a column
read off it there is the mistake that used to cost you a hook that quietly never
ran, and now costs you every write the hook is attached to.
So spell keys against the object's **declared** fields, and on an insert event
read `previous` only against `null`: the mistake fails every write the hook is on.

---

Expand Down Expand Up @@ -270,7 +264,7 @@ The sandbox is handed a **JSON snapshot** of these (built by
| `ctx.previous` | object \| `undefined` | Pre-write record on update/delete. **`undefined` on insert** → use `!ctx.previous` to detect *create*. |
| `ctx.result` | object \| `undefined` | `after*` only. ⚠️ **partial** on afterUpdate — see gotcha 1. |
| `ctx.user` | object \| `undefined` | `{ id, name, email, organizationId }`. `undefined` for system / unauthenticated writes. |
| `ctx.session` | object \| `undefined` | `{ userId, organizationId, isSystem, … }`. **No role list** — `session.roles` was retired in 17.0.0 (declared, never produced). |
| `ctx.session` | object \| `undefined` | `{ userId, organizationId, isSystem, … }`. **No role list** — `session.roles` was retired in 17.0.0. |
| `ctx.event` | string | e.g. `'afterUpdate'` — dispatch on it when one hook subscribes to several events. |
| `ctx.object` | string | The target object name. |
| `ctx.api` | object | Cross-object CRUD. Gated by `api.read` / `api.write` — see below. |
Expand All @@ -285,10 +279,6 @@ The sandbox is handed a **JSON snapshot** of these (built by
wrap is `(async (input, ctx) => { … })(input, ctx)` — the action params arrive as
the first arg.)

Because the VM only holds a snapshot, mutating `ctx.previous` / `ctx.result` is
local and thrown away; the only way to change the persisted write is
`ctx.input.x = …` or `return { … }`.

### `ctx.api.object(name)` — the cross-object repo

`ctx.api.object('<object>')` returns a repository bound to the current
Expand Down Expand Up @@ -635,17 +625,20 @@ const protectStrategicAccounts = defineHook({
name: 'crm_protect_strategic_accounts',
object: 'account',
events: ['beforeDelete'],
refusals: {
STRATEGIC_ACCOUNT: { message: 'A strategic account cannot be deleted.' },
OPEN_OPPORTUNITIES: { message: 'Close its open opportunities first.', status: 409 },
},
body: {
language: 'js',
source: `
// A delete's ctx.input is empty: ctx.previous is the row being deleted.
if (ctx.previous && ctx.previous.type === 'Strategic')
throw new Error('Cannot delete Strategic accounts');
throw Object.assign(new Error(), { code: 'STRATEGIC_ACCOUNT' });
const open = await ctx.api.object('opportunity').count({
where: { account_id: ctx.previous.id, stage: { $in: ['Prospecting', 'Negotiation'] } },
});
if (open > 0)
throw new Error('Cannot delete an account with ' + open + ' open opportunities');
if (open > 0) throw Object.assign(new Error(), { code: 'OPEN_OPPORTUNITIES' });
`,
capabilities: ['api.read'],
},
Expand Down Expand Up @@ -834,8 +827,8 @@ the layout both real hook modules in the repo use.

✅ **DO** — use `before*` for validation and `after*` for side effects; set
`async: true` for non-critical background work; go through `ctx.api` for
cross-object operations; give a thrown error a message the caller can act on;
test hooks in isolation.
cross-object operations; refuse a business rule by a
[declared code](#declared-refusals-refusals); test hooks in isolation.

❌ **DON'T** — do expensive work in `before*` (it blocks the transaction); let a
hook re-trigger itself; use `object: '*'` unless you mean every object; throw in
Expand All @@ -848,25 +841,40 @@ hook re-trigger itself; use `object: '*'` unless you mean every object; throw in
## Error Handling

A throw aborts the operation and rolls back its transaction; `onError: 'log'`
(above) makes an `after*` hook log and continue instead.
(above) makes an `after*` hook log and continue instead; neither gives the hook's own code.

### Declared refusals (`refusals`)

With a `refusals` catalogue, a sandboxed `body` refuses **by code only** and the
runtime answers with that entry:
A hook that enforces a business rule declares a `refusals` catalogue, one
SCREAMING_SNAKE code per rule, and its body throws the code. Without one, a `body`'s
throw is answered `HOOK_ABORTED`, the platform's code: fine for a failure you did not
plan, not for a rule the user can act on. A cross-object guard a validation rule
cannot read:

<!-- os:check -->
```typescript
refusals: {
CREDIT_LIMIT: { message: 'Over the credit limit.' },
ON_HOLD: { message: 'Account on hold.', status: 409 }, // default 400
},
body: { language: 'js', source: `
if (ctx.input.amount > 100000) throw Object.assign(new Error(), { code: 'CREDIT_LIMIT' });
` },
import { defineHook } from '@objectstack/spec/data';

export default defineHook({
name: 'checkout_requires_stock',
object: 'asset_checkout',
events: ['beforeInsert'],
refusals: { ASSET_NOT_IN_STOCK: { message: 'Only an in-stock asset can be checked out.', status: 409 } },
body: {
language: 'js',
capabilities: ['api.read'],
source: `
const id = ctx.input.asset_id;
const asset = id && await ctx.api.object('asset').findOne({ where: { id } });
if (asset?.status !== 'in_stock') throw Object.assign(new Error(), { code: 'ASSET_NOT_IN_STOCK' });
`,
},
});
```

- Codes are SCREAMING_SNAKE; the envelope carries one as `declaredCode`.
- Refused at save without a `js` body (e.g. a bare `handler`).
- Only a `js` `body` can declare one: a `handler` hook, inline TS included, is
refused at save.
- The wire `code` is the status's (default 400), with the hook's as `declaredCode`.
- The message is plain text (a `{{ }}` hole is refused), translated via
`hooks.<hook>.refusals.<CODE>.message`; the body's own text and `status` are unused.
- An undeclared code, or none, is refused: at save if thrown literally, else as
Expand Down
11 changes: 5 additions & 6 deletions skills/objectstack-data/rules/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,6 @@ condition: P`record.amount != null && record.amount < 0`

// Require field when another field has value
condition: P`record.status == 'approved' && isBlank(record.approver_id)`

// Date range validation
condition: P`record.start_date != null && record.end_date != null && record.end_date < record.start_date`
```

> On **insert**, an optional field omitted from the payload reads as `null`
Expand Down Expand Up @@ -246,8 +243,9 @@ With an `otherwise` branch:

Hitting another object or running arbitrary code is **not** a validation type.
Implement it as a `beforeInsert` / `beforeUpdate` lifecycle hook whose `body`
throws on failure. A `body` has no outbound HTTP: an external-API check goes
through a Connector recipe ([data-hooks.md](../references/data-hooks.md#capabilities--the-complete-list)).
throws a [declared code](../references/data-hooks.md#declared-refusals-refusals).
A `body` has no outbound HTTP: an external-API check goes through a Connector
recipe ([data-hooks.md](../references/data-hooks.md#capabilities--the-complete-list)).

<!-- os:check -->
```typescript
Expand All @@ -257,12 +255,13 @@ const taxIdCheck = defineHook({
name: 'crm_tax_id_blocklist_check',
object: 'account',
events: ['beforeInsert', 'beforeUpdate'],
refusals: { TAX_ID_BLOCKED: { message: 'This tax ID is blocked.' } },
body: {
language: 'js',
source: `
if (!ctx.input.tax_id) return;
const hit = await ctx.api.object('blocked_tax_id').findOne({ where: { tax_id: ctx.input.tax_id } });
if (hit) throw new Error('Tax ID is blocked');
if (hit) throw Object.assign(new Error(), { code: 'TAX_ID_BLOCKED' });
`,
capabilities: ['api.read'],
},
Expand Down
Loading