@edge-base/server 0.1.1
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/admin-build/.gitkeep +0 -0
- package/admin-build/_app/env.js +1 -0
- package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
- package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
- package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
- package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
- package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
- package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
- package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
- package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
- package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
- package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
- package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
- package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
- package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
- package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
- package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
- package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
- package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
- package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
- package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
- package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
- package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
- package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
- package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
- package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
- package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
- package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
- package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
- package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
- package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
- package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
- package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
- package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
- package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
- package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
- package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
- package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
- package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
- package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
- package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
- package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
- package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
- package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
- package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
- package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
- package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
- package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
- package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
- package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
- package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
- package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
- package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
- package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
- package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
- package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
- package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
- package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
- package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
- package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
- package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
- package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
- package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
- package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
- package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
- package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
- package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
- package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
- package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
- package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
- package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
- package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
- package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
- package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
- package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
- package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
- package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
- package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
- package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
- package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
- package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
- package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
- package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
- package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
- package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
- package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
- package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
- package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
- package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
- package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
- package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
- package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
- package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
- package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
- package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
- package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
- package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
- package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
- package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
- package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
- package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
- package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
- package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
- package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
- package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
- package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
- package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
- package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
- package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
- package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
- package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
- package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
- package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
- package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
- package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
- package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
- package/admin-build/_app/version.json +1 -0
- package/admin-build/favicon.svg +26 -0
- package/admin-build/index.html +45 -0
- package/openapi.json +19543 -0
- package/package.json +66 -0
- package/src/__tests__/admin-assets.test.ts +55 -0
- package/src/__tests__/admin-data-routes.test.ts +488 -0
- package/src/__tests__/admin-db-target.test.ts +103 -0
- package/src/__tests__/admin-routing.test.ts +31 -0
- package/src/__tests__/admin-user-management.test.ts +311 -0
- package/src/__tests__/analytics-query.test.ts +75 -0
- package/src/__tests__/auth-d1.test.ts +749 -0
- package/src/__tests__/auth-db-adapter.test.ts +73 -0
- package/src/__tests__/auth-jwt.test.ts +440 -0
- package/src/__tests__/auth-oauth.test.ts +389 -0
- package/src/__tests__/auth-password.test.ts +367 -0
- package/src/__tests__/auth-redirect.test.ts +87 -0
- package/src/__tests__/backup-restore.test.ts +711 -0
- package/src/__tests__/broadcast.test.ts +128 -0
- package/src/__tests__/cli.test.ts +178 -0
- package/src/__tests__/cloudflare-realtime.test.ts +113 -0
- package/src/__tests__/config.test.ts +469 -0
- package/src/__tests__/cors.test.ts +154 -0
- package/src/__tests__/cron.test.ts +302 -0
- package/src/__tests__/d1-handler.test.ts +402 -0
- package/src/__tests__/d1-sql.test.ts +120 -0
- package/src/__tests__/database-live-config.test.ts +42 -0
- package/src/__tests__/database-live-emitter.test.ts +56 -0
- package/src/__tests__/database-live-filters.test.ts +63 -0
- package/src/__tests__/database-live-route.test.ts +113 -0
- package/src/__tests__/db-sql.test.ts +163 -0
- package/src/__tests__/do-lifecycle.test.ts +263 -0
- package/src/__tests__/do-router.test.ts +729 -0
- package/src/__tests__/email-provider.test.ts +128 -0
- package/src/__tests__/email-templates.test.ts +528 -0
- package/src/__tests__/error-format.test.ts +250 -0
- package/src/__tests__/field-ops.test.ts +242 -0
- package/src/__tests__/functions-context.test.ts +334 -0
- package/src/__tests__/functions-d1-proxy.test.ts +229 -0
- package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
- package/src/__tests__/functions-route.test.ts +139 -0
- package/src/__tests__/internal-request.test.ts +77 -0
- package/src/__tests__/log-writer.test.ts +44 -0
- package/src/__tests__/logger.test.ts +58 -0
- package/src/__tests__/meta-admin-proxy.test.ts +48 -0
- package/src/__tests__/meta-export-coverage.test.ts +191 -0
- package/src/__tests__/meta-route-registration.test.ts +47 -0
- package/src/__tests__/namespace-dump.test.ts +28 -0
- package/src/__tests__/oauth-providers.test.ts +337 -0
- package/src/__tests__/openapi-coverage.test.ts +144 -0
- package/src/__tests__/pagination.test.ts +59 -0
- package/src/__tests__/password-policy.test.ts +191 -0
- package/src/__tests__/plugin-migrations.test.ts +379 -0
- package/src/__tests__/postgres-batch-compat.test.ts +133 -0
- package/src/__tests__/postgres-dialect.test.ts +328 -0
- package/src/__tests__/postgres-executor.test.ts +79 -0
- package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
- package/src/__tests__/postgres-schema-init.test.ts +105 -0
- package/src/__tests__/postgres-table-utils.test.ts +107 -0
- package/src/__tests__/presence.test.ts +199 -0
- package/src/__tests__/provider.test.ts +550 -0
- package/src/__tests__/public-user-profile.test.ts +339 -0
- package/src/__tests__/push-handlers.test.ts +179 -0
- package/src/__tests__/push-provider.test.ts +80 -0
- package/src/__tests__/push-token.test.ts +418 -0
- package/src/__tests__/query.test.ts +771 -0
- package/src/__tests__/rate-limit.test.ts +260 -0
- package/src/__tests__/room-access-policy.test.ts +101 -0
- package/src/__tests__/room-handler-context.test.ts +130 -0
- package/src/__tests__/room-monitoring.test.ts +138 -0
- package/src/__tests__/room-runtime-routing.test.ts +222 -0
- package/src/__tests__/room.test.ts +254 -0
- package/src/__tests__/route-parser.test.ts +490 -0
- package/src/__tests__/rules.test.ts +234 -0
- package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
- package/src/__tests__/scheduled.test.ts +80 -0
- package/src/__tests__/schema.test.ts +1273 -0
- package/src/__tests__/security-hardening.test.ts +312 -0
- package/src/__tests__/server.unit.test.ts +333 -0
- package/src/__tests__/service-key-db-proxy.test.ts +650 -0
- package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
- package/src/__tests__/service-key.test.ts +757 -0
- package/src/__tests__/smoke-skip-report.test.ts +72 -0
- package/src/__tests__/sms-provider.test.ts +39 -0
- package/src/__tests__/sql-route.test.ts +218 -0
- package/src/__tests__/storage-hook-context.test.ts +115 -0
- package/src/__tests__/totp.test.ts +200 -0
- package/src/__tests__/uuid.test.ts +144 -0
- package/src/__tests__/validation.test.ts +773 -0
- package/src/__tests__/websocket-pending.test.ts +163 -0
- package/src/_functions-registry.ts +51 -0
- package/src/bench-entry.ts +9 -0
- package/src/cloudflare-test.d.ts +1 -0
- package/src/durable-objects/auth-do.ts +49 -0
- package/src/durable-objects/database-do.ts +2240 -0
- package/src/durable-objects/database-live-do.ts +949 -0
- package/src/durable-objects/logs-do.ts +1200 -0
- package/src/durable-objects/room-runtime-base.ts +1604 -0
- package/src/durable-objects/rooms-do.ts +2191 -0
- package/src/generated-config.ts +6 -0
- package/src/index.ts +382 -0
- package/src/lib/admin-assets.ts +54 -0
- package/src/lib/admin-db-target.ts +301 -0
- package/src/lib/admin-routing.ts +35 -0
- package/src/lib/admin-user-management.ts +464 -0
- package/src/lib/analytics-adapter.ts +103 -0
- package/src/lib/analytics-query.ts +579 -0
- package/src/lib/auth-d1-service.ts +1193 -0
- package/src/lib/auth-d1.ts +1056 -0
- package/src/lib/auth-db-adapter.ts +289 -0
- package/src/lib/auth-redirect.ts +116 -0
- package/src/lib/cidr.ts +115 -0
- package/src/lib/client-ip.ts +51 -0
- package/src/lib/cloudflare-realtime.ts +251 -0
- package/src/lib/control-db.ts +36 -0
- package/src/lib/cron.ts +163 -0
- package/src/lib/d1-handler.ts +1425 -0
- package/src/lib/d1-schema-init.ts +255 -0
- package/src/lib/d1-sql.ts +33 -0
- package/src/lib/database-live-config.ts +24 -0
- package/src/lib/database-live-emitter.ts +111 -0
- package/src/lib/db-sql.ts +66 -0
- package/src/lib/do-retry.ts +36 -0
- package/src/lib/do-router.ts +270 -0
- package/src/lib/do-sql.ts +73 -0
- package/src/lib/email-provider.ts +379 -0
- package/src/lib/email-templates.ts +285 -0
- package/src/lib/email-translations.ts +422 -0
- package/src/lib/errors.ts +151 -0
- package/src/lib/functions.ts +2091 -0
- package/src/lib/hono.ts +56 -0
- package/src/lib/internal-request.ts +56 -0
- package/src/lib/jwt.ts +354 -0
- package/src/lib/log-writer.ts +272 -0
- package/src/lib/namespace-dump.ts +125 -0
- package/src/lib/oauth-providers.ts +1225 -0
- package/src/lib/op-parser.ts +99 -0
- package/src/lib/openapi.ts +146 -0
- package/src/lib/pagination.ts +19 -0
- package/src/lib/password-policy.ts +102 -0
- package/src/lib/password.ts +145 -0
- package/src/lib/plugin-migrations.ts +612 -0
- package/src/lib/postgres-executor.ts +203 -0
- package/src/lib/postgres-handler.ts +1102 -0
- package/src/lib/postgres-schema-init.ts +341 -0
- package/src/lib/postgres-table-utils.ts +87 -0
- package/src/lib/public-user-profile.ts +187 -0
- package/src/lib/push-provider.ts +409 -0
- package/src/lib/push-token.ts +294 -0
- package/src/lib/query-engine.ts +768 -0
- package/src/lib/room-monitoring.ts +97 -0
- package/src/lib/room-runtime.ts +14 -0
- package/src/lib/route-parser.ts +434 -0
- package/src/lib/schema.ts +538 -0
- package/src/lib/schemas.ts +152 -0
- package/src/lib/service-key.ts +419 -0
- package/src/lib/sms-provider.ts +230 -0
- package/src/lib/startup-config.ts +99 -0
- package/src/lib/totp.ts +242 -0
- package/src/lib/uuid.ts +87 -0
- package/src/lib/validation.ts +205 -0
- package/src/lib/version.ts +2 -0
- package/src/lib/websocket-pending.ts +40 -0
- package/src/middleware/auth.ts +169 -0
- package/src/middleware/captcha-verify.ts +217 -0
- package/src/middleware/cors.ts +159 -0
- package/src/middleware/error-handler.ts +54 -0
- package/src/middleware/internal-guard.ts +26 -0
- package/src/middleware/logger.ts +126 -0
- package/src/middleware/rate-limit.ts +283 -0
- package/src/middleware/rules.ts +475 -0
- package/src/routes/admin-auth.ts +447 -0
- package/src/routes/admin.ts +3501 -0
- package/src/routes/analytics-api.ts +290 -0
- package/src/routes/auth.ts +4222 -0
- package/src/routes/backup.ts +1466 -0
- package/src/routes/config.ts +53 -0
- package/src/routes/d1.ts +109 -0
- package/src/routes/database-live.ts +281 -0
- package/src/routes/functions.ts +155 -0
- package/src/routes/health.ts +32 -0
- package/src/routes/kv.ts +167 -0
- package/src/routes/oauth.ts +1055 -0
- package/src/routes/push.ts +1465 -0
- package/src/routes/room.ts +639 -0
- package/src/routes/schema-endpoint.ts +76 -0
- package/src/routes/sql.ts +176 -0
- package/src/routes/storage.ts +1674 -0
- package/src/routes/tables.ts +699 -0
- package/src/routes/users.ts +21 -0
- package/src/routes/vectorize.ts +372 -0
- package/src/types.ts +99 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query engine: filter/sort/pagination → SQL.
|
|
3
|
+
*
|
|
4
|
+
* Filter tuple format: [field, operator, value]
|
|
5
|
+
* OR filter: orFilters — conditions joined with OR
|
|
6
|
+
* Sort: { field, direction }
|
|
7
|
+
* Pagination: offset-based or cursor-based (UUID v7)
|
|
8
|
+
*
|
|
9
|
+
* Supports two SQL dialects:
|
|
10
|
+
* - 'sqlite' (default): ? bind params, INSTR() for contains
|
|
11
|
+
* - 'postgres': $1,$2 bind params, ILIKE for contains
|
|
12
|
+
*/
|
|
13
|
+
import type { FilterOperator, SortDirection } from '@edge-base/shared';
|
|
14
|
+
import { EdgeBaseError } from '@edge-base/shared';
|
|
15
|
+
|
|
16
|
+
// ─── Types ───
|
|
17
|
+
|
|
18
|
+
export type SqlDialect = 'sqlite' | 'postgres';
|
|
19
|
+
|
|
20
|
+
export type FilterTuple = [string, FilterOperator, unknown];
|
|
21
|
+
|
|
22
|
+
export interface SortOption {
|
|
23
|
+
field: string;
|
|
24
|
+
direction: SortDirection;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PaginationOptions {
|
|
28
|
+
limit?: number;
|
|
29
|
+
offset?: number;
|
|
30
|
+
after?: string; // cursor (UUID v7 id)
|
|
31
|
+
before?: string;
|
|
32
|
+
page?: number;
|
|
33
|
+
perPage?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface QueryOptions {
|
|
37
|
+
filters?: FilterTuple[];
|
|
38
|
+
orFilters?: FilterTuple[]; // OR group — conditions joined with OR
|
|
39
|
+
sort?: SortOption[];
|
|
40
|
+
pagination?: PaginationOptions;
|
|
41
|
+
fields?: string[];
|
|
42
|
+
search?: string; // FTS5 search term
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface QueryResult {
|
|
46
|
+
sql: string;
|
|
47
|
+
params: unknown[];
|
|
48
|
+
countSql?: string;
|
|
49
|
+
countParams?: unknown[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Bind Parameter Tracker ───
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Tracks bind parameter index for PostgreSQL ($1, $2, ...) vs SQLite (?).
|
|
56
|
+
*/
|
|
57
|
+
class BindTracker {
|
|
58
|
+
private idx = 0;
|
|
59
|
+
constructor(private dialect: SqlDialect) {}
|
|
60
|
+
|
|
61
|
+
/** Returns the next placeholder: '?' for sqlite, '$N' for postgres */
|
|
62
|
+
next(): string {
|
|
63
|
+
this.idx++;
|
|
64
|
+
return this.dialect === 'postgres' ? `$${this.idx}` : '?';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Returns N placeholders for IN clauses */
|
|
68
|
+
nextN(count: number): string[] {
|
|
69
|
+
return Array.from({ length: count }, () => this.next());
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Query Builder ───
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a SELECT query from query options.
|
|
77
|
+
*/
|
|
78
|
+
export function buildListQuery(
|
|
79
|
+
tableName: string,
|
|
80
|
+
options: QueryOptions,
|
|
81
|
+
dialect: SqlDialect = 'sqlite',
|
|
82
|
+
): QueryResult {
|
|
83
|
+
const params: unknown[] = [];
|
|
84
|
+
const bt = new BindTracker(dialect);
|
|
85
|
+
|
|
86
|
+
// ── FTS5 search integration (SQLite) ──
|
|
87
|
+
// When options.search is provided, JOIN with the FTS5 table for full-text filtering.
|
|
88
|
+
const hasSearch = !!options.search;
|
|
89
|
+
const ftsTable = `${tableName}_fts`;
|
|
90
|
+
|
|
91
|
+
// SELECT clause
|
|
92
|
+
const selectFields = options.fields?.length
|
|
93
|
+
? options.fields.map(f => `${esc(tableName)}.${esc(f)}`).join(', ')
|
|
94
|
+
: `${esc(tableName)}.*`;
|
|
95
|
+
|
|
96
|
+
let sql: string;
|
|
97
|
+
if (hasSearch && dialect === 'sqlite') {
|
|
98
|
+
const escapedTerm = `"${options.search!.replace(/"/g, '""')}"`;
|
|
99
|
+
sql = `SELECT ${selectFields} FROM ${esc(ftsTable)} JOIN ${esc(tableName)} ON ${esc(tableName)}.rowid = ${esc(ftsTable)}.rowid WHERE ${esc(ftsTable)} MATCH ${bt.next()}`;
|
|
100
|
+
params.push(escapedTerm);
|
|
101
|
+
} else if (hasSearch && dialect === 'postgres') {
|
|
102
|
+
// PostgreSQL: ILIKE-based search across all text columns
|
|
103
|
+
sql = `SELECT ${selectFields} FROM ${esc(tableName)}`;
|
|
104
|
+
// We'll add the ILIKE condition as a WHERE clause below
|
|
105
|
+
} else {
|
|
106
|
+
sql = `SELECT ${selectFields} FROM ${esc(tableName)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// WHERE clause (filters + cursor pagination)
|
|
110
|
+
const { whereClause, whereParams } = buildWhereClause(options.filters, options.pagination, options.orFilters, bt, dialect);
|
|
111
|
+
if (whereClause) {
|
|
112
|
+
sql += hasSearch && dialect === 'sqlite' ? ` AND (${whereClause})` : ` WHERE ${whereClause}`;
|
|
113
|
+
params.push(...whereParams);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// PostgreSQL search: add ILIKE conditions
|
|
117
|
+
if (hasSearch && dialect === 'postgres') {
|
|
118
|
+
const ilikeCondition = buildPostgresRowSearchCondition(tableName, bt);
|
|
119
|
+
sql += whereClause ? ` AND ${ilikeCondition}` : ` WHERE ${ilikeCondition}`;
|
|
120
|
+
params.push(options.search!);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ORDER BY clause — FTS5 search defaults to rank ordering when no explicit sort
|
|
124
|
+
const orderBy = buildOrderByClause(options.sort, options.pagination);
|
|
125
|
+
if (orderBy) {
|
|
126
|
+
sql += ` ORDER BY ${orderBy}`;
|
|
127
|
+
} else if (hasSearch && dialect === 'sqlite') {
|
|
128
|
+
sql += ` ORDER BY ${esc(ftsTable)}.rank`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// LIMIT / OFFSET
|
|
132
|
+
const { limitClause, limitParams } = buildLimitClause(options.pagination, bt);
|
|
133
|
+
if (limitClause) {
|
|
134
|
+
sql += ` ${limitClause}`;
|
|
135
|
+
params.push(...limitParams);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// COUNT query (for offset pagination)
|
|
139
|
+
let countSql: string | undefined;
|
|
140
|
+
let countParams: unknown[] | undefined;
|
|
141
|
+
if (!options.pagination?.after && !options.pagination?.before) {
|
|
142
|
+
const countBt = new BindTracker(dialect);
|
|
143
|
+
const { whereClause: cw, whereParams: cp } = buildWhereClause(options.filters, undefined, options.orFilters, countBt, dialect);
|
|
144
|
+
|
|
145
|
+
if (hasSearch && dialect === 'sqlite') {
|
|
146
|
+
const escapedTerm = `"${options.search!.replace(/"/g, '""')}"`;
|
|
147
|
+
countSql = `SELECT COUNT(*) as total FROM ${esc(ftsTable)} JOIN ${esc(tableName)} ON ${esc(tableName)}.rowid = ${esc(ftsTable)}.rowid WHERE ${esc(ftsTable)} MATCH ${countBt.next()}`;
|
|
148
|
+
countParams = [escapedTerm];
|
|
149
|
+
if (cw) {
|
|
150
|
+
countSql += ` AND (${cw})`;
|
|
151
|
+
countParams.push(...cp);
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
countSql = `SELECT COUNT(*) as total FROM ${esc(tableName)}`;
|
|
155
|
+
countParams = [];
|
|
156
|
+
if (cw) {
|
|
157
|
+
countSql += ` WHERE ${cw}`;
|
|
158
|
+
countParams = cp;
|
|
159
|
+
}
|
|
160
|
+
if (hasSearch && dialect === 'postgres') {
|
|
161
|
+
const ilikeCondition = buildPostgresRowSearchCondition(tableName, countBt);
|
|
162
|
+
countSql += cw ? ` AND ${ilikeCondition}` : ` WHERE ${ilikeCondition}`;
|
|
163
|
+
countParams.push(options.search!);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { sql, params, countSql, countParams };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build a COUNT query for a table.
|
|
173
|
+
*/
|
|
174
|
+
export function buildCountQuery(
|
|
175
|
+
tableName: string,
|
|
176
|
+
filters?: FilterTuple[],
|
|
177
|
+
orFilters?: FilterTuple[],
|
|
178
|
+
dialect: SqlDialect = 'sqlite',
|
|
179
|
+
): { sql: string; params: unknown[] } {
|
|
180
|
+
const params: unknown[] = [];
|
|
181
|
+
const bt = new BindTracker(dialect);
|
|
182
|
+
let sql = `SELECT COUNT(*) as total FROM ${esc(tableName)}`;
|
|
183
|
+
|
|
184
|
+
const { whereClause, whereParams } = buildWhereClause(filters, undefined, orFilters, bt, dialect);
|
|
185
|
+
if (whereClause) {
|
|
186
|
+
sql += ` WHERE ${whereClause}`;
|
|
187
|
+
params.push(...whereParams);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { sql, params };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build a single-record GET query.
|
|
195
|
+
*/
|
|
196
|
+
export function buildGetQuery(
|
|
197
|
+
tableName: string,
|
|
198
|
+
id: string,
|
|
199
|
+
fields?: string[],
|
|
200
|
+
dialect: SqlDialect = 'sqlite',
|
|
201
|
+
): { sql: string; params: unknown[] } {
|
|
202
|
+
const selectFields = fields?.length
|
|
203
|
+
? fields.map(esc).join(', ')
|
|
204
|
+
: '*';
|
|
205
|
+
|
|
206
|
+
const placeholder = dialect === 'postgres' ? '$1' : '?';
|
|
207
|
+
return {
|
|
208
|
+
sql: `SELECT ${selectFields} FROM ${esc(tableName)} WHERE "id" = ${placeholder}`,
|
|
209
|
+
params: [id],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build a FTS5 search query with highlight support.
|
|
215
|
+
* For PostgreSQL dialect, uses ILIKE across specified fields (no FTS5).
|
|
216
|
+
*/
|
|
217
|
+
export function buildSearchQuery(
|
|
218
|
+
tableName: string,
|
|
219
|
+
searchTerm: string,
|
|
220
|
+
options?: {
|
|
221
|
+
pagination?: PaginationOptions;
|
|
222
|
+
filters?: FilterTuple[];
|
|
223
|
+
orFilters?: FilterTuple[];
|
|
224
|
+
sort?: SortOption[];
|
|
225
|
+
limit?: number;
|
|
226
|
+
offset?: number;
|
|
227
|
+
ftsFields?: string[]; // FTS field names for highlight (SQLite) / search columns (Postgres)
|
|
228
|
+
highlightPre?: string;
|
|
229
|
+
highlightPost?: string;
|
|
230
|
+
},
|
|
231
|
+
dialect: SqlDialect = 'sqlite',
|
|
232
|
+
): QueryResult {
|
|
233
|
+
const pagination: PaginationOptions | undefined = options?.pagination || options?.limit !== undefined || options?.offset !== undefined
|
|
234
|
+
? {
|
|
235
|
+
...options?.pagination,
|
|
236
|
+
limit: options?.pagination?.limit ?? options?.limit,
|
|
237
|
+
offset: options?.pagination?.offset ?? options?.offset,
|
|
238
|
+
}
|
|
239
|
+
: options?.pagination;
|
|
240
|
+
|
|
241
|
+
// PostgreSQL: ILIKE-based search across text columns (no FTS5)
|
|
242
|
+
if (dialect === 'postgres') {
|
|
243
|
+
const bt = new BindTracker('postgres');
|
|
244
|
+
const searchFields = options?.ftsFields?.length ? options.ftsFields : ['id'];
|
|
245
|
+
const params: unknown[] = [];
|
|
246
|
+
const searchConditions = searchFields.map((field) => {
|
|
247
|
+
params.push(searchTerm);
|
|
248
|
+
return `${esc(field)}::text ILIKE '%' || ${bt.next()} || '%'`;
|
|
249
|
+
});
|
|
250
|
+
const { whereClause, whereParams } = buildWhereClause(
|
|
251
|
+
options?.filters,
|
|
252
|
+
pagination,
|
|
253
|
+
options?.orFilters,
|
|
254
|
+
bt,
|
|
255
|
+
dialect,
|
|
256
|
+
);
|
|
257
|
+
const whereParts = [`(${searchConditions.join(' OR ')})`];
|
|
258
|
+
if (whereClause) {
|
|
259
|
+
whereParts.push(`(${whereClause})`);
|
|
260
|
+
params.push(...whereParams);
|
|
261
|
+
}
|
|
262
|
+
const orderBy = buildOrderByClause(options?.sort, pagination);
|
|
263
|
+
const { limitClause, limitParams } = buildLimitClause(pagination, bt);
|
|
264
|
+
params.push(...limitParams);
|
|
265
|
+
|
|
266
|
+
const countBt = new BindTracker('postgres');
|
|
267
|
+
const countParams: unknown[] = [];
|
|
268
|
+
const countSearchConditions = searchFields.map((field) => {
|
|
269
|
+
countParams.push(searchTerm);
|
|
270
|
+
return `${esc(field)}::text ILIKE '%' || ${countBt.next()} || '%'`;
|
|
271
|
+
});
|
|
272
|
+
const { whereClause: countWhere, whereParams: countWhereParams } = buildWhereClause(
|
|
273
|
+
options?.filters,
|
|
274
|
+
undefined,
|
|
275
|
+
options?.orFilters,
|
|
276
|
+
countBt,
|
|
277
|
+
dialect,
|
|
278
|
+
);
|
|
279
|
+
const countWhereParts = [`(${countSearchConditions.join(' OR ')})`];
|
|
280
|
+
if (countWhere) {
|
|
281
|
+
countWhereParts.push(`(${countWhere})`);
|
|
282
|
+
countParams.push(...countWhereParams);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
sql: `SELECT * FROM ${esc(tableName)} WHERE ${whereParts.join(' AND ')} ORDER BY ${orderBy} ${limitClause}`,
|
|
287
|
+
params,
|
|
288
|
+
countSql: `SELECT COUNT(*) as total FROM ${esc(tableName)} WHERE ${countWhereParts.join(' AND ')}`,
|
|
289
|
+
countParams,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// SQLite: FTS5 with highlight support
|
|
294
|
+
const ftsTable = `${tableName}_fts`;
|
|
295
|
+
const bt = new BindTracker('sqlite');
|
|
296
|
+
const params: unknown[] = [];
|
|
297
|
+
|
|
298
|
+
// Build highlight SELECT columns
|
|
299
|
+
const highlightPre = options?.highlightPre ?? '<mark>';
|
|
300
|
+
const highlightPost = options?.highlightPost ?? '</mark>';
|
|
301
|
+
const highlightColumns: string[] = [];
|
|
302
|
+
|
|
303
|
+
if (options?.ftsFields?.length) {
|
|
304
|
+
for (let i = 0; i < options.ftsFields.length; i++) {
|
|
305
|
+
const fieldName = options.ftsFields[i];
|
|
306
|
+
highlightColumns.push(
|
|
307
|
+
`highlight(${esc(ftsTable)}, ${i}, '${highlightPre.replace(/'/g, "''")}', '${highlightPost.replace(/'/g, "''")}') as "${fieldName}_highlighted"`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const selectCols = [
|
|
313
|
+
`${esc(tableName)}.*`,
|
|
314
|
+
`${esc(ftsTable)}.rank`,
|
|
315
|
+
...highlightColumns,
|
|
316
|
+
].join(', ');
|
|
317
|
+
|
|
318
|
+
const escapedTerm = buildSqliteFtsMatch(searchTerm);
|
|
319
|
+
params.push(escapedTerm);
|
|
320
|
+
const { whereClause, whereParams } = buildWhereClause(
|
|
321
|
+
options?.filters,
|
|
322
|
+
pagination,
|
|
323
|
+
options?.orFilters,
|
|
324
|
+
bt,
|
|
325
|
+
dialect,
|
|
326
|
+
);
|
|
327
|
+
params.push(...whereParams);
|
|
328
|
+
const orderBy = options?.sort?.length
|
|
329
|
+
? buildOrderByClause(options.sort, pagination)
|
|
330
|
+
: `${esc(ftsTable)}.rank, "id" ASC`;
|
|
331
|
+
const { limitClause, limitParams } = buildLimitClause(pagination, bt);
|
|
332
|
+
params.push(...limitParams);
|
|
333
|
+
|
|
334
|
+
const countBt = new BindTracker('sqlite');
|
|
335
|
+
const countParams: unknown[] = [escapedTerm];
|
|
336
|
+
const { whereClause: countWhere, whereParams: countWhereParams } = buildWhereClause(
|
|
337
|
+
options?.filters,
|
|
338
|
+
undefined,
|
|
339
|
+
options?.orFilters,
|
|
340
|
+
countBt,
|
|
341
|
+
dialect,
|
|
342
|
+
);
|
|
343
|
+
countParams.push(...countWhereParams);
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
sql: `SELECT ${selectCols}
|
|
347
|
+
FROM ${esc(ftsTable)}
|
|
348
|
+
JOIN ${esc(tableName)} ON ${esc(tableName)}.rowid = ${esc(ftsTable)}.rowid
|
|
349
|
+
WHERE ${esc(ftsTable)} MATCH ?
|
|
350
|
+
${whereClause ? `AND (${whereClause})` : ''}
|
|
351
|
+
ORDER BY ${orderBy}
|
|
352
|
+
${limitClause}`,
|
|
353
|
+
params,
|
|
354
|
+
countSql: `SELECT COUNT(*) as total
|
|
355
|
+
FROM ${esc(ftsTable)}
|
|
356
|
+
JOIN ${esc(tableName)} ON ${esc(tableName)}.rowid = ${esc(ftsTable)}.rowid
|
|
357
|
+
WHERE ${esc(ftsTable)} MATCH ?
|
|
358
|
+
${countWhere ? `AND (${countWhere})` : ''}`,
|
|
359
|
+
countParams,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function buildSubstringSearchQuery(
|
|
364
|
+
tableName: string,
|
|
365
|
+
searchTerm: string,
|
|
366
|
+
options?: {
|
|
367
|
+
pagination?: PaginationOptions;
|
|
368
|
+
filters?: FilterTuple[];
|
|
369
|
+
orFilters?: FilterTuple[];
|
|
370
|
+
sort?: SortOption[];
|
|
371
|
+
limit?: number;
|
|
372
|
+
offset?: number;
|
|
373
|
+
fields?: string[];
|
|
374
|
+
},
|
|
375
|
+
dialect: SqlDialect = 'sqlite',
|
|
376
|
+
): QueryResult {
|
|
377
|
+
const pagination: PaginationOptions | undefined = options?.pagination || options?.limit !== undefined || options?.offset !== undefined
|
|
378
|
+
? {
|
|
379
|
+
...options?.pagination,
|
|
380
|
+
limit: options?.pagination?.limit ?? options?.limit,
|
|
381
|
+
offset: options?.pagination?.offset ?? options?.offset,
|
|
382
|
+
}
|
|
383
|
+
: options?.pagination;
|
|
384
|
+
const fields = options?.fields?.length ? options.fields : ['id'];
|
|
385
|
+
|
|
386
|
+
if (dialect === 'postgres') {
|
|
387
|
+
const bt = new BindTracker('postgres');
|
|
388
|
+
const params: unknown[] = [];
|
|
389
|
+
const searchConditions = fields.map((field) => {
|
|
390
|
+
params.push(searchTerm);
|
|
391
|
+
return `${esc(field)}::text ILIKE '%' || ${bt.next()} || '%'`;
|
|
392
|
+
});
|
|
393
|
+
const { whereClause, whereParams } = buildWhereClause(
|
|
394
|
+
options?.filters,
|
|
395
|
+
pagination,
|
|
396
|
+
options?.orFilters,
|
|
397
|
+
bt,
|
|
398
|
+
dialect,
|
|
399
|
+
);
|
|
400
|
+
const whereParts = [`(${searchConditions.join(' OR ')})`];
|
|
401
|
+
if (whereClause) {
|
|
402
|
+
whereParts.push(`(${whereClause})`);
|
|
403
|
+
params.push(...whereParams);
|
|
404
|
+
}
|
|
405
|
+
const orderBy = buildOrderByClause(options?.sort, pagination);
|
|
406
|
+
const { limitClause, limitParams } = buildLimitClause(pagination, bt);
|
|
407
|
+
params.push(...limitParams);
|
|
408
|
+
|
|
409
|
+
const countBt = new BindTracker('postgres');
|
|
410
|
+
const countParams: unknown[] = [];
|
|
411
|
+
const countSearchConditions = fields.map((field) => {
|
|
412
|
+
countParams.push(searchTerm);
|
|
413
|
+
return `${esc(field)}::text ILIKE '%' || ${countBt.next()} || '%'`;
|
|
414
|
+
});
|
|
415
|
+
const { whereClause: countWhere, whereParams: countWhereParams } = buildWhereClause(
|
|
416
|
+
options?.filters,
|
|
417
|
+
undefined,
|
|
418
|
+
options?.orFilters,
|
|
419
|
+
countBt,
|
|
420
|
+
dialect,
|
|
421
|
+
);
|
|
422
|
+
const countWhereParts = [`(${countSearchConditions.join(' OR ')})`];
|
|
423
|
+
if (countWhere) {
|
|
424
|
+
countWhereParts.push(`(${countWhere})`);
|
|
425
|
+
countParams.push(...countWhereParams);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
sql: `SELECT * FROM ${esc(tableName)} WHERE ${whereParts.join(' AND ')} ORDER BY ${orderBy} ${limitClause}`,
|
|
430
|
+
params,
|
|
431
|
+
countSql: `SELECT COUNT(*) as total FROM ${esc(tableName)} WHERE ${countWhereParts.join(' AND ')}`,
|
|
432
|
+
countParams,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const bt = new BindTracker('sqlite');
|
|
437
|
+
const params: unknown[] = [];
|
|
438
|
+
const conditions = fields.map((field) => {
|
|
439
|
+
params.push(searchTerm);
|
|
440
|
+
return `instr(lower(CAST(${esc(field)} AS TEXT)), lower(${bt.next()})) > 0`;
|
|
441
|
+
});
|
|
442
|
+
const { whereClause, whereParams } = buildWhereClause(
|
|
443
|
+
options?.filters,
|
|
444
|
+
pagination,
|
|
445
|
+
options?.orFilters,
|
|
446
|
+
bt,
|
|
447
|
+
dialect,
|
|
448
|
+
);
|
|
449
|
+
if (whereClause) {
|
|
450
|
+
params.push(...whereParams);
|
|
451
|
+
}
|
|
452
|
+
const orderBy = buildOrderByClause(options?.sort, pagination);
|
|
453
|
+
const { limitClause, limitParams } = buildLimitClause(pagination, bt);
|
|
454
|
+
params.push(...limitParams);
|
|
455
|
+
|
|
456
|
+
const countBt = new BindTracker('sqlite');
|
|
457
|
+
const countParams: unknown[] = [];
|
|
458
|
+
const countConditions = fields.map((field) => {
|
|
459
|
+
countParams.push(searchTerm);
|
|
460
|
+
return `instr(lower(CAST(${esc(field)} AS TEXT)), lower(${countBt.next()})) > 0`;
|
|
461
|
+
});
|
|
462
|
+
const { whereClause: countWhere, whereParams: countWhereParams } = buildWhereClause(
|
|
463
|
+
options?.filters,
|
|
464
|
+
undefined,
|
|
465
|
+
options?.orFilters,
|
|
466
|
+
countBt,
|
|
467
|
+
dialect,
|
|
468
|
+
);
|
|
469
|
+
if (countWhere) {
|
|
470
|
+
countParams.push(...countWhereParams);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
sql: `SELECT * FROM ${esc(tableName)} WHERE (${conditions.join(' OR ')})${whereClause ? ` AND (${whereClause})` : ''} ORDER BY ${orderBy} ${limitClause}`,
|
|
475
|
+
params,
|
|
476
|
+
countSql: `SELECT COUNT(*) as total FROM ${esc(tableName)} WHERE (${countConditions.join(' OR ')})${countWhere ? ` AND (${countWhere})` : ''}`,
|
|
477
|
+
countParams,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function buildSqliteFtsMatch(searchTerm: string): string {
|
|
482
|
+
const terms = searchTerm
|
|
483
|
+
.trim()
|
|
484
|
+
.split(/\s+/)
|
|
485
|
+
.map((term) => term.replace(/^"+|"+$/g, '').trim())
|
|
486
|
+
.filter((term) => term.length > 0)
|
|
487
|
+
.map((term) => `"${term.replace(/"/g, '""')}"*`);
|
|
488
|
+
|
|
489
|
+
if (terms.length === 0) {
|
|
490
|
+
return '""';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return terms.join(' ');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function buildPostgresRowSearchCondition(
|
|
497
|
+
tableName: string,
|
|
498
|
+
bt: BindTracker,
|
|
499
|
+
): string {
|
|
500
|
+
return `(to_jsonb(${esc(tableName)})::text ILIKE '%' || ${bt.next()} || '%')`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
// ─── WHERE Clause Builder ───
|
|
505
|
+
|
|
506
|
+
function buildWhereClause(
|
|
507
|
+
filters?: FilterTuple[],
|
|
508
|
+
pagination?: PaginationOptions,
|
|
509
|
+
orFilters?: FilterTuple[],
|
|
510
|
+
bt?: BindTracker,
|
|
511
|
+
dialect: SqlDialect = 'sqlite',
|
|
512
|
+
): { whereClause: string; whereParams: unknown[] } {
|
|
513
|
+
const _bt = bt ?? new BindTracker(dialect);
|
|
514
|
+
const conditions: string[] = [];
|
|
515
|
+
const params: unknown[] = [];
|
|
516
|
+
|
|
517
|
+
// Filter tuples → WHERE conditions (AND)
|
|
518
|
+
if (filters?.length) {
|
|
519
|
+
for (const [field, op, value] of filters) {
|
|
520
|
+
const { condition, condParams } = buildFilterCondition(field, op, value, _bt, dialect);
|
|
521
|
+
conditions.push(condition);
|
|
522
|
+
params.push(...condParams);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// OR filter group — conditions joined with OR
|
|
527
|
+
if (orFilters?.length) {
|
|
528
|
+
if (orFilters.length > 5) {
|
|
529
|
+
throw new EdgeBaseError(400, 'OR_FILTER_LIMIT_EXCEEDED: maximum 5 conditions in OR group');
|
|
530
|
+
}
|
|
531
|
+
const orClauses: string[] = [];
|
|
532
|
+
for (const [field, op, value] of orFilters) {
|
|
533
|
+
const { condition, condParams } = buildFilterCondition(field, op, value, _bt, dialect);
|
|
534
|
+
orClauses.push(condition);
|
|
535
|
+
params.push(...condParams);
|
|
536
|
+
}
|
|
537
|
+
conditions.push(`(${orClauses.join(' OR ')})`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Cursor pagination → WHERE id > ? or id < ?
|
|
541
|
+
if (pagination?.after) {
|
|
542
|
+
conditions.push(`"id" > ${_bt.next()}`);
|
|
543
|
+
params.push(pagination.after);
|
|
544
|
+
}
|
|
545
|
+
if (pagination?.before) {
|
|
546
|
+
conditions.push(`"id" < ${_bt.next()}`);
|
|
547
|
+
params.push(pagination.before);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
whereClause: conditions.length ? conditions.join(' AND ') : '',
|
|
552
|
+
whereParams: params,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function buildFilterCondition(
|
|
557
|
+
field: string,
|
|
558
|
+
op: FilterOperator,
|
|
559
|
+
value: unknown,
|
|
560
|
+
bt: BindTracker,
|
|
561
|
+
dialect: SqlDialect = 'sqlite',
|
|
562
|
+
): { condition: string; condParams: unknown[] } {
|
|
563
|
+
const col = esc(field);
|
|
564
|
+
|
|
565
|
+
switch (op) {
|
|
566
|
+
case '==':
|
|
567
|
+
return { condition: `${col} = ${bt.next()}`, condParams: [value] };
|
|
568
|
+
case '!=':
|
|
569
|
+
return { condition: `${col} != ${bt.next()}`, condParams: [value] };
|
|
570
|
+
case '>':
|
|
571
|
+
return { condition: `${col} > ${bt.next()}`, condParams: [value] };
|
|
572
|
+
case '<':
|
|
573
|
+
return { condition: `${col} < ${bt.next()}`, condParams: [value] };
|
|
574
|
+
case '>=':
|
|
575
|
+
return { condition: `${col} >= ${bt.next()}`, condParams: [value] };
|
|
576
|
+
case '<=':
|
|
577
|
+
return { condition: `${col} <= ${bt.next()}`, condParams: [value] };
|
|
578
|
+
case 'contains':
|
|
579
|
+
if (dialect === 'postgres') {
|
|
580
|
+
// PostgreSQL: use ILIKE for case-insensitive substring matching
|
|
581
|
+
return { condition: `${col} ILIKE '%' || ${bt.next()} || '%'`, condParams: [value] };
|
|
582
|
+
}
|
|
583
|
+
// SQLite: Use INSTR instead of LIKE to avoid pattern complexity limit
|
|
584
|
+
return { condition: `INSTR(${col}, ${bt.next()}) > 0`, condParams: [value] };
|
|
585
|
+
case 'in': {
|
|
586
|
+
const arr = value as unknown[];
|
|
587
|
+
const placeholders = bt.nextN(arr.length).join(', ');
|
|
588
|
+
return { condition: `${col} IN (${placeholders})`, condParams: arr };
|
|
589
|
+
}
|
|
590
|
+
case 'not in':
|
|
591
|
+
case 'not-in': {
|
|
592
|
+
const arr = value as unknown[];
|
|
593
|
+
const placeholders = bt.nextN(arr.length).join(', ');
|
|
594
|
+
return { condition: `${col} NOT IN (${placeholders})`, condParams: arr };
|
|
595
|
+
}
|
|
596
|
+
case 'contains-any': {
|
|
597
|
+
const arr = value as unknown[];
|
|
598
|
+
if (dialect === 'postgres') {
|
|
599
|
+
// PostgreSQL: jsonb array overlap — tags ?| array['a','b']
|
|
600
|
+
const placeholders = bt.nextN(arr.length).join(', ');
|
|
601
|
+
return { condition: `${col}::jsonb ?| ARRAY[${placeholders}]`, condParams: arr };
|
|
602
|
+
}
|
|
603
|
+
// SQLite: EXISTS (SELECT 1 FROM json_each(col) WHERE value IN (?, ?))
|
|
604
|
+
const placeholders = bt.nextN(arr.length).join(', ');
|
|
605
|
+
return { condition: `EXISTS (SELECT 1 FROM json_each(${col}) WHERE value IN (${placeholders}))`, condParams: arr };
|
|
606
|
+
}
|
|
607
|
+
default:
|
|
608
|
+
throw new EdgeBaseError(400, `Unsupported filter operator: ${op}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ─── ORDER BY Clause Builder ───
|
|
613
|
+
|
|
614
|
+
function buildOrderByClause(
|
|
615
|
+
sort?: SortOption[],
|
|
616
|
+
pagination?: PaginationOptions,
|
|
617
|
+
): string {
|
|
618
|
+
const parts: string[] = [];
|
|
619
|
+
|
|
620
|
+
if (sort?.length) {
|
|
621
|
+
for (const s of sort) {
|
|
622
|
+
const dir = s.direction.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
|
623
|
+
parts.push(`${esc(s.field)} ${dir}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Default sort by id for cursor pagination or if no explicit sort
|
|
628
|
+
if (!parts.length) {
|
|
629
|
+
if (pagination?.before) {
|
|
630
|
+
parts.push('"id" DESC');
|
|
631
|
+
} else {
|
|
632
|
+
parts.push('"id" ASC');
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// When using custom sort, add "id" as tiebreaker to ensure deterministic
|
|
637
|
+
// ordering. Without this, non-unique sort keys (e.g. createdAt) produce
|
|
638
|
+
// non-deterministic row order, causing offset pagination to return
|
|
639
|
+
// overlapping results across pages. For cursor pagination, "id" is also
|
|
640
|
+
// required because WHERE "id" > ? depends on ORDER BY ending with "id".
|
|
641
|
+
if (sort?.length) {
|
|
642
|
+
const hasIdSort = sort.some(s => s.field === 'id');
|
|
643
|
+
if (!hasIdSort) {
|
|
644
|
+
parts.push(pagination?.before ? '"id" DESC' : '"id" ASC');
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return parts.join(', ');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ─── LIMIT Clause Builder ───
|
|
652
|
+
|
|
653
|
+
function buildLimitClause(
|
|
654
|
+
pagination?: PaginationOptions,
|
|
655
|
+
bt?: BindTracker,
|
|
656
|
+
): { limitClause: string; limitParams: unknown[] } {
|
|
657
|
+
const _bt = bt ?? new BindTracker('sqlite');
|
|
658
|
+
|
|
659
|
+
if (!pagination) {
|
|
660
|
+
return { limitClause: `LIMIT ${_bt.next()}`, limitParams: [20] }; // Default limit
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const limit = pagination.limit ?? pagination.perPage ?? 20;
|
|
664
|
+
|
|
665
|
+
// Cursor-based: no offset
|
|
666
|
+
if (pagination.after || pagination.before) {
|
|
667
|
+
return { limitClause: `LIMIT ${_bt.next()}`, limitParams: [limit] };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Offset-based
|
|
671
|
+
const offset = pagination.offset ?? ((pagination.page ?? 1) - 1) * limit;
|
|
672
|
+
return { limitClause: `LIMIT ${_bt.next()} OFFSET ${_bt.next()}`, limitParams: [limit, offset] };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ─── Query Parameter Keys ───
|
|
676
|
+
|
|
677
|
+
/** All query parameter keys that parseQueryParams() handles.
|
|
678
|
+
* Used by admin proxy as whitelist — adding a key here auto-forwards it. */
|
|
679
|
+
export const QUERY_PARAM_KEYS = [
|
|
680
|
+
'limit', 'offset', 'page', 'perPage',
|
|
681
|
+
'after', 'before',
|
|
682
|
+
'sort', 'filter', 'orFilter',
|
|
683
|
+
'fields', 'search',
|
|
684
|
+
] as const;
|
|
685
|
+
|
|
686
|
+
// ─── Parse Query Parameters ───
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Parse REST API query parameters into QueryOptions.
|
|
690
|
+
*/
|
|
691
|
+
export function parseQueryParams(params: Record<string, string>): QueryOptions {
|
|
692
|
+
const options: QueryOptions = {};
|
|
693
|
+
|
|
694
|
+
// Parse filter: JSON-encoded filter tuples
|
|
695
|
+
if (params.filter) {
|
|
696
|
+
try {
|
|
697
|
+
options.filters = JSON.parse(params.filter) as FilterTuple[];
|
|
698
|
+
} catch {
|
|
699
|
+
// Invalid filter — ignore
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Parse OR filter
|
|
704
|
+
if (params.orFilter) {
|
|
705
|
+
try {
|
|
706
|
+
const orFilters = JSON.parse(params.orFilter) as FilterTuple[];
|
|
707
|
+
if (orFilters.length <= 5) {
|
|
708
|
+
options.orFilters = orFilters;
|
|
709
|
+
}
|
|
710
|
+
} catch {
|
|
711
|
+
// Invalid orFilter — ignore
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Parse sort: "field:asc,field2:desc"
|
|
716
|
+
if (params.sort) {
|
|
717
|
+
options.sort = params.sort.split(',').map(s => {
|
|
718
|
+
const [field, dir] = s.split(':');
|
|
719
|
+
return { field, direction: (dir as SortDirection) || 'asc' };
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Parse pagination — validate numeric types to prevent SQLITE_MISMATCH
|
|
724
|
+
options.pagination = {};
|
|
725
|
+
if (params.limit) {
|
|
726
|
+
const n = parseInt(params.limit, 10);
|
|
727
|
+
if (isNaN(n)) throw new EdgeBaseError(400, 'Invalid limit parameter: must be a number');
|
|
728
|
+
if (n < 0) throw new EdgeBaseError(400, 'Invalid limit parameter: must be non-negative');
|
|
729
|
+
options.pagination.limit = Math.min(n, 1000);
|
|
730
|
+
}
|
|
731
|
+
if (params.offset) {
|
|
732
|
+
const n = parseInt(params.offset, 10);
|
|
733
|
+
if (isNaN(n)) throw new EdgeBaseError(400, 'Invalid offset parameter: must be a number');
|
|
734
|
+
if (n < 0) throw new EdgeBaseError(400, 'Invalid offset parameter: must be non-negative');
|
|
735
|
+
options.pagination.offset = n;
|
|
736
|
+
}
|
|
737
|
+
if (params.page) {
|
|
738
|
+
const n = parseInt(params.page, 10);
|
|
739
|
+
if (isNaN(n) || n < 1) throw new EdgeBaseError(400, 'Invalid page parameter: must be a positive number');
|
|
740
|
+
options.pagination.page = n;
|
|
741
|
+
}
|
|
742
|
+
if (params.perPage) {
|
|
743
|
+
const n = parseInt(params.perPage, 10);
|
|
744
|
+
if (isNaN(n)) throw new EdgeBaseError(400, 'Invalid perPage parameter: must be a number');
|
|
745
|
+
if (n < 0) throw new EdgeBaseError(400, 'Invalid perPage parameter: must be non-negative');
|
|
746
|
+
options.pagination.perPage = n;
|
|
747
|
+
}
|
|
748
|
+
if (params.after) options.pagination.after = params.after;
|
|
749
|
+
if (params.before) options.pagination.before = params.before;
|
|
750
|
+
|
|
751
|
+
// Parse fields: "field1,field2"
|
|
752
|
+
if (params.fields) {
|
|
753
|
+
options.fields = params.fields.split(',').map(f => f.trim());
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Parse search
|
|
757
|
+
if (params.search) {
|
|
758
|
+
options.search = params.search;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return options;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ─── Utility ───
|
|
765
|
+
|
|
766
|
+
function esc(name: string): string {
|
|
767
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
768
|
+
}
|