blendx docs

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.

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().

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.

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 }.

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 }.

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.

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.

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.