@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,771 @@
1
+ /**
2
+ * 서버 단위 테스트 — lib/query-engine.ts
3
+ * 1-04 query.test.ts — 120개
4
+ *
5
+ * 실행: cd packages/server && npx vitest run src/__tests__/query.test.ts
6
+ *
7
+ * 테스트 대상:
8
+ * buildListQuery / buildCountQuery / buildGetQuery / buildSearchQuery
9
+ * parseQueryParams
10
+ * filter operators: == != > < >= <= contains in not in
11
+ * OR filter (orFilters, max 5)
12
+ * sort / limit / offset / cursor after/before
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest';
16
+ import {
17
+ buildListQuery,
18
+ buildCountQuery,
19
+ buildGetQuery,
20
+ buildSearchQuery,
21
+ buildSubstringSearchQuery,
22
+ parseQueryParams,
23
+ QUERY_PARAM_KEYS,
24
+ } from '../lib/query-engine.js';
25
+
26
+ // ─── A. buildGetQuery ─────────────────────────────────────────────────────────
27
+
28
+ describe('buildGetQuery', () => {
29
+ it('generates SELECT * from table WHERE id = ?', () => {
30
+ const { sql, params } = buildGetQuery('posts', 'post-123');
31
+ expect(sql).toContain('SELECT *');
32
+ expect(sql).toContain('"posts"');
33
+ expect(sql).toContain('"id" = ?');
34
+ expect(params).toEqual(['post-123']);
35
+ });
36
+
37
+ it('with fields projection', () => {
38
+ const { sql } = buildGetQuery('posts', 'id-1', ['id', 'title']);
39
+ expect(sql).toContain('"id"');
40
+ expect(sql).toContain('"title"');
41
+ expect(sql).not.toContain('SELECT *');
42
+ });
43
+
44
+ it('empty fields → SELECT *', () => {
45
+ const { sql } = buildGetQuery('posts', 'x', []);
46
+ expect(sql).toContain('SELECT *');
47
+ });
48
+ });
49
+
50
+ // ─── B. buildCountQuery ───────────────────────────────────────────────────────
51
+
52
+ describe('buildCountQuery', () => {
53
+ it('no filters → COUNT(*)', () => {
54
+ const { sql, params } = buildCountQuery('posts');
55
+ expect(sql).toContain('SELECT COUNT(*) as total');
56
+ expect(sql).toContain('"posts"');
57
+ expect(params).toEqual([]);
58
+ });
59
+
60
+ it('with filter adds WHERE', () => {
61
+ const { sql, params } = buildCountQuery('posts', [['status', '==', 'published']]);
62
+ expect(sql).toContain('WHERE');
63
+ expect(params).toContain('published');
64
+ });
65
+
66
+ it('with OR filter', () => {
67
+ const { sql } = buildCountQuery('posts', undefined, [['status', '==', 'draft'], ['status', '==', 'published']]);
68
+ expect(sql).toContain('OR');
69
+ });
70
+ });
71
+
72
+ // ─── C. buildListQuery — no filters ──────────────────────────────────────────
73
+
74
+ describe('buildListQuery — no filters', () => {
75
+ it('basic query', () => {
76
+ const { sql, params: _params } = buildListQuery('posts', {});
77
+ expect(sql).toContain('SELECT "posts".* FROM "posts"');
78
+ expect(sql).toContain('ORDER BY');
79
+ expect(sql).toContain('LIMIT');
80
+ });
81
+
82
+ it('default limit is 20', () => {
83
+ const { params } = buildListQuery('posts', {});
84
+ // params should contain 20 as default limit
85
+ expect(params).toContain(20);
86
+ });
87
+
88
+ it('generates countSql for non-cursor pagination', () => {
89
+ const { countSql } = buildListQuery('posts', {});
90
+ expect(countSql).toContain('SELECT COUNT(*)');
91
+ });
92
+
93
+ it('no countSql for cursor (after) pagination', () => {
94
+ const { countSql } = buildListQuery('posts', { pagination: { after: 'cursor-abc' } });
95
+ expect(countSql).toBeUndefined();
96
+ });
97
+ });
98
+
99
+ // ─── D. buildListQuery — filter operators ─────────────────────────────────────
100
+
101
+ describe('buildListQuery — filter operators', () => {
102
+ it('== operator', () => {
103
+ const { sql, params } = buildListQuery('posts', { filters: [['status', '==', 'published']] });
104
+ expect(sql).toContain('= ?');
105
+ expect(params).toContain('published');
106
+ });
107
+
108
+ it('!= operator', () => {
109
+ const { sql } = buildListQuery('posts', { filters: [['status', '!=', 'deleted']] });
110
+ expect(sql).toContain('!= ?');
111
+ });
112
+
113
+ it('> operator', () => {
114
+ const { sql, params } = buildListQuery('posts', { filters: [['views', '>', 100]] });
115
+ expect(sql).toContain('> ?');
116
+ expect(params).toContain(100);
117
+ });
118
+
119
+ it('< operator', () => {
120
+ const { sql } = buildListQuery('posts', { filters: [['views', '<', 100]] });
121
+ expect(sql).toContain('< ?');
122
+ });
123
+
124
+ it('>= operator', () => {
125
+ const { sql } = buildListQuery('posts', { filters: [['age', '>=', 18]] });
126
+ expect(sql).toContain('>= ?');
127
+ });
128
+
129
+ it('<= operator', () => {
130
+ const { sql } = buildListQuery('posts', { filters: [['age', '<=', 65]] });
131
+ expect(sql).toContain('<= ?');
132
+ });
133
+
134
+ it('contains → INSTR(col, ?) > 0', () => {
135
+ const { sql, params } = buildListQuery('posts', { filters: [['title', 'contains', 'hello']] });
136
+ expect(sql).toContain('INSTR("title", ?) > 0');
137
+ expect(params).toContain('hello');
138
+ });
139
+
140
+ it('in → IN (?, ?, ?)', () => {
141
+ const { sql, params } = buildListQuery('posts', { filters: [['status', 'in', ['a', 'b', 'c']]] });
142
+ expect(sql).toContain('IN (?, ?, ?)');
143
+ expect(params).toContain('a');
144
+ expect(params).toContain('c');
145
+ });
146
+
147
+ it('not in → NOT IN', () => {
148
+ const { sql } = buildListQuery('posts', { filters: [['status', 'not in', ['deleted', 'spam']]] });
149
+ expect(sql).toContain('NOT IN');
150
+ });
151
+
152
+ it('in with empty array', () => {
153
+ const { sql } = buildListQuery('posts', { filters: [['status', 'in', []]] });
154
+ // Empty IN() should generate "IN ()" which might return 0 results
155
+ expect(sql).toContain('IN ()');
156
+ });
157
+
158
+ it('multiple filters → AND', () => {
159
+ const { sql } = buildListQuery('posts', {
160
+ filters: [['status', '==', 'pub'], ['views', '>', 100]],
161
+ });
162
+ expect(sql).toContain('AND');
163
+ });
164
+
165
+ it('unknown operator → throws', () => {
166
+ expect(() =>
167
+ buildListQuery('posts', { filters: [['status', 'between' as any, 'a']] }),
168
+ ).toThrow('Unsupported filter operator');
169
+ });
170
+ });
171
+
172
+ // ─── E. buildListQuery — OR filters ──────────────────────────────────────────
173
+
174
+ describe('buildListQuery — OR filters', () => {
175
+ it('OR filters joined with OR', () => {
176
+ const { sql } = buildListQuery('posts', {
177
+ orFilters: [['status', '==', 'draft'], ['status', '==', 'published']],
178
+ });
179
+ expect(sql).toContain('OR');
180
+ });
181
+
182
+ it('AND + OR combined', () => {
183
+ const { sql } = buildListQuery('posts', {
184
+ filters: [['category', '==', 'tech']],
185
+ orFilters: [['status', '==', 'draft'], ['status', '==', 'published']],
186
+ });
187
+ expect(sql).toContain('AND');
188
+ expect(sql).toContain('OR');
189
+ });
190
+
191
+ it('OR filters grouped with parentheses', () => {
192
+ const { sql } = buildListQuery('posts', {
193
+ orFilters: [['a', '==', '1'], ['b', '==', '2']],
194
+ });
195
+ expect(sql).toContain('(');
196
+ expect(sql).toContain(')');
197
+ });
198
+
199
+ it('OR filter > 5 → throws', () => {
200
+ expect(() =>
201
+ buildListQuery('posts', {
202
+ orFilters: [
203
+ ['a', '==', '1'], ['b', '==', '2'], ['c', '==', '3'],
204
+ ['d', '==', '4'], ['e', '==', '5'], ['f', '==', '6'],
205
+ ],
206
+ }),
207
+ ).toThrow('OR_FILTER_LIMIT_EXCEEDED');
208
+ });
209
+
210
+ it('OR filter exactly 5 → OK', () => {
211
+ expect(() =>
212
+ buildListQuery('posts', {
213
+ orFilters: [
214
+ ['a', '==', '1'], ['b', '==', '2'], ['c', '==', '3'],
215
+ ['d', '==', '4'], ['e', '==', '5'],
216
+ ],
217
+ }),
218
+ ).not.toThrow();
219
+ });
220
+ });
221
+
222
+ // ─── F. buildListQuery — sort ─────────────────────────────────────────────────
223
+
224
+ describe('buildListQuery — sort', () => {
225
+ it('sort ASC', () => {
226
+ const { sql } = buildListQuery('posts', { sort: [{ field: 'title', direction: 'asc' }] });
227
+ expect(sql).toContain('"title" ASC');
228
+ });
229
+
230
+ it('sort DESC', () => {
231
+ const { sql } = buildListQuery('posts', { sort: [{ field: 'createdAt', direction: 'desc' }] });
232
+ expect(sql).toContain('"createdAt" DESC');
233
+ });
234
+
235
+ it('multiple sorts', () => {
236
+ const { sql } = buildListQuery('posts', {
237
+ sort: [{ field: 'category', direction: 'asc' }, { field: 'createdAt', direction: 'desc' }],
238
+ });
239
+ expect(sql).toContain('"category" ASC');
240
+ expect(sql).toContain('"createdAt" DESC');
241
+ });
242
+
243
+ it('no sort → default ORDER BY id ASC', () => {
244
+ const { sql } = buildListQuery('posts', {});
245
+ expect(sql).toContain('"id" ASC');
246
+ });
247
+
248
+ it('before cursor → default ORDER BY id DESC', () => {
249
+ const { sql } = buildListQuery('posts', { pagination: { before: 'cursor-x' } });
250
+ expect(sql).toContain('"id" DESC');
251
+ });
252
+ });
253
+
254
+ // ─── G. buildListQuery — pagination ──────────────────────────────────────────
255
+
256
+ describe('buildListQuery — pagination', () => {
257
+ it('limit', () => {
258
+ const { sql, params } = buildListQuery('posts', { pagination: { limit: 10 } });
259
+ expect(sql).toContain('LIMIT ?');
260
+ expect(params).toContain(10);
261
+ });
262
+
263
+ it('limit=1', () => {
264
+ const { params } = buildListQuery('posts', { pagination: { limit: 1 } });
265
+ expect(params).toContain(1);
266
+ });
267
+
268
+ it('offset', () => {
269
+ const { sql, params } = buildListQuery('posts', { pagination: { limit: 10, offset: 20 } });
270
+ expect(sql).toContain('OFFSET ?');
271
+ expect(params).toContain(20);
272
+ });
273
+
274
+ it('page=2, perPage=10 → offset=10', () => {
275
+ const { params } = buildListQuery('posts', { pagination: { page: 2, perPage: 10 } });
276
+ // offset = (page-1) * perPage = 10
277
+ expect(params).toContain(10);
278
+ });
279
+
280
+ it('cursor after → WHERE id > ?', () => {
281
+ const { sql, params } = buildListQuery('posts', { pagination: { after: 'cursor-abc' } });
282
+ expect(sql).toContain('"id" > ?');
283
+ expect(params).toContain('cursor-abc');
284
+ });
285
+
286
+ it('cursor before → WHERE id < ?', () => {
287
+ const { sql, params } = buildListQuery('posts', { pagination: { before: 'cursor-xyz' } });
288
+ expect(sql).toContain('"id" < ?');
289
+ expect(params).toContain('cursor-xyz');
290
+ });
291
+
292
+ it('after cursor + filter combined', () => {
293
+ const { sql } = buildListQuery('posts', {
294
+ filters: [['status', '==', 'pub']],
295
+ pagination: { after: 'cursor-1' },
296
+ });
297
+ expect(sql).toContain('"status" = ?');
298
+ expect(sql).toContain('"id" > ?');
299
+ });
300
+ });
301
+
302
+ // ─── H. buildSearchQuery ─────────────────────────────────────────────────────
303
+
304
+ describe('buildSearchQuery', () => {
305
+ it('generates FTS5 MATCH query', () => {
306
+ const { sql, params } = buildSearchQuery('posts', 'hello world');
307
+ expect(sql).toContain('MATCH ?');
308
+ expect(params[0]).toBe('"hello"* "world"*');
309
+ });
310
+
311
+ it('uses {tableName}_fts table', () => {
312
+ const { sql } = buildSearchQuery('posts', 'query');
313
+ expect(sql).toContain('"posts_fts"');
314
+ });
315
+
316
+ it('default limit 20, offset 0', () => {
317
+ const { params } = buildSearchQuery('posts', 'q');
318
+ expect(params[1]).toBe(20);
319
+ expect(params[2]).toBeUndefined();
320
+ });
321
+
322
+ it('custom limit and offset', () => {
323
+ const { params } = buildSearchQuery('posts', 'q', { limit: 5, offset: 10 });
324
+ expect(params[1]).toBe(5);
325
+ expect(params[2]).toBe(10);
326
+ });
327
+
328
+ it('with ftsFields generates highlight columns', () => {
329
+ const { sql } = buildSearchQuery('posts', 'q', { ftsFields: ['title', 'body'] });
330
+ expect(sql).toContain('highlight(');
331
+ expect(sql).toContain('title_highlighted');
332
+ expect(sql).toContain('body_highlighted');
333
+ });
334
+
335
+ it('custom highlightPre/Post', () => {
336
+ const { sql } = buildSearchQuery('posts', 'q', {
337
+ ftsFields: ['title'],
338
+ highlightPre: '<b>',
339
+ highlightPost: '</b>',
340
+ });
341
+ expect(sql).toContain('<b>');
342
+ expect(sql).toContain('</b>');
343
+ });
344
+
345
+ it('ORDER BY rank', () => {
346
+ const { sql } = buildSearchQuery('posts', 'q');
347
+ expect(sql).toContain('rank');
348
+ });
349
+
350
+ it('JOIN on rowid', () => {
351
+ const { sql } = buildSearchQuery('posts', 'q');
352
+ expect(sql).toContain('rowid');
353
+ });
354
+
355
+ it('supports filters, sorting, and count queries', () => {
356
+ const { sql, params, countSql, countParams } = buildSearchQuery('posts', 'q', {
357
+ filters: [['status', '==', 'published']],
358
+ sort: [{ field: 'createdAt', direction: 'desc' }],
359
+ pagination: { limit: 5, offset: 10 },
360
+ });
361
+ expect(sql).toContain('"status" = ?');
362
+ expect(sql).toContain('ORDER BY "createdAt" DESC, "id" ASC');
363
+ expect(params).toEqual(['"q"*', 'published', 5, 10]);
364
+ expect(countSql).toContain('"status" = ?');
365
+ expect(countParams).toEqual(['"q"*', 'published']);
366
+ });
367
+ });
368
+
369
+ describe('buildSubstringSearchQuery', () => {
370
+ it('supports filters and sorting', () => {
371
+ const { sql, params, countSql, countParams } = buildSubstringSearchQuery('posts', 'needle', {
372
+ fields: ['title'],
373
+ filters: [['status', '==', 'draft']],
374
+ sort: [{ field: 'title', direction: 'desc' }],
375
+ pagination: { limit: 2, offset: 1 },
376
+ });
377
+ expect(sql).toContain('instr(lower(CAST("title" AS TEXT)), lower(?)) > 0');
378
+ expect(sql).toContain('"status" = ?');
379
+ expect(sql).toContain('ORDER BY "title" DESC, "id" ASC');
380
+ expect(params).toEqual(['needle', 'draft', 2, 1]);
381
+ expect(countSql).toContain('"status" = ?');
382
+ expect(countParams).toEqual(['needle', 'draft']);
383
+ });
384
+ });
385
+
386
+ // ─── I. parseQueryParams ──────────────────────────────────────────────────────
387
+
388
+ describe('parseQueryParams', () => {
389
+ it('parses filter JSON', () => {
390
+ const opts = parseQueryParams({ filter: '[["status","==","pub"]]' });
391
+ expect(opts.filters).toEqual([['status', '==', 'pub']]);
392
+ });
393
+
394
+ it('invalid filter JSON → ignores', () => {
395
+ const opts = parseQueryParams({ filter: 'invalid-json' });
396
+ expect(opts.filters).toBeUndefined();
397
+ });
398
+
399
+ it('parses sort: field:asc', () => {
400
+ const opts = parseQueryParams({ sort: 'title:asc' });
401
+ expect(opts.sort).toEqual([{ field: 'title', direction: 'asc' }]);
402
+ });
403
+
404
+ it('parses sort: multiple fields', () => {
405
+ const opts = parseQueryParams({ sort: 'category:asc,createdAt:desc' });
406
+ expect(opts.sort).toHaveLength(2);
407
+ expect(opts.sort![1].direction).toBe('desc');
408
+ });
409
+
410
+ it('parses limit', () => {
411
+ const opts = parseQueryParams({ limit: '10' });
412
+ expect(opts.pagination?.limit).toBe(10);
413
+ });
414
+
415
+ it('parses offset', () => {
416
+ const opts = parseQueryParams({ offset: '20' });
417
+ expect(opts.pagination?.offset).toBe(20);
418
+ });
419
+
420
+ it('parses page', () => {
421
+ const opts = parseQueryParams({ page: '3' });
422
+ expect(opts.pagination?.page).toBe(3);
423
+ });
424
+
425
+ it('parses after cursor', () => {
426
+ const opts = parseQueryParams({ after: 'cursor-abc' });
427
+ expect(opts.pagination?.after).toBe('cursor-abc');
428
+ });
429
+
430
+ it('parses before cursor', () => {
431
+ const opts = parseQueryParams({ before: 'cursor-xyz' });
432
+ expect(opts.pagination?.before).toBe('cursor-xyz');
433
+ });
434
+
435
+ it('parses fields: comma-separated', () => {
436
+ const opts = parseQueryParams({ fields: 'id,title,status' });
437
+ expect(opts.fields).toEqual(['id', 'title', 'status']);
438
+ });
439
+
440
+ it('parses search', () => {
441
+ const opts = parseQueryParams({ search: 'hello' });
442
+ expect(opts.search).toBe('hello');
443
+ });
444
+
445
+ it('empty params → empty options', () => {
446
+ const opts = parseQueryParams({});
447
+ expect(opts.filters).toBeUndefined();
448
+ expect(opts.sort).toBeUndefined();
449
+ });
450
+
451
+ it('parses orFilter JSON', () => {
452
+ const opts = parseQueryParams({ orFilter: '[["a","==","1"],["b","==","2"]]' });
453
+ expect(opts.orFilters).toHaveLength(2);
454
+ });
455
+
456
+ it('invalid orFilter JSON → ignores', () => {
457
+ const opts = parseQueryParams({ orFilter: 'bad-json' });
458
+ expect(opts.orFilters).toBeUndefined();
459
+ });
460
+
461
+ it('non-numeric limit → throws 400', () => {
462
+ expect(() => parseQueryParams({ limit: 'abc' })).toThrow('Invalid limit');
463
+ });
464
+
465
+ it('negative limit → throws 400', () => {
466
+ expect(() => parseQueryParams({ limit: '-5' })).toThrow('Invalid limit');
467
+ });
468
+
469
+ it('non-numeric offset → throws 400', () => {
470
+ expect(() => parseQueryParams({ offset: 'xyz' })).toThrow('Invalid offset');
471
+ });
472
+
473
+ it('negative offset → throws 400', () => {
474
+ expect(() => parseQueryParams({ offset: '-1' })).toThrow('Invalid offset');
475
+ });
476
+
477
+ it('non-numeric page → throws 400', () => {
478
+ expect(() => parseQueryParams({ page: 'abc' })).toThrow('Invalid page');
479
+ });
480
+
481
+ it('page=0 → throws 400 (must be positive)', () => {
482
+ expect(() => parseQueryParams({ page: '0' })).toThrow('Invalid page');
483
+ });
484
+
485
+ it('non-numeric perPage → throws 400', () => {
486
+ expect(() => parseQueryParams({ perPage: 'xyz' })).toThrow('Invalid perPage');
487
+ });
488
+
489
+ it('valid numeric strings → parsed correctly', () => {
490
+ const opts = parseQueryParams({ limit: '50', offset: '10', page: '2', perPage: '25' });
491
+ expect(opts.pagination?.limit).toBe(50);
492
+ expect(opts.pagination?.offset).toBe(10);
493
+ expect(opts.pagination?.page).toBe(2);
494
+ expect(opts.pagination?.perPage).toBe(25);
495
+ });
496
+
497
+ it('limit=0 → valid (explicit zero limit)', () => {
498
+ const opts = parseQueryParams({ limit: '0' });
499
+ expect(opts.pagination?.limit).toBe(0);
500
+ });
501
+
502
+ it('perPage with parseQueryParams → field-level validation', () => {
503
+ const opts = parseQueryParams({ perPage: '100' });
504
+ expect(opts.pagination?.perPage).toBe(100);
505
+ });
506
+ });
507
+
508
+ // ─── J. QUERY_PARAM_KEYS ↔ parseQueryParams sync ─────────────────────────────
509
+
510
+ describe('QUERY_PARAM_KEYS ↔ parseQueryParams sync', () => {
511
+ /** Test values that cause parseQueryParams to produce non-empty output for each key. */
512
+ const testValues: Record<string, string> = {
513
+ filter: JSON.stringify([['x', '==', 1]]),
514
+ orFilter: JSON.stringify([['x', '==', 1]]),
515
+ sort: 'x:asc',
516
+ limit: '10',
517
+ offset: '5',
518
+ page: '2',
519
+ perPage: '10',
520
+ after: 'cursor-abc',
521
+ before: 'cursor-xyz',
522
+ fields: 'x,y',
523
+ search: 'test',
524
+ };
525
+
526
+ const emptyOpts = JSON.stringify(parseQueryParams({}));
527
+
528
+ it('every QUERY_PARAM_KEYS key has effect in parseQueryParams', () => {
529
+ for (const key of QUERY_PARAM_KEYS) {
530
+ const opts = parseQueryParams({ [key]: testValues[key] ?? 'test' });
531
+ const hasEffect = JSON.stringify(opts) !== emptyOpts;
532
+ expect(hasEffect, `QUERY_PARAM_KEYS key '${key}' is ignored by parseQueryParams`).toBe(true);
533
+ }
534
+ });
535
+
536
+ it('every parseQueryParams key is in QUERY_PARAM_KEYS', () => {
537
+ for (const key of Object.keys(testValues)) {
538
+ expect(
539
+ (QUERY_PARAM_KEYS as readonly string[]).includes(key),
540
+ `parseQueryParams handles '${key}' but it is missing from QUERY_PARAM_KEYS`,
541
+ ).toBe(true);
542
+ }
543
+ });
544
+ });
545
+
546
+ // ─── L. 3-way sync: QUERY_PARAM_KEYS ↔ parseQueryParams ↔ Zod schema ──────
547
+
548
+ describe('3-way sync: QUERY_PARAM_KEYS ↔ Zod queryParamsSchema', () => {
549
+ // Dynamic import to avoid circular — schemas.ts is a new file
550
+ it('queryParamsSchema keys === QUERY_PARAM_KEYS', async () => {
551
+ const { queryParamsSchema } = await import('../lib/schemas.js');
552
+ const zodKeys = Object.keys(queryParamsSchema.shape).sort();
553
+ const engineKeys = [...QUERY_PARAM_KEYS].sort();
554
+ expect(zodKeys).toEqual(engineKeys);
555
+ });
556
+ });
557
+
558
+ // ─── M. Mutation-killing: exact params + countSql verification ──────────────
559
+
560
+ describe('buildListQuery — exact params verification', () => {
561
+ it('no filters → params contains only default limit', () => {
562
+ const { params } = buildListQuery('t', {});
563
+ expect(params).toEqual([20]);
564
+ });
565
+
566
+ it('no filters → countParams is empty array', () => {
567
+ const { countParams } = buildListQuery('t', {});
568
+ expect(countParams).toEqual([]);
569
+ });
570
+
571
+ it('no filters → countSql has no WHERE', () => {
572
+ const { countSql } = buildListQuery('t', {});
573
+ expect(countSql).not.toContain('WHERE');
574
+ });
575
+
576
+ it('with filter → countSql includes WHERE and countParams has value', () => {
577
+ const { countSql, countParams } = buildListQuery('t', {
578
+ filters: [['status', '==', 'pub']],
579
+ });
580
+ expect(countSql).toContain('WHERE');
581
+ expect(countParams).toEqual(['pub']);
582
+ });
583
+
584
+ it('!= operator → params include the filter value', () => {
585
+ const { params } = buildListQuery('t', {
586
+ filters: [['status', '!=', 'deleted']],
587
+ });
588
+ expect(params).toContain('deleted');
589
+ });
590
+
591
+ it('< operator → params include the filter value', () => {
592
+ const { params } = buildListQuery('t', {
593
+ filters: [['views', '<', 100]],
594
+ });
595
+ expect(params).toContain(100);
596
+ });
597
+
598
+ it('>= operator → params include the filter value', () => {
599
+ const { params } = buildListQuery('t', {
600
+ filters: [['age', '>=', 18]],
601
+ });
602
+ expect(params).toContain(18);
603
+ });
604
+
605
+ it('<= operator → params include the filter value', () => {
606
+ const { params } = buildListQuery('t', {
607
+ filters: [['age', '<=', 65]],
608
+ });
609
+ expect(params).toContain(65);
610
+ });
611
+
612
+ it('OR filters → params include all OR values', () => {
613
+ const { params } = buildListQuery('t', {
614
+ orFilters: [['a', '==', 'v1'], ['b', '==', 'v2']],
615
+ });
616
+ expect(params).toContain('v1');
617
+ expect(params).toContain('v2');
618
+ });
619
+
620
+ it('cursor after → no countSql (cursor pagination)', () => {
621
+ const { countSql } = buildListQuery('t', { pagination: { after: 'x' } });
622
+ expect(countSql).toBeUndefined();
623
+ });
624
+ });
625
+
626
+ describe('buildListQuery — SQL structure precision', () => {
627
+ it('no filters → SQL has no WHERE clause', () => {
628
+ const { sql } = buildListQuery('t', {});
629
+ expect(sql).not.toContain('WHERE');
630
+ });
631
+
632
+ it('with filter → SQL has WHERE clause', () => {
633
+ const { sql } = buildListQuery('t', { filters: [['a', '==', 1]] });
634
+ expect(sql).toContain('WHERE');
635
+ });
636
+
637
+ it('no sort → SQL still has ORDER BY', () => {
638
+ const { sql } = buildListQuery('t', {});
639
+ expect(sql).toContain('ORDER BY');
640
+ });
641
+
642
+ it('multiple fields → joined with comma-space', () => {
643
+ const { sql } = buildGetQuery('t', 'id-1', ['a', 'b', 'c']);
644
+ expect(sql).toContain('"a", "b", "c"');
645
+ });
646
+
647
+ it('multiple AND filters → conditions joined with AND', () => {
648
+ const { sql } = buildListQuery('t', {
649
+ filters: [['a', '==', 1], ['b', '==', 2]],
650
+ });
651
+ // Verify AND separator exists between conditions
652
+ expect(sql).toMatch(/"a" = \? AND "b" = \?/);
653
+ });
654
+
655
+ it('OR filters → conditions joined with OR inside parens', () => {
656
+ const { sql } = buildListQuery('t', {
657
+ orFilters: [['a', '==', 1], ['b', '==', 2]],
658
+ });
659
+ expect(sql).toMatch(/\("a" = \? OR "b" = \?\)/);
660
+ });
661
+
662
+ it('no filters → empty whereClause generates no WHERE text', () => {
663
+ const { sql } = buildCountQuery('t');
664
+ expect(sql).toBe('SELECT COUNT(*) as total FROM "t"');
665
+ });
666
+ });
667
+
668
+ describe('buildSearchQuery — mutation-killing', () => {
669
+ it('search term with double-quotes → escaped by doubling', () => {
670
+ const { params } = buildSearchQuery('t', 'say "hello"');
671
+ expect(params[0]).toBe('"say"* "hello"*');
672
+ });
673
+
674
+ it('single token search uses prefix matching for better DX', () => {
675
+ const { params } = buildSearchQuery('t', '준규');
676
+ expect(params[0]).toBe('"준규"*');
677
+ });
678
+
679
+ it('default highlight tags are <mark> and </mark>', () => {
680
+ const { sql } = buildSearchQuery('t', 'q', { ftsFields: ['title'] });
681
+ expect(sql).toContain("<mark>");
682
+ expect(sql).toContain("</mark>");
683
+ });
684
+
685
+ it('highlight quote escaping in highlight tags with single quotes', () => {
686
+ const { sql } = buildSearchQuery('t', 'q', {
687
+ ftsFields: ['title'],
688
+ highlightPre: "it's",
689
+ highlightPost: "end's",
690
+ });
691
+ // Single quotes should be doubled for SQL safety
692
+ expect(sql).toContain("it''s");
693
+ expect(sql).toContain("end''s");
694
+ });
695
+
696
+ it('selectCols includes tableName.* and ftsTable.rank separated by comma', () => {
697
+ const { sql } = buildSearchQuery('t', 'q');
698
+ expect(sql).toContain('"t".*, "t_fts".rank');
699
+ });
700
+
701
+ it('ftsFields produces indexed highlight columns', () => {
702
+ const { sql } = buildSearchQuery('t', 'q', { ftsFields: ['a', 'b'] });
703
+ // Column index matters for highlight()
704
+ expect(sql).toContain('highlight("t_fts", 0,');
705
+ expect(sql).toContain('highlight("t_fts", 1,');
706
+ });
707
+
708
+ it('ftsFields highlight columns joined with comma-space', () => {
709
+ const { sql } = buildSearchQuery('t', 'q', { ftsFields: ['a'] });
710
+ // Verify the select columns are properly comma-separated
711
+ expect(sql).toContain('"t".*, "t_fts".rank, highlight(');
712
+ });
713
+
714
+ it('empty ftsFields → no highlight columns', () => {
715
+ const { sql } = buildSearchQuery('t', 'q', { ftsFields: [] });
716
+ expect(sql).not.toContain('highlight(');
717
+ });
718
+ });
719
+
720
+ describe('buildSubstringSearchQuery', () => {
721
+ it('builds SQLite instr() fallback across search fields', () => {
722
+ const { sql, params } = buildSubstringSearchQuery('posts', '준규', { fields: ['title', 'content'] });
723
+ expect(sql).toContain('instr(lower(CAST("title" AS TEXT)), lower(?)) > 0');
724
+ expect(sql).toContain('instr(lower(CAST("content" AS TEXT)), lower(?)) > 0');
725
+ expect(params).toEqual(['준규', '준규', 20]);
726
+ });
727
+
728
+ it('passes the raw term through the SQLite instr() fallback', () => {
729
+ const { params } = buildSubstringSearchQuery('posts', '50%_off', { fields: ['title'] });
730
+ expect(params[0]).toBe('50%_off');
731
+ });
732
+ });
733
+
734
+ describe('buildCountQuery — exact params', () => {
735
+ it('no filters → params is empty array', () => {
736
+ const { params } = buildCountQuery('t');
737
+ expect(params).toEqual([]);
738
+ });
739
+
740
+ it('with filter → params match filter values exactly', () => {
741
+ const { params } = buildCountQuery('t', [['status', '==', 'active']]);
742
+ expect(params).toEqual(['active']);
743
+ });
744
+
745
+ it('with OR filters → sql contains WHERE with OR', () => {
746
+ const { sql, params } = buildCountQuery('t', undefined, [['a', '==', 1], ['b', '==', 2]]);
747
+ expect(sql).toContain('WHERE');
748
+ expect(sql).toContain('OR');
749
+ expect(params).toEqual([1, 2]);
750
+ });
751
+ });
752
+
753
+ describe('buildListQuery — sort tiebreaker', () => {
754
+ it('custom sort adds id tiebreaker with comma separator', () => {
755
+ const { sql } = buildListQuery('t', {
756
+ sort: [{ field: 'name', direction: 'asc' }],
757
+ });
758
+ expect(sql).toContain('"name" ASC, "id" ASC');
759
+ });
760
+
761
+ it('sort by id alone → no duplicate tiebreaker', () => {
762
+ const { sql } = buildListQuery('t', {
763
+ sort: [{ field: 'id', direction: 'desc' }],
764
+ });
765
+ // Should only have one "id" in ORDER BY, not a duplicate
766
+ const orderByMatch = sql.match(/ORDER BY (.+?) LIMIT/s);
767
+ expect(orderByMatch).toBeTruthy();
768
+ const orderByParts = orderByMatch![1].split(',').map(s => s.trim());
769
+ expect(orderByParts).toHaveLength(1);
770
+ });
771
+ });