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.
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
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
undefinedis 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.
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| Operator | Matches |
|---|---|
eq | equals the value (same as passing the value) |
not | does not equal the value |
in, notIn | equals one of, or none of, a list of values |
gt, gte, lt, lte | compares, 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 } })andwhere({ title: { in: [1] } })do not compile. nulland absent.nullis a value; an absent optional field isundefined, and the two are different.{ notes: null }and{ notes: { in: [null] } }matchnullonly; for an optional field,{ in: [undefined, 3] }matches absent.notandnotInare the exact complement, so{ not: 'a' }also matches rows where the field isnullor absent.- Comparisons skip what cannot be compared.
null, absent fields and values of another kind never matchgt,gte,ltorlte. Strings compare by code unit, as inorderBy. - Deleted rows are in no result,
notincluded. Live queries drop them as they are deleted. - An
undefinedoperand is ignored, like anundefinedfield. A bad operand (aninthat is not a list, a comparison with a boolean) throws aTypeError. - 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
sync.tasks.all().orderBy('done').orderBy('title', 'desc');- The first
orderBysorts; 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:
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 ofIntl.Collator. It applies to string values only; other values keep their order. - An invalid locale tag throws a
RangeErrorwhen 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. Checktypeof Intlif 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
localewhen the order must match everywhere.
Limiting: limit and first
sync.tasks.all().orderBy('title').limit(10); // at most 10 rows
sync.projects.where({ name: 'Japan' }).first(); // a query of one row, or nullfirst() is a query too: get() returns a row or null, and subscribe hears when that changes.
The last limit of a chain wins.
Results
| Member | What 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 |
key | Stable 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.
sync.tasks.where({ done: false }) === sync.tasks.where({ done: false }); // true
sync.tasks.where((task) => !task.done) === sync.tasks.where((task) => !task.done); // falseSo 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()ortx.tasks.get(id): they include the transaction's own writes. See Transactions.