Guide·@tilia/query·Guided tour

The Guide

A reading path through @tilia/query, in order: remote data that reads like local state, then queries and views, the two-tier read, writing while offline, conflicts, and the adapter boundary. Read it top to bottom once. When you need to look something up 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 list data, cache it, refresh it when it goes stale, merge live updates, and — if the app must keep working when the network becomes flaky or absent — keep local data safe until it returns. Each hand-rolled copy of that lifecycle 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 this 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 always knows whether what it shows is confirmed current or served from memory of the last visit, and 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 (HTTP, WebSocket, a sync engine), your storage (IndexedDB, SQLite, a file), your domain's query language, or even the clock — the app calls tick() from whatever scheduler it already has. All of those arrive as small adapters you write once per data source. 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.

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, while on 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 twice, writes that outlive the connection, changing devices, a week without a signal, and what happens when the server disagrees. If you decide for your team, this chapter and the last may be all you need. The chapters between are for whoever will build. And even a team that never adopts the library can adopt the rules, and the care.

Reference: 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 remote adaptor is required too, but it describes 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 } from "@tilia/query";

type DeckQuery = { deck: string };

const cards = make<Card, DeckQuery>({
  id: (card) => card.id,
  matches: (query, card) => card.deck === query.deck,
  remote: {
    online, // a tilia signal — chapter 4
    fetch: (query, channel) =>
      api.deckCards(query.deck, {
        onSuccess: channel.set,
        onFail: channel.fail,
      }),
    push: (ops, channel) => api.push(ops, channel), // chapter 4
  },
  local: cardStore, // a small adaptor over the device's storage — chapter 6
});
open TiliaQuery

type deckQuery = {deck: string}

let cards = make({
  id: card => card.id,
  matches: (query, card) => card.deck === query.deck,
  remote: {
    online, // a tilia signal — chapter 4
    fetch: (query, channel) =>
      Api.deckCards(query.deck, ~onSuccess=channel.set, ~onFail=channel.fail),
    push: (ops, channel) => Api.push(ops, channel), // chapter 4
  },
  local: cardStore, // a small adaptor over the device's storage — chapter 6
})

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 fetch, 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 arrives, when a live update lands — 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 query whose membership cannot be expressed per row (a limit, a page, an aggregate) belongs in a domain adaptor of its own, not in this shape.

Reading is asking

Two readers cover collection data: array returns a query's results, one returns the first result. Both are reactive tilia values — read them in a component or an observer and the subscription is the reading, exactly as in tilia:

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

const DeckView = leaf(() => {
  const result = cards.array({ deck: "spanish" });
  switch (result) {
    case "loading":
      return <Skeleton />;
    case "notFound":
    case "notLocal":
      return <EmptyState />;
    default:
      if (result.state === "failed") return <Retryable message={result.message} />;
      return <Deck cards={result.data} dim={!result.fresh} />;
  }
});
open TiliaReact

@react.component
let make = leaf(() => {
  switch cards.array({deck: "spanish"}) {
  | Loading => <Skeleton />
  | NotFound | NotLocal => <EmptyState />
  | Failed({message}) => <Retryable message />
  | Loaded({data, fresh}) => <Deck cards=data dim={!fresh} />
  }
})

The result is a loadable: a value with a lifecycle. Five answers are possible, and in ReScript the compiler holds you to all of them; the next chapter gives each one its precise meaning.

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 a select with a query literal in a component, and it keeps the query shape in one place when it evolves.

Two functions, two readers, one config. What that config gives back becomes visible the first time the app opens somewhere slow. Because now a read does not answer once. It answers twice.

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

03

Reads answer twice

Open a query and two reads start at once: the local store answers from what the device already holds, and the remote answers from the truth. The local answer arrives in milliseconds; the remote takes whatever the network takes. The user sees the first and is quietly upgraded to the second:

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

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.

fresh is about knowledge, not location

The fresh field does not say where the rows are stored. It says whether the value is known to be current. A UI can whisper that distinction — dim the deck a shade, show a small dot. When a fresh remote result lands, it becomes the visible one, and its rows are written through to the local store, so tomorrow's cold start answers from today's truth.

When local storage cannot answer a query, it calls unknown. While online, that leaves the query Loading until the remote responds; offline it becomes NotLocal. A local set([]) is different: it is a known empty cached result, so array answers Loaded and one answers NotFound.

Five answers, each a sentence

A loadable never makes the reader guess. Each state is a complete sentence:

  • Loading — an answer may still be coming. Shown only when there is truly nothing to show yet.
  • Loaded, fresh: false — here is what we know; we are checking.
  • Loaded, fresh: true — this is current.
  • NotFound — a source answered with an empty result. An answer, from one.
  • NotLocal — the device is offline and holds nothing for this query. Also an answer, not a progress state: the app can say "not available offline" instead of spinning forever.
  • Failed — the remote fetch broke, and the message surfaces at the read site, where the value is used. There is no global error slot, and the query is not stuck: it re-enters the refresh cycle and retries.

The heartbeat

Who decides when "fresh" stops being true? 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 (30 seconds by default, while online), results nobody watches are let go from memory, old local data is eventually purged.

"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 refetch returning the same rows changes nothing: the result keeps its identity, and nothing re-renders. Background freshness is free at the UI layer — you never pay a repaint for learning that nothing changed.

Story

The 8:04 train pulls out. Alice opens the laptop before the wifi has decided whether it exists; the Spanish deck is simply there, yesterday's copy, a shade dimmer if you know where to look. Three stops later, something on the screen barely brightens. All along, she never saw a spinner, because there was none.

Reading is now settled: local answers, remote confirms, the app tells the truth about which is which. The train, meanwhile, is heading for the mountains — and the first tunnel is about mutations.

Reference: loadable-type, local-channel-type, expiry-type, tick, canopy-type, canopy

04

Tunnels

The engine never guesses about connectivity. The application owns a tilia signal and tells the state as it changes:

Example
import { signal } from "tilia";

const [online, setOnline] = signal(navigator.onLine);
window.addEventListener("online", () => setOnline(true));
window.addEventListener("offline", () => setOnline(false));
open Tilia

let (online, setOnline) = signal(true)
window.addEventListener("online", () => setOnline(true))
window.addEventListener("offline", () => setOnline(false))

That signal went into the config in chapter 2. Everything this chapter describes hangs off it and off one decision about mutations.

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) =>
  cards.upsert({
    ...card,
    interval: result === "Pass" ? card.interval * 2 : 1,
    lastReview: clock.today,
  });
let review = (card, result) =>
  cards.upsert({
    ...card,
    interval: result === Pass ? card.interval * 2 : 1,
    lastReview: clock.today,
  })

upsert does four things, in order. It updates the value in memory and in the local store. 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, no refetch. It appends the operation to the outbox. And if the remote is online, it pushes. Voilà.

Note what is not in that list: waiting. This is where you win your users. The write path never blocks on the network. The app is snappy by construction, and offline support stops being a mode — it is just the case where step four waits.

The outbox

The outbox is an ordered queue of operations the remote has not yet confirmed. Each mutation gets a sequence number; when the local store is configured, each is persisted, so pending writes survive a restart. The queue is visible as one observable number:

Example
cards.status.pending; // operations waiting for the remote — reactive
cards.status.pending // operations waiting for the remote — reactive

When the connection returns, the signal flips to true and pending operations are pushed as one ordered batch. The remote confirms each one; a confirmed upsert comes back with the authoritative value, because the server may have corrected it, and that value is merged into memory and local storage.

Confirmed operations leave the outbox, pending counts down, and the app is exactly where it would be if the network had never left. A transient failure just returns the unconfirmed part of the batch to pending for a later try. A failure that is not transient reverts each unconfirmed optimistic change to remote truth and records its context; chapter 7 explains what the application does with it.

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 review 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: upsert, remove, op-type, status-type, status, write-channel-type

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 local store, holding whatever it saw last. At the station it gets one bar of signal and a minute of attention: the queries Alice opens answer instantly from the phone's local copy, refresh from the server, and — because remote results are written through — the phone's store now carries the deck as the laptop left it. Then the bus turns into the hills and the signal dies, and none of that matters anymore.

Multi-device support, offline support, and plain cache correctness turn out to be the same discipline. There is one truth, at the meeting point; everything else is a device remembering.

When the server speaks first

So far the remote only ever answered questions. Real backends also volunteer facts — a websocket delivery, a sync engine's notification. Those enter through receive:

Example
// values that changed
socket.on("cards.changed", cards.receive.changed);
// ids that were deleted
socket.on("cards.removed", cards.receive.removed);
// values that changed
Socket.on(socket, "cards.changed", cards.receive.changed) 
// ids that were deleted
Socket.on(socket, "cards.removed", cards.receive.removed)

Deliveries are past tense on purpose: facts about the server, not commands to it. A changed value is offered to every in-memory query through matches — it joins the results it now belongs to and leaves the ones it no longer does, the same membership logic mutations use. A removed id leaves every result. A changed value that lands on a row with an unconfirmed local edit goes through the merge machinery, while a server removal against a pending create or update records a conflict and keeps the removal visible. A pending write is never silently clobbered by an incoming fact. Chapter 7 owns that story.

Queries that stay fresh on their own

When a source pushes complete results (a server subscription per query) the adaptor answers through channel.live instead of channel.set, calling it again on every update, and registers its cleanup with channel.finally:

Example
fetch: (query, channel) => {
  const sub = socket.subscribe(deckTopic(query), channel.live);
  channel.finally(() => sub.close());
},
fetch: (query, channel) => {
  let sub = Socket.subscribe(socket, deckTopic(query), channel.live)
  channel.finally(() => sub->Subscription.close)
},

live tells the engine the source keeps this result fresh, so the periodic refresh skips it. finally hands the engine the teardown, and the engine runs it exactly once, when the fetch closes — superseded, retired from memory, or disposed. Late replies on a closed fetch are ignored wholesale.

Story

Alice buys a terrible coffee, thumbs the phone awake in the bus queue, and the deck is already mid-thought — the queue starts where the laptop stopped, 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

Keep your protocol past tense too. A message named cardChanged carries a fact and can be replayed, reordered, or ignored safely; a message named changeCard is a command.

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: receive-type, receive-changed, receive-removed, read-channel-type, remote-type, dispose

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 the app code, notably, does nothing special about any of it. Nothing in Alice's features branches on connectivity. The engine and one adaptor absorb the whole week.

The local adaptor

The durable half of every promise so far lives behind the local config: a table for rows, plus a small string store for the engine bookkeeping (query records, outbox entries). The adaptor is command-only plumbing over whatever the platform offers (IndexedDB in a browser, SQLite in an app), written once:

Example
const cardStore: Local<Card, DeckQuery> = {
  fetch, // answer a query from the values table, or say unknown()
  push,  // apply ordered mutations
  set,   // engine bookkeeping: write one entry
  get,   // engine bookkeeping: read entries back
  ids,   // list every stored row id (for the purge below)
};
let cardStore: TiliaQuery.local<deckQuery, card> = {
  fetch, // answer a query from the values table, or say unknown()
  push,  // apply ordered mutations
  set,   // engine bookkeeping: write one entry
  get,   // engine bookkeeping: read entries back
  ids,   // list every stored row id (for the purge below)
}

With it configured, the week holds together by construction. Reads answer from the values table — Loaded, honest fresh: false, all week. Writes apply to memory and disk, and their outbox entries are persisted too. When the phone dies at two percent and restarts, the outbox loads back in sequence order and the app resumes as if nothing happened, because as far as the data is concerned, nothing did. The outbox is data, not process state.

Three clocks

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

Example
expiry: {
  refresh: 30_000,            // is this result current? — 30 seconds
  memory: 300_000,            // should this be kept in RAM? - 5 minutes
  local: 30 * 24 * 3_600_000, // should local store keep this? - 30 days
},
expiry: {
  refresh: 30_000,            // is this result current? — 30 seconds
  memory: 300_000,            // should this be kept in RAM? - 5 minutes
  local: 30 * 24 * 3_600_000, // should local store keep this? - 30 days
},

Expiring one layer never implies expiring another. A result going stale (refresh) does not evict it from RAM; leaving RAM (memory) does not touch the disk; and the disk (local) forgets only what was unseen for weeks.

The local entry (30 days) measures not used, not time offline. Each time Alice views the deck, its persisted query is seen again, so its cards remain on disk even after months without connectivity. Only a query left unopened for thirty days becomes eligible for purging.

The disk clock comes with a garbage collector: once in a while, a purge walks the persisted query records, marks every row a retained query still references, and sweeps the rest — mark and sweep, the same reachability idea as any GC. One guarantee in it matters more than the mechanism: pending writes are roots. An edit that has not reached the server cannot be purged, ever, no matter how old the queries around it grow. Retention tidies memory of the server's data; it has no authority over promises not yet kept.

All of this runs inside the same tick() the app was already calling. No daemons, no timers of the engine's own — the week is maintained by the heartbeat.

Story

Evenings at Nora's kitchen table, the phone propped against the fruit bowl. Alice is reviewing 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 living while she was in the hills. The bus back down is where the deck finds out.

Reference: local-type, local-channel-type, expiry-type, tick

07

When the world returns

Halfway down the valley the phone finds a bar of signal, the connectivity signal flips, and two things happen at once: forty-one operations push to the server in the order Alice made them, and a week of the server's own history comes back the other way. Most of it passes without a ripple — confirmed writes leave the outbox, changed rows slot 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 her problem out of the app's. 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 remote value arrives for a row already known locally, the engine calls your merge function for two related jobs:

  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.

change tells the local story: Clean carries the current value with no local edit, Created a new local value, Updated the base and the edit made from it, and Removed the deleted value; remote is the server's version. For a conflict, Updated provides the full three-way setup — base, yours, theirs. A conflict only happens when the same field changed from both sides, away from each other:

Example
merge: (change, remote) => {
  switch (change.change) {
    case "clean": 
      // no local edit: fold the server's fields in place
      Object.assign(change.value, remote);
      return true;
    case "updated": {
      const { base, edited: mine } = change;
      for (const key of editableFields) {
        const iChanged = mine[key] !== base[key];
        const theyChanged = remote[key] !== base[key];
        if (iChanged && theyChanged && mine[key] !== remote[key]) return false;
        if (theyChanged) mine[key] = remote[key];
      }
      // both edits survive, in one card
      return true;
    }
    case "created":
      // the server already has this id: same card, or a conflict
      return editableFields.every((key) => change.edited[key] === remote[key]);
    case "removed":
      // keep the freshest version under the pending remove
      Object.assign(change.base, remote);
      return true;
  }
},
merge: (~change, ~remote) =>
  switch change {
  | Clean({value}) =>
    // fold the server's fields in place
    value.example = remote.example 
    true
  | Updated({base, edited: mine}) =>
    if mine.example !== base.example && remote.example !== base.example {
      mine.example === remote.example // the same rewrite is no conflict
    } else {
      if remote.example !== base.example {
        mine.example = remote.example
      }
      // both edits survive, in one card
      true 
      // …the other fields follow the same three-way rule
    }
    // same card, or a conflict
  | Created({edited}) => edited.example === remote.example 
  | Removed({base}) =>
    // keep the freshest version under the pending remove
    base.example = remote.example 
    true
  },

Return true and the merged value stands: Nadia fixed the article on one card while Alice tuned its interval, and both changes simply coexist — nobody ever knows there was a disagreement, because there wasn't one. Return false and the engine keeps remote truth as the visible value and records the disagreement, with nothing thrown away.

When a human must choose

Recorded disagreements — and mutations the server definitively refuses at push time — land in status.rejected, each carrying the local side of its story: what the row was, what was written, and the server's message when there is one. Remote truth is already visible in the collection. The reactive list is the app's cue to ask, gently, with both versions on screen:

Example
const keepTheirs = (r: Rejection<Card>) => cards.dismiss(r);

const keepMine = (r: Rejection<Card>, edited: Card) => {
  cards.upsert(edited); // a newer write wins over an older rejection
  cards.dismiss(r);
};
let keepTheirs = r => cards.dismiss(r)

let keepMine = (r, edited) => {
  cards.upsert(edited) // a newer write wins over an older rejection
  cards.dismiss(r)
}

There is no special conflict-resolution mode: keeping your version is an ordinary write, pushed like any other. dismiss only retires the context once a human has resolved or ignored it; it neither retries nor changes data. 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: change-type, rejection-type, status-type, status, dismiss

08

Onward

Step back and look at what Alice's app is made of now. The components still read cards.array({deck: "spanish"}) and render what comes back. The review action still writes a card. Nothing in the feature code mentions tunnels, buses, outboxes, or Spain. One make() call, two small adaptors, and a tick() on the app's own clock carry the entire trip — and the mental model compresses well:

  • Reads answer twice. The device answers now, the network confirms later, and fresh says which answer you are looking at.
  • Offline is a state, not an error. Every loadable state is a complete sentence; NotLocal is an answer, not an apology.
  • A mutation accepted is a mutation kept. Applied to memory and disk immediately, queued in order, pushed at reconnection, safe across restarts — and never garbage-collected while unsent.
  • The server is the meeting point. Devices are just places where the data is remembered; changing hands costs nothing because no cache pretends to be the owner.
  • Disagreement is data. Base, yours, theirs: merge what merges, and hold the rest — verbatim — for a human, 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 vitest-bdd runs:

Contract
Scenario: A mutation made offline survives a restart
  Given the remote is offline
  When Alice upserts the card "gato"
  And the application restarts
  Then 1 operation is pending
  And the card "gato" is 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 complete public surface — every function and type with its signature in both TypeScript and ReScript. It is the place for the precise rule wherever this guide chose the readable one.

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: tilia-query-type, make