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, equality and typed keys. -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 copy of your initialized module. Changes to module globals don’t persist between calls.

Collections and indexes

A collection is a named set of JSON records. Keys are strings, or JSON values checked by a key schema. Indexes find records by field.

APIContract
collection<T = Json>(name): Collection<T>
collection(name, schema): Collection<Infer<S>>
A reference to a collection; it doesn’t contact a server. With a schema, T comes from it and every write is checked: a bad record fails with INVALID_RECORD and details: { collection, key, path }. Missing records read as null.
ref.key(schema): Collection<T, K>A new reference whose keys are JSON values, usually tuples, stored as canonical JSON. A key that fails the schema fails with INVALID_KEY. Scans and ranges return decoded keys.
ref.index(name, fields): Collection<T, K, I>A new reference with one more index on one or more fields of T. Duplicate index names are rejected. Chain it where you declare the collection and use the result.
ref.by(name): IndexA declared index. Unknown names are type errors, and throw TypeError at run time.
Index<T, K, F>eq(value): an equality Query; for several fields, pass a tuple in field order. range(options): an ordered RangeQuery.
Collection<T = Json, K = string, I = {}>Readonly kind: "collection", name and indexes, plus key, index and by. A definition, not a client-side table.
Query<T, K>Readonly kind: "query", collection, fields and value. Pass it to ctx.query.
RangeQuery<T, K>Readonly kind: "range", collection, fields and options. Pass it to ctx.range.
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.
IndexMapIndex names to field lists: the I of a collection.
FieldOf<T>The field names an index may use: the string keys of T.
IndexValues<T, F>The value types of fields F, as a tuple.
EqualityValue<T, F>What eq and aggregate groups take: the field’s type for a one-field index, otherwise IndexValues.
ScanOptionsOptions for ctx.scan: index (a declared index; omit to walk keys), prefix, one lower bound (gt/gte) and one upper bound (lt/lte) on the next field, reverse, offset and limit. Typed-key collections take a tuple prefix without an index, but reject key bounds with INVALID_SCAN.
RangeOptionsRequired positive limit. Optional prefix (leading field values, typed), bounds, reverse, and after (a cursor from the previous page).
Row<T, K>{ key, value }, with a decoded key for typed-key collections.
RangePage<T, K>rows: Row[] 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, v } from "@flower-js/sdk";

const orders = collection("orders", v.object({
  tenant: v.string(), state: v.enum(["pending", "paid"]), cents: v.int({ min: 0 }),
})).index("byTenantState", ["tenant", "state"]);

const pending = query("orders.pending", { args: v.string() }, (ctx, tenant) =>
  ctx.query(orders.by("byTenantState").eq([tenant, "pending"])));

export default define({ collections: [orders], http: { pending } });
  • Declare indexed collections: in define({ collections }), in a component, or as the source of an aggregate or trigger. Otherwise queries fall back to scanning, and test databases fail with UNDECLARED_INDEX.
  • A collection name must always carry the same indexes; declaring it twice with different ones is rejected.
  • 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 the first due timer 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(name, options): Aggregate<G, V>A derived value per group, where the group is a value of one index: the field’s type, or a tuple for several fields. Read it with ctx.get(agg, group). List it in definitions; its source collection is declared for you.
Aggregate<G, V>A Derived<G, V> with readonly aggregate: AggregateMetadata.
AggregateMetadataReadonly collection and fields.
AggregateOptionsRequired source, index (a declared index name), initial(group), add(value, row, key, group) and remove(value, row, key, group). Keys are decoded for typed-key collections.
Maintain a sum from changed rows
import { aggregate, collection, define, query, v } from "@flower-js/sdk";

const lines = collection("lines", v.object({ shop: v.string(), cents: v.int() }))
  .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,
});
const read = query("internal.shop.total", { args: v.string() }, (ctx, shop) => ctx.get(total, shop));

export default define({ definitions: [total], http: { "shop.total": read } });
  • 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, or a value that reads it, 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.