Client and data layer
The typed fetch client speaks PocketBase's wire; the MobX data layer turns it into observable lists, records and aggregates that the server pages, filters and sums.
locosdk/client has two layers. The client is a typed fetch wrapper over the REST API. The data layer, createDb, gives observable objects that own their query, their page, their total, their loading state and their error. A page reads getters and never slices data itself.
The client#
import { createClient } from 'locosdk/client'
import { schema } from '../schema.ts'
const client = createClient(location.origin, { schema })
const orders = client.collection('orders')
const order = await orders.getOne(id, { expand: 'client_ref' })
order.expand?.client_ref?.name // typed through the relation
A collection name outside the schema fails the typecheck. A literal fields list types the answer: getOne(id, { fields: 'id,label' }) is a Pick of those two keys.
| call | request |
|---|---|
getList(page, perPage, { filter, sort, fields, expand, skipTotal }) | GET /api/collections/:c/records |
getOne(id, { fields, expand }) | GET /api/collections/:c/records/:id |
getFirst(filter, opts) | a one row list; a 404 ClientError when empty |
create(data), update(id, data), delete(id) | POST, PATCH, DELETE |
client.batch() then .collection(c).create/update/delete(...) and .send() | POST /api/batch, one transaction |
authWithPassword(identity, password) | sign in |
requestOTP(email), authWithOTP(otpId, code, { mfaId }) | sign in with a code |
authRefresh() | a fresh token |
requestPasswordReset(email), confirmPasswordReset(token, password, passwordConfirm) | password reset |
requestEmailChange(newEmail), confirmEmailChange(token, password) | email change |
client.fileToken(), client.fileUrl(record, filename, { token }) | protected files |
client.send(path, { method, query, body }) | any custom route |
- A non 2xx answer throws
ClientError { status, response, url }with the server's message. client.authis observable:token,record,isValid,clear(). With astorage(localStoragein a browser) it persists the session.
The second factor is in the type#
authWithPassword resolves { token, record } or { mfaId }. The server answers 401 both for a wrong password and for a right password that still needs a code. A caller that only catches errors tells a user with the right password that it is wrong. With the union, the compiler makes you handle the code step.
Files ride in the data#
A File or Blob on a file field turns the write into multipart by itself:
await orders.update(id, { scan: file }) // a single file field
await orders.update(id, { 'docs+': [a, b] }) // append to a multiple field
await orders.update(id, { 'docs-': ['old_x1.pdf'] }) // remove by stored name
await orders.update(id, { scan: '' }) // clear a single field
+ and - also add to and subtract from a number, a multiple select and a multiple relation.
The data layer#
import { createDb } from 'locosdk/client'
import { notify } from 'locosdk/ui'
const db = createDb(client, { notify })
db.orders // one ObservableCollection per name, made on first read
db.$client // the client
| class | for | complete |
|---|---|---|
LiveList | one page of a collection, with its total | yes, paged |
LiveChildren | every child of one record, paged | yes, paged |
list.refTo(target, field) | the records the rows on screen point at, one query per 100 ids | yes |
list.topChildrenFrom(child, field) | the first take children of each row, for a cell | no: it says when it cut |
LiveAggregate | the grouped rows of a view | the server's, or null |
LiveAggregateQuery | a custom route that runs the SQL over the user's filter | the server's, or null |
LiveScanAggregate | a narrow read of every matching row, reduced in the page | a floor past limit, flagged |
Lists#
const list = new LiveList(db.orders, { perPage: 50, sort: '-created', debounceMs: 300 })
list.setFilter(and(facet('status', ['open']), search(['label', 'client_ref.name'], needle)))
list.rows // the page
list.total // the server's count
const all = await list.fetchAll({ fields: 'id,label,amount' }) // { rows, total, truncated }
setFilter,setSortandsetPerPagego back to page 1 and forget the old total.perPagestops at 500, the server's page ceiling.- The filter builders bind every value:
and,or,eq,cmp,facet(empty means no filter),search,ownedByandidIn(empty means NOTHING),eqAny,eqAnyRef,withoutBackRef. - A multiple relation matches through its path:
eqAnyRef('tags', id)istags.id ?= id.
Aggregates#
Pick the tier by who decides the grouping:
- The app decides it: a
viewinschema.tsdoes the arithmetic,LiveAggregatereads it. - The user composes the filter: a custom route runs the SQL over it,
LiveAggregateQuerycalls it. - Neither exists yet:
LiveScanAggregatereads one column of every matching row. It costs one row per record and says when it stopped at its limit.
Every aggregate answers null until the server proved it. Never write ?? 0: a zero nobody computed is a lie on screen. The two server tiers take watch, the collections whose writes move the number.
const byStatus = new LiveAggregate(db.orders_by_status, { watch: [db.orders] })
const late = byStatus.by('status', 'late')?.count ?? null
Writes#
db.orders.update(id, patch)changes the cached record at once, then sends. Writes to one collection inside 50 ms go as onePOST /api/batch. A refusal shows the server's field errors throughnotifyand puts the server's value back.- A write with a file goes alone and at once, as multipart.
record.updateAndSave(patch, 500)is the form autosave: debounced, it sends only the changed fields and rolls back on refusal.record.assign(patch)changes the cache only.save(),rollback(),delete()act on it.createanddeleterefetch the lists; an edit refetches only the aggregates.
Query#
Query<T>(load) is the one async primitive: observable result, error, isRunning, run() to refresh. A stale answer never overwrites a newer one. Make it once and keep it: a new Query inside a render fetches on every render.
Tests#
createStub({ schema }) is the wire in memory. Hand stub.fetch to createClient, seed stub.lists, answer queries with stub.listResponders, then assert what was ASKED: stub.listCalls (page, filter, sort, fields), stub.sendCalls, stub.requests. It takes multipart writes and password resets like the server.
const stub = createStub({ schema })
const client = createClient('https://orders.example.com', { schema, fetch: stub.fetch })
stub.lists.orders = [{ id: 'o1', label: 'A', status: 'open', amount: 10 }]