# 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

```ts
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.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:

```ts
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

```ts
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

```ts
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.

```ts
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.

```ts
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 }]
```
