blendx docs

Blend cookbook

Fifteen 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.
  // Deleting for good is not for owners: purge has a policy of its own.
  policy: {
    default: allow.owner('user_id'),
    index: allow.authenticated,
    purge: allow.when(({ auth }) => auth?.role === 'admin'),
  },
  actions: (a) => [a.index({ trashed: true }), a.show(), a.destroy(), a.restore(), a.purge()],
});

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. purge (DELETE /orders/:id/purge) deletes a row for good, whether it is soft-deleted or not, and answers 204; a row other rows still reference answers 409. Both exist only on a soft-delete table: elsewhere destroy already deletes for good (docs/decisions.md D29).

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.

12. An effect that must not be lost

a.member('refund', {
  rules: () => z.object({ reason: z.string().min(3) }),
  calculate: () => ({ status: 'refunded' as const }),
  // From the outbox, at least once: the provider ignores a repeat with the same key.
  later: ({ saved, id }) => payments.refund(saved.id, { idempotencyKey: `refund-${id}` }),
}),

A later hook does not run in the request. The write leaves an outbox entry in its own transaction, so the entry exists exactly when the refund does, and the worker that startOutbox({ app, db, resources }) starts in the server runs it: at least once, retrying until it succeeds or its tenth attempt fails. It may run twice, so give the other system the entry's id as an idempotency key. The first later hook brings the outbox table with it: run blendx generate, then blendx migrate generate. payments stands for the app's own client.

import users from './users.ts';

export default blend(models.orders, {
  policy: { default: allow.owner('user_id'), index: allow.public },
  includes: { user: users },
  actions: (a) => [a.index(), a.show()],
});

GET /orders?include=user gives every order its user, the row user_id points to: a relation is named after its foreign key column without _id. Each nested row is what the users blend's show would reply to the same requester, hidden columns removed, or null where it would refuse or find nothing. The target must expose show, and two blends cannot include each other.

14. Nest the rows that point at a row

import orderNotes from './order_notes.ts';

export default blend(models.orders, {
  policy: { default: allow.owner('user_id'), index: allow.public },
  includes: { notes: { blend: orderNotes, limit: 10, sort: '-created_at' } },
  actions: (a) => [a.index(), a.show()],
});

GET /orders/1?include=notes gives the order its notes, the rows of order_notes whose order_id points at it, newest first, at most ten, as an array that is [] when there are none. The blend names the include, since the schema does not name the inverse of a foreign key; by: 'author_id' picks the column when the target points at the table twice. The limit is required: a has-many include is for a row's bounded children, and a list that pages belongs on the target's index. Each row goes through the target's show as a belongs-to row does, and a row it refuses is dropped.

15. Nest an included row's own includes

import orders from './orders.ts';

export default blend(models.order_notes, {
  policy: allow.authenticated,
  includes: { order: orders },
  actions: (a) => [a.index(), a.show()],
});

Nothing more is declared: once the orders blend includes user (pattern 13), GET /order_notes/1?include=order.user gives the note its order, and the order its user. A path follows the includes of the included blends, as far as they go, and asks its prefixes on the way. Each level goes through its own blend's show, so an order the requester may not see is null with nothing below it, and a has-many's limit applies at its level. The review of order_notes lists every path a request can follow, order.user: users, through its show: ..., so a reviewer sees what a note can reach without opening the orders blend. The conformance fixture does it the other way round, its notes including their author, so GET /orders/1?include=notes.author nests an author into each of an order's notes.