Guide · 02

Your code is the API.

An app is collections of JSON records, values derived from them, and methods that read or change them. Schemas check what comes in; only the methods you list are public.

An application is one TypeScript module built from three pieces:

SOURCE

collection

Named JSON records. An optional schema checks every write.

DEPENDENCY

derive

A synchronous function of records and other derived values. Always private.

BOUNDARY

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:

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:

Deploy and call
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 http are 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 with DEFINITION_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.

A schema for method arguments
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 as lines[0].quantity: must be at least 1, and details: { path }. Nothing runs.
  • collection(name, schema) checks every write. A bad record fails the mutation with INVALID_RECORD. Records already stored aren't rechecked when the schema changes.
  • Objects are closed: unknown properties are rejected. Use v.record(item) or v.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.

Tuple keys and a typed index
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 with UNDECLARED_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.

Inside a mutation
if (shop.stock < quantity) fail("OUT_OF_STOCK", "Not enough dough", { stock: shop.stock });
What an HTTP caller receives · 422
{"error":{"code":"EVALUATION_FAILED","message":"OUT_OF_STOCK: Not enough dough",
  "failure":{"code":"OUT_OF_STOCK","message":"Not enough dough","details":{"stock":2}}}}
What the TypeScript client sees
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.code says what happened to the request (EVALUATION_FAILED, FORBIDDEN, TRANSACTION_ABORTED). error.failure is the method's own failure, with code, message and optional details.
  • A plain throw arrives as COMPUTE_ERROR with 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.

Database context operations
Read operationsMutation-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.
  • get returns null for a missing record.
  • scan returns { 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.