Values and callbacks
Flower stores JSON. Callbacks are plain synchronous functions over that JSON.
| API | Contract |
|---|---|
Json | The stored value type: null | boolean | number | string | Json[] | { [key: string]: Json }. Numbers must be finite. Checked at runtime too. |
canonicalJson(value: unknown): string | Serializes 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
nullfor “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.
| API | Contract |
|---|---|
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. |
IndexScalar | null | 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. |
ScanOptions | Options 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. |
RangeOptions | Required 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. |
CollectionManifest | Readonly 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.
| API | Contract |
|---|---|
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. |
AggregateMetadata | Readonly 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);addandremovemust 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.