@querykitjs/prisma-pg 1.0.1 → 2.0.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.en.md CHANGED
@@ -31,16 +31,35 @@ const { data, meta } = await usersRepository.findList({
31
31
  - **Soft delete** — automatic when the model has `deletedAt`; `softDelete`/`restore`/`withDeleted`.
32
32
  - **Upsert / bulk** — `upsert`, `upsertMany`.
33
33
  - **Framework-agnostic** — it knows nothing about HTTP: Express, Hono, NestJS, background jobs, all the same.
34
- - **Cross-adapter parity** — the same surface and the same results as `@querykitjs/drizzle-pg` and `@querykitjs/mongoose` ([PARITY.md](./PARITY.md)).
34
+ - **Cross-adapter parity** — the same surface and the same results as `@querykitjs/drizzle-pg` and `@querykitjs/mongoose`.
35
35
 
36
36
  ## Install
37
37
 
38
38
  ```bash
39
- bun add @querykitjs/prisma-pg @prisma/client
40
- # or: npm i @querykitjs/prisma-pg @prisma/client
39
+ bun add @querykitjs/prisma-pg @prisma/client @prisma/adapter-pg pg
40
+ # or: npm i @querykitjs/prisma-pg @prisma/client @prisma/adapter-pg pg
41
41
  ```
42
42
 
43
- `@prisma/client` (>=5) is a peer dependency. The package never **imports** your generated client: it reads model metadata off your client instance at runtime and derives types from the delegate you pass in.
43
+ `@prisma/client` (`^6.19 || ^7.10`) is a peer dependency; `@prisma/adapter-pg` and `pg` belong to your app (Prisma 7 requires a driver adapter). The package never **imports** your generated client: it reads model metadata off your client instance at runtime and derives types from the delegate you pass in.
44
+
45
+ ### Requirements
46
+
47
+ | | Version | Tested (`bun run test:matrix`) |
48
+ | ----------------------- | ------------------ | ------------------------------ |
49
+ | Node.js | `>=22.12` | 24 |
50
+ | `@prisma/client` (peer) | `^6.19 \|\| ^7.10` | 6.19.3, 7.10.1 |
51
+
52
+ Node 20 is no longer supported (`engines`: `>=22.12`). Peer ranges cover the last two majors; both ends are tested.
53
+
54
+ ### Prisma 7 and Prisma 6
55
+
56
+ The adapter reads model metadata from the client instance you pass in, so it works the same with either major. Both are tested (`test:matrix`) with the `prisma-client` generator and `@prisma/adapter-pg`.
57
+
58
+ - **Prisma 7** — generator `provider = "prisma-client"` with an `output` (e.g. `"../generated/prisma"`); the datasource `url` lives in `prisma.config.ts`, not in the schema; build the client with a driver adapter as in the Setup below.
59
+ - **Prisma 6.19** — the same generator and adapter work; keep `url = env("DATABASE_URL")` in the schema's `datasource` block. A `prisma-client-js` client created with `new PrismaClient()` keeps working too.
60
+ - **Pitfall — non-`public` schema:** with a driver adapter Prisma ignores a `?schema=` parameter in the connection string, so queries go to `public`. Pass the schema to the adapter: `new PrismaPg({ connectionString }, { schema: "app" })`.
61
+
62
+ **Migrating to Prisma 7:** switch the generator to `prisma-client` with an `output`, import `PrismaClient` from that output, install `@prisma/adapter-pg` + `pg` and pass `adapter`, and move the `url` to `prisma.config.ts`. Nothing changes in your `createRegistry` / repository code.
44
63
 
45
64
  ## Setup
46
65
 
@@ -48,15 +67,22 @@ bun add @querykitjs/prisma-pg @prisma/client
48
67
 
49
68
  ```ts
50
69
  // db/registry.ts
51
- import { PrismaClient } from "@prisma/client";
70
+ import { PrismaPg } from "@prisma/adapter-pg";
52
71
  import { createRegistry } from "@querykitjs/prisma-pg";
72
+ import { PrismaClient } from "../generated/prisma/client"; // `prisma-client` generator output
53
73
 
54
- export const prisma = new PrismaClient();
74
+ export const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
55
75
  export const registry = createRegistry(prisma);
56
76
  // or with pagination defaults:
57
77
  // export const registry = createRegistry(prisma, { defaultPerPage: 20, defaultLimit: 20 });
58
78
  ```
59
79
 
80
+ `timestamps` — the createdAt / deletedAt field names (for example in a snake_case project): the default sort and the default `sortable` use that `createdAt`, soft delete (`softDelete`, `restore`, `withDeleted`) uses that `deletedAt`.
81
+
82
+ ```ts
83
+ export const registry = createRegistry(prisma, { timestamps: { createdAt: "created_at", deletedAt: "deleted_at" } });
84
+ ```
85
+
60
86
  ### 2. Repository (per model)
61
87
 
62
88
  A model is taken by its **handle**, the way drizzle-pg takes a table object and mongoose takes a `Model`. Its name (the delegate key, camelCase) works too: `User` → `"user"`, `LegalEntity` → `"legalEntity"`.
@@ -129,6 +155,39 @@ export class UsersController {
129
155
 
130
156
  A validated payload goes into the repository params with **no cast** — the contract is closed through `@querykitjs/core`.
131
157
 
158
+ ## Safe by default — `filterable`, `sortable`, `limits`
159
+
160
+ A client filters and sorts only by the fields the server allows, and only with the operators it allows. A refused request stops with a `QueryKitError` before Prisma is called — whatever `strict` and `onSkippedCondition` say (it is never dropped silently). Option names, defaults and `reason`/`site` are the same as drizzle-pg's.
161
+
162
+ ```ts
163
+ const usersRepository = registry.repository(prisma.user, {
164
+ forcedColumns: { id: true, name: true, email: true, createdAt: true },
165
+ filterable: {
166
+ email: ["=", "in"],
167
+ createdAt: ["between", ">=", "<="],
168
+ name: ["contains"], // "contains" and its token "%_%" are one operator
169
+ "posts.title": ["="], // a relation field — "relation.field"
170
+ },
171
+ sortable: ["id", "createdAt", "name"],
172
+ limits: { maxInItems: 500 }, // over the registry's createRegistry(..., { limits })
173
+ });
174
+ ```
175
+
176
+ | Option | When not given (default) | When refused |
177
+ | ------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
178
+ | `filterable` | `allowedColumns` (else the `forcedColumns` keys, else only the primary key), with `"="` and `"in"` only | `reason: "forbidden-key"` / `"forbidden-operator"`, `site: "filter"` |
179
+ | `sortable` | the primary key and `createdAt` (when the table has it); a client-sent `cursorKey` too | `reason: "forbidden-key"`, `site: "sort"` / `"cursorKey"` |
180
+ | `limits` | core `DEFAULT_QUERY_LIMITS` (depth 5, conditions 30, array 200, string 256, offset 10 000, sort 3) | `reason: "limit-exceeded"`, `site: "filter"` / `"sort"` / `"page"` |
181
+
182
+ - `aggregate`: the `filter` and every `groupBy`/`sum`/`avg`/`min`/`max` column must be in `filterable` (`site: "aggregate"`).
183
+ - `findById`: only the client `filter` is checked; `idKey` is the server's choice.
184
+ - A key the table does **not** have (`nope`) stays `unknown-key` — skipped, or an error under `strict`.
185
+ - Mutation filters (`updateMany`, `deleteMany`, …) count as server code and are not checked.
186
+ - Operator aliases are one: `["="]` also admits `eq`, `["contains"]` also `%_%`. `"*"` admits every operator.
187
+ - The policy applies to **every** read call on the repository — the server's own filters in custom methods too (`findBySlug` needs `slug` in `filterable`). For internal queries on fields you do not open to clients, build a separate, wider repository and never hand it to a route.
188
+ - A relation field is opened or kept closed with a `"relation.field"` key (`posts.secret` not listed ⇒ `forbidden-key`). The adapter does not compile such a key into a filter (it stays `unknown-key`); use `raw()` for a relation predicate.
189
+ - An unbranded raw Prisma `where` object (`{ secret: { startsWith: "a" } }`) can come from a JSON body — `forbidden-key` (R19). Server code brands it with `raw({...})`; ⚠️ a `raw()` node is not checked by `filterable`, never build one from request data.
190
+
132
191
  ## Advanced filters
133
192
 
134
193
  A filter is a tree of field conditions and logical groups (`and`/`or`/`not`). A flat array is implicit `AND`.
@@ -161,17 +220,19 @@ await usersRepository.findAll({
161
220
 
162
221
  `contains`/`startsWith`/`endsWith` are case-**insensitive** (`mode: "insensitive"`), `like` is case-**sensitive**, `ilike` is insensitive — exactly as in the drizzle-pg adapter.
163
222
 
164
- > **LIKE patterns.** Prisma has no raw SQL `LIKE` inside `where`, so the pattern is translated — **exactly**, so any pattern returns the same rows as on drizzle-pg: `%ali%` → `contains`, `ali%` → `startsWith`, `%ali` → `endsWith`, no wildcard → `equals`. Interior `%` and `_` are handled with two identities — `LIKE 'A%S'` ≡ `LIKE 'A%S%' AND LIKE '%S'`, and for a pattern with no `%`, `LIKE 'P'` ≡ `LIKE 'P%' AND NOT LIKE 'P_%'` (which pins the length). Only a **malformed** pattern (a dangling trailing `\`) is dropped and reported. Details: [PARITY.md](./PARITY.md) §5.1.
223
+ > **LIKE patterns.** Prisma has no raw SQL `LIKE` inside `where`, so the pattern is translated — **exactly**, so any pattern returns the same rows as on drizzle-pg: `%ali%` → `contains`, `ali%` → `startsWith`, `%ali` → `endsWith`, no wildcard → `equals`. Interior `%` and `_` are handled with two identities — `LIKE 'A%S'` ≡ `LIKE 'A%S%' AND LIKE '%S'`, and for a pattern with no `%`, `LIKE 'P'` ≡ `LIKE 'P%' AND NOT LIKE 'P_%'` (which pins the length). Only a **malformed** pattern (a dangling trailing `\`) is dropped and reported.
165
224
 
166
225
  ### Raw `where` escape hatch
167
226
 
168
- For anything the DSL cannot express (relation predicates, JSON filters), pass a Prisma `where` object as a filter node:
227
+ For anything the DSL cannot express (relation predicates, JSON filters), pass a Prisma `where` object branded with `raw()` as a filter node (R19 — an unbranded object is `forbidden-key`; ⚠️ `raw()` is not checked by `filterable`):
169
228
 
170
229
  ```ts
171
- await usersRepository.findAll({ filter: { posts: { some: { title: { contains: "querykit" } } } } });
230
+ import { raw } from "@querykitjs/core";
231
+
232
+ await usersRepository.findAll({ filter: raw({ posts: { some: { title: { contains: "querykit" } } } }) });
172
233
 
173
234
  await usersRepository.findAll({
174
- filter: f.or(f.eq("role", "admin"), { posts: { some: { published: true } } }),
235
+ filter: f.or(f.eq("role", "admin"), raw({ posts: { some: { published: true } } })),
175
236
  });
176
237
  ```
177
238
 
@@ -219,6 +280,8 @@ Unknown keys are skipped; if no valid sort remains it falls back to `createdAt d
219
280
  // 1) Offset — with total_items/total_pages
220
281
  const list = await usersRepository.findList({ page: 2, perPage: 20 });
221
282
  list.meta; // { total_items, total_pages, current_page, per_page, has_next, has_prev }
283
+ // withTotal: false — no count query (large tables): perPage + 1 rows, total_items / total_pages = null
284
+ const fast = await usersRepository.findList({ page: 2, perPage: 20, withTotal: false });
222
285
 
223
286
  // 2) Infinite scroll — no COUNT
224
287
  const feed = await postsRepository.findInfinite({ limit: 20, offset: 40 });
@@ -230,12 +293,35 @@ const p2 = await postsRepository.findCursor({ limit: 20, cursor: p1.meta.next_cu
230
293
  p1.meta; // { limit, has_next, has_prev, next_cursor, prev_cursor }
231
294
  ```
232
295
 
296
+ ### Cursor: ties and signing
297
+
298
+ - The seek key is `(cursorKey, @id)`: with a non-unique `cursorKey` (for example a shared `createdAt`) no row is skipped or repeated. When `cursorKey` is the primary key itself the seek uses that one field.
299
+ - A token (v2) holds only scalar values (string, number, boolean, ISO date) and they are cast to the field type; an object, an array or a value that does not fit → `QueryKitError` `reason: "invalid-value"`, `site: "cursorKey"` (the driver is not called).
300
+ - The registry option `cursorSecret` signs tokens with HMAC-SHA256; an unsigned or altered token → `invalid-value`. Without it tokens are unsigned — a client can build one itself (still only within the allowed `sortable` keys and the filter).
301
+
302
+ ```ts
303
+ export const registry = createRegistry(prisma, { cursorSecret: process.env.CURSOR_SECRET });
304
+ ```
305
+
233
306
  `perPage`/`limit` are **capped** (`DEFAULT_MAX_PER_PAGE` = 200) — the repository clamps even if validation was bypassed.
234
307
 
235
308
  > Cursor tokens are **byte-identical** to the drizzle-pg and mongoose adapters', so an old token keeps working if the backend swaps ORMs.
236
309
 
237
310
  ## Typed `columns` and `with`
238
311
 
312
+ A client `with` opens only the relations listed in `relations` (task 014, R21):
313
+
314
+ ```ts
315
+ const usersRepository = registry.repository("user", {
316
+ relations: { posts: { columns: ["id", "title"], filterable: { published: "*" } } },
317
+ });
318
+ await usersRepository.findAll({ with: { posts: { where: { published: true }, take: 5 } } }); // posts: id, title
319
+ ```
320
+
321
+ - `columns` is required — a list or an explicit `"*"`. `with: { rel: true }` also selects only those columns.
322
+ - An unlisted relation, column, filter field or nested relation → `QueryKitError` `reason: "forbidden-key"`, `site: "with"`, `key` `relation` or `relation.field`; the driver is not called.
323
+ - Allowed options: `select`, a nested `include` (with `nested`), `where` (its fields in `filterable`), `take` / `skip`, `orderBy` on allowed columns.
324
+
239
325
  ```ts
240
326
  const picked = await usersRepository.findAll({ columns: { id: true, email: true } });
241
327
  // ^? { id: number; email: string }[] — `name` is not even in the type
@@ -255,8 +341,29 @@ A `with` value may be `true` or a Prisma relation config:
255
341
  await usersRepository.findAll({ with: { posts: { select: { title: true }, take: 5, orderBy: { createdAt: "desc" } } } });
256
342
  ```
257
343
 
344
+ ## Bulk update and delete
345
+
346
+ `updateWhere` / `deleteWhere` are one write (no read before it) and return `{ count }`:
347
+
348
+ ```ts
349
+ const { count } = await usersRepository.updateWhere([{ key: "status", value: "trial" }], { status: "expired" });
350
+ await usersRepository.deleteWhere([{ key: "status", value: "expired" }]); // { count }
351
+
352
+ // returning: { limit } — { count, rows }, at most limit rows (maxLimit, default 200)
353
+ const { rows } = await usersRepository.updateWhere(filter, patch, { returning: { limit: 50 } });
354
+
355
+ // no filter — only when you say so (the scope still applies)
356
+ await usersRepository.deleteWhere({ all: true });
357
+ ```
358
+
359
+ - An empty filter (`undefined`, `{}`, `[]`) or one whose every condition was skipped (for example only an unknown key) → `QueryKitError` `reason: "empty-filter"`, `site: "mutation"` — the driver is not called, the table is never wiped silently.
360
+ - A mutation filter passes `filterable` and `limits` like a read's (`forbidden-key`, `forbidden-operator`, `limit-exceeded`) — the server's own filters too.
361
+ - `updateWhere` `rows` come from one `updateManyAndReturn` (atomic). `deleteWhere` `rows` are read **just before** the delete (Prisma has no `deleteManyAndReturn`) — not atomic: a row changed in between may or may not be listed.
362
+
258
363
  ## Transactions
259
364
 
365
+ > ⚠️ Only **database work** inside the callback. External effects (HTTP calls, messages, email) go through an outbox: the driver may retry the transaction, and an external effect would run again and cannot be rolled back.
366
+
260
367
  ```ts
261
368
  await registry.transaction(async () => {
262
369
  const user = await usersRepository.create({ name: "Ali", email: "ali@example.com" });
@@ -278,6 +385,12 @@ await mine.create({ title: "New" }); // authorId filled in automatically
278
385
 
279
386
  The scope is `AND`ed into every read/write and merged into inserts (scope wins). If a scope key does not resolve on the model, building the repository **throws** — dropping it silently would remove the filter.
280
387
 
388
+ A scope value may also be a list (task 014): `{ shopId: { in: [1, 2] } }` limits every read and write to those values; an empty list (`{ in: [] }`) matches no row (not an error). `create` fills in the equality scopes only.
389
+
390
+ ```ts
391
+ const mine = repository.scoped({ shopId: { in: user.shopIds } });
392
+ ```
393
+
281
394
  ## Projection guards
282
395
 
283
396
  ```ts
@@ -318,13 +431,60 @@ await ordersRepository.aggregate({ sum: "amount", avg: "amount", groupBy: "regio
318
431
  >
319
432
  > **Bulk.** Prisma has no bulk upsert, so `upsertMany` issues sequential `upsert`s (input order preserved). Wrap large imports in `registry.transaction()`.
320
433
 
434
+ ## Values, NULL order and parity
435
+
436
+ All three adapters (drizzle-pg, prisma-pg, mongoose) return the same result (task 021):
437
+
438
+ - **Literal value operators.** `contains`, `startsWith` and `endsWith` match their value literally: `startsWith "%"` finds only rows starting with `%`, `contains "_"` a real `_`. Wildcards belong to `like` / `ilike` / `notLike` only.
439
+ - **Type casting.** Every value is cast to its field type (number, boolean, date, uuid, enum). A mismatch (`age = "abc"`) is `invalid-value`: the condition is dropped and `onSkippedCondition` is called, a `QueryKitError` under `strict`; the driver is not called.
440
+ - **`not`** follows SQL three-valued logic: `not(and(…))` / `not(or(…))` over a NULL field agree on every adapter.
441
+ - **NULL order.** NULL first on `asc`, last on `desc` (MongoDB's order). prisma-pg emits `{ sort, nulls }` only where the field is known to be optional (see Limitations).
442
+ - **A malformed sort item** (`null`, `{}`) is dropped and reported as `invalid-value` (`site: "sort"`), an error under `strict`.
443
+
444
+ ## Migrating to 2.0
445
+
446
+ In 2.0 a repository without a policy is **narrow**: `=` and `in` on `allowedColumns` / `forcedColumns` / the `@id` field only, sorting by `@id` and `createdAt`. To bring back the 1.x behaviour for one repository on purpose:
447
+
448
+ ```ts
449
+ const open = registry.repository(prisma.user, {
450
+ filterable: { id: "*", name: "*", email: "*", age: "*", createdAt: "*" },
451
+ sortable: ["id", "name", "email", "age", "createdAt"],
452
+ });
453
+ ```
454
+
455
+ A raw `where` now needs `raw()` (R19):
456
+
457
+ ```ts
458
+ import { raw } from "@querykitjs/core";
459
+
460
+ // 1.x — forbidden-key in 2.0:
461
+ // await usersRepository.findAll({ filter: { posts: { some: { published: true } } } });
462
+
463
+ // 2.0:
464
+ await usersRepository.findAll({ filter: raw({ posts: { some: { published: true } } }) });
465
+ ```
466
+
467
+ - First collect from `onSkippedCondition` logs which fields and operators are in use, then narrow `filterable` to exactly those — `"*"` is a stopgap.
468
+ - New `QueryKitError` reasons: `forbidden-key`, `forbidden-operator`, `limit-exceeded`; new site: `page`. Return them as 400.
469
+ - Pages deeper than offset 10 000 are now refused — use `findCursor`, or raise it with `limits: { maxOffset }`.
470
+ - Cursor tokens are v2 now (task 012): an old (v1) `next_cursor` / `prev_cursor` is refused with `invalid-value` — after the upgrade start paging from the first page again.
471
+ - `updateWhere` / `deleteWhere` return `{ count }` instead of `Row[]` (task 013); for rows pass `{ returning: { limit } }` → `{ count, rows }`.
472
+ - A filterless `updateWhere` / `deleteWhere` is `empty-filter`; write `{ all: true }` for every row. A mutation filter now passes the `filterable` policy.
473
+ - `OffsetMeta.total_items` / `total_pages` are typed `number | null` (`null` with `withTotal: false`).
474
+ - `with` now needs the `relations` allow-list (task 014): on a repository without it `with` is `forbidden-key`, `site: "with"`.
475
+ - A scope value may be `{ in: [...] }`; the registry option `timestamps` names the createdAt / deletedAt fields.
476
+
321
477
  ## Limitations
322
478
 
323
479
  - `updateById` / `deleteById` / `softDelete` / `restore` require a **single-field `@id`**. Prisma's `update`/`delete` accept only a unique `where`, while querykit's predicate also carries the scope and soft-delete guards. With a composite `@@id`, use `updateWhere`/`deleteWhere`.
324
480
  - Filter/sort keys are **Prisma field names** (`createdAt`), not DB columns (`created_at`) — Prisma's own `where` does not accept a `@map`ped name either.
325
481
  - Text operators only apply to `String` fields; on any other type the condition is dropped and reported.
326
482
 
327
- Full list and the three-adapter comparison: [PARITY.md](./PARITY.md).
483
+ These are pinned by `test/contract.ts`, which asserts all three adapters agree on the same payload.
484
+
485
+ - **`_` in `like` / `ilike`.** Prisma has no one-character wildcard: an unescaped `_` is `invalid-value` (an error under strict). Write `\\_` for a literal underscore.
486
+ - **uuid fields.** When the runtime model does not expose `@db.Uuid`, a malformed uuid reaches Postgres (22P02 / P2023) unless the field is declared: `fields: [{ name: "ref", nativeType: "Uuid" }]` — then it is `invalid-value`.
487
+ - **NULL order on Prisma 7.** Prisma 7's runtime model does not say whether a field is optional; `nulls` is emitted only when that is known. Declare it: `registry.repository(prisma.user, { fields: [{ name: "age", isRequired: false }] })` — otherwise that field keeps PostgreSQL's order.
328
488
 
329
489
  ## API reference
330
490
 
package/README.md CHANGED
@@ -31,16 +31,35 @@ const { data, meta } = await usersRepository.findList({
31
31
  - **Soft-delete** — `deletedAt` maydoni bo'lsa avtomatik; `softDelete`/`restore`/`withDeleted`.
32
32
  - **Upsert / bulk** — `upsert`, `upsertMany`.
33
33
  - **Framework-agnostik** — HTTP'ni bilmaydi: Express, Hono, NestJS, fon vazifalari — hammasiga tushadi.
34
- - **Cross-adapter parity** — `@querykitjs/drizzle-pg` va `@querykitjs/mongoose` bilan bir xil yuza va bir xil natija ([PARITY.md](./PARITY.md)).
34
+ - **Cross-adapter parity** — `@querykitjs/drizzle-pg` va `@querykitjs/mongoose` bilan bir xil yuza va bir xil natija.
35
35
 
36
36
  ## O'rnatish
37
37
 
38
38
  ```bash
39
- bun add @querykitjs/prisma-pg @prisma/client
40
- # yoki: npm i @querykitjs/prisma-pg @prisma/client
39
+ bun add @querykitjs/prisma-pg @prisma/client @prisma/adapter-pg pg
40
+ # yoki: npm i @querykitjs/prisma-pg @prisma/client @prisma/adapter-pg pg
41
41
  ```
42
42
 
43
- `@prisma/client` (>=5) — peer dependency. Paket generatsiya qilingan client'ni **import qilmaydi**: model metadata'sini ish vaqtida sizning client instansiyangizdan o'qiydi, tiplarni esa siz uzatgan delegate'dan chiqaradi.
43
+ `@prisma/client` (`^6.19 || ^7.10`) — peer dependency; `@prisma/adapter-pg` va `pg` — ilovangizning bog'liqligi (Prisma 7 driver adapter talab qiladi). Paket generatsiya qilingan client'ni **import qilmaydi**: model metadata'sini ish vaqtida sizning client instansiyangizdan o'qiydi, tiplarni esa siz uzatgan delegate'dan chiqaradi.
44
+
45
+ ### Talablar
46
+
47
+ | | Versiya | Tekshiriladi (`bun run test:matrix`) |
48
+ | ----------------------- | ------------------ | ------------------------------------ |
49
+ | Node.js | `>=22.12` | 24 |
50
+ | `@prisma/client` (peer) | `^6.19 \|\| ^7.10` | 6.19.3, 7.10.1 |
51
+
52
+ Node 20 endi qo'llab-quvvatlanmaydi (`engines`: `>=22.12`). Peer diapazoni — oxirgi ikki major; ikkala cheti testda.
53
+
54
+ ### Prisma 7 va Prisma 6
55
+
56
+ Adapter model metadata'sini siz bergan client instansiyasidan o'qiydi, shuning uchun ikkala major bilan bir xil ishlaydi. Ikkalasi ham `prisma-client` generatori va `@prisma/adapter-pg` bilan testda (`test:matrix`).
57
+
58
+ - **Prisma 7** — generator `provider = "prisma-client"` va `output` (masalan `"../generated/prisma"`); datasource `url` sxemada emas, `prisma.config.ts` da; client quyidagi Sozlash'dagidek driver adapter bilan yaratiladi.
59
+ - **Prisma 6.19** — xuddi shu generator va adapter ishlaydi; sxemaning `datasource` blokida `url = env("DATABASE_URL")` qoladi. `prisma-client-js` bilan `new PrismaClient()` orqali yaratilgan client ham ishlashda davom etadi.
60
+ - **Tuzoq — `public` bo'lmagan schema:** driver adapter bilan Prisma connection string'dagi `?schema=` parametrini e'tiborsiz qoldiradi va so'rovlar `public`ga ketadi. Schema'ni adapterga bering: `new PrismaPg({ connectionString }, { schema: "app" })`.
61
+
62
+ **Prisma 7 ga o'tish:** generatorni `output` bilan `prisma-client` ga almashtiring, `PrismaClient`ni shu chiqishdan import qiling, `@prisma/adapter-pg` + `pg` o'rnatib `adapter` bering, `url`ni `prisma.config.ts` ga ko'chiring. `createRegistry` / repository kodingizda hech narsa o'zgarmaydi.
44
63
 
45
64
  ## Sozlash
46
65
 
@@ -48,15 +67,22 @@ bun add @querykitjs/prisma-pg @prisma/client
48
67
 
49
68
  ```ts
50
69
  // db/registry.ts
51
- import { PrismaClient } from "@prisma/client";
70
+ import { PrismaPg } from "@prisma/adapter-pg";
52
71
  import { createRegistry } from "@querykitjs/prisma-pg";
72
+ import { PrismaClient } from "../generated/prisma/client"; // `prisma-client` generator chiqishi
53
73
 
54
- export const prisma = new PrismaClient();
74
+ export const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
55
75
  export const registry = createRegistry(prisma);
56
76
  // yoki pagination default'larini sozlab:
57
77
  // export const registry = createRegistry(prisma, { defaultPerPage: 20, defaultLimit: 20 });
58
78
  ```
59
79
 
80
+ `timestamps` — createdAt / deletedAt maydon nomlari (masalan snake_case loyihada): default sort va default `sortable` shu `createdAt`ni, soft-delete (`softDelete`, `restore`, `withDeleted`) shu `deletedAt`ni ishlatadi.
81
+
82
+ ```ts
83
+ export const registry = createRegistry(prisma, { timestamps: { createdAt: "created_at", deletedAt: "deleted_at" } });
84
+ ```
85
+
60
86
  ### 2. Repository (model bo'yicha)
61
87
 
62
88
  Model **handle** bilan olinadi — `drizzle-pg` jadval obyektini, `mongoose` esa `Model`ni qabul qilgani kabi. Nom (delegate kaliti, camelCase) ham qabul qilinadi: `User` → `"user"`, `LegalEntity` → `"legalEntity"`.
@@ -129,6 +155,39 @@ export class UsersController {
129
155
 
130
156
  Validatsiyalangan payload repository params'iga **cast'siz** tushadi — kontrakt `@querykitjs/core` orqali yopilgan.
131
157
 
158
+ ## Safe by default — `filterable`, `sortable`, `limits`
159
+
160
+ Mijoz faqat server ruxsat bergan maydon bo'yicha, faqat ruxsat bergan operator bilan filtrlaydi va saralaydi. Ruxsatsiz so'rov Prisma'ga yetmasdan `QueryKitError` bilan to'xtaydi — `strict` va `onSkippedCondition`dan qat'i nazar (jim tashlanmaydi). Opsiya nomlari, default'lar, `reason`/`site` — drizzle-pg bilan bir xil.
161
+
162
+ ```ts
163
+ const usersRepository = registry.repository(prisma.user, {
164
+ forcedColumns: { id: true, name: true, email: true, createdAt: true },
165
+ filterable: {
166
+ email: ["=", "in"],
167
+ createdAt: ["between", ">=", "<="],
168
+ name: ["contains"], // "contains" va uning tokeni "%_%" — bitta operator
169
+ "posts.title": ["="], // relation maydoni — "relation.field"
170
+ },
171
+ sortable: ["id", "createdAt", "name"],
172
+ limits: { maxInItems: 500 }, // registry createRegistry(..., { limits }) ustidan
173
+ });
174
+ ```
175
+
176
+ | Opsiya | Berilmasa (default) | Rad etilsa |
177
+ | ------------ | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
178
+ | `filterable` | `allowedColumns` (yo'q bo'lsa `forcedColumns` kalitlari, u ham yo'q bo'lsa faqat primary key), faqat `"="` va `"in"` | `reason: "forbidden-key"` / `"forbidden-operator"`, `site: "filter"` |
179
+ | `sortable` | primary key va `createdAt` (jadvalda bo'lsa); mijoz yuborgan `cursorKey` ham shu ro'yxatdan | `reason: "forbidden-key"`, `site: "sort"` / `"cursorKey"` |
180
+ | `limits` | core `DEFAULT_QUERY_LIMITS` (chuqurlik 5, shartlar 30, massiv 200, satr 256, offset 10 000, sort 3) | `reason: "limit-exceeded"`, `site: "filter"` / `"sort"` / `"page"` |
181
+
182
+ - `aggregate`: `filter` va har `groupBy`/`sum`/`avg`/`min`/`max` ustuni `filterable` ichida bo'lishi shart (`site: "aggregate"`).
183
+ - `findById`: faqat mijoz `filter`i tekshiriladi; `idKey` — server tanlovi.
184
+ - Jadvalda **yo'q** kalit (`nope`) avvalgidek `unknown-key` — skip yoki `strict`da xato.
185
+ - Mutatsiya metodlari (`updateMany`, `deleteMany`, …) filtri server kodi deb hisoblanadi va tekshirilmaydi.
186
+ - Operator aliaslari bitta: `["="]` `eq`ni ham, `["contains"]` `%_%`ni ham ochadi. `"*"` — barcha operatorlar.
187
+ - Siyosat repository'ning **har** o'qish chaqiruviga qo'llanadi — custom metodlardagi server filtrlari ham (`findBySlug` uchun `slug` `filterable`da). Mijozga ochilmaydigan maydon bo'yicha ichki so'rov uchun alohida, kengroq repository yarating va uni route'ga bermang.
188
+ - Relation maydoni `"relation.field"` kaliti bilan yopiladi yoki ochiladi (`posts.secret` — ro'yxatda bo'lmasa `forbidden-key`). Adapter bunday kalitni filtr sifatida kompilyatsiya qilmaydi (avvalgidek `unknown-key`); relation predikati uchun `raw()` ishlating.
189
+ - Brendsiz xom Prisma `where` obyekti (`{ secret: { startsWith: "a" } }`) JSON'dan kelishi mumkin — `forbidden-key` (R19). Server kodi `raw({...})` bilan belgilaydi; ⚠️ `raw()` tuguni `filterable` bilan tekshirilmaydi, uni so'rov ma'lumotidan qurmang.
190
+
132
191
  ## Advanced filterlar
133
192
 
134
193
  Filter — field shartlari va mantiqiy guruhlar (`and`/`or`/`not`) daraxti. Flat massiv implicit `AND`.
@@ -161,17 +220,19 @@ await usersRepository.findAll({
161
220
 
162
221
  `contains`/`startsWith`/`endsWith` — **case-insensitive** (`mode: "insensitive"`), `like` — case-sensitive, `ilike` — insensitive. Bu drizzle-pg adapteri bilan aynan bir xil.
163
222
 
164
- > **LIKE pattern'lari.** Prisma `where` ichida xom SQL `LIKE` yo'q, shuning uchun pattern tarjima qilinadi — lekin **aniq**, ya'ni har qanday pattern drizzle-pg bilan bir xil qatorlarni qaytaradi: `%ali%` → `contains`, `ali%` → `startsWith`, `%ali` → `endsWith`, wildcard'siz → `equals`. Ichki `%` va `_` uchun ikkita ayniyat ishlatiladi — `LIKE 'A%S'` ≡ `LIKE 'A%S%' AND LIKE '%S'`, va `%`siz pattern uchun `LIKE 'P'` ≡ `LIKE 'P%' AND NOT LIKE 'P_%'` (uzunlikni qadaydi). Faqat **buzuq** pattern (oxirida osilib qolgan `\`) tashlanadi va reportlanadi. Batafsil: [PARITY.md](./PARITY.md) §5.1.
223
+ > **LIKE pattern'lari.** Prisma `where` ichida xom SQL `LIKE` yo'q, shuning uchun pattern tarjima qilinadi — lekin **aniq**, ya'ni har qanday pattern drizzle-pg bilan bir xil qatorlarni qaytaradi: `%ali%` → `contains`, `ali%` → `startsWith`, `%ali` → `endsWith`, wildcard'siz → `equals`. Ichki `%` va `_` uchun ikkita ayniyat ishlatiladi — `LIKE 'A%S'` ≡ `LIKE 'A%S%' AND LIKE '%S'`, va `%`siz pattern uchun `LIKE 'P'` ≡ `LIKE 'P%' AND NOT LIKE 'P_%'` (uzunlikni qadaydi). Faqat **buzuq** pattern (oxirida osilib qolgan `\`) tashlanadi va reportlanadi.
165
224
 
166
225
  ### Xom `where` escape-hatch
167
226
 
168
- DSL ifodalay olmaydigan narsalar (relation predikatlari, `mode`, JSON filtrlari) uchun to'g'ridan-to'g'ri Prisma `where` obyekti tugun sifatida beriladi:
227
+ DSL ifodalay olmaydigan narsalar (relation predikatlari, `mode`, JSON filtrlari) uchun Prisma `where` obyekti `raw()` bilan belgilanib tugun sifatida beriladi (R19 — brendsiz obyekt `forbidden-key`; ⚠️ `raw()` `filterable`dan o'tmaydi):
169
228
 
170
229
  ```ts
171
- await usersRepository.findAll({ filter: { posts: { some: { title: { contains: "querykit" } } } } });
230
+ import { raw } from "@querykitjs/core";
231
+
232
+ await usersRepository.findAll({ filter: raw({ posts: { some: { title: { contains: "querykit" } } } }) });
172
233
 
173
234
  await usersRepository.findAll({
174
- filter: f.or(f.eq("role", "admin"), { posts: { some: { published: true } } }),
235
+ filter: f.or(f.eq("role", "admin"), raw({ posts: { some: { published: true } } })),
175
236
  });
176
237
  ```
177
238
 
@@ -219,6 +280,8 @@ Noma'lum kalit skip qilinadi; yaroqli sort qolmasa `createdAt desc`, u ham bo'lm
219
280
  // 1) Offset — total_items/total_pages bilan
220
281
  const list = await usersRepository.findList({ page: 2, perPage: 20 });
221
282
  list.meta; // { total_items, total_pages, current_page, per_page, has_next, has_prev }
283
+ // withTotal: false — count so'ralmaydi (katta jadval): perPage + 1 qator, total_items / total_pages = null
284
+ const fast = await usersRepository.findList({ page: 2, perPage: 20, withTotal: false });
222
285
 
223
286
  // 2) Infinite-scroll — COUNT'siz
224
287
  const feed = await postsRepository.findInfinite({ limit: 20, offset: 40 });
@@ -230,12 +293,35 @@ const p2 = await postsRepository.findCursor({ limit: 20, cursor: p1.meta.next_cu
230
293
  p1.meta; // { limit, has_next, has_prev, next_cursor, prev_cursor }
231
294
  ```
232
295
 
296
+ ### Cursor: teng qiymatlar va imzo
297
+
298
+ - Seek kaliti `(cursorKey, @id)`: `cursorKey` unikal bo'lmasa ham (masalan bir xil `createdAt`) hech bir qator tushib qolmaydi va takrorlanmaydi. `cursorKey` primary key'ning o'zi bo'lsa — bitta maydonli seek.
299
+ - Token (v2) ichida faqat skalyar qiymat (satr, son, boolean, ISO sana) bo'ladi va maydon tipiga cast qilinadi; obyekt, massiv yoki mos kelmaydigan qiymat → `QueryKitError` `reason: "invalid-value"`, `site: "cursorKey"` (drayver chaqirilmaydi).
300
+ - Registry opsiyasi `cursorSecret` token'ni HMAC-SHA256 bilan imzolaydi; imzosiz yoki o'zgartirilgan token → `invalid-value`. Usiz token imzosiz — mijoz uni o'zi yasay oladi (faqat ruxsat etilgan `sortable` kalit va filter doirasida).
301
+
302
+ ```ts
303
+ export const registry = createRegistry(prisma, { cursorSecret: process.env.CURSOR_SECRET });
304
+ ```
305
+
233
306
  `perPage`/`limit` **cheklangan** (`DEFAULT_MAX_PER_PAGE` = 200) — validatsiya chetlab o'tilsa ham repository o'zi clamp qiladi.
234
307
 
235
308
  > Cursor tokeni `drizzle-pg` va `mongoose` adapterlari bilan **bayt-ma-bayt bir xil** — backend ORM'i almashsa ham eski token ishlayveradi.
236
309
 
237
310
  ## Tipli `columns` va `with`
238
311
 
312
+ Mijoz `with` bilan faqat `relations`da yozilgan relation'larni ochadi (task 014, R21):
313
+
314
+ ```ts
315
+ const usersRepository = registry.repository("user", {
316
+ relations: { posts: { columns: ["id", "title"], filterable: { published: "*" } } },
317
+ });
318
+ await usersRepository.findAll({ with: { posts: { where: { published: true }, take: 5 } } }); // posts: id, title
319
+ ```
320
+
321
+ - `columns` majburiy — ro'yxat yoki aniq `"*"`. `with: { rel: true }` ham faqat shu ustunlarni tanlaydi.
322
+ - Ro'yxatda yo'q relation, ustun, filtr maydoni yoki ichki relation → `QueryKitError` `reason: "forbidden-key"`, `site: "with"`, `key` — `relation` yoki `relation.field`; drayver chaqirilmaydi.
323
+ - Ruxsat etilgan opsiyalar: `select`, ichki `include` (`nested` bilan), `where` (maydonlari `filterable`da), `take` / `skip`, ruxsat etilgan ustunlar bo'yicha `orderBy`.
324
+
239
325
  ```ts
240
326
  const picked = await usersRepository.findAll({ columns: { id: true, email: true } });
241
327
  // ^? { id: number; email: string }[] — `name` tipda ham yo'q
@@ -255,8 +341,29 @@ const both = await usersRepository.findAll({ columns: { id: true }, with: { post
255
341
  await usersRepository.findAll({ with: { posts: { select: { title: true }, take: 5, orderBy: { createdAt: "desc" } } } });
256
342
  ```
257
343
 
344
+ ## Ommaviy yangilash va o'chirish
345
+
346
+ `updateWhere` / `deleteWhere` — bitta yozish so'rovi (oldin o'qish yo'q), natija `{ count }`:
347
+
348
+ ```ts
349
+ const { count } = await usersRepository.updateWhere([{ key: "status", value: "trial" }], { status: "expired" });
350
+ await usersRepository.deleteWhere([{ key: "status", value: "expired" }]); // { count }
351
+
352
+ // returning: { limit } — `{ count, rows }`, ko'pi bilan limit ta qator (maxLimit, default 200)
353
+ const { rows } = await usersRepository.updateWhere(filter, patch, { returning: { limit: 50 } });
354
+
355
+ // filtrsiz — faqat aniq ko'rsatilsa (scope baribir qo'llanadi)
356
+ await usersRepository.deleteWhere({ all: true });
357
+ ```
358
+
359
+ - Bo'sh filtr (`undefined`, `{}`, `[]`) yoki hamma sharti tashlab yuborilgan filtr (masalan faqat noma'lum kalit) → `QueryKitError` `reason: "empty-filter"`, `site: "mutation"` — drayver chaqirilmaydi, jadval jim o'chmaydi.
360
+ - Mutatsiya filtri o'qishdagidek `filterable` va `limits`dan o'tadi (`forbidden-key`, `forbidden-operator`, `limit-exceeded`). Server'ning o'z filtri ham.
361
+ - `updateWhere` `rows` — bitta `updateManyAndReturn` natijasi (atomar). `deleteWhere` `rows` — o'chirishdan **oldin** o'qiladi (Prisma'da `deleteManyAndReturn` yo'q) — atomar emas: orada o'zgargan qator ro'yxatda bo'lishi yoki bo'lmasligi mumkin.
362
+
258
363
  ## Tranzaksiyalar
259
364
 
365
+ > ⚠️ Callback ichida **faqat DB amallari**. Tashqi amallar (HTTP, xabar, email) — outbox orqali: drayver tranzaksiyani qayta urinishi mumkin, tashqi amal esa takrorlanadi va qaytarib bo'lmaydi.
366
+
260
367
  ```ts
261
368
  await registry.transaction(async () => {
262
369
  const user = await usersRepository.create({ name: "Ali", email: "ali@example.com" });
@@ -278,6 +385,12 @@ await mine.create({ title: "Yangi" }); // authorId avtomatik qo'yiladi
278
385
 
279
386
  Scope har bir o'qish/yozishga `AND` bilan qo'shiladi va insert'larga singdiriladi (scope ustun keladi). Scope kaliti model'da topilmasa repository **yaratilishida** xato beradi — jimgina tushib qolsa filtr yo'qolardi.
280
387
 
388
+ Scope qiymati ro'yxat ham bo'lishi mumkin (task 014): `{ shopId: { in: [1, 2] } }` — har o'qish va yozish shu qiymatlar bilan cheklanadi; bo'sh ro'yxat (`{ in: [] }`) — hech qanday qator (xato emas). `create` faqat tenglik scope'larini to'ldiradi.
389
+
390
+ ```ts
391
+ const mine = repository.scoped({ shopId: { in: user.shopIds } });
392
+ ```
393
+
281
394
  ## Projeksiya guard'lari
282
395
 
283
396
  ```ts
@@ -318,13 +431,60 @@ await ordersRepository.aggregate({ sum: "amount", avg: "amount", groupBy: "regio
318
431
  >
319
432
  > **Bulk.** Prisma'da bulk upsert yo'q — `upsertMany` ketma-ket `upsert` qiladi (kirish tartibi saqlanadi). Katta importlar uchun `registry.transaction()` ichida chaqiring.
320
433
 
434
+ ## Qiymatlar, NULL tartibi va bir xillik
435
+
436
+ Uchala adapter (drizzle-pg, prisma-pg, mongoose) bir xil natija beradi (task 021):
437
+
438
+ - **Literal qiymat operatorlari.** `contains`, `startsWith`, `endsWith` qiymatni harfma-harf qidiradi: `startsWith "%"` faqat `%` bilan boshlanuvchi qatorlar, `contains "_"` — haqiqiy `_`. Wildcard faqat `like` / `ilike` / `notLike` da.
439
+ - **Tip cast'i.** Har qiymat maydon tipiga keltiriladi (son, boolean, sana, uuid, enum). Mos kelmasa (`age = "abc"`) — `invalid-value`: shart tashlanadi va `onSkippedCondition` chaqiriladi, `strict` da `QueryKitError`; drayver chaqirilmaydi.
440
+ - **`not`** — SQL uch qiymatli mantiq: NULL maydon bo'yicha `not(and(…))` / `not(or(…))` hamma adapterda bir xil.
441
+ - **NULL tartibi.** `asc` — NULL birinchi, `desc` — NULL oxirida (MongoDB tartibi). prisma-pg `{ sort, nulls }` ni faqat maydon ixtiyoriy ekani ma'lum bo'lsa qo'yadi (pastdagi Cheklovlar).
442
+ - **Buzuq sort elementi** (`null`, `{}`) — tashlanadi va `invalid-value` (`site: "sort"`) bilan xabar qilinadi, `strict` da xato.
443
+
444
+ ## Migrating to 2.0
445
+
446
+ 2.0 da siyosatsiz repository **tor**: faqat `allowedColumns` / `forcedColumns` / `@id` maydoni bo'yicha `=` va `in`, sort — `@id` va `createdAt`. 1.x xulqini bitta repository uchun ataylab qaytarish:
447
+
448
+ ```ts
449
+ const open = registry.repository(prisma.user, {
450
+ filterable: { id: "*", name: "*", email: "*", age: "*", createdAt: "*" },
451
+ sortable: ["id", "name", "email", "age", "createdAt"],
452
+ });
453
+ ```
454
+
455
+ Xom `where` endi `raw()` bilan (R19):
456
+
457
+ ```ts
458
+ import { raw } from "@querykitjs/core";
459
+
460
+ // 1.x — 2.0 da forbidden-key:
461
+ // await usersRepository.findAll({ filter: { posts: { some: { published: true } } } });
462
+
463
+ // 2.0:
464
+ await usersRepository.findAll({ filter: raw({ posts: { some: { published: true } } }) });
465
+ ```
466
+
467
+ - Avval `onSkippedCondition` loglaridan ishlatilayotgan maydon/operatorlarni yig'ing, keyin `filterable`ni aynan shularga toraytiring — `"*"` vaqtinchalik yo'l.
468
+ - `QueryKitError`ning yangi `reason`lari: `forbidden-key`, `forbidden-operator`, `limit-exceeded`; yangi `site`: `page`. Ularni 400 qilib qaytaring.
469
+ - Offset 10 000 dan chuqur sahifalar endi rad etiladi — `findCursor` ishlating yoki `limits: { maxOffset }` bilan oshiring.
470
+ - Cursor token formati v2 (task 012): eski (v1) `next_cursor` / `prev_cursor` endi `invalid-value` bilan rad etiladi — yangilashdan keyin paginatsiyani birinchi sahifadan boshlang.
471
+ - `updateWhere` / `deleteWhere` endi `Row[]` emas, `{ count }` qaytaradi (task 013); qatorlar kerak bo'lsa `{ returning: { limit } }` → `{ count, rows }`.
472
+ - Filtrsiz `updateWhere` / `deleteWhere` → `empty-filter`; hamma qatorlar uchun `{ all: true }` yozing. Mutatsiya filtri endi `filterable` siyosatidan o'tadi.
473
+ - `OffsetMeta.total_items` / `total_pages` tipi `number | null` (`withTotal: false` bilan `null`).
474
+ - `with` endi faqat `relations` allow-list bilan ishlaydi (task 014): ro'yxatsiz repository'da `with` → `forbidden-key`, `site: "with"`.
475
+ - Scope qiymati `{ in: [...] }` bo'lishi mumkin; `timestamps` registry opsiyasi createdAt / deletedAt nomlarini beradi.
476
+
321
477
  ## Cheklovlar
322
478
 
323
479
  - `updateById` / `deleteById` / `softDelete` / `restore` — **bitta maydonli `@id`** talab qiladi. Prisma `update`/`delete` faqat unique `where` qabul qiladi, querykit predikati esa scope va soft-delete guard'ini ham olib yuradi. Kompozit `@@id` bo'lsa `updateWhere`/`deleteWhere` ishlating.
324
480
  - Filter/sort kalitlari — **Prisma maydon nomi** (`createdAt`), DB ustun nomi (`created_at`) emas. Prisma `where`ning o'zi ham `@map`langan nomni qabul qilmaydi.
325
481
  - Matn operatorlari faqat `String` maydonlarda; boshqa tipda shart tashlanadi va reportlanadi.
326
482
 
327
- To'liq ro'yxat va uchala adapter solishtiruvi: [PARITY.md](./PARITY.md).
483
+ Bu farqlar `test/contract.ts` bilan qadalgan — uchala adapter bir xil payloaddan bir xil natija berishi shu yerda tekshiriladi.
484
+
485
+ - **`like` / `ilike` da `_`.** Prisma'da bitta belgili wildcard yo'q: qochirilmagan `_` → `invalid-value` (strict'da xato). Literal `_` uchun `\\_` yozing.
486
+ - **uuid maydonlari.** Runtime model `@db.Uuid` ni bermasa, noto'g'ri uuid Postgres'ga yetadi (22P02 / P2023) — maydonni e'lon qiling: `fields: [{ name: "ref", nativeType: "Uuid" }]`, shunda `invalid-value`.
487
+ - **NULL tartibi Prisma 7 da.** Prisma 7 runtime modeli maydon ixtiyoriymi-yo'qligini bermaydi; `nulls` faqat ma'lum bo'lsa qo'yiladi. Ixtiyoriy maydonni e'lon qiling: `registry.repository(prisma.user, { fields: [{ name: "age", isRequired: false }] })` — aks holda o'sha maydonda PostgreSQL tartibi.
328
488
 
329
489
  ## API ma'lumotnoma
330
490