make builds a TiliaQuery from its Config: the query state for one collection, coordinating memory, an optional local store and the authoritative remote.
id — extract a value's unique id.
matches — membership: does this value belong to this query? It must be a pure predicate over one value. Limits, pagination and aggregates do not fit this shape.
sort — return the result sorter for a query. one returns the first value per this order. Default: delivery order.
merge — merge a remote value into its local value in place using the local Change. Return false to keep remote truth and record a conflict.
Queries should be plain data that survives a JSON round trip. The default key needs it, and so does the local purge: persisted query records store the query itself, so matches can run against records whose query is no longer in memory.
The engine owns no timers. Call tick on an interval to drive refresh, expiry, garbage collection and push retries.
one reads the first result of a query — first per the sort given to make.
The read is reactive: inside observe, watch or a component, the caller re-runs when the result changes. Reading the same query from many places shares one entry — it does not multiply fetches.
NotFound means the fetch completed and the result was empty. one is the only reader that answers NotFound — array answers an empty Loaded instead.
Reading a value by id is not a separate API: make the id a query and read it with one.
const first = cards.one({ deck:"es"});if(typeof first ==="object"&& first.state ==="loaded"){console.log(first.data.english, first.fresh);}elseif(first ==="notFound"){console.log("deck is empty");}
switch cards.one({deck:"es"}){|Loaded({data, fresh})=>Console.log2(data.english, fresh)|NotFound=>Console.log("deck is empty")|Loading|NotLocal|Failed(_)=>()}
array reads a query's results, ordered per the sort given to make.
The read is reactive: inside observe, watch or a component, the caller re-runs when the result changes.
array never answers NotFound: an empty result is Loaded with an empty array.
Results include the optimistic overlay — pending writes are reconciled with every remote delivery, so an unconfirmed write never flickers out without either merging or producing a rejection.
All states and the fresh flag behave as described in Loadable.
Write a value optimistically and queue it for the remote.
upsert:(value:T)=>void
upsert:'a => unit
upsert writes a value: it is applied locally, persisted, and its op queued in the outbox for Remote.push. The write is optimistic — local state changes before the remote confirms.
What happens immediately:
Memory and the local store take the new value.
The value joins every in-memory query result whose matches accepts it, and leaves every result it no longer matches — moving a card between decks updates both queries at once.
Both changes reach the affected queries' persisted records. Records that exist only on disk are not scanned; they catch up on the query's next refresh.
The op is appended to the outbox and counts in status.pending.
Edge cases:
If no persisted query record lists the id after the join, a synthetic record keeps the row alive through the local purge. The next purge offers such a row to every persisted query; a match adopts it.
Confirmation replaces the local value with the authoritative one from WriteChannel.set — the server may have corrected it.
A definitive push failure reverts the optimistic value to remote truth and adds a context to status.rejected; resolve or ignore it, then dismiss it.
cards below is the collection from make. See guide chapter Tunnels.
remove deletes a value by id, before the remote confirms. A remove never requires a full value — the op carries only the id.
What happens immediately:
The id leaves every in-memory query result and the persisted query records.
The local row is deleted.
The op queues in the outbox and counts in status.pending, like any write.
Edge cases:
The remote's confirmation (WriteChannel.removed) only clears the op — the local deletion is already complete.
A pending remove keeps overlaying remote deliveries: the id is filtered out of every result until the op confirms.
A stale id left in a query record from an earlier session is harmless: the purge sweep only examines rows that still exist locally, and the next refresh rewrites the record without the id.
cards below is the collection from make. See guide chapter Tunnels.
Report values changed on the server (inbound push).
receive.changed:(values:T[])=>void
changed: array<'a>=> unit
receive.changed is part of Receive. It reports values that changed on the server — an inbound push, typically wired to a websocket or sync feed. Deliveries are facts about the server (past tense), not commands.
Each delivered value is matched against every in-memory query, like an optimistic upsert:
It joins the results it matches and leaves the results it no longer matches.
With no pending write, merge receives Clean and the current local value keeps its identity when the merge succeeds.
With a pending create, update or remove, merge receives the matching Change. A successful merge rebases the pending operation on the remote value. A rejected merge clears the pending operation, shows remote truth and records a conflict in status.rejected.
Retention and freshness:
A delivered value is kept in memory only while some in-memory query matches it, and persisted only while some query record lists it. A value matching nothing is dropped.
Deliveries do not touch freshness: the fresh flag and refresh scheduling stay owned by the per-query read channel (set / live).
status is the collection's reactive Status. It is a tilia object: reading it inside observe, watch or a component tracks it.
pending — number of ops waiting in the outbox. Every upsert / remove counts immediately, including offline writes: remote.push is never called while offline, so the count only drains once online.
rejected — contexts for conflicts and writes the remote definitively refused, keyed by value id (a newer rejection replaces an older one). Resolve or ignore each entry, then remove it with dismiss.
Edge cases:
Read-path errors are not here — a failed fetch shows up as Failed in Loadable, at the read site.
The outbox is durable when a local store is configured: pending ops persist, reload at boot in sequence order, and replay when online. Rejected ops have already left the outbox; rejection contexts are not persisted.
A rejected operation has already left the outbox and remote truth has already replaced the optimistic value. Dismissing only retires the context after the application has resolved or intentionally ignored it; it does not retry a write or change collection data.
Keeping the local version is an ordinary upsert, followed by dismiss. Keeping remote truth only needs dismiss.
tick is the collection's heartbeat. The engine owns no timers: everything time-based happens inside tick, plus reactions to remote.online transitions. Call it on an interval; anything ≤ expiry.refresh / 2 is fine.
One tick can:
Refresh observed queries whose last remote delivery is older than expiry.refresh — except live queries, whose source keeps them fresh.
Flip a stale result to fresh: false (see Loadable) and retry failed non-live queries.
Update observed queries' last-seen time, and evict unobserved queries past expiry.memory — eviction closes the query's fetch, running its finally teardown.
Push pending ops that are not already in flight.
Run the local purge — gated: on the first tick after boot, then at most once per expiry.local / 8 (3.75 days at the default).
Only the purge is gated; refresh checks, last-seen updates and memory expiry run on every tick.
Deterministic JSON serialization — the default query key.
functionsortedStringify(value:unknown):string
let sortedStringify:'a =>string
sortedStringify serializes a value to JSON with sorted keys at every level, so two structurally equal queries produce the same string regardless of key order.
It is the default key in make: the string identifies a query in memory and in the persisted query registry.
Only meaningful on plain data — no functions, no cycles. This is the same constraint the local purge puts on queries anyway: persisted records carry their query through a JSON round trip.
_canopy returns a Canopy showing which queries the engine currently holds in memory, by key:
live — observed right now: something is reading the query's result inside an observer.
idle — cached but unobserved: still in memory, waiting for expiry.memory to evict it.
There is no registration API behind this. Reading a result inside an observer is what keeps a query live; the engine asks tilia's observer graph. This is the same signal tick uses to decide what to refresh and what to evict.
The underscore marks a tooling entry point — meant for debugging, devtools and library authors, not everyday application code.
typet<'query,'a>={
one:'query => loadable<'a>,
array:'query => loadable<array<'a>>,
upsert:'a => unit,
remove:string=> unit,
receive: receive<'a>,
status: status<'a>,
dismiss: rejection<'a>=> unit,
tick: unit => unit,
dispose: unit => unit,
_canopy: unit => canopy,}
TiliaQuery<T, Q>/t<'query, 'a> is the collection object returned by make: one value per collection, holding everything the application touches.
Feature modules typically wrap this object in domain-specific helpers rather than exposing it raw, so application code keeps reading in the language of the business.
Config collects the required collection logic and adaptors, plus the optional cache, timing, identity, ordering and merge settings passed to make.
sort returns the result sorter for a query.
merge receives the local Change and remote value. Merge into the local value in place and return true, or return false to keep remote truth and record a conflict.
Loading — no source has answered yet. A progress state: show a spinner.
Loaded — data, with a fresh flag (see below).
NotFound — a source answered with an empty result. Only one answers it; array answers an empty Loaded.
NotLocal — the offline dead end: nothing cached locally and the remote unreachable. Unlike Loading it is an answer, not progress — show "not available offline", not a spinner.
Failed — the fetch error, carried to the place where the value is read. There is no global error slot to join against.
fresh says whether the data is known-fresh from the remote (true) or served from cache (false). It describes trust, not where the rows physically live.
On tick, a non-live remote result with no delivery within expiry.refresh flips to fresh: false; the next remote delivery flips it back.
While online, the flip waits one extra refresh-check period (expiry.refresh / 8) so an in-flight refresh can land without a flip/flop. Offline, it flips right at the limit.
More edge cases:
NotLocal only appears while offline. While online, local.unknown() leaves the query Loading until the remote responds. local.set([]) is a known empty answer: one returns NotFound and array returns Loaded with an empty array.
A Failed non-live query is not stuck: it re-enters the refresh loop and is retried once per refresh window.
let label =(result:TiliaQuery.loadable<array<card>>)=>switch result {|Loading=>"…"|NotFound=>"not found"|NotLocal=>"not available offline"|Failed({message})=> message
|Loaded({fresh:true})=>"fresh"|Loaded({fresh:false})=>"cached"}
importtype{ Op }from"@tilia/query";constapply=(ops: Op<Card>[])=>
ops.forEach((op)=>
op.op ==="upsert"?console.log("write", op.value.id):console.log("delete", op.id));
let apply =(ops: array<TiliaQuery.op<card>>)=>
ops->Array.forEach(op =>switch op {|Upsert({value})=>Console.log2("write", value.id)|Remove({id})=>Console.log2("delete", id)})
Change is the local context passed to Config.merge when a remote value arrives.
Clean carries the current value when there is no local write.
Created carries a new local value not yet confirmed remotely.
Updated carries the base value and latest local edited value. Together with the remote value, these are the three inputs to a three-way merge.
Removed carries the value deleted locally while its remove is pending.
The merge runs inside Tilia.batch. Mutate the local value in place and return true when the histories reconcile. Return false to show remote truth and record the corresponding Rejection.
Rejection is the context kept when an optimistic operation was reverted — either a conflict (the merge refused a remote value) or a failure (the remote definitively refused the push). Rejections live in status.rejected.
edited is the latest local edit, base the value it started from — or the removed value, for remove variants.
Failed variants carry the remote's message.
The current remote value is already in memory: remote truth is what the queries show, the rejection holds the local side of the story.
At most one rejection is retained per value id: a newer rejection replaces the older.
Keeping your version is an ordinary write — upsert the edited value and it wins like any other write. dismiss retires the context once a human has seen it.
importtype{ Rejection }from"@tilia/query";constdescribe=(r: Rejection<Card>)=>"message"in r ?`refused: ${r.message}`:"two versions of this card";
let describe =(r:TiliaQuery.rejection<card>)=>switch r {|CreateFailed({message})|UpdateFailed({message})|RemoveFailed({message})=>`refused: ${message}`|CreateConflict(_)|UpdateConflict(_)|RemoveConflict(_)=>"two versions of this card"}
typeremote<'query,'a>={
online:Tilia.signal<bool>,
fetch:('query,Channel.read<'a>)=> unit,
push:(array<op<'a>>,Channel.write<'a>)=> unit,}
Remote wires the authoritative store into make. The library owns the lifecycle; the adaptor owns the transport.
online is a tilia signal, owned by the app: set online.value as connectivity changes. The engine reacts to transitions:
Flipping to false settles queries still Loading to NotLocal. An in-flight remote response may still land and is taken as-is; its freshness self-corrects on later ticks.
channel.set with the complete result set (one-shot; the engine refreshes periodically).
Or channel.live when the adaptor keeps the result fresh itself (a subscription): register the teardown with channel.finally, call channel.end when the source shuts down. Going offline does not end a live query — the engine cannot know whether the transport survived, so ending is the adaptor's call.
push receives one ordered batch of every pending op not already in flight, with a WriteChannel:
Confirm each op individually via channel.set / channel.removed.
End with nothing (all confirmed), channel.retry (transient failure) or channel.fail (definitive).
typelocal<'query,'a>={
fetch:('query,Channel.local<'a>)=> unit,
push: array<op<'a>>=> unit,
set:(~tag:string,~key:string, option<string>)=> unit,
get:(~tag:string,~key:string=?,~set: array<string>=> unit)=> unit,
ids:(~set: array<string>=> unit)=> unit,}
Local wires durable storage into make. It is two stores in one adaptor: a typed values table (fetch, push) and a string KV for engine bookkeeping (set, get). Values reach the adaptor typed, so it can store them natively and index them.
Local persistence is command-only — there is no write channel. Confirmation, retry and rejection are remote concepts; a local storage error is the adaptor's own business (log, retry, surface in app state). The library never sees it.
fetch — answer a query from the values table through a LocalChannel: set with results, or unknown when the store cannot answer.
push — apply value changes in order: Upsert writes or replaces the row, Remove drops it.
set — store a bookkeeping entry under a tag and key; None / undefined deletes it.
get — read one entry by key, or every entry for the tag when the key is omitted. Reply through the given set — synchronously or later, like everything else.
ids — reply with the id of every row in the values table. The purge sweep enumerates rows through this.
typeread<'a>={
set: array<'a>=> unit,
live: array<'a>=> unit,
fail:string=> unit,end: unit => unit,
finally:(unit => unit)=> unit,}
ReadChannel is handed to Remote.fetch. Every delivery is the query's complete result set — each call replaces the previous results.
set — publish results. The idiomatic "I am keeping this value fresh" call: invoke it again whenever fresher results arrive. A set-only query is refreshed periodically by the engine.
live — publish results and declare that the adaptor keeps them fresh on its own (e.g. a server subscription). Call it again on every update; expiry.refresh skips a live query.
fail — publish a failed result. It does not close the fetch: a live source may recover by delivering again. A failed non-live query re-enters the refresh loop and is retried once per refresh window.
end — the stream is over. Valid after set or live; not a substitute for fail. It closes the fetch: the registered finally runs, and a live query becomes a normal remote result again, re-entering periodic refresh.
finally — register the fetch's teardown (e.g. unsubscribe a socket). One slot, last write wins.
The teardown contract:
The engine runs the registered finally exactly once, when the fetch closes: on end, when a newer fetch supersedes this one, when the query is evicted from memory, or on dispose.
Registering on an already closed fetch runs the function immediately — a source that ends synchronously inside fetch is still torn down.
Every callback on a closed fetch is a noop. The engine suppresses late replies from ended, superseded or evicted fetches — adaptors do not need to.
typelocal<'a>={
set: array<'a>=> unit,
unknown: unit => unit,}
LocalChannel is handed to Local.fetch. It has exactly two answers:
set — here are the cached results.
unknown — the local storage cannot answer this query.
What the engine does with unknown depends on connectivity:
Online, the query stays Loading until the remote responds.
Offline, it settles to NotLocal — an answer, not progress. See Loadable.
Call set([]) when the store knows the result is empty. Reserve unknown for a store that cannot distinguish an empty result from a query it has never cached.
// This indexed table can answer the query, including with an empty result.fetch:(query: Query, channel: LocalChannel<Card>)=>{
db.cards
.where("deck").equals(query.deck).toArray().then(channel.set);}
// This indexed table can answer the query, including with an empty result.let fetch =(query: query, channel:TiliaQuery.Channel.local<card>)=>
db.cards.filter(card => card.deck=== query.deck)->Promise.thenResolve(channel.set)->ignore
typewrite<'a>={
set:'a => unit,
removed:string=> unit,
retry: unit => unit,
fail:string=> unit,}
WriteChannel is handed to Remote.push together with a batch of ops.
Per-op confirmations — call one per op, matched by the value's id:
set — confirms an upsert. Pass the authoritative value: echo the input, or the server-corrected / conflict-resolved version. Whatever is set replaces the local value and drops the op from the outbox.
removed — confirms a remove, by id.
Batch endings:
Nothing — every op confirmed; the batch is done.
retry — transient failure (offline, timeout). Every op not yet confirmed stays pending and is pushed again on a later tick or when remote.online flips back to true.
fail — definitive refusal. Every op not yet confirmed becomes a Rejection in status.rejected.
The first definitive call wins; everything on the channel is a noop afterwards. Ops confirmed before a fail have already left the outbox and are not rejected.
// Confirm op by op; report the first server error as definitive.constpush=async(ops: Op<Card>[], channel: WriteChannel<Card>)=>{for(const op of ops){try{if(op.op ==="upsert") channel.set(await api.upsert(op.value));else{await api.remove(op.id);
channel.removed(op.id);}}catch(e){return channel.fail(String(e));}}};
// Confirm op by op; report the first server error as definitive.// After a `fail`, the remaining calls on the channel are noops.let push =(ops, channel:TiliaQuery.Channel.write<card>)=>
ops->Array.forEach(op =>switch op {|TiliaQuery.Upsert({value})=>
api.upsert(value)->Promise.thenResolve(result =>switch result {|Ok(card)=> channel.set(card)|Error(error)=> channel.fail(error)})->ignore
|TiliaQuery.Remove({id})=>
api.remove(id)->Promise.thenResolve(result =>switch result {|Ok()=> channel.removed(id)|Error(error)=> channel.fail(error)})->ignore
})
Expiry sets the three timing tiers, all in milliseconds. Memory and local are different tiers, not different strictness: memory is a small RAM cache of the queries being (or recently) observed — seconds to minutes. Local is the durable superset on disk — days to weeks. 100 MB is fine in local; it is not fine in memory.
refresh — interval between refreshes for observed queries. Default: 30_000 (30 s). A query whose channel declared live is skipped — its source keeps it fresh.
memory — how long an unobserved query result stays in RAM. Default: 300_000 (5 min). Eviction only frees memory: the data stays in the local store, and reopening the query re-materializes it from there. Eviction also closes the query's fetch — an unobserved live query keeps its subscription open until this expiry, then its finally teardown runs.
local — how long a query is retained in the local store since its last refresh. Default: 2_592_000_000 (30 days). The local purge runs against it, at most once per local / 8.
All timing takes effect only when tick runs — the engine owns no timers.