Expiring records
expiringCollection from @flower-js/sdk/temporal stores records that stop being readable after a deadline.
| API | Contract |
|---|---|
Expiration | null (never), {at}, {afterCreationMs} or {afterUpdateMs}. Nonnegative integer milliseconds. |
ExpiringEntry<T> | value, createdAt, updatedAt and expiresAt (or null). |
expiringCollection<T = Json>(name, {expiration?} = {}) | Creates the helper. Default expiration is null. Names starting with $flower. are reserved. |
helper.records: Collection<ExpiringEntry<T>> | The backing collection. Reading it directly can return expired entries; use the helper methods instead. |
entry(ctx, key): ExpiringEntry<T> | null | The live entry with timestamps, or null once expired. |
get(ctx, key): T | null | The live value, or null. |
scan(ctx): {key: string, value: T}[] | All live records. Cost includes expired rows that haven’t been swept. |
set(ctx, key, value, expiration?): ExpiringEntry<T> | Writes with this call’s expiration or the default. Keeps createdAt if the old entry is still live; an expired key starts fresh. |
delete(ctx, key): void | Removes the record, live or expired. |
sweep(ctx): number | Deletes expired records in bounded pages and returns the count. Call it from a mutation or maintenance handler. Nothing sweeps automatically. |
- Add
helper.recordstodefine({collections}). - A record is expired when
now >= expiresAt. Reads enforce this even before a sweep. - Expiry doesn’t trigger anything. To act at a deadline, use the scheduler.
Worker leases
workQueue hands each job to one worker at a time and rejects results from a worker whose lease was lost. See the external workers guide for full patterns.
| API | Contract |
|---|---|
workQueue<Payload = Json, Result = Json>(name, {maxLeaseMs, defaultLeaseMs?, scope?}) | Creates a queue. maxLeaseMs is required. defaultLeaseMs defaults to it and can’t exceed it. scope (default "") splits one backing collection into separate queues. |
Lease | owner, token, expiresAt. |
LeaseIdentity | id, owner, token, and optional history. Pass it unchanged from the claim to complete or fail. |
Claim<Payload = Json> | A Lease plus id, payload and attempt. |
Job<Payload = Json, Result = Json> | The stored job: payload, scope, id, state (pending, leased, completed or failed), timestamps, attempts, lease, result, error. |
queue.records: Collection<Job<Payload, Result>> | The backing collection, covering all scopes. Writing it directly can break the queue. |
enqueue(ctx, id, payload, {replaceFinished?} = {}): Job | Adds a pending job. A duplicate ID fails, unless replaceFinished is set and the old job is completed or failed. |
get(ctx, id): Job | null | The job’s current state. An expired lease shows as pending with a LEASE_EXPIRED error. |
scan(ctx): {key: string, value: Job}[] | Every job in this scope, by ID. |
claim(ctx, owner, leaseMs = defaultLeaseMs): Claim | null | Leases the oldest available job (including expired leases) with a new, higher token. Returns null if nothing is available. |
complete(ctx, identity, result): Job | Stores the result if the lease is still current and unexpired. |
fail(ctx, identity, error: Json): Job | Same check, then marks the job failed with the error. |
retry(ctx, id): Job | Moves a failed job back to pending. Only failed jobs can be retried. |
sweep(ctx): number | Returns expired leases to pending and returns the count. Finished jobs stay. |
- Call the writing methods from a mutation. Add
queue.recordstodefine({collections}). - A stale, expired or replaced lease fails with
LEASE_LOST. - Leases can’t be renewed.
- A retried claim returns its original deadline, which may have passed already. Check
expiresAtbefore starting work. - Flower can’t make external side effects exactly-once. Pass the token to external systems and have them reject stale tokens.
- Scopes separate queues. They don’t restrict access.
Delayed callbacks
scheduler from @flower-js/sdk/scheduler runs a mutation after a deadline. The timer commits in the same transaction as the write that schedules it.
| API | Contract |
|---|---|
scheduler(name, handlers, options = {}) | Creates timers. handlers maps names to mutations; they don’t need to be public. Register collections: [timers.records] and maintenance: timers.maintenance in define. |
SchedulerOptions | Optional maxAttempts (3), retryDelayMs (1,000) and maxRetryDelayMs (60,000). |
ScheduledTimer<Args = Json> | state (pending or failed), handler, args, dueAt, attempts, error, createdAt, updatedAt. |
timers.records: Collection<ScheduledTimer> | The backing collection. Don’t write it directly. |
timers.maintenance: {run, onError} | The handlers to register in define. Each run fires one due timer. |
get(ctx, id): ScheduledTimer | null | Reads one timer. |
scan(ctx, state?): (ScheduledTimer & {id: string})[] | Lists timers, optionally by state, ordered by dueAt then ID. |
after(ctx, id, delayMs, handler, args): ScheduledTimer | Schedules delayMs after ctx.now(). Reusing an ID replaces the earlier timer (debounce). |
at(ctx, id, dueAt, handler, args): ScheduledTimer | Schedules at an absolute time in milliseconds. A past time runs at the next chance. |
cancel(ctx, id): boolean | Deletes a timer. Returns whether it existed. |
retry(ctx, id, delayMs = 0): ScheduledTimer | Reschedules a failed timer with a fresh attempt count, using the currently deployed handler. |
MaintenanceFailure | error: {code, message} and failedAt, passed to the error handler after the failed run is rolled back. |
MaintenanceContinuation | Return {$flower: {continue: true}} to run again right away while work remains. |
MaintenanceHandlers | run and onError mutations. |
MaintenanceManifest | Readonly name, kind: "mutation", optional onError. |
The order and its timer commit together
import { collection, define, mutation } from "@flower-js/sdk";
import { scheduler } from "@flower-js/sdk/scheduler";
const orders = collection<{ state: string }>("orders");
const bake = mutation("internal.bake", (ctx, id: string) => {
const order = ctx.get(orders, id);
if (order) ctx.set(orders, id, { ...order, state: "ready" });
return null;
});
const timers = scheduler("oven", { bake });
const place = mutation("orders.place", (ctx, id: string) => {
ctx.set(orders, id, { state: "baking" });
timers.after(ctx, `bake:${id}`, 5_000, "bake", id);
return { id };
});
export default define({ collections: [timers.records], http: { place }, maintenance: timers.maintenance });- A deadline means “not before”. Load, elections and outages can delay a timer.
- If the handler succeeds, its writes and the timer’s removal commit together.
- If it fails, its writes roll back and it retries with doubling delay, up to
maxAttempts. Then it stays as a failed timer you can inspect and retry. - A handler can run more than once before it commits. Put external side effects in a work queue.
- By default the server checks for due timers every 250 ms. That’s an operator setting.