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, 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
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 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.
| API | Contract |
|---|---|
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): Index | A 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. |
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. |
IndexMap | Index 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. |
ScanOptions | Options 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. |
RangeOptions | Required 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. |
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, 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 withUNDECLARED_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.
| API | Contract |
|---|---|
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. |
AggregateMetadata | Readonly collection and fields. |
AggregateOptions | Required 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 } });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, 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.