Guide·v0.x

The Guide

A reading path through the purpose of @tilia/query: questions with identity, answers that grow stronger, writes held through disconnection, independent devices, durable memory, and disagreements that lose nothing. Read it from top to bottom once. When you need an exact signature or rule later, use the API reference instead — it is flat, complete, and made for that.

01

Where the network ends

This chapter is for the person deciding whether @tilia/query belongs in their stack. There is no code in it.

@tilia/query is a query-state layer for remote collections, built on tilia reactivity. It exists because every feature that talks to a server ends up reinventing the same lifecycle: load collection data, remember it, refresh it when it grows old, and keep local changes safe when the network becomes unreliable. Each hand-rolled copy is subtly different, and the differences are where the bugs live. @tilia/query makes it one lifecycle, shared by every collection in the application.

Care, written as behavior

Underneath the API sits a conviction: an application should treat its user's time and words as things that matter. Here is how we translate that idea into enforceable rules:

  • Never make the user wait for data the device has already seen. The cache answers now; the network improves the answer when it can.
  • Offline is a state, not an error. A tunnel is not an exceptional condition. Nothing red appears; nothing stops working that could keep working.
  • A write accepted is a write kept. An edit made with no signal is applied on the spot, held durably, and delivered — in order — when the world returns. Restarting the app changes nothing.
  • Freshness is honest. The app knows whether it holds part of an answer, a complete remembered answer, or one confirmed current. It can say so quietly instead of blocking.
  • Disagreement is data, not damage. When two people — or two devices — edit the same thing, the resolution has everything on the table: the common ancestor, your version, theirs. What can merge, merges. What cannot becomes a question for a human, with no version silently lost.

The landing page borrows a word for the third rule: sève — the sap. Held through winter, flowing again at thaw. Offline support is not a feature bolted onto a cache; it is what emerges naturally when the cache receives true persistence.

What it deliberately does not do

@tilia/query owns the lifecycle and nothing else. It does not know your transport, your domain's query language, or even the clock — the app calls tick() from whatever scheduler it already has. The default store asks the application only how its backend finds, saves, and removes values. Durable browser storage is an optional IndexedDB keyspace. The boundary is strict on purpose: the library can promise one coherent lifecycle precisely because it refuses to absorb the parts that differ between applications.

Construction makes that boundary visible. make returns a pair: a query object for reading and a store for writing, observing sync status, and recovering refused work. They share one cache and one lifecycle, but feature code never has to pretend that a read and a write are the same kind of act.

This is the same philosophy as tilia itself: a narrow library that supports domain-oriented development instead of a framework that replaces it. Feature modules are expected to wrap their query state in domain-specific helpers, so application code keeps reading in the language of the business.

What it costs

The library is small and adds no dependencies beyond tilia. Every result it returns is a tilia value, so the reactivity you already understand carries through: a component re-renders only when a value it actually read has changed, and a background refetch that changes nothing re-renders nothing. The same API serves TypeScript and ReScript.

How this guide works

Readers of the tilia guide left Alice with a working spaced-repetition scheduler and one politely ignored question: what happens when the cards must live on a server? This guide answers it over the course of a trip.

Story

Alice is going to spend a week with her friend Nora, in a village in the Spanish hills where the network is a rumor. Between here and there: a train with tunnels, a bus with curves, a laptop, a phone — and a deck of flashcards that had better not care about any of it.

Each chapter explains one part of the lifecycle and why it is shaped that way: the shape of a query, the read that answers in stages, writes that outlive the connection, changing devices, a week without a signal, and what happens when the server disagrees. If you are deciding for your team, this chapter and the last may be all you need. The chapters between are for whoever will build it. And even a team that never adopts the library can adopt the rules and the care.

Reference: make, store-make, tilia-query-type →

02

A shape for queries

In tilia's domain-driven guide, the scheduler's repository was injected... and politely ignored for nine chapters. Now it is connected to a remote server, and the first question is what the engine needs to know about your domain to manage it.

The domain-specific answer is deliberately short: two functions. The store needs three more answers from the backend — how to find, save, and remove — but those describe transport rather than the domain.

Identity and membership

id says which row a value is. matches says whether a value belongs to a query. Everything else (caching, refreshing, offline writes, merging) is built on those two answers:

Example
import { make, Store } from "@tilia/query";

type DeckQuery = { 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 }),
});
open TiliaQuery

type deckQuery = {deck: string}

let (cards, store) = make({
  id: card => card.id,
  matches: (query, card) => card.deck === query.deck,
  store: Store.make({find, upsert, remove}),
})

A find answers one question, while upsert and remove say what became of one write. They are small translations around the application's existing backend; their exact callback shapes belong in the Store.make reference. The important point here is the boundary: the engine knows identity and membership, while the store knows how to speak to the server.

A query is plain data — {deck: "spanish"} — and its deterministically serialized form is its cache key by default. A custom key can replace that rule. Ask the same question anywhere in the application and you get the same living result: one find, one cached id list, one identity. There is nothing to register and nothing to name; the question is the key.

Notice what matches is: a pure predicate over one row. That restriction is the cornerstone of the library. Because membership can be decided by looking at a single value, the engine can update query results locally when a write happens, without asking the server which lists changed. Ordering can still depend on the query because sort, when supplied, returns a sorter for that query. A question whose membership cannot be decided per row — a limit, a page, an aggregate — needs a different domain boundary rather than a misleading query.

Reading is asking

The returned pair gives each side a plain role. cards is the query object: array returns all results and one returns the first. store is where the application writes and watches synchronization. Both belong to the same engine, but keeping their verbs apart makes feature code easier to read:

Example
import { leaf } from "@tilia/react";

const DeckView = leaf(() => {
  const result = cards.array({ deck: "spanish" });
  return typeof result === "object" && result.state === "loaded"
    ? <Deck cards={result.data} />
    : <DeckUnavailable result={result} />;
});
open TiliaReact

@react.component
let make = leaf(() => {
  switch cards.array({deck: "spanish"}) {
  | Loaded({data}) => <Deck cards=data />
  | result => <DeckUnavailable result />
  }
})

Both readers are reactive tilia values — reading is subscribing, exactly as in tilia. Their result is a loadable: not merely data, but the application's present knowledge of that data. The next chapter explains why that knowledge has a shape of its own.

Story

Alice packs. Her cards became an account last month; the laptop and the phone are both signed in. Nothing in her deck components changed that day. They still read cards.array({deck: "spanish"}) and render what comes back.

Pro tip

Keep queries in domain vocabulary and wrap the filters and views in feature helpers: deck.select("spanish") reads better than selecting with a query literal in a component, and it keeps the query shape in one place as it evolves.

Two domain functions, three backend operations, and one pair. What that pair means becomes visible the first time the app opens somewhere slow. A read does not merely answer once; it begins with what this device knows and becomes stronger when the world replies.

Reference: make, store-make, config-type, store-config-type, sorted-stringify, loadable-type, one, array →

03

Reads answer twice

Open a query and two kinds of knowledge meet. The store answers from what the device already holds; the backend answers from the present. The first arrives without waiting for the network, and the second strengthens it when it can:

Example
cards.array({ deck: "spanish" });
// now   → { state: "loaded", data: [gato, perro], claim: "local" }
// later → { state: "loaded", data: [gato, perro], claim: "fresh" }
cards.array({deck: "spanish"})
// now   → Loaded({data: [gato, perro], claim: Local})
// later → Loaded({data: [gato, perro], claim: Fresh})

That is the whole trick, and it is the first rule from chapter 1 made mechanical: no spinner ever stands in front of data the device has already seen. The network improves the answer; it does not gate it.

Claims are about knowledge

The claim does not say where rows happen to be stored. It says what the store can honestly assert about the answer:

  • partial means “these rows match, but there may be more.” A device can recognize matching cards it holds without claiming it has the whole deck.
  • local means “this is the complete answer I remembered for this exact question.” It is useful, but it is not a claim about the server now.
  • fresh means “this complete answer is authoritative as of now.” Usually the backend just confirmed it; a synchronized store could make the same claim without moving any bytes.

This is more expressive than a freshness boolean because incompleteness and age are different problems. Alice can review two locally known cards under a partial answer without being told that they are the entire deck. A local answer can safely render an empty deck because the device remembers that emptiness as complete. When a fresh answer arrives, its rows and query record are persisted; after it ages, tick() lowers it to local without taking the data away.

Nothing also has a reason

A loadable asks the reader to make only three broad choices: wait, render, or explain why there is no data. The last choice carries a structured reason, because “nothing” can mean importantly different things:

Example
const result = cards.one({ deck: "spanish" });

if (result === "loading") return <Skeleton />;
if (result.state === "loaded") return <Card card={result.data} claim={result.claim} />;
if (result.reason === "offline") return <UnavailableOffline />;
if (result.reason.reason === "failed") {
  return <Retryable message={result.reason.message} />;
}
return <NoCardYet claim={result.reason.claim} />;
switch cards.one({deck: "spanish"}) {
| Loading => <Skeleton />
| Loaded({data, claim}) => <Card card=data claim />
| NoData({reason: Offline}) => <UnavailableOffline />
| NoData({reason: Failed({message})}) => <Retryable message />
| NoData({reason: NoMatch({claim})}) => <NoCardYet claim />
}

offline says the device has nothing useful for the question and cannot improve that answer now. failed carries the backend's message when the first attempt failed before anything could be shown. noMatch belongs to one: a complete answer was empty, and its own claim says whether that emptiness is remembered or current. For array, a complete empty answer is ordinary loaded data — an empty collection is still an answer.

An empty partial answer is not useful enough to render. While online it remains loading, because a complete answer may still arrive; offline it becomes noData with offline, so the app never spins forever.

There is one deliberate asymmetry. If a refresh fails after rows are already loaded, the rows stay visible with their existing claim. Replacing useful data with an error would make a temporary outage destructive. The optional onError(query, message) on make receives that background failure for logging or a quiet notice; only a first failure with nothing behind it becomes noData with failed at the read site.

The heartbeat

Who decides when a fresh claim becomes local? The engine has no timers. The application calls tick(), and the library does the time-based work: queries someone is watching are refreshed when their result grows old, results nobody watches are eventually let go from memory, and the store gets the same heartbeat for persistence and retry work.

"Someone is watching" is not a subscription API. The engine asks tilia's observer graph which results are currently being read. A component rendering cards.array({deck: "spanish"}) keeps that query alive, and closing the component lets it retire. Reading marks the query as observed, like any reactive value in tilia.

Pro tip

A refresh returning the same rows changes nothing: the result keeps its identity, and nothing re-renders. Background freshness is quiet at the UI layer — you do not pay a repaint for learning that nothing changed.

Story

The 8:04 train pulls out. Alice opens the laptop before the Wi-Fi has decided whether it exists; the Spanish deck is simply there, yesterday's complete copy, marked local in the small status line. Three stops later it becomes fresh. All along, she never saw a spinner, because there was none.

Reading is now settled: the device offers what it knows, the backend confirms what it can, and the app tells the truth about the strength of the answer. The train, meanwhile, is heading for the mountains — and the first tunnel is about mutations.

Reference: claim-type, reason-type, loadable-type, expiry-type, tick →

04

Tunnels

The default store follows browser connectivity through Store.online(). Store.make uses that shared signal unless the application supplies another one, so for most browser apps a tunnel requires no feature-level code.

Connectivity is a permission to try, not proof that a request will succeed. A train can keep its Wi-Fi icon through a dead tunnel. That is why backend operations can still report a transient outcome: online begins the attempt; only the reply settles it.

Mutations apply now

Every mutation is optimistic: the local state changes before the remote hears about it. Alice's review action barely changes from the tilia guide:

Example
const review = (card: Card, result: Result) =>
  store.upsert({
    ...card,
    interval: result === "Pass" ? card.interval * 2 : 1,
    lastReview: clock.today,
  });
let review = (card, result) =>
  store.upsert({
    ...card,
    interval: result === Pass ? card.interval * 2 : 1,
    lastReview: clock.today,
  })

store.upsert changes the shared in-memory value immediately. It updates query membership using matches: the new value joins every in-memory query it now matches and leaves every one it no longer does, so moving a card between decks updates both lists at once without another find. Then it appends the operation to the outbox and, when online, asks the backend to save it. store.remove(id) follows the same path in the other direction.

Note what is not in that list: waiting. The write path never blocks on the network. The app is responsive by construction, and offline support stops being a mode — it is just the case where delivery waits.

The outbox

The outbox is an ordered queue of operations the backend has not yet confirmed. The queue is visible through the store as one reactive number:

Example
store.status.pending; // operations waiting for the backend
store.status.pending // operations waiting for the backend

Store.make preserves that order by issuing one operation and waiting for its outcome before issuing the next. This is not merely an implementation detail. One change may depend on an earlier one; allowing later work to overtake its cause would change the meaning of Alice's actions.

A saved upsert comes back with the backend's authoritative value, so server corrections can settle into the same row. A conflict comes back with the server's row: if the histories merge, the rebased operation stays pending; if they do not, server truth becomes visible and the local story becomes a rejection. A definitive rejection applies only to that operation: its optimistic effect is reverted, its context is kept for recovery, and later independent work may continue. A transient outcome says the backend knows nothing certain about the operation, so the sequence stops there and that operation — together with everything after it — waits untouched for a later tick or reconnect.

Confirmed operations leave the outbox and pending counts down. Persistence in chapter 6 will make the same ordered queue survive a restart; recovery from a conflict or rejection belongs to chapter 7.

Story

Twenty minutes in, the train drops into the first tunnel mid-review. Alice taps Pass; the card reschedules; the queue advances. In the corner of the screen, a small "3 pending" appears, then the mountain ends and it fades away. She notices none of it — which is the entire success criterion for this chapter.

Pro tip

Show status.pending somewhere small. "3 changes pending" is calm, specific, and true. It is better than silence, far better than an alarm. The number counting down after a tunnel is the app visibly keeping its promise.

The laptop's outbox empties between tunnels, which is about to matter: at the end of this train ride, Alice puts the laptop away. The deck is getting on the bus in a different pocket.

Reference: store-make, store-online, upsert, remove, status-type, status →

05

Two devices, one deck

Changing devices is where hand-rolled sync layers usually crack, so it is worth saying plainly: in this design, "continue on your phone" is not a feature anyone built. It is the result of two rules applied consistently: the server is authoritative, and every cache admits it is a cache.

The meeting point

By the time Alice's train reaches the station, the laptop's outbox has drained through the gaps between tunnels. Every review she made, every card she reworded, is on the server — not because some export ran, but because that is where mutations were always headed. Closing the laptop loses nothing, because the laptop was never the owner of anything.

The phone, meanwhile, has its own store, holding whatever it saw last. At the station it gets one bar of signal and a minute of attention. The query Alice opens answers instantly from the phone's remembered copy, then its ordinary refresh returns the server's complete answer. Because fresh results are written through, the phone now carries the deck as the laptop left it:

Example
const result = cards.array({ deck: "spanish" }); // read through the query
const waiting = store.status.pending;            // sync through the store
let result = cards.array({deck: "spanish"}) // read through the query
let waiting = store.status.pending          // sync through the store

No transfer occurs between the devices. The phone asks the same plain-data question the laptop asked; the shared server is where their histories meet. With the durable persistence in the next chapter, work left pending on the laptop would stay safe there, but the phone could not invent it. It would become available elsewhere only after the laptop reconnected and delivered it. That limitation is not a crack in the model; it is the model telling the truth.

Then the bus turns into the hills and the signal dies. The fresh phone answer becomes local as time passes, but its rows remain. Multi-device support, offline support, and plain cache correctness turn out to be the same discipline: one authoritative meeting point, and devices that remember without pretending to own.

Some backends push updates instead of waiting for the next refresh. That lower-level integration belongs to Store.custom in the API reference; the lifecycle and the promise to the user remain the same.

Story

Alice buys a terrible coffee, thumbs the phone awake in the bus queue, and the deck is already mid-thought: her review starts where the laptop stopped, and the card she reworded in the last tunnel reads the new way. The bus climbs; the bars vanish; the deck doesn't flinch.

Pro tip

Do not promise device-to-device magic. “Available on your other devices after sync” is both calmer and more accurate than suggesting that two offline devices somehow share a present.

The phone now holds everything it needs. It will have to, because where the bus is going there is no third answer coming — for a week.

Reference: store-make, loadable-type, array, status-type →

06

A week at Nora's

A tunnel tests whether offline works. A week tests whether offline was designed. Seven days without a signal means restarts, storage limits, and a growing pile of unsent writes — and Alice's feature code, notably, does nothing special about any of it. Nothing branches on connectivity. The same query and store absorb the whole week.

Memory that survives the process

Without a persist option, Store.make uses an in-memory keyspace. The lifecycle is already offline-capable, but a page reload forgets it. In a browser, @tilia/query/indexeddb changes the material of that memory without changing the model:

Example
import { Store, make } from "@tilia/query";
import { make as indexedDb } from "@tilia/query/indexeddb";

const [cards, store] = make({
  id: (card: Card) => card.id,
  matches: (query: DeckQuery, card) => card.deck === query.deck,
  expiry: { refresh: 30_000, memory: 300_000 },
  store: Store.make<Card, DeckQuery>({
    find,
    upsert,
    remove,
    persist: indexedDb({
      name: "alice-cards",
      onError: reportPersistence,
    }),
    expiry: { local: 30 * 24 * 3_600_000 },
  }),
});
let (cards, store) = TiliaQuery.make({
  id: card => card.id,
  matches: (query, card) => card.deck === query.deck,
  expiry: {refresh: 30_000., memory: 300_000.},
  store: TiliaQuery.Store.make({
    find,
    upsert,
    remove,
    persist: TiliaQueryIndexedDb.make({
      name: "alice-cards",
      onError: reportPersistence,
    }),
    expiry: {local: 30. *. 24. *. 3_600_000.},
  }),
})

Rows, remembered query answers, and the optimistic outbox all pass through that one durable keyspace. When the phone dies at two percent and restarts, the store reconstructs its memory and pending work. The feature resumes as if nothing happened, because as far as the data is concerned, nothing did. The outbox is data, not process state.

IndexedDB failure still deserves attention: onError is where the application reports an opening or transaction problem. Persistence cannot make a broken disk safe; it can make failure visible without giving the query engine a second, contradictory error model.

Three clocks

Retention is governed by three independent expiries, each answering a different question:

refresh asks whether an observed answer should be confirmed again. memory asks how long an unobserved query should occupy RAM. Both belong to make, because they govern the query engine. local asks how long the durable record of an unobserved query should keep participating in retention, so it belongs to Store.make.

The split in the configuration mirrors the split in responsibility. Expiring one layer never implies expiring another. A fresh answer becoming local does not evict it from RAM; leaving RAM does not touch IndexedDB; and durable storage forgets only data that has gone unreferenced for long enough.

The local clock measures usefulness, not time offline. Each time Alice views the deck, its query record is seen again. Leave the deck unopened past the local expiry and that record may be removed; rows that no surviving record or pending operation references are then swept. The duration belongs to the query record, not each row: if a fresh answer stops listing a card, that card can disappear at the next purge unless another record or pending write still needs it.

The one guarantee that matters more than the cleanup mechanism is this: pending writes are roots. An edit that has not reached the server cannot be purged, no matter how old the queries around it grow. Retention may tidy the device's memory of server data; it has no authority over promises not yet kept.

All three clocks advance through the same cards.tick() the app was already calling. There are no daemons and no timers of the engine's own — the application's heartbeat maintains the week.

Story

In the evenings at Nora's kitchen table, with the phone propped against the fruit bowl, Alice reviews her cards. She updates the clumsy example sentence on echar de menos and adds new cards from dinner conversations: sobremesa has no direct English translation, so she writes time spent talking at the table after a meal. The counter reads "41 waiting" by Friday, a number she has stopped reading as a warning. It isn't one. It is an inventory of things the app is keeping for her.

Pro tip

Never dress pending up as an error state offline. Forty-one held writes on day five is the system working exactly as designed — show it like a draft count, not a failure count.

Forty-one operations, one week, two versions of a few shared cards — because Alice's study group kept going while she was in the hills. The bus back down is where the deck finds out.

Reference: store-make, indexeddb-make, indexeddb-config-type, expiry-type, store-expiry-type, tick, status →

07

When the world returns

Halfway down the valley, the phone finds a bar of signal. Forty-one operations begin reaching the server in the order Alice made them, while ordinary query refresh brings back the week as the server now knows it. Most of the reunion passes without a ripple — saved writes leave the outbox and fresh rows settle into their queries. This chapter is about the handful that collide.

While Alice was in the hills, her study group kept editing the shared deck. Nadia rewrote the example sentence on echar de menos — the same card Alice rewrote at Nora's table. Two honest edits, one card. What an app does next shows how much it cares.

The common answers are both small betrayals: refetch and let the server's copy silently replace Alice's week, or surface a raw "409 Conflict" and make the app's problem hers. The design here refuses both, using an old idea from version control: never compare two versions when you can compare three.

Three versions on the table

When a fetched value, inbound delivery, or conflict response meets a row already known locally, the store calls the merge function given to Store.make for two related jobs. A successful save confirmation is already authoritative and replaces the row directly.

  1. fold remote fields into the existing object in place, preserving its reactive identity, and
  2. decide whether the local and remote histories can be reconciled.

An updated change carries the base and the edit made from it; remote is the server's version. That provides the full three-way setup — base, yours, theirs. A true conflict exists only where the same field changed on both sides in different ways:

Example
const conflicts = <T>(base: T, mine: T, theirs: T) =>
  mine !== base && theirs !== base && mine !== theirs;
let conflicts = (~base, ~mine, ~theirs) =>
  mine !== base && theirs !== base && mine !== theirs

Applied field by field, this rule separates disagreement from mere concurrency. If only Nadia changed the example, her sentence can be folded into Alice's existing object. If only Alice changed the interval, her edit remains. The Change reference gives the exact shapes for clean rows, creations, updates, and removals; the design purpose is the same in each case: preserve identity, preserve every distinct edit, and ask a human only when the histories truly disagree.

Return true, and the merged value stands. If this happened while an operation was pending, that operation has been rebased and can be tried against the newer server version. Nadia can fix the article while Alice tunes the interval, and both edits simply coexist — nobody needs to hear about a disagreement because there was none. Return false, and the store keeps server truth as the visible value and records Alice's version separately, with nothing thrown away.

When a human must choose

Recorded disagreements — and mutations the server definitively refuses — land in store.status.rejected. Each record carries the local side of its story: what the row was, what was written, and the server's message when there is one. Server truth is already visible through the query. The records remain in outbox order even if replies arrived differently, so a chain of dependent edits can be considered cause first.

Example
const tryAgain = (rejection: Rejection<Card>) =>
  store.retry(rejection);

const keepTheirs = (rejection: Rejection<Card>) =>
  store.discard(rejection);

const saveResolution = (draft: Card) => store.upsert(draft);
let tryAgain = rejection => store.retry(rejection)

let keepTheirs = rejection => store.discard(rejection)

let saveResolution = draft => store.upsert(draft)

retry is for work that should be attempted again: perhaps Alice signed in again after a policy rejection. It drops that rejection, reapplies its saved edit against the value visible now, and queues a new ordinary operation. discard accepts the visible server truth and drops only the recovery record. When a human composes a third version, as Alice will, an ordinary upsert writes it and clears the older rejection for that card; there is no special conflict-writing mode.

The exact rejection object matters. Recovery acts on the record the screen was actually showing; if a later write has already cleared or replaced it, a stale button does nothing. That small rule prevents an old dialog from reviving an older history.

The invariant underneath is the one from chapter 1 — no version is ever silently lost. The server's week is in the deck; Alice's week is either merged in or held, verbatim, in a context waiting for her eyes.

Story

One card interrupts the bus ride: echar de menos, her sentence and Nadia's, side by side. Nadia's verb is better; Alice's ending is funnier. She takes thirty seconds to weave them into one sentence neither of them wrote, taps keep, and the deck moves on — one question asked, out of forty-one writes and a week apart.

Pro tip

Design the conflict screen before you need it, in domain language — "two versions of this card" beats "sync error". If the screen is kind, conflicts stop being failures and become what they actually are: two people caring about the same thing.

The trip is over: tunnels, buses, a week in the hills, two devices, one disagreement, zero losses. What remains is to step back and see what was actually built — and what any stack, with or without this library, ought to promise.

Reference: store-make, change-type, rejection-type, status-type, status, retry, discard →

08

Onward

Step back and look at what Alice's app is made of now. Components read cards.array({deck: "spanish"}); actions write through store.upsert(card); recovery reads store.status and chooses retry or discard. Nothing in the feature code mentions tunnels, buses, outboxes, or Spain. One [cards, store] pair, a Store.make backend, IndexedDB persistence, and cards.tick() on the app's own clock carry the entire trip — and the mental model compresses well:

  • Reads grow in strength. partial, local, and fresh distinguish a useful fragment, a complete memory, and an answer authoritative now.
  • Nothing has a reason. offline, failed, and noMatch lead to different humane interfaces; none asks the app to infer meaning from absence.
  • A mutation accepted is a mutation kept. It is applied optimistically, queued in order, persisted across restarts, and retried after reconnect without allowing later work to overtake it.
  • The server is the meeting point. Devices remember independently and meet through ordinary refresh and reconnect; no cache pretends to be the owner.
  • Disagreement is data. Base, yours, theirs: merge what merges, and hold the rest for an explicit retry, discard, or human resolution, with no version silently lost.

None of these required @tilia/query. They required deciding that a spinner in front of cached data is a small unkindness, that an edit accepted is a promise, that a conflict is two people caring about the same thing. The library is one careful implementation of those decisions; the decisions travel to any stack. If you build this lifecycle yourself, build it to these rules — your users will not know the words, but they will feel the difference on every train.

Kept honest

Behavior like "a mutation made offline survives a restart" is exactly the kind of claim that rots in prose. In the épure toolset it doesn't stay prose — the engine's behavior is pinned by an executable specification, scenarios first, in the shape @epure/vitest runs:

Contract
Scenario: A mutation made offline survives a restart
  Given the phone is offline
  When Alice saves the card "gato"
  And the application restarts
  Then the store has 1 pending operation
  And the query includes "gato" in the "spanish" deck

Specification-first is how the offline promises stay promises while the implementation moves.

Where to go from here

The @tilia/query API reference documents the public surface with TypeScript and ReScript signatures. It is the place to look up a precise outcome or type after this guide has given it a purpose.

The reactivity underneath — why reading is subscribing, why identity means no wasted repaints — is the tilia guide, and its complete surface is in the tilia API reference. This guide leaned on it in every chapter. Both libraries are open source at github.com/tiliajs, and the method they serve — software drawn before it is built — is the épure project.

Story

The train home. Tunnels again — Alice doesn't look up. Somewhere under her thumbs a queue is holding her words like sap through winter, and she has no idea, and that is the highest compliment an architecture ever gets.

Reference: make, store-make, tilia-query-type, claim-type, reason-type, retry, discard, indexeddb-make →