Templates
Versioned templates that create records, such as an invoice or a project with its tasks, system starters each organization forks, merge fields for text and email, and the workflows each new record starts.
templates stores templates of a type you define, such as invoice, project or onboarding-email. A template has settings, a versioned body and a language, and applying it creates a record from the newest published version through your type's apply. The module records which version made which record, and starts the workflow-builder workflows attached to the template on the new record.
System templates (no organization) are starters your app ships and keeps by key with sync. An organization forks one to change it, and the fork tracks the version it copied, so you can tell when the starter moved on.
Reading templates needs templates.read, applying one needs templates.apply, and creating, editing, publishing, forking and attaching workflows needs templates.manage. Admins get every permission, and members get templates.read and templates.apply. Only the service role changes system templates.
better-supabase sql add templatesThe module needs tenant and access. With outbox it records events, with audit it audits publishes and applications, with workflow-builder it attaches workflows, and with jsonb-schemas it checks a workflow's inputs against its whole input schema.
Template types
A type gives a template its Standard Schemas and creates the record. settings and body are checked on every write and parsed again before apply. apply returns the record it created as a subject.
import "server-only";
import * as v from "valibot";
import {
createTemplates,
defineMergeFields,
defineTemplateType,
sqlTransport,
} from "better-supabase/blocks/templates";
const project = defineTemplateType({
settings: v.object({ color: v.string() }),
body: v.object({ tasks: v.array(v.string()) }),
apply: async ({ body, settings, organizationId, key }) => {
const created = await projects
.create({ organizationId, color: settings.color, tasks: body.tasks, key })
.orThrow();
return { subject: { type: "project", id: created.id } };
},
});
export const invoiceFields = defineMergeFields("invoice", {
"customer.name": { label: "Customer", example: "Ada Lovelace" },
total: { label: "Total", example: "€120.00" },
});
export const templatesFor = (sql: Sql) =>
createTemplates({
transport: sqlTransport(sql),
types: { project },
workflows: builderFor(sql),
});A type without apply stores templates you render yourself, such as emails. Applying one returns an invalid_request error.
Starters, forks and defaults
await service
.sync([
{
key: "launch",
type: "project",
name: "Product launch",
body: { tasks: ["Kickoff", "Plan", "Retro"] },
},
])
.orThrow();
const [starter] = await templates.list(null, { type: "project" }).orThrow();
const fork = await templates.fork(starter.id, organizationId).orThrow();
await templates.setDefault(fork.id).orThrow();sync upserts system templates by key as the service role, and publishes a new version of each starter whose body changed (it compares the SHA-256 of the body). It returns how many versions it published, so running it on every deploy is cheap.
fork(source, organizationId) copies the source's newest published version into a new template in that organization, published as version 1, with its workflow attachments. The fork keeps forkedFrom and forkedFromVersion. upstreamVersion is the source's newest published version, and behind is true when it is newer than the one the fork copied.
list(organizationId) returns the organization's templates and the system ones, defaults first; list(null) returns only the system ones. setDefault makes a template the default for its organization, type and language; a unique index keeps one.
Versions
await templates.versions
.save(fork.id, { tasks: ["Kickoff", "Plan"] })
.orThrow();
await templates.versions.publish(fork.id).orThrow();versions.save updates the open draft, or opens the next version when the last one is published; a template has at most one draft. versions.publish publishes the draft (TEMPLATE_NO_DRAFT when there is none) and records template.published. Published versions don't change, so an application always points at the exact body it used. get returns the template with its newest published version and its draft, bodies included; versions.list lists them without bodies.
Applying a template
const { subject, runs } = await templates
.apply(templateId, { into: organizationId, values: { month: "October" } })
.orThrow();apply reads the newest published version (TEMPLATE_NOT_PUBLISHED without one), calls the type's apply with the parsed settings and body, the organization, your values and your key, then records the application and its template.applied event. A throw from apply returns as an error and records nothing. applications.list(organizationId, { template, subject }) lists what each template made.
Then it starts each enabled attached workflow on the new record through workflows.run, with the attachment's inputs as the run's input, the record as its subject and template-application:<application>:<definition> as its idempotency key. A run starts as the caller, so a member who may apply a template but not run the workflow gets the record and an error in runs for that workflow; apply doesn't fail. Pass a builder with a service transport when the workflows should run anyway.
Workflows on new records
const version = await builder.versions
.save(definition.id, graph, {
inputSchema: {
type: "object",
required: ["owner"],
properties: { owner: { type: "string" } },
},
})
.orThrow();
await builder.versions.publish(version.id).orThrow();
await templates.workflows
.attach({
template: fork.id,
definition: definition.id,
inputs: { owner: userId },
})
.orThrow();workflows.attach checks inputs against the inputSchema of the definition's published version: its required keys always, and the whole schema with jsonb-schemas installed (TEMPLATE_WORKFLOW_INPUT_INVALID). apply checks them again, because the definition may have published a new schema since. The definition has to belong to the template's organization or be a system one (TEMPLATE_WORKFLOW_NOT_FOUND), and it has to run on the subject type apply returns. Attaching it again replaces the inputs; detach removes it.
Recurring records
The templates.apply step creates a record from a workflow, so a scheduled workflow builds the monthly invoice or the weekly checklist.
import {
TEMPLATES_APPLY_STEP,
templatesApplyStep,
} from "better-supabase/blocks/templates";
export const stepLibrary = [TEMPLATES_APPLY_STEP, ...otherSteps];
export const steps = {
"templates.apply": templatesApplyStep(templatesFor(postgres.admin)),
...otherHandlers,
};A node's config takes template and optional values, and the step applies the template in the run's organization. It passes <runKey>:<node> to your type's apply as key, so a retried step can find the record it already created instead of creating a second one. It returns { applicationId, subject, runs }. Publishing a graph with the node checks templates.apply.
Merge fields
import {
composeTemplateEmail,
renderMergeFields,
} from "better-supabase/blocks/templates";
renderMergeFields("Hi {{ customer.name }}", { customer: { name: "Ada" } });
invoiceFields.unknown("Total: {{ total }} {{ vat }}"); // ["vat"]
const email = composeTemplateEmail(
{ subject: "Invoice {{ number }}", html: "<p>Hi {{ customer.name }}</p>" },
values,
{ category: "notification", templateKey: "invoice-v3" },
);renderMergeFields replaces {{ name }} with its value and {{{ name }}} with its value unescaped. With escape: "html", {{ name }} is escaped for HTML. A dotted name reads that key first and then the nested path, never an inherited property. A missing value renders as empty, or as the field itself with missing: "keep". Values print as text: dates as ISO 8601, Temporal values with their toString, arrays joined with commas and plain objects as JSON; pass format to change that. It has no dependencies and runs on every WinterTC runtime.
defineMergeFields(subjectType, fields) declares the fields a record type offers, for an editor's field picker: names, examples() for a preview, unknown(text) for the fields a template uses that the catalog doesn't define, and a typed render. composeTemplateEmail renders an email body for the notifications email channel's compose, with the HTML escaped and the subject and text left as text.
Events and hooks
The module records template.published with the versionId, version and type, and template.applied with the templateId, versionId and the new record as its subject, so the record's activity feed shows which template made it. It calls before_template_save, after_template_save, before_template_apply and after_template_apply; hooks in createTemplates wraps the client's methods.
Errors carry these hints: TEMPLATE_INVALID, TEMPLATE_NOT_FOUND, TEMPLATE_FORBIDDEN, TEMPLATE_NO_DRAFT, TEMPLATE_NOT_PUBLISHED, TEMPLATE_WORKFLOW_NOT_FOUND and TEMPLATE_WORKFLOW_INPUT_INVALID.
Tables
| Table | What it holds |
|---|---|
templates | One row per template: organization (none for a system one), type, name, language, default, settings, the fork's source |
template_versions | The versions of each template, with the body and its SHA-256; the draft has no published_at |
template_applications | Which version of which template created which record, by whom |
template_workflows | The workflow definitions each template starts on a new record, with their inputs (with workflow-builder) |
Last updated on
Time tracking
Timers and logged time on any record, one running timer per member and no overlapping entries enforced by Postgres, weekly timesheets approved through the approvals module, locked weeks, and a weekly grid shape for a data grid.
Identities
Link an email address or a chat user to an app user or a contact, through a one-time code the signed-in user issues or a confirmed address.