@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.
Files changed (309) hide show
  1. package/admin-build/.gitkeep +0 -0
  2. package/admin-build/_app/env.js +1 -0
  3. package/admin-build/_app/immutable/assets/0.Bm6cF078.css +1 -0
  4. package/admin-build/_app/immutable/assets/1.BfW3pUNa.css +1 -0
  5. package/admin-build/_app/immutable/assets/11.CVmQOewb.css +1 -0
  6. package/admin-build/_app/immutable/assets/12.B1EhbRZT.css +1 -0
  7. package/admin-build/_app/immutable/assets/13.BvwYeuwE.css +1 -0
  8. package/admin-build/_app/immutable/assets/14.CdVfcO0R.css +1 -0
  9. package/admin-build/_app/immutable/assets/15.2yeZ66b-.css +1 -0
  10. package/admin-build/_app/immutable/assets/17.BVg0JEVu.css +1 -0
  11. package/admin-build/_app/immutable/assets/18.Rwnl3x_i.css +1 -0
  12. package/admin-build/_app/immutable/assets/20.DsPWA9AV.css +1 -0
  13. package/admin-build/_app/immutable/assets/21.Dz2RJ56c.css +1 -0
  14. package/admin-build/_app/immutable/assets/22.DwNLk5Ai.css +1 -0
  15. package/admin-build/_app/immutable/assets/23.CFpu0gOO.css +1 -0
  16. package/admin-build/_app/immutable/assets/24.Cy5LBeoJ.css +1 -0
  17. package/admin-build/_app/immutable/assets/25.pUyLVf-h.css +1 -0
  18. package/admin-build/_app/immutable/assets/26.DBcGrlXa.css +1 -0
  19. package/admin-build/_app/immutable/assets/27.BswYyAJD.css +1 -0
  20. package/admin-build/_app/immutable/assets/28.B4ueB1Kf.css +1 -0
  21. package/admin-build/_app/immutable/assets/29.B-qU6PdF.css +1 -0
  22. package/admin-build/_app/immutable/assets/3.Dg81Pgmd.css +1 -0
  23. package/admin-build/_app/immutable/assets/30.CsdWum94.css +1 -0
  24. package/admin-build/_app/immutable/assets/31.U6OwIp50.css +1 -0
  25. package/admin-build/_app/immutable/assets/4.CyawCCux.css +1 -0
  26. package/admin-build/_app/immutable/assets/5.C0YO2HTk.css +1 -0
  27. package/admin-build/_app/immutable/assets/8.Br5jd6kD.css +1 -0
  28. package/admin-build/_app/immutable/assets/Badge.EMYLHBxE.css +1 -0
  29. package/admin-build/_app/immutable/assets/Button.DpzMRTjK.css +1 -0
  30. package/admin-build/_app/immutable/assets/ConfirmDialog.DAnaWRRk.css +1 -0
  31. package/admin-build/_app/immutable/assets/EmptyState.CwKsu57Y.css +1 -0
  32. package/admin-build/_app/immutable/assets/Input.BDUSenmU.css +1 -0
  33. package/admin-build/_app/immutable/assets/Modal.Dm5B0Xie.css +1 -0
  34. package/admin-build/_app/immutable/assets/PageShell.CmU-Xh-b.css +1 -0
  35. package/admin-build/_app/immutable/assets/SchemaFieldEditor.g4NsCdno.css +1 -0
  36. package/admin-build/_app/immutable/assets/Select.BW4Keufm.css +1 -0
  37. package/admin-build/_app/immutable/assets/Skeleton.KWUulTKJ.css +1 -0
  38. package/admin-build/_app/immutable/assets/Tabs.CniGYb67.css +1 -0
  39. package/admin-build/_app/immutable/assets/TimeChart.BTCDAvmT.css +1 -0
  40. package/admin-build/_app/immutable/assets/Toggle.Cy_K12OM.css +1 -0
  41. package/admin-build/_app/immutable/assets/TopList.ClFzmPlA.css +1 -0
  42. package/admin-build/_app/immutable/chunks/7B47DvSx.js +1 -0
  43. package/admin-build/_app/immutable/chunks/7f08Id8e.js +1 -0
  44. package/admin-build/_app/immutable/chunks/8wJeQ7LN.js +1 -0
  45. package/admin-build/_app/immutable/chunks/B-h2afW5.js +1 -0
  46. package/admin-build/_app/immutable/chunks/B8vJP3wz.js +1 -0
  47. package/admin-build/_app/immutable/chunks/BR_fL5Yv.js +1 -0
  48. package/admin-build/_app/immutable/chunks/BY92tFS2.js +1 -0
  49. package/admin-build/_app/immutable/chunks/BcR-Rdj9.js +1 -0
  50. package/admin-build/_app/immutable/chunks/BdrwyZv8.js +1 -0
  51. package/admin-build/_app/immutable/chunks/Bh56EfQ_.js +1 -0
  52. package/admin-build/_app/immutable/chunks/BkrCkgYp.js +1 -0
  53. package/admin-build/_app/immutable/chunks/BmRjiP5k.js +1 -0
  54. package/admin-build/_app/immutable/chunks/BsokvhWC.js +1 -0
  55. package/admin-build/_app/immutable/chunks/C4D51vTW.js +1 -0
  56. package/admin-build/_app/immutable/chunks/C6puvcoR.js +2 -0
  57. package/admin-build/_app/immutable/chunks/CCKNu7m7.js +1 -0
  58. package/admin-build/_app/immutable/chunks/CWj6FrbW.js +1 -0
  59. package/admin-build/_app/immutable/chunks/Ce-ngf4p.js +5 -0
  60. package/admin-build/_app/immutable/chunks/Cs0GwzJA.js +1 -0
  61. package/admin-build/_app/immutable/chunks/CwROoZK0.js +1 -0
  62. package/admin-build/_app/immutable/chunks/CxCPv_Ut.js +1 -0
  63. package/admin-build/_app/immutable/chunks/CxbRue-5.js +1 -0
  64. package/admin-build/_app/immutable/chunks/CyqB6g-D.js +1 -0
  65. package/admin-build/_app/immutable/chunks/D5h5A1cc.js +2 -0
  66. package/admin-build/_app/immutable/chunks/DnyL7Zq-.js +1 -0
  67. package/admin-build/_app/immutable/chunks/DoPXzH7F.js +1 -0
  68. package/admin-build/_app/immutable/chunks/DrQSgw-f.js +1 -0
  69. package/admin-build/_app/immutable/chunks/DttM2zNO.js +1 -0
  70. package/admin-build/_app/immutable/chunks/DuXuUBWN.js +1 -0
  71. package/admin-build/_app/immutable/chunks/MdeqaOQx.js +10 -0
  72. package/admin-build/_app/immutable/chunks/NuUjtcO2.js +1 -0
  73. package/admin-build/_app/immutable/chunks/Q2nPFxS6.js +1 -0
  74. package/admin-build/_app/immutable/chunks/R6arueIl.js +1 -0
  75. package/admin-build/_app/immutable/chunks/UUazaC_N.js +1 -0
  76. package/admin-build/_app/immutable/chunks/cOYbrQxx.js +1 -0
  77. package/admin-build/_app/immutable/chunks/eFQHTGwA.js +1 -0
  78. package/admin-build/_app/immutable/chunks/ehbppgYb.js +1 -0
  79. package/admin-build/_app/immutable/chunks/glwixJlP.js +1 -0
  80. package/admin-build/_app/immutable/chunks/vApWTCBs.js +1 -0
  81. package/admin-build/_app/immutable/chunks/w89G9Xpi.js +1 -0
  82. package/admin-build/_app/immutable/chunks/wJsUhbfZ.js +1 -0
  83. package/admin-build/_app/immutable/chunks/zfauFM8P.js +1 -0
  84. package/admin-build/_app/immutable/entry/app.CcO-Uos3.js +2 -0
  85. package/admin-build/_app/immutable/entry/start.COebYq3I.js +1 -0
  86. package/admin-build/_app/immutable/nodes/0.CjtHKU-6.js +1 -0
  87. package/admin-build/_app/immutable/nodes/1.DEisjlM0.js +1 -0
  88. package/admin-build/_app/immutable/nodes/10.CvhdyWVB.js +1 -0
  89. package/admin-build/_app/immutable/nodes/11.DjHqcOvy.js +1 -0
  90. package/admin-build/_app/immutable/nodes/12.mQLz4Mj_.js +1 -0
  91. package/admin-build/_app/immutable/nodes/13.CBonZZyP.js +110 -0
  92. package/admin-build/_app/immutable/nodes/14.d-oiZL0j.js +3 -0
  93. package/admin-build/_app/immutable/nodes/15.CKPQsUYF.js +1 -0
  94. package/admin-build/_app/immutable/nodes/16.wPzAPQGx.js +1 -0
  95. package/admin-build/_app/immutable/nodes/17.DayhKyEZ.js +1 -0
  96. package/admin-build/_app/immutable/nodes/18.DKwS0Ir0.js +1 -0
  97. package/admin-build/_app/immutable/nodes/19.wPzAPQGx.js +1 -0
  98. package/admin-build/_app/immutable/nodes/2.BKoKrw1i.js +1 -0
  99. package/admin-build/_app/immutable/nodes/20.BvIkkkrW.js +1 -0
  100. package/admin-build/_app/immutable/nodes/21.DMaFhdHk.js +128 -0
  101. package/admin-build/_app/immutable/nodes/22.3xdgwuK1.js +1 -0
  102. package/admin-build/_app/immutable/nodes/23.8Bvgjbsl.js +112 -0
  103. package/admin-build/_app/immutable/nodes/24.DzSSzRhG.js +2 -0
  104. package/admin-build/_app/immutable/nodes/25.9KKYBnAE.js +2 -0
  105. package/admin-build/_app/immutable/nodes/26.Bhn9dfhY.js +1 -0
  106. package/admin-build/_app/immutable/nodes/27.kRLiC24G.js +1 -0
  107. package/admin-build/_app/immutable/nodes/28.BVIN1-7N.js +1 -0
  108. package/admin-build/_app/immutable/nodes/29.3yabZWj4.js +1 -0
  109. package/admin-build/_app/immutable/nodes/3.BFtSOkX7.js +2 -0
  110. package/admin-build/_app/immutable/nodes/30.CyCQlwaP.js +1 -0
  111. package/admin-build/_app/immutable/nodes/31.C4LDXjES.js +1 -0
  112. package/admin-build/_app/immutable/nodes/4.CvbiMlCa.js +1 -0
  113. package/admin-build/_app/immutable/nodes/5.C6BLv2eM.js +1 -0
  114. package/admin-build/_app/immutable/nodes/6.BcXvfl2P.js +1 -0
  115. package/admin-build/_app/immutable/nodes/7.CIuqhPiK.js +1 -0
  116. package/admin-build/_app/immutable/nodes/8.BQOR_JfO.js +1 -0
  117. package/admin-build/_app/immutable/nodes/9.NZqXQxPy.js +1 -0
  118. package/admin-build/_app/version.json +1 -0
  119. package/admin-build/favicon.svg +26 -0
  120. package/admin-build/index.html +45 -0
  121. package/openapi.json +19543 -0
  122. package/package.json +66 -0
  123. package/src/__tests__/admin-assets.test.ts +55 -0
  124. package/src/__tests__/admin-data-routes.test.ts +488 -0
  125. package/src/__tests__/admin-db-target.test.ts +103 -0
  126. package/src/__tests__/admin-routing.test.ts +31 -0
  127. package/src/__tests__/admin-user-management.test.ts +311 -0
  128. package/src/__tests__/analytics-query.test.ts +75 -0
  129. package/src/__tests__/auth-d1.test.ts +749 -0
  130. package/src/__tests__/auth-db-adapter.test.ts +73 -0
  131. package/src/__tests__/auth-jwt.test.ts +440 -0
  132. package/src/__tests__/auth-oauth.test.ts +389 -0
  133. package/src/__tests__/auth-password.test.ts +367 -0
  134. package/src/__tests__/auth-redirect.test.ts +87 -0
  135. package/src/__tests__/backup-restore.test.ts +711 -0
  136. package/src/__tests__/broadcast.test.ts +128 -0
  137. package/src/__tests__/cli.test.ts +178 -0
  138. package/src/__tests__/cloudflare-realtime.test.ts +113 -0
  139. package/src/__tests__/config.test.ts +469 -0
  140. package/src/__tests__/cors.test.ts +154 -0
  141. package/src/__tests__/cron.test.ts +302 -0
  142. package/src/__tests__/d1-handler.test.ts +402 -0
  143. package/src/__tests__/d1-sql.test.ts +120 -0
  144. package/src/__tests__/database-live-config.test.ts +42 -0
  145. package/src/__tests__/database-live-emitter.test.ts +56 -0
  146. package/src/__tests__/database-live-filters.test.ts +63 -0
  147. package/src/__tests__/database-live-route.test.ts +113 -0
  148. package/src/__tests__/db-sql.test.ts +163 -0
  149. package/src/__tests__/do-lifecycle.test.ts +263 -0
  150. package/src/__tests__/do-router.test.ts +729 -0
  151. package/src/__tests__/email-provider.test.ts +128 -0
  152. package/src/__tests__/email-templates.test.ts +528 -0
  153. package/src/__tests__/error-format.test.ts +250 -0
  154. package/src/__tests__/field-ops.test.ts +242 -0
  155. package/src/__tests__/functions-context.test.ts +334 -0
  156. package/src/__tests__/functions-d1-proxy.test.ts +229 -0
  157. package/src/__tests__/functions-registry-runtime-config.test.ts +17 -0
  158. package/src/__tests__/functions-route.test.ts +139 -0
  159. package/src/__tests__/internal-request.test.ts +77 -0
  160. package/src/__tests__/log-writer.test.ts +44 -0
  161. package/src/__tests__/logger.test.ts +58 -0
  162. package/src/__tests__/meta-admin-proxy.test.ts +48 -0
  163. package/src/__tests__/meta-export-coverage.test.ts +191 -0
  164. package/src/__tests__/meta-route-registration.test.ts +47 -0
  165. package/src/__tests__/namespace-dump.test.ts +28 -0
  166. package/src/__tests__/oauth-providers.test.ts +337 -0
  167. package/src/__tests__/openapi-coverage.test.ts +144 -0
  168. package/src/__tests__/pagination.test.ts +59 -0
  169. package/src/__tests__/password-policy.test.ts +191 -0
  170. package/src/__tests__/plugin-migrations.test.ts +379 -0
  171. package/src/__tests__/postgres-batch-compat.test.ts +133 -0
  172. package/src/__tests__/postgres-dialect.test.ts +328 -0
  173. package/src/__tests__/postgres-executor.test.ts +79 -0
  174. package/src/__tests__/postgres-field-ops-compat.test.ts +222 -0
  175. package/src/__tests__/postgres-schema-init.test.ts +105 -0
  176. package/src/__tests__/postgres-table-utils.test.ts +107 -0
  177. package/src/__tests__/presence.test.ts +199 -0
  178. package/src/__tests__/provider.test.ts +550 -0
  179. package/src/__tests__/public-user-profile.test.ts +339 -0
  180. package/src/__tests__/push-handlers.test.ts +179 -0
  181. package/src/__tests__/push-provider.test.ts +80 -0
  182. package/src/__tests__/push-token.test.ts +418 -0
  183. package/src/__tests__/query.test.ts +771 -0
  184. package/src/__tests__/rate-limit.test.ts +260 -0
  185. package/src/__tests__/room-access-policy.test.ts +101 -0
  186. package/src/__tests__/room-handler-context.test.ts +130 -0
  187. package/src/__tests__/room-monitoring.test.ts +138 -0
  188. package/src/__tests__/room-runtime-routing.test.ts +222 -0
  189. package/src/__tests__/room.test.ts +254 -0
  190. package/src/__tests__/route-parser.test.ts +490 -0
  191. package/src/__tests__/rules.test.ts +234 -0
  192. package/src/__tests__/runtime-surface-accounting.test.ts +120 -0
  193. package/src/__tests__/scheduled.test.ts +80 -0
  194. package/src/__tests__/schema.test.ts +1273 -0
  195. package/src/__tests__/security-hardening.test.ts +312 -0
  196. package/src/__tests__/server.unit.test.ts +333 -0
  197. package/src/__tests__/service-key-db-proxy.test.ts +650 -0
  198. package/src/__tests__/service-key-provider-bypass.test.ts +138 -0
  199. package/src/__tests__/service-key.test.ts +757 -0
  200. package/src/__tests__/smoke-skip-report.test.ts +72 -0
  201. package/src/__tests__/sms-provider.test.ts +39 -0
  202. package/src/__tests__/sql-route.test.ts +218 -0
  203. package/src/__tests__/storage-hook-context.test.ts +115 -0
  204. package/src/__tests__/totp.test.ts +200 -0
  205. package/src/__tests__/uuid.test.ts +144 -0
  206. package/src/__tests__/validation.test.ts +773 -0
  207. package/src/__tests__/websocket-pending.test.ts +163 -0
  208. package/src/_functions-registry.ts +51 -0
  209. package/src/bench-entry.ts +9 -0
  210. package/src/cloudflare-test.d.ts +1 -0
  211. package/src/durable-objects/auth-do.ts +49 -0
  212. package/src/durable-objects/database-do.ts +2240 -0
  213. package/src/durable-objects/database-live-do.ts +949 -0
  214. package/src/durable-objects/logs-do.ts +1200 -0
  215. package/src/durable-objects/room-runtime-base.ts +1604 -0
  216. package/src/durable-objects/rooms-do.ts +2191 -0
  217. package/src/generated-config.ts +6 -0
  218. package/src/index.ts +382 -0
  219. package/src/lib/admin-assets.ts +54 -0
  220. package/src/lib/admin-db-target.ts +301 -0
  221. package/src/lib/admin-routing.ts +35 -0
  222. package/src/lib/admin-user-management.ts +464 -0
  223. package/src/lib/analytics-adapter.ts +103 -0
  224. package/src/lib/analytics-query.ts +579 -0
  225. package/src/lib/auth-d1-service.ts +1193 -0
  226. package/src/lib/auth-d1.ts +1056 -0
  227. package/src/lib/auth-db-adapter.ts +289 -0
  228. package/src/lib/auth-redirect.ts +116 -0
  229. package/src/lib/cidr.ts +115 -0
  230. package/src/lib/client-ip.ts +51 -0
  231. package/src/lib/cloudflare-realtime.ts +251 -0
  232. package/src/lib/control-db.ts +36 -0
  233. package/src/lib/cron.ts +163 -0
  234. package/src/lib/d1-handler.ts +1425 -0
  235. package/src/lib/d1-schema-init.ts +255 -0
  236. package/src/lib/d1-sql.ts +33 -0
  237. package/src/lib/database-live-config.ts +24 -0
  238. package/src/lib/database-live-emitter.ts +111 -0
  239. package/src/lib/db-sql.ts +66 -0
  240. package/src/lib/do-retry.ts +36 -0
  241. package/src/lib/do-router.ts +270 -0
  242. package/src/lib/do-sql.ts +73 -0
  243. package/src/lib/email-provider.ts +379 -0
  244. package/src/lib/email-templates.ts +285 -0
  245. package/src/lib/email-translations.ts +422 -0
  246. package/src/lib/errors.ts +151 -0
  247. package/src/lib/functions.ts +2091 -0
  248. package/src/lib/hono.ts +56 -0
  249. package/src/lib/internal-request.ts +56 -0
  250. package/src/lib/jwt.ts +354 -0
  251. package/src/lib/log-writer.ts +272 -0
  252. package/src/lib/namespace-dump.ts +125 -0
  253. package/src/lib/oauth-providers.ts +1225 -0
  254. package/src/lib/op-parser.ts +99 -0
  255. package/src/lib/openapi.ts +146 -0
  256. package/src/lib/pagination.ts +19 -0
  257. package/src/lib/password-policy.ts +102 -0
  258. package/src/lib/password.ts +145 -0
  259. package/src/lib/plugin-migrations.ts +612 -0
  260. package/src/lib/postgres-executor.ts +203 -0
  261. package/src/lib/postgres-handler.ts +1102 -0
  262. package/src/lib/postgres-schema-init.ts +341 -0
  263. package/src/lib/postgres-table-utils.ts +87 -0
  264. package/src/lib/public-user-profile.ts +187 -0
  265. package/src/lib/push-provider.ts +409 -0
  266. package/src/lib/push-token.ts +294 -0
  267. package/src/lib/query-engine.ts +768 -0
  268. package/src/lib/room-monitoring.ts +97 -0
  269. package/src/lib/room-runtime.ts +14 -0
  270. package/src/lib/route-parser.ts +434 -0
  271. package/src/lib/schema.ts +538 -0
  272. package/src/lib/schemas.ts +152 -0
  273. package/src/lib/service-key.ts +419 -0
  274. package/src/lib/sms-provider.ts +230 -0
  275. package/src/lib/startup-config.ts +99 -0
  276. package/src/lib/totp.ts +242 -0
  277. package/src/lib/uuid.ts +87 -0
  278. package/src/lib/validation.ts +205 -0
  279. package/src/lib/version.ts +2 -0
  280. package/src/lib/websocket-pending.ts +40 -0
  281. package/src/middleware/auth.ts +169 -0
  282. package/src/middleware/captcha-verify.ts +217 -0
  283. package/src/middleware/cors.ts +159 -0
  284. package/src/middleware/error-handler.ts +54 -0
  285. package/src/middleware/internal-guard.ts +26 -0
  286. package/src/middleware/logger.ts +126 -0
  287. package/src/middleware/rate-limit.ts +283 -0
  288. package/src/middleware/rules.ts +475 -0
  289. package/src/routes/admin-auth.ts +447 -0
  290. package/src/routes/admin.ts +3501 -0
  291. package/src/routes/analytics-api.ts +290 -0
  292. package/src/routes/auth.ts +4222 -0
  293. package/src/routes/backup.ts +1466 -0
  294. package/src/routes/config.ts +53 -0
  295. package/src/routes/d1.ts +109 -0
  296. package/src/routes/database-live.ts +281 -0
  297. package/src/routes/functions.ts +155 -0
  298. package/src/routes/health.ts +32 -0
  299. package/src/routes/kv.ts +167 -0
  300. package/src/routes/oauth.ts +1055 -0
  301. package/src/routes/push.ts +1465 -0
  302. package/src/routes/room.ts +639 -0
  303. package/src/routes/schema-endpoint.ts +76 -0
  304. package/src/routes/sql.ts +176 -0
  305. package/src/routes/storage.ts +1674 -0
  306. package/src/routes/tables.ts +699 -0
  307. package/src/routes/users.ts +21 -0
  308. package/src/routes/vectorize.ts +372 -0
  309. 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
+ }