@querykitjs/prisma-pg 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suhrobbek Soatov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,363 @@
1
+ <div align="right">
2
+
3
+ **English** · [O'zbekcha](./README.md)
4
+
5
+ </div>
6
+
7
+ # @querykitjs/prisma-pg
8
+
9
+ > A repository layer over **Prisma (PostgreSQL)** with advanced filtering and flexible pagination.
10
+
11
+ Everything you would otherwise hand-write on top of Prisma — nested filters, three pagination styles, transactions, RBAC scoping, soft-delete, bulk upserts and aggregation — behind one typed repository. Result types are inferred **automatically** from `columns`/`with`.
12
+
13
+ ```ts
14
+ const { data, meta } = await usersRepository.findList({
15
+ page: 2,
16
+ perPage: 20,
17
+ filter: f.and(f.eq("status", "active"), f.gte("age", 18)),
18
+ sort: [{ key: "createdAt", direction: "desc" }],
19
+ });
20
+ // ^? data: User[] meta: { total_items, total_pages, has_next, ... }
21
+ ```
22
+
23
+ ## Features
24
+
25
+ - **Advanced filters** — nested `and`/`or`/`not`, 27 operators, raw Prisma `where` escape hatch, `f` helpers.
26
+ - **Three pagination styles** — offset (`findList`), infinite scroll (`findInfinite`), cursor/keyset (`findCursor`).
27
+ - **`columns` + `with` inference** — selected fields and loaded relations are typed with no manual generics.
28
+ - **Multi-field sort**, `count`, `exists`, `aggregate` (count/sum/avg/min/max + groupBy).
29
+ - **Transactions** — `registry.transaction()`; repositories inside it join automatically.
30
+ - **Scoped repositories** — a constant base filter + insert defaults, for RBAC / multi-tenancy.
31
+ - **Soft delete** — automatic when the model has `deletedAt`; `softDelete`/`restore`/`withDeleted`.
32
+ - **Upsert / bulk** — `upsert`, `upsertMany`.
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)).
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ bun add @querykitjs/prisma-pg @prisma/client
40
+ # or: npm i @querykitjs/prisma-pg @prisma/client
41
+ ```
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.
44
+
45
+ ## Setup
46
+
47
+ ### 1. Registry (once)
48
+
49
+ ```ts
50
+ // db/registry.ts
51
+ import { PrismaClient } from "@prisma/client";
52
+ import { createRegistry } from "@querykitjs/prisma-pg";
53
+
54
+ export const prisma = new PrismaClient();
55
+ export const registry = createRegistry(prisma);
56
+ // or with pagination defaults:
57
+ // export const registry = createRegistry(prisma, { defaultPerPage: 20, defaultLimit: 20 });
58
+ ```
59
+
60
+ ### 2. Repository (per model)
61
+
62
+ 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"`.
63
+
64
+ ```ts
65
+ // db/users.repository.ts
66
+ import { prisma, registry } from "./registry";
67
+
68
+ export const usersRepository = registry.repository(prisma.user);
69
+ // …or by name: registry.repository("user") — the two are equivalent
70
+
71
+ // with custom methods:
72
+ export const postsRepository = registry.repository("post", base => ({
73
+ findBySlug: (slug: string) => base.findOne({ filter: [{ key: "slug", value: slug }] }),
74
+ }));
75
+
76
+ // with repository options (RBAC / projection):
77
+ export const safeUsers = registry.repository("user", {
78
+ forcedColumns: { id: true, name: true, email: true }, // a password can never leak
79
+ });
80
+ ```
81
+
82
+ ## Framework-agnostic: Express, Hono, NestJS
83
+
84
+ This package knows nothing about HTTP. Parse and validate the request in your app (`@querykitjs/zod` or `@querykitjs/class-validator`); the repository call is identical everywhere.
85
+
86
+ **Express**
87
+
88
+ ```ts
89
+ import express from "express";
90
+ import { offsetParamsSchema } from "@querykitjs/zod";
91
+ import { usersRepository } from "./db/users.repository";
92
+
93
+ const app = express();
94
+ app.post("/users/list", express.json(), async (req, res) => {
95
+ const params = offsetParamsSchema.parse(req.body);
96
+ res.json(await usersRepository.findList(params));
97
+ });
98
+ ```
99
+
100
+ **Hono**
101
+
102
+ ```ts
103
+ import { Hono } from "hono";
104
+ import { offsetParamsSchema } from "@querykitjs/zod";
105
+ import { usersRepository } from "./db/users.repository";
106
+
107
+ const app = new Hono();
108
+ app.post("/users/list", async c => {
109
+ const params = offsetParamsSchema.parse(await c.req.json());
110
+ return c.json(await usersRepository.findList(params));
111
+ });
112
+ ```
113
+
114
+ **NestJS**
115
+
116
+ ```ts
117
+ import { Body, Controller, Post } from "@nestjs/common";
118
+ import { OffsetParamsDto } from "@querykitjs/class-validator";
119
+ import { usersRepository } from "./db/users.repository";
120
+
121
+ @Controller("users")
122
+ export class UsersController {
123
+ @Post("list")
124
+ list(@Body() params: OffsetParamsDto) {
125
+ return usersRepository.findList(params);
126
+ }
127
+ }
128
+ ```
129
+
130
+ A validated payload goes into the repository params with **no cast** — the contract is closed through `@querykitjs/core`.
131
+
132
+ ## Advanced filters
133
+
134
+ A filter is a tree of field conditions and logical groups (`and`/`or`/`not`). A flat array is implicit `AND`.
135
+
136
+ ```ts
137
+ import { createFilters } from "@querykitjs/prisma-pg";
138
+ const f = createFilters<typeof prisma.user>(); // field-name autocomplete
139
+
140
+ await usersRepository.findAll({
141
+ filter: f.and(f.eq("status", "active"), f.or(f.gte("age", 18), f.in("role", ["admin", "owner"])), f.not(f.isNull("deletedAt"))),
142
+ });
143
+
144
+ // object form (no helpers):
145
+ await usersRepository.findAll({
146
+ filter: [
147
+ { key: "status", operation: "=", value: "active" },
148
+ { key: "name", operation: "%_%", value: "ali" },
149
+ ],
150
+ });
151
+ ```
152
+
153
+ ### Operators
154
+
155
+ | Group | Operators |
156
+ | ---------- | -------------------------------------------------------------------------------- |
157
+ | Comparison | `=`/`eq`, `!=`/`ne`, `>`/`gt`, `>=`/`gte`, `<`/`lt`, `<=`/`lte` |
158
+ | Text | `contains`/`%_%`, `startsWith`/`%_`, `endsWith`/`_%`, `like`, `ilike`, `notLike` |
159
+ | Set | `in`, `notIn`, `between`, `notBetween` |
160
+ | NULL | `isNull`, `isNotNull` |
161
+
162
+ `contains`/`startsWith`/`endsWith` are case-**insensitive** (`mode: "insensitive"`), `like` is case-**sensitive**, `ilike` is insensitive — exactly as in the drizzle-pg adapter.
163
+
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.
165
+
166
+ ### Raw `where` escape hatch
167
+
168
+ For anything the DSL cannot express (relation predicates, JSON filters), pass a Prisma `where` object as a filter node:
169
+
170
+ ```ts
171
+ await usersRepository.findAll({ filter: { posts: { some: { title: { contains: "querykit" } } } } });
172
+
173
+ await usersRepository.findAll({
174
+ filter: f.or(f.eq("role", "admin"), { posts: { some: { published: true } } }),
175
+ });
176
+ ```
177
+
178
+ ### Unknown keys: watch, then enforce
179
+
180
+ An unknown filter/sort key is **dropped silently** (legacy parity). That widens results, so you can make it visible:
181
+
182
+ ```ts
183
+ // step 1: watch (behaviour unchanged)
184
+ createRegistry(prisma, { onSkippedCondition: info => logger.warn({ querykit: info }, "condition dropped") });
185
+
186
+ // step 2: once the log is clean — enforce
187
+ createRegistry(prisma, { strict: true });
188
+ ```
189
+
190
+ ```ts
191
+ import { QueryKitError } from "@querykitjs/core";
192
+
193
+ try {
194
+ return await usersRepository.findList(params);
195
+ } catch (err) {
196
+ if (err instanceof QueryKitError) return res.status(400).json({ error: err.message, info: err.info });
197
+ throw err;
198
+ }
199
+ ```
200
+
201
+ A `scope` or `cursorKey` key is **always** fatal regardless of `strict` — those are the server's own, and dropping them silently would be a security hole.
202
+
203
+ ## Multi-field sort
204
+
205
+ ```ts
206
+ await usersRepository.findAll({
207
+ sort: [
208
+ { key: "role", direction: "asc" },
209
+ { key: "createdAt", direction: "desc" },
210
+ ],
211
+ });
212
+ ```
213
+
214
+ Unknown keys are skipped; if no valid sort remains it falls back to `createdAt desc`, then `<id> desc`, so pagination stays stable.
215
+
216
+ ## Pagination — three strategies
217
+
218
+ ```ts
219
+ // 1) Offset — with total_items/total_pages
220
+ const list = await usersRepository.findList({ page: 2, perPage: 20 });
221
+ list.meta; // { total_items, total_pages, current_page, per_page, has_next, has_prev }
222
+
223
+ // 2) Infinite scroll — no COUNT
224
+ const feed = await postsRepository.findInfinite({ limit: 20, offset: 40 });
225
+ feed.meta; // { limit, offset, count, has_more, next_offset }
226
+
227
+ // 3) Cursor (keyset) — stable under inserts
228
+ const p1 = await postsRepository.findCursor({ limit: 20, order: "desc" });
229
+ const p2 = await postsRepository.findCursor({ limit: 20, cursor: p1.meta.next_cursor });
230
+ p1.meta; // { limit, has_next, has_prev, next_cursor, prev_cursor }
231
+ ```
232
+
233
+ `perPage`/`limit` are **capped** (`DEFAULT_MAX_PER_PAGE` = 200) — the repository clamps even if validation was bypassed.
234
+
235
+ > Cursor tokens are **byte-identical** to the drizzle-pg and mongoose adapters', so an old token keeps working if the backend swaps ORMs.
236
+
237
+ ## Typed `columns` and `with`
238
+
239
+ ```ts
240
+ const picked = await usersRepository.findAll({ columns: { id: true, email: true } });
241
+ // ^? { id: number; email: string }[] — `name` is not even in the type
242
+
243
+ const withPosts = await usersRepository.findAll({ with: { posts: true } });
244
+ withPosts[0].posts[0].title; // the relation is typed
245
+
246
+ const both = await usersRepository.findAll({ columns: { id: true }, with: { posts: true } });
247
+ // ^? { id: number; posts: Post[] }[]
248
+ ```
249
+
250
+ > ⚠️ Prisma rejects `select` and `include` **at the same level**. The adapter handles it: when both are given, relations are placed inside a single `select`. You never see the difference.
251
+
252
+ A `with` value may be `true` or a Prisma relation config:
253
+
254
+ ```ts
255
+ await usersRepository.findAll({ with: { posts: { select: { title: true }, take: 5, orderBy: { createdAt: "desc" } } } });
256
+ ```
257
+
258
+ ## Transactions
259
+
260
+ ```ts
261
+ await registry.transaction(async () => {
262
+ const user = await usersRepository.create({ name: "Ali", email: "ali@example.com" });
263
+ await postsRepository.create({ title: "Hello", authorId: user.id });
264
+ });
265
+ ```
266
+
267
+ Repositories inside pick up the transaction client through ambient context (AsyncLocalStorage) — no `tx` threading. Any throw rolls everything back.
268
+
269
+ > ⚠️ Prisma has no savepoints, so a **nested** `transaction()` reuses the current one instead of opening another. The mongoose adapter behaves the same; drizzle-pg creates a real savepoint.
270
+
271
+ ## Scoped repositories (RBAC / multi-tenancy)
272
+
273
+ ```ts
274
+ const mine = postsRepository.scoped({ authorId: user.id });
275
+ await mine.findList({ page: 1 }); // only this user's posts
276
+ await mine.create({ title: "New" }); // authorId filled in automatically
277
+ ```
278
+
279
+ 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
+
281
+ ## Projection guards
282
+
283
+ ```ts
284
+ registry.repository("user", { forcedColumns: { id: true, name: true } }); // client `columns` ignored
285
+ registry.repository("user", { allowedColumns: ["id", "name", "email"] }); // client selection intersected
286
+ ```
287
+
288
+ An empty intersection yields the allowlist, **never** the full row. The guard also covers `aggregate`, and reading a forbidden field through `cursorKey` is prevented.
289
+
290
+ > ⚠️ Guards affect **read** methods only. Write methods return the full `Row` by type contract — re-read with `findById` before handing a write result to a client.
291
+
292
+ ## Soft delete
293
+
294
+ Enabled automatically when the model has a `deletedAt` field:
295
+
296
+ ```ts
297
+ await postsRepository.softDelete(id); // deletedAt = now()
298
+ await postsRepository.findAll({}); // deleted rows are excluded
299
+ await postsRepository.findAll({ withDeleted: true }); // everything
300
+ await postsRepository.restore(id); // deletedAt = null
301
+ ```
302
+
303
+ An `updatedAt` field is bumped on every update (unless it is `@updatedAt`, which Prisma maintains itself — the adapter then stays out of the way).
304
+
305
+ ## Upsert, bulk and aggregation
306
+
307
+ ```ts
308
+ await usersRepository.upsert({ email: "a@b.com", name: "Ali" }, { target: "email" });
309
+ await usersRepository.upsertMany(rows, { target: "externalId" });
310
+
311
+ await postsRepository.aggregate({ count: true, groupBy: "authorId" });
312
+ // → [{ authorId: 1, count: 12 }, …]
313
+ await ordersRepository.aggregate({ sum: "amount", avg: "amount", groupBy: "region" });
314
+ // → [{ region: "TAS", sum_amount: 900, avg_amount: 75 }, …]
315
+ ```
316
+
317
+ > **Compound unique.** A multi-field `target` uses Prisma's generated name: `{ target: ["a", "b"] }` → `where: { a_b: { a, b } }`. For a constraint declared with `@@unique(name: "...")`, use the raw client.
318
+ >
319
+ > **Bulk.** Prisma has no bulk upsert, so `upsertMany` issues sequential `upsert`s (input order preserved). Wrap large imports in `registry.transaction()`.
320
+
321
+ ## Limitations
322
+
323
+ - `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
+ - 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
+ - Text operators only apply to `String` fields; on any other type the condition is dropped and reported.
326
+
327
+ Full list and the three-adapter comparison: [PARITY.md](./PARITY.md).
328
+
329
+ ## API reference
330
+
331
+ | Method | Description |
332
+ | -------------------------------------------- | -------------------------------------- |
333
+ | `findAll(params?)` | All matching rows |
334
+ | `findOne(params?)` / `findById(id, params?)` | One row or `undefined` |
335
+ | `findList(params?)` | Offset pagination — `{ data, meta }` |
336
+ | `findInfinite(params?)` | Infinite scroll — `{ data, meta }` |
337
+ | `findCursor(params?)` | Cursor/keyset — `{ data, meta }` |
338
+ | `count(filter?)` / `exists(filter?)` | Count / existence |
339
+ | `create` / `createMany` | Insert |
340
+ | `upsert` / `upsertMany` | Insert or update on conflict |
341
+ | `updateById` / `updateWhere` | Update (+ `updatedAt` bump) |
342
+ | `deleteById` / `deleteWhere` | Hard delete |
343
+ | `softDelete` / `restore` | Soft delete / restore |
344
+ | `aggregate(spec)` | count/sum/avg/min/max + groupBy |
345
+ | `scoped(scope)` | A new repository with a constant scope |
346
+
347
+ `createRegistry(prisma, options?)` — `defaultPerPage`, `defaultLimit`, `maxPerPage`, `maxLimit`, `strict`, `onSkippedCondition`.
348
+
349
+ ## Development
350
+
351
+ ```bash
352
+ bun run build
353
+ bun run typecheck
354
+ bun run test:generate # fixture Prisma client (no database needed)
355
+ bun run test:query # compiler tests (no database)
356
+ bun run test:contract # cross-adapter + web contract audit (no database)
357
+ bun run test:types # type-inference test
358
+ DATABASE_URL=postgres://user:pass@localhost:5432/db bun run test:smoke
359
+ ```
360
+
361
+ ## License
362
+
363
+ MIT