Blend cookbook
Eleven patterns that cover most of what a blend ever says. Each shows only what differs from the defaults; everything left out is derived from the schema. Every pattern links to the test that pins its behaviour.
The examples use the shop tables: users (with a password), and orders (with a user_id and soft delete). Every snippet also compiles, with a typed identity, in the cookbook, compiled.
1. Expose a table read-only, hiding a column
export default blend(models.users, {
policy: allow.public,
hidden: ['password'],
actions: (a) => [a.index(), a.show()],
});
Only listed actions get routes. A hidden column leaves the server only in the reply of an action that reveals it (a.store({ reveal: ['password'] }), never on index), and it cannot be filtered or sorted on.
2. A policy per action, and the owner rule
export default blend(models.orders, {
policy: {
default: allow.owner('user_id'),
index: allow.public,
store: allow.authenticated,
},
actions: (a) => [a.index(), a.store(), a.show(), a.update(), a.destroy()],
});
allow.owner('user_id') passes when the record's user_id equals the identity's id. A policy that needs an identity answers 401 before the input is read.
- an owner policy allows the owner and refuses everyone else
- requiresAuth comes from each action policy
3. A column computed from the input
a.store({
rules: () => z.object({ a: z.number(), b: z.number() }),
calculate: ({ input }) => ({ result: input.a + input.b }),
}),
rules replaces the default input; calculate turns the input into the columns to write. Write rules first: calculate's input type comes from it.
4. Add a field to the default rules
a.store({
rules: ({ prev }) => prev.extend({ coupon: z.string().optional() }),
calculate: ({ input }) => ({
total: input.coupon === 'HALF' ? (Number(input.total) / 2).toFixed(2) : input.total,
}),
}),
Using prev keeps every derived rule and adds to it; returning a new object replaces them. Either way, unknown keys are still refused unless the object says .loose().
- calculate is typed from rules, the loaded record and the model
- an action can replace the defaults; a plain object is made strict
5. A member action that writes
a.member('refund', {
rules: () => z.object({ reason: z.string().min(3) }),
calculate: () => ({ status: 'refunded' as const }),
}),
POST /orders/:id/refund loads the order (locked for the update), checks the policy, saves what calculate returns and replies with the record.
6. A collection action with a declared reply
a.collection('quote', {
method: 'get',
rules: () => z.object({ quantity: z.string() }),
calculate: ({ input }) => ({ total: Number(input.quantity) * 10 }),
reply: z.object({ total: z.number() }),
}),
A collection action loads and saves nothing: calculate's result is the reply. reply describes it for OpenAPI and the review, and it is type-checked against what calculate returns.
- a collection action answers 200 with what calculate returned
- OpenAPI describes declared replies, at their status, and warns about the rest
7. Scope a listing to the requester
a.index({ scope: ({ auth }) => ({ user_id: auth?.id }) }),
scope returns column values, and the default load adds each as an equality, so pages and meta.total count only the requester's rows; filters from the query still apply within them. A value that is undefined or null matches no row, so a scope that cannot be worked out lists nothing, and {} scopes nothing: auth?.is_admin ? {} : { user_id: auth?.id }.
- scope (D22): only the rows in scope, and pages and totals count only those
- scope: a missing value matches no row; an empty scope scopes nothing
8. Reshape the reply
a.member('rename', {
rules: () => z.object({ display_name: z.string() }),
calculate: ({ input }) => ({ display_name: input.display_name }),
respond: ({ prev, record }) => ({ ...prev, status: 202, body: { renamed: record.display_name } }),
reply: { status: 202, body: z.object({ renamed: z.string().nullable() }) },
}),
respond receives the default reply and the public record (hidden columns already removed). When it builds a new body, reply declares it; a status other than the default goes in { status, body }.
- see only the public record, and may change the status and body
- another status is declared with { status, body }
9. One more authorization rule
a.store({
// An order is placed for oneself.
authorize: ({ prev, auth, input }) => prev && input.user_id === auth?.id,
}),
authorize receives the policy's decision as prev. Keeping prev && adds a rule on top of the policy; dropping it replaces the policy for this action.
- the policy decides first, then app, resource and action hooks run in order
- the conformance fixture's orders blend
10. Soft delete, restore and trashed rows
export default blend(models.orders, {
// The owner rule needs a record, so index (which has none) gets a policy of its own.
policy: { default: allow.owner('user_id'), index: allow.authenticated },
actions: (a) => [a.index({ trashed: true }), a.show(), a.destroy(), a.restore()],
});
On a table with a nullable deleted_at, destroy sets it instead of deleting, and the row disappears from index and show. restore clears it. index({ trashed: true }) accepts ?trashed=with or ?trashed=only.
- destroy soft-deletes: 204, the row stays with deleted_at set, show no longer finds it
- restore clears deleted_at
- ?trashed works only where the resource enables it
11. Do something once a write has committed
a.member('refund', {
rules: () => z.object({ reason: z.string().min(3) }),
calculate: () => ({ status: 'refunded' as const }),
// Once the refund has committed, tell the customer.
after: ({ saved, input }) => notify(saved.user_id, `Your order was refunded: ${input.reason}`),
}),
after runs once the action's write has committed, before the reply, with the row as saved (saved), the row as loaded (record), the input, the identity and the database. Only actions that write have one. The reply waits for it; what it throws goes to createServer's onError, and the reply stands. App and resource after hooks run too, each in turn, so an app-wide audit log belongs in defineApp({ hooks: { after } }). notify stands for the app's own mailer.