@querykitjs/core 1.2.0 → 2.1.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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Filter operatorlari — yagona runtime manba. `FilterOperator` tipi (`types.ts`)
3
+ * shundan chiqadi; `@querykitjs/zod` shu massivdan zod enum quradi.
4
+ */
5
+ declare const FILTER_OPERATORS: readonly ["=", "!=", ">", ">=", "<", "<=", "eq", "ne", "gt", "gte", "lt", "lte", "like", "ilike", "notLike", "contains", "startsWith", "endsWith", "%_%", "%_", "_%", "in", "notIn", "between", "notBetween", "isNull", "isNotNull"];
6
+ /**
7
+ * Qiymati **text pattern** bo'lgan operatorlar. Backend adapterlari wire qiymatni
8
+ * ustun/path tipiga cast qilishda shularni chetlab o'tadi — aks holda
9
+ * `ilike(dateColumn, "%2026%")` ISO string o'rniga `String(Date)` olardi
10
+ * (`"Mon Jul 28 2026 …"`). Ro'yxat ikki adapterda divergent bo'lmasligi uchun
11
+ * shu yerda — yagona manba.
12
+ */
13
+ declare const TEXT_FILTER_OPERATORS: readonly ["like", "ilike", "notLike", "contains", "startsWith", "endsWith", "%_%", "%_", "_%"];
14
+
15
+ /**
16
+ * `@querykitjs/core` — ORM-agnostik query DSL tiplari. Backend adapterlar
17
+ * (`@querykitjs/drizzle-pg`) va frontend (`@querykitjs/web`) shu bir xil kontraktga
18
+ * tayanadi. Tiplar ustun-kaliti (`TKey`) bo'yicha generic; adapterlar uni o'z
19
+ * kalit tipiga (Drizzle jadval ustuni yoki entity maydoni) ixtisoslashtiradi.
20
+ */
21
+
22
+ /** Qo'llab-quvvatlanadigan filter operatorlari (token va nom aliaslari). */
23
+ type FilterOperator = (typeof FILTER_OPERATORS)[number];
24
+ /**
25
+ * Tiplangan kalit, lekin har qanday `string` ham qabul qilinadi. Wire (JSON) har
26
+ * doim string-keyed keladi va adapterlar noma'lum kalitni baribir skip qiladi —
27
+ * tip shunchaki haqiqatni aytsin, route'da `as` cast kerak bo'lmasin.
28
+ *
29
+ * `(string & {})` — autocomplete'ni saqlaydigan TS trick'i: IDE `TKey`
30
+ * variantlarini taklif qiladi, boshqa string esa xato bermaydi.
31
+ */
32
+ type LooseKey<TKey extends string> = TKey | (string & {});
33
+ /**
34
+ * A filter scalar. `Date` is accepted **in-memory** (adapters cast it for local
35
+ * queries); over the JSON wire it serializes to an ISO string, so
36
+ * `@querykitjs/zod` validates scalars **without** `Date` (string/number/boolean/null).
37
+ */
38
+ type FilterScalar = string | number | boolean | Date | null;
39
+ type FilterValue = FilterScalar | FilterScalar[];
40
+ /** Bitta maydon sharti (`operation` default `"="`). */
41
+ interface FieldCondition<TKey extends string = string> {
42
+ key: TKey;
43
+ operation?: FilterOperator;
44
+ value?: FilterValue;
45
+ }
46
+ interface AndGroup<TKey extends string = string, TRaw = never> {
47
+ and: FilterNode<TKey, TRaw>[];
48
+ }
49
+ interface OrGroup<TKey extends string = string, TRaw = never> {
50
+ or: FilterNode<TKey, TRaw>[];
51
+ }
52
+ interface NotGroup<TKey extends string = string, TRaw = never> {
53
+ not: FilterNode<TKey, TRaw>;
54
+ }
55
+ /**
56
+ * Filter daraxti tuguni: maydon sharti, mantiqiy guruh, yoki adapter qo'shadigan
57
+ * raw escape-hatch (`TRaw`, masalan Drizzle `SQL`; default `never`).
58
+ */
59
+ type FilterNode<TKey extends string = string, TRaw = never> = FieldCondition<TKey> | AndGroup<TKey, TRaw> | OrGroup<TKey, TRaw> | NotGroup<TKey, TRaw> | TRaw;
60
+ /** Public filter: daraxt/tugun yoki flat massiv (implicit AND). */
61
+ type Filter<TKey extends string = string, TRaw = never> = FilterNode<TKey, TRaw> | FieldCondition<TKey>[];
62
+ type SortDirection = "asc" | "desc";
63
+ /** One sort field — `{ key, direction }`. */
64
+ interface SortItem<TKey extends string = string> {
65
+ key: TKey;
66
+ direction?: SortDirection;
67
+ }
68
+ /** Sort is **always an array** of {@link SortItem} (multi-field capable). */
69
+ type Sort<TKey extends string = string> = SortItem<TKey>[];
70
+ /** Scope qiymati ro'yxat bo'yicha: `{ in: [...] }`; bo'sh ro'yxat — hech qanday qator. */
71
+ interface ScopeIn {
72
+ in: readonly FilterScalar[];
73
+ }
74
+ /**
75
+ * Scoped repository'ning har amaliga qo'llanadigan scope: maydon → tenglik qiymati yoki `{ in: [...] }`.
76
+ * `create` default'lari faqat tenglik scope'laridan olinadi.
77
+ */
78
+ type Scope<TKey extends string = string> = Partial<Record<TKey, FilterScalar | ScopeIn>>;
79
+ /** Options for upsert / upsertMany — conflict target(s) + optional update set. */
80
+ interface UpsertOptions<TKey extends string = string, TInsert = Record<string, unknown>> {
81
+ /** Unique/conflict key(s) whose match triggers an update instead of an insert. */
82
+ target: TKey | TKey[];
83
+ /** Columns/fields to update on conflict. Defaults to the inserted values minus `target`. */
84
+ set?: Partial<TInsert>;
85
+ }
86
+ /** Aggregate query spec — group + count/sum/avg/min/max, each keyed by field. */
87
+ interface AggregateSpec<TKey extends string = string, TFilter = unknown> {
88
+ filter?: TFilter;
89
+ /** Group rows by these field(s); each appears in the output rows. */
90
+ groupBy?: TKey | TKey[];
91
+ count?: boolean;
92
+ sum?: TKey | TKey[];
93
+ avg?: TKey | TKey[];
94
+ min?: TKey | TKey[];
95
+ max?: TKey | TKey[];
96
+ withDeleted?: boolean;
97
+ }
98
+ /** Paginatsiyasiz umumiy wire params. */
99
+ interface WireBaseParams {
100
+ filter?: Filter;
101
+ sort?: Sort;
102
+ columns?: Record<string, boolean>;
103
+ with?: Record<string, unknown>;
104
+ withDeleted?: boolean;
105
+ }
106
+ /** Offset (sahifali) wire params. */
107
+ interface WireOffsetParams extends WireBaseParams {
108
+ page?: number;
109
+ perPage?: number;
110
+ /**
111
+ * `false` — umumiy son so'ralmaydi (count yo'q): `perPage + 1` qator o'qiladi,
112
+ * `has_next` shundan, `total_items` / `total_pages` = `null`. Default `true`.
113
+ */
114
+ withTotal?: boolean;
115
+ }
116
+ /** Infinite-scroll wire params. */
117
+ interface WireInfiniteParams extends WireBaseParams {
118
+ limit?: number;
119
+ offset?: number;
120
+ }
121
+ /** Cursor (keyset) wire params — `sort` yo'q, tartib `order` + `direction` bilan. */
122
+ interface WireCursorParams extends Omit<WireBaseParams, "sort"> {
123
+ limit?: number;
124
+ cursor?: string | null;
125
+ cursorKey?: string;
126
+ order?: SortDirection;
127
+ direction?: "forward" | "backward";
128
+ }
129
+ /**
130
+ * Shart nima uchun tashlab yuborilgani yoki rad etilgani. `forbidden-key`,
131
+ * `forbidden-operator` va `limit-exceeded` — server siyosati (`filterable` /
132
+ * `sortable` / `limits`): ular **hech qachon jim tashlanmaydi**, `strict`dan qat'i nazar
133
+ * `QueryKitError` bo'ladi.
134
+ */
135
+ type SkipReason = "unknown-key" | "invalid-value" | "forbidden-key" | "forbidden-operator" | "limit-exceeded" | "empty-filter";
136
+ /**
137
+ * Shart qayerda tashlab yuborilgani (`page` — offset chuqurligi, `mutation` — `updateWhere` / `deleteWhere` filtri,
138
+ * `with` — `relations` ro'yxatida yo'q relation, ustun yoki filtr).
139
+ */
140
+ type SkipSite = "filter" | "sort" | "cursorKey" | "aggregate" | "page" | "mutation" | "with";
141
+ /**
142
+ * Server siyosati: maydon → ruxsat etilgan operatorlar, yoki `"*"` (hammasi — faqat aniq
143
+ * yozilganda). Ro'yxatda yo'q maydon yoki operator `QueryKitError` beradi.
144
+ */
145
+ type Filterable<TKey extends string = string> = Partial<Record<TKey, readonly FilterOperator[] | "*">>;
146
+ /** Tashlab yuborilgan bitta shart haqida ma'lumot. */
147
+ interface SkippedCondition {
148
+ /** Jadval (drizzle) yoki model (mongoose) nomi. */
149
+ source: string;
150
+ site: SkipSite;
151
+ key: string;
152
+ operation?: FilterOperator;
153
+ reason: SkipReason;
154
+ }
155
+ /** Offset (sahifali) paginatsiya meta'si. */
156
+ interface OffsetMeta {
157
+ /** `withTotal: false` bilan `null` (count so'ralmagan). */
158
+ total_items: number | null;
159
+ /** `withTotal: false` bilan `null`. */
160
+ total_pages: number | null;
161
+ current_page: number;
162
+ per_page: number;
163
+ has_next: boolean;
164
+ has_prev: boolean;
165
+ }
166
+ /** Infinite-scroll paginatsiya meta'si. */
167
+ interface InfiniteMeta {
168
+ limit: number;
169
+ offset: number;
170
+ count: number;
171
+ has_more: boolean;
172
+ next_offset: number | null;
173
+ }
174
+ /** Cursor (keyset) paginatsiya meta'si. */
175
+ interface CursorMeta {
176
+ limit: number;
177
+ has_next: boolean;
178
+ has_prev: boolean;
179
+ next_cursor: string | null;
180
+ prev_cursor: string | null;
181
+ }
182
+ /**
183
+ * `updateWhere` / `deleteWhere` uchun "hamma qatorlar" — faqat shu aniq ko'rsatilsa filtrsiz
184
+ * mutatsiya bajariladi (scope baribir qo'llanadi). Bo'sh filtr — `QueryKitError` `empty-filter`.
185
+ */
186
+ interface AllRows {
187
+ all: true;
188
+ }
189
+ /** `updateWhere` / `deleteWhere` opsiyalari: `returning: { limit }` — ta'sirlangan qatorlardan ko'pi bilan `limit` tasi. */
190
+ interface MutationOptions {
191
+ returning?: false | {
192
+ limit: number;
193
+ };
194
+ }
195
+ /** Ta'sirlangan qatorlar soni. */
196
+ interface MutationCount {
197
+ count: number;
198
+ }
199
+ /** `returning: { limit }` bilan: soni va ko'pi bilan `limit` ta qator. */
200
+ interface MutationRows<TRow> extends MutationCount {
201
+ rows: TRow[];
202
+ }
203
+ /**
204
+ * Bitta relation uchun mijoz `with` ruxsati (R21): `columns` majburiy — ro'yxat yoki aniq `"*"`;
205
+ * `filterable` — relation filtrida (prisma `where`, mongoose `match`) ruxsat etilgan maydonlar;
206
+ * `nested` — shu relation ichidagi `with` uchun xuddi shu shakl (yo'q ⇒ ichki `with` — `forbidden-key`).
207
+ */
208
+ interface RelationRule {
209
+ columns: readonly string[] | "*";
210
+ filterable?: Filterable;
211
+ nested?: Relations;
212
+ }
213
+ /** Repository `relations`: relation → {@link RelationRule}. Ro'yxatda yo'q relation — `forbidden-key`, `site: "with"`. */
214
+ type Relations = Record<string, RelationRule>;
215
+
216
+ /**
217
+ * Umumiy pagination default'lari — yagona manba. Backend adapter (`createRegistry`
218
+ * options) va frontend (`createQuery` config) shu qiymatlarni fallback sifatida
219
+ * ishlatadi, shuning uchun ular hech qachon bir-biridan farq qilmaydi.
220
+ */
221
+ /** Offset (list) sahifa o'lchami default'i. */
222
+ declare const DEFAULT_PER_PAGE = 20;
223
+ /** Infinite/cursor `limit` default'i. */
224
+ declare const DEFAULT_LIMIT = 20;
225
+ /**
226
+ * `perPage` uchun yuqori chegara — cap'siz paginatsiya DoS yuzasi. Validatsiya
227
+ * (`@querykitjs/zod` factory'lari), backend repositorylari va frontend `createQuery`
228
+ * shu bitta qiymatga tayanadi, shuning uchun ular hech qachon farq qilmaydi.
229
+ */
230
+ declare const DEFAULT_MAX_PER_PAGE = 200;
231
+ /** Infinite/cursor `limit` uchun yuqori chegara. Qarang: {@link DEFAULT_MAX_PER_PAGE}. */
232
+ declare const DEFAULT_MAX_LIMIT = 200;
233
+ /** Longest root-to-leaf path of a filter tree, in nodes: the root is level 1, every `and`/`or`/`not` and the condition itself add one. */
234
+ declare const DEFAULT_MAX_FILTER_DEPTH = 5;
235
+ /** Field conditions (leaves) in one filter, across all groups. */
236
+ declare const DEFAULT_MAX_CONDITIONS = 30;
237
+ /** Items in one array filter value (`in`, `notIn`, …). */
238
+ declare const DEFAULT_MAX_IN_ITEMS = 200;
239
+ /** Characters in one string filter value, also inside an array value. */
240
+ declare const DEFAULT_MAX_STRING_LENGTH = 256;
241
+ /** Rows skipped before a page: `(page - 1) × perPage` for offset pagination, `offset` for infinite. Deeper pages need a cursor. */
242
+ declare const DEFAULT_MAX_OFFSET = 10000;
243
+ /** Fields in one `sort` array. */
244
+ declare const DEFAULT_MAX_SORT_FIELDS = 3;
245
+ /**
246
+ * Wildcards (`%`, `_`) in one `like` / `ilike` / `notLike` pattern, after a run
247
+ * of `%` counts as one. Each extra wildcard multiplies the matcher's backtracking
248
+ * (`%a%a%a…%b`), on MongoDB's regex engine and on Postgres alike.
249
+ */
250
+ declare const DEFAULT_MAX_LIKE_WILDCARDS = 2;
251
+ /** The six request limits, by the option name every package uses. */
252
+ interface QueryLimits {
253
+ maxDepth: number;
254
+ maxConditions: number;
255
+ maxInItems: number;
256
+ maxStringLength: number;
257
+ maxOffset: number;
258
+ maxSortFields: number;
259
+ maxLikeWildcards: number;
260
+ }
261
+ /** The core defaults as one object. */
262
+ declare const DEFAULT_QUERY_LIMITS: Readonly<QueryLimits>;
263
+ /**
264
+ * The defaults with the given limits on top; an `undefined` entry keeps the
265
+ * default. `Infinity` turns a limit off (⚠️ a DoS surface).
266
+ *
267
+ * @example
268
+ * ```ts
269
+ * resolveLimits({ maxInItems: 50 }); // { maxDepth: 5, …, maxInItems: 50, … }
270
+ * ```
271
+ */
272
+ declare function resolveLimits(limits?: Partial<QueryLimits>): QueryLimits;
273
+
274
+ /**
275
+ * Brand a driver-native filter object so an adapter accepts it as a filter node.
276
+ * The object itself is returned (non-enumerable brand), so the driver receives
277
+ * exactly what you wrote.
278
+ *
279
+ * @example
280
+ * ```ts
281
+ * import { raw } from "@querykitjs/core";
282
+ * repo.findAll({ filter: { and: [{ key: "status", value: "active" }, raw({ posts: { some: { published: true } } })] } });
283
+ * ```
284
+ */
285
+ declare function raw<T extends object>(value: T): T;
286
+ /** Whether a filter node was branded with {@link raw}. */
287
+ declare const isRaw: (node: unknown) => boolean;
288
+
289
+ export { DEFAULT_LIMIT as D, TEXT_FILTER_OPERATORS as T, raw as X, resolveLimits as Y, isRaw as Z, DEFAULT_MAX_CONDITIONS as f, DEFAULT_MAX_FILTER_DEPTH as g, DEFAULT_MAX_IN_ITEMS as h, DEFAULT_MAX_LIKE_WILDCARDS as i, DEFAULT_MAX_LIMIT as j, DEFAULT_MAX_OFFSET as k, DEFAULT_MAX_PER_PAGE as l, DEFAULT_MAX_SORT_FIELDS as m, DEFAULT_MAX_STRING_LENGTH as n, DEFAULT_PER_PAGE as o, DEFAULT_QUERY_LIMITS as p, FILTER_OPERATORS as q };
290
+ export type { AndGroup as A, SkipReason as B, CursorMeta as C, SkipSite as E, FilterValue as F, Sort as G, SortDirection as H, InfiniteMeta as I, SortItem as J, WireCursorParams as K, LooseKey as L, MutationCount as M, NotGroup as N, OrGroup as O, WireInfiniteParams as P, QueryLimits as Q, RelationRule as R, SkippedCondition as S, UpsertOptions as U, WireOffsetParams as V, WireBaseParams as W, FieldCondition as a, FilterScalar as b, FilterNode as c, AggregateSpec as d, AllRows as e, Filter as r, FilterOperator as s, Filterable as t, MutationOptions as u, MutationRows as v, OffsetMeta as w, Relations as x, Scope as y, ScopeIn as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@querykitjs/core",
3
- "version": "1.2.0",
3
+ "version": "2.1.0",
4
4
  "description": "ORM-agnostic query DSL shared by querykit packages — filters, operators & wire types.",
5
5
  "type": "module",
6
6
  "author": {
@@ -23,26 +23,52 @@
23
23
  "types": "./dist/index.d.ts",
24
24
  "exports": {
25
25
  ".": {
26
- "types": "./dist/index.d.ts",
27
- "import": "./dist/index.js",
28
- "require": "./dist/index.cjs"
26
+ "import": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "require": {
31
+ "types": "./dist/index.d.cts",
32
+ "default": "./dist/index.cjs"
33
+ }
34
+ },
35
+ "./internal": {
36
+ "import": {
37
+ "types": "./dist/internal.d.ts",
38
+ "default": "./dist/internal.js"
39
+ },
40
+ "require": {
41
+ "types": "./dist/internal.d.cts",
42
+ "default": "./dist/internal.cjs"
43
+ }
44
+ }
45
+ },
46
+ "typesVersions": {
47
+ "*": {
48
+ "internal": [
49
+ "./dist/internal.d.ts"
50
+ ]
29
51
  }
30
52
  },
31
53
  "files": [
32
54
  "dist",
33
55
  "README.md",
34
56
  "README.en.md",
35
- "LICENSE"
57
+ "LICENSE",
58
+ "skills"
36
59
  ],
37
60
  "sideEffects": false,
38
61
  "publishConfig": {
39
62
  "access": "public"
40
63
  },
64
+ "engines": {
65
+ "node": ">=22.12"
66
+ },
41
67
  "scripts": {
42
68
  "build": "tsup",
43
69
  "dev": "tsup --watch",
44
70
  "typecheck": "tsc --noEmit",
45
- "test:smoke": "bun run test/smoke.ts"
71
+ "test:smoke": "bun run test/smoke.ts && bun run test/internal.ts"
46
72
  },
47
73
  "keywords": [
48
74
  "querykit",
@@ -0,0 +1,160 @@
1
+ ---
2
+ name: querykit-core
3
+ description: Use when code creates or reads querykit filters, sort, pagination params or response meta, writes a querykit adapter or validator, or handles QueryKitError. @querykitjs/core is the single source of the request contract shared by every querykit package (backend adapters, zod and class-validator, the web client) - filter helpers (createFilters, f), operators, wire param and meta types, pagination defaults and caps, request limits, skip diagnostics.
4
+ ---
5
+
6
+ ## Safety rules
7
+
8
+ - DO cap page sizes with `DEFAULT_MAX_PER_PAGE` / `DEFAULT_MAX_LIMIT` (both `200`) imported from `@querykitjs/core`. Uncapped pagination is a DoS surface.
9
+ - DON'T write your own page-size cap (`100`, `500`, …) next to querykit code; the validators, the repositories and the web client all read the core constants.
10
+ - DO keep the request limits (`DEFAULT_QUERY_LIMITS`: filter depth `5`, conditions `30`, array items `200`, string length `256`, offset `10 000`, sort fields `3`). Raise one per schema or DTO with `limits` (`resolveLimits` fills the rest), never by turning a limit off; deeper pages than `maxOffset` go through cursor pagination.
11
+ - DON'T redeclare `FieldCondition`, `Filter`, `Sort`, `Wire*Params` or `*Meta` in an app or an adapter; import them from `@querykitjs/core` so both ends of the wire share one type.
12
+ - DO send dates over the wire as ISO strings. `Date` is accepted only in-memory (`FilterScalar`); adapters cast the string from the column type.
13
+ - DON'T forward client `columns`, `with` or `withDeleted` (fields of `WireBaseParams`) to a repository unchecked; projection belongs to the server (`forcedColumns` / `allowedColumns` on the backend adapters; a client `with` opens only the repository's `relations` allow-list).
14
+ - DO make dropped conditions visible: a backend adapter skips an unknown key or an invalid value silently unless you pass `onSkippedCondition` (receives a `SkippedCondition`) or `strict: true` (throws `QueryKitError`).
15
+ - DON'T treat `forbidden-key` / `forbidden-operator` / `limit-exceeded` / `empty-filter` as skippable: they mean the server policy refused the request (`empty-filter`: an `updateWhere` / `deleteWhere` without a condition — pass `{ all: true }` on purpose), and adapters throw them even without `strict`.
16
+ - DO wrap a driver-native filter object in `raw()` (from `@querykitjs/core`) when server code needs one; an unbranded object is refused as `forbidden-key`. DON'T build a `raw()` node from request data — the `filterable` policy does not look inside it.
17
+ - DO map `QueryKitTimeoutError` (`code: "QUERYKIT_TIMEOUT"`, a read past `maxTimeMS`) to 503/504, not 400 — the request was valid.
18
+ - DO map `QueryKitError` to HTTP 400; check `err instanceof QueryKitError`, or `err.code === "QUERYKIT_INVALID_CONDITION"` across RPC/worker boundaries. DON'T match on the message text.
19
+
20
+ ## When to use
21
+
22
+ - Building a typed filter tree on the server or in tests (`createFilters`, `f`).
23
+ - Typing a request body or response meta shared between backend and frontend.
24
+ - Writing a new adapter or validator that must follow the querykit contract.
25
+ - Reading the list of supported operators (`FILTER_OPERATORS`, `TEXT_FILTER_OPERATORS`).
26
+
27
+ Backend adapters (`@querykitjs/drizzle-pg`, `@querykitjs/prisma-pg`, `@querykitjs/mongoose`), validators (`@querykitjs/zod`, `@querykitjs/class-validator`) and `@querykitjs/web` re-use these types; reach for their own skills for repository, validation or client code.
28
+
29
+ ## Setup
30
+
31
+ ```bash
32
+ npm install @querykitjs/core
33
+ ```
34
+
35
+ ```ts
36
+ import { createFilters, DEFAULT_MAX_PER_PAGE, QueryKitError, type Filter } from "@querykitjs/core";
37
+
38
+ const uf = createFilters<"status">();
39
+ const filter: Filter<"status"> = uf.eq("status", "active");
40
+ export { filter, DEFAULT_MAX_PER_PAGE, QueryKitError };
41
+ ```
42
+
43
+ ## Common tasks
44
+
45
+ ### Build a typed filter
46
+
47
+ `createFilters<TKey>()` binds the helpers to your keys; `f` is the same set without key typing. A flat array is an implicit AND; `range` returns two conditions (`>=` and `<=`) to spread into it.
48
+
49
+ ```ts
50
+ import { createFilters, type Filter } from "@querykitjs/core";
51
+
52
+ type UserKey = "status" | "age" | "name" | "createdAt";
53
+ const uf = createFilters<UserKey>();
54
+
55
+ export const tree: Filter<UserKey> = uf.and(uf.eq("status", "active"), uf.gte("age", 18), uf.or(uf.contains("name", "ali"), uf.isNull("name")));
56
+
57
+ export const flat: Filter<UserKey> = [uf.in("status", ["active", "invited"]), ...uf.range("createdAt", "2026-01-01", "2026-12-31")];
58
+ ```
59
+
60
+ ### Cap a page size
61
+
62
+ ```ts
63
+ import { DEFAULT_MAX_PER_PAGE, DEFAULT_PER_PAGE } from "@querykitjs/core";
64
+
65
+ export function perPageFrom(raw: unknown): number {
66
+ const requested = typeof raw === "number" && Number.isInteger(raw) && raw > 0 ? raw : DEFAULT_PER_PAGE;
67
+ return Math.min(requested, DEFAULT_MAX_PER_PAGE);
68
+ }
69
+ ```
70
+
71
+ ### Raise one request limit
72
+
73
+ ```ts
74
+ import { DEFAULT_QUERY_LIMITS, resolveLimits, type QueryLimits } from "@querykitjs/core";
75
+
76
+ // An import endpoint that filters by up to 500 ids; every other limit keeps its default.
77
+ export const importLimits: QueryLimits = resolveLimits({ maxInItems: 500 });
78
+
79
+ export const isDefaultDepth = importLimits.maxDepth === DEFAULT_QUERY_LIMITS.maxDepth;
80
+ ```
81
+
82
+ ### Type a request and its response meta
83
+
84
+ ```ts
85
+ import type { FilterOperator, OffsetMeta, WireOffsetParams } from "@querykitjs/core";
86
+
87
+ const since: FilterOperator = ">=";
88
+
89
+ export const request: WireOffsetParams = {
90
+ filter: [
91
+ { key: "status", value: "active" },
92
+ { key: "createdAt", operation: since, value: "2026-01-01T00:00:00.000Z" },
93
+ ],
94
+ sort: [{ key: "createdAt", direction: "desc" }],
95
+ page: 1,
96
+ perPage: 20,
97
+ };
98
+
99
+ export function hasMore(meta: OffsetMeta): boolean {
100
+ return meta.has_next;
101
+ }
102
+ ```
103
+
104
+ ### Handle dropped conditions and `QueryKitError`
105
+
106
+ ```ts
107
+ import { QueryKitError, type SkippedCondition } from "@querykitjs/core";
108
+
109
+ export function logSkipped(info: SkippedCondition): void {
110
+ console.warn(`querykit skipped ${info.site} "${info.key}" on ${info.source}: ${info.reason}`);
111
+ }
112
+
113
+ export function toHttpError(err: unknown): { status: 400; error: string; key: string } | null {
114
+ if (err instanceof QueryKitError) return { status: 400, error: err.message, key: err.info.key };
115
+ return null;
116
+ }
117
+ ```
118
+
119
+ ### Check an operator coming from outside
120
+
121
+ ```ts
122
+ import { FILTER_OPERATORS, TEXT_FILTER_OPERATORS, type FilterOperator } from "@querykitjs/core";
123
+
124
+ export function isFilterOperator(value: string): value is FilterOperator {
125
+ return (FILTER_OPERATORS as readonly string[]).includes(value);
126
+ }
127
+
128
+ export function isTextOperator(operation: FilterOperator): boolean {
129
+ return (TEXT_FILTER_OPERATORS as readonly string[]).includes(operation);
130
+ }
131
+ ```
132
+
133
+ ## Request contract
134
+
135
+ The same JSON shape travels from `@querykitjs/web` through a validator to a backend repository.
136
+
137
+ | Part | Shape | Core type |
138
+ | ------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------ |
139
+ | condition | `{ key, operation?, value? }` — `operation` defaults to `"="` | `FieldCondition` |
140
+ | groups | `{ and: [...] }`, `{ or: [...] }`, `{ not: node }`; a flat array = AND | `FilterNode`, `Filter` |
141
+ | sort | always an array: `[{ key, direction? }]`, `direction` `"asc"` / `"desc"` | `Sort`, `SortItem` |
142
+ | offset page | `page`, `perPage` (default `20`, cap `200`) | `WireOffsetParams` |
143
+ | infinite | `limit` (default `20`, cap `200`), `offset` | `WireInfiniteParams` |
144
+ | cursor | `limit`, `cursor`, `cursorKey`, `order`, `direction` (`"forward"` / `"backward"`); no `sort` | `WireCursorParams` |
145
+ | response meta | snake_case: `total_items`, `per_page`, `has_next`, `next_cursor`, … | `OffsetMeta`, `InfiniteMeta`, `CursorMeta` |
146
+
147
+ Operators: comparison `=` `!=` `>` `>=` `<` `<=` (aliases `eq` `ne` `gt` `gte` `lt` `lte`); text `like` `ilike` `notLike` `contains` `startsWith` `endsWith` (tokens `%_%` = contains, `%_` = startsWith, `_%` = endsWith); sets `in` `notIn`; ranges `between` `notBetween` (value `[min, max]`); `isNull` `isNotNull` (no value).
148
+
149
+ Request limits (`QueryLimits`, defaults in `DEFAULT_QUERY_LIMITS`): `maxDepth` `5` (root-to-leaf nodes; the root and the condition each count one level), `maxConditions` `30` (field conditions across all groups), `maxInItems` `200` (items in one array value), `maxStringLength` `256` (characters in one string value, also inside an array), `maxOffset` `10000` (`(page - 1) × perPage`, or infinite `offset`), `maxSortFields` `3`, `maxLikeWildcards` `2` (`%`/`_` in one `like`/`ilike`/`notLike` pattern; a run of `%%` counts once; checked by the adapters' policy). A validator error names the limit and its value, e.g. `maxInItems (200)`.
150
+
151
+ Skip diagnostics: `SkippedCondition` = `{ source, site, key, operation?, reason }`, `site` one of `filter` `sort` `cursorKey` `aggregate` `page` `mutation` `with`, `reason` one of `unknown-key` `invalid-value` (dropped, or thrown under `strict`) and `forbidden-key` `forbidden-operator` `limit-exceeded` `empty-filter` (server policy / empty mutation filter: always thrown as `QueryKitError`, never dropped). `Filterable` = field → allowed operators or `"*"`.
152
+
153
+ ## Mistakes to avoid
154
+
155
+ - Sending `sort` as an object (`{ createdAt: "desc" }`) — it is always an array of `{ key, direction }`.
156
+ - Putting a `Date` in a JSON body — send the ISO string; `@querykitjs/zod` validates wire scalars without `Date`.
157
+ - Sending `between` as two values or two conditions — on the wire its `value` is one array `[min, max]`.
158
+ - Adding `sort` to cursor params — `WireCursorParams` has no `sort`; ordering comes from `order` and `direction`.
159
+ - Using `f` where the keys are known — `createFilters<TKey>()` catches a mistyped key at compile time.
160
+ - Treating an empty result as "no match" while a filter key is misspelled — wire `onSkippedCondition` or `strict` first.