An application is one TypeScript module built from three pieces:
collection
Named JSON records. An optional schema checks every write.
derive
A synchronous function of records and other derived values. Always private.
query / mutation
Read a snapshot, or make one atomic change. Public only if you list it in http.
A complete example
Save this as examples/counter.ts:
import { collection, define, derive, fail, mutation, query, v } from "@flower-js/sdk";
const counterId = v.string({ min: 1, max: 64 });
const counters = collection("counters", v.int({ min: 0 }));
const doubled = derive("counter.doubled", (ctx, id: string) =>
(ctx.get(counters, id) ?? 0) * 2,
);
const increment = mutation("internal.counter.increment", {
args: v.object({ id: counterId, by: v.int({ min: 1, max: 10 }) }),
}, (ctx, { id, by }) => {
const count = (ctx.get(counters, id) ?? 0) + by;
if (count > 1_000) fail("COUNTER_FULL", "Counters stop at 1,000", { count });
ctx.set(counters, id, count);
ctx.materialize(doubled, id);
return ctx.get(doubled, id); // Includes the write above.
});
const get = query("internal.counter.get", { args: counterId }, (ctx, id) => ({
count: ctx.get(counters, id) ?? 0,
doubled: ctx.get(doubled, id),
}));
const app = define({
definitions: [doubled],
http: { "counter.increment": increment, "counter.get": get },
});
export default app;Deploy it and call it:
node sdk/cli.ts deploy examples/counter.ts
node sdk/cli.ts call counter.increment '{"id":"visits","by":1}' --request-id visit-001
node sdk/cli.ts call counter.get '"visits"'
# value: { count: 1, doubled: 2 }
node sdk/cli.ts call counter.increment '{"id":"visits","by":0}'
# flower: INVALID_ARGUMENT: by: must be at least 1 {"path":["by"]}- Only aliases listed in
httpare public. Everything else stays private. - Aliases don't have to match internal names. Each deploy replaces the whole list.
- List every derived value you read in
definitions. A missing one fails at run time withDEFINITION_MISSING. - Export the module as a named constant: clients and tests take its type with
typeof app.
Check inputs with schemas
Pass args to a method and Flower checks every call before your code runs. The argument type comes from the schema, so there's nothing to annotate.
const line = v.object({
sku: v.string({ pattern: /^[A-Z0-9-]{3,32}$/ }),
quantity: v.int({ min: 1, max: 99 }),
note: v.optional(v.string({ max: 200 })),
});
const place = mutation("order.place", {
args: v.object({ id: v.string({ min: 1 }), lines: v.array(line, { min: 1, max: 50 }) }),
}, (ctx, order) => {
// order: { id: string; lines: { sku: string; quantity: number; note?: string }[] }
return order.lines.length;
});- A bad call fails with
INVALID_ARGUMENT, a readable message such aslines[0].quantity: must be at least 1, anddetails: { path }. Nothing runs. collection(name, schema)checks every write. A bad record fails the mutation withINVALID_RECORD. Records already stored aren't rechecked when the schema changes.- Objects are closed: unknown properties are rejected. Use
v.record(item)orv.object(shape, { rest })for open maps. - Any synchronous Standard Schema, such as zod, works wherever a schema does. It is bundled with your app.
- Without
args, arguments are unchecked JSON with whatever type you annotate. Check them yourself.
Every validator is in the schema reference.
Typed keys and indexes
Keys are strings unless you give the collection a key schema. Tuple keys keep related records together, and indexes find records by field.
const orders = collection("orders", v.object({
shop: v.string(), status: v.enum(["open", "paid"]), cents: v.int({ min: 0 }),
}))
.key(v.tuple([v.string(), v.string()])) // [tenant, orderId]
.index("byShopStatus", ["shop", "status"]);
const openOrders = query("orders.open", { args: v.string() }, (ctx, shop) =>
ctx.query(orders.by("byShopStatus").eq([shop, "open"])));
const tenantOrders = query("orders.tenant", { args: v.string() }, (ctx, tenant) =>
ctx.scan(orders, { prefix: [tenant] })); // [{ key: [tenant, id], value }]
export default define({ collections: [orders], http: { openOrders, tenantOrders } });.key()and.index()each return a new reference. Chain them where you declare the collection and use the result everywhere.- Index names and fields are checked against the record type, and so is the value you pass to
eq. - Typed keys are stored as canonical JSON. A key that doesn't match fails with
INVALID_KEY. Scans decode keys back to tuples. - List indexed collections in
define({ collections }), or reach them through a component. Otherwise the server scans the whole collection, and tests fail withUNDECLARED_INDEX.
Fail with a code
fail(code, message, details?) aborts the call with a failure the caller can act on. A failing mutation commits nothing.
if (shop.stock < quantity) fail("OUT_OF_STOCK", "Not enough dough", { stock: shop.stock });{"error":{"code":"EVALUATION_FAILED","message":"OUT_OF_STOCK: Not enough dough",
"failure":{"code":"OUT_OF_STOCK","message":"Not enough dough","details":{"stock":2}}}}try {
await client.mutate("pizza.order", { id: "o-7", shop: "north", quantity: 3 });
} catch (error) {
if (!(error instanceof FlowerError) || error.failure?.code !== "OUT_OF_STOCK") throw error;
console.log("Sold out", error.failure.details);
}- Codes are
UPPER_SNAKE_CASE; details must be JSON. error.codesays what happened to the request (EVALUATION_FAILED,FORBIDDEN,TRANSACTION_ABORTED).error.failureis the method's own failure, withcode,messageand optionaldetails.- A plain
throwarrives asCOMPUTE_ERRORwith its message. Returning an error-shaped object is a success. - Flower's own codes, such as
INVALID_ARGUMENT, are listed in the failure reference.
What ctx can do
Queries can read; mutations can also write.
| Read operations | Mutation-only operations |
|---|---|
ctx.get(collection, key) | ctx.set(collection, key, value) |
ctx.get(derived, args?) | ctx.delete(collection, key) |
ctx.scan(collection, options?) | ctx.materialize(derived, args?) |
ctx.query(collection.by(index).eq(value)) | ctx.unmaterialize(derived, args?) |
ctx.range(collection.by(index).range({ prefix, lte, limit })) | fail(code, message) aborts the whole mutation. |
ctx.now(), and in methods ctx.principal() | All writes commit together, or none do. |
getreturnsnullfor a missing record.scanreturns{ key, value }rows in key order.- Everything a callback reads is tracked. See Reactive values.
Module setup runs once
When you deploy, Flower runs your module's top level once, including define() and every component, and snapshots the result. Each callback starts from a copy of that snapshot, so imports and setup cost nothing per call.
- Top-level code has no database access and no randomness. Do that work inside callbacks.
- Globals still reset: every callback gets a fresh copy, and changes to module state don't persist.
- A setup heap over 8 MiB isn't snapshotted; callbacks then start from the shared base image and your compiled code.
- To rerun module code in every callback instead, build or deploy with
--initialization per-invocation.