lazypock 0.13.0 → 0.14.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,143 @@ 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
+ ```
266
+
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
281
+
282
+ ```typescript
283
+ await postsSvc.getList(1, 20, { sort: '-created' }); // string
284
+ await postsSvc.getList(1, 20, { sort: ['title', '-published'] }); // array — suggests each field
285
+ ```
286
+
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:
295
+
296
+ ```typescript
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
302
+ ```
303
+
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.
270
308
 
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' });
309
+ | Operator | Meaning |
310
+ | --- | --- |
311
+ | `=` `!=` | equal / not equal |
312
+ | `~` `!~` | contains / does not contain |
313
+ | `>` `>=` `<` `<=` | comparisons |
314
+ | `?=` `?!=` `?~` `?!~` `?>` `?>=` `?<` `?<=` | "any element matches" variants for array fields |
275
315
 
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
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(); // !((… || …))
280
338
  ```
281
339
 
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`:
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
+ #### Expanding relations
356
+
357
+ `expand` attaches the related record(s) under `record.expand`; the relation
358
+ field itself keeps the id:
304
359
 
305
360
  ```typescript
306
- const posts = await postsSvc.getFullList({ expand: 'author' });
307
- posts[0].expand?.author?.email; // ✓ typed UsersRecord (auth → has email)
361
+ await postsSvc.getOne('abc', { expand: 'author' }); // full related record
362
+ await postsSvc.getFullList({ expand: ['author', 'category'] }); // array form
363
+ await postsSvc.getFullList({ expand: ['author.profile'] }); // nested relation
364
+ await postsSvc.getFullList({ expand: ['author.name', 'author.email'] }); // only some fields
308
365
  ```
309
366
 
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.
367
+ - `record.expand.author` is the related record; `record.author` is the id.
368
+ - Field selection (`author.name`) is applied for you — PocketBase narrows it
369
+ server-side, the LazyPock server returns the full related record and the SDK
370
+ keeps only the requested fields.
371
+ - Distinguishing a field (`author.name`) from a nested relation
372
+ (`author.profile`) needs the target schema. Codegen's `createClient()` wires
373
+ it in automatically; with a hand-written client, pass `types.schemas`.
374
+ - On a typed service, `record.expand.author` is typed as the target record
375
+ (`record.expand.user` is `UsersRecord`, not `unknown`); multi-relations
376
+ (`maxSelect > 1`) become arrays.
377
+ - The relation field + `expand.*` entries are merged into any active
378
+ `select()` / `fields` projection, so the expanded data is never dropped.
314
379
 
315
380
  **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).
381
+ model but the server still resolves it for `filter` / `sort` / `expand` /
382
+ `select`.
383
+
384
+ > **Full guide with more recipes** (ids, search, relation filters, status
385
+ > queries): [Queries](https://lazypock.gnuzd.dev/sdk/typescript/queries).
321
386
 
322
387
  - The **untyped** client (`client.collection('posts')` without `typed<T>()`)
323
388
  still accepts any string — suggestions kick in once the service is typed.
@@ -391,11 +456,16 @@ PocketBase-style service for the collections themselves (admin):
391
456
 
392
457
  Returned by `client.collection(name)`.
393
458
 
459
+ - `where(field)` — Start a **typed filter clause** (`where('id').in(ids)`, `where('title').contains(x)`);
460
+ field names are checked/suggested and values escaped — see [Filtering](#filtering-sorting--expanding)
394
461
  - `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`)
462
+ - `getList(page, perPage, options?)` — Paginated list of records (typed `filter`/`sort`/`expand`/`fields`; `sort`/`expand` accept a string or an array)
396
463
  - `getFullList(options?)` — Fetch all records (auto-paginates)
397
- - `getFirstListItem(filter, options?)` — Fetch first record matching filter
464
+ - `getFirstListItem(filter, options?)` — Fetch first record matching filter (`filter` may be a string or a `FilterExpr`)
398
465
  - `getOne(id, options?)` — Get record by ID
466
+ - `expandFields(options?)` — List the collection's relation fields (for building `expand`)
467
+ - `typed<T>()` — Cast the service to a record shape (compile-time only)
468
+ - `withSchema(schema)` — Bind a schema explicitly (hidden-field exclusion + query checking)
399
469
  - `create(data, options?)` — Create record
400
470
  - `update(id, data, options?)` — Update record
401
471
  - `delete(id, options?)` — Delete record