@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 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
+ [![npm version](https://img.shields.io/npm/v/@crossfox/query-handler.svg)](https://www.npmjs.com/package/@crossfox/query-handler)
8
+ [![license](https://img.shields.io/npm/l/@crossfox/query-handler.svg)](#license)
9
+ [![bun](https://img.shields.io/badge/runtime-Bun-fbf0df?logo=bun)](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).
@@ -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
+ }
@@ -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");
@@ -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
+ }
@@ -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;