lazypock 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -246,78 +246,152 @@ const session = await client.authWithPassword('users', 'ada@example.com', 'corre
246
246
  const whoami = await client.me(); // fresh record via GET /api/me
247
247
  ```
248
248
 
249
- ### `filter` / `sort` / `expand` — type-checked suggestions
249
+ ### Filtering, sorting & expanding
250
250
 
251
- With a typed service, the query options validate field names (and filter
252
- operators) at compile time — your editor suggests valid fields as you type:
251
+ Typed services validate every `filter` / `sort` / `expand` field at compile
252
+ time. Use the **array** forms of `sort`/`expand` and the typed `where()`
253
+ builder to get **autocomplete** while you type.
253
254
 
254
255
  ```typescript
255
- await postsSvc.getList(1, 20, { sort: '-title' }); // ✓ suggests title/published/…
256
- await postsSvc.getList(1, 20, { sort: '-nope' }); // ✗ compile error
257
- await postsSvc.getList(1, 20, { sort: 'title, nope' }); // ✗ every token checked
258
-
259
- await postsSvc.getList(1, 20, {
260
- filter: "title ~ 'x' && published = true", // ✓ every clause field + operator checked
261
- });
262
- await postsSvc.getList(1, 20, { filter: `title=${search}` }); // ✓ spaces optional
263
- await postsSvc.getList(1, 20, { filter: "(title = 'a' || title = 'b')" }); // ✓ parens
264
- await postsSvc.getList(1, 20, { filter: "author.email = 'x'" }); // ✓ relation dot-path
265
- await postsSvc.getList(1, 20, { filter: "tags ?= 'news'" }); // ✓ any/at-least-one-of
266
- await postsSvc.getList(1, 20, { filter: 'nope = 1' }); // ✗ compile error
267
- await postsSvc.getList(1, 20, {
268
- filter: "title = 'a' && nope = 'y'", // ✗ EVERY clause is validated
256
+ const q = postsSvc.where; // typed filter builder
257
+
258
+ await postsSvc.getList(1, 20, { sort: ['-created'] }); // sort
259
+ await postsSvc.getFullList({ filter: q('id').in(ids) }); // filter by ids
260
+ await postsSvc.getList(1, 20, { filter: q('title').contains(term) }); // search
261
+ await postsSvc.getFullList({
262
+ filter: q('published').eq(true),
263
+ expand: ['author.name'],
269
264
  });
265
+ ```
270
266
 
271
- await postsSvc.getList(1, 20, { expand: 'author' }); // ✓ field suggested
272
- await postsSvc.getList(1, 20, { expand: 'author.user' }); // ✓ nested dot-path
273
- await postsSvc.getList(1, 20, { expand: 'author, nope' }); // ✗ every token checked
274
- await postsSvc.getOne('abc', { expand: 'author' });
267
+ | I want to… | Do this |
268
+ | --- | --- |
269
+ | Sort newest first | `getList(1, 20, { sort: ['-created'] })` |
270
+ | Filter by a list of ids | `getFullList({ filter: postsSvc.where('id').in(ids) })` |
271
+ | Search a text field | `getList(1, 20, { filter: postsSvc.where('title').contains(term) })` |
272
+ | Both conditions (AND) | `q('published').eq(true).and(q('views').gt(100))` |
273
+ | Either condition (OR) | `q('a').eq(1).or(q('b').eq(2))` |
274
+ | Negate a condition | `q('archived').eq(true).not()` |
275
+ | Filter by relation id | `q('author').eq(userId)` |
276
+ | Expand a relation | `getList(1, 20, { expand: ['author'] })` |
277
+ | Only some expanded fields | `getList(1, 20, { expand: ['author.name'] })` |
278
+ | Return only some fields | `client.collection('posts').select('id', 'title').getList()` |
279
+
280
+ #### Sorting
275
281
 
276
- // Expanded records carry an `expand` property keyed by the requested fields,
277
- // typed to the target collection's record (codegen client):
278
- const posts = await postsSvc.getFullList({ expand: 'author' });
279
- posts[0].expand?.author?.email; // ✓ typed, not unknown
282
+ ```typescript
283
+ await postsSvc.getList(1, 20, { sort: '-created' }); // string
284
+ await postsSvc.getList(1, 20, { sort: ['title', '-published'] }); // array — suggests each field
280
285
  ```
281
286
 
282
- - `filter` — `field op value` clauses with `= != ~ !~ > >= < <=` operators,
283
- plus the PocketBase `?`-prefixed array operators `?= ?!= ?~ ?!~ ?> ?>= ?< ?<=`
284
- ("any/at-least-one-of" over multi-select / multiple relation / multiple file
285
- fields: `tags ?= 'news'`); spaces around the operator are optional; `&&`,
286
- `||`, `!`, and parentheses are allowed; relation dot-paths like
287
- `author.email = 'x'` typecheck. **Every** clause's field name and operator
288
- are validated — a typo in any clause is a compile error, and quoted values
289
- may contain `&&`/`||` (e.g. `title ~ 'a && b'`). Field names and operators
290
- are suggested as you type.
291
- - `sort` — `field`, `-field` (desc), `+field`, or comma-separated. **Every**
292
- token is validated.
293
- - `expand` — comma-separated relation field names, including nested dot-paths
294
- (`author.user`); **every** token is validated; non-relation fields warn at
295
- runtime when a schema is available.
296
-
297
- **Expanded records are typed.** When a list/read is called with `expand`,
298
- the returned records include an optional `expand` object whose keys are the
299
- requested top-level relation fields (`record.expand.author` — dot-paths
300
- collapse to their first segment). With the codegen client, each value is
301
- typed as its **target collection's record** — the generated `*ExpandMap`
302
- types resolve relation fields to their target record type, so
303
- `record.expand.user` is `UsersRecord`, not `unknown`:
287
+ `-field` = descending, `+field` / `field` = ascending. **Every** token is
288
+ validated (array and string alike); the array form adds per-field
289
+ autocomplete.
290
+
291
+ #### Filtering — string expressions
292
+
293
+ Full PocketBase syntax, validated clause by clause — a typo in any clause is
294
+ a compile error:
304
295
 
305
296
  ```typescript
306
- const posts = await postsSvc.getFullList({ expand: 'author' });
307
- posts[0].expand?.author?.email; // ✓ typed UsersRecord (auth → has email)
297
+ await postsSvc.getList(1, 20, { filter: "title ~ 'hello' && published = true" });
298
+ await postsSvc.getList(1, 20, { filter: "(title = 'a' || title = 'b')" });
299
+ await postsSvc.getList(1, 20, { filter: "tags ?= 'news'" }); // any array element
300
+ await postsSvc.getList(1, 20, { filter: "author.email = 'ada@example.com'" });
301
+ await postsSvc.getList(1, 20, { filter: "deleted_at = null" }); // IS NULL
308
302
  ```
309
303
 
310
- Multi-relations (`maxSelect > 1`) expand to `Array<TargetRecord>`, auth
311
- targets carry the `AuthRecord` fields, and unknown/unresolvable targets fall
312
- back to `unknown`. Hand-written services (no schema, e.g.
313
- `createClient<MyCollections>()`) keep `unknown` values.
304
+ > **Relation dot-paths** (`author.email = 'x'`, including multi-level paths)
305
+ > and `field = null` / `field != null` are supported by current LazyPock
306
+ > servers. On older servers a dot-path filter is rejected with HTTP `400` —
307
+ > filter by the relation id (`author = 'USER_ID'`) instead.
308
+
309
+ | Operator | Meaning |
310
+ | --- | --- |
311
+ | `=` `!=` | equal / not equal |
312
+ | `~` `!~` | contains / does not contain |
313
+ | `>` `>=` `<` `<=` | comparisons |
314
+ | `?=` `?!=` `?~` `?!~` `?>` `?>=` `?<` `?<=` | "any element matches" variants for array fields |
315
+
316
+ #### Filtering — typed builder
317
+
318
+ `service.where(field)` suggests field names, turns operators into methods, and
319
+ escapes values (no manual quoting):
320
+
321
+ ```typescript
322
+ const q = postsSvc.where;
323
+
324
+ q('title').eq('x'); // title = 'x'
325
+ q('title').contains('x'); // title ~ 'x'
326
+ q('published').eq(true); // published = true
327
+ q('views').gte(100); // views >= 100
328
+ q('title').eq(null); // title = null (is empty)
329
+ q('id').in(['a', 'b', 'c']); // (id = 'a' || id = 'b' || id = 'c') — list membership
330
+ q('id').notIn(['a', 'b']); // (id != 'a' && id != 'b')
331
+ q('tags').anyEq('news'); // any array element equals
332
+ q('author').eq(userId); // filter by relation id
333
+ q('author.email').eq('ada@example.com'); // relation dot-path (null + != supported too)
334
+
335
+ // combine — and() / or() / not()
336
+ q('title').contains('x').and(q('published').eq(true)); // (… && …)
337
+ q('a').eq(1).or(q('b').eq(2)).not(); // !((… || …))
338
+ ```
339
+
340
+ | Comparison | Emits | Array (`any…`) | Emits |
341
+ | --- | --- | --- | --- |
342
+ | `eq(v)` | `field = v` | `anyEq(v)` | `field ?= v` |
343
+ | `neq(v)` | `field != v` | `anyNeq(v)` | `field ?!= v` |
344
+ | `contains(v)` | `field ~ v` | `anyContains(v)` | `field ?~ v` |
345
+ | `notContains(v)` | `field !~ v` | `anyNotContains(v)` | `field ?!~ v` |
346
+ | `gt(v)` / `gte(v)` | `field > v` / `field >= v` | `anyGt(v)` / `anyGte(v)` | `field ?> v` / `field ?>= v` |
347
+ | `lt(v)` / `lte(v)` | `field < v` / `field <= v` | `anyLt(v)` / `anyLte(v)` | `field ?< v` / `field ?<= v` |
348
+ | `in(values)` | `(field = a \|\| field = b \|\| …)` | `notIn(values)` | `(field != a && field != b && …)` |
349
+
350
+ Values may be `string | number | boolean | null`; the server enforces the
351
+ per-field type. An empty `in([])` / `notIn([])` throws. A built expression
352
+ can be passed anywhere a `filter` string is accepted — including
353
+ `getFirstListItem(q('slug').eq('hello-world'))`.
354
+
355
+ For a one-off filter, build inline with a callback — it receives the same
356
+ typed `where` helper, so field names are still suggested and checked:
357
+
358
+ ```typescript
359
+ await postsSvc.getFullList({
360
+ filter: (w) => w('title').contains('x').and(w('published').eq(true)),
361
+ });
362
+ ```
363
+
364
+ #### Expanding relations
365
+
366
+ `expand` attaches the related record(s) under `record.expand`; the relation
367
+ field itself keeps the id:
368
+
369
+ ```typescript
370
+ await postsSvc.getOne('abc', { expand: 'author' }); // full related record
371
+ await postsSvc.getFullList({ expand: ['author', 'category'] }); // array form
372
+ await postsSvc.getFullList({ expand: ['author.profile'] }); // nested relation
373
+ await postsSvc.getFullList({ expand: ['author.name', 'author.email'] }); // only some fields
374
+ ```
375
+
376
+ - `record.expand.author` is the related record; `record.author` is the id.
377
+ - Field selection (`author.name`) is applied for you — PocketBase narrows it
378
+ server-side, the LazyPock server returns the full related record and the SDK
379
+ keeps only the requested fields.
380
+ - Distinguishing a field (`author.name`) from a nested relation
381
+ (`author.profile`) needs the target schema. Codegen's `createClient()` wires
382
+ it in automatically; with a hand-written client, pass `types.schemas`.
383
+ - On a typed service, `record.expand.author` is typed as the target record
384
+ (`record.expand.user` is `UsersRecord`, not `unknown`); multi-relations
385
+ (`maxSelect > 1`) become arrays.
386
+ - The relation field + `expand.*` entries are merged into any active
387
+ `select()` / `fields` projection, so the expanded data is never dropped.
314
388
 
315
389
  **Hidden fields are queryable.** A hidden relation is excluded from the read
316
- model (no `record.user`) but the server still resolves it for
317
- `filter`/`sort`/`expand`/`select` — the generated `*QueryFields` type keeps
318
- those keys accepted, so `getFullList({ expand: "user" })` typechecks for a
319
- hidden relation (and `record.expand?.user` is typed to the target record on
320
- the result).
390
+ model but the server still resolves it for `filter` / `sort` / `expand` /
391
+ `select`.
392
+
393
+ > **Full guide with more recipes** (ids, search, relation filters, status
394
+ > queries): [Queries](https://lazypock.gnuzd.dev/sdk/typescript/queries).
321
395
 
322
396
  - The **untyped** client (`client.collection('posts')` without `typed<T>()`)
323
397
  still accepts any string — suggestions kick in once the service is typed.
@@ -391,11 +465,16 @@ PocketBase-style service for the collections themselves (admin):
391
465
 
392
466
  Returned by `client.collection(name)`.
393
467
 
468
+ - `where(field)` — Start a **typed filter clause** (`where('id').in(ids)`, `where('title').contains(x)`);
469
+ field names are checked/suggested and values escaped — see [Filtering](#filtering-sorting--expanding)
394
470
  - `select(...fields)` — Project reads to the given fields (see [Field projection](#field-projection-select--query-suggestions)); `select('*')` restores the all-visible default
395
- - `getList(page, perPage, options?)` — Paginated list of records (typed `filter`/`sort`/`expand`/`fields`)
471
+ - `getList(page, perPage, options?)` — Paginated list of records (typed `filter`/`sort`/`expand`/`fields`; `sort`/`expand` accept a string or an array)
396
472
  - `getFullList(options?)` — Fetch all records (auto-paginates)
397
- - `getFirstListItem(filter, options?)` — Fetch first record matching filter
473
+ - `getFirstListItem(filter, options?)` — Fetch first record matching filter (`filter` may be a string or a `FilterExpr`)
398
474
  - `getOne(id, options?)` — Get record by ID
475
+ - `expandFields(options?)` — List the collection's relation fields (for building `expand`)
476
+ - `typed<T>()` — Cast the service to a record shape (compile-time only)
477
+ - `withSchema(schema)` — Bind a schema explicitly (hidden-field exclusion + query checking)
399
478
  - `create(data, options?)` — Create record
400
479
  - `update(id, data, options?)` — Update record
401
480
  - `delete(id, options?)` — Delete record