DocsDevelopers

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.

callrequest
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.auth is observable: token, record, isValid, clear(). With a storage (localStorage in 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
classforcomplete
LiveListone page of a collection, with its totalyes, paged
LiveChildrenevery child of one record, pagedyes, paged
list.refTo(target, field)the records the rows on screen point at, one query per 100 idsyes
list.topChildrenFrom(child, field)the first take children of each row, for a cellno: it says when it cut
LiveAggregatethe grouped rows of a viewthe server's, or null
LiveAggregateQuerya custom route that runs the SQL over the user's filterthe server's, or null
LiveScanAggregatea narrow read of every matching row, reduced in the pagea 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, setSort and setPerPage go back to page 1 and forget the old total.
  • perPage stops at 500, the server's page ceiling.
  • The filter builders bind every value: and, or, eq, cmp, facet (empty means no filter), search, ownedBy and idIn (empty means NOTHING), eqAny, eqAnyRef, withoutBackRef.
  • A multiple relation matches through its path: eqAnyRef('tags', id) is tags.id ?= id.

Aggregates#

Pick the tier by who decides the grouping:

  1. The app decides it: a view in schema.ts does the arithmetic, LiveAggregate reads it.
  2. The user composes the filter: a custom route runs the SQL over it, LiveAggregateQuery calls it.
  3. Neither exists yet: LiveScanAggregate reads 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 one POST /api/batch. A refusal shows the server's field errors through notify and 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.
  • create and delete refetch 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 }]

This page as markdown