Guide · 04

Build from parts.

A component bundles collections, definitions, background tasks and triggers. Plug it into define({ uses }) and it just works.

Most of Flower's helpers are components: the scheduler, work queues, expiring collections and external values. You can write your own the same way, and define puts every part where it belongs.

Package parts together

This component keeps shopping carts. A task deletes carts a day after they open, and a trigger deletes a cart's lines whenever the cart goes, whoever deleted it.

carts.ts
import { collection, component, define, mutation, query, task, trigger, v } from "@flower-js/sdk";

const DAY = 86_400_000;
const carts = collection("carts", v.object({ openedAt: v.int() })).index("byOpened", ["openedAt"]);
const lines = collection("cartLines", v.object({ quantity: v.int({ min: 1 }) }))
  .key(v.tuple([v.string(), v.string()])); // [cart, sku]

const expire = task("carts.expire", {
  due: (ctx) => {
    const oldest = ctx.range(carts.by("byOpened").range({ limit: 1 })).rows[0];
    return oldest ? oldest.value.openedAt + DAY : null;
  },
  run: (ctx) => {
    const stale = ctx.range(carts.by("byOpened").range({ lte: ctx.now() - DAY, limit: 64 })).rows;
    for (const cart of stale) ctx.delete(carts, cart.key);
    return { expired: stale.length };
  },
});

const cascade = trigger("carts.cascade", carts, (ctx, { key, after }) => {
  if (after === null) for (const line of ctx.scan(lines, { prefix: [key] })) ctx.delete(lines, line.key);
});

export const shoppingCarts = component({ collections: [carts, lines], tasks: [expire], triggers: [cascade] });

const add = mutation("cart.add", {
  args: v.object({ cart: v.string({ min: 1 }), sku: v.string({ min: 1 }), quantity: v.int({ min: 1 }) }),
}, (ctx, { cart, sku, quantity }) => {
  if (!ctx.get(carts, cart)) ctx.set(carts, cart, { openedAt: ctx.now() });
  ctx.set(lines, [cart, sku], { quantity });
  return null;
});
const view = query("cart.view", { args: v.string({ min: 1 }) }, (ctx, cart) =>
  ctx.scan(lines, { prefix: [cart] }).map(({ key, value }) => ({ sku: key[1], ...value })));

const app = define({ uses: [shoppingCarts], http: { "cart.add": add, "cart.view": view } });
export default app;
  • component({ uses, collections, definitions, tasks, triggers, keys }) takes the same parts as define, except http and auth.
  • define({ uses }) gathers every component it reaches, including nested uses. A component reached twice is included once.
  • Components never add public methods. Choose what to expose in http, where helpers such as jobs.http("jobs") can be spread.
  • Names are global: two different definitions or two tasks with the same name are rejected, and so are two triggers with one name on the same collection, or one collection declared with different indexes. Names starting with $flower. are reserved.

Run work in the background

A task says when it next has work, and does one bounded piece of it:

  • due(ctx) returns the earliest time the task has work, or null. It reads the database like a query, so it can depend only on data and time.
  • run(ctx) does one unit of work as a mutation and returns JSON. The task stays eligible while due is in the past, so handle a page, such as 64 rows, and return.
  • onError(ctx, { error, failedAt }) is optional. It runs in place of a failed run, against the same snapshot, and its writes commit instead.

define combines every task (yours, your components', and those behind materialize) into one maintenance handler. You never write that handler yourself.

  • Each maintenance run picks the task whose due time is earliest and runs it in its own commit.
  • The leader calls maintenance every 250 ms, and keeps going while work is due, for up to 50 ms at a time. Both are operator settings.
  • If a run fails and the task has no onError, that task alone backs off: 1 s, doubling up to 60 s. Other tasks keep running, and the next success clears the backoff.
  • error.code is the real failure: your fail() code, COMPUTE_ERROR for a plain throw, or EVALUATION_BUDGET.
  • A run can be evaluated more than once before it commits. Send external side effects through a queue.

React to changed rows

A trigger runs inside every mutation that changed a row of its collection, once per changed key, just before the commit. It gets { key, before, after }; before is null for a new row and after is null for a deleted one.

  • It fires for every writer: methods, tasks, scheduled handlers and transaction participants. Its writes commit with the change.
  • A row changed and then changed back within one mutation doesn't fire.
  • Triggers may write other rows and fire other triggers. After 32 rounds the mutation fails with TRIGGER_LOOP.
  • A trigger's collection is declared for you.

Built-in components

Components included in the SDK
HelperWhat it adds to define({ uses })
scheduler(name, handlers)A timer collection and a task that runs due handlers, with retries.
queue(name, options)A job collection, fencing counters and a task that reclaims expired leases.
expiringCollection(name)A collection and a task that deletes expired records.
external(name, options)Result collections, derived values and, with each, a trigger that tracks stale rows.

Pass them straight to uses: define({ uses: [timers, jobs, digest] }). Materialization policies are added by define itself.