@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,1102 @@
1
+ /**
2
+ * PostgreSQL request handler for provider='neon'|'postgres'.
3
+ *
4
+ * Runs in Worker context (not DO) — handles:
5
+ * - Hyperdrive binding resolution
6
+ * - Lazy schema initialization (via postgres-schema-init)
7
+ * - CRUD operations (via query-engine + postgres-executor)
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 database-do.ts CRUD logic but uses PostgreSQL instead of SQLite.
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
+ DbBlock,
25
+ } from '@edge-base/shared';
26
+ import { EdgeBaseError, getTableAccess, getTableHooks } from '@edge-base/shared';
27
+ import { parseConfig } from './do-router.js';
28
+ import {
29
+ ensureLocalDevPostgresSchema,
30
+ getLocalDevPostgresExecOptions,
31
+ getProviderBindingName,
32
+ executePostgresQuery,
33
+ withPostgresConnection,
34
+ type PostgresExecutor,
35
+ } from './postgres-executor.js';
36
+ import { ensurePgSchema } from './postgres-schema-init.js';
37
+ import {
38
+ buildListQuery, buildCountQuery, buildGetQuery, buildSearchQuery,
39
+ parseQueryParams,
40
+ type FilterTuple,
41
+ } from './query-engine.js';
42
+ import { summarizeValidationErrors, validateInsert, validateUpdate } from './validation.js';
43
+ import { emitDbLiveEvent, emitDbLiveBatchEvent } from './database-live-emitter.js';
44
+ import { forbiddenError, hookRejectedError } from './errors.js';
45
+ import {
46
+ escapePgIdentifier,
47
+ preparePgInsertData,
48
+ preparePgUpdateData,
49
+ stripInternalPgFields,
50
+ } from './postgres-table-utils.js';
51
+ import { isTrustedInternalContext } from './internal-request.js';
52
+ import { executeDbTriggers } from './functions.js';
53
+ import { parseUpdateBody } from './op-parser.js';
54
+
55
+ // ─── Types ───
56
+
57
+ interface PgResolvedDb {
58
+ connectionString: string;
59
+ dbBlock: DbBlock;
60
+ namespace: string;
61
+ }
62
+
63
+ // ─── Main Handler ───
64
+
65
+ /**
66
+ * Handle a request to a PostgreSQL-backed database table.
67
+ * Called from tables.ts when provider is 'neon' or 'postgres'.
68
+ *
69
+ * @param c - Hono context
70
+ * @param namespace - Database namespace (e.g. 'shared')
71
+ * @param tableName - Table name (e.g. 'posts')
72
+ * @param doPath - Internal path (e.g. '/tables/posts', '/tables/posts/abc123')
73
+ */
74
+ export async function handlePgRequest(
75
+ c: Context<HonoEnv>,
76
+ namespace: string,
77
+ tableName: string,
78
+ doPath: string,
79
+ ): Promise<Response> {
80
+ const resolved = resolvePgConnection(c.env, namespace);
81
+ const tableConfig = resolved.dbBlock.tables?.[tableName];
82
+ if (!tableConfig) {
83
+ return c.json({ code: 404, message: `Table '${tableName}' not found in database '${namespace}'.` }, 404);
84
+ }
85
+ const isServiceKey = checkServiceKey(c);
86
+ const auth = c.get('auth') as AuthContext | null | undefined ?? null;
87
+ const method = c.req.raw.method;
88
+ const pathSuffix = doPath.replace(`/tables/${tableName}`, '');
89
+ const localDevOptions = getLocalDevPostgresExecOptions(c.env as unknown as Record<string, unknown>, namespace);
90
+
91
+ if (localDevOptions) {
92
+ await ensureLocalDevPostgresSchema(localDevOptions);
93
+ }
94
+
95
+ return withPostgresConnection(
96
+ resolved.connectionString,
97
+ async (query) => {
98
+ if (!localDevOptions) {
99
+ await ensurePgSchema(
100
+ resolved.connectionString,
101
+ namespace,
102
+ resolved.dbBlock.tables ?? {},
103
+ query,
104
+ );
105
+ }
106
+
107
+ if (method === 'GET') {
108
+ if (pathSuffix === '/count') {
109
+ return handleCount(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
110
+ }
111
+ if (pathSuffix === '/search') {
112
+ return handleSearch(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
113
+ }
114
+ if (pathSuffix && pathSuffix !== '/') {
115
+ const id = pathSuffix.slice(1);
116
+ return handleGet(c, resolved, tableName, tableConfig, id, auth, isServiceKey, query);
117
+ }
118
+ return handleList(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
119
+ }
120
+
121
+ if (method === 'POST') {
122
+ if (pathSuffix === '/batch') {
123
+ return handleBatch(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
124
+ }
125
+ if (pathSuffix === '/batch-by-filter') {
126
+ return handleBatchByFilter(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
127
+ }
128
+ return handleInsert(c, resolved, tableName, tableConfig, auth, isServiceKey, query);
129
+ }
130
+
131
+ if (method === 'PATCH' || method === 'PUT') {
132
+ const id = pathSuffix.slice(1);
133
+ return handleUpdate(c, resolved, tableName, tableConfig, id, auth, isServiceKey, query);
134
+ }
135
+
136
+ if (method === 'DELETE') {
137
+ const id = pathSuffix.slice(1);
138
+ return handleDelete(c, resolved, tableName, tableConfig, id, auth, isServiceKey, query);
139
+ }
140
+
141
+ return c.json({ code: 405, message: 'Method not allowed' }, 405);
142
+ },
143
+ localDevOptions,
144
+ );
145
+ }
146
+
147
+ // ─── Connection Resolution ───
148
+
149
+ function resolvePgConnection(env: Env, namespace: string): PgResolvedDb {
150
+ const config = parseConfig(env);
151
+ const dbBlock = config.databases?.[namespace];
152
+ if (!dbBlock) {
153
+ throw new EdgeBaseError(404, `Database '${namespace}' not found in config.`);
154
+ }
155
+
156
+ const bindingName = getProviderBindingName(namespace);
157
+ const envRecord = env as unknown as Record<string, unknown>;
158
+
159
+ // 1. Try Hyperdrive binding (production — object with .connectionString)
160
+ const hyperdrive = envRecord[bindingName] as { connectionString: string } | undefined;
161
+ if (hyperdrive?.connectionString) {
162
+ return { connectionString: hyperdrive.connectionString, dbBlock, namespace };
163
+ }
164
+
165
+ // 2. Fallback: direct connection string from env (local dev — {BINDING}_URL string)
166
+ const envKey = dbBlock.connectionString ?? `${bindingName}_URL`;
167
+ const directUrl = envRecord[envKey] as string | undefined;
168
+ if (directUrl) {
169
+ return { connectionString: directUrl, dbBlock, namespace };
170
+ }
171
+
172
+ throw new EdgeBaseError(500,
173
+ `PostgreSQL connection for '${namespace}' not found. ` +
174
+ `In production: run 'edgebase deploy' to auto-provision Hyperdrive. ` +
175
+ `In development: add ${envKey}=postgres://... to .env.development`,
176
+ );
177
+ }
178
+
179
+ // ─── Service Key Check ───
180
+
181
+ function checkServiceKey(c: Context<HonoEnv>): boolean {
182
+ if (isTrustedInternalContext(c)) return true;
183
+ // Public request paths must be validated upstream (rules middleware / admin route)
184
+ // so provider-backed handlers observe the same scoped + constrained bypass result.
185
+ return c.get('isServiceKey' as never) === true;
186
+ }
187
+
188
+ // ─── Rule Evaluation ───
189
+
190
+ async function evalRowRule(
191
+ rule: TableRules['read'],
192
+ auth: AuthContext | null,
193
+ row: Record<string, unknown>,
194
+ ): Promise<boolean> {
195
+ if (rule === undefined || rule === null) return true;
196
+ if (typeof rule === 'boolean') return rule;
197
+ if (typeof rule === 'function') {
198
+ try {
199
+ const result = await Promise.race([
200
+ Promise.resolve(rule(auth, row)),
201
+ new Promise<boolean>((_, reject) => setTimeout(() => reject(new Error('Rule timeout')), 50)),
202
+ ]);
203
+ return result;
204
+ } catch {
205
+ return false; // fail-closed
206
+ }
207
+ }
208
+ return true;
209
+ }
210
+
211
+ async function evalInsertRule(
212
+ rule: TableRules['insert'],
213
+ auth: AuthContext | null,
214
+ ): Promise<boolean> {
215
+ if (rule === undefined || rule === null) return true;
216
+ if (typeof rule === 'boolean') return rule;
217
+ if (typeof rule === 'function') {
218
+ try {
219
+ const result = await Promise.race([
220
+ Promise.resolve((rule as (a: AuthContext | null) => boolean | Promise<boolean>)(auth)),
221
+ new Promise<boolean>((_, reject) => setTimeout(() => reject(new Error('Rule timeout')), 50)),
222
+ ]);
223
+ return result;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+ return true;
229
+ }
230
+
231
+ // ─── Hook Context Builder ───
232
+
233
+ function buildHookCtx(
234
+ connectionString: string,
235
+ tables: Record<string, TableConfig>,
236
+ executionCtx?: ExecutionContext,
237
+ queryExecutor?: PostgresExecutor,
238
+ ): HookCtx {
239
+ const query =
240
+ queryExecutor ??
241
+ ((sql: string, params: unknown[] = []) => executePostgresQuery(connectionString, sql, params));
242
+
243
+ return {
244
+ db: {
245
+ async get(table: string, id: string): Promise<Record<string, unknown> | null> {
246
+ const { sql, params } = buildGetQuery(table, id, undefined, 'postgres');
247
+ const result = await query(sql, params);
248
+ return result.rows.length > 0
249
+ ? stripInternalPgFields(result.rows[0] as Record<string, unknown>)
250
+ : null;
251
+ },
252
+ async list(table: string, filter?: Record<string, unknown>): Promise<Array<Record<string, unknown>>> {
253
+ let sql = `SELECT * FROM "${table.replace(/"/g, '""')}"`;
254
+ const params: unknown[] = [];
255
+ if (filter && Object.keys(filter).length > 0) {
256
+ const conditions: string[] = [];
257
+ let idx = 1;
258
+ for (const [key, value] of Object.entries(filter)) {
259
+ conditions.push(`"${key.replace(/"/g, '""')}" = $${idx++}`);
260
+ params.push(value);
261
+ }
262
+ sql += ` WHERE ${conditions.join(' AND ')}`;
263
+ }
264
+ sql += ' LIMIT 100';
265
+ const result = await query(sql, params);
266
+ return result.rows.map(r => stripInternalPgFields(r as Record<string, unknown>));
267
+ },
268
+ async exists(table: string, filter: Record<string, unknown>): Promise<boolean> {
269
+ const conditions: string[] = [];
270
+ const params: unknown[] = [];
271
+ let idx = 1;
272
+ for (const [key, value] of Object.entries(filter)) {
273
+ conditions.push(`"${key.replace(/"/g, '""')}" = $${idx++}`);
274
+ params.push(value);
275
+ }
276
+ const where = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
277
+ const sql = `SELECT 1 FROM "${table.replace(/"/g, '""')}"${where} LIMIT 1`;
278
+ const result = await query(sql, params);
279
+ return result.rows.length > 0;
280
+ },
281
+ },
282
+ databaseLive: {
283
+ async broadcast(_channel: string, _event: string, _data: unknown): Promise<void> {
284
+ // HookCtx broadcast — not implemented for PostgreSQL provider (no direct env access)
285
+ // Use database-live subscription from client SDK instead
286
+ },
287
+ },
288
+ push: {
289
+ async send(_userId: string, _payload: { title?: string; body: string }): Promise<void> {
290
+ // Push notifications — same mechanism as DO (via Worker env)
291
+ },
292
+ },
293
+ waitUntil(promise: Promise<unknown>): void {
294
+ if (executionCtx) {
295
+ executionCtx.waitUntil(promise);
296
+ }
297
+ },
298
+ };
299
+ }
300
+
301
+ function toFieldErrorData(
302
+ errors: Record<string, string>,
303
+ ): Record<string, { code: string; message: string }> {
304
+ return Object.fromEntries(
305
+ Object.entries(errors).map(([key, message]) => [key, { code: 'invalid', message }]),
306
+ );
307
+ }
308
+
309
+ // ═══════════════════════════════════════════════════════════════════════════
310
+ // CRUD Operations
311
+ // ═══════════════════════════════════════════════════════════════════════════
312
+
313
+ // ─── LIST ───
314
+
315
+ async function handleList(
316
+ c: Context<HonoEnv>,
317
+ resolved: PgResolvedDb,
318
+ tableName: string,
319
+ tableConfig: TableConfig,
320
+ auth: AuthContext | null,
321
+ isServiceKey: boolean,
322
+ query: PostgresExecutor,
323
+ ): Promise<Response> {
324
+ const tableAccess = getTableAccess(tableConfig);
325
+ if (!isServiceKey && tableAccess?.read === false) {
326
+ const error = forbiddenError('Access denied.');
327
+ return c.json(error.toJSON(), error.status as 403);
328
+ }
329
+
330
+ const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
331
+ const { sql, params, countSql, countParams } = buildListQuery(tableName, queryOpts, 'postgres');
332
+ const result = await query(sql, params);
333
+
334
+ // Apply read rules per row
335
+ let items = result.rows.map(r => stripInternalPgFields(r as Record<string, unknown>));
336
+ const tableHooks = getTableHooks(tableConfig);
337
+ if (!isServiceKey && tableAccess?.read !== undefined) {
338
+ const filtered: Record<string, unknown>[] = [];
339
+ for (const row of items) {
340
+ if (await evalRowRule(tableAccess.read, auth, row)) {
341
+ filtered.push(row);
342
+ }
343
+ }
344
+ items = filtered;
345
+ }
346
+
347
+ // Apply onEnrich hook
348
+ if (tableHooks?.onEnrich) {
349
+ const hookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx, query);
350
+ for (let i = 0; i < items.length; i++) {
351
+ try {
352
+ const enriched = await tableHooks.onEnrich(auth, items[i], hookCtx);
353
+ if (enriched && typeof enriched === 'object') items[i] = { ...items[i], ...enriched };
354
+ } catch (err) {
355
+ console.error(`[EdgeBase] onEnrich hook error for table "${tableName}":`, err);
356
+ }
357
+ }
358
+ }
359
+
360
+ // Get total count
361
+ let total: number | null = null;
362
+ const includeTotal = !['0', 'false'].includes((c.req.query('includeTotal') ?? '').toLowerCase());
363
+ if (includeTotal && countSql && countParams) {
364
+ const countResult = await query(countSql, countParams);
365
+ total = Number(countResult.rows[0]?.total ?? 0);
366
+ }
367
+
368
+ const perPage = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 20;
369
+ const page = queryOpts.pagination?.page ?? 1;
370
+ const hasMore = queryOpts.pagination?.after || queryOpts.pagination?.before
371
+ ? items.length >= perPage
372
+ : null;
373
+ const cursor = hasMore && items.length > 0
374
+ ? String((items[items.length - 1] as Record<string, unknown>).id ?? '')
375
+ : null;
376
+
377
+ return c.json({ items, total, hasMore, cursor, page: hasMore !== null ? null : page, perPage });
378
+ }
379
+
380
+ // ─── COUNT ───
381
+
382
+ async function handleCount(
383
+ c: Context<HonoEnv>,
384
+ resolved: PgResolvedDb,
385
+ tableName: string,
386
+ tableConfig: TableConfig,
387
+ _auth: AuthContext | null,
388
+ isServiceKey: boolean,
389
+ query: PostgresExecutor,
390
+ ): Promise<Response> {
391
+ const tableAccess = getTableAccess(tableConfig);
392
+ if (!isServiceKey && tableAccess?.read === false) {
393
+ const error = forbiddenError('Access denied.');
394
+ return c.json(error.toJSON(), error.status as 403);
395
+ }
396
+
397
+ const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
398
+ const { sql, params } = buildCountQuery(tableName, queryOpts.filters, queryOpts.orFilters, 'postgres');
399
+ const result = await query(sql, params);
400
+ const total = result.rows[0]?.total ?? 0;
401
+ return c.json({ total });
402
+ }
403
+
404
+ // ─── SEARCH ───
405
+
406
+ async function handleSearch(
407
+ c: Context<HonoEnv>,
408
+ resolved: PgResolvedDb,
409
+ tableName: string,
410
+ tableConfig: TableConfig,
411
+ auth: AuthContext | null,
412
+ isServiceKey: boolean,
413
+ query: PostgresExecutor,
414
+ ): Promise<Response> {
415
+ const tableAccess = getTableAccess(tableConfig);
416
+ if (!isServiceKey && tableAccess?.read === false) {
417
+ const error = forbiddenError('Access denied.');
418
+ return c.json(error.toJSON(), error.status as 403);
419
+ }
420
+
421
+ const queryOpts = parseQueryParams(Object.fromEntries(new URL(c.req.url).searchParams));
422
+ const searchTerm = queryOpts.search || '';
423
+ if (!searchTerm) {
424
+ return c.json({ code: 400, message: 'Search term is required (use ?search=)' }, 400);
425
+ }
426
+
427
+ // Use FTS fields from config, or fallback to text columns from schema
428
+ const ftsFields = tableConfig.fts?.length
429
+ ? tableConfig.fts
430
+ : getTextFields(tableConfig);
431
+ const limit = queryOpts.pagination?.limit ?? queryOpts.pagination?.perPage ?? 20;
432
+ const offset = queryOpts.pagination?.offset ?? ((queryOpts.pagination?.page ?? 1) - 1) * limit;
433
+ const searchQuery = buildSearchQuery(tableName, searchTerm, {
434
+ pagination: queryOpts.pagination,
435
+ filters: queryOpts.filters,
436
+ orFilters: queryOpts.orFilters,
437
+ sort: queryOpts.sort,
438
+ ftsFields,
439
+ }, 'postgres');
440
+
441
+ const result = await query(searchQuery.sql, searchQuery.params);
442
+ let items = result.rows.map(r => stripInternalPgFields(r as Record<string, unknown>));
443
+ let total = items.length;
444
+ if (searchQuery.countSql) {
445
+ const countResult = await query(searchQuery.countSql, searchQuery.countParams ?? []);
446
+ total = Number(countResult.rows[0]?.total ?? items.length);
447
+ }
448
+
449
+ // Apply read rules
450
+ if (!isServiceKey && tableAccess?.read !== undefined) {
451
+ const filtered: Record<string, unknown>[] = [];
452
+ for (const row of items) {
453
+ if (await evalRowRule(tableAccess.read, auth, row)) {
454
+ filtered.push(row);
455
+ }
456
+ }
457
+ items = filtered;
458
+ }
459
+
460
+ return c.json({ items, total, hasMore: total > offset + items.length, cursor: null, page: null, perPage: limit });
461
+ }
462
+
463
+ // ─── GET ───
464
+
465
+ async function handleGet(
466
+ c: Context<HonoEnv>,
467
+ resolved: PgResolvedDb,
468
+ tableName: string,
469
+ tableConfig: TableConfig,
470
+ id: string,
471
+ auth: AuthContext | null,
472
+ isServiceKey: boolean,
473
+ query: PostgresExecutor,
474
+ ): Promise<Response> {
475
+ const fieldsParam = new URL(c.req.url).searchParams.get('fields');
476
+ const fields = fieldsParam ? fieldsParam.split(',').map(f => f.trim()) : undefined;
477
+
478
+ const { sql, params } = buildGetQuery(tableName, id, fields, 'postgres');
479
+ const result = await query(sql, params);
480
+
481
+ if (result.rows.length === 0) {
482
+ return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
483
+ }
484
+
485
+ const row = stripInternalPgFields(result.rows[0] as Record<string, unknown>);
486
+
487
+ // Check read rule
488
+ const tableAccess = getTableAccess(tableConfig);
489
+ const tableHooks = getTableHooks(tableConfig);
490
+ if (!isServiceKey && tableAccess?.read !== undefined) {
491
+ if (!(await evalRowRule(tableAccess.read, auth, row))) {
492
+ return c.json({ code: 403, message: 'Access denied.' }, 403);
493
+ }
494
+ }
495
+
496
+ // Apply onEnrich hook
497
+ if (tableHooks?.onEnrich) {
498
+ const hookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx, query);
499
+ try {
500
+ const enriched = await tableHooks.onEnrich(auth, row, hookCtx);
501
+ if (enriched && typeof enriched === 'object') return c.json({ ...row, ...enriched });
502
+ } catch (err) {
503
+ console.error(`[EdgeBase] onEnrich hook error for table "${tableName}":`, err);
504
+ }
505
+ }
506
+
507
+ return c.json(row);
508
+ }
509
+
510
+ // ─── INSERT ───
511
+
512
+ async function handleInsert(
513
+ c: Context<HonoEnv>,
514
+ resolved: PgResolvedDb,
515
+ tableName: string,
516
+ tableConfig: TableConfig,
517
+ auth: AuthContext | null,
518
+ isServiceKey: boolean,
519
+ query: PostgresExecutor,
520
+ ): Promise<Response> {
521
+ let body: Record<string, unknown>;
522
+ try {
523
+ body = await c.req.json();
524
+ } catch {
525
+ return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
526
+ }
527
+
528
+ // Check insert rule
529
+ const tableAccess = getTableAccess(tableConfig);
530
+ const tableHooks = getTableHooks(tableConfig);
531
+ if (!isServiceKey && tableAccess?.insert !== undefined) {
532
+ if (!(await evalInsertRule(tableAccess.insert, auth))) {
533
+ return c.json({ code: 403, message: 'Insert not allowed.' }, 403);
534
+ }
535
+ }
536
+
537
+ // Validate against schema
538
+ const validation = validateInsert(body, tableConfig.schema);
539
+ if (!validation.valid) {
540
+ return c.json({
541
+ code: 400,
542
+ message: summarizeValidationErrors(validation.errors),
543
+ data: toFieldErrorData(validation.errors),
544
+ errors: validation.errors,
545
+ }, 400);
546
+ }
547
+
548
+ // Run beforeInsert hook
549
+ const requestHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx, query);
550
+ if (tableHooks?.beforeInsert) {
551
+ try {
552
+ const transformed = await tableHooks.beforeInsert(auth, body, requestHookCtx);
553
+ if (transformed && typeof transformed === 'object') {
554
+ body = { ...body, ...transformed };
555
+ }
556
+ } catch (err) {
557
+ const hookError = hookRejectedError(err, 'Insert rejected by beforeInsert hook.');
558
+ return c.json(hookError.toJSON(), hookError.status as 400);
559
+ }
560
+ }
561
+
562
+ const { data } = preparePgInsertData(body, tableConfig);
563
+
564
+ // Check upsert mode
565
+ const url = new URL(c.req.url);
566
+ const isUpsert = url.searchParams.get('upsert') === 'true';
567
+ const conflictTarget = url.searchParams.get('conflictTarget') || 'id';
568
+ let isUpdate = false;
569
+ let upsertBeforeRow: Record<string, unknown> | null = null;
570
+
571
+ if (isUpsert && data[conflictTarget] !== undefined) {
572
+ const checkSql = `SELECT * FROM ${escapePgIdentifier(tableName)} WHERE ${escapePgIdentifier(conflictTarget)} = $1 LIMIT 1`;
573
+ const checkResult = await query(checkSql, [data[conflictTarget]]);
574
+ isUpdate = checkResult.rows.length > 0;
575
+ upsertBeforeRow = isUpdate
576
+ ? stripInternalPgFields(checkResult.rows[0] as Record<string, unknown>)
577
+ : null;
578
+ }
579
+
580
+ // Build INSERT SQL
581
+ const columns = Object.keys(data);
582
+ const values = columns.map(col => data[col] ?? null);
583
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
584
+
585
+ let sql: string;
586
+ if (isUpsert) {
587
+ const setClauses = columns
588
+ .filter(col => col !== 'id' && col !== conflictTarget && col !== 'createdAt')
589
+ .map(col => `${escapePgIdentifier(col)} = EXCLUDED.${escapePgIdentifier(col)}`);
590
+ sql = `INSERT INTO ${escapePgIdentifier(tableName)} (${columns.map(escapePgIdentifier).join(', ')}) VALUES (${placeholders})` +
591
+ ` ON CONFLICT (${escapePgIdentifier(conflictTarget)}) DO UPDATE SET ${setClauses.join(', ')}` +
592
+ ` RETURNING *`;
593
+ } else {
594
+ sql = `INSERT INTO ${escapePgIdentifier(tableName)} (${columns.map(escapePgIdentifier).join(', ')}) VALUES (${placeholders}) RETURNING *`;
595
+ }
596
+
597
+ const result = await query(sql, values);
598
+ const inserted = stripInternalPgFields(result.rows[0] as Record<string, unknown>);
599
+
600
+ // Run afterInsert hook (fire-and-forget)
601
+ if (tableHooks?.afterInsert) {
602
+ const hook = tableHooks.afterInsert;
603
+ const backgroundHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx);
604
+ backgroundHookCtx.waitUntil(Promise.resolve(hook(inserted, backgroundHookCtx)).catch(() => {}));
605
+ }
606
+
607
+ // Emit database-live event (fire-and-forget)
608
+ c.executionCtx.waitUntil(
609
+ emitDbLiveEvent(
610
+ c.env,
611
+ resolved.namespace,
612
+ tableName,
613
+ isUpsert && isUpdate ? 'modified' : 'added',
614
+ String(inserted.id ?? ''),
615
+ inserted,
616
+ ),
617
+ );
618
+ c.executionCtx.waitUntil(
619
+ executeDbTriggers(
620
+ tableName,
621
+ isUpsert && isUpdate ? 'update' : 'insert',
622
+ isUpsert && isUpdate ? { before: upsertBeforeRow ?? inserted, after: inserted } : { after: inserted },
623
+ {
624
+ databaseNamespace: c.env.DATABASE,
625
+ authNamespace: c.env.AUTH,
626
+ kvNamespace: c.env.KV,
627
+ config: parseConfig(c.env),
628
+ env: c.env as never,
629
+ executionCtx: c.executionCtx as never,
630
+ },
631
+ { namespace: resolved.namespace },
632
+ ),
633
+ );
634
+
635
+ if (isUpsert) {
636
+ const statusCode = isUpdate ? 200 : 201;
637
+ const action = isUpdate ? 'updated' : 'inserted';
638
+ return c.json({ ...inserted, action }, statusCode as 200);
639
+ }
640
+
641
+ return c.json(inserted, 201);
642
+ }
643
+
644
+ // ─── UPDATE ───
645
+
646
+ async function handleUpdate(
647
+ c: Context<HonoEnv>,
648
+ resolved: PgResolvedDb,
649
+ tableName: string,
650
+ tableConfig: TableConfig,
651
+ id: string,
652
+ auth: AuthContext | null,
653
+ isServiceKey: boolean,
654
+ query: PostgresExecutor,
655
+ ): Promise<Response> {
656
+ let body: Record<string, unknown>;
657
+ try {
658
+ body = await c.req.json();
659
+ } catch {
660
+ return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
661
+ }
662
+
663
+ // Validate against schema
664
+ const validation = validateUpdate(body, tableConfig.schema);
665
+ if (!validation.valid) {
666
+ return c.json({
667
+ code: 400,
668
+ message: 'Validation failed.',
669
+ data: toFieldErrorData(validation.errors),
670
+ errors: validation.errors,
671
+ }, 400);
672
+ }
673
+
674
+ // Fetch existing record to check rules
675
+ const { sql: getSql, params: getParams } = buildGetQuery(tableName, id, undefined, 'postgres');
676
+ const existing = await query(getSql, getParams);
677
+ if (existing.rows.length === 0) {
678
+ return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
679
+ }
680
+ const existingRow = existing.rows[0] as Record<string, unknown>;
681
+
682
+ // Check update rule
683
+ const tableAccess = getTableAccess(tableConfig);
684
+ const tableHooks = getTableHooks(tableConfig);
685
+ if (!isServiceKey && tableAccess?.update !== undefined) {
686
+ if (!(await evalRowRule(tableAccess.update, auth, existingRow))) {
687
+ return c.json({ code: 403, message: 'Update not allowed.' }, 403);
688
+ }
689
+ }
690
+
691
+ // Run beforeUpdate hook
692
+ const requestHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx, query);
693
+ if (tableHooks?.beforeUpdate) {
694
+ try {
695
+ const transformed = await tableHooks.beforeUpdate(auth, existingRow, body, requestHookCtx);
696
+ if (transformed && typeof transformed === 'object') {
697
+ body = { ...body, ...transformed };
698
+ }
699
+ } catch (err) {
700
+ const hookError = hookRejectedError(err, 'Update rejected by beforeUpdate hook.');
701
+ return c.json(hookError.toJSON(), hookError.status as 400);
702
+ }
703
+ }
704
+
705
+ const { data } = preparePgUpdateData(body, tableConfig);
706
+
707
+ if (Object.keys(data).length === 0) {
708
+ return c.json({ code: 400, message: 'No valid fields to update.' }, 400);
709
+ }
710
+
711
+ const { setClauses, params, nextParamIndex } = parseUpdateBody(
712
+ data,
713
+ ['id'],
714
+ { dialect: 'postgres', startIndex: 1 },
715
+ );
716
+
717
+ const sql = `UPDATE ${escapePgIdentifier(tableName)} SET ${setClauses.join(', ')} WHERE "id" = $${nextParamIndex} RETURNING *`;
718
+ const result = await query(sql, [...params, id]);
719
+
720
+ if (result.rows.length === 0) {
721
+ return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
722
+ }
723
+
724
+ const updated = stripInternalPgFields(result.rows[0] as Record<string, unknown>);
725
+
726
+ // Run afterUpdate hook (fire-and-forget)
727
+ if (tableHooks?.afterUpdate) {
728
+ const hook = tableHooks.afterUpdate;
729
+ const backgroundHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx);
730
+ backgroundHookCtx.waitUntil(
731
+ Promise.resolve(hook(existingRow, updated, backgroundHookCtx)).catch(() => {}),
732
+ );
733
+ }
734
+
735
+ // Emit database-live event (fire-and-forget)
736
+ c.executionCtx.waitUntil(
737
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, 'modified', id, updated),
738
+ );
739
+ c.executionCtx.waitUntil(
740
+ executeDbTriggers(
741
+ tableName,
742
+ 'update',
743
+ {
744
+ before: existingRow,
745
+ after: updated,
746
+ },
747
+ {
748
+ databaseNamespace: c.env.DATABASE,
749
+ authNamespace: c.env.AUTH,
750
+ kvNamespace: c.env.KV,
751
+ config: parseConfig(c.env),
752
+ env: c.env as never,
753
+ executionCtx: c.executionCtx as never,
754
+ },
755
+ { namespace: resolved.namespace },
756
+ ),
757
+ );
758
+
759
+ return c.json(updated);
760
+ }
761
+
762
+ // ─── DELETE ───
763
+
764
+ async function handleDelete(
765
+ c: Context<HonoEnv>,
766
+ resolved: PgResolvedDb,
767
+ tableName: string,
768
+ tableConfig: TableConfig,
769
+ id: string,
770
+ auth: AuthContext | null,
771
+ isServiceKey: boolean,
772
+ query: PostgresExecutor,
773
+ ): Promise<Response> {
774
+ // Fetch existing record
775
+ const { sql: getSql, params: getParams } = buildGetQuery(tableName, id, undefined, 'postgres');
776
+ const existing = await query(getSql, getParams);
777
+ if (existing.rows.length === 0) {
778
+ return c.json({ code: 404, message: `Record '${id}' not found in '${tableName}'.` }, 404);
779
+ }
780
+ const existingRow = existing.rows[0] as Record<string, unknown>;
781
+
782
+ // Check delete rule
783
+ const tableAccess = getTableAccess(tableConfig);
784
+ const tableHooks = getTableHooks(tableConfig);
785
+ if (!isServiceKey && tableAccess?.delete !== undefined) {
786
+ if (!(await evalRowRule(tableAccess.delete, auth, existingRow))) {
787
+ return c.json({ code: 403, message: 'Delete not allowed.' }, 403);
788
+ }
789
+ }
790
+
791
+ // Run beforeDelete hook
792
+ const requestHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx, query);
793
+ if (tableHooks?.beforeDelete) {
794
+ try {
795
+ await tableHooks.beforeDelete(auth, existingRow, requestHookCtx);
796
+ } catch (err) {
797
+ const hookError = hookRejectedError(err, 'Delete rejected by beforeDelete hook.');
798
+ return c.json(hookError.toJSON(), hookError.status as 400);
799
+ }
800
+ }
801
+
802
+ // Execute DELETE
803
+ const sql = `DELETE FROM ${escapePgIdentifier(tableName)} WHERE "id" = $1 RETURNING *`;
804
+ const result = await query(sql, [id]);
805
+
806
+ if (result.rows.length === 0) {
807
+ return c.json({ code: 404, message: `Record '${id}' not found.` }, 404);
808
+ }
809
+
810
+ // Run afterDelete hook (fire-and-forget)
811
+ if (tableHooks?.afterDelete) {
812
+ const hook = tableHooks.afterDelete;
813
+ const backgroundHookCtx = buildHookCtx(resolved.connectionString, resolved.dbBlock.tables ?? {}, c.executionCtx);
814
+ backgroundHookCtx.waitUntil(
815
+ Promise.resolve(hook(existingRow, backgroundHookCtx)).catch(() => {}),
816
+ );
817
+ }
818
+
819
+ // Emit database-live event (fire-and-forget)
820
+ c.executionCtx.waitUntil(
821
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, 'removed', id, stripInternalPgFields(existingRow)),
822
+ );
823
+ c.executionCtx.waitUntil(
824
+ executeDbTriggers(
825
+ tableName,
826
+ 'delete',
827
+ { before: existingRow },
828
+ {
829
+ databaseNamespace: c.env.DATABASE,
830
+ authNamespace: c.env.AUTH,
831
+ kvNamespace: c.env.KV,
832
+ config: parseConfig(c.env),
833
+ env: c.env as never,
834
+ executionCtx: c.executionCtx as never,
835
+ },
836
+ { namespace: resolved.namespace },
837
+ ),
838
+ );
839
+
840
+ return c.json({ success: true, deleted: stripInternalPgFields(existingRow) });
841
+ }
842
+
843
+ // ─── BATCH ───
844
+
845
+ async function handleBatch(
846
+ c: Context<HonoEnv>,
847
+ resolved: PgResolvedDb,
848
+ tableName: string,
849
+ tableConfig: TableConfig,
850
+ auth: AuthContext | null,
851
+ isServiceKey: boolean,
852
+ query: PostgresExecutor,
853
+ ): Promise<Response> {
854
+ let body: {
855
+ items?: Record<string, unknown>[];
856
+ inserts?: Record<string, unknown>[];
857
+ updates?: { id: string; data: Record<string, unknown> }[];
858
+ deletes?: string[];
859
+ };
860
+ try {
861
+ body = await c.req.json();
862
+ } catch {
863
+ return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
864
+ }
865
+
866
+ const inserts = Array.isArray(body.inserts)
867
+ ? body.inserts
868
+ : Array.isArray(body.items)
869
+ ? body.items
870
+ : [];
871
+ const updates = Array.isArray(body.updates) ? body.updates : [];
872
+ const deletes = Array.isArray(body.deletes) ? body.deletes : [];
873
+
874
+ const totalOps = inserts.length + updates.length + deletes.length;
875
+ if (totalOps === 0) {
876
+ return c.json({ code: 400, message: 'items array is required and must not be empty' }, 400);
877
+ }
878
+
879
+ if (totalOps > 500) {
880
+ return c.json({ code: 400, message: 'Batch size cannot exceed 500 items.' }, 400);
881
+ }
882
+
883
+ if (updates.length > 0 || deletes.length > 0) {
884
+ return c.json({
885
+ code: 400,
886
+ message: 'PostgreSQL batch currently supports inserts/upserts only. Use batch-by-filter for updates/deletes.',
887
+ }, 400);
888
+ }
889
+
890
+ // Check insert rule (table-level, once)
891
+ const tableAccess = getTableAccess(tableConfig);
892
+ if (!isServiceKey && inserts.length > 0 && tableAccess?.insert !== undefined) {
893
+ if (!(await evalInsertRule(tableAccess.insert, auth))) {
894
+ return c.json({ code: 403, message: 'Insert not allowed.' }, 403);
895
+ }
896
+ }
897
+
898
+ const upsertMode = c.req.query('upsert') === 'true';
899
+ const conflictTarget = c.req.query('conflictTarget') || 'id';
900
+ const results: Record<string, unknown>[] = [];
901
+
902
+ for (const item of inserts) {
903
+ // Validate
904
+ const validation = validateInsert(item, tableConfig.schema);
905
+ if (!validation.valid) {
906
+ return c.json({
907
+ code: 400,
908
+ message: 'Validation failed.',
909
+ data: toFieldErrorData(validation.errors),
910
+ errors: validation.errors,
911
+ }, 400);
912
+ }
913
+
914
+ const { data } = preparePgInsertData(item, tableConfig);
915
+
916
+ const columns = Object.keys(data);
917
+ const values = columns.map(col => data[col] ?? null);
918
+ const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
919
+ let sql = `INSERT INTO ${escapePgIdentifier(tableName)} (${columns.map(escapePgIdentifier).join(', ')}) VALUES (${placeholders})`;
920
+ if (upsertMode) {
921
+ const updateCols = columns.filter((col) => col !== 'id' && col !== 'createdAt' && col !== conflictTarget);
922
+ if (updateCols.length > 0) {
923
+ const updateSet = updateCols
924
+ .map((col) => `${escapePgIdentifier(col)} = EXCLUDED.${escapePgIdentifier(col)}`)
925
+ .join(', ');
926
+ sql += ` ON CONFLICT (${escapePgIdentifier(conflictTarget)}) DO UPDATE SET ${updateSet}`;
927
+ } else {
928
+ sql += ` ON CONFLICT (${escapePgIdentifier(conflictTarget)}) DO NOTHING`;
929
+ }
930
+ }
931
+ sql += ' RETURNING *';
932
+
933
+ const result = await query(sql, values);
934
+ if (result.rows.length > 0) {
935
+ results.push(stripInternalPgFields(result.rows[0] as Record<string, unknown>));
936
+ }
937
+ }
938
+
939
+ // Emit batch database-live events
940
+ if (results.length > 0) {
941
+ const changes = results.map(r => ({
942
+ type: 'added' as const,
943
+ docId: String((r as Record<string, unknown>).id ?? ''),
944
+ data: r as Record<string, unknown>,
945
+ }));
946
+ if (changes.length >= 10) {
947
+ c.executionCtx.waitUntil(
948
+ emitDbLiveBatchEvent(c.env, resolved.namespace, tableName, changes),
949
+ );
950
+ } else {
951
+ for (const ch of changes) {
952
+ c.executionCtx.waitUntil(
953
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, ch.type, ch.docId, ch.data),
954
+ );
955
+ }
956
+ }
957
+ }
958
+
959
+ return c.json({
960
+ inserted: results,
961
+ items: results,
962
+ });
963
+ }
964
+
965
+ // ─── BATCH BY FILTER ───
966
+
967
+ async function handleBatchByFilter(
968
+ c: Context<HonoEnv>,
969
+ resolved: PgResolvedDb,
970
+ tableName: string,
971
+ tableConfig: TableConfig,
972
+ _auth: AuthContext | null,
973
+ isServiceKey: boolean,
974
+ query: PostgresExecutor,
975
+ ): Promise<Response> {
976
+ let body: {
977
+ action?: string;
978
+ filter?: FilterTuple[];
979
+ orFilter?: FilterTuple[];
980
+ update?: Record<string, unknown>;
981
+ data?: Record<string, unknown>;
982
+ limit?: number;
983
+ };
984
+ try {
985
+ body = await c.req.json();
986
+ } catch {
987
+ return c.json({ code: 400, message: 'Invalid JSON body' }, 400);
988
+ }
989
+
990
+ if (!body.action || !['delete', 'update'].includes(body.action)) {
991
+ return c.json({ code: 400, message: "batch-by-filter requires 'action' to be 'delete' or 'update'." }, 400);
992
+ }
993
+ if (!body.filter || !Array.isArray(body.filter)) {
994
+ return c.json({ code: 400, message: "batch-by-filter requires 'filter' to be a non-empty array." }, 400);
995
+ }
996
+ const updateData = body.update ?? body.data;
997
+ if (body.action === 'update' && !updateData) {
998
+ return c.json({ code: 400, message: "batch-by-filter with action 'update' requires 'update' data." }, 400);
999
+ }
1000
+
1001
+ const limit = Math.min(body.limit ?? 500, 500);
1002
+ const { sql: selectSql, params: selectParams } = buildListQuery(tableName, {
1003
+ filters: body.filter,
1004
+ orFilters: body.orFilter,
1005
+ pagination: { limit },
1006
+ fields: ['id'],
1007
+ }, 'postgres');
1008
+ const selectResult = await query(selectSql, selectParams);
1009
+ const allRows = selectResult.rows;
1010
+ const processed = allRows.length;
1011
+
1012
+ if (allRows.length === 0) {
1013
+ return c.json({ processed: 0, succeeded: 0 });
1014
+ }
1015
+
1016
+ const ids = allRows.map((row) => String((row as Record<string, unknown>).id));
1017
+ const idPlaceholders = ids.map((_, index) => `$${index + 1}`).join(', ');
1018
+ let succeeded = 0;
1019
+
1020
+ if (body.action === 'delete') {
1021
+ // Check delete rule at table level
1022
+ const tableAccess = getTableAccess(tableConfig);
1023
+ if (!isServiceKey && tableAccess?.delete !== undefined) {
1024
+ if (typeof tableAccess.delete === 'boolean' && !tableAccess.delete) {
1025
+ return c.json({ code: 403, message: 'Delete not allowed.' }, 403);
1026
+ }
1027
+ }
1028
+
1029
+ const sql = `DELETE FROM ${escapePgIdentifier(tableName)} WHERE "id" IN (${idPlaceholders}) RETURNING *`;
1030
+ const result = await query(sql, ids);
1031
+ succeeded = result.rowCount;
1032
+
1033
+ if (succeeded > 0) {
1034
+ c.executionCtx.waitUntil(
1035
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, 'removed', '_bulk', { action: 'delete', count: succeeded }),
1036
+ );
1037
+ }
1038
+
1039
+ return c.json({
1040
+ processed,
1041
+ succeeded,
1042
+ deleted: result.rowCount,
1043
+ items: result.rows.map(r => stripInternalPgFields(r as Record<string, unknown>)),
1044
+ });
1045
+ }
1046
+
1047
+ // action === 'update'
1048
+ if (!updateData || Object.keys(updateData).length === 0) {
1049
+ return c.json({ code: 400, message: 'data is required for update action.' }, 400);
1050
+ }
1051
+
1052
+ // Check update rule at table level
1053
+ const tableAccess = getTableAccess(tableConfig);
1054
+ if (!isServiceKey && tableAccess?.update !== undefined) {
1055
+ if (typeof tableAccess.update === 'boolean' && !tableAccess.update) {
1056
+ return c.json({ code: 403, message: 'Update not allowed.' }, 403);
1057
+ }
1058
+ }
1059
+
1060
+ const prepared = preparePgUpdateData(updateData, tableConfig).data;
1061
+ if (Object.keys(prepared).length === 0) {
1062
+ return c.json({ code: 400, message: 'No valid fields to update.' }, 400);
1063
+ }
1064
+
1065
+ const { setClauses, params: updateValues } = parseUpdateBody(
1066
+ prepared,
1067
+ ['id'],
1068
+ { dialect: 'postgres', startIndex: ids.length + 1 },
1069
+ );
1070
+ const updateParams = [...ids, ...updateValues];
1071
+
1072
+ const sql = `UPDATE ${escapePgIdentifier(tableName)} SET ${setClauses.join(', ')} WHERE "id" IN (${idPlaceholders}) RETURNING *`;
1073
+ const result = await query(sql, updateParams);
1074
+ succeeded = result.rowCount;
1075
+
1076
+ if (succeeded > 0) {
1077
+ c.executionCtx.waitUntil(
1078
+ emitDbLiveEvent(c.env, resolved.namespace, tableName, 'modified', '_bulk', { action: 'update', count: succeeded }),
1079
+ );
1080
+ }
1081
+
1082
+ return c.json({
1083
+ processed,
1084
+ succeeded,
1085
+ updated: result.rowCount,
1086
+ items: result.rows.map(r => stripInternalPgFields(r as Record<string, unknown>)),
1087
+ });
1088
+ }
1089
+
1090
+ // ─── Helpers ───
1091
+
1092
+ function getTextFields(config: TableConfig): string[] {
1093
+ if (!config.schema) return ['id'];
1094
+ const fields: string[] = [];
1095
+ for (const [name, field] of Object.entries(config.schema)) {
1096
+ if (field === false) continue;
1097
+ if (field.type === 'string' || field.type === 'text') {
1098
+ fields.push(name);
1099
+ }
1100
+ }
1101
+ return fields.length > 0 ? fields : ['id'];
1102
+ }