@crossfox/query-handler 0.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.
- package/LICENSE +118 -0
- package/README.md +62 -0
- package/dist/cache.d.ts +12 -0
- package/dist/cache.js +43 -0
- package/dist/config.d.ts +89 -0
- package/dist/config.js +114 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.js +108 -0
- package/dist/cursor.d.ts +9 -0
- package/dist/cursor.js +37 -0
- package/dist/elysia.d.ts +33 -0
- package/dist/elysia.js +9 -0
- package/dist/env.d.ts +4 -0
- package/dist/env.js +30 -0
- package/dist/filter.d.ts +6 -0
- package/dist/filter.js +207 -0
- package/dist/helpers.d.ts +36 -0
- package/dist/helpers.js +262 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.js +66 -0
- package/dist/rate-limit.d.ts +2 -0
- package/dist/rate-limit.js +14 -0
- package/dist/rateLimit.d.ts +8 -0
- package/dist/rateLimit.js +29 -0
- package/dist/relation.d.ts +2 -0
- package/dist/relation.js +64 -0
- package/dist/schema.d.ts +35 -0
- package/dist/schema.js +32 -0
- package/dist/types.d.ts +267 -0
- package/dist/types.js +1 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.js +11 -0
- package/dist/worker.d.ts +19 -0
- package/dist/worker.js +560 -0
- package/package.json +92 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# PolyForm Noncommercial License 1.0.0
|
|
2
|
+
|
|
3
|
+
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
|
4
|
+
|
|
5
|
+
## Acceptance
|
|
6
|
+
|
|
7
|
+
In order to get any license under these terms, you must agree to them as both
|
|
8
|
+
strict conditions and as covenants.
|
|
9
|
+
|
|
10
|
+
## Copyright License
|
|
11
|
+
|
|
12
|
+
The licensor grants you a copyright license for the software to do everything
|
|
13
|
+
you might do with the software that would otherwise infringe the licensor's
|
|
14
|
+
copyright in it for any permitted purpose. However, you may only distribute
|
|
15
|
+
the software according to [Distribution License](#distribution-license) and
|
|
16
|
+
make modifications or new works based on the software according to
|
|
17
|
+
[Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Distribution License
|
|
20
|
+
|
|
21
|
+
The licensor grants you an additional copyright license to distribute copies
|
|
22
|
+
of the software. Your license to distribute covers distributing the software
|
|
23
|
+
with changes and new works permitted by
|
|
24
|
+
[Changes and New Works License](#changes-and-new-works-license).
|
|
25
|
+
|
|
26
|
+
## Notices
|
|
27
|
+
|
|
28
|
+
You must ensure that anyone who gets a copy of any part of the software from
|
|
29
|
+
you also gets a copy of these terms or the URL for them above, as well as
|
|
30
|
+
copies of any plain-text lines beginning with `Required Notice:` that the
|
|
31
|
+
licensor provided with the software. For example:
|
|
32
|
+
|
|
33
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
34
|
+
|
|
35
|
+
Required Notice: Copyright Oleksii Fursov (https://crossfox.online)
|
|
36
|
+
|
|
37
|
+
## Changes and New Works License
|
|
38
|
+
|
|
39
|
+
The licensor grants you an additional copyright license to make modifications
|
|
40
|
+
and new works based on the software for any permitted purpose.
|
|
41
|
+
|
|
42
|
+
## Patent License
|
|
43
|
+
|
|
44
|
+
The licensor grants you a patent license for the software that covers patent
|
|
45
|
+
claims the licensor can license, or becomes able to license, that you infringe
|
|
46
|
+
by using the software.
|
|
47
|
+
|
|
48
|
+
## Noncommercial Purposes
|
|
49
|
+
|
|
50
|
+
Any noncommercial purpose is a permitted purpose.
|
|
51
|
+
|
|
52
|
+
## Personal Uses
|
|
53
|
+
|
|
54
|
+
Personal use for research, experiment, and testing for the benefit of public
|
|
55
|
+
knowledge, personal study, private entertainment, hobby projects, amateur
|
|
56
|
+
pursuits, or religious observance, without any anticipated commercial
|
|
57
|
+
application, is use for a permitted purpose.
|
|
58
|
+
|
|
59
|
+
## Noncommercial Organizations
|
|
60
|
+
|
|
61
|
+
Use by any charitable organization, educational institution, public research
|
|
62
|
+
organization, public safety or health organization, environmental protection
|
|
63
|
+
organization, or government institution is use for a permitted purpose.
|
|
64
|
+
Use by a for-profit organization, or for-profit activity by a nonprofit
|
|
65
|
+
organization, is not use for a permitted purpose. The licensor may make an
|
|
66
|
+
exception for a specific organization in writing.
|
|
67
|
+
|
|
68
|
+
## Fair Use
|
|
69
|
+
|
|
70
|
+
You may have "fair use" rights for the software under the law. These terms do
|
|
71
|
+
not limit them.
|
|
72
|
+
|
|
73
|
+
## No Other Rights
|
|
74
|
+
|
|
75
|
+
These terms do not allow you to sublicense or transfer any of your licenses
|
|
76
|
+
to anyone else, or prevent the licensor from granting licenses to anyone else.
|
|
77
|
+
These terms do not imply any other licenses.
|
|
78
|
+
|
|
79
|
+
## Patent Defense
|
|
80
|
+
|
|
81
|
+
If you make any written claim that the software infringes or contributes to
|
|
82
|
+
infringement of any patent, your patent license for the software granted under
|
|
83
|
+
these terms ends immediately. If your company makes such a claim, your patent
|
|
84
|
+
license ends immediately for work on behalf of your company.
|
|
85
|
+
|
|
86
|
+
## Violations
|
|
87
|
+
|
|
88
|
+
The first time you are notified in writing that you have violated any of these
|
|
89
|
+
terms, or done anything with the software that is not licensed, your license
|
|
90
|
+
can nonetheless continue if you come into full compliance with these terms,
|
|
91
|
+
and take practical steps to correct past violations, within 32 days of
|
|
92
|
+
receiving notice. Otherwise, all your licenses end immediately.
|
|
93
|
+
|
|
94
|
+
## No Liability
|
|
95
|
+
|
|
96
|
+
***As far as the law allows, the software comes as is, without any warranty
|
|
97
|
+
or condition, and the licensor will not be liable to you for any damages
|
|
98
|
+
arising out of these terms or the use or nature of the software, under any
|
|
99
|
+
kind of legal claim.***
|
|
100
|
+
|
|
101
|
+
## Definitions
|
|
102
|
+
|
|
103
|
+
The **licensor** is the individual or entity offering these terms, and the
|
|
104
|
+
**software** is the software the licensor makes available under these terms.
|
|
105
|
+
|
|
106
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
107
|
+
|
|
108
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of
|
|
109
|
+
organization that you work for, plus all organizations that have control over,
|
|
110
|
+
are under the control of, or are under common control with that organization.
|
|
111
|
+
**Control** means ownership of substantially all the assets of an entity, or
|
|
112
|
+
the power to direct its management and policies by vote, contract, or
|
|
113
|
+
otherwise. Control can be direct or indirect.
|
|
114
|
+
|
|
115
|
+
**Your licenses** are all the licenses granted to you for the software under
|
|
116
|
+
these terms.
|
|
117
|
+
|
|
118
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @crossfox/query-handler
|
|
2
|
+
|
|
3
|
+
HTTP list API layer on **[@crossfox/db](https://www.npmjs.com/package/@crossfox/db)**: filters, sort, search, relations (hasMany), cursor pagination, in-memory result cache, optional Elysia plugin.
|
|
4
|
+
|
|
5
|
+
**Requires [Bun](https://bun.sh)** and peer `@crossfox/db`.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@crossfox/query-handler)
|
|
8
|
+
[](#license)
|
|
9
|
+
[](https://bun.sh)
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add @crossfox/query-handler @crossfox/db
|
|
15
|
+
# optional Elysia adapter:
|
|
16
|
+
bun add elysia
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { setupDb, mysqlFromEnv, createSchema } from "@crossfox/db";
|
|
23
|
+
import { queryHandler, configureQueryHandler } from "@crossfox/query-handler";
|
|
24
|
+
|
|
25
|
+
await setupDb({ mysql: mysqlFromEnv() });
|
|
26
|
+
configureQueryHandler({ isDev: true, strictFilters: true });
|
|
27
|
+
|
|
28
|
+
const User = createSchema<{ id: number; name: string; status: number }>("users", {
|
|
29
|
+
primaryKey: "id",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// In an Elysia handler — return the instance; plugin calls .run()
|
|
33
|
+
return queryHandler(User, query, {
|
|
34
|
+
filters: { status: "number", name: "string" },
|
|
35
|
+
sort: ["id", "!name"],
|
|
36
|
+
maxLimit: 50,
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Entry points
|
|
41
|
+
|
|
42
|
+
| import | contents |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `@crossfox/query-handler` | `queryHandler`, `createQueryHandler`, `configureQueryHandler`, cache helpers |
|
|
45
|
+
| `@crossfox/query-handler/elysia` | `queryHandlerPlugin`, `typeQuerySchema` |
|
|
46
|
+
| `@crossfox/query-handler/schema` | TypeBox query schema only |
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { Elysia } from "elysia";
|
|
50
|
+
import { queryHandlerPlugin } from "@crossfox/query-handler/elysia";
|
|
51
|
+
|
|
52
|
+
new Elysia().use(queryHandlerPlugin);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
[PolyForm Noncommercial 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0) —
|
|
58
|
+
free for non-commercial use; copyright notice required
|
|
59
|
+
(`Required Notice: Copyright Oleksii Fursov`).
|
|
60
|
+
|
|
61
|
+
**Commercial use** (for-profit products, SaaS, client work, etc.) needs a separate
|
|
62
|
+
written license from the author: [nodepro777@gmail.com](mailto:nodepro777@gmail.com).
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface CacheEntry {
|
|
2
|
+
value: unknown[];
|
|
3
|
+
total: number;
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function cacheGet(key: string): CacheEntry | null;
|
|
7
|
+
export declare function cacheSet(key: string, value: unknown[], total: number, ttl: number): void;
|
|
8
|
+
/** Очистить просроченные записи. Вызывайте периодически (например, раз в минуту). */
|
|
9
|
+
export declare function purgeQueryHandlerCache(): number;
|
|
10
|
+
/** Инвалидировать по ключу или RegExp. */
|
|
11
|
+
export declare function invalidateQueryHandlerCache(keyOrPattern: string | RegExp): number;
|
|
12
|
+
export {};
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// In-memory кэш с TTL
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
const _cache = new Map();
|
|
5
|
+
export function cacheGet(key) {
|
|
6
|
+
const entry = _cache.get(key);
|
|
7
|
+
if (!entry)
|
|
8
|
+
return null;
|
|
9
|
+
if (entry.expiresAt <= Date.now()) {
|
|
10
|
+
_cache.delete(key);
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return entry;
|
|
14
|
+
}
|
|
15
|
+
export function cacheSet(key, value, total, ttl) {
|
|
16
|
+
_cache.set(key, { value, total, expiresAt: Date.now() + ttl });
|
|
17
|
+
}
|
|
18
|
+
/** Очистить просроченные записи. Вызывайте периодически (например, раз в минуту). */
|
|
19
|
+
export function purgeQueryHandlerCache() {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
let purged = 0;
|
|
22
|
+
for (const [k, v] of _cache) {
|
|
23
|
+
if (v.expiresAt <= now) {
|
|
24
|
+
_cache.delete(k);
|
|
25
|
+
purged++;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return purged;
|
|
29
|
+
}
|
|
30
|
+
/** Инвалидировать по ключу или RegExp. */
|
|
31
|
+
export function invalidateQueryHandlerCache(keyOrPattern) {
|
|
32
|
+
let deleted = 0;
|
|
33
|
+
for (const k of _cache.keys()) {
|
|
34
|
+
const hit = typeof keyOrPattern === 'string'
|
|
35
|
+
? k === keyOrPattern
|
|
36
|
+
: keyOrPattern.test(k);
|
|
37
|
+
if (hit) {
|
|
38
|
+
_cache.delete(k);
|
|
39
|
+
deleted++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return deleted;
|
|
43
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { QueryBuilder } from "@crossfox/db";
|
|
2
|
+
import type { QueryHandlerHookCtx } from "./types";
|
|
3
|
+
import type { RateLimitHitFn } from "./rateLimit";
|
|
4
|
+
export type { QueryHandlerHookCtx };
|
|
5
|
+
type BeforeQueryHook = (q: QueryBuilder, ctx: QueryHandlerHookCtx) => void | Promise<void>;
|
|
6
|
+
type AfterQueryHook = (items: unknown[], total: number, ctx: QueryHandlerHookCtx) => void | Promise<void>;
|
|
7
|
+
export interface QueryHandlerLogger {
|
|
8
|
+
info?(app: string, message: string, data?: unknown): void;
|
|
9
|
+
warn?(app: string, message: string, data?: unknown): void;
|
|
10
|
+
}
|
|
11
|
+
/** Глобальные настройки queryHandler (env, boot, configureQueryHandler). */
|
|
12
|
+
export interface QueryHandlerGlobalConfig {
|
|
13
|
+
page?: number;
|
|
14
|
+
limit?: number;
|
|
15
|
+
count?: boolean;
|
|
16
|
+
maxLimit?: number;
|
|
17
|
+
strictFilters?: boolean;
|
|
18
|
+
ignoreQueryFields?: boolean;
|
|
19
|
+
/** Dev-режим: mock, предупреждения о фильтрах. */
|
|
20
|
+
isDev?: boolean;
|
|
21
|
+
/** Разрешить options.mock (обычно isDev || test). */
|
|
22
|
+
allowMock?: boolean;
|
|
23
|
+
logger?: QueryHandlerLogger;
|
|
24
|
+
/** Подмена rate-limit store (например Redis в кластере). */
|
|
25
|
+
rateLimitHit?: RateLimitHitFn;
|
|
26
|
+
limits?: {
|
|
27
|
+
searchMinLength?: number;
|
|
28
|
+
allowedOperators?: string[];
|
|
29
|
+
};
|
|
30
|
+
cursorPaginate?: boolean | string | {
|
|
31
|
+
field?: string;
|
|
32
|
+
count?: boolean;
|
|
33
|
+
};
|
|
34
|
+
initial?: {
|
|
35
|
+
page?: number;
|
|
36
|
+
limit?: number;
|
|
37
|
+
sort?: string | string[];
|
|
38
|
+
search?: string;
|
|
39
|
+
relation?: string | string[];
|
|
40
|
+
after?: number | string;
|
|
41
|
+
before?: number | string;
|
|
42
|
+
scope?: string | string[];
|
|
43
|
+
field?: readonly string[];
|
|
44
|
+
filter?: Record<string, unknown>;
|
|
45
|
+
};
|
|
46
|
+
beforeQuery?: BeforeQueryHook;
|
|
47
|
+
afterQuery?: AfterQueryHook;
|
|
48
|
+
}
|
|
49
|
+
/** Статические дефолты из env (QUERY_HANDLER_*). */
|
|
50
|
+
export declare const queryHandlerStaticConfig: QueryHandlerGlobalConfig;
|
|
51
|
+
export type QueryHandlerConfigureInput = Partial<QueryHandlerGlobalConfig> | ((current: Readonly<QueryHandlerGlobalConfig>) => Partial<QueryHandlerGlobalConfig>);
|
|
52
|
+
export interface QueryHandlerResolvedDefaults {
|
|
53
|
+
queryMerge: {
|
|
54
|
+
page: number;
|
|
55
|
+
limit: number;
|
|
56
|
+
sort: string[];
|
|
57
|
+
search: string;
|
|
58
|
+
field: string[];
|
|
59
|
+
relation: string[];
|
|
60
|
+
scope: string;
|
|
61
|
+
after: number | string | undefined;
|
|
62
|
+
before: number | string | undefined;
|
|
63
|
+
filter: Record<string, unknown>;
|
|
64
|
+
};
|
|
65
|
+
runtime: {
|
|
66
|
+
count: boolean;
|
|
67
|
+
maxLimit: number;
|
|
68
|
+
strictFilters: boolean;
|
|
69
|
+
ignoreQueryFields: boolean;
|
|
70
|
+
isDev: boolean;
|
|
71
|
+
allowMock: boolean;
|
|
72
|
+
limits: NonNullable<QueryHandlerGlobalConfig["limits"]>;
|
|
73
|
+
cursorPaginate: QueryHandlerGlobalConfig["cursorPaginate"];
|
|
74
|
+
logger?: QueryHandlerLogger;
|
|
75
|
+
rateLimitHit: RateLimitHitFn;
|
|
76
|
+
};
|
|
77
|
+
initial: NonNullable<QueryHandlerGlobalConfig["initial"]>;
|
|
78
|
+
beforeQuery?: BeforeQueryHook;
|
|
79
|
+
afterQuery?: AfterQueryHook;
|
|
80
|
+
}
|
|
81
|
+
export declare function getQueryHandlerGlobalConfig(): Readonly<QueryHandlerGlobalConfig>;
|
|
82
|
+
export declare function getQueryHandlerDefaults(): Readonly<QueryHandlerResolvedDefaults>;
|
|
83
|
+
/**
|
|
84
|
+
* Глобальный конфигуратор queryHandler.
|
|
85
|
+
* Вызывайте при старте приложения или в тестах.
|
|
86
|
+
*/
|
|
87
|
+
export declare function configureQueryHandler(input: QueryHandlerConfigureInput): void;
|
|
88
|
+
/** Сброс к значениям из env / {@link queryHandlerStaticConfig} (для тестов). */
|
|
89
|
+
export declare function resetQueryHandlerConfig(): void;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { envBool, envInt, readEnv } from "./env";
|
|
2
|
+
import { fixedWindowHit } from "./rateLimit";
|
|
3
|
+
/** Статические дефолты из env (QUERY_HANDLER_*). */
|
|
4
|
+
export const queryHandlerStaticConfig = {
|
|
5
|
+
page: envInt(readEnv("QUERY_HANDLER_PAGE"), 1),
|
|
6
|
+
limit: envInt(readEnv("QUERY_HANDLER_LIMIT"), 10),
|
|
7
|
+
maxLimit: envInt(readEnv("QUERY_HANDLER_MAX_LIMIT"), 100),
|
|
8
|
+
count: envBool(readEnv("QUERY_HANDLER_COUNT"), true),
|
|
9
|
+
strictFilters: envBool(readEnv("QUERY_HANDLER_STRICT_FILTERS"), false),
|
|
10
|
+
ignoreQueryFields: envBool(readEnv("QUERY_HANDLER_IGNORE_QUERY_FIELD"), false),
|
|
11
|
+
isDev: readEnv("NODE_ENV") !== "production",
|
|
12
|
+
limits: {
|
|
13
|
+
searchMinLength: envInt(readEnv("QUERY_HANDLER_SEARCH_MIN_LENGTH"), 2),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const EMPTY_INITIAL = {};
|
|
17
|
+
function readStaticConfig() {
|
|
18
|
+
const c = queryHandlerStaticConfig;
|
|
19
|
+
return {
|
|
20
|
+
page: c.page,
|
|
21
|
+
limit: c.limit,
|
|
22
|
+
count: c.count,
|
|
23
|
+
maxLimit: c.maxLimit,
|
|
24
|
+
strictFilters: c.strictFilters,
|
|
25
|
+
ignoreQueryFields: c.ignoreQueryFields,
|
|
26
|
+
isDev: c.isDev,
|
|
27
|
+
allowMock: c.allowMock,
|
|
28
|
+
logger: c.logger,
|
|
29
|
+
rateLimitHit: c.rateLimitHit,
|
|
30
|
+
limits: c.limits ? { ...c.limits } : undefined,
|
|
31
|
+
cursorPaginate: c.cursorPaginate,
|
|
32
|
+
initial: c.initial ? { ...c.initial } : undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function buildResolved(from) {
|
|
36
|
+
const isDev = from.isDev ?? (readEnv("NODE_ENV") !== "production");
|
|
37
|
+
return {
|
|
38
|
+
queryMerge: {
|
|
39
|
+
page: from.page ?? 1,
|
|
40
|
+
limit: from.limit ?? 20,
|
|
41
|
+
sort: [],
|
|
42
|
+
search: "",
|
|
43
|
+
field: [],
|
|
44
|
+
relation: [],
|
|
45
|
+
scope: "",
|
|
46
|
+
after: undefined,
|
|
47
|
+
before: undefined,
|
|
48
|
+
filter: {},
|
|
49
|
+
},
|
|
50
|
+
runtime: {
|
|
51
|
+
count: from.count ?? true,
|
|
52
|
+
maxLimit: from.maxLimit ?? 100,
|
|
53
|
+
strictFilters: from.strictFilters ?? false,
|
|
54
|
+
ignoreQueryFields: from.ignoreQueryFields ?? false,
|
|
55
|
+
isDev,
|
|
56
|
+
allowMock: from.allowMock ?? isDev,
|
|
57
|
+
limits: { ...from.limits },
|
|
58
|
+
cursorPaginate: from.cursorPaginate,
|
|
59
|
+
logger: from.logger,
|
|
60
|
+
rateLimitHit: from.rateLimitHit ?? fixedWindowHit,
|
|
61
|
+
},
|
|
62
|
+
initial: from.initial ? { ...from.initial } : { ...EMPTY_INITIAL },
|
|
63
|
+
beforeQuery: from.beforeQuery,
|
|
64
|
+
afterQuery: from.afterQuery,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function toGlobalConfig(resolved) {
|
|
68
|
+
return {
|
|
69
|
+
page: resolved.queryMerge.page,
|
|
70
|
+
limit: resolved.queryMerge.limit,
|
|
71
|
+
count: resolved.runtime.count,
|
|
72
|
+
maxLimit: resolved.runtime.maxLimit,
|
|
73
|
+
strictFilters: resolved.runtime.strictFilters,
|
|
74
|
+
ignoreQueryFields: resolved.runtime.ignoreQueryFields,
|
|
75
|
+
isDev: resolved.runtime.isDev,
|
|
76
|
+
allowMock: resolved.runtime.allowMock,
|
|
77
|
+
logger: resolved.runtime.logger,
|
|
78
|
+
rateLimitHit: resolved.runtime.rateLimitHit,
|
|
79
|
+
limits: { ...resolved.runtime.limits },
|
|
80
|
+
cursorPaginate: resolved.runtime.cursorPaginate,
|
|
81
|
+
initial: { ...resolved.initial },
|
|
82
|
+
beforeQuery: resolved.beforeQuery,
|
|
83
|
+
afterQuery: resolved.afterQuery,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function mergeGlobalConfig(base, patch) {
|
|
87
|
+
return {
|
|
88
|
+
...base,
|
|
89
|
+
...patch,
|
|
90
|
+
limits: patch.limits !== undefined ? { ...base.limits, ...patch.limits } : base.limits,
|
|
91
|
+
initial: patch.initial !== undefined ? { ...base.initial, ...patch.initial } : base.initial,
|
|
92
|
+
beforeQuery: patch.beforeQuery !== undefined ? patch.beforeQuery : base.beforeQuery,
|
|
93
|
+
afterQuery: patch.afterQuery !== undefined ? patch.afterQuery : base.afterQuery,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
let resolvedDefaults = buildResolved(readStaticConfig());
|
|
97
|
+
export function getQueryHandlerGlobalConfig() {
|
|
98
|
+
return toGlobalConfig(resolvedDefaults);
|
|
99
|
+
}
|
|
100
|
+
export function getQueryHandlerDefaults() {
|
|
101
|
+
return resolvedDefaults;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Глобальный конфигуратор queryHandler.
|
|
105
|
+
* Вызывайте при старте приложения или в тестах.
|
|
106
|
+
*/
|
|
107
|
+
export function configureQueryHandler(input) {
|
|
108
|
+
const patch = typeof input === "function" ? input(getQueryHandlerGlobalConfig()) : input;
|
|
109
|
+
resolvedDefaults = buildResolved(mergeGlobalConfig(getQueryHandlerGlobalConfig(), patch));
|
|
110
|
+
}
|
|
111
|
+
/** Сброс к значениям из env / {@link queryHandlerStaticConfig} (для тестов). */
|
|
112
|
+
export function resetQueryHandlerConfig() {
|
|
113
|
+
resolvedDefaults = buildResolved(readStaticConfig());
|
|
114
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FilterFieldType, FilterOperator } from "./types";
|
|
2
|
+
export declare const ALL_FILTER_OPERATORS: Set<FilterOperator>;
|
|
3
|
+
export declare const DEFAULT_OPERATORS_BY_TYPE: Record<FilterFieldType, FilterOperator[]>;
|
|
4
|
+
export declare const DEFAULT_OPERATOR_SETS: Record<FilterFieldType, Set<FilterOperator>>;
|
|
5
|
+
export declare const TYPE_BY_PATTERN: Array<[RegExp, FilterFieldType]>;
|
|
6
|
+
export declare const PARSERS: Record<FilterFieldType, (v: string, field: string) => unknown>;
|
|
7
|
+
export declare const DANGEROUS_KEYS: Set<string>;
|
|
8
|
+
export declare const SIMPLE_ID_RE: RegExp;
|
|
9
|
+
export declare const CUSTOM_SORT_RE: RegExp;
|
|
10
|
+
export declare const KNOWN_OPTION_KEYS: Set<string>;
|
|
11
|
+
export declare const ARRAY_MERGE_KEYS: Set<string>;
|
|
12
|
+
export declare const OBJECT_MERGE_KEYS: Set<string>;
|
|
13
|
+
/** Маркер экземпляра queryHandler (Elysia onAfterHandle, type guard). */
|
|
14
|
+
export declare const QUERY_HANDLER_BRAND: unique symbol;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Операторы
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
export const ALL_FILTER_OPERATORS = new Set([
|
|
5
|
+
'between', 'in', 'nin', 'like', 'nlike',
|
|
6
|
+
'gt', 'lt', 'lte', 'gte', 'eq', 'neq', 'null', 'nnull',
|
|
7
|
+
]);
|
|
8
|
+
export const DEFAULT_OPERATORS_BY_TYPE = {
|
|
9
|
+
string: ['eq', 'neq', 'like', 'nlike', 'in', 'nin', 'null', 'nnull'],
|
|
10
|
+
number: ['eq', 'neq', 'gt', 'lt', 'gte', 'lte', 'between', 'in', 'nin', 'null', 'nnull'],
|
|
11
|
+
date: ['eq', 'neq', 'gt', 'lt', 'gte', 'lte', 'between', 'null', 'nnull'],
|
|
12
|
+
boolean: ['eq'],
|
|
13
|
+
};
|
|
14
|
+
export const DEFAULT_OPERATOR_SETS = {
|
|
15
|
+
string: new Set(DEFAULT_OPERATORS_BY_TYPE.string),
|
|
16
|
+
number: new Set(DEFAULT_OPERATORS_BY_TYPE.number),
|
|
17
|
+
date: new Set(DEFAULT_OPERATORS_BY_TYPE.date),
|
|
18
|
+
boolean: new Set(DEFAULT_OPERATORS_BY_TYPE.boolean),
|
|
19
|
+
};
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
// Автоопределение типа по имени поля
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
export const TYPE_BY_PATTERN = [
|
|
24
|
+
[/_id$/, 'number'],
|
|
25
|
+
[/^id$/, 'number'],
|
|
26
|
+
[/_at$/, 'date'],
|
|
27
|
+
[/_date$/, 'date'],
|
|
28
|
+
[/uuid/, 'string'],
|
|
29
|
+
[/slug/, 'string'],
|
|
30
|
+
[/^is_/, 'boolean'],
|
|
31
|
+
[/^has_/, 'boolean'],
|
|
32
|
+
[/_count$/, 'number'],
|
|
33
|
+
[/_price$/, 'number'],
|
|
34
|
+
[/_amount$/, 'number'],
|
|
35
|
+
];
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Парсеры значений из URL
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
export const PARSERS = {
|
|
40
|
+
number: (v, field) => {
|
|
41
|
+
const n = Number(v);
|
|
42
|
+
if (!isFinite(n))
|
|
43
|
+
throw new Error(`Filter "${field}": expected a number, got "${v}"`);
|
|
44
|
+
return n;
|
|
45
|
+
},
|
|
46
|
+
date: (v, field) => {
|
|
47
|
+
if (!/^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]+)?$/.test(v))
|
|
48
|
+
throw new Error(`Filter "${field}": expected date YYYY-MM-DD, got "${v}"`);
|
|
49
|
+
if (isNaN(new Date(v).getTime()))
|
|
50
|
+
throw new Error(`Filter "${field}": invalid date "${v}"`);
|
|
51
|
+
return v;
|
|
52
|
+
},
|
|
53
|
+
string: (v, field) => {
|
|
54
|
+
if (v.length > 500)
|
|
55
|
+
throw new Error(`Filter "${field}": value is too long (max 500 chars)`);
|
|
56
|
+
return v;
|
|
57
|
+
},
|
|
58
|
+
boolean: (v, field) => {
|
|
59
|
+
if (v === '1')
|
|
60
|
+
return true;
|
|
61
|
+
if (v === '0')
|
|
62
|
+
return false;
|
|
63
|
+
throw new Error(`Filter "${field}": expected 1 or 0, got "${v}"`);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
// Безопасность
|
|
68
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
export const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
70
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
// RegExp
|
|
72
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
export const SIMPLE_ID_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
74
|
+
export const CUSTOM_SORT_RE = /^!?\$/;
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
// Известные ключи опций (для валидации)
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
export const KNOWN_OPTION_KEYS = new Set([
|
|
79
|
+
'query', 'apply',
|
|
80
|
+
'fields', 'fieldsDefault', 'fieldsForced',
|
|
81
|
+
'sorts', 'sortsDefault', 'sortsCustom',
|
|
82
|
+
'search',
|
|
83
|
+
'filters', 'filtersDefault', 'filtersRequired', 'filtersForced', 'requiredFilters', 'strictFilters',
|
|
84
|
+
'relations', 'relationsDefault',
|
|
85
|
+
'cursorPaginate', 'withTotal', 'maxLimit',
|
|
86
|
+
'scopes', 'scopesDefault', 'extends',
|
|
87
|
+
'before', 'beforeQuery', 'after', 'afterQuery', 'afterEach',
|
|
88
|
+
'transform', 'fieldsTransform', 'ignoreQueryFields',
|
|
89
|
+
'aggregate', 'groupBy', 'having',
|
|
90
|
+
'distinct', 'mode', 'transaction', 'onEmpty', 'versions', 'chunk', 'randomize', 'rateLimit',
|
|
91
|
+
'cacheTtl', 'cacheKey',
|
|
92
|
+
'meta', 'mock',
|
|
93
|
+
'initial', 'limits', 'expose',
|
|
94
|
+
]);
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// Merge — ключи по стратегии
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
export const ARRAY_MERGE_KEYS = new Set([
|
|
99
|
+
'fields', 'fieldsForced', 'fieldsDefault', 'sorts', 'groupBy',
|
|
100
|
+
'relationsDefault', 'scopesDefault',
|
|
101
|
+
]);
|
|
102
|
+
export const OBJECT_MERGE_KEYS = new Set([
|
|
103
|
+
'filters', 'filtersDefault', 'filtersRequired', 'filtersForced',
|
|
104
|
+
'scopes', 'extends', 'sortsCustom', 'fieldsTransform',
|
|
105
|
+
'aggregate', 'having', 'expose', 'versions', 'limits', 'relations',
|
|
106
|
+
]);
|
|
107
|
+
/** Маркер экземпляра queryHandler (Elysia onAfterHandle, type guard). */
|
|
108
|
+
export const QUERY_HANDLER_BRAND = Symbol.for("@crossfox/query-handler");
|
package/dist/cursor.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { QueryBuilder } from "@crossfox/db";
|
|
2
|
+
import type { CursorPaginateConfig } from "./types";
|
|
3
|
+
export interface CursorResult {
|
|
4
|
+
items: unknown[];
|
|
5
|
+
total: number;
|
|
6
|
+
nextId: unknown;
|
|
7
|
+
prevId: unknown;
|
|
8
|
+
}
|
|
9
|
+
export declare function runCursor(q: QueryBuilder, after: unknown, before: unknown, limit: number, field: string, cfg: CursorPaginateConfig, displayDir?: 'ASC' | 'DESC'): Promise<CursorResult>;
|
package/dist/cursor.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export async function runCursor(q, after, before, limit, field, cfg, displayDir = 'DESC') {
|
|
2
|
+
let isReversed = false;
|
|
3
|
+
if (after !== undefined) {
|
|
4
|
+
q.where({ [field]: { gt: after } }).order(field, 'ASC');
|
|
5
|
+
if (displayDir === 'DESC')
|
|
6
|
+
isReversed = true;
|
|
7
|
+
}
|
|
8
|
+
else if (before !== undefined) {
|
|
9
|
+
q.where({ [field]: { lt: before } }).order(field, 'DESC');
|
|
10
|
+
if (displayDir === 'ASC')
|
|
11
|
+
isReversed = true;
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
q.order(field, displayDir);
|
|
15
|
+
}
|
|
16
|
+
// COUNT клонируется до LIMIT, учитывает GROUP BY
|
|
17
|
+
const countClone = cfg.allowCount
|
|
18
|
+
? q.clone({ from: true, where: true, join: true, groupBy: true })
|
|
19
|
+
: null;
|
|
20
|
+
const [rows, total] = await Promise.all([
|
|
21
|
+
q.limit(limit + 1).run(),
|
|
22
|
+
countClone
|
|
23
|
+
? countClone.count()
|
|
24
|
+
: Promise.resolve(-2),
|
|
25
|
+
]);
|
|
26
|
+
const hasMore = rows.length > limit;
|
|
27
|
+
if (hasMore)
|
|
28
|
+
rows.pop();
|
|
29
|
+
if (isReversed)
|
|
30
|
+
rows.reverse();
|
|
31
|
+
return {
|
|
32
|
+
items: rows,
|
|
33
|
+
total: total,
|
|
34
|
+
nextId: hasMore ? (rows[rows.length - 1]?.[field] ?? null) : null,
|
|
35
|
+
prevId: before !== undefined ? (rows[0]?.[field] ?? null) : null,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/elysia.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Elysia } from "elysia";
|
|
2
|
+
/** Если хендлер вернул экземпляр queryHandler — выполняется `run()`. */
|
|
3
|
+
export declare const queryHandlerPlugin: Elysia<"", {
|
|
4
|
+
decorator: {};
|
|
5
|
+
store: {};
|
|
6
|
+
derive: {};
|
|
7
|
+
resolve: {};
|
|
8
|
+
}, {
|
|
9
|
+
typebox: {};
|
|
10
|
+
error: {};
|
|
11
|
+
}, {
|
|
12
|
+
schema: {};
|
|
13
|
+
standaloneSchema: {};
|
|
14
|
+
macro: {};
|
|
15
|
+
macroFn: {};
|
|
16
|
+
parser: {};
|
|
17
|
+
response: {
|
|
18
|
+
200: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
}, {}, {
|
|
21
|
+
derive: {};
|
|
22
|
+
resolve: {};
|
|
23
|
+
schema: {};
|
|
24
|
+
standaloneSchema: {};
|
|
25
|
+
response: {};
|
|
26
|
+
}, {
|
|
27
|
+
derive: {};
|
|
28
|
+
resolve: {};
|
|
29
|
+
schema: {};
|
|
30
|
+
standaloneSchema: {};
|
|
31
|
+
response: {};
|
|
32
|
+
}>;
|
|
33
|
+
export { typeQuerySchema, queryTypeQuerySchema } from "./schema";
|
package/dist/elysia.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Elysia } from "elysia";
|
|
2
|
+
import { isQueryHandler } from "./helpers";
|
|
3
|
+
/** Если хендлер вернул экземпляр queryHandler — выполняется `run()`. */
|
|
4
|
+
export const queryHandlerPlugin = new Elysia({ name: "crossfox-query-handler" })
|
|
5
|
+
.onAfterHandle({ as: "global" }, async ({ response }) => {
|
|
6
|
+
if (isQueryHandler(response))
|
|
7
|
+
return response.run();
|
|
8
|
+
});
|
|
9
|
+
export { typeQuerySchema, queryTypeQuerySchema } from "./schema";
|
package/dist/env.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Чтение env без зависимости от app config. */
|
|
2
|
+
export declare function envInt(raw: string | undefined, fallback: number): number;
|
|
3
|
+
export declare function envBool(raw: string | undefined, fallback: boolean): boolean;
|
|
4
|
+
export declare function readEnv(key: string): string | undefined;
|