Values and queries must not be null or undefined. Queries, and values used
with the shipped store, must survive a JSON round trip unchanged: query
records, persisted rows, and rejection snapshots all rely on it, even when
the keyspace is only in memory. The backend must preserve client-supplied ids.
Example
import{ make, Store }from"@tilia/query";typeCard={ id:string; deck:string; front:string; back:string; seen:number};typeDeckQuery={ deck:string};const[cards, store]=make({id:(card: Card)=> card.id,matches:(query: DeckQuery, card: Card)=> card.deck === query.deck,
store: Store.make<Card, DeckQuery>({ find, upsert, remove }),});// Two handles, two verbs. A view reads through `cards`; a review writes// through `store`, and every open deck that lists the card follows.const spanish = cards.array({ deck:"spanish"});
store.upsert({ id:"cat.es", deck:"spanish", front:"cat", back:"gato", seen:1});
openTiliaQuerytypecard={id:string, deck:string, front:string,mutable back:string,mutable seen:int}typedeckQuery={deck:string}let(cards, store)=make({
id: card => card.id,
matches:(query, card)=> card.deck=== query.deck,
store:Store.make({find, upsert, remove}),})// Two handles, two verbs. A view reads through `cards`; a review writes// through `store`, and every open deck that lists the card follows.let spanish = cards.array({deck:"spanish"})
store.upsert({id:"cat.es", deck:"spanish", front:"cat", back:"gato", seen:1})
Store.make is the default high-level backend adapter. Its StoreConfig supplies one query operation and one operation for each write kind; the result is a StoreFactory for the root make.
Writes are issued one at a time in outbox order, each waiting for its Outcome or Removal. This preserves cascades and lets a transient response stop the batch before later operations are sent.
Example
// Three translations around an existing HTTP client. Ordering, batching and// retry are the store's; this code only says what the backend answered.const factory = Store.make<Card, DeckQuery>({find:(query, answer)=>
api.get(`/decks/${query.deck}`).then(answer.fresh,(error: ApiError)=> answer.fail(error.message)),upsert:(card, reply)=>
api.put(`/cards/${card.id}`, card).then((saved)=>reply({ outcome:"saved", value: saved }),(error: ApiError)=>{if(error.status ===409)reply({ outcome:"conflict", value: error.body });elseif(error.status >=500)reply("transient");elsereply({ outcome:"rejected", message: error.message });},),remove:(id, reply)=>
api.delete(`/cards/${id}`).then(()=>reply("removed"),(error: ApiError)=>reply(error.status >=500?"transient":{ outcome:"rejected", message: error.message }),),});
// Three translations around an existing HTTP client. Ordering, batching and// retry are the store's; this code only says what the backend answered.let factory =Store.make({
find:(query, answer)=>Api.get(`/decks/${query.deck}`)->Promise.thenResolve(result =>switch result {|Ok(cards)=> answer.fresh(cards)|Error(message)=> answer.fail(message)})->ignore,
upsert:(card, reply)=>Api.put(`/cards/${card.id}`, card)->Promise.thenResolve(result =>switch result {|Ok(saved)=>reply(Saved({value: saved}))|Error(Stale(theirs))=>reply(Conflict({value: theirs}))|Error(Refused(message))=>reply(Rejected({message: message}))|Error(Unavailable)=>reply(Transient)})->ignore,
remove:(id, reply)=>Api.delete(`/cards/${id}`)->Promise.thenResolve(result =>switch result {|Ok()=>reply(Removed)|Error(Refused(message))=>reply(Rejected({message: message}))|Error(Stale(_))=>reply(Rejected({message:"changed on the server"}))|Error(Unavailable)=>reply(Transient)})->ignore,})
Store.custom is the lower-level adapter for a backend already expressed as Remote read and write channels. It adds the shipped store's cache, outbox, merge, query registry, persistence, and application Store handle.
Use Store.make when the backend is more naturally described as individual find, upsert, and remove operations.
Example
// `custom` is for a backend that pushes. This fetch subscribes and keeps the// result fresh itself; `finally` is where the subscription ends, whether the// query was evicted, superseded or the app disposed.const factory = Store.custom<Card, DeckQuery>({
remote:{
online: Store.online(),fetch:(query, channel)=>{const feed = socket.subscribe(query, channel.live);
channel.finally(feed.close);},
push,},
persist:indexeddb({ name:"flashcards"}),});
// `custom` is for a backend that pushes. This fetch subscribes and keeps the// result fresh itself; `finally` is where the subscription ends, whether the// query was evicted, superseded or the app disposed.let factory =Store.custom({
remote:{
online:Store.online(),
fetch:(query, channel)=>{let feed = socket.subscribe(query, channel.live)
channel.finally(feed.close)},
push,},
persist:TiliaQueryIndexedDb.make({name:"flashcards"}),})
Store.memory creates the in-memory Kv used when persist is omitted. It follows the same storage path as a durable keyspace but forgets all entries with the process.
Example
// A test builds the app on a keyspace that forgets with the process. Rows,// query records and the outbox go down the same path as with IndexedDB, so// what the test proves about persistence holds in production.const[cards, store]=make({id:(card: Card)=> card.id,matches:(query: DeckQuery, card: Card)=> card.deck === query.deck,
store: Store.make<Card, DeckQuery>({ find, upsert, remove, persist: Store.memory()}),});
// A test builds the app on a keyspace that forgets with the process. Rows,// query records and the outbox go down the same path as with IndexedDB, so// what the test proves about persistence holds in production.let(cards, store)=make({
id: card => card.id,
matches:(query, card)=> card.deck=== query.deck,
store:Store.make({find, upsert, remove, persist:Store.memory()}),})
TypeScript exports make from @tilia/query/indexeddb; ReScript exposes it as TiliaQueryIndexedDb.make. It creates a Kv using one object store and compound [tag, key] keys, so each tag is an IndexedDB range.
Calls made before the database opens wait for it. Writes are batched into one transaction, and any queued writes are flushed before a read answers.
Example
import{ Store }from"@tilia/query";import{ make as indexeddb }from"@tilia/query/indexeddb";// One database for the app, opened on first use; a read or write made before// it opens waits for it. `onError` is the only place a database that will not// open makes itself known: reads answer empty and writes are dropped.const persist =indexeddb({
name:"flashcards",onError:(error)=>console.error("persistence lost", error),});const factory = Store.make<Card, DeckQuery>({ find, upsert, remove, persist });
// One database for the app, opened on first use; a read or write made before// it opens waits for it. `onError` is the only place a database that will not// open makes itself known: reads answer empty and writes are dropped.let persist =TiliaQueryIndexedDb.make({
name:"flashcards",
onError: error =>Console.error2("persistence lost", error),})let factory =Store.make({find, upsert, remove, persist})
one reads the first value after the configured sort. The read is reactive and shares the query's cached entry with every other reader.
An empty complete answer becomes NoData({NoMatch({claim})}); array instead returns a loaded empty array. The absence carries its Claim, so it can age like any other answer.
Example
// The card under review: the first of the deck per `sort`. A deck with nothing// left is `noMatch`, and it says so with the claim its rows would have had, so// the view can tell "done" from "done as of last sync".constreview=()=>{const next = cards.one({ deck:"spanish"});if(next ==="loading")returnspinner();if(next.state ==="loaded")returnflashcard(next.data);if(next.reason ==="offline")returnofflineNotice();return next.reason.reason ==="noMatch"?deckDone(next.reason.claim):failure(next.reason.message);};
// The card under review: the first of the deck per `sort`. A deck with nothing// left is `NoMatch`, and it says so with the claim its rows would have had, so// the view can tell "done" from "done as of last sync".let review =()=>switch cards.one({deck:"spanish"}){|Loading=>spinner()|Loaded({data: card})=>flashcard(card)|NoData({reason:Offline})=>offlineNotice()|NoData({reason:NoMatch({claim})})=>deckDone(claim)|NoData({reason:Failed({message})})=>failure(message)}
array reads all values matching a query, ordered by the configured sort. The read is reactive and includes the optimistic overlay from pending store writes.
A complete empty answer is data: { state: "loaded", data: [], claim }. An empty partial answer remains "loading" while remote data is possible and becomes offline NoData when it is not.
Example
// The deck list. `claim` is a field, not a state: the common path renders// whatever is there, and only the badge asks how strong the answer is. An// empty deck is a loaded empty list, not an absence.constdeckView=()=>{const deck = cards.array({ deck:"spanish"});if(deck ==="loading")returnspinner();if(deck.state ==="noData")returnempty(deck.reason);returnlist(deck.data, deck.claim ==="fresh"?null:staleBadge(deck.claim));};
// The deck list. `claim` is a field, not a state: the common path renders// whatever is there, and only the badge asks how strong the answer is. An// empty deck is a loaded empty list, not an absence.let deckView =()=>switch cards.array({deck:"spanish"}){|Loading=>spinner()|NoData({reason})=>empty(reason)|Loaded({data, claim:Fresh})=>list(data,None)|Loaded({data, claim})=>list(data,Some(staleBadge(claim)))}
tick runs all time-based work; the engine owns no timers. Call it at least twice per configured refresh interval.
The engine first refreshes observed non-live queries, lowers aged claims, updates observation times, and evicts idle queries past Expiry.memory. It then calls the store heartbeat, which handles local purge and push retries. Expiry and StoreExpiry configure the two halves independently.
Example
// The engine owns no timers. One interval drives refresh, ageing, eviction,// local purge and push retries. Every 10 s lands three times inside the// default 30 s refresh window, which is the "at least twice" the rule asks.const timer =setInterval(cards.tick,10_000);// A test owns time instead: no interval, a tick where the scenario says so.
clock.advance(35_000);
cards.tick();
// The engine owns no timers. One interval drives refresh, ageing, eviction,// local purge and push retries. Every 10 s lands three times inside the// default 30 s refresh window, which is the "at least twice" the rule asks.let timer =setInterval(cards.tick,10_000)// A test owns time instead: no interval, a tick where the scenario says so.
clock.advance(35_000)
cards.tick()
dispose stops connectivity observation, closes every open find, runs each registered finally teardown once, and then disposes the store. The engine completes its own shutdown before calling the store, so no find remains in flight against a stopped store.
Cached data follows normal expiry and nothing is written during disposal. Calls after the first are safe. Stop any application timer that calls tick separately.
Example
// Leaving the page. Stop the heartbeat first, then let the engine close its// finds — each `finally` teardown runs once — and shut the store down.// Nothing is written on the way out: local data keeps to its own expiry, so// the next visit starts from it.constleave=()=>{clearInterval(timer);
cards.dispose();};
window.addEventListener("pagehide", leave);
// Leaving the page. Stop the heartbeat first, then let the engine close its// finds — each `finally` teardown runs once — and shut the store down.// Nothing is written on the way out: local data keeps to its own expiry, so// the next visit starts from it.let leave =()=>{clearInterval(timer)
cards.dispose()}Window.addEventListener("pagehide", leave)
Optimistically write a value through the store handle.
upsert:(value:T)=>void
upsert:'a => unit
upsert belongs to the Store handle returned beside the query engine. It places the value immediately, updates every in-memory query whose matches result changed, persists the new optimistic state, and queues an ordered outbox operation.
Writing an id clears its current rejection. A saved confirmation replaces the
value with authoritative server data. A conflict is offered to merge: an
accepted merge rebases the write and keeps it pending, while a declined or
missing merge keeps remote truth and records a conflict
Rejection. A definitive rejection reverts the
optimistic change and records its message.
Example
// Alice passes a card in a tunnel. The deck view moves on now — the row is// placed and every open query that lists it follows — while the write waits// in the outbox and goes when the train comes out.constpass=(card: Card)=> store.upsert({...card, seen: card.seen +1});// A new card is the same call. The id is the client's, and the backend keeps it.constadd=(deck:string, front:string, back:string)=>
store.upsert({ id: crypto.randomUUID(), deck, front, back, seen:0});
// Alice passes a card in a tunnel. The deck view moves on now — the row is// placed and every open query that lists it follows — while the write waits// in the outbox and goes when the train comes out.let pass = card => store.upsert({...card, seen: card.seen+1})// A new card is the same call. The id is the client's, and the backend keeps it.let add =(~deck,~front,~back)=> store.upsert({id:newId(), deck, front, back, seen:0})
Optimistically remove a value through the store handle.
remove:(id:string)=>void
remove:string=> unit
remove belongs to the Store handle. It removes the id from in-memory queries and persistence immediately, then queues an outbox operation containing only the id.
The optimistic removal continues to overlay incoming query answers until
confirmed. A conflict is offered to merge: an accepted merge updates the
saved base and keeps the remove pending, while a declined or missing merge
keeps remote truth and records the removed value as the rejection's base.
A definitive rejection restores and records the base when one is known; an
unknown-id remove has no value from which to make a rejection. Writing the
same id clears its current rejection.
Example
// The card leaves every open deck at once, and the outbox carries only its// id. If the server refuses, the card comes back, with a rejection that holds// the removed value as `base`.constdrop=(card: Card)=> store.remove(card.id);
// The card leaves every open deck at once, and the outbox carries only its// id. If the server refuses, the card comes back, with a rejection that holds// the removed value as `base`.let drop = card => store.remove(card.id)
receive.changed accepts authoritative facts from a subscription or other server push. Values join and leave matching in-memory queries and replace clean persisted copies.
When a value has a pending local operation, the configured merge receives its Change. Returning true rebases the pending edit after mutating the local value in place; returning false keeps remote truth, clears the operation, and records a conflict. Deliveries do not change query freshness.
Example
// Nora edits a card on her phone and the server tells every device. Clean// rows are replaced. A card Alice is editing offline is not overwritten: it// goes through `merge`, and her edit is rebased or recorded as a conflict.
socket.on("cards:changed",(changed: Card[])=> store.receive.changed(changed));
// Nora edits a card on her phone and the server tells every device. Clean// rows are replaced. A card Alice is editing offline is not overwritten: it// goes through `merge`, and her edit is rebased or recorded as a conflict.
socket.on("cards:changed", changed => store.receive.changed(changed))
receive.removed accepts ids deleted by the server. Each id leaves matching in-memory queries and is deleted from persistence.
A server removal conflicts with a pending create or update: the operation is cleared and a rejection is recorded. It confirms and clears a pending remove. Deliveries do not change query freshness.
Example
// Ids only: a deletion never needs the row. A pending edit to a card the// server removed becomes a rejection instead of bringing the card back.
socket.on("cards:removed",(ids:string[])=> store.receive.removed(ids));
// Ids only: a deletion never needs the row. A pending edit to a card the// server removed becomes a rejection instead of bringing the card back.
socket.on("cards:removed", ids => store.receive.removed(ids))
status is the reactive Status on the Store handle.
pending counts outbox operations waiting for confirmation, including offline writes.
rejected lists reverted conflicts and definitive write failures in outbox order, regardless of reply order.
Read failures are represented by Loadable, not by status.
Example
// A sync indicator in the header. `status` is a tilia object, so `observe`// re-runs as the outbox drains and as rejections come and go.observe(()=>{const{ pending, rejected }= store.status;
syncBadge.textContent = pending >0?`${pending} to sync`:"";
syncBadge.hidden = pending ===0&& rejected.length ===0;});
// A sync indicator in the header. `status` is a tilia object, so `observe`// re-runs as the outbox drains and as rejections come and go.Tilia.observe(()=>{let{pending, rejected}= store.status
syncBadge.textContent= pending >0?`${pending->Int.toString} to sync`:""
syncBadge.hidden= pending ===0 && rejected->Array.length===0})->ignore
retry removes the exact Rejection, reapplies its edit as an ordinary optimistic write against the value that exists now, and queues a copied operation.
It acts only when the same rejection record is still present. A record already cleared or replaced is a no-op. Retrying a remove queues another remove; retrying a create or update queues the recorded edited value.
Example
// The rejections panel. "Try again" queues the refused work as a new// optimistic write against the row as it stands now. The record is spent// either way: if the row has moved on since, `retry` on it is a no-op.for(const rejection of store.status.rejected){
panel.row(describe(rejection),{tryAgain:()=> store.retry(rejection)});}
// The rejections panel. "Try again" queues the refused work as a new// optimistic write against the row as it stands now. The record is spent// either way: if the row has moved on since, `retry` on it is a no-op.
store.status.rejected->Array.forEach(rejection =>
panel.row(describe(rejection),~tryAgain=()=> store.retry(rejection)))
discard removes the exact Rejection without changing rows or queueing work. Remote truth already stands when a rejection is created.
It acts only when the same rejection record is still present. A record already cleared or replaced is a no-op.
Example
// The panel's other button. Remote truth already stands — a rejection is// recorded after the revert — so "keep theirs" only drops the record.for(const rejection of store.status.rejected){
panel.row(describe(rejection),{keepTheirs:()=> store.discard(rejection)});}
// The panel's other button. Remote truth already stands — a rejection is// recorded after the revert — so "keep theirs" only drops the record.
store.status.rejected->Array.forEach(rejection =>
panel.row(describe(rejection),~keepTheirs=()=> store.discard(rejection)))
sortedStringify serializes JSON with object keys sorted at every level. It is the default query key used by make, so structurally equal plain-data queries share an entry regardless of property order.
It does not support functions or cycles.
Example
// Two views ask for the same deck with the keys in a different order. The// default key sees one question: one find, one cached list, one identity.const same =sortedStringify({ deck:"spanish", due:true})===sortedStringify({ due:true, deck:"spanish"});// true// The same rule inside a custom `key`, for a query that carries a field the// engine should not distinguish on.constkey=({ label: _label,...query }: DeckQuery &{ label:string})=>sortedStringify(query);
// Two views ask for the same deck with the keys in a different order. The// default key sees one question: one find, one cached list, one identity.let same =sortedStringify({"deck":"spanish","due":true})===sortedStringify({"due":true,"deck":"spanish"})// true// The same rule inside a custom `key`, for a query that carries a field the// engine should not distinguish on.let key =(query: labelledQuery)=>sortedStringify({deck: query.deck})
_canopy returns a Canopy with the query keys currently observed (live) and cached but unobserved (idle).
It is an internal/debug surface for tooling. The engine uses the same observation state to refresh and evict queries.
Example
// A devtools panel: which questions the UI is asking right now, and which// answers are kept warm for a return visit until memory expiry.const{ live, idle }= cards._canopy();console.table({ observed: live, cached: idle });
// A devtools panel: which questions the UI is asking right now, and which// answers are kept warm for a return visit until memory expiry.let{live, idle}= cards._canopy()Console.table({"observed": live,"cached": idle})
Store.rowTag is the Kv tag under which the shipped store keeps one JSON value per row, keyed by id. It is public so a custom keyspace can index those entries and implement lookup against the same rows.
Example
// A keyspace of your own keeps a deck index beside the rows. Each row is JSON// under `rowTag`, keyed by its id, so `set` can maintain the index as rows// come and go — and `lookup` can later answer a deck the store has no record// of, and certify it.const persist: Kv ={...table,set:(tag, key, value)=>{
table.set(tag, key, value);if(tag === Store.rowTag) deckIndex.update(key, value);},};
// A keyspace of your own keeps a deck index beside the rows. Each row is JSON// under `rowTag`, keyed by its id, so `set` can maintain the index as rows// come and go — and `lookup` can later answer a deck the store has no record// of, and certify it.let persist:Store.Kv.t={...table,
set:(~tag,~key, value)=>{
table.set(~tag,~key, value)if tag ===Store.rowTag{DeckIndex.update(key, value)}},}
Store.online returns the process-wide connectivity signal used by Store.make when online is omitted. In environments with addEventListener, it follows browser online/offline events. Where connectivity cannot be observed, it is true so writes are not permanently blocked.
Example
// One signal per process, shared by every store that does not bring its own.// In a browser it follows online/offline events. Write to it where the browser// cannot know: a "work offline" switch, or a test going into a tunnel.const online = Store.online();
offlineSwitch.onchange=()=>{
online.value =!offlineSwitch.checked;};
// One signal per process, shared by every store that does not bring its own.// In a browser it follows online/offline events. Write to it where the browser// cannot know: a "work offline" switch, or a test going into a tunnel.let online =Store.online()
offlineSwitch.onchange=()=> online.value=!offlineSwitch.checked
typet<'query,'a>={
one:'query => loadable<'a>,
array:'query => loadable<array<'a>>,
tick: unit => unit,
dispose: unit => unit,
_canopy: unit => canopy,}
TiliaQuery<T, Q> / t<'query, 'a> is the first item returned by make. Its application surface is read-only:
Writes, inbound server facts, and synchronization status are on the separate Store handle returned by the shipped store factories.
Example
// A feature that only displays takes the query object, and nothing else: it// can ask, it cannot write. The store handle stays with the feature that edits.constdueCount=(cards: TiliaQuery<Card, DeckQuery>, deck:string)=>{const result = cards.array({ deck });return result !=="loading"&& result.state ==="loaded"? result.data.filter(due).length :0;};
// A feature that only displays takes the query object, and nothing else: it// can ask, it cannot write. The store handle stays with the feature that edits.let dueCount =(cards: t<deckQuery, card>, deck)=>switch cards.array({deck: deck}){|Loaded({data})=> data->Array.filter(due)->Array.length|Loading|NoData(_)=>0}
Config contains engine concerns; store concerns belong to the supplied StoreFactory.
id returns the stable unique id for a value.
matches is a pure per-row query membership predicate. A find must return
the complete result set; limits, pagination, and aggregates do not fit this
query shape.
store creates the source and application store handle.
expiry sets engine refresh and memory timing.
now returns milliseconds; the default is the current system time.
sort returns a sorter for the full result array; the default preserves
delivery order.
onError receives a find failure that cannot be shown at the read site
because an existing answer is retained. That includes a complete empty
answer. A first failure with no answer becomes NoData({Failed}); refresh
failures that preserve an answer call onError. Loaded has no error
field.
Example
// Everything the engine needs to know about cards and decks. The store gets// the same answers through `Schema`, defaults resolved, and never re-derives them.const config: Config<Card, DeckQuery, Store<Card>>={id:(card)=> card.id,matches:(query, card)=> card.deck === query.deck,sort:()=>(cards)=>[...cards].sort((a, b)=> a.seen - b.seen),
expiry:{ refresh:15_000, memory:5*60_000},onError:(query, message)=>console.warn(`refresh of ${query.deck} failed: ${message}`),
store: Store.make({ find, upsert, remove }),};
// Everything the engine needs to know about cards and decks. The store gets// the same answers through `schema`, defaults resolved, and never re-derives them.let config: config<deckQuery, card,Store.t<card>>={
id: card => card.id,
matches:(query, card)=> card.deck=== query.deck,
sort: _query => cards => cards->Array.toSorted((a, b)=>Int.compare(a.seen, b.seen)),
expiry:{refresh:15_000., memory:300_000.},
onError:(~query,~message)=>Console.warn(`refresh of ${query.deck} failed: ${message}`),
store:Store.make({find, upsert, remove}),}
An empty partial answer is not data. It stays loading while a remote answer is possible and becomes offline NoData otherwise. A complete empty array answer is loaded; only one projects it to NoMatch.
Example
// One renderer for every read. `claim` is a field, so the loaded branch// renders first and only the view asks how strong the answer is.const render =<T>(value: Loadable<T>,view:(data:T, claim: Claim)=> Node)=>{if(value ==="loading")returnspinner();if(value.state ==="loaded")returnview(value.data, value.claim);returnempty(value.reason);};
// One renderer for every read. `claim` is a field, so the loaded branch// renders first and only the view asks how strong the answer is.let render =(value, view)=>switch value {|Loading=>spinner()|Loaded({data, claim})=>view(data, claim)|NoData({reason})=>empty(reason)}
partial / Partial contains locally held rows without claiming they are the complete result.
local / Local is a complete result according to local storage, but is not authoritative now.
fresh / Fresh is complete and authoritative now. It may come from a synchronized local store; it does not mean “from the network.”
Remote answers raise a claim to fresh. Only tick lowers a claim as data ages.
Example
// A badge under the deck: nothing for fresh, a word for what local storage// could and could not promise.constbadge=(claim: Claim)=>
claim ==="fresh"?"": claim ==="local"?"as of last sync":"some cards, maybe not all";
// A badge under the deck: nothing for fresh, a word for what local storage// could and could not promise.let badge = claim =>switch claim {|Fresh=>""|Local=>"as of last sync"|Partial=>"some cards, maybe not all"}
offline / Offline means the store had no usable rows and no remote answer is currently possible.
failed / Failed carries the store's read error message.
noMatch / NoMatch means one received a complete empty answer. Its claim ages with the answer; it does not assert that a particular row does not exist.
Example
// The empty state is not one message. Offline invites waiting; a failure// carries the store's words; no match means the deck is done — with the same// claim its rows would have had.constexplain=(reason: Reason)=>{if(reason ==="offline")return"No connection yet. Cards will appear when it returns.";if(reason.reason ==="failed")return reason.message;return reason.claim ==="fresh"?"Deck complete.":"Nothing here as of last sync.";};
// The empty state is not one message. Offline invites waiting; a failure// carries the store's words; no match means the deck is done — with the same// claim its rows would have had.let explain = reason =>switch reason {|Offline=>"No connection yet. Cards will appear when it returns."|Failed({message})=> message
|NoMatch({claim:Fresh})=>"Deck complete."|NoMatch(_)=>"Nothing here as of last sync."}
Op is one optimistic write not yet confirmed by the remote. upsert carries the full value; remove carries only its id.
Remote.push receives operations as an ordered batch. Confirm them through the matching WriteChannel method.
Example
// A push translates each operation to the backend's verb. A remove carries// the id only: nothing else is needed to delete, and nothing else is sent.constsend=(op: Op<Card>)=>
op.op ==="upsert"? api.put(`/cards/${op.value.id}`, op.value): api.delete(`/cards/${op.id}`);
// A push translates each operation to the backend's verb. A remove carries// the id only: nothing else is needed to delete, and nothing else is sent.let send = op =>switch op {|Upsert({value})=>Api.put(`/cards/${value.id}`, value)->Promise.thenResolve(_ =>())|Remove({id})=>Api.delete(`/cards/${id}`)->Promise.thenResolve(_ =>())}
Change is passed to the shipped store's merge callback with an incoming remote value:
clean carries the current value when no write is pending.
created carries the latest local create.
updated carries the original base and latest edited value.
removed carries the value removed locally.
The callback runs inside Tilia.batch. Mutate the local object in place and return true to merge. Return false to keep remote truth and record the corresponding conflict.
Example
// Field-level merge. Alice changed `back`; Nora, elsewhere, `seen`. Keep both:// a field Alice left alone takes the remote's value, and her write goes again.constmerge=(change: Change<Card>, remote: Card)=>{switch(change.change){case"clean":
Object.assign(change.value, remote);returntrue;case"updated":{const{ base, edited }= change;if(edited.seen === base.seen) edited.seen = remote.seen;if(edited.back === base.back) edited.back = remote.back;returntrue;}default:returnfalse;// a create or a remove against a row the server holds: record the conflict}};
// Field-level merge. Alice changed `back`; Nora, elsewhere, `seen`. Keep both:// a field Alice left alone takes the remote's value, and her write goes again.let merge =(~change,~remote)=>switch change {|Clean({value})=>
value.back= remote.back
value.seen= remote.seentrue|Updated({base, edited})=>if edited.seen=== base.seen{
edited.seen= remote.seen}if edited.back=== base.back{
edited.back= remote.back}true|Created(_)|Removed(_)=>false// a create or a remove against a row the server holds: record the conflict}
Rejection records an optimistic create, update, or remove that was reverted. Conflict variants come from a remote value that merge could not reconcile, including one supplied through WriteChannel.conflict. Failed variants come from WriteChannel.reject or fail and carry the remote message.
edited is the latest local edit. base is the value it started from, or the removed value. These fields are JSON-copied snapshots; the current remote value already stands in the store.
At most one rejection exists per id. A later write clears it. retry queues its work again; discard drops only the record.
Example
// One line per refused write, in outbox order: retry the first and the ones// it caused may follow. Conflicts have no message of their own; failures// carry the server's.constdescribe=(rejection: Rejection<Card>)=>{switch(rejection.rejection){case"createConflict":return`"${rejection.edited.front}" already exists on the server`;case"updateConflict":return`"${rejection.edited.front}" changed on the server while you edited it`;case"removeConflict":return`"${rejection.base.front}" changed on the server before it was removed`;case"removeFailed":return`"${rejection.base.front}": ${rejection.message}`;default:return`"${rejection.edited.front}": ${rejection.message}`;}};
// One line per refused write, in outbox order: retry the first and the ones// it caused may follow. Conflicts have no message of their own; failures// carry the server's.let describe = rejection =>switch rejection {|CreateConflict({edited})=>`"${edited.front}" already exists on the server`|UpdateConflict({edited})=>`"${edited.front}" changed on the server while you edited it`|RemoveConflict({base})=>`"${base.front}" changed on the server before it was removed`|CreateFailed({edited, message})|UpdateFailed({edited, message})=>`"${edited.front}": ${message}`|RemoveFailed({base, message})=>`"${base.front}": ${message}`}
Status is the reactive write state at store.status.
pending counts operations waiting in the outbox. rejected is ordered by the outbox, even when replies arrived in another order, so a cascade can be retried cause-first. It contains at most one record per id, and any new write to that id clears the record.
// Sync state for the header, in one sentence. Reactive: `observe` re-runs as// the outbox drains and as rejections come and go.observe(()=>{const{ pending, rejected }: Status<Card>= store.status;
header.sync = pending ===0&& rejected.length ===0?"up to date":`${pending} waiting, ${rejected.length} refused`;});
// Sync state for the header, in one sentence. Reactive: `observe` re-runs as// the outbox drains and as rejections come and go.Tilia.observe(()=>{let{pending, rejected}: status<card>= store.status
header.sync=
pending ===0 && rejected->Array.length===0?"up to date":`${pending->Int.toString} waiting, ${rejected->Array.length->Int.toString} refused`})->ignore
typeremote<'query,'a>={
online:Tilia.signal<bool>,
fetch:('query,Channel.read<'a>)=> unit,
push:(array<op<'a>>,Channel.write<'a>)=> unit,}
Remote is the advanced channel adapter consumed by Store.custom.
online is an application-owned reactive connectivity signal. Pending operations push only while it is true.
fetch answers one query through a ReadChannel, using fresh for a complete current answer or live when the source maintains it.
push receives an ordered batch of pending Op values and answers through a WriteChannel.
Example
// A remote for `Store.custom`: the app's connectivity signal, one fetch per// query, one push per batch. `push` answers each operation through the write// channel — see WriteChannel for it in full.const remote: Remote<Card, DeckQuery>={
online: network.online,fetch:(query, channel)=>
api.get(`/decks/${query.deck}`).then(channel.fresh,(error: ApiError)=> channel.fail(error.message)),
push,};
// A remote for `Store.custom`: the app's connectivity signal, one fetch per// query, one push per batch. `push` answers each operation through the write// channel — see Channel.write for it in full.let remote: remote<deckQuery, card>={
online: network.online,
fetch:(query, channel)=>Api.get(`/decks/${query.deck}`)->Promise.thenResolve(result =>switch result {|Ok(cards)=> channel.fresh(cards)|Error(message)=> channel.fail(message)})->ignore,
push,}
typereceive<'a>={
changed: array<'a>=> unit,
removed: array<string>=> unit,}
Receive is exposed by the application Store handle for server-initiated facts.
receive.changed applies complete values; receive.removed applies deleted ids. Both update matching queries, persistence, and pending-write reconciliation without changing query freshness.
Example
// The two inbound facts, wired once at startup. On reconnect the server// replays what was missed through the same two calls, and every open deck// catches up without a refetch.const receive: Receive<Card>= store.receive;
socket.on("cards:changed", receive.changed);
socket.on("cards:removed", receive.removed);
// The two inbound facts, wired once at startup. On reconnect the server// replays what was missed through the same two calls, and every open deck// catches up without a refetch.let receive: receive<card>= store.receive
socket.on("cards:changed", receive.changed)
socket.on("cards:removed", receive.removed)
receive accepts changed values and removed ids from server pushes.
status exposes reactive outbox and rejection state.
retry and discard resolve exact rejection records.
The query engine itself is read-only; all application writes go through this handle.
Example
// A feature that edits takes the store handle; one that only displays takes// the query object. The reviewer needs both verbs and nothing about reads.constreviewer=(store: Store<Card>)=>({pass:(card: Card)=> store.upsert({...card, seen: card.seen +1}),drop:(card: Card)=> store.remove(card.id),});
// A feature that edits takes the store handle; one that only displays takes// the query object. The reviewer needs both verbs and nothing about reads.let reviewer =(store:Store.t<card>)=>{
pass: card => store.upsert({...card, seen: card.seen+1}),
drop: card => store.remove(card.id),}
Expiry contains engine-owned durations in milliseconds:
refresh is the refresh interval for observed non-live queries. Default when expiry is omitted: 30_000.
memory is how long an unobserved query stays in memory. Eviction closes its find and tells the store to forget the query, but leaves persisted data intact. Default: 300_000.
All checks run from tick. Local row retention belongs to StoreExpiry.
Example
// A live scoreboard that can afford it: refresh every 10 s while someone is// looking, forget a deck a minute after the last look. Storage keeps its own// clock, in StoreExpiry.const expiry: Expiry ={ refresh:10_000, memory:60_000};
// A live scoreboard that can afford it: refresh every 10 s while someone is// looking, forget a deck a minute after the last look. Storage keeps its own// clock, in Store.expiry.let expiry: expiry ={refresh:10_000., memory:60_000.}
typeChannel.read<'a>={
fresh: array<'a>=> unit,
live: array<'a>=> unit,
fail:string=> unit,end: unit => unit,
finally:(unit => unit)=> unit,}
ReadChannel is passed to Remote.fetch. Every value delivery is the complete query result and replaces its previous result.
fresh(values) publishes an authoritative answer that the engine refreshes periodically.
live(values) publishes the same fresh claim and declares that the source maintains it, so periodic refresh is skipped.
fail(message) reports a failure without closing the find; a live source may recover.
end() closes the find and returns a live query to periodic refresh.
finally(teardown) registers cleanup run once when the find closes by
ending, replacement, eviction, or disposal. The last registration wins;
registering after the find already closed runs the teardown immediately.
Every other callback on a closed find is ignored.
Example
// One fetch, two kinds of server. A server that answers when asked gets// `fresh`, and the engine refreshes on its own clock. A server that streams// gets `live`: the find stays open, `fail` reports a hiccup without closing// it, `end` hands the query back to periodic refresh, and `finally` is where// the stream is closed — on eviction, on a superseding fetch, on dispose.constfetch=(query: DeckQuery, channel: ReadChannel<Card>)=>{if(!server.live){
server.fetch(query).then(channel.fresh,(error: Error)=> channel.fail(error.message));return;}const feed = server.subscribe(query, channel.live);
feed.onError((error)=> channel.fail(error.message));
feed.onClose(channel.end);
channel.finally(feed.close);};
// One fetch, two kinds of server. A server that answers when asked gets// `fresh`, and the engine refreshes on its own clock. A server that streams// gets `live`: the find stays open, `fail` reports a hiccup without closing// it, `end` hands the query back to periodic refresh, and `finally` is where// the stream is closed — on eviction, on a superseding fetch, on dispose.let fetch =(query, channel:Channel.read<card>)=>if!server.live{
server.fetch(query)->Promise.thenResolve(result =>switch result {|Ok(cards)=> channel.fresh(cards)|Error(message)=> channel.fail(message)})->ignore
}else{let feed = server.subscribe(query, channel.live)
feed.onError(message => channel.fail(message))
feed.onClose(channel.end)
channel.finally(feed.close)}
typeChannel.local<'a>={
partial: array<'a>=> unit,
local: array<'a>=> unit,}
LocalChannel is used by StoreConfig.lookup, StoreChannels.lookup, and a custom Source.
partial(values) returns rows the store holds without claiming they are the complete result. partial([]) explicitly says it holds nothing.
local(values) returns the complete result according to local storage.
An empty partial answer remains loading while remote data is possible and becomes an offline absence otherwise. An empty local answer is complete data for array and NoMatch for one.
Example
// `lookup` is asked about a deck the store has no record of. A keyspace with// a deck index can hand back its rows and certify they are all of them:// `local`. A scan could only say `partial`, and an empty partial is not an// answer — the engine keeps waiting for the network.constlookup=(query: DeckQuery, channel: LocalChannel<Card>)=>
deckIndex.rows(query.deck).then(channel.local);
// `lookup` is asked about a deck the store has no record of. A keyspace with// a deck index can hand back its rows and certify they are all of them:// `local`. A scan could only say `partial`, and an empty partial is not an// answer — the engine keeps waiting for the network.let lookup =(query, channel:Channel.local<card>)=>DeckIndex.rows(query.deck)->Promise.thenResolve(channel.local)->ignore
Combine local and authoritative answers for one engine find.
ReadChannel<T>& LocalChannel<T>
typeChannel.find<'a>={
partial: array<'a>=> unit,
local: array<'a>=> unit,
fresh: array<'a>=> unit,
live: array<'a>=> unit,
fail:string=> unit,end: unit => unit,
finally:(unit => unit)=> unit,}
Channel.find is the combined channel passed to a custom
Source. TypeScript expresses it as the intersection
ReadChannel<T> & LocalChannel<T>; ReScript exports the named
Channel.find<'a> type.
It lets one engine find receive partial or local answers from storage and
fresh or live answers from an authoritative source. fail, end, and
finally use the ReadChannel lifecycle. The
engine ignores an answer that would weaken the strongest claim already
published during that find.
Example
// A custom source answers one find twice: storage first, so the deck shows at// once, then the network. The engine keeps the stronger claim — a `local`// that lands after `fresh` cannot pull the result back down.constfind=(query: DeckQuery, channel: ReadChannel<Card>& LocalChannel<Card>)=>{
storage.rows(query).then(channel.local);if(online.value) server.fetch(query).then(channel.fresh,(error: Error)=> channel.fail(error.message));};
// A custom source answers one find twice: storage first, so the deck shows at// once, then the network. The engine keeps the stronger claim — a `local`// that lands after `fresh` cannot pull the result back down.let find =(query, channel:Channel.find<card>)=>{
storage.rows(query)->Promise.thenResolve(channel.local)->ignore
if online.value{
server.fetch(query)->Promise.thenResolve(result =>switch result {|Ok(cards)=> channel.fresh(cards)|Error(message)=> channel.fail(message)})->ignore
}}
typeChannel.write<'a>={
set:'a => unit,
removed:string=> unit,
conflict:'a => unit,
reject:(string,string)=> unit,
retry: unit => unit,
fail:string=> unit,}
WriteChannel answers the ordered operations passed to Remote.push.
set(value) confirms one upsert with its authoritative value.
removed(id) confirms one remove.
conflict(value) supplies the server row. If merge accepts it, the
operation is rebased and stays pending; otherwise remote truth is placed,
the operation leaves the outbox, and a conflict rejection is recorded.
reject(id, message) definitively refuses one operation.
retry() ends the push without deciding any unanswered operation; they remain pending.
fail(message) definitively rejects every unanswered operation. Earlier answers stand.
set and conflict match operations by id(value), so the backend must
preserve client ids. Per-operation callbacks answer only their matching
operation; a successfully merged conflict leaves that operation unsettled
for a later push.
The first retry() or fail() closes the push. Every later callback on that
channel, including a delayed per-operation answer, is ignored.
Example
// One request per operation, in outbox order, each answered by name. A 409// hands back the server's row: the write rebases through `merge` and goes// again on a later tick. An outage says nothing about the writes and ends the// push; whatever was not answered goes again later. Any other refusal is this// one write's, and the next one is still sent.constpush=(ops: Op<Card>[], channel: WriteChannel<Card>)=>{const next =async(index:number):Promise<void>=>{const op = ops[index];if(!op)return;try{if(op.op ==="remove"){await api.delete(`/cards/${op.id}`);
channel.removed(op.id);}else{
channel.set(await api.put(`/cards/${op.value.id}`, op.value));}}catch(e){const error = e as ApiError;if(error.status ===409&& op.op ==="upsert") channel.conflict(error.body);elseif(error.status >=500)return channel.retry();else channel.reject(op.op ==="remove"? op.id : op.value.id, error.message);}returnnext(index +1);};voidnext(0);};
// One request per operation, in outbox order, each answered by name. A// conflict hands back the server's row: the write rebases through `merge` and// goes again on a later tick. An outage says nothing about the writes and// ends the push; whatever was not answered goes again later. Any other// refusal is this one write's, and the next one is still sent.let push =(ops, channel:Channel.write<card>)=>{letrec next = index =>switch ops[index]{|None=>()|Some(Remove({id}))=>Api.delete(`/cards/${id}`)->Promise.thenResolve(result =>switch result {|Ok()=>
channel.removed(id)next(index +1)|Error(Unavailable)=> channel.retry()|Error(Refused(message))=>
channel.reject(id, message)next(index +1)|Error(Stale(_))=>
channel.reject(id,"changed on the server")next(index +1)})->ignore
|Some(Upsert({value}))=>Api.put(`/cards/${value.id}`, value)->Promise.thenResolve(result =>switch result {|Ok(saved)=>
channel.set(saved)next(index +1)|Error(Stale(theirs))=>
channel.conflict(theirs)next(index +1)|Error(Unavailable)=> channel.retry()|Error(Refused(message))=>
channel.reject(value.id, message)next(index +1)})->ignore
}next(0)}
Canopy is returned by _canopy. live contains observed query keys; idle contains unobserved keys retained until memory expiry.
Example
// A devtools line: what the UI is asking right now, and what is kept warm// for a return visit until memory expiry evicts it.constreport=({ live, idle }: Canopy)=>`${live.length} observed, ${idle.length} kept warm`;
// A devtools line: what the UI is asking right now, and what is kept warm// for a return visit until memory expiry evicts it.let report =({live, idle}: canopy)=>`${live->Array.length->Int.toString} observed, ${idle->Array.length->Int.toString} kept warm`
typeStore.config<'query,'a>={
find:('query,Store.answer<'a>)=> unit,
upsert:('a,Store.Outcome.t<'a>=> unit)=> unit,
remove:(string,Store.Removal.t=> unit)=> unit,
online?:Tilia.signal<bool>,
persist?:Store.Kv.t,
lookup?:('query,Channel.local<'a>)=> unit,
merge?:(~change: change<'a>,~remote:'a)=>bool,
expiry?:Store.expiry,}
StoreConfig describes the high-level backend consumed by Store.make.
lookup may answer an unregistered query as partial or, when it can certify completeness, local. Without it, the store scans its rows and can only answer partial.
merge reconciles a Change with remote truth in place.
expiry controls how long unheld query records remain in local retention.
Example
// The three required functions, then what the shipped store can do with more:// durable rows, an index to answer decks it has no record of, a merge that// keeps both sides' edits, and a shorter memory than the default month.const config: StoreConfig<Card, DeckQuery>={
find,
upsert,
remove,
persist:indexeddb({ name:"flashcards"}),
lookup,
merge,
expiry:{ local:7*24*60*60_000},};
// The three required functions, then what the shipped store can do with more:// durable rows, an index to answer decks it has no record of, a merge that// keeps both sides' edits, and a shorter memory than the default month.let config:Store.config<deckQuery, card>={
find,
upsert,
remove,
persist:TiliaQueryIndexedDb.make({name:"flashcards"}),
lookup,
merge,
expiry:{local:604_800_000.},}
typeStore.answer<'a>={
fresh: array<'a>=> unit,
fail:string=> unit,}
Answer is passed to StoreConfig.find. fresh(values) supplies the complete authoritative result set as of now; fail(message) reports a read failure.
The weaker partial and local claims are storage concerns and are not valid backend-find answers.
Example
// A find answers once: the complete deck as of now, or why not. The weaker// claims are not offered here — what the store already holds is its own// business, and it has answered from it before this call was made.constfind=(query: DeckQuery, answer: Answer<Card>)=>
api.get(`/decks/${query.deck}`).then(answer.fresh,(error: ApiError)=> answer.fail(error.message));
// A find answers once: the complete deck as of now, or why not. The weaker// claims are not offered here — what the store already holds is its own// business, and it has answered from it before this call was made.let find =(query, answer:Store.answer<card>)=>Api.get(`/decks/${query.deck}`)->Promise.thenResolve(result =>switch result {|Ok(cards)=> answer.fresh(cards)|Error(message)=> answer.fail(message)})->ignore
Outcome is returned through StoreConfig.upsert's reply:
saved / Saved confirms the operation with the authoritative value.
conflict / Conflict supplies the backend value. An accepted merge
rebases the operation and keeps it pending; a declined or missing merge
places remote truth and records a conflict rejection.
rejected / Rejected definitively refuses only this operation and records the message.
transient / Transient says nothing about the write. Processing stops and this and later operations retry.
Example
// What became of one write, in the backend's terms. A 409 carries the row the// server holds: `conflict` keeps the write pending, so it rebases through// `merge` and goes again. An outage is `transient`: nothing is said about the// write, and the batch stops here. Anything else refuses this write alone.constupsert=(card: Card,reply:(outcome: Outcome<Card>)=>void)=>
api.put(`/cards/${card.id}`, card).then((saved)=>reply({ outcome:"saved", value: saved }),(error: ApiError)=>{if(error.status ===409)reply({ outcome:"conflict", value: error.body });elseif(error.status >=500)reply("transient");elsereply({ outcome:"rejected", message: error.message });},);
// What became of one write, in the backend's terms. A conflict carries the row// the server holds: `Conflict` keeps the write pending, so it rebases through// `merge` and goes again. An outage is `Transient`: nothing is said about the// write, and the batch stops here. Anything else refuses this write alone.let upsert =(card, reply:Store.Outcome.t<card>=> unit)=>Api.put(`/cards/${card.id}`, card)->Promise.thenResolve(result =>switch result {|Ok(saved)=>reply(Saved({value: saved}))|Error(Stale(theirs))=>reply(Conflict({value: theirs}))|Error(Unavailable)=>reply(Transient)|Error(Refused(message))=>reply(Rejected({message: message}))})->ignore
Removal is returned through StoreConfig.remove's reply:
removed / Removed confirms the deletion.
rejected / Rejected definitively refuses it and records the message
when the store has a base value for the remove. An unknown-id remove is
cleared without a rejection because removeFailed requires that base.
transient / Transient leaves it unanswered, stops processing, and retries it later.
It is separate from Outcome, preventing an upsert-only result from answering a remove.
Example
// What became of one remove. `removed` confirms it; a refusal brings the card// back with a rejection holding the removed value; `transient` stops the batch// and the remove goes again later. There is no `saved` to answer a remove// with by mistake: this is its own type.constremove=(id:string,reply:(outcome: Removal)=>void)=>
api.delete(`/cards/${id}`).then(()=>reply("removed"),(error: ApiError)=>reply(error.status >=500?"transient":{ outcome:"rejected", message: error.message }),);
// What became of one remove. `Removed` confirms it; a refusal brings the card// back with a rejection holding the removed value; `Transient` stops the batch// and the remove goes again later. There is no `Saved` to answer a remove// with by mistake: this is its own type.let remove =(id, reply:Store.Removal.t=> unit)=>Api.delete(`/cards/${id}`)->Promise.thenResolve(result =>switch result {|Ok()=>reply(Removed)|Error(Unavailable)=>reply(Transient)|Error(Refused(message))=>reply(Rejected({message: message}))|Error(Stale(_))=>reply(Rejected({message:"changed on the server"}))})->ignore
remote supplies low-level fetch and push channels.
persist is the string keyspace; omission uses Store.memory.
lookup may answer an unregistered query from a custom index as partial or certified local.
merge reconciles remote values with local state in place and returns whether it succeeded.
expiry controls how long unheld query records remain in local retention.
Example
// `Store.custom` with everything the shipped store offers around a remote:// durable rows, an index for decks it has no record of, a merge that keeps// both sides' edits, and a shorter memory than the default month.const channels: StoreChannels<Card, DeckQuery>={
remote,
persist:indexeddb({ name:"flashcards"}),
lookup,
merge,
expiry:{ local:7*24*60*60_000},};
// `Store.custom` with everything the shipped store offers around a remote:// durable rows, an index for decks it has no record of, a merge that keeps// both sides' edits, and a shorter memory than the default month.let channels:Store.channels<deckQuery, card>={
remote,
persist:TiliaQueryIndexedDb.make({name:"flashcards"}),
lookup,
merge,
expiry:{local:604_800_000.},}
Configure retained query records in local storage.
typeStoreExpiry={
local:number}
typeStore.expiry={
local:float,}
StoreExpiry contains the shipped store's local duration in milliseconds.
It controls how long an unheld query record remains in the retention graph
after that query was last seen. The default is 2_592_000_000 (30 days).
Rows have no separate grace period. Each purge removes rows that no surviving
query record or pending operation references, so a row omitted by a fresh
answer may be swept immediately even when its former query record is young.
Local purge runs from the store half of tick. Engine refresh and in-memory query eviction use the separate Expiry.
Example
// A deck nobody has opened for a week leaves local storage at the next purge,// and its rows go with it — unless another deck's record or a pending write// still needs them. The engine's own timing is in Expiry.const factory = Store.make<Card, DeckQuery>({
find,
upsert,
remove,
expiry:{ local:7*24*60*60_000},});
// A deck nobody has opened for a week leaves local storage at the next purge,// and its rows go with it — unless another deck's record or a pending write// still needs them. The engine's own timing is in `expiry`.let factory =Store.make({
find,
upsert,
remove,
expiry:{local:604_800_000.},})
typeStore.Kv.t={
get:(~tag:string,~keys: array<string>=?,~set: array<string>=> unit)=> unit,
keys:(~tag:string,~set: array<string>=> unit)=> unit,
set:(~tag:string,~key:string, option<string>)=> unit,}
Kv is the complete persistence interface required by the shipped store. Rows, query records, and the outbox are tagged string entries.
get(tag, keys, set) reads named entries, or every entry under the tag when keys is undefined.
keys(tag, set) lists entry keys without reading their values.
set(tag, key, value) writes one entry; undefined / None deletes it.
Reads reply through callbacks synchronously or later. Persistence is command-only: an implementation that cannot read answers with no values, and write errors are reported by the implementation rather than through the store. Use Store.memory or the TypeScript IndexedDB adapter.
Example
// The whole of what the shipped store asks of persistence: three functions// over tagged string entries. This one, over a Map of Maps, is the shape// `Store.memory()` has; IndexedDB answers the same three calls later instead// of now, and the store does not care which.const tags =newMap<string, Map<string,string>>();constunder=(tag:string)=> tags.get(tag)?? tags.set(tag,newMap()).get(tag)!;const persist: Kv ={get:(tag, keys, set)=>set((keys ??[...under(tag).keys()]).flatMap((key)=>under(tag).get(key)??[])),keys:(tag, set)=>set([...under(tag).keys()]),set:(tag, key, value)=>{if(value ===undefined)under(tag).delete(key);elseunder(tag).set(key, value);},};
// The whole of what the shipped store asks of persistence: three functions// over tagged string entries. This one, over a Map of Maps, is the shape// `Store.memory()` has; IndexedDB answers the same three calls later instead// of now, and the store does not care which.let tags:Map.t<string,Map.t<string,string>>=Map.make()let under = tag =>switch tags->Map.get(tag){|Some(entries)=> entries
|None=>let entries =Map.make()
tags->Map.set(tag, entries)
entries
}let persist:Store.Kv.t={
get:(~tag,~keys=?,~set)=>{let entries =under(tag)let keys = keys->Option.getOr(entries->Map.keys->Iterator.toArray)set(keys->Array.filterMap(key => entries->Map.get(key)))},
keys:(~tag,~set)=>set(under(tag)->Map.keys->Iterator.toArray),
set:(~tag,~key, value)=>switch value {|Some(value)=>under(tag)->Map.set(key, value)|None=>under(tag)->Map.delete(key)->ignore
},}
Schema is the root Config after defaults are resolved. make passes it once to a custom StoreFactory, so engine and store share the same id, membership, key, ordering, and clock behavior.
The store must not apply its own defaults for these fields.
Example
// A custom store answers finds with the engine's own rules — `matches`,// `sort`, `id`, `key`, `now`, defaults resolved — and never re-derives them.// Here a find over rows the store holds: filter by the engine's membership,// order by the engine's sort, and the two halves cannot disagree.constfindIn=(schema: Schema<Card, DeckQuery>, rows: Card[])=>(query: DeckQuery, channel: LocalChannel<Card>)=>
channel.local(schema.sort(query)(rows.filter((card)=> schema.matches(query, card))));
// A custom store answers finds with the engine's own rules — `matches`,// `sort`, `id`, `key`, `now`, defaults resolved — and never re-derives them.// Here a find over rows the store holds: filter by the engine's membership,// order by the engine's sort, and the two halves cannot disagree.let findIn =(schema: schema<deckQuery, card>, rows)=>(query, channel:Channel.local<card>)=>
channel.local(schema.sort(query)(rows->Array.filter(card => schema.matches(query, card))))
item(id) returns the canonical live object when present. A merge may mutate that object in place; a store must not keep another canonical copy.
changed(values) applies remote truth and retains rows only while an in-memory query matches.
removed(ids) applies remote deletions.
place(value) applies an optimistic value and keeps its row even when no current query lists it.
Insertions and deletions remain controlled by these methods rather than direct access to the row table.
Example
// A store of your own, fed by a socket. Rows arrive as facts: `changed` places// remote truth and keeps a row only while some open deck wants it; `removed`// drops it everywhere. A write goes through `place`, which keeps its row even// before any deck lists it, and the server answers through the same socket.// `item` reads the live object, so a partial update can start from it.const factory: StoreFactory<Card, DeckQuery,{write:(card: Card)=>void}>=(schema, binding)=>{
socket.on("cards:changed",(rows: Card[])=> binding.changed(rows));
socket.on("cards:removed",(ids:string[])=> binding.removed(ids));
socket.on("cards:seen",({ id, seen }:{ id:string; seen:number})=>{const card = binding.item(id);if(card) binding.changed([{...card, seen }]);});constwrite=(card: Card)=>{
binding.place(card);
socket.send("cards:save", card);};return[source(schema, binding),{ write }];};
// A store of your own, fed by a socket. Rows arrive as facts: `changed` places// remote truth and keeps a row only while some open deck wants it; `removed`// drops it everywhere. A write goes through `place`, which keeps its row even// before any deck lists it, and the server answers through the same socket.// `item` reads the live object, so a partial update can start from it.let factory: store<deckQuery, card, writer>=(schema, binding)=>{
socket.on("cards:changed", rows => binding.changed(rows))
socket.on("cards:removed", ids => binding.removed(ids))
socket.on("cards:seen",(seen: seenEvent)=>switch binding.item(seen.id){|Some(card)=> binding.changed([{...card, seen: seen.seen}])|None=>()})let write = card =>{
binding.place(card)
socket.send("cards:save", card)}(source(schema, binding),{write: write})}
typesource<'query,'a>={
online:Tilia.signal<bool>,
find:('query,Channel.find<'a>)=> unit,
forget:'query => unit,
tick: unit => unit,
dispose: unit => unit,}
Source is what a custom store returns to the engine:
online states whether a remote answer is possible.
find receives the combined Channel.find.
There is one find per query regardless of how many storage tiers answer it,
and another on each refresh.
forget is called only when an idle query is evicted from memory.
tick runs the store heartbeat after the engine heartbeat.
dispose shuts the store down after the engine has closed all finds.
Example
// What the engine asks of a store, for one that keeps rows in memory and// nothing else. It answers a find from the rows it placed, through the live// objects; it has nothing to forget per query, nothing to do on a tick, and// only the socket to close.const source =(schema: Schema<Card, DeckQuery>, binding: Binding<Card>): Source<Card, DeckQuery>=>({
online: Store.online(),find:(query, channel)=>
channel.local(held.flatMap((id)=> binding.item(id)??[]).filter((card)=> schema.matches(query, card))),forget:()=>{},tick:()=>{},dispose:()=> socket.close(),});
// What the engine asks of a store, for one that keeps rows in memory and// nothing else. It answers a find from the rows it placed, through the live// objects; it has nothing to forget per query, nothing to do on a tick, and// only the socket to close.let source =(schema: schema<deckQuery, card>, binding: binding<card>): source<deckQuery, card>=>{
online:Store.online(),
find:(query, channel)=>
channel.local(
held->Array.filterMap(id => binding.item(id))->Array.filter(card => schema.matches(query, card)),),
forget: _query =>(),
tick:()=>(),
dispose:()=> socket.close(),}
StoreFactory is the store value accepted by Config. make calls it once with the resolved Schema and an already working Binding. It returns the store's Source and any application handle S.
The factory shape ensures neither engine nor store exists unconnected. A custom store may use the binding during construction. Use void / unit for S when it exposes no application handle.
Example
// The factory shape: given the schema and a binding that already works, hand// the source back in the same breath. Rows placed here are readable before// `make` returns, and a store with nothing to offer the app returns `undefined`.const seeded =(rows: Card[]): StoreFactory<Card, DeckQuery,void>=>(schema, binding)=>{
rows.forEach(binding.place);return[source(schema, binding),undefined];};const[cards]=make({id:(card: Card)=> card.id,matches:(query: DeckQuery, card: Card)=> card.deck === query.deck,
store:seeded(fixtures),});
// The factory shape: given the schema and a binding that already works, hand// the source back in the same breath. Rows placed here are readable before// `make` returns, and a store with nothing to offer the app returns `()`.let seeded =(rows): store<deckQuery, card, unit>=>(schema, binding)=>{
rows->Array.forEach(binding.place)(source(schema, binding),())}let(cards,())=make({
id: card => card.id,
matches:(query, card)=> card.deck=== query.deck,
store:seeded(fixtures),})
TypeScript exports Config from @tilia/query/indexeddb; ReScript exposes the same contract as TiliaQueryIndexedDb.config.
name is the required database name.
store is the object-store name. Default: "entries".
version is the database version. Default: 1.
onError receives database-open and transaction-abort errors. Failed reads still answer with no values and failed writes are dropped because Kv persistence is command-only.
Example
importtype{ Config }from"@tilia/query/indexeddb";// Bump `version` when the layout changes. `onError` is the only place a// database that will not open makes itself known: reads answer empty, writes// are dropped, and the app keeps working from memory — so say so somewhere.const config: Config ={
name:"flashcards",
version:2,onError:(error)=> telemetry.report("indexeddb", error),};
// Bump `version` when the layout changes. `onError` is the only place a// database that will not open makes itself known: reads answer empty, writes// are dropped, and the app keeps working from memory — so say so somewhere.let config:TiliaQueryIndexedDb.config={
name:"flashcards",
version:2.,
onError: error =>Telemetry.report("indexeddb", error),}