Reference·@tilia/query·TypeScript & ReScript signatures

API Reference

The public surface of @tilia/query. Each entry gives the signature, behavior, and one minimal example.

make(config)

@tilia/querySince 0.1

Build a query engine and its application-facing store handle.

function make<T, Q, S>(config: Config<T, Q, S>): [TiliaQuery<T, Q>, S]
let make: config<'query, 'a, 'store> => (t<'query, 'a>, 'store)

make resolves the engine Config, creates its StoreFactory, and returns a tuple:

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";

type Card = { id: string; deck: string; front: string; back: string; seen: number };
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 }),
});

// 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 });
open TiliaQuery

type card = {id: string, deck: string, front: string, mutable back: string, mutable seen: int}
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}),
})

// 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(config)

@tilia/querySince 0.1

Build the shipped store from backend operations.

Store.make<T, Q>(config: StoreConfig<T, Q>): StoreFactory<T, Q, Store<T>>
Store.make: Store.config<'query, 'a> => store<'query, 'a, Store.t<'a>>

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 });
        else if (error.status >= 500) reply("transient");
        else reply({ 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(channels)

@tilia/querySince 0.1

Build the shipped store from advanced channels.

Store.custom<T, Q>(channels: StoreChannels<T, Q>): StoreFactory<T, Q, Store<T>>
Store.custom: Store.channels<'query, 'a> => store<'query, 'a, Store.t<'a>>

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()

@tilia/querySince 0.1

Create a process-local string keyspace.

Store.memory(): Kv
Store.memory: unit => Store.Kv.t

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()}),
})

IndexedDB make(config)

@tilia/querySince 0.1

Create a Kv backed by IndexedDB.

function make(config: Config): Kv
TiliaQueryIndexedDb.make: TiliaQueryIndexedDb.config => TiliaQueryStore.Kv.t

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(query)

@tilia/querySince 0.1

Reactively read the first sorted result.

one: (query: Q) => Loadable<T>
one: 'query => loadable<'a>

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".
const review = () => {
  const next = cards.one({ deck: "spanish" });
  if (next === "loading") return spinner();
  if (next.state === "loaded") return flashcard(next.data);
  if (next.reason === "offline") return offlineNotice();
  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(query)

@tilia/querySince 0.1

Reactively read a query's sorted result array.

array: (query: Q) => Loadable<T[]>
array: 'query => loadable<array<'a>>

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.
const deckView = () => {
  const deck = cards.array({ deck: "spanish" });
  if (deck === "loading") return spinner();
  if (deck.state === "noData") return empty(deck.reason);
  return list(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)))
  }

query.tick()

@tilia/querySince 0.1

Run the engine and store heartbeat.

tick: () => void
tick: unit => unit

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()

query.dispose()

@tilia/querySince 0.1

Close finds and dispose the store.

dispose: () => void
dispose: unit => unit

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.
const leave = () => {
  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)

store.upsert(value)

@tilia/querySince 0.1

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.
const pass = (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.
const add = (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})

store.remove(id)

@tilia/querySince 0.1

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`.
const drop = (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)

store.receive.changed(values)

@tilia/querySince 0.1

Apply changed values pushed by the server.

receive.changed: (values: T[]) => void
changed: array<'a> => unit

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))

store.receive.removed(ids)

@tilia/querySince 0.1

Apply removed ids pushed by the server.

receive.removed: (ids: string[]) => void
removed: array<string> => unit

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))

store.status

@tilia/querySince 0.1

Read reactive pending and rejected write state.

status: Status<T>
status: status<'a>

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

store.retry(rejection)

@tilia/querySince 0.1

Reapply and queue one rejected operation.

retry: (rejection: Rejection<T>) => void
retry: rejection<'a> => unit

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))
)

store.discard(rejection)

@tilia/querySince 0.1

Drop one rejection without retrying it.

discard: (rejection: Rejection<T>) => void
discard: rejection<'a> => unit

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(value)

@tilia/querySince 0.1

Deterministically serialize plain JSON data.

function sortedStringify(value: unknown): string
let sortedStringify: 'a => string

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.
const key = ({ 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})

query._canopy()

@tilia/querySince 0.1

Inspect observed and idle query keys.

_canopy: () => Canopy
_canopy: unit => canopy

_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

@tilia/querySince 0.1

Identify row entries in a persistent keyspace.

Store.rowTag: string
Store.rowTag: string

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()

@tilia/querySince 0.1

Return the shared browser connectivity signal.

Store.online(): Signal<boolean>
Store.online: unit => Tilia.signal<bool>

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

TiliaQuery

@tilia/querySince 0.1

Read-only query and lifecycle handle returned by make.

type TiliaQuery<T, Q> = {
  one: (query: Q) => Loadable<T>,
  array: (query: Q) => Loadable<T[]>,
  tick: () => void,
  dispose: () => void,
  _canopy: () => Canopy
}
type t<'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:

  • one and array perform reactive reads.
  • tick advances engine and store time.
  • dispose shuts both halves down.
  • _canopy exposes debug state.

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.
const dueCount = (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

@tilia/querySince 0.1

Configure the query engine and its store factory.

type Config<T, Q, S> = {
  id: (value: T) => string,
  matches: (query: Q, value: T) => boolean,
  store: StoreFactory<T, Q, S>,
  expiry?: Expiry,
  now?: () => number,
  key?: (query: Q) => string,
  sort?: (query: Q) => (values: T[]) => T[],
  onError?: (query: Q, message: string) => void
}
type config<'query, 'a, 'store> = {
  id: 'a => string,
  matches: ('query, 'a) => bool,
  store: store<'query, 'a, 'store>,
  expiry?: expiry,
  now?: unit => float,
  key?: 'query => string,
  sort?: 'query => array<'a> => array<'a>,
  onError?: (~query: 'query, ~message: string) => unit,
}

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.
  • key identifies a query; the default is sortedStringify.
  • 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}),
}

Loadable

@tilia/querySince 0.1

Represent waiting, available data, or an explained absence.

type Loadable<T> =
  | "loading"
  | { state: "loaded", claim: Claim, data: T }
  | { state: "noData", reason: Reason }
@tag("state")
type loadable<'a> =
  | @as("loading") Loading
  | @as("loaded") Loaded({claim: claim, data: 'a})
  | @as("noData") NoData({reason: reason})

Loadable is returned by one and array:

  • "loading" / Loading means no usable answer is available yet.
  • loaded / Loaded carries data and its Claim.
  • noData / NoData carries a Reason.

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") return spinner();
  if (value.state === "loaded") return view(value.data, value.claim);
  return empty(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)
  }

Claim

@tilia/querySince 0.1

State how complete and authoritative an answer is.

type Claim = "partial" | "local" | "fresh"
type claim =
  | @as("partial") Partial
  | @as("local") Local
  | @as("fresh") Fresh

Claim describes an answer's strength:

  • 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.
const badge = (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"
  }

Reason

@tilia/querySince 0.1

Explain why a read has no data.

type Reason =
  | "offline"
  | { reason: "failed", message: string }
  | { reason: "noMatch", claim: Claim }
@tag("reason")
type reason =
  | @as("failed") Failed({message: string})
  | @as("offline") Offline
  | @as("noMatch") NoMatch({claim: claim})

Reason is the payload of Loadable's noData state:

  • 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.
const explain = (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

@tilia/querySince 0.1

Describe one unconfirmed outbox operation.

type Op<T> =
  | { op: "upsert", value: T }
  | { op: "remove", id: string }
@tag("op")
type op<'a> =
  | @as("upsert") Upsert({value: 'a})
  | @as("remove") Remove({id: string})

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.
const send = (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

@tilia/querySince 0.1

Describe local context when a remote value arrives.

type Change<T> =
  | { change: "clean", value: T }
  | { change: "created", edited: T }
  | { change: "updated", base: T, edited: T }
  | { change: "removed", base: T }
@tag("change")
type change<'a> =
  | @as("clean") Clean({value: 'a})
  | @as("created") Created({edited: 'a})
  | @as("updated") Updated({base: 'a, edited: 'a})
  | @as("removed") Removed({base: 'a})

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.
const merge = (change: Change<Card>, remote: Card) => {
  switch (change.change) {
    case "clean":
      Object.assign(change.value, remote);
      return true;
    case "updated": {
      const { base, edited } = change;
      if (edited.seen === base.seen) edited.seen = remote.seen;
      if (edited.back === base.back) edited.back = remote.back;
      return true;
    }
    default:
      return false; // 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.seen
    true
  | 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

@tilia/querySince 0.1

Preserve context for a reverted optimistic operation.

type Rejection<T> =
  | { rejection: "createConflict", edited: T }
  | { rejection: "createFailed", edited: T, message: string }
  | { rejection: "updateConflict", base: T, edited: T }
  | { rejection: "updateFailed", base: T, edited: T, message: string }
  | { rejection: "removeConflict", base: T }
  | { rejection: "removeFailed", base: T, message: string }
@tag("rejection")
type rejection<'a> =
  | @as("createConflict") CreateConflict({edited: 'a})
  | @as("createFailed") CreateFailed({edited: 'a, message: string})
  | @as("updateConflict") UpdateConflict({base: 'a, edited: 'a})
  | @as("updateFailed") UpdateFailed({base: 'a, edited: 'a, message: string})
  | @as("removeConflict") RemoveConflict({base: 'a})
  | @as("removeFailed") RemoveFailed({base: 'a, message: string})

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.
const describe = (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

@tilia/querySince 0.1

Represent reactive outbox and rejection state.

type Status<T> = {
  pending: number,
  rejected: Rejection<T>[]
}
type status<'a> = {
  pending: int,
  rejected: array<rejection<'a>>,
}

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.

Read failures are returned through Loadable.

Example
// 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

Remote

@tilia/querySince 0.1

Connect Store.custom to an authoritative remote backend.

type Remote<T, Q> = {
  online: Signal<boolean>,
  fetch: (query: Q, channel: ReadChannel<T>) => void,
  push: (ops: Op<T>[], channel: WriteChannel<T>) => void
}
type remote<'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,
}

Receive

@tilia/querySince 0.1

Accept authoritative facts pushed by the server.

type Receive<T> = {
  changed: (values: T[]) => void,
  removed: (ids: string[]) => void
}
type receive<'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)

Store

@tilia/querySince 0.1

Application write and synchronization handle from the shipped store.

type Store<T> = {
  upsert: (value: T) => void,
  remove: (id: string) => void,
  receive: Receive<T>,
  status: Status<T>,
  retry: (rejection: Rejection<T>) => void,
  discard: (rejection: Rejection<T>) => void
}
type Store.t<'a> = {
  upsert: 'a => unit,
  remove: string => unit,
  receive: receive<'a>,
  status: status<'a>,
  retry: rejection<'a> => unit,
  discard: rejection<'a> => unit,
}

Store<T> / Store.t<'a> is the second tuple item returned by make when configured with Store.make or Store.custom.

  • upsert and remove perform optimistic writes.
  • 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.
const reviewer = (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

@tilia/querySince 0.1

Configure engine refresh and memory timing.

type Expiry = {
  refresh: number,
  memory: number
}
type expiry = {
  refresh: float,
  memory: float,
}

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.}

ReadChannel

@tilia/querySince 0.1

Answer and manage one remote find.

type ReadChannel<T> = {
  fresh: (values: T[]) => void,
  live: (values: T[]) => void,
  fail: (message: string) => void,
  end: () => void,
  finally: (teardown: () => void) => void
}
type Channel.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.
const fetch = (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)
  }

LocalChannel

@tilia/querySince 0.1

State what locally persisted rows can prove.

type LocalChannel<T> = {
  partial: (values: T[]) => void,
  local: (values: T[]) => void
}
type Channel.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.
const lookup = (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

Channel.find

@tilia/querySince 0.1

Combine local and authoritative answers for one engine find.

ReadChannel<T> & LocalChannel<T>
type Channel.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.
const find = (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
  }
}

WriteChannel

@tilia/querySince 0.1

Answer operations in a Remote.push batch.

type WriteChannel<T> = {
  set: (value: T) => void,
  removed: (id: string) => void,
  conflict: (value: T) => void,
  reject: (id: string, message: string) => void,
  retry: () => void,
  fail: (message: string) => void
}
type Channel.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.
const push = (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);
      else if (error.status >= 500) return channel.retry();
      else channel.reject(op.op === "remove" ? op.id : op.value.id, error.message);
    }
    return next(index + 1);
  };
  void next(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>) => {
  let rec 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

@tilia/querySince 0.1

List observed and cached query keys.

type Canopy = {
  live: string[],
  idle: string[]
}
type canopy = {
  live: array<string>,
  idle: array<string>,
}

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.
const report = ({ 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`

StoreConfig

@tilia/querySince 0.1

Configure Store.make with backend operations and storage.

type StoreConfig<T, Q> = {
  find: (query: Q, answer: Answer<T>) => void,
  upsert: (value: T, reply: (outcome: Outcome<T>) => void) => void,
  remove: (id: string, reply: (outcome: Removal) => void) => void,
  online?: Signal<boolean>,
  persist?: Kv,
  lookup?: (query: Q, channel: LocalChannel<T>) => void,
  merge?: (change: Change<T>, remote: T) => boolean,
  expiry?: StoreExpiry
}
type Store.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.

  • find answers with Answer.
  • upsert replies once with an Outcome.
  • remove replies once with a Removal.
  • online defaults to Store.online.
  • persist defaults to an in-memory Kv.
  • 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.},
}

Answer

@tilia/querySince 0.1

Answer one Store.make backend find.

type Answer<T> = {
  fresh: (values: T[]) => void,
  fail: (message: string) => void
}
type Store.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.
const find = (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

@tilia/querySince 0.1

Classify one Store.make upsert result.

type Outcome<T> =
  | { outcome: "saved", value: T }
  | { outcome: "conflict", value: T }
  | { outcome: "rejected", message: string }
  | "transient"
type Store.Outcome.t<'a> =
  | @as("saved") Saved({value: 'a})
  | @as("conflict") Conflict({value: 'a})
  | @as("rejected") Rejected({message: string})
  | @as("transient") Transient

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.
const upsert = (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 });
      else if (error.status >= 500) reply("transient");
      else reply({ 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

@tilia/querySince 0.1

Classify one Store.make remove result.

type Removal =
  | "removed"
  | { outcome: "rejected", message: string }
  | "transient"
type Store.Removal.t =
  | @as("removed") Removed
  | @as("rejected") Rejected({message: string})
  | @as("transient") Transient

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.
const remove = (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

StoreChannels

@tilia/querySince 0.1

Configure Store.custom with advanced channels and storage.

type StoreChannels<T, Q> = {
  remote: Remote<T, Q>,
  persist?: Kv,
  lookup?: (query: Q, channel: LocalChannel<T>) => void,
  merge?: (change: Change<T>, remote: T) => boolean,
  expiry?: StoreExpiry
}
type Store.channels<'query, 'a> = {
  remote: remote<'query, 'a>,
  persist?: Store.Kv.t,
  lookup?: ('query, Channel.local<'a>) => unit,
  merge?: (~change: change<'a>, ~remote: 'a) => bool,
  expiry?: Store.expiry,
}

StoreChannels configures Store.custom:

  • 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.},
}

StoreExpiry

@tilia/querySince 0.1

Configure retained query records in local storage.

type StoreExpiry = {
  local: number
}
type Store.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.},
})

Kv

@tilia/querySince 0.1

Persist tagged string entries for the shipped store.

type Kv = {
  get: (tag: string, keys: string[] | undefined, set: (values: string[]) => void) => void,
  keys: (tag: string, set: (keys: string[]) => void) => void,
  set: (tag: string, key: string, value: string | undefined) => void
}
type Store.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 = new Map<string, Map<string, string>>();
const under = (tag: string) => tags.get(tag) ?? tags.set(tag, new Map()).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);
    else under(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

@tilia/querySince 0.1

Share resolved collection behavior with a custom store.

type Schema<T, Q> = {
  id: (value: T) => string,
  matches: (query: Q, value: T) => boolean,
  key: (query: Q) => string,
  sort: (query: Q) => (values: T[]) => T[],
  now: () => number
}
type schema<'query, 'a> = {
  id: 'a => string,
  matches: ('query, 'a) => bool,
  key: 'query => string,
  sort: 'query => array<'a> => array<'a>,
  now: unit => float,
}

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.
const findIn = (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))))

Binding

@tilia/querySince 0.1

Let a custom store update engine rows safely.

type Binding<T> = {
  item: (id: string) => T | undefined,
  changed: (values: T[]) => void,
  removed: (ids: string[]) => void,
  place: (value: T) => void
}
type binding<'a> = {
  item: string => option<'a>,
  changed: array<'a> => unit,
  removed: array<string> => unit,
  place: 'a => unit,
}

Binding is passed to a custom StoreFactory:

  • 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 }]);
  });
  const write = (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})
}

Source

@tilia/querySince 0.1

Define the store side of the engine-store seam.

type Source<T, Q> = {
  online: Signal<boolean>,
  find: (query: Q, channel: ReadChannel<T> & LocalChannel<T>) => void,
  forget: (query: Q) => void,
  tick: () => void,
  dispose: () => void
}
type source<'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

@tilia/querySince 0.1

Connect a store to an initialized query engine.

type StoreFactory<T, Q, S> =
  (schema: Schema<T, Q>, binding: Binding<T>) => [Source<T, Q>, S]
type store<'query, 'a, 'store> =
  (schema<'query, 'a>, binding<'a>) => (source<'query, 'a>, 'store)

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),
})

IndexedDB config

@tilia/querySince 0.1

Configure the TypeScript/JavaScript IndexedDB keyspace.

type Config = {
  name: string,
  store?: string,
  version?: number,
  onError?: (error: Error) => void
}
type TiliaQueryIndexedDb.config = {
  name: string,
  store?: string,
  version?: float,
  onError?: JsError.t => unit,
}

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
import type { 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),
}