Ferry
Client

Queries

A query reads the rows of one table that match, in order, and stays up to date: ask it for its result at any time, or subscribe to hear when the result changes.

Client
const open = sync.tasks.where({ projectId, done: false }).orderBy('title').limit(50);

open.get(); // Task[]: the current result
const stop = open.subscribe((tasks) => render(tasks)); // each new result, until stop()

Queries run in memory, on the device: no loading state, no network, and they work offline.

Filtering: where

Client
sync.tasks.where({ projectId, done: false }); // field equalities
sync.tasks.where((task) => task.title.includes(search)); // any predicate
sync.tasks.where({ projectId }).where((task) => task.notes !== null); // chained: AND
  • An object matches rows whose fields equal every value given (or satisfy their operators). Arrays and objects compare by content. A field whose value is undefined is ignored, so optional filters can be passed as they are: where({ projectId, done: showDone ? undefined : false }).
  • A predicate can test anything.
  • Chained wheres combine with AND. For OR, use a predicate.

Operators

A field can take an object of operators instead of a value. Every operator and every field must hold.

Client
sync.tasks.where({ title: { in: ['Visa', 'Hotel'] } }); // one of these
sync.tasks.where({ projectId, done: { not: true } }); // anything but true
sync.tasks.where({ title: { gte: 'A', lt: 'M' } }); // a range
sync.tasks.where({ notes: { not: null } }); // has notes
OperatorMatches
eqequals the value (same as passing the value)
notdoes not equal the value
in, notInequals one of, or none of, a list of values
gt, gte, lt, ltecompares, for fields of numbers or strings
  • Typed per field. Comparisons exist only on number and string fields, and every operand has the field's type: where({ done: { gt: true } }) and where({ title: { in: [1] } }) do not compile.
  • null and absent. null is a value; an absent optional field is undefined, and the two are different. { notes: null } and { notes: { in: [null] } } match null only; for an optional field, { in: [undefined, 3] } matches absent. not and notIn are the exact complement, so { not: 'a' } also matches rows where the field is null or absent.
  • Comparisons skip what cannot be compared. null, absent fields and values of another kind never match gt, gte, lt or lte. Strings compare by code unit, as in orderBy.
  • Deleted rows are in no result, not included. Live queries drop them as they are deleted.
  • An undefined operand is ignored, like an undefined field. A bad operand (an in that is not a list, a comparison with a boolean) throws a TypeError.
  • An object whose keys are all operator names is read as operators. To match a JSON-object field that looks like one, write { eq: value }.

Ordering: orderBy

Client
sync.tasks.all().orderBy('done').orderBy('title', 'desc');
  • The first orderBy sorts; each later one breaks the ties of the ones before; the row id breaks the rest. So results always come in the same order.
  • Without orderBy, rows come by id: creation order, since ids are UUIDv7.
  • Values of different kinds sort as: missing, booleans, numbers, strings, then arrays and objects.
  • Strings compare by code unit ('B' < 'a') unless you name a collation. For a locale's order, pass one:
Client
sync.tasks.all().orderBy('title', 'asc', { locale: 'sv' }); // Swedish rules: Ö after Z
sync.tasks.all().orderBy('title', 'asc', { locale: 'en', options: { numeric: true } }); // "Task 9" before "Task 10"
  • A collation is { locale?, options? }: a BCP 47 tag (the device's locale by default) and the options of Intl.Collator. It applies to string values only; other values keep their order.
  • An invalid locale tag throws a RangeError when the query is built.
  • Where the runtime has no Intl (some Hermes builds), the collation is ignored and the order is by code unit, as without it. Check typeof Intl if the order matters there.
  • Orders come from the device's own locale data, so two devices can order the same strings differently. Use a fixed locale when the order must match everywhere.

Limiting: limit and first

Client
sync.tasks.all().orderBy('title').limit(10); // at most 10 rows
sync.projects.where({ name: 'Japan' }).first(); // a query of one row, or null

first() is a query too: get() returns a row or null, and subscribe hears when that changes. The last limit of a chain wins.

Results

MemberWhat it does
get()The current result: a frozen array (or a row or null for first())
subscribe(listener)Calls listener with each new result, not with the current one. Returns stop
keyStable text for a query without predicates; null with one

A result is computed once and kept: get() returns the same array until a row of its table changes in a way that changes the result. Rows that did not change keep their objects too. That is what lets React skip re-renders: see useQuery.

The same chain is the same query

Queries made only of object filters, orders and limits are interned: writing the same chain again returns the same query object.

Client
sync.tasks.where({ done: false }) === sync.tasks.where({ done: false }); // true
sync.tasks.where((task) => !task.done) === sync.tasks.where((task) => !task.done); // false

So such a query can be written inline wherever it is needed, a React render included. A predicate query is new on every call: build it once (or, in React, with dependencies).

Gotchas

  • One table per query. There are no joins: read the project, then query its tasks. Both are in memory, so this costs nothing.
  • Queries see every row the device holds, which is every row the user can see. Ferry is built for data that fits in memory.
  • A query recomputes when its table changes, by scanning that table. Thousands of rows are fine; for a hot path over a huge table, keep a narrower query or a derived store.
  • Order is by value, not by locale or case, unless you pass a collation. See Ordering.
  • Inside a transaction, read with tx.tasks.all() or tx.tasks.get(id): they include the transaction's own writes. See Transactions.

On this page