@querykitjs/core 1.2.0 → 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.
@@ -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.