Reference · 02

Values and collections.

What a value can be, how collections and indexes work, and incremental totals.

Values and callbacks

Flower stores JSON. Callbacks are plain synchronous functions over that JSON.

APIContract
JsonThe stored value type: null | boolean | number | string | Json[] | { [key: string]: Json }. Numbers must be finite. Checked at runtime too.
canonicalJson(value: unknown): stringSerializes with sorted object keys; used for identity and equality. -0 becomes 0. Throws TypeError for anything that isn’t plain JSON (cycles, depth over 128, BigInt, undefined, class instances, and so on).
  • Encode BigInt, Date, Map, Set and typed arrays as JSON yourself. Use safe integers (such as cents) when you need exact math.
  • Return a JSON value, never a Promise or undefined. Use null for “nothing”.
  • Callbacks have no filesystem, network, timers or wall clock. Use ctx.now() for time and external workers for side effects.
  • Each callback starts from a fresh, isolated image. Module globals don’t persist between calls.

Collections and indexes

A collection is a named set of JSON records keyed by string. Indexes let you look records up by field.

APIContract
collection<T = Record<string, Json>>(name: string): Collection<T>Returns a reference to a collection; it doesn’t contact a server. Keys are nonempty strings. Missing records read as null.
ref.index(name, fields): Collection<T>Declares an index on one or more fields and returns the same reference. Duplicate index names are rejected. Declare indexes before define().
ref.by(name).eq(value: Json): Query<T>Equality query on an index. For a multi-field index, pass an array in field order. Unknown index or wrong length throws TypeError.
Collection<T>Readonly kind: "collection", name and indexes, plus index and by. A definition, not a client-side table.
Query<T>Readonly kind: "query", collection, fields and value.
ref.by(name).range(options): RangeQuery<T>Ordered, limited query on an index. Declare the collection in define({collections}) so it uses a stored index instead of a scan.
IndexScalarnull | boolean | number | string. Sort order: null, false, true, numbers, then strings. Ties break by record key. Records with a missing or non-scalar field are left out of ordered scans.
ScanOptionsOptions for ctx.scan: index (omit to walk keys), prefix, one lower bound (gt/gte) and one upper bound (lt/lte) on the next field, reverse (default false), offset (default 0) and limit (default all). Unknown options are rejected.
RangeOptionsRequired positive limit. Optional prefix, bounds, reverse, and after (a cursor from the previous page).
RangeQuery<T>Readonly kind: "range", collection, fields and options.
RangePage<T>rows: {key, value}[] and cursor: string | null; null means no more rows. Pass the cursor as after with the same query. Rows edited between pages can be skipped or repeated.
CollectionManifestReadonly name and indexes: the collection schema as stored in a deployed module.
Declare the schema as part of the application
import { collection, define, query } from "@flower-js/sdk";

const orders = collection<{ tenant: string; state: string; cents: number }>("orders")
  .index("byTenantState", ["tenant", "state"]);
const pending = query("orders.pending", (ctx, tenant: string) =>
  ctx.query(orders.by("byTenantState").eq([tenant, "pending"])));

export default define({ collections: [orders], http: { pending } });
  • List indexed collections in define({ collections }). Otherwise queries fall back to scanning.
  • Indexes don’t enforce uniqueness. A missing field doesn’t match null.
  • Rows and their index entries commit together. Queries inside a mutation see that mutation’s writes.
  • Deploying a new index builds it in one step. For large collections, use staged deployment.

Example: find due timers with ctx.range(timers.by("due").range({prefix: ["pending"], lte: ctx.now(), limit: 1})). More detail is in INDEXES.md.

Running totals

An aggregate keeps a per-group value, such as a sum, and updates it from each changed row. It doesn’t reread the whole group.

APIContract
aggregate<Row, Value>(name, options: AggregateOptions<Row, Value>): Aggregate<Value>A derived value per group, where the group is the value of one index. Read it with ctx.get(agg, group).
Aggregate<T = Json>A Derived<Json, T> with readonly aggregate: AggregateMetadata.
AggregateMetadataReadonly collection and fields.
AggregateOptions<Row, Value>Required source, index, initial(group), add(value, row, key, group) and remove(value, row, key, group). The source and index must be declared in the module.
Maintain a sum from changed rows
import { aggregate, collection } from "@flower-js/sdk";

const lines = collection<{ shop: string; cents: number }>("lines")
  .index("byShop", ["shop"]);
const total = aggregate("shop.total", {
  source: lines, index: "byShop",
  initial: () => 0,
  add: (sum, row) => sum + row.cents,
  remove: (sum, row) => sum - row.cents,
});
// Register collections: [lines], definitions: [total].
// In a mutation: ctx.materialize(total, shop);
// In any callback: ctx.get(total, shop);
  • add and remove must be deterministic, work in any order, and undo each other. Flower can’t check this for you.
  • Use integer units for exact totals. Floating-point sums can drift.
  • An update removes the old row and adds the new one, so moving a row between groups updates both.
  • Materialize an aggregate to keep it maintained. Unmaterialized ones may rebuild on every read. Redeploying rebuilds them.
  • If a reducer throws, the error is stored and the next relevant change rebuilds from scratch.