blendx docs

Blends

A blend exposes one table. It lives in blends/<table>.ts and default-exports blend(model, spec). From examples/expenses:

import { allow, blend, z } from 'blendx';
import { models } from '../src/generated/schema.gen.ts';

export default blend(models.users, {
  // Anyone may sign up. After that, a user sees only their own record.
  policy: { store: allow.public, show: allow.owner('id') },
  actions: (a) => [
    a.store({
      rules: ({ prev }) =>
        prev.pick({ email: true, name: true }).extend({ email: z.email().max(255) }),
    }),
    a.show(),
  ],
});

models comes from src/generated/schema.gen.ts, which blendx generate writes from schema.dbml: one model per table, carrying its columns and what blendx read from the schema (the primary key, the timestamps, soft delete, the constraints). The file's name must be the table's: blendx generate refuses blends/people.ts when it blends users. A table without a blend has no routes.

The spec has four keys:

Key
policyRequired. Who may run each action (Policies).
actionsRequired. The actions to expose (Actions).
hiddenColumns left out of replies, unless an action reveals them (Hidden columns).
hooksHooks that run for every action of this table (Hooks).

Actions

The actions callback receives a builder, a, and returns the list of actions to expose. Nothing else gets a route: an action you do not list does not exist, and a request for it is a 404.

CallRouteBy defaultReply
a.index()GET /<table>a filtered, sorted page of rows200, { data, meta }
a.store()POST /<table>validates the body and inserts a row201, the record
a.show()GET /<table>/:idloads the row200, the record
a.update()PATCH /<table>/:idvalidates a partial body and updates the row200, the record
a.destroy()DELETE /<table>/:idsoft-deletes the row, or deletes it when the table has no deleted_at204, no body
a.restore()POST /<table>/:id/restoreclears deleted_at on a soft-deleted row200, the record
a.purge()DELETE /<table>/:id/purgedeletes the row for good, soft-deleted or not204, no body
a.member(name, spec)POST /<table>/:id/<name>loads the row and updates it with what calculate returns200, the record
a.collection(name, spec)POST /<table>/<name>loads and saves nothing200, what calculate returns
  • a.restore() and a.purge() exist only on a table with a nullable deleted_at timestamp. On any other table they are a type error: destroy already deletes for good there. Purge has a policy of its own, like every action, so a blend can leave destroy to a row's owner and purge to an administrator (cookbook pattern 10).
  • Each action is listed once. Every call takes an optional spec: the hooks where the action differs from the defaults (Hooks), and the options below.
  • A collection action's route, such as GET /expenses/quote, is matched before GET /expenses/:id.

What each default does in detail (which columns store accepts, how index filters) is derived from the schema: see The schema and The HTTP API.

Index options

a.index({ trashed: true }) also accepts ?trashed=with (live and soft-deleted rows) and ?trashed=only (soft-deleted rows). It needs a soft-delete table.

Scope

a.index({ scope }) limits a listing to the rows the requester may see. scope receives the identity and returns column values; the default load adds each as an equality, so pages and meta.total count only those rows, and query filters still apply within them. From examples/expenses:

a.index({
  // Approvers list every claim; everyone else, their own.
  scope: ({ auth }) => (auth?.is_approver ? {} : { user_id: auth?.id }),
}),
  • The keys are columns and the values their types; tsc refuses anything else.
  • A value that is undefined or null matches no row, so a scope that cannot be worked out, such as one reading an identity that is missing, lists nothing. {} scopes nothing.
  • The review file shows the scope as written, and the columns it scopes by.

Custom actions

a.member(name, spec) acts on one record, and a.collection(name, spec) on the table. A name is lowercase letters, digits and _, and not a built-in action's name. Two options shape the route:

  • method: 'get', 'post', 'patch' or 'delete'; 'post' by default. A get action reads its input from the query string, the others from the JSON body.
  • path: the path segment, the name by default.

A custom action starts from empty rules: it accepts {} and nothing else until its rules hook adds fields.

A member action runs like an update: it loads its row (locked for the update), checks the policy, saves what calculate returns and replies with the record.

a.member('reject', {
  rules: () => z.object({ note: z.string().min(3).max(500) }),
  authorize: ({ prev, auth, record }) => prev && reviewable(record, auth),
  calculate: ({ input }) => ({ status: 'rejected' as const, review_note: input.note }),
}),

A collection action loads and saves nothing. What calculate returns is the reply body, so it is not limited to columns, and reply describes it for OpenAPI and the review (Declaring a reply):

a.collection('quote', {
  method: 'get',
  rules: () => z.object({ amount, category: z.enum(expense_category.enumValues) }),
  calculate: ({ input }) => price(input.amount, input.category),
  reply: z.object({ tax: z.string(), total: z.string() }),
}),

Policies

blendx is default-deny: every exposed action needs a policy, and blend() refuses one that has none.

PolicyAllowsNeeds an identity
allow.publicanyoneno
allow.authenticatedany request with an identityyes
allow.owner(column, authKey = 'id')the identity whose authKey equals the record's columnyes
allow.when(check, { description, requiresAuth })whenever check returns truewhen requiresAuth is true
denynobodyno

Give one policy for every action, or one per action with default for the rest:

policy: {
  default: allow.owner('user_id'),
  index: allow.authenticated,
  store: allow.authenticated,
  approve: approvers,
  reject: approvers,
},

blend() refuses an action that gets no policy (no entry and no default), and an entry for an action the blend does not list.

  • A policy that needs an identity answers 401 to a request without one, before the input is read. Every other refusal is a 403, and comes after validation and loading (the order of failures).
  • allow.owner needs a record, so it refuses index, store and collection actions: give those a policy of their own.
  • allow.when receives { auth, record, input, action }, where record is undefined for actions that load none. Give it a description: the review file shows it as the action's authorize line. Set requiresAuth: true when the rule cannot pass without an identity, so such requests get 401 rather than 403.
const approvers = allow.when(({ auth }) => auth?.is_approver === true, {
  requiresAuth: true,
  description: 'an approver',
});

auth is what your app's auth function returns, typed from it (The app and identity). A rule that also depends on the input or on the record's state, such as "only a draft can be submitted", is clearer as an authorize hook, which receives the policy's decision as prev.

Hidden columns

hidden: ['password'] keeps a column out of every reply, index pages included, and out of the index filters and sorting. It is still a column: store and update accept it as input unless their rules drop it, and hooks see it on the record. blend() refuses a name that is not a column.

A column that one reply must carry and every other reply must hide, such as a token returned once at sign-up, is revealed by that action. From examples/expenses:

hidden: ['api_token'],
actions: (a) => [a.store({ rules: ..., reveal: ['api_token'] }), a.show()],

reveal is for actions that reply with one record: store, show, update, restore and member actions. It names only hidden columns, and index, destroy, purge and collection actions take none, so no page ever carries the column. The action's reply type and its OpenAPI schema include what it reveals, and the review lists it under the action as reveals (docs/decisions.md D24).

Includes

A blend may let index and show nest the row a foreign key points to. From the conformance fixture's orders:

import users from './users.ts';

export default blend(models.orders, {
  policy: { ... },
  includes: { user: users },
  actions: (a) => [a.index({ trashed: true }), a.store(), a.show(), ...],
});

GET /orders?include=user then gives every order its user, the row user_id points to (The HTTP API). A relation is named after its foreign key column without _id, so user_id gives user, and only single-column foreign keys ending in _id are relations. The types refuse a name that is not a relation of the table, and a blend of another table than the one it points to.

Each included row goes through the target blend's show, as GET /users/:id would for the same requester: its policy and authorize hooks decide row by row, and its hidden columns are left out. A row that show would refuse or not find, such as a soft-deleted one, is null. So the target must expose show, and its show may not have a load hook. Two blends cannot include each other, because their files would import each other (docs/decisions.md D28).

An include nests the includes of its own target, so a request may follow a path: in the fixture, order_notes includes author: users, and GET /orders/1?include=notes.author gives each of the order's notes its author. Nothing more is declared: the paths a blend allows are those its targets' blends allow, and the review lists every one of them, notes.author: users, through its show: ..., beside the direct includes. Each level goes through its own blend's show, and a level that is null or dropped nests nothing below it (docs/decisions.md D32).

The rows that point at a row

A blend may also nest the rows of another table whose foreign key points at its own, a has-many, bounded. From the same orders:

import orderNotes from './order_notes.ts';

export default blend(models.orders, {
  policy: { ... },
  includes: { user: users, notes: { blend: orderNotes, limit: 2, sort: '-id' } },
  actions: (a) => [a.index({ trashed: true }), a.store(), a.show(), ...],
});

GET /orders/1?include=notes then gives the order its notes, an array of the rows of order_notes whose order_id is 1, never null. Nothing in the schema names the inverse of a foreign key, so the blend does: the key is the name, and the value says which blend, how many at most, and in what order.

  • blend is the blend of the table that points here. It must have exactly one single-column foreign key to this table; when it has more, as a reviews table with an author_id and a reviewer_id would, by: 'author_id' says which.
  • limit is required: a has-many is unbounded, and the include is for a row's bounded children, such as an order's notes or its lines. A user's orders stay on GET /orders?user_id=1, which pages. The limit shows in the review, and the rows beyond it are cut; a client that needs the total asks the target's index.
  • sort is a column of the target, with - for descending, and defaults to the target's primary key ascending.

The rows are loaded in one query for the whole reply, at most limit per row, and each goes through the target's show as a belongs-to row does: a row show refuses is dropped, and still counts against the limit, so a list may hold fewer than limit rows while more exist. A bare blend under a name is always a belongs-to include; the object form is always a has-many. The types refuse a has-many without a limit, a blend of a table that does not point here, a sort that is not its column, and a name that is a column or a belongs-to relation of the table (docs/decisions.md D31).

Declaring a reply

blendx describes every default reply in OpenAPI and in the review file. There are two replies it cannot derive: a collection action's result, and a body that a respond hook builds. Declare them with reply:

  • reply: z.object({ ... }): the body, sent with the action's default status.
  • reply: { status: 202, body: z.object({ ... }) }: when respond returns another status.

The declaration is type-checked against what the action sends: the keys must match, and every body must fit the schema. A mismatch is a type error on the action that names the reason. An undeclared reply is not an error: blendx generate prints a warning, and OpenAPI and the review describe the reply as unknown. (Cookbook, pattern 8.)

Mistakes blend() refuses

blend() checks a definition when its file is imported, so blendx generate, the tests and the server all stop at once with blend(<table>): ...:

  • an action listed twice;
  • an include that is not a relation of the table, is not a blend of the table it points to, points to a blend without show or whose show has a load hook, or has the name of a column;
  • a has-many include whose blend has no foreign key to the table, or more than one without by, whose limit is not a positive integer, or whose sort is not a visible column of the target;
  • an action without a policy, or a policy for an action that is not listed;
  • a hidden column that is not a column;
  • a custom action that reuses a built-in name, has a name that is not lowercase letters, digits and _, or has an invalid path;
  • restore, purge, or trashed on index, on a table without soft delete;
  • a reply that is neither a zod schema nor { status, body };
  • a scope that is not a function;
  • a reveal that names a column that is not hidden, or sits on an action that does not reply with one record (index, destroy, purge, a collection action).

Mistakes in types, such as a calculate that returns a column the table does not have, are caught earlier, by tsc.