Skip to content

Write contract

Write contract

A collection declares what may be written in `+collection.ts` beside its model — the input a caller may submit, a transform that turns it into what the collection writes, the notifications a write raises, and any similarity indexes the collection offers as search commands. A model without one is read-only: nothing writes it, directly or through a relation.

Input and payload

The input is a positive allowlist — `columns` names fields, `with` names the relation actions a caller may nest — and anything outside it is refused before anything runs. The transform runs once per admitted batch, reads as the workspace through `db`, and returns one payload per input or refuses — a refusal writes nothing. Without a transform the decoded input is the payload.

import { defineCollection, refuse } from '@norbital-ai/bolt/authoring';
import { Effect } from 'effect';
import model from './+model.js';

export default defineCollection({
  model,
  create: { input: { columns: { name: true, status: true } } },
  update: { input: { columns: { status: true } } },
  delete: {},
  transform: (inputs, { existing, db }) =>
    Effect.gen(function* () {
      const names = inputs.map((input, i) => input.name ?? existing[i]?.name ?? '');
      const taken = yield* db.sites.count({ where: { name: { in: names } } });
      if (taken) refuse('Site already exists.');
      return inputs;
    }),
  notifications: {
    committed: [
      {
        channel: 'inbox',
        recipients: ({ requestor }) => [requestor],
        message: () => ({ title: 'Site saved', body: 'The site was saved.' })
      }
    ]
  }
});

Operations

Each operation is declared on its own. `create` and `update` name their input selection; delete: {} exposes delete with no input and no transform:

OperationInputWhat the engine does
createthe selected columns — non-nullable, undefaulted ones required — and nested `create` actions; no idallocates the id, runs the transform, commits the whole graph in one transaction
updatea partial of the selected columns and explicit relation actions: `create`, `update`, `upsert`, `link`, `unlink`, `delete`asserts the observed row versions, runs the transform against `existing`, commits the graph
deletethe id onlyreads the cascade closure, removes dependents as the model says, records every removed row in history
Durable work belongs in automations, not the transform
A transform is part of somebody else’s write: it runs before the transaction, its reads are re-asserted inside it, and its budget is two read waves. Work that must survive the write belongs in automations — scheduled, event-driven, or started from code with `api.automations.run`, each a durable background run of its own. Notices belong in `notifications`, written in the commit and delivered by the host.

Choose the narrowest role

Companion roles overlap on purpose — pick the narrowest one that fits:

  • Write contract — what a caller may submit, mutation invariants, and the notices a write raises
  • Pipelines — reusable bulk ingest and artifact contracts ( pipelines)
  • Integrations — reliable external delivery that reuses pipelines ( integrations)