@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,1425 @@
1
+ /**
2
+ * D1 request handler for single-instance (non-multi-tenant) databases.
3
+ *
4
+ * Runs in Worker context (not DO) — handles:
5
+ * - D1 binding resolution (dynamic per namespace)
6
+ * - Lazy schema initialization (via d1-schema-init)
7
+ * - CRUD operations (via query-engine SQLite dialect)
8
+ * - Rules evaluation (Worker level)
9
+ * - Hooks execution (Worker level)
10
+ *
11
+ * Database Live: After successful writes, emits events to DatabaseLiveDO
12
+ * via fire-and-forget stub.fetch() — same pattern as database-do.ts.
13
+ *
14
+ * Mirrors postgres-handler.ts structurally but uses D1 API + SQLite dialect.
15
+ */
16
+ import type { Context } from 'hono';
17
+ import type { HonoEnv } from './hono.js';
18
+ import type { Env } from '../types.js';
19
+ import type {
20
+ AuthContext,
21
+ TableConfig,
22
+ TableRules,
23
+ HookCtx,
24
+ SchemaField,
25
+ DbBlock,
26
+ } from '@edge-base/shared';
27
+ import { EdgeBaseError, getTableAccess, getTableHooks } from '@edge-base/shared';
28
+ import { parseConfig, getD1BindingName } from './do-router.js';
29
+ import { ensureD1Schema } from './d1-schema-init.js';
30
+ import {
31
+ buildListQuery, buildCountQuery, buildGetQuery, buildSearchQuery, buildSubstringSearchQuery,
32
+ parseQueryParams,
33
+ type FilterTuple,
34
+ } from './query-engine.js';
35
+ import { summarizeValidationErrors, validateInsert, validateUpdate } from './validation.js';
36
+ import { buildEffectiveSchema } from './schema.js';
37
+ import { generateId } from './uuid.js';
38
+ import { parseUpdateBody } from './op-parser.js';
39
+ import { emitDbLiveEvent, emitDbLiveBatchEvent, sendToDatabaseLiveDO } from './database-live-emitter.js';
40
+ import { isTrustedInternalContext } from './internal-request.js';
41
+ import { executeDbTriggers } from './functions.js';
42
+ import { forbiddenError, hookRejectedError, normalizeDatabaseError } from './errors.js';
43
+
44
+ // ─── Types ───
45
+
46
+ interface D1ResolvedDb {
47
+ db: D1Database;
48
+ dbBlock: DbBlock;
49
+ namespace: string;
50
+ }
51
+
52
+ // ─── Main Handler ───
53
+
54
+ /**
55
+ * Handle a request to a D1-backed database table.
56
+ * Called from tables.ts when shouldRouteToD1() returns true.
57
+ */
58
+ export async function handleD1Request(
59
+ c: Context<HonoEnv>,
60
+ namespace: string,
61
+ tableName: string,
62
+ doPath: string,
63
+ ): Promise<Response> {
64
+ // 1. Resolve D1 binding
65
+ const resolved = resolveD1Binding(c.env, namespace);
66
+
67
+ // 2. Validate table exists in config
68
+ const tableConfig = resolved.dbBlock.tables?.[tableName];
69
+ if (!tableConfig) {
70
+ return c.json({ code: 404, message: `Table '${tableName}' not found in database '${namespace}'.` }, 404);
71
+ }
72
+
73
+ // 3. Lazy schema init
74
+ await ensureD1Schema(
75
+ resolved.db,
76
+ namespace,
77
+ resolved.dbBlock.tables ?? {},
78
+ );
79
+
80
+ // 4. Check if this is a service key request
81
+ const isServiceKey = checkServiceKey(c);
82
+
83
+ // 5. Get auth context
84
+ const auth = c.get('auth') as AuthContext | null | undefined ?? null;
85
+
86
+ // 6. Parse operation from path + method
87
+ const method = c.req.raw.method;
88
+ const pathSuffix = doPath.replace(`/tables/${tableName}`, '');
89
+
90
+ // 7. Route to operation
91
+ if (method === 'GET') {
92
+ if (pathSuffix === '/count') {
93
+ return handleCount(c, resolved, tableName, tableConfig, auth, isServiceKey);
94
+ }
95
+ if (pathSuffix === '/search') {
96
+ return handleSearch(c, resolved, tableName, tableConfig, auth, isServiceKey);
97
+ }
98
+ if (pathSuffix && pathSuffix !== '/') {
99
+ const id = pathSuffix.slice(1);
100
+ return handleGet(c, resolved, tableName, tableConfig, id, auth, isServiceKey);
101
+ }
102
+ return handleList(c, resolved, tableName, tableConfig, auth, isServiceKey);
103
+ }
104
+
105
+ if (method === 'POST') {
106
+ if (pathSuffix === '/batch') {
107
+ return handleBatch(c, resolved, tableName, tableConfig, auth, isServiceKey);
108
+ }
109
+ if (pathSuffix === '/batch-by-filter') {
110
+ return handleBatchByFilter(c, resolved, tableName, tableConfig, auth, isServiceKey);
111
+ }
112
+ return handleInsert(c, resolved, tableName, tableConfig, auth, isServiceKey);
113
+ }
114
+
115
+ if (method === 'PATCH' || method === 'PUT') {
116
+ const id = pathSuffix.slice(1);
117
+ return handleUpdate(c, resolved, tableName, tableConfig, id, auth, isServiceKey);
118
+ }
119
+
120
+ if (method === 'DELETE') {
121
+ const id = pathSuffix.slice(1);
122
+ return handleDelete(c, resolved, tableName, tableConfig, id, auth, isServiceKey);
123
+ }
124
+
125
+ return c.json({ code: 405, message: 'Method not allowed' }, 405);
126
+ }
127
+
128
+ // ─── D1 Binding Resolution ───
129
+
130
+ function resolveD1Binding(env: Env, namespace: string): D1ResolvedDb {
131
+ const config = parseConfig(env);
132
+ const dbBlock = config.databases?.[namespace];
133
+ if (!dbBlock) {
134
+ throw new EdgeBaseError(404, `Database '${namespace}' not found in config.`);
135
+ }
136
+
137
+ const bindingName = getD1BindingName(namespace);
138
+ const envRecord = env as unknown as Record<string, unknown>;
139
+ const db = envRecord[bindingName] as D1Database | undefined;
140
+
141
+ if (!db) {
142
+ throw new EdgeBaseError(500,
143
+ `D1 binding '${bindingName}' not found for namespace '${namespace}'. ` +
144
+ `Run 'edgebase deploy' to auto-provision D1 databases, ` +
145
+ `or add [[d1_databases]] binding = "${bindingName}" to wrangler.toml for local dev.`,
146
+ );
147
+ }
148
+
149
+ return { db, dbBlock, namespace };
150
+ }
151
+
152
+ // ─── Service Key Check ───
153
+
154
+ function checkServiceKey(c: Context<HonoEnv>): boolean {
155
+ if (isTrustedInternalContext(c)) return true;
156
+ // Public request paths must be validated upstream (rules middleware / admin route)
157
+ // so provider-backed handlers observe the same scoped + constrained bypass result.
158
+ return c.get('isServiceKey' as never) === true;
159
+ }
160
+
161
+ // ─── D1 Query Helpers ───
162
+
163
+ async function executeD1Query(
164
+ db: D1Database,
165
+ sql: string,
166
+ params: unknown[],
167
+ ): Promise<{ rows: Record<string, unknown>[]; rowCount: number }> {
168
+ try {
169
+ const stmt = db.prepare(sql);
170
+ const bound = params.length > 0 ? stmt.bind(...params) : stmt;
171
+ const result = await bound.all();
172
+ return {
173
+ rows: (result.results ?? []) as Record<string, unknown>[],
174
+ rowCount: result.meta?.changes ?? 0,
175
+ };
176
+ } catch (error) {
177
+ const normalized = normalizeDatabaseError(error);
178
+ if (normalized) throw normalized;
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ // ─── Rule Evaluation ───
184
+
185
+ async function evalRowRule(
186
+ rule: TableRules['read'],
187
+ auth: AuthContext | null,
188
+ row: Record<string, unknown>,
189
+ ): Promise<boolean> {
190
+ if (rule === undefined || rule === null) return true;
191
+ if (typeof rule === 'boolean') return rule;
192
+ if (typeof rule === 'function') {
193
+ try {
194
+ const result = await Promise.race([
195
+ Promise.resolve(rule(auth, row)),
196
+ new Promise<boolean>((_, reject) => setTimeout(() => reject(new Error('Rule timeout')), 50)),
197
+ ]);
198
+ return result;
199
+ } catch {
200
+ return false; // fail-closed
201
+ }
202
+ }
203
+ return true;
204
+ }
205
+
206
+ async function evalInsertRule(
207
+ rule: TableRules['insert'],
208
+ auth: AuthContext | null,
209
+ ): Promise<boolean> {
210
+ if (rule === undefined || rule === null) return true;
211
+ if (typeof rule === 'boolean') return rule;
212
+ if (typeof rule === 'function') {
213
+ try {
214
+ const result = await Promise.race([
215
+ Promise.resolve((rule as (a: AuthContext | null) => boolean | Promise<boolean>)(auth)),
216
+ new Promise<boolean>((_, reject) => setTimeout(() => reject(new Error('Rule timeout')), 50)),
217
+ ]);
218
+ return result;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+ return true;
224
+ }
225
+
226
+ // ─── Hook Context Builder ───
227
+
228
+ function buildHookCtx(
229
+ db: D1Database,
230
+ tables: Record<string, TableConfig>,
231
+ env: Env,
232
+ executionCtx?: ExecutionContext,
233
+ ): HookCtx {
234
+ return {
235
+ db: {
236
+ async get(table: string, id: string): Promise<Record<string, unknown> | null> {
237
+ const { sql, params } = buildGetQuery(table, id, undefined, 'sqlite');
238
+ const result = await executeD1Query(db, sql, params);
239
+ return result.rows.length > 0 ? stripInternalFields(result.rows[0]) : null;
240
+ },
241
+ async list(table: string, filter?: Record<string, unknown>): Promise<Array<Record<string, unknown>>> {
242
+ let sql = `SELECT * FROM "${table.replace(/"/g, '""')}"`;
243
+ const params: unknown[] = [];
244
+ if (filter && Object.keys(filter).length > 0) {
245
+ const conditions: string[] = [];
246
+ for (const [key, value] of Object.entries(filter)) {
247
+ conditions.push(`"${key.replace(/"/g, '""')}" = ?`);
248
+ params.push(value);
249
+ }
250
+ sql += ` WHERE ${conditions.join(' AND ')}`;
251
+ }
252
+ sql += ' LIMIT 100';
253
+ const result = await executeD1Query(db, sql, params);
254
+ return result.rows.map(r => stripInternalFields(r));
255
+ },
256
+ async exists(table: string, filter: Record<string, unknown>): Promise<boolean> {
257
+ const conditions: string[] = [];
258
+ const params: unknown[] = [];
259
+ for (const [key, value] of Object.entries(filter)) {
260
+ conditions.push(`"${key.replace(/"/g, '""')}" = ?`);
261
+ params.push(value);
262
+ }
263
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
264
+ const sql = `SELECT 1 FROM "${table.replace(/"/g, '""')}"${where} LIMIT 1`;
265
+ const result = await executeD1Query(db, sql, params);
266
+ return result.rows.length > 0;
267
+ },
268
+ },
269
+ databaseLive: {
270
+ async broadcast(channel: string, event: string, data: unknown): Promise<void> {
271
+ await sendToDatabaseLiveDO(
272
+ env,
273
+ { channel, event, payload: data ?? {} },
274
+ '/internal/broadcast',
275
+ );
276
+ },
277
+ },
278
+ push: {
279
+ async send(_userId: string, _payload: { title?: string; body: string }): Promise<void> {
280
+ // Push notifications — same mechanism as DO (via Worker env)
281
+ },
282
+ },
283
+ waitUntil(promise: Promise<unknown>): void {
284
+ if (executionCtx) {
285
+ executionCtx.waitUntil(promise);
286
+ }
287
+ },
288
+ };
289
+ }
290
+
291
+ // ─── Utility ───
292
+
293
+ function esc(name: string): string {
294
+ return `"${name.replace(/"/g, '""')}"`;
295
+ }
296
+
297
+ function stripInternalFields(row: Record<string, unknown>): Record<string, unknown> {
298
+ const cleaned = { ...row };
299
+ delete cleaned._fts;
300
+ return cleaned;
301
+ }
302
+
303
+ /**
304
+ * Normalize D1/SQLite row values:
305
+ * - Boolean fields: 0/1 → true/false
306
+ * - JSON fields: parse string → object
307
+ * - Number fields: string → number (SQLite may return strings)
308
+ * Mirrors database-do.ts normalizeRow().
309
+ */
310
+ function normalizeRow(
311
+ row: Record<string, unknown>,
312
+ tableConfig: TableConfig,
313
+ ): Record<string, unknown> {
314
+ if (!tableConfig.schema) return row;
315
+ const result = { ...row };
316
+
317
+ for (const [key, fieldDef] of Object.entries(tableConfig.schema)) {
318
+ if (fieldDef === false) continue;
319
+ const value = result[key];
320
+ if (value === undefined) continue;
321
+
322
+ if (fieldDef.type === 'boolean') {
323
+ if (value === 1 || value === '1' || value === 'true' || value === true) {
324
+ result[key] = true;
325
+ } else if (value === 0 || value === '0' || value === 'false' || value === false) {
326
+ result[key] = false;
327
+ } else if (value === null) {
328
+ result[key] = null;
329
+ } else {
330
+ result[key] = Boolean(value);
331
+ }
332
+ } else if (fieldDef.type === 'json') {
333
+ if (typeof value === 'string' && value.length > 0) {
334
+ try {
335
+ result[key] = JSON.parse(value);
336
+ } catch {
337
+ // Keep as string if not valid JSON
338
+ }
339
+ }
340
+ } else if (fieldDef.type === 'number') {
341
+ if (typeof value === 'string') {
342
+ const num = Number(value);
343
+ if (!Number.isNaN(num)) result[key] = num;
344
+ }
345
+ }
346
+ }
347
+
348
+ return result;
349
+ }
350
+
351
+ function serializeJsonFields(
352
+ data: Record<string, unknown>,
353
+ schema: Record<string, SchemaField>,
354
+ ): void {
355
+ for (const [key, field] of Object.entries(schema)) {
356
+ if (field.type === 'json' && data[key] !== undefined && data[key] !== null) {
357
+ if (typeof data[key] !== 'string') {
358
+ data[key] = JSON.stringify(data[key]);
359
+ }
360
+ } else if (field.type === 'boolean' && data[key] !== undefined && data[key] !== null) {
361
+ data[key] = data[key] === true || data[key] === 'true' || data[key] === 1 || data[key] === '1'
362
+ ? 1
363
+ : 0;
364
+ }
365
+ }
366
+ }
367
+
368
+ function filterToSchemaColumns(
369
+ data: Record<string, unknown>,
370
+ effectiveSchema: Record<string, SchemaField>,
371
+ ): Record<string, unknown> {
372
+ const filtered: Record<string, unknown> = {};
373
+ for (const key of Object.keys(data)) {
374
+ if (key in effectiveSchema) {
375
+ filtered[key] = data[key];
376
+ }
377
+ }
378
+ return filtered;
379
+ }
380
+
381
+ // ═══════════════════════════════════════════════════════════════════════════
382
+ // CRUD Operations
383
+ // ═══════════════════════════════════════════════════════════════════════════
384
+
385
+ // ─── LIST ───
386
+
387
+ async function handleList(
388
+ c: Context<HonoEnv>,
389
+ resolved: D1ResolvedDb,
390
+ tableName: string,
391
+ tableConfig: TableConfig,
392
+ auth: AuthContext | null,
393
+ isServiceKey: boolean,
394
+ ): Promise<Response> {
395
+ const tableAccess = getTableAccess(tableConfig);
396
+ if (!isServiceKey && tableAccess?.read === false) {
397
+ const error = forbiddenError('Access denied.');
398
+ return c.json(error.toJSON(), error.status as 403);
399
+ }
400
+
401
+ const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
402
+ const { sql, params, countSql, countParams } = buildListQuery(tableName, queryOpts, 'sqlite');
403
+ const result = await executeD1Query(resolved.db, sql, params);
404
+
405
+ // Apply read rules per row + normalize booleans/JSON
406
+ let items = result.rows.map(r => normalizeRow(stripInternalFields(r), tableConfig));
407
+ const tableHooks = getTableHooks(tableConfig);
408
+ if (!isServiceKey && tableAccess?.read !== undefined) {
409
+ const filtered: Record<string, unknown>[] = [];
410
+ for (const row of items) {
411
+ if (await evalRowRule(tableAccess.read, auth, row)) {
412
+ filtered.push(row);
413
+ }
414
+ }
415
+ items = filtered;
416
+ }
417
+
418
+ // Apply onEnrich hook
419
+ if (tableHooks?.onEnrich) {
420
+ const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
421
+ for (let i = 0; i < items.length; i++) {
422
+ try {
423
+ const enriched = await tableHooks.onEnrich(auth, items[i], hookCtx);
424
+ if (enriched && typeof enriched === 'object') items[i] = { ...items[i], ...enriched };
425
+ } catch (err) {
426
+ console.error(`[EdgeBase] onEnrich hook error for table "${tableName}":`, err);
427
+ }
428
+ }
429
+ }
430
+
431
+ // Get total count
432
+ let total: number | null = null;
433
+ if (countSql && countParams) {
434
+ const countResult = await executeD1Query(resolved.db, countSql, countParams);
435
+ total = Number(countResult.rows[0]?.total ?? 0);
436
+ }
437
+
438
+ const perPage = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 20;
439
+ const page = queryOpts.pagination?.page ?? 1;
440
+ // Always include cursor/hasMore like DO does — clients can start cursor pagination from any page
441
+ const hasMore = items.length === perPage;
442
+ const cursor = hasMore && items.length > 0
443
+ ? String((items[items.length - 1]).id ?? '')
444
+ : null;
445
+
446
+ return c.json({ items, total, hasMore, cursor, page, perPage });
447
+ }
448
+
449
+ // ─── COUNT ───
450
+
451
+ async function handleCount(
452
+ c: Context<HonoEnv>,
453
+ resolved: D1ResolvedDb,
454
+ tableName: string,
455
+ tableConfig: TableConfig,
456
+ _auth: AuthContext | null,
457
+ isServiceKey: boolean,
458
+ ): Promise<Response> {
459
+ const tableAccess = getTableAccess(tableConfig);
460
+ if (!isServiceKey && tableAccess?.read === false) {
461
+ const error = forbiddenError('Access denied.');
462
+ return c.json(error.toJSON(), error.status as 403);
463
+ }
464
+
465
+ const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
466
+ const { sql, params } = buildCountQuery(tableName, queryOpts.filters, queryOpts.orFilters, 'sqlite');
467
+ const result = await executeD1Query(resolved.db, sql, params);
468
+ const total = result.rows[0]?.total ?? 0;
469
+ return c.json({ total });
470
+ }
471
+
472
+ // ─── SEARCH ───
473
+
474
+ async function handleSearch(
475
+ c: Context<HonoEnv>,
476
+ resolved: D1ResolvedDb,
477
+ tableName: string,
478
+ tableConfig: TableConfig,
479
+ auth: AuthContext | null,
480
+ isServiceKey: boolean,
481
+ ): Promise<Response> {
482
+ const tableAccess = getTableAccess(tableConfig);
483
+ if (!isServiceKey && tableAccess?.read === false) {
484
+ const error = forbiddenError('Access denied.');
485
+ return c.json(error.toJSON(), error.status as 403);
486
+ }
487
+
488
+ const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
489
+ const searchTerm = queryOpts.search || '';
490
+ if (!searchTerm) {
491
+ return c.json({ items: [] });
492
+ }
493
+
494
+ const ftsFields = tableConfig.fts?.length
495
+ ? tableConfig.fts
496
+ : getTextFields(tableConfig);
497
+
498
+ let items: Record<string, unknown>[];
499
+ let total = 0;
500
+ const limit = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 20;
501
+ const offset = queryOpts.pagination?.offset ?? ((queryOpts.pagination?.page ?? 1) - 1) * limit;
502
+ const searchQuery = buildSearchQuery(tableName, searchTerm, {
503
+ pagination: queryOpts.pagination,
504
+ filters: queryOpts.filters,
505
+ orFilters: queryOpts.orFilters,
506
+ sort: queryOpts.sort,
507
+ ftsFields,
508
+ }, 'sqlite');
509
+ try {
510
+ const result = await executeD1Query(resolved.db, searchQuery.sql, searchQuery.params);
511
+ items = result.rows.map(r => normalizeRow(stripInternalFields(r), tableConfig));
512
+ if (searchQuery.countSql) {
513
+ const countResult = await executeD1Query(resolved.db, searchQuery.countSql, searchQuery.countParams ?? []);
514
+ total = Number(countResult.rows[0]?.total ?? items.length);
515
+ }
516
+ } catch {
517
+ items = [];
518
+ }
519
+
520
+ if (items.length === 0 && ftsFields.length > 0) {
521
+ const fallback = buildSubstringSearchQuery(tableName, searchTerm, {
522
+ pagination: queryOpts.pagination,
523
+ filters: queryOpts.filters,
524
+ orFilters: queryOpts.orFilters,
525
+ sort: queryOpts.sort,
526
+ fields: ftsFields,
527
+ }, 'sqlite');
528
+ const result = await executeD1Query(resolved.db, fallback.sql, fallback.params);
529
+ items = result.rows.map((row) => normalizeRow(stripInternalFields(row), tableConfig));
530
+ if (fallback.countSql) {
531
+ const countResult = await executeD1Query(resolved.db, fallback.countSql, fallback.countParams ?? []);
532
+ total = Number(countResult.rows[0]?.total ?? items.length);
533
+ }
534
+ }
535
+
536
+ // Apply read rules
537
+ if (!isServiceKey && tableAccess?.read !== undefined) {
538
+ const filtered: Record<string, unknown>[] = [];
539
+ for (const row of items) {
540
+ if (await evalRowRule(tableAccess.read, auth, row)) {
541
+ filtered.push(row);
542
+ }
543
+ }
544
+ items = filtered;
545
+ }
546
+
547
+ return c.json({ items, total, hasMore: total > offset + items.length, cursor: null, page: null, perPage: limit });
548
+ }
549
+
550
+ // ─── GET ───
551
+
552
+ async function handleGet(
553
+ c: Context<HonoEnv>,
554
+ resolved: D1ResolvedDb,
555
+ tableName: string,
556
+ tableConfig: TableConfig,
557
+ id: string,
558
+ auth: AuthContext | null,
559
+ isServiceKey: boolean,
560
+ ): Promise<Response> {
561
+ const fieldsParam = new URL(c.req.url).searchParams.get('fields');
562
+ const fields = fieldsParam ? fieldsParam.split(',').map(f => f.trim()) : undefined;
563
+
564
+ const { sql, params } = buildGetQuery(tableName, id, fields, 'sqlite');
565
+ const result = await executeD1Query(resolved.db, sql, params);
566
+
567
+ if (result.rows.length === 0) {
568
+ return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
569
+ }
570
+
571
+ const row = normalizeRow(stripInternalFields(result.rows[0]), tableConfig);
572
+
573
+ // Check read rule
574
+ const tableAccess = getTableAccess(tableConfig);
575
+ const tableHooks = getTableHooks(tableConfig);
576
+ if (!isServiceKey && tableAccess?.read !== undefined) {
577
+ if (!(await evalRowRule(tableAccess.read, auth, row))) {
578
+ return c.json({ code: 403, message: 'Access denied.' }, 403);
579
+ }
580
+ }
581
+
582
+ // Apply onEnrich hook
583
+ if (tableHooks?.onEnrich) {
584
+ const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
585
+ try {
586
+ const enriched = await tableHooks.onEnrich(auth, row, hookCtx);
587
+ if (enriched && typeof enriched === 'object') return c.json({ ...row, ...enriched });
588
+ } catch (err) {
589
+ console.error(`[EdgeBase] onEnrich hook error for table "${tableName}":`, err);
590
+ }
591
+ }
592
+
593
+ return c.json(row);
594
+ }
595
+
596
+ // ─── INSERT ───
597
+
598
+ async function handleInsert(
599
+ c: Context<HonoEnv>,
600
+ resolved: D1ResolvedDb,
601
+ tableName: string,
602
+ tableConfig: TableConfig,
603
+ auth: AuthContext | null,
604
+ isServiceKey: boolean,
605
+ ): Promise<Response> {
606
+ let body: Record<string, unknown>;
607
+ try {
608
+ body = await c.req.json();
609
+ } catch {
610
+ return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
611
+ }
612
+ body = applySchemaFieldAliases(body, tableConfig.schema);
613
+
614
+ // Check insert rule
615
+ const tableAccess = getTableAccess(tableConfig);
616
+ const tableHooks = getTableHooks(tableConfig);
617
+ if (!isServiceKey && tableAccess?.insert !== undefined) {
618
+ if (!(await evalInsertRule(tableAccess.insert, auth))) {
619
+ return c.json({ code: 403, message: 'Insert not allowed.' }, 403);
620
+ }
621
+ }
622
+
623
+ // Validate against schema
624
+ const validation = validateInsert(body, tableConfig.schema);
625
+ if (!validation.valid) {
626
+ return c.json({
627
+ code: 400,
628
+ message: summarizeValidationErrors(validation.errors),
629
+ data: Object.fromEntries(Object.entries(validation.errors).map(([k, v]) => [k, { code: 'invalid', message: v }])),
630
+ }, 400);
631
+ }
632
+
633
+ // Build effective schema
634
+ const effectiveSchema = buildEffectiveSchema(tableConfig.schema);
635
+
636
+ // Auto-fields
637
+ if (!body.id) body.id = generateId();
638
+ const now = new Date().toISOString();
639
+ if (effectiveSchema.createdAt) body.createdAt = now;
640
+ if (effectiveSchema.updatedAt) body.updatedAt = now;
641
+
642
+ // Apply defaults for missing fields
643
+ for (const [name, field] of Object.entries(effectiveSchema)) {
644
+ if (body[name] === undefined && field.default !== undefined) {
645
+ body[name] = field.default;
646
+ }
647
+ }
648
+
649
+ // Run beforeInsert hook
650
+ const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
651
+ if (tableHooks?.beforeInsert) {
652
+ try {
653
+ const transformed = await tableHooks.beforeInsert(auth, body, hookCtx);
654
+ if (transformed && typeof transformed === 'object') {
655
+ body = { ...body, ...transformed };
656
+ }
657
+ } catch (err) {
658
+ const hookError = hookRejectedError(err, 'Insert rejected by beforeInsert hook.');
659
+ return c.json(hookError.toJSON(), hookError.status as 400);
660
+ }
661
+ }
662
+
663
+ // Filter to schema columns + serialize JSON
664
+ const data = filterToSchemaColumns(body, effectiveSchema);
665
+ serializeJsonFields(data, effectiveSchema);
666
+
667
+ // Check upsert mode
668
+ const url = new URL(c.req.url);
669
+ const isUpsert = url.searchParams.get('upsert') === 'true';
670
+ const conflictTarget = url.searchParams.get('conflictTarget') || 'id';
671
+
672
+ // Validate conflictTarget for upserts
673
+ if (isUpsert) {
674
+ // Check field exists in schema
675
+ if (!effectiveSchema[conflictTarget]) {
676
+ return c.json({ code: 400, message: `conflictTarget '${conflictTarget}' does not exist in schema.` }, 400);
677
+ }
678
+ // Check field is unique (or 'id')
679
+ if (conflictTarget !== 'id') {
680
+ const fieldDef = tableConfig.schema?.[conflictTarget];
681
+ if (!fieldDef || !(fieldDef as SchemaField).unique) {
682
+ return c.json({ code: 400, message: `conflictTarget '${conflictTarget}' must be a unique field.` }, 400);
683
+ }
684
+ }
685
+ }
686
+
687
+ // For upsert: check if record already exists to determine action
688
+ let isUpdate = false;
689
+ let upsertBeforeRow: Record<string, unknown> | null = null;
690
+ if (isUpsert && data[conflictTarget] !== undefined) {
691
+ const checkSql = `SELECT * FROM ${esc(tableName)} WHERE ${esc(conflictTarget)} = ? LIMIT 1`;
692
+ const checkResult = await executeD1Query(resolved.db, checkSql, [data[conflictTarget]]);
693
+ isUpdate = checkResult.rows.length > 0;
694
+ upsertBeforeRow = isUpdate ? stripInternalFields(checkResult.rows[0]) : null;
695
+ }
696
+
697
+ // Build INSERT SQL (SQLite uses ? params)
698
+ const columns = Object.keys(data);
699
+ const values = columns.map(col => data[col] ?? null);
700
+ const placeholders = columns.map(() => '?').join(', ');
701
+
702
+ let sql: string;
703
+ if (isUpsert) {
704
+ const setClauses = columns
705
+ .filter(col => col !== 'id' && col !== conflictTarget && col !== 'createdAt')
706
+ .map(col => `${esc(col)} = excluded.${esc(col)}`);
707
+ sql = `INSERT INTO ${esc(tableName)} (${columns.map(esc).join(', ')}) VALUES (${placeholders})` +
708
+ ` ON CONFLICT (${esc(conflictTarget)}) DO UPDATE SET ${setClauses.join(', ')}`;
709
+ } else {
710
+ sql = `INSERT INTO ${esc(tableName)} (${columns.map(esc).join(', ')}) VALUES (${placeholders})`;
711
+ }
712
+
713
+ await executeD1Query(resolved.db, sql, values);
714
+
715
+ // D1 doesn't support RETURNING * — re-fetch the inserted row using the stable conflict target.
716
+ const fetchField = isUpsert && conflictTarget !== 'id' ? conflictTarget : 'id';
717
+ const fetchValue = data[fetchField];
718
+ const fetchResult = await executeD1Query(
719
+ resolved.db,
720
+ `SELECT * FROM ${esc(tableName)} WHERE ${esc(String(fetchField))} = ? LIMIT 1`,
721
+ [fetchValue],
722
+ );
723
+ const rawRow = fetchResult.rows.length > 0 ? stripInternalFields(fetchResult.rows[0]) : data;
724
+ const inserted = normalizeRow(rawRow, tableConfig);
725
+
726
+ // Run afterInsert hook (fire-and-forget)
727
+ if (tableHooks?.afterInsert) {
728
+ const hook = tableHooks.afterInsert;
729
+ hookCtx.waitUntil(Promise.resolve(hook(inserted, hookCtx)).catch(() => {}));
730
+ }
731
+
732
+ // Emit database-live event (fire-and-forget)
733
+ const eventType = isUpsert && isUpdate ? 'modified' : 'added';
734
+ c.executionCtx.waitUntil(
735
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, eventType, String(inserted.id ?? ''), inserted),
736
+ );
737
+ c.executionCtx.waitUntil(
738
+ executeDbTriggers(
739
+ tableName,
740
+ isUpsert && isUpdate ? 'update' : 'insert',
741
+ isUpsert && isUpdate ? { before: upsertBeforeRow ?? inserted, after: inserted } : { after: inserted },
742
+ {
743
+ databaseNamespace: c.env.DATABASE,
744
+ authNamespace: c.env.AUTH,
745
+ kvNamespace: c.env.KV,
746
+ config: parseConfig(c.env),
747
+ env: c.env as never,
748
+ executionCtx: c.executionCtx as never,
749
+ },
750
+ { namespace: resolved.namespace },
751
+ ),
752
+ );
753
+
754
+ // Upsert response includes action field
755
+ if (isUpsert) {
756
+ const statusCode = isUpdate ? 200 : 201;
757
+ const action = isUpdate ? 'updated' : 'inserted';
758
+ return c.json({ ...inserted, action }, statusCode as 200);
759
+ }
760
+
761
+ return c.json(inserted, 201);
762
+ }
763
+
764
+ // ─── UPDATE ───
765
+
766
+ async function handleUpdate(
767
+ c: Context<HonoEnv>,
768
+ resolved: D1ResolvedDb,
769
+ tableName: string,
770
+ tableConfig: TableConfig,
771
+ id: string,
772
+ auth: AuthContext | null,
773
+ isServiceKey: boolean,
774
+ ): Promise<Response> {
775
+ let body: Record<string, unknown>;
776
+ try {
777
+ body = await c.req.json();
778
+ } catch {
779
+ return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
780
+ }
781
+ body = applySchemaFieldAliases(body, tableConfig.schema);
782
+
783
+ // Validate against schema
784
+ const validation = validateUpdate(body, tableConfig.schema);
785
+ if (!validation.valid) {
786
+ return c.json({ code: 400, message: 'Request body failed validation. See data for field-level errors.', data: Object.fromEntries(Object.entries(validation.errors).map(([k, v]) => [k, { code: 'invalid', message: v }])) }, 400);
787
+ }
788
+
789
+ // Fetch existing record to check rules
790
+ const { sql: getSql, params: getParams } = buildGetQuery(tableName, id, undefined, 'sqlite');
791
+ const existing = await executeD1Query(resolved.db, getSql, getParams);
792
+ if (existing.rows.length === 0) {
793
+ return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
794
+ }
795
+ const existingRow = existing.rows[0];
796
+
797
+ // Check update rule
798
+ const tableAccess = getTableAccess(tableConfig);
799
+ const tableHooks = getTableHooks(tableConfig);
800
+ if (!isServiceKey && tableAccess?.update !== undefined) {
801
+ if (!(await evalRowRule(tableAccess.update, auth, existingRow))) {
802
+ return c.json({ code: 403, message: `Access denied: 'update' rule blocked record "${id}" in table "${tableName}".` }, 403);
803
+ }
804
+ }
805
+
806
+ // Build effective schema
807
+ const effectiveSchema = buildEffectiveSchema(tableConfig.schema);
808
+
809
+ // Auto-field: updatedAt
810
+ if (effectiveSchema.updatedAt) {
811
+ body.updatedAt = new Date().toISOString();
812
+ }
813
+
814
+ // Run beforeUpdate hook
815
+ const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
816
+ if (tableHooks?.beforeUpdate) {
817
+ try {
818
+ const transformed = await tableHooks.beforeUpdate(auth, existingRow, body, hookCtx);
819
+ if (transformed && typeof transformed === 'object') {
820
+ body = { ...body, ...transformed };
821
+ }
822
+ } catch (err) {
823
+ const hookError = hookRejectedError(err, 'Update rejected by beforeUpdate hook.');
824
+ return c.json(hookError.toJSON(), hookError.status as 400);
825
+ }
826
+ }
827
+
828
+ // Filter to schema columns + serialize JSON (skip $op objects from serialization)
829
+ delete body.id;
830
+ delete body.createdAt;
831
+ const data = filterToSchemaColumns(body, effectiveSchema);
832
+ // Serialize JSON fields (but skip $op objects)
833
+ for (const [key, val] of Object.entries(data)) {
834
+ if (val && typeof val === 'object' && '$op' in (val as Record<string, unknown>)) continue;
835
+ const fieldDef = effectiveSchema[key];
836
+ if (fieldDef && typeof fieldDef === 'object' && fieldDef.type === 'json' && val !== null && val !== undefined) {
837
+ data[key] = JSON.stringify(val);
838
+ } else if (fieldDef && typeof fieldDef === 'object' && fieldDef.type === 'boolean' && val !== null && val !== undefined) {
839
+ data[key] = val === true || val === 'true' || val === 1 || val === '1'
840
+ ? 1
841
+ : 0;
842
+ }
843
+ }
844
+
845
+ // Use parseUpdateBody to handle both regular values and $op field operators
846
+ const { setClauses, params } = parseUpdateBody(data);
847
+
848
+ if (setClauses.length === 0) {
849
+ // Empty update body — return existing record as-is (same as DO handler)
850
+ return c.json(existingRow);
851
+ }
852
+
853
+ // Build UPDATE SQL with WHERE id = ?
854
+ params.push(id);
855
+ const sql = `UPDATE ${esc(tableName)} SET ${setClauses.join(', ')} WHERE "id" = ?`;
856
+ const updateResult = await executeD1Query(resolved.db, sql, params);
857
+
858
+ if (updateResult.rowCount === 0) {
859
+ return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
860
+ }
861
+
862
+ // Re-fetch updated row (D1 doesn't support RETURNING *)
863
+ const fetchResult = await executeD1Query(resolved.db, getSql, getParams);
864
+ const rawUpdated = fetchResult.rows.length > 0 ? stripInternalFields(fetchResult.rows[0]) : { id, ...data };
865
+ const updated = normalizeRow(rawUpdated, tableConfig);
866
+
867
+ // Run afterUpdate hook (fire-and-forget)
868
+ if (tableHooks?.afterUpdate) {
869
+ const hook = tableHooks.afterUpdate;
870
+ hookCtx.waitUntil(
871
+ Promise.resolve(hook(existingRow, updated, hookCtx)).catch(() => {}),
872
+ );
873
+ }
874
+
875
+ // Emit database-live event (fire-and-forget)
876
+ c.executionCtx.waitUntil(
877
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, 'modified', id, updated),
878
+ );
879
+ c.executionCtx.waitUntil(
880
+ executeDbTriggers(
881
+ tableName,
882
+ 'update',
883
+ {
884
+ before: existingRow as Record<string, unknown>,
885
+ after: updated,
886
+ },
887
+ {
888
+ databaseNamespace: c.env.DATABASE,
889
+ authNamespace: c.env.AUTH,
890
+ kvNamespace: c.env.KV,
891
+ config: parseConfig(c.env),
892
+ env: c.env as never,
893
+ executionCtx: c.executionCtx as never,
894
+ },
895
+ { namespace: resolved.namespace },
896
+ ),
897
+ );
898
+
899
+ return c.json(updated);
900
+ }
901
+
902
+ // ─── DELETE ───
903
+
904
+ async function handleDelete(
905
+ c: Context<HonoEnv>,
906
+ resolved: D1ResolvedDb,
907
+ tableName: string,
908
+ tableConfig: TableConfig,
909
+ id: string,
910
+ auth: AuthContext | null,
911
+ isServiceKey: boolean,
912
+ ): Promise<Response> {
913
+ // Fetch existing record
914
+ const { sql: getSql, params: getParams } = buildGetQuery(tableName, id, undefined, 'sqlite');
915
+ const existing = await executeD1Query(resolved.db, getSql, getParams);
916
+ if (existing.rows.length === 0) {
917
+ return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
918
+ }
919
+ const existingRow = existing.rows[0];
920
+
921
+ // Check delete rule
922
+ const tableAccess = getTableAccess(tableConfig);
923
+ const tableHooks = getTableHooks(tableConfig);
924
+ if (!isServiceKey && tableAccess?.delete !== undefined) {
925
+ if (!(await evalRowRule(tableAccess.delete, auth, existingRow))) {
926
+ return c.json({ code: 403, message: 'Delete not allowed.' }, 403);
927
+ }
928
+ }
929
+
930
+ // Run beforeDelete hook
931
+ const hookCtx = buildHookCtx(resolved.db, resolved.dbBlock.tables ?? {}, c.env, c.executionCtx);
932
+ if (tableHooks?.beforeDelete) {
933
+ try {
934
+ await tableHooks.beforeDelete(auth, existingRow, hookCtx);
935
+ } catch (err) {
936
+ const hookError = hookRejectedError(err, 'Delete rejected by beforeDelete hook.');
937
+ return c.json(hookError.toJSON(), hookError.status as 400);
938
+ }
939
+ }
940
+
941
+ // Execute DELETE
942
+ const sql = `DELETE FROM ${esc(tableName)} WHERE "id" = ?`;
943
+ await executeD1Query(resolved.db, sql, [id]);
944
+
945
+ // Run afterDelete hook (fire-and-forget)
946
+ if (tableHooks?.afterDelete) {
947
+ const hook = tableHooks.afterDelete;
948
+ hookCtx.waitUntil(
949
+ Promise.resolve(hook(existingRow, hookCtx)).catch(() => {}),
950
+ );
951
+ }
952
+
953
+ // Emit database-live event (fire-and-forget)
954
+ c.executionCtx.waitUntil(
955
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, 'removed', id, stripInternalFields(existingRow)),
956
+ );
957
+ c.executionCtx.waitUntil(
958
+ executeDbTriggers(
959
+ tableName,
960
+ 'delete',
961
+ { before: existingRow as Record<string, unknown> },
962
+ {
963
+ databaseNamespace: c.env.DATABASE,
964
+ authNamespace: c.env.AUTH,
965
+ kvNamespace: c.env.KV,
966
+ config: parseConfig(c.env),
967
+ env: c.env as never,
968
+ executionCtx: c.executionCtx as never,
969
+ },
970
+ { namespace: resolved.namespace },
971
+ ),
972
+ );
973
+
974
+ return c.json({ deleted: true });
975
+ }
976
+
977
+ // ─── BATCH ───
978
+
979
+ async function handleBatch(
980
+ c: Context<HonoEnv>,
981
+ resolved: D1ResolvedDb,
982
+ tableName: string,
983
+ tableConfig: TableConfig,
984
+ auth: AuthContext | null,
985
+ isServiceKey: boolean,
986
+ ): Promise<Response> {
987
+ let body: {
988
+ inserts?: Record<string, unknown>[];
989
+ updates?: { id: string; data: Record<string, unknown> }[];
990
+ deletes?: string[];
991
+ };
992
+ try {
993
+ body = await c.req.json();
994
+ } catch {
995
+ return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
996
+ }
997
+
998
+ // Batch size limit: 500 total ops
999
+ const MAX_BATCH_SIZE = 500;
1000
+ const totalOps = (body.inserts?.length ?? 0) + (body.updates?.length ?? 0) + (body.deletes?.length ?? 0);
1001
+ if (totalOps > MAX_BATCH_SIZE) {
1002
+ return c.json({ code: 400, message: `Batch limit exceeded: ${totalOps} operations (max ${MAX_BATCH_SIZE}).` }, 400);
1003
+ }
1004
+
1005
+ // Check insert rule (table-level, once)
1006
+ const tableAccess = getTableAccess(tableConfig);
1007
+ if (!isServiceKey && body.inserts?.length && tableAccess?.insert !== undefined) {
1008
+ if (!(await evalInsertRule(tableAccess.insert, auth))) {
1009
+ return c.json({ code: 403, message: 'Insert not allowed.' }, 403);
1010
+ }
1011
+ }
1012
+
1013
+ // Upsert mode support
1014
+ const upsertMode = c.req.query('upsert') === 'true';
1015
+ const conflictTarget = c.req.query('conflictTarget') || 'id';
1016
+
1017
+ if (upsertMode && conflictTarget !== 'id') {
1018
+ const eff = buildEffectiveSchema(tableConfig.schema);
1019
+ const targetField = eff[conflictTarget];
1020
+ if (!targetField) {
1021
+ return c.json({ code: 400, message: `conflictTarget '${conflictTarget}' does not exist in schema.` }, 400);
1022
+ }
1023
+ if (!targetField.unique) {
1024
+ return c.json({ code: 400, message: `conflictTarget '${conflictTarget}' must be a unique field.` }, 400);
1025
+ }
1026
+ }
1027
+
1028
+ // ── Pre-validate ALL operations before executing any (ensures atomicity) ──
1029
+ const effectiveSchema = buildEffectiveSchema(tableConfig.schema);
1030
+ const now = new Date().toISOString();
1031
+
1032
+ // Validate all inserts
1033
+ if (body.inserts?.length) {
1034
+ body.inserts = body.inserts.map((item) => applySchemaFieldAliases(item, tableConfig.schema));
1035
+ for (const item of body.inserts) {
1036
+ const validation = validateInsert(item, tableConfig.schema);
1037
+ if (!validation.valid) {
1038
+ return c.json({ code: 400, message: 'Batch insert request failed validation. See data for field-level errors.', data: Object.fromEntries(Object.entries(validation.errors).map(([k, v]) => [k, { code: 'invalid', message: v }])) }, 400);
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ // Validate all updates
1044
+ if (body.updates?.length) {
1045
+ body.updates = body.updates.map((entry) => ({
1046
+ ...entry,
1047
+ data: applySchemaFieldAliases(entry.data, tableConfig.schema),
1048
+ }));
1049
+ for (const entry of body.updates) {
1050
+ if (!entry.id) {
1051
+ return c.json({ code: 400, message: 'Each batch update entry must include an id.' }, 400);
1052
+ }
1053
+ if (!entry.data || typeof entry.data !== 'object') {
1054
+ return c.json({ code: 400, message: 'Each batch update entry must include a data object.' }, 400);
1055
+ }
1056
+ const validation = validateUpdate(entry.data, tableConfig.schema);
1057
+ if (!validation.valid) {
1058
+ return c.json({ code: 400, message: 'Batch update request failed validation. See data for field-level errors.', data: Object.fromEntries(Object.entries(validation.errors).map(([k, v]) => [k, { code: 'invalid', message: v }])) }, 400);
1059
+ }
1060
+ }
1061
+ }
1062
+
1063
+ // Check delete rules (table-level)
1064
+ if (!isServiceKey && body.deletes?.length && tableAccess?.delete !== undefined) {
1065
+ if (!(await evalRowRule(tableAccess.delete, auth, {}))) {
1066
+ return c.json({ code: 403, message: 'Delete not allowed.' }, 403);
1067
+ }
1068
+ }
1069
+
1070
+ // ── All validation passed — now execute ──
1071
+ const results: Record<string, unknown> = {};
1072
+ const allChanges: Array<{ type: 'added' | 'modified' | 'removed'; docId: string; data: Record<string, unknown> | null }> = [];
1073
+
1074
+ // ── Inserts ──
1075
+ if (body.inserts) results.inserted = [];
1076
+ if (body.inserts?.length) {
1077
+ const stmts: D1PreparedStatement[] = [];
1078
+ const insertedRecords: Record<string, unknown>[] = [];
1079
+
1080
+ for (const item of body.inserts) {
1081
+ const id = (item.id as string) || generateId();
1082
+ const record: Record<string, unknown> = { ...item, id };
1083
+ if (effectiveSchema.createdAt) record.createdAt = now;
1084
+ if (effectiveSchema.updatedAt) record.updatedAt = now;
1085
+
1086
+ for (const [fname, field] of Object.entries(effectiveSchema)) {
1087
+ if (record[fname] === undefined && field.default !== undefined) {
1088
+ record[fname] = field.default;
1089
+ }
1090
+ }
1091
+
1092
+ const data = filterToSchemaColumns(record, effectiveSchema);
1093
+ serializeJsonFields(data, effectiveSchema);
1094
+
1095
+ const columns = Object.keys(data);
1096
+ const values = columns.map(col => data[col] ?? null);
1097
+ const placeholders = columns.map(() => '?').join(', ');
1098
+ const colStr = columns.map(esc).join(', ');
1099
+
1100
+ let sql: string;
1101
+ if (upsertMode) {
1102
+ const updateCols = columns.filter(k => k !== 'id' && k !== 'createdAt' && k !== conflictTarget);
1103
+ const updateSet = updateCols.map(k => `${esc(k)} = excluded.${esc(k)}`).join(', ');
1104
+ sql = updateSet
1105
+ ? `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders}) ON CONFLICT(${esc(conflictTarget)}) DO UPDATE SET ${updateSet}`
1106
+ : `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders}) ON CONFLICT(${esc(conflictTarget)}) DO NOTHING`;
1107
+ } else {
1108
+ sql = `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders})`;
1109
+ }
1110
+
1111
+ const stmt = resolved.db.prepare(sql);
1112
+ stmts.push(values.length > 0 ? stmt.bind(...values) : stmt);
1113
+ insertedRecords.push(data);
1114
+ }
1115
+
1116
+ // Execute all inserts atomically via db.batch()
1117
+ await resolved.db.batch(stmts);
1118
+
1119
+ // Re-fetch all inserted rows
1120
+ const inserted = results.inserted as Record<string, unknown>[];
1121
+ for (const rec of insertedRecords) {
1122
+ const fetchField = upsertMode && conflictTarget !== 'id' ? conflictTarget : 'id';
1123
+ const fetchValue = rec[fetchField];
1124
+ const fetchResult = await executeD1Query(resolved.db, `SELECT * FROM ${esc(tableName)} WHERE ${esc(String(fetchField))} = ?`, [fetchValue]);
1125
+ if (fetchResult.rows.length > 0) {
1126
+ const row = normalizeRow(stripInternalFields(fetchResult.rows[0]), tableConfig);
1127
+ inserted.push(row);
1128
+ allChanges.push({ type: 'added', docId: String(row.id ?? ''), data: row });
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ // ── Updates ──
1134
+ if (body.updates) results.updated = [];
1135
+ if (body.updates?.length) {
1136
+ const updated = results.updated as Record<string, unknown>[];
1137
+ for (const entry of body.updates) {
1138
+ const updateData = { ...entry.data };
1139
+ delete updateData.id;
1140
+ delete updateData.createdAt;
1141
+ if (effectiveSchema.updatedAt?.onUpdate === 'now') {
1142
+ updateData.updatedAt = now;
1143
+ }
1144
+
1145
+ // Serialize json-type fields
1146
+ for (const [key, value] of Object.entries(updateData)) {
1147
+ if (effectiveSchema[key]?.type === 'json' && value !== null && value !== undefined && typeof value === 'object' && !('$op' in (value as Record<string, unknown>))) {
1148
+ updateData[key] = JSON.stringify(value);
1149
+ } else if (effectiveSchema[key]?.type === 'boolean' && value !== null && value !== undefined && (typeof value !== 'object' || !('$op' in value))) {
1150
+ updateData[key] = value === true || value === 'true' || value === 1 || value === '1'
1151
+ ? 1
1152
+ : 0;
1153
+ }
1154
+ }
1155
+
1156
+ const { setClauses, params } = parseUpdateBody(updateData);
1157
+ if (setClauses.length > 0) {
1158
+ params.push(entry.id);
1159
+ await executeD1Query(resolved.db, `UPDATE ${esc(tableName)} SET ${setClauses.join(', ')} WHERE "id" = ?`, params);
1160
+ }
1161
+
1162
+ // Re-fetch the updated row
1163
+ const fetchResult = await executeD1Query(resolved.db, `SELECT * FROM ${esc(tableName)} WHERE "id" = ?`, [entry.id]);
1164
+ const row = fetchResult.rows.length > 0
1165
+ ? normalizeRow(stripInternalFields(fetchResult.rows[0]), tableConfig)
1166
+ : { id: entry.id, ...entry.data };
1167
+ updated.push(row);
1168
+ allChanges.push({ type: 'modified', docId: String(row.id ?? entry.id), data: row });
1169
+ }
1170
+ }
1171
+
1172
+ // ── Deletes ──
1173
+ if (body.deletes) results.deleted = 0;
1174
+ if (body.deletes?.length) {
1175
+ for (const id of body.deletes) {
1176
+ await executeD1Query(resolved.db, `DELETE FROM ${esc(tableName)} WHERE "id" = ?`, [id]);
1177
+ }
1178
+ results.deleted = body.deletes.length;
1179
+ for (const id of body.deletes) {
1180
+ allChanges.push({ type: 'removed', docId: id, data: null });
1181
+ }
1182
+ }
1183
+
1184
+ // Emit database-live events
1185
+ if (allChanges.length > 0) {
1186
+ if (allChanges.length >= 10) {
1187
+ c.executionCtx.waitUntil(
1188
+ emitDbLiveBatchEvent(c.env, resolved.namespace, tableName, allChanges),
1189
+ );
1190
+ } else {
1191
+ for (const ch of allChanges) {
1192
+ c.executionCtx.waitUntil(
1193
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, ch.type, ch.docId, ch.data),
1194
+ );
1195
+ }
1196
+ }
1197
+ }
1198
+
1199
+ return c.json(results);
1200
+ }
1201
+
1202
+ // ─── BATCH BY FILTER ───
1203
+
1204
+ async function handleBatchByFilter(
1205
+ c: Context<HonoEnv>,
1206
+ resolved: D1ResolvedDb,
1207
+ tableName: string,
1208
+ tableConfig: TableConfig,
1209
+ _auth: AuthContext | null,
1210
+ _isServiceKey: boolean,
1211
+ ): Promise<Response> {
1212
+ let body: {
1213
+ action?: string;
1214
+ filter?: FilterTuple[];
1215
+ orFilter?: FilterTuple[];
1216
+ update?: Record<string, unknown>;
1217
+ limit?: number;
1218
+ };
1219
+ try {
1220
+ body = await c.req.json();
1221
+ } catch {
1222
+ return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
1223
+ }
1224
+
1225
+ if (!body.action || !['delete', 'update'].includes(body.action)) {
1226
+ return c.json({ code: 400, message: "batch-by-filter requires 'action' to be 'delete' or 'update'." }, 400);
1227
+ }
1228
+ if (!body.filter || !Array.isArray(body.filter)) {
1229
+ return c.json({ code: 400, message: "batch-by-filter requires 'filter' to be a non-empty array." }, 400);
1230
+ }
1231
+ if (body.action === 'update' && !body.update) {
1232
+ return c.json({ code: 400, message: "batch-by-filter with action 'update' requires 'update' data." }, 400);
1233
+ }
1234
+
1235
+ const limit = Math.min(body.limit ?? 500, 500);
1236
+
1237
+ // Find matching records using buildListQuery
1238
+ const { sql: selectSql, params: selectParams } = buildListQuery(tableName, {
1239
+ filters: body.filter,
1240
+ orFilters: body.orFilter,
1241
+ pagination: { limit },
1242
+ }, 'sqlite');
1243
+ const selectResult = await executeD1Query(resolved.db, selectSql, selectParams);
1244
+ const allRows = selectResult.rows;
1245
+ const processed = allRows.length;
1246
+
1247
+ if (allRows.length === 0) {
1248
+ return c.json({ processed: 0, succeeded: 0 });
1249
+ }
1250
+
1251
+ const ids = allRows.map(r => r.id as string);
1252
+ const placeholders = ids.map(() => '?').join(', ');
1253
+ let succeeded = 0;
1254
+
1255
+ if (body.action === 'delete') {
1256
+ await executeD1Query(resolved.db, `DELETE FROM ${esc(tableName)} WHERE "id" IN (${placeholders})`, ids);
1257
+ succeeded = ids.length;
1258
+ } else if (body.action === 'update' && body.update) {
1259
+ const effectiveSchema = buildEffectiveSchema(tableConfig.schema);
1260
+ const updateData = { ...body.update };
1261
+ if (effectiveSchema.updatedAt?.onUpdate === 'now') {
1262
+ updateData.updatedAt = new Date().toISOString();
1263
+ }
1264
+
1265
+ // Serialize json-type fields
1266
+ for (const [key, value] of Object.entries(updateData)) {
1267
+ if (effectiveSchema[key]?.type === 'json' && value !== null && value !== undefined && typeof value === 'object' && !('$op' in (value as Record<string, unknown>))) {
1268
+ updateData[key] = JSON.stringify(value);
1269
+ } else if (effectiveSchema[key]?.type === 'boolean' && value !== null && value !== undefined && (typeof value !== 'object' || !('$op' in value))) {
1270
+ updateData[key] = value === true || value === 'true' || value === 1 || value === '1'
1271
+ ? 1
1272
+ : 0;
1273
+ }
1274
+ }
1275
+
1276
+ const { setClauses, params } = parseUpdateBody(updateData);
1277
+ if (setClauses.length > 0) {
1278
+ await executeD1Query(
1279
+ resolved.db,
1280
+ `UPDATE ${esc(tableName)} SET ${setClauses.join(', ')} WHERE "id" IN (${placeholders})`,
1281
+ [...params, ...ids],
1282
+ );
1283
+ }
1284
+ succeeded = ids.length;
1285
+ }
1286
+
1287
+ // Emit database-live events
1288
+ if (succeeded > 0) {
1289
+ const eventType = body.action === 'delete' ? 'removed' : 'modified';
1290
+ c.executionCtx.waitUntil(
1291
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, eventType as 'modified' | 'removed', '_bulk', { action: body.action, count: succeeded }),
1292
+ );
1293
+ }
1294
+
1295
+ return c.json({ processed, succeeded });
1296
+ }
1297
+
1298
+ // ─── Helpers ───
1299
+
1300
+ function getTextFields(config: TableConfig): string[] {
1301
+ if (!config.schema) return ['id'];
1302
+ const fields: string[] = [];
1303
+ for (const [name, field] of Object.entries(config.schema)) {
1304
+ if (field === false) continue;
1305
+ if (field.type === 'string' || field.type === 'text') {
1306
+ fields.push(name);
1307
+ }
1308
+ }
1309
+ return fields.length > 0 ? fields : ['id'];
1310
+ }
1311
+
1312
+ function toSnakeCase(value: string): string {
1313
+ return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
1314
+ }
1315
+
1316
+ function toCamelCase(value: string): string {
1317
+ return value.replace(/_([a-z0-9])/g, (_match, char: string) => char.toUpperCase());
1318
+ }
1319
+
1320
+ function applySchemaFieldAliases<T extends Record<string, unknown> | null | undefined>(
1321
+ record: T,
1322
+ schema?: Record<string, SchemaField | false>,
1323
+ ): T {
1324
+ if (!schema || !record || typeof record !== 'object' || Array.isArray(record)) return record;
1325
+
1326
+ const effectiveSchema = buildEffectiveSchema(schema);
1327
+ const normalized: Record<string, unknown> = { ...record };
1328
+
1329
+ for (const key of Object.keys(effectiveSchema)) {
1330
+ const snake = toSnakeCase(key);
1331
+ const camel = toCamelCase(key);
1332
+
1333
+ if (effectiveSchema[snake] && normalized[snake] === undefined && normalized[key] !== undefined) {
1334
+ normalized[snake] = normalized[key];
1335
+ }
1336
+ if (effectiveSchema[camel] && normalized[camel] === undefined && normalized[key] !== undefined) {
1337
+ normalized[camel] = normalized[key];
1338
+ }
1339
+ }
1340
+
1341
+ return normalized as T;
1342
+ }
1343
+
1344
+ // ─── Exported batch import for admin routes ───
1345
+
1346
+ /**
1347
+ * Batch import records into D1 directly (bypasses rules, for admin use).
1348
+ * Returns { imported, errors }.
1349
+ */
1350
+ export async function d1BatchImport(
1351
+ env: Env,
1352
+ namespace: string,
1353
+ tableName: string,
1354
+ records: Record<string, unknown>[],
1355
+ options?: { upsert?: boolean; conflictTarget?: string },
1356
+ ): Promise<{ imported: number; errors: Array<{ row: number; message: string }> }> {
1357
+ const resolved = resolveD1Binding(env, namespace);
1358
+ const tableConfig = resolved.dbBlock.tables?.[tableName];
1359
+ if (!tableConfig) {
1360
+ throw new EdgeBaseError(404, `Table '${tableName}' not found in database '${namespace}'.`);
1361
+ }
1362
+
1363
+ await ensureD1Schema(resolved.db, namespace, resolved.dbBlock.tables ?? {});
1364
+
1365
+ const effectiveSchema = buildEffectiveSchema(tableConfig.schema);
1366
+ const now = new Date().toISOString();
1367
+ const upsertMode = options?.upsert ?? false;
1368
+ const conflictTarget = options?.conflictTarget ?? 'id';
1369
+
1370
+ const stmts: D1PreparedStatement[] = [];
1371
+ const errors: Array<{ row: number; message: string }> = [];
1372
+
1373
+ for (let i = 0; i < records.length; i++) {
1374
+ const item = applySchemaFieldAliases(records[i], tableConfig.schema);
1375
+ const validation = validateInsert(item, tableConfig.schema);
1376
+ if (!validation.valid) {
1377
+ errors.push({ row: i, message: Object.values(validation.errors).join('; ') });
1378
+ continue;
1379
+ }
1380
+
1381
+ const id = (item.id as string) || generateId();
1382
+ const record: Record<string, unknown> = { ...item, id };
1383
+ if (effectiveSchema.createdAt) record.createdAt = now;
1384
+ if (effectiveSchema.updatedAt) record.updatedAt = now;
1385
+
1386
+ for (const [fname, field] of Object.entries(effectiveSchema)) {
1387
+ if (record[fname] === undefined && field.default !== undefined) {
1388
+ record[fname] = field.default;
1389
+ }
1390
+ }
1391
+
1392
+ const data = filterToSchemaColumns(record, effectiveSchema);
1393
+ serializeJsonFields(data, effectiveSchema);
1394
+
1395
+ const columns = Object.keys(data);
1396
+ const values = columns.map(col => data[col] ?? null);
1397
+ const placeholders = columns.map(() => '?').join(', ');
1398
+ const colStr = columns.map(esc).join(', ');
1399
+
1400
+ let sql: string;
1401
+ if (upsertMode) {
1402
+ const updateCols = columns.filter(k => k !== 'id' && k !== 'createdAt' && k !== conflictTarget);
1403
+ const updateSet = updateCols.map(k => `${esc(k)} = excluded.${esc(k)}`).join(', ');
1404
+ sql = updateSet
1405
+ ? `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders}) ON CONFLICT(${esc(conflictTarget)}) DO UPDATE SET ${updateSet}`
1406
+ : `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders}) ON CONFLICT(${esc(conflictTarget)}) DO NOTHING`;
1407
+ } else {
1408
+ sql = `INSERT INTO ${esc(tableName)} (${colStr}) VALUES (${placeholders})`;
1409
+ }
1410
+
1411
+ const stmt = resolved.db.prepare(sql);
1412
+ stmts.push(values.length > 0 ? stmt.bind(...values) : stmt);
1413
+ }
1414
+
1415
+ if (stmts.length === 0) {
1416
+ return { imported: 0, errors };
1417
+ }
1418
+
1419
+ try {
1420
+ await resolved.db.batch(stmts);
1421
+ return { imported: stmts.length, errors };
1422
+ } catch (err) {
1423
+ return { imported: 0, errors: [{ row: 0, message: err instanceof Error ? err.message : 'Batch insert failed' }] };
1424
+ }
1425
+ }