@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,2091 @@
1
+ /**
2
+ * App Functions runtime — context builder, function registry, and Cross-DO proxy.
3
+ *
4
+ * Functions execute with injected context:
5
+ * - `admin` — AdminEdgeBase-compatible SDK instance
6
+ * - `auth` — current user info (from request JWT)
7
+ * - `storage` — optional R2 storage client
8
+ * - `analytics`— optional analytics adapter
9
+ * - `data` — trigger event data (DB triggers: before/after)
10
+ * - `request` — raw HTTP request (HTTP triggers)
11
+ */
12
+ // DurableObjectNamespace & D1Database come from the global
13
+ // @cloudflare/workers-types registered in tsconfig.json "types".
14
+ // Importing them from the module directly causes type incompatibility
15
+ // because TypeScript treats the global ambient type and the module
16
+ // export as distinct types (Request.headers missing getSetCookie, etc.).
17
+ import * as authService from './auth-d1-service.js';
18
+ import type { EdgeBaseConfig } from '@edge-base/shared';
19
+ import type {
20
+ FunctionDefinition,
21
+ FunctionTrigger,
22
+ DbTrigger,
23
+ AuthTrigger,
24
+ StorageTrigger,
25
+ ScheduleTrigger,
26
+ HttpTrigger,
27
+ } from '@edge-base/shared';
28
+ import { getDbDoName, getD1BindingName, callDO, shouldRouteToD1 } from './do-router.js';
29
+ import { executeDoSql } from './do-sql.js';
30
+ import { D1AuthDb, type AuthDb } from './auth-db-adapter.js';
31
+ import { handleD1Request } from './d1-handler.js';
32
+ import { handlePgRequest } from './postgres-handler.js';
33
+ import { buildInternalHandlerContext } from './internal-request.js';
34
+ import type { Env } from '../types.js';
35
+ import { createSignedToken } from '../routes/storage.js';
36
+ import {
37
+ deleteManagedAdminUser,
38
+ normalizeAdminUserUpdates,
39
+ updateManagedAdminUser,
40
+ } from './admin-user-management.js';
41
+
42
+ // ─── Function Context Types ───
43
+
44
+ export interface AuthContext {
45
+ id: string;
46
+ email?: string;
47
+ isAnonymous?: boolean;
48
+ custom?: Record<string, unknown>;
49
+ }
50
+
51
+ export interface TableProxy {
52
+ insert(data: Record<string, unknown>): Promise<Record<string, unknown>>;
53
+ upsert(
54
+ data: Record<string, unknown>,
55
+ options?: { conflictTarget?: string },
56
+ ): Promise<Record<string, unknown>>;
57
+ update(id: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
58
+ delete(id: string): Promise<{ deleted: boolean }>;
59
+ get(id: string): Promise<Record<string, unknown>>;
60
+ list(options?: {
61
+ limit?: number;
62
+ filter?: unknown;
63
+ }): Promise<{ items: Record<string, unknown>[] }>;
64
+ }
65
+
66
+ export interface AdminAuthContext {
67
+ getUser(userId: string): Promise<Record<string, unknown>>;
68
+ listUsers(options?: {
69
+ limit?: number;
70
+ cursor?: string;
71
+ }): Promise<{ users: Record<string, unknown>[]; cursor?: string }>;
72
+ createUser(data: {
73
+ email: string;
74
+ password: string;
75
+ displayName?: string;
76
+ role?: string;
77
+ }): Promise<Record<string, unknown>>;
78
+ updateUser(userId: string, data: Record<string, unknown>): Promise<Record<string, unknown>>;
79
+ deleteUser(userId: string): Promise<void>;
80
+ setCustomClaims(userId: string, claims: Record<string, unknown>): Promise<void>;
81
+ revokeAllSessions(userId: string): Promise<void>;
82
+ }
83
+
84
+ /**
85
+ * AdminEdgeBase-shaped context object injected as context.admin.
86
+ *
87
+ * Matches the external AdminEdgeBase API surface — table, storage, auth, sql, broadcast.
88
+ * Built internally via HTTP fetch to the current Worker URL (no @edge-base/sdk import needed).
89
+ */
90
+ export interface FunctionAdminContext {
91
+ /** Cross-DO table access — rules bypassed, Service Key authenticated. */
92
+ table(name: string): TableProxy;
93
+ /**
94
+ * Access a specific DB namespace instance (§5).
95
+ * Replaces forTenant() — aligned with Database-first architecture (#133).
96
+ *
97
+ * @example
98
+ * // Static DB
99
+ * context.admin.db('shared').table('posts').list()
100
+ * // Dynamic DB (tenant/user)
101
+ * context.admin.db('workspace', 'ws-456').table('documents').list()
102
+ */
103
+ db(namespace: string, id?: string): { table(name: string): TableProxy };
104
+ /** Admin user management. */
105
+ auth: AdminAuthContext;
106
+ /** Raw SQL on a DB namespace DO. */
107
+ sql(
108
+ namespace: string,
109
+ id: string | undefined,
110
+ query: string,
111
+ params?: unknown[],
112
+ ): Promise<unknown[]>;
113
+ /** Server-side broadcast to a database-live channel. */
114
+ broadcast(channel: string, event: string, payload?: Record<string, unknown>): Promise<void>;
115
+ /** Inter-function calls. Calls another registered function by name. */
116
+ functions: {
117
+ call(name: string, data?: unknown): Promise<unknown>;
118
+ };
119
+ /** Access a user-defined KV namespace. */
120
+ kv(namespace: string): FunctionKvProxy;
121
+ /** Access a user-defined D1 database. */
122
+ d1(database: string): FunctionD1Proxy;
123
+ /** Access a user-defined Vectorize index. */
124
+ vector(index: string): FunctionVectorizeProxy;
125
+ /** Push notification management. */
126
+ push: FunctionPushProxy;
127
+ }
128
+
129
+ /** Push notification proxy — routes through Worker HTTP. */
130
+ export interface FunctionPushProxy {
131
+ /** Send a push notification to a single user's devices. */
132
+ send(
133
+ userId: string,
134
+ payload: Record<string, unknown>,
135
+ ): Promise<{ sent: number; failed: number; removed: number }>;
136
+ /** Send push notifications to multiple users (no limit — server chunks internally at 500). */
137
+ sendMany(
138
+ userIds: string[],
139
+ payload: Record<string, unknown>,
140
+ ): Promise<{ sent: number; failed: number; removed: number }>;
141
+ /** Get registered device tokens for a user — token values NOT exposed. */
142
+ getTokens(
143
+ userId: string,
144
+ ): Promise<
145
+ Array<{
146
+ deviceId: string;
147
+ platform: string;
148
+ updatedAt: string;
149
+ deviceInfo?: Record<string, string>;
150
+ metadata?: Record<string, unknown>;
151
+ }>
152
+ >;
153
+ /** Get push send logs for a user (last 24h,). */
154
+ getLogs(
155
+ userId: string,
156
+ limit?: number,
157
+ ): Promise<
158
+ Array<{
159
+ sentAt: string;
160
+ userId: string;
161
+ platform: string;
162
+ status: string;
163
+ collapseId?: string;
164
+ error?: string;
165
+ }>
166
+ >;
167
+ /** Send push directly using an FCM token (bypasses KV storage). Service Key only. */
168
+ sendToToken(
169
+ token: string,
170
+ payload: Record<string, unknown>,
171
+ platform?: string,
172
+ ): Promise<{ sent: number; failed: number; error?: string }>;
173
+ /** Send to an FCM topic. Service Key only. */
174
+ sendToTopic(
175
+ topic: string,
176
+ payload: Record<string, unknown>,
177
+ ): Promise<{ success: boolean; error?: string }>;
178
+ /** Broadcast to all devices via /topics/all. Service Key only. */
179
+ broadcast(payload: Record<string, unknown>): Promise<{ success: boolean; error?: string }>;
180
+ }
181
+
182
+ /** Storage proxy for App Functions — wraps R2Bucket with convenience methods. */
183
+ export interface FunctionStorageProxy {
184
+ put(key: string, value: ReadableStream | ArrayBuffer | string, options?: { contentType?: string; customMetadata?: Record<string, string> }): Promise<void>;
185
+ get(key: string): Promise<{ body: ReadableStream; contentType: string; size: number; customMetadata: Record<string, string> } | null>;
186
+ delete(key: string): Promise<void>;
187
+ getSignedUrl(key: string, options?: { expiresIn?: number }): Promise<string>;
188
+ list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ keys: Array<{ key: string; size: number; contentType: string }>; cursor?: string; truncated: boolean }>;
189
+ head(key: string): Promise<{ key: string; size: number; contentType: string; customMetadata: Record<string, string> } | null>;
190
+ }
191
+
192
+ /** KV proxy for App Functions — routes through Worker HTTP. */
193
+ export interface FunctionKvProxy {
194
+ get(key: string): Promise<string | null>;
195
+ set(key: string, value: string, options?: { ttl?: number }): Promise<void>;
196
+ delete(key: string): Promise<void>;
197
+ list(options?: {
198
+ prefix?: string;
199
+ limit?: number;
200
+ cursor?: string;
201
+ }): Promise<{ keys: string[]; cursor?: string }>;
202
+ }
203
+
204
+ /** D1 proxy for App Functions — routes through Worker HTTP. */
205
+ export interface FunctionD1Proxy {
206
+ exec<T = Record<string, unknown>>(query: string, params?: unknown[]): Promise<T[]>;
207
+ }
208
+
209
+ /** Vectorize proxy for App Functions — routes through Worker HTTP. */
210
+ export interface FunctionVectorizeProxy {
211
+ upsert(
212
+ vectors: Array<{
213
+ id: string;
214
+ values: number[];
215
+ metadata?: Record<string, unknown>;
216
+ namespace?: string;
217
+ }>,
218
+ ): Promise<{ ok: true; count?: number; mutationId?: string }>;
219
+ insert(
220
+ vectors: Array<{
221
+ id: string;
222
+ values: number[];
223
+ metadata?: Record<string, unknown>;
224
+ namespace?: string;
225
+ }>,
226
+ ): Promise<{ ok: true; count?: number; mutationId?: string }>;
227
+ search(
228
+ vector: number[],
229
+ options?: {
230
+ topK?: number;
231
+ filter?: Record<string, unknown>;
232
+ namespace?: string;
233
+ returnValues?: boolean;
234
+ returnMetadata?: boolean | 'all' | 'indexed' | 'none';
235
+ },
236
+ ): Promise<
237
+ Array<{
238
+ id: string;
239
+ score: number;
240
+ values?: number[];
241
+ metadata?: Record<string, unknown>;
242
+ namespace?: string;
243
+ }>
244
+ >;
245
+ queryById(
246
+ vectorId: string,
247
+ options?: {
248
+ topK?: number;
249
+ filter?: Record<string, unknown>;
250
+ namespace?: string;
251
+ returnValues?: boolean;
252
+ returnMetadata?: boolean | 'all' | 'indexed' | 'none';
253
+ },
254
+ ): Promise<
255
+ Array<{
256
+ id: string;
257
+ score: number;
258
+ values?: number[];
259
+ metadata?: Record<string, unknown>;
260
+ namespace?: string;
261
+ }>
262
+ >;
263
+ getByIds(
264
+ ids: string[],
265
+ ): Promise<
266
+ Array<{ id: string; values?: number[]; metadata?: Record<string, unknown>; namespace?: string }>
267
+ >;
268
+ delete(ids: string[]): Promise<{ ok: true; count?: number; mutationId?: string }>;
269
+ describe(): Promise<{
270
+ vectorCount: number;
271
+ dimensions: number;
272
+ metric: string;
273
+ id?: string;
274
+ name?: string;
275
+ processedUpToDatetime?: string;
276
+ processedUpToMutation?: string;
277
+ }>;
278
+ }
279
+
280
+ /**
281
+ * App Function execution context.
282
+ * §5: legacy top-level auth helpers removed. Use `context.admin.db(namespace, id?)`
283
+ * and `context.admin.auth` exclusively.
284
+ */
285
+ export interface FunctionContext {
286
+ request: Request;
287
+ auth: AuthContext | null;
288
+ /**
289
+ * Server-side EdgeBase admin client (§5,).
290
+ * Use context.admin.db(namespace, id?).table(name) for all DB access.
291
+ *
292
+ * @example
293
+ * // Static DB
294
+ * await context.admin.db('shared').table('posts').list()
295
+ * // Dynamic DB
296
+ * await context.admin.db('workspace', 'ws-456').table('documents').list()
297
+ */
298
+ admin: FunctionAdminContext;
299
+ /**
300
+ * URL path parameters extracted from file-system routing.
301
+ * For dynamic routes like `functions/users/[userId]/posts/[postId].ts`,
302
+ * params will contain `{ userId: '...', postId: '...' }`.
303
+ *
304
+ * @example
305
+ * // functions/users/[userId].ts → GET /api/functions/users/abc123
306
+ * context.params.userId // 'abc123'
307
+ */
308
+ params: Record<string, string>;
309
+ /**
310
+ * Trigger metadata (§5).
311
+ * For DB triggers: namespace + id of the DO that fired the trigger.
312
+ * For schedule/http/auth triggers: namespace and id are undefined.
313
+ */
314
+ trigger?: {
315
+ namespace: string;
316
+ id?: string;
317
+ /** DB trigger: table name. */
318
+ table?: string;
319
+ /** DB trigger: event type. */
320
+ event?: 'insert' | 'update' | 'delete';
321
+ };
322
+ data?: { before?: Record<string, unknown>; after?: Record<string, unknown> };
323
+ before?: Record<string, unknown>;
324
+ after?: Record<string, unknown>;
325
+ storage?: FunctionStorageProxy; // Available when R2 binding present
326
+ analytics?: unknown; // AnalyticsAdapter when ANALYTICS_APP binding present
327
+ /** Plugin-specific config from edgebase.config.ts plugins section. */
328
+ pluginConfig?: Record<string, unknown>;
329
+ }
330
+
331
+ // ─── Function Registry ───
332
+
333
+ /**
334
+ * In-memory function registry.
335
+ * Populated at Worker startup from bundled config.
336
+ * Key: function name or function name + HTTP method, Value: FunctionDefinition.
337
+ */
338
+ const functionRegistry = new Map<string, FunctionDefinition>();
339
+
340
+ const HTTP_REGISTRY_SEPARATOR = '::';
341
+
342
+ function buildRegistryKey(name: string, def: FunctionDefinition): string {
343
+ if (def.trigger.type !== 'http') return name;
344
+ const trigger = def.trigger as unknown as { method?: string };
345
+ const method = (trigger.method || '*').toUpperCase();
346
+ return `${name}${HTTP_REGISTRY_SEPARATOR}${method}`;
347
+ }
348
+
349
+ function getRegistryName(key: string, def: FunctionDefinition): string {
350
+ if (def.trigger.type !== 'http') return key;
351
+ const separatorIndex = key.lastIndexOf(HTTP_REGISTRY_SEPARATOR);
352
+ return separatorIndex === -1 ? key : key.slice(0, separatorIndex);
353
+ }
354
+
355
+ export function registerFunction(name: string, def: FunctionDefinition): void {
356
+ functionRegistry.set(buildRegistryKey(name, def), def);
357
+ }
358
+
359
+ export function getRegisteredFunctions(): Map<string, FunctionDefinition> {
360
+ return functionRegistry;
361
+ }
362
+
363
+ /** Clear all registered functions — used for test isolation. */
364
+ export function clearFunctionRegistry(): void {
365
+ functionRegistry.clear();
366
+ }
367
+
368
+ export function getFunctionsByTrigger(
369
+ type: FunctionTrigger['type'],
370
+ match?: Partial<DbTrigger | AuthTrigger | StorageTrigger | ScheduleTrigger | HttpTrigger>,
371
+ ): Array<{ name: string; definition: FunctionDefinition }> {
372
+ const results: Array<{ name: string; definition: FunctionDefinition }> = [];
373
+ for (const [key, def] of functionRegistry) {
374
+ if (def.trigger.type !== type) continue;
375
+ const name = getRegistryName(key, def);
376
+ if (match) {
377
+ let matched = true;
378
+ for (const [key, value] of Object.entries(match)) {
379
+ if (key === 'type') continue;
380
+ if ((def.trigger as unknown as Record<string, unknown>)[key] !== value) {
381
+ matched = false;
382
+ break;
383
+ }
384
+ }
385
+ if (!matched) continue;
386
+ }
387
+ results.push({ name, definition: def });
388
+ }
389
+ return results;
390
+ }
391
+
392
+ // ─── Route Pattern Matching (File-System Routing) ───
393
+
394
+ interface CompiledRoute {
395
+ name: string;
396
+ /** Matched HTTP path relative to /api/functions (trigger.path or route name). */
397
+ path: string;
398
+ definition: FunctionDefinition;
399
+ /** HTTP method constraint — null means any method */
400
+ method: string | null;
401
+ /** Regex pattern for matching URL paths */
402
+ pattern: RegExp;
403
+ /** Ordered param names extracted from pattern */
404
+ paramNames: string[];
405
+ /** Whether this route has dynamic segments */
406
+ isDynamic: boolean;
407
+ /** Specificity score for ordering (higher = more specific) */
408
+ specificity: number;
409
+ }
410
+
411
+ const compiledRoutes: CompiledRoute[] = [];
412
+
413
+ function escapeRegex(str: string): string {
414
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
415
+ }
416
+
417
+ function normalizeHttpRoutePath(path: string): string {
418
+ const trimmed = path.trim();
419
+ if (!trimmed) return '';
420
+ return trimmed.replace(/^\/+/, '').replace(/\/+$/, '');
421
+ }
422
+
423
+ /**
424
+ * Compile a route name (e.g., 'users/[userId]/posts/[postId]') into a regex pattern.
425
+ */
426
+ function compileRoutePattern(name: string): {
427
+ pattern: RegExp;
428
+ paramNames: string[];
429
+ isDynamic: boolean;
430
+ } {
431
+ const paramNames: string[] = [];
432
+ let isDynamic = false;
433
+
434
+ if (name === '' || name === '/') {
435
+ return { pattern: /^$/, paramNames: [], isDynamic: false };
436
+ }
437
+
438
+ const segments = name.split('/');
439
+ const regexParts: string[] = [];
440
+
441
+ for (const segment of segments) {
442
+ // Catch-all: [...slug]
443
+ const catchAllMatch = segment.match(/^\[\.\.\.([^\]]+)\]$/);
444
+ if (catchAllMatch) {
445
+ paramNames.push(catchAllMatch[1]);
446
+ regexParts.push('(.+)');
447
+ isDynamic = true;
448
+ continue;
449
+ }
450
+
451
+ // Dynamic param: [userId]
452
+ const paramMatch = segment.match(/^\[([^\]]+)\]$/);
453
+ if (paramMatch) {
454
+ paramNames.push(paramMatch[1]);
455
+ regexParts.push('([^/]+)');
456
+ isDynamic = true;
457
+ continue;
458
+ }
459
+
460
+ // Express-style dynamic param in custom trigger.path: :userId
461
+ const colonParamMatch = segment.match(/^:([^/]+)$/);
462
+ if (colonParamMatch) {
463
+ paramNames.push(colonParamMatch[1]);
464
+ regexParts.push('([^/]+)');
465
+ isDynamic = true;
466
+ continue;
467
+ }
468
+
469
+ // Static segment
470
+ regexParts.push(escapeRegex(segment));
471
+ }
472
+
473
+ const pattern = new RegExp(`^${regexParts.join('/')}$`);
474
+ return { pattern, paramNames, isDynamic };
475
+ }
476
+
477
+ /**
478
+ * Calculate route specificity for ordering.
479
+ * Static segments score higher than dynamic ones. More segments = higher score.
480
+ */
481
+ function calculateSpecificity(name: string): number {
482
+ if (name === '' || name === '/') return 0;
483
+ const segments = name.split('/');
484
+ let score = segments.length * 100;
485
+ for (const seg of segments) {
486
+ if (seg.match(/^\[\.\.\.([^\]]+)\]$/)) {
487
+ score -= 50; // Catch-all is least specific
488
+ } else if (seg.match(/^\[([^\]]+)\]$/)) {
489
+ score -= 10; // Dynamic param
490
+ } else {
491
+ score += 10; // Static segment bonus
492
+ }
493
+ }
494
+ return score;
495
+ }
496
+
497
+ /**
498
+ * Rebuild compiled routes from the function registry.
499
+ * Must be called after all functions are registered (after initFunctionRegistry()).
500
+ */
501
+ export function rebuildCompiledRoutes(): void {
502
+ compiledRoutes.length = 0;
503
+ const seenRoutes = new Map<string, string>();
504
+
505
+ for (const [key, def] of functionRegistry) {
506
+ if (def.trigger.type !== 'http') continue;
507
+ const name = getRegistryName(key, def);
508
+
509
+ const trigger = def.trigger as unknown as { type: string; method?: string; path?: string };
510
+ const routePath = normalizeHttpRoutePath(trigger.path ?? name);
511
+ const { pattern, paramNames, isDynamic } = compileRoutePattern(routePath);
512
+ const routeKey = `${(trigger.method || '*').toUpperCase()}:${pattern.source}`;
513
+ const previous = seenRoutes.get(routeKey);
514
+ if (previous) {
515
+ throw new Error(
516
+ `HTTP route collision: '${name}' and '${previous}' both resolve to ` +
517
+ `'/${routePath || ''}' for method ${(trigger.method || '*').toUpperCase()}.`,
518
+ );
519
+ }
520
+ seenRoutes.set(routeKey, name);
521
+
522
+ compiledRoutes.push({
523
+ name,
524
+ path: routePath,
525
+ definition: def,
526
+ method: trigger.method || null,
527
+ pattern,
528
+ paramNames,
529
+ isDynamic,
530
+ specificity: calculateSpecificity(routePath),
531
+ });
532
+ }
533
+
534
+ // Sort by specificity (most specific first)
535
+ compiledRoutes.sort((a, b) => b.specificity - a.specificity);
536
+ }
537
+
538
+ /**
539
+ * Match a request path against compiled routes.
540
+ * Returns the matched route and extracted params, or null if no match.
541
+ */
542
+ export function matchRoute(
543
+ path: string,
544
+ method: string,
545
+ ): { route: CompiledRoute; params: Record<string, string> } | null {
546
+ for (const route of compiledRoutes) {
547
+ // Method check (skip if method doesn't match)
548
+ if (route.method && route.method.toUpperCase() !== method.toUpperCase()) continue;
549
+
550
+ const match = route.pattern.exec(path);
551
+ if (!match) continue;
552
+
553
+ // Extract params
554
+ const params: Record<string, string> = {};
555
+ for (let i = 0; i < route.paramNames.length; i++) {
556
+ params[route.paramNames[i]] = decodeURIComponent(match[i + 1]);
557
+ }
558
+
559
+ return { route, params };
560
+ }
561
+
562
+ return null;
563
+ }
564
+
565
+ /**
566
+ * Check if a route exists for a given path (any method).
567
+ * Used for 405 detection.
568
+ */
569
+ export function routeExistsForPath(path: string): boolean {
570
+ for (const route of compiledRoutes) {
571
+ if (route.pattern.test(path)) return true;
572
+ }
573
+ return false;
574
+ }
575
+
576
+ // ─── Middleware Registry ───
577
+
578
+ const middlewareRegistry = new Map<string, (context: unknown) => Promise<unknown>>();
579
+
580
+ /**
581
+ * Register a directory middleware handler.
582
+ * @param dirPath Directory path relative to functions/ (empty string = root)
583
+ * @param handler Middleware handler (default export)
584
+ */
585
+ export function registerMiddleware(
586
+ dirPath: string,
587
+ handler: { default?: (ctx: unknown) => Promise<unknown> } | ((ctx: unknown) => Promise<unknown>),
588
+ ): void {
589
+ const fn =
590
+ typeof handler === 'function'
591
+ ? handler
592
+ : ((handler.default ?? handler) as (ctx: unknown) => Promise<unknown>);
593
+ middlewareRegistry.set(dirPath, fn);
594
+ }
595
+
596
+ /** Clear middleware registry — used for test isolation. */
597
+ export function clearMiddlewareRegistry(): void {
598
+ middlewareRegistry.clear();
599
+ }
600
+
601
+ /**
602
+ * Get middleware chain for a given function path.
603
+ * Returns middlewares ordered from root to most specific directory.
604
+ *
605
+ * Example: for function 'admin/users/[userId]':
606
+ * 1. middleware at '' (root)
607
+ * 2. middleware at 'admin'
608
+ * 3. middleware at 'admin/users'
609
+ */
610
+ export function getMiddlewareChain(
611
+ functionName: string,
612
+ ): Array<(context: unknown) => Promise<unknown>> {
613
+ const chain: Array<(context: unknown) => Promise<unknown>> = [];
614
+
615
+ // Check root middleware
616
+ const rootMw = middlewareRegistry.get('');
617
+ if (rootMw) chain.push(rootMw);
618
+
619
+ // Check each directory level
620
+ if (functionName) {
621
+ const parts = functionName.split('/');
622
+ let dirPath = '';
623
+ // Iterate directory parts (all segments except the last, which is the file)
624
+ for (let i = 0; i < parts.length - 1; i++) {
625
+ dirPath = dirPath ? `${dirPath}/${parts[i]}` : parts[i];
626
+ const mw = middlewareRegistry.get(dirPath);
627
+ if (mw) chain.push(mw);
628
+ }
629
+ }
630
+
631
+ return chain;
632
+ }
633
+
634
+ /**
635
+ * Wrap a method-export handler (from named export like GET, POST) into a FunctionDefinition.
636
+ * Called by generated registry code.
637
+ */
638
+ export function wrapMethodExport(
639
+ handler:
640
+ | FunctionDefinition
641
+ | {
642
+ handler?: (ctx: unknown) => Promise<unknown>;
643
+ captcha?: boolean;
644
+ trigger?: { path?: string };
645
+ }
646
+ | ((ctx: unknown) => Promise<unknown>),
647
+ method: string,
648
+ runtimeTrigger?: { path?: string },
649
+ ): FunctionDefinition {
650
+ // handler can be: raw function, or { handler, captcha? } from defineFunction()
651
+ let fn: (ctx: unknown) => Promise<unknown>;
652
+ let captcha: boolean | undefined;
653
+ let path: string | undefined;
654
+
655
+ if (typeof handler === 'function') {
656
+ fn = handler;
657
+ } else if (handler && typeof handler === 'object') {
658
+ fn = handler.handler ?? (handler as unknown as (ctx: unknown) => Promise<unknown>);
659
+ captcha = handler.captcha;
660
+ if ('trigger' in handler && handler.trigger && typeof handler.trigger === 'object' && 'path' in handler.trigger) {
661
+ const triggerPath = handler.trigger.path;
662
+ path = typeof triggerPath === 'string' ? triggerPath : undefined;
663
+ }
664
+ } else {
665
+ fn = handler as (ctx: unknown) => Promise<unknown>;
666
+ }
667
+
668
+ return {
669
+ trigger: {
670
+ type: 'http',
671
+ method: method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
672
+ ...(runtimeTrigger?.path ? { path: runtimeTrigger.path } : path ? { path } : {}),
673
+ },
674
+ captcha,
675
+ handler: fn,
676
+ };
677
+ }
678
+
679
+ // ─── Table Proxy (Cross-DO) ───
680
+
681
+ /**
682
+ * Build a cross-DO table proxy.
683
+ * All calls bypass access rules by using Service Key.
684
+ */
685
+ function buildTableProxy(
686
+ tableName: string,
687
+ namespace: DurableObjectNamespace,
688
+ config: EdgeBaseConfig,
689
+ workerUrl?: string,
690
+ serviceKey?: string,
691
+ env?: Env,
692
+ executionCtx?: ExecutionContext,
693
+ /** DB routing info (§5). If provided, overrides auto-detect. */
694
+ dbTarget?: { namespace: string; id?: string; doName: string },
695
+ routingOptions?: { preferDirectDo?: boolean },
696
+ ): TableProxy {
697
+ // Determine DO name: explicit DB target (§5) or auto-detect from config
698
+ let resolvedTarget: { namespace: string; id?: string; doName: string };
699
+ if (dbTarget) {
700
+ resolvedTarget = dbTarget;
701
+ } else {
702
+ // Auto-detect namespace for this table from config (§1)
703
+ let tableNamespace = 'shared';
704
+ for (const [ns, dbBlock] of Object.entries(config.databases ?? {})) {
705
+ if (dbBlock.tables?.[tableName]) {
706
+ tableNamespace = ns;
707
+ break;
708
+ }
709
+ }
710
+ resolvedTarget = {
711
+ namespace: tableNamespace,
712
+ doName: getDbDoName(tableNamespace),
713
+ };
714
+ }
715
+ const doName = resolvedTarget.doName;
716
+ const headers: Record<string, string> = { 'X-DO-Name': doName };
717
+ if (serviceKey) {
718
+ headers['X-EdgeBase-Service-Key'] = serviceKey;
719
+ }
720
+ // Add internal header to bypass rules
721
+ headers['X-EdgeBase-Internal'] = 'true';
722
+
723
+ const buildTablePath = (id?: string, query?: URLSearchParams): string => {
724
+ const base = resolvedTarget.id !== undefined
725
+ ? `/api/db/${resolvedTarget.namespace}/${resolvedTarget.id}/tables/${tableName}`
726
+ : `/api/db/${resolvedTarget.namespace}/tables/${tableName}`;
727
+ const withId = id ? `${base}/${id}` : base;
728
+ const search = query && Array.from(query.keys()).length > 0 ? `?${query.toString()}` : '';
729
+ return `${withId}${search}`;
730
+ };
731
+ const buildDirectTablePath = (id?: string, query?: URLSearchParams): string => {
732
+ const base = `/tables/${tableName}`;
733
+ const withId = id ? `${base}/${id}` : base;
734
+ const search = query && Array.from(query.keys()).length > 0 ? `?${query.toString()}` : '';
735
+ return `${withId}${search}`;
736
+ };
737
+
738
+ const requestViaWorker = async (
739
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
740
+ path: string,
741
+ body?: Record<string, unknown>,
742
+ ): Promise<Response> => {
743
+ return fetch(`${workerUrl}${path}`, {
744
+ method,
745
+ headers: {
746
+ 'Content-Type': 'application/json',
747
+ ...headers,
748
+ },
749
+ body: body === undefined || method === 'GET' || method === 'DELETE'
750
+ ? undefined
751
+ : JSON.stringify(body),
752
+ });
753
+ };
754
+ const requestViaD1Handler = async (
755
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
756
+ directPath: string,
757
+ query?: URLSearchParams,
758
+ body?: Record<string, unknown>,
759
+ ): Promise<Response> => {
760
+ if (!env) {
761
+ throw new Error('D1 table proxy requires env.');
762
+ }
763
+
764
+ const request = new Request(
765
+ `http://internal/api/db/${resolvedTarget.namespace}${resolvedTarget.id ? `/${resolvedTarget.id}` : ''}${buildDirectTablePath(undefined, query).replace('/tables/' + tableName, directPath)}`,
766
+ {
767
+ method,
768
+ headers: {
769
+ 'Content-Type': 'application/json',
770
+ ...headers,
771
+ },
772
+ body: body === undefined || method === 'GET' || method === 'DELETE'
773
+ ? undefined
774
+ : JSON.stringify(body),
775
+ },
776
+ );
777
+
778
+ return handleD1Request(
779
+ buildInternalHandlerContext({
780
+ env,
781
+ request,
782
+ body,
783
+ executionCtx,
784
+ }),
785
+ resolvedTarget.namespace,
786
+ tableName,
787
+ directPath,
788
+ );
789
+ };
790
+ const requestViaPgHandler = async (
791
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
792
+ directPath: string,
793
+ query?: URLSearchParams,
794
+ body?: Record<string, unknown>,
795
+ ): Promise<Response> => {
796
+ if (!env) {
797
+ throw new Error('PostgreSQL table proxy requires env.');
798
+ }
799
+
800
+ const request = new Request(
801
+ `http://internal/api/db/${resolvedTarget.namespace}${resolvedTarget.id ? `/${resolvedTarget.id}` : ''}${buildDirectTablePath(undefined, query).replace('/tables/' + tableName, directPath)}`,
802
+ {
803
+ method,
804
+ headers: {
805
+ 'Content-Type': 'application/json',
806
+ ...headers,
807
+ },
808
+ body: body === undefined || method === 'GET' || method === 'DELETE'
809
+ ? undefined
810
+ : JSON.stringify(body),
811
+ },
812
+ );
813
+
814
+ return handlePgRequest(
815
+ buildInternalHandlerContext({
816
+ env,
817
+ request,
818
+ body,
819
+ executionCtx,
820
+ }),
821
+ resolvedTarget.namespace,
822
+ tableName,
823
+ directPath,
824
+ );
825
+ };
826
+ const requestViaDirectDo = async (
827
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
828
+ directPath: string,
829
+ body?: Record<string, unknown>,
830
+ ): Promise<Response> => {
831
+ const res = await callDO(namespace, doName, directPath, {
832
+ method,
833
+ body,
834
+ headers,
835
+ });
836
+
837
+ if (!resolvedTarget.id || res.status !== 201) {
838
+ return res;
839
+ }
840
+
841
+ const createPayload = await res.clone().json().catch(() => null) as
842
+ | { needsCreate?: boolean }
843
+ | null;
844
+ if (!createPayload?.needsCreate) {
845
+ return res;
846
+ }
847
+
848
+ return callDO(namespace, doName, directPath, {
849
+ method,
850
+ body,
851
+ headers: {
852
+ ...headers,
853
+ 'X-DO-Create-Authorized': '1',
854
+ },
855
+ });
856
+ };
857
+ const requestTable = async (
858
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
859
+ id?: string,
860
+ body?: Record<string, unknown>,
861
+ query?: URLSearchParams,
862
+ ): Promise<Response> => {
863
+ const directPath = buildDirectTablePath(id);
864
+ const directPathWithQuery = buildDirectTablePath(id, query);
865
+ const provider = config.databases?.[resolvedTarget.namespace]?.provider;
866
+
867
+ if (!routingOptions?.preferDirectDo && shouldRouteToD1(resolvedTarget.namespace, config) && env) {
868
+ return requestViaD1Handler(method, directPath, query, body);
869
+ }
870
+ if ((provider === 'neon' || provider === 'postgres') && env) {
871
+ return requestViaPgHandler(method, directPath, query, body);
872
+ }
873
+ if (env) {
874
+ return requestViaDirectDo(method, directPathWithQuery, body);
875
+ }
876
+ if (workerUrl) {
877
+ return requestViaWorker(method, buildTablePath(id, query), body);
878
+ }
879
+ return requestViaDirectDo(method, directPathWithQuery, body);
880
+ };
881
+
882
+ const insert = async (data: Record<string, unknown>): Promise<Record<string, unknown>> => {
883
+ const res = await requestTable('POST', undefined, data);
884
+ if (!res.ok) {
885
+ const err = (await res.json().catch(() => ({}))) as Record<string, unknown>;
886
+ throw new Error(`Cross-DO insert failed: ${err.message || res.status}`);
887
+ }
888
+ return (await res.json()) as Record<string, unknown>;
889
+ };
890
+
891
+ const upsert = async (
892
+ data: Record<string, unknown>,
893
+ options?: { conflictTarget?: string },
894
+ ): Promise<Record<string, unknown>> => {
895
+ const query = new URLSearchParams();
896
+ query.set('upsert', 'true');
897
+ if (options?.conflictTarget) {
898
+ query.set('conflictTarget', options.conflictTarget);
899
+ }
900
+
901
+ const res = await requestTable('POST', undefined, data, query);
902
+ if (!res.ok) {
903
+ const err = (await res.json().catch(() => ({}))) as Record<string, unknown>;
904
+ throw new Error(`Cross-DO upsert failed: ${err.message || res.status}`);
905
+ }
906
+ return (await res.json()) as Record<string, unknown>;
907
+ };
908
+
909
+ const update = async (
910
+ id: string,
911
+ data: Record<string, unknown>,
912
+ ): Promise<Record<string, unknown>> => {
913
+ const res = await requestTable('PATCH', id, data);
914
+ if (!res.ok) {
915
+ const err = (await res.json().catch(() => ({}))) as Record<string, unknown>;
916
+ throw new Error(`Cross-DO update failed: ${err.message || res.status}`);
917
+ }
918
+ return (await res.json()) as Record<string, unknown>;
919
+ };
920
+
921
+ const del = async (id: string): Promise<{ deleted: boolean }> => {
922
+ const res = await requestTable('DELETE', id);
923
+ if (!res.ok) {
924
+ const err = (await res.json().catch(() => ({}))) as Record<string, unknown>;
925
+ throw new Error(`Cross-DO delete failed: ${err.message || res.status}`);
926
+ }
927
+ return (await res.json()) as { deleted: boolean };
928
+ };
929
+
930
+ const get = async (id: string): Promise<Record<string, unknown>> => {
931
+ const res = await requestTable('GET', id);
932
+ if (!res.ok) {
933
+ const err = (await res.json().catch(() => ({}))) as Record<string, unknown>;
934
+ throw new Error(`Cross-DO get failed: ${err.message || res.status}`);
935
+ }
936
+ return (await res.json()) as Record<string, unknown>;
937
+ };
938
+
939
+ const list = async (listOptions?: {
940
+ limit?: number;
941
+ filter?: unknown;
942
+ }): Promise<{ items: Record<string, unknown>[] }> => {
943
+ const query = new URLSearchParams();
944
+ if (listOptions?.limit) query.set('limit', String(listOptions.limit));
945
+ if (listOptions?.filter) query.set('filter', JSON.stringify(listOptions.filter));
946
+
947
+ const res = await requestTable('GET', undefined, undefined, query);
948
+ if (!res.ok) {
949
+ const err = (await res.json().catch(() => ({}))) as Record<string, unknown>;
950
+ throw new Error(`Cross-DO list failed: ${err.message || res.status}`);
951
+ }
952
+ return (await res.json()) as { items: Record<string, unknown>[] };
953
+ };
954
+
955
+ return { insert, upsert, update, delete: del, get, list };
956
+ }
957
+
958
+ interface BuildAdminDbProxyOptions {
959
+ databaseNamespace: DurableObjectNamespace;
960
+ config: EdgeBaseConfig;
961
+ workerUrl?: string;
962
+ serviceKey?: string;
963
+ env?: Env;
964
+ executionCtx?: ExecutionContext;
965
+ preferDirectDo?: boolean;
966
+ }
967
+
968
+ export function buildAdminDbProxy(options: BuildAdminDbProxyOptions): FunctionAdminContext['db'] {
969
+ return (namespace: string, id?: string) => {
970
+ const doName = getDbDoName(namespace, id);
971
+
972
+ return {
973
+ table(tableName: string): TableProxy {
974
+ return buildTableProxy(
975
+ tableName,
976
+ options.databaseNamespace,
977
+ options.config,
978
+ options.workerUrl,
979
+ options.serviceKey,
980
+ options.env,
981
+ options.executionCtx,
982
+ { namespace, id, doName },
983
+ { preferDirectDo: options.preferDirectDo },
984
+ );
985
+ },
986
+ };
987
+ };
988
+ }
989
+
990
+ // ─── AdminAuth Context Builder ───
991
+
992
+ interface AdminAuthOptions {
993
+ authNamespace?: DurableObjectNamespace; // kept for interface compatibility, no longer used
994
+ databaseNamespace?: DurableObjectNamespace;
995
+ d1Database?: D1Database;
996
+ serviceKey?: string;
997
+ /** Worker origin URL — enables createUser via HTTP relay. */
998
+ workerUrl?: string;
999
+ /** KV namespace — used to clean up push tokens on user deletion. */
1000
+ kvNamespace?: KVNamespace;
1001
+ }
1002
+
1003
+ /**
1004
+ * Build admin auth context for App Functions.
1005
+ * Uses AUTH_DB D1 directly for all operations (D1-first architecture).
1006
+ * Cross-shard operations (listUsers, createUser) also available via Worker HTTP relay
1007
+ * when workerUrl is provided.
1008
+ */
1009
+ export function buildAdminAuthContext(options: AdminAuthOptions): AdminAuthContext {
1010
+ const { d1Database, serviceKey, workerUrl, kvNamespace } = options;
1011
+
1012
+ /** Ensure AUTH_DB is available, or throw a clear error. */
1013
+ const requireAuthDb = (op: string): AuthDb => {
1014
+ if (!d1Database) {
1015
+ throw new Error(
1016
+ `admin.auth.${op}() requires AUTH_DB D1. ` +
1017
+ 'Not available in this context — use the Admin API or SDK.',
1018
+ );
1019
+ }
1020
+ return new D1AuthDb(d1Database);
1021
+ };
1022
+
1023
+ return {
1024
+ async getUser(userId: string): Promise<Record<string, unknown>> {
1025
+ const db = requireAuthDb('getUser');
1026
+ const user = await authService.getUserById(db, userId);
1027
+ if (!user) throw new Error(`User not found: ${userId}`);
1028
+ return authService.sanitizeUser(user, { includeAppMetadata: true });
1029
+ },
1030
+
1031
+ async listUsers(opts?: {
1032
+ limit?: number;
1033
+ cursor?: string;
1034
+ }): Promise<{ users: Record<string, unknown>[]; cursor?: string }> {
1035
+ if (d1Database) {
1036
+ const authDb = new D1AuthDb(d1Database);
1037
+ const limit = opts?.limit ?? 100;
1038
+ const offset = opts?.cursor ? parseInt(opts.cursor, 10) : 0;
1039
+ const result = await authService.listUsers(authDb, limit, offset);
1040
+ const nextOffset = offset + limit;
1041
+ return {
1042
+ users: result.users.map((u) => authService.sanitizeUser(u, { includeAppMetadata: true })),
1043
+ cursor: nextOffset < result.total ? String(nextOffset) : undefined,
1044
+ };
1045
+ }
1046
+ if (workerUrl && serviceKey) {
1047
+ // HTTP relay fallback: GET /api/auth/admin/users → Worker → D1
1048
+ const params = new URLSearchParams();
1049
+ if (opts?.limit) params.set('limit', String(opts.limit));
1050
+ if (opts?.cursor) params.set('cursor', opts.cursor);
1051
+ const qs = params.toString();
1052
+ const res = await fetch(`${workerUrl}/api/auth/admin/users${qs ? `?${qs}` : ''}`, {
1053
+ method: 'GET',
1054
+ headers: { 'X-EdgeBase-Service-Key': serviceKey },
1055
+ });
1056
+ if (!res.ok) throw new Error(`admin.auth.listUsers() relay failed: ${res.status}`);
1057
+ return res.json() as Promise<{ users: Record<string, unknown>[]; cursor?: string }>;
1058
+ }
1059
+ throw new Error(
1060
+ 'admin.auth.listUsers() is not available in this context (requires D1 or workerUrl). ' +
1061
+ 'Pass workerUrl to buildFunctionContext(), or use the external SDK.',
1062
+ );
1063
+ },
1064
+
1065
+ async createUser(data: {
1066
+ email: string;
1067
+ password: string;
1068
+ displayName?: string;
1069
+ role?: string;
1070
+ }): Promise<Record<string, unknown>> {
1071
+ if (workerUrl && serviceKey) {
1072
+ // HTTP relay: POST /api/auth/admin/users → Worker → D1
1073
+ // createUser has complex side effects (email index, _users_public, etc.)
1074
+ // so it routes through the admin route which handles the full flow.
1075
+ const res = await fetch(`${workerUrl}/api/auth/admin/users`, {
1076
+ method: 'POST',
1077
+ headers: {
1078
+ 'Content-Type': 'application/json',
1079
+ 'X-EdgeBase-Service-Key': serviceKey,
1080
+ },
1081
+ body: JSON.stringify(data),
1082
+ });
1083
+ if (!res.ok) {
1084
+ const err = (await res.json().catch(() => ({ message: 'createUser failed' }))) as {
1085
+ message: string;
1086
+ };
1087
+ throw new Error(err.message);
1088
+ }
1089
+ const result = (await res.json()) as { user: Record<string, unknown> };
1090
+ return result.user;
1091
+ }
1092
+ throw new Error(
1093
+ 'admin.auth.createUser() is not available in this context (requires workerUrl). ' +
1094
+ 'Pass workerUrl to buildFunctionContext(), or use the external SDK.',
1095
+ );
1096
+ },
1097
+
1098
+ async updateUser(
1099
+ userId: string,
1100
+ data: Record<string, unknown>,
1101
+ ): Promise<Record<string, unknown>> {
1102
+ const db = requireAuthDb('updateUser');
1103
+ const updates = await normalizeAdminUserUpdates(data);
1104
+ const user = await updateManagedAdminUser(db, userId, updates as Record<string, unknown>, {
1105
+ kv: kvNamespace,
1106
+ });
1107
+ if (!user) throw new Error(`admin.auth.updateUser failed: user not found`);
1108
+ return authService.sanitizeUser(user, { includeAppMetadata: true });
1109
+ },
1110
+
1111
+ async deleteUser(userId: string): Promise<void> {
1112
+ const db = requireAuthDb('deleteUser');
1113
+ const deleted = await deleteManagedAdminUser(db, userId, {
1114
+ kv: kvNamespace,
1115
+ });
1116
+ if (!deleted) {
1117
+ throw new Error(`admin.auth.deleteUser failed: user not found`);
1118
+ }
1119
+ },
1120
+
1121
+ async setCustomClaims(userId: string, claims: Record<string, unknown>): Promise<void> {
1122
+ const db = requireAuthDb('setCustomClaims');
1123
+ await authService.updateUser(db, userId, {
1124
+ customClaims: JSON.stringify(claims),
1125
+ });
1126
+ },
1127
+
1128
+ async revokeAllSessions(userId: string): Promise<void> {
1129
+ const db = requireAuthDb('revokeAllSessions');
1130
+ await authService.deleteAllUserSessions(db, userId);
1131
+ },
1132
+ };
1133
+ }
1134
+
1135
+ // ─── Full Function Context Builder ───
1136
+
1137
+ export interface BuildFunctionContextOptions {
1138
+ request: Request;
1139
+ auth: AuthContext | null;
1140
+ databaseNamespace: DurableObjectNamespace;
1141
+ authNamespace: DurableObjectNamespace;
1142
+ d1Database?: D1Database;
1143
+ config: EdgeBaseConfig;
1144
+ serviceKey?: string;
1145
+ storage?: unknown;
1146
+ analytics?: unknown;
1147
+ data?: { before?: Record<string, unknown>; after?: Record<string, unknown> };
1148
+ /** KV namespace — used to clean up push tokens on user deletion. */
1149
+ kvNamespace?: KVNamespace;
1150
+ env?: Env;
1151
+ executionCtx?: ExecutionContext;
1152
+ /**
1153
+ * Worker origin URL for context.admin internal HTTP transport.
1154
+ */
1155
+ workerUrl?: string;
1156
+ /** Current call depth for inter-function calls. */
1157
+ callDepth?: number;
1158
+ /** Plugin name — when set, injects pluginConfig from config.plugins[pluginName]. */
1159
+ pluginName?: string;
1160
+ /**
1161
+ * Trigger origin (§5).
1162
+ * For DB triggers: the DO's namespace and id that fired the trigger.
1163
+ */
1164
+ triggerInfo?: {
1165
+ namespace: string;
1166
+ id?: string;
1167
+ table?: string;
1168
+ event?: 'insert' | 'update' | 'delete';
1169
+ };
1170
+ /** URL path parameters extracted from file-system routing (e.g., { userId: '...' }). */
1171
+ params?: Record<string, string>;
1172
+ /**
1173
+ * Force admin.db() to stay on direct Durable Object transport.
1174
+ * Used by RoomsDO handlers that already execute inside a DO.
1175
+ */
1176
+ preferDirectDoDb?: boolean;
1177
+ }
1178
+
1179
+ export function buildFunctionContext(options: BuildFunctionContextOptions): FunctionContext {
1180
+ const adminAuthContext = buildAdminAuthContext({
1181
+ authNamespace: options.authNamespace,
1182
+ databaseNamespace: options.databaseNamespace,
1183
+ d1Database: options.d1Database,
1184
+ serviceKey: options.serviceKey,
1185
+ workerUrl: options.workerUrl, // enables listUsers/createUser HTTP relay
1186
+ kvNamespace: options.kvNamespace,
1187
+ });
1188
+ const adminDb = buildAdminDbProxy({
1189
+ databaseNamespace: options.databaseNamespace,
1190
+ config: options.config,
1191
+ workerUrl: options.workerUrl,
1192
+ serviceKey: options.serviceKey,
1193
+ env: options.env,
1194
+ executionCtx: options.executionCtx,
1195
+ preferDirectDo: options.preferDirectDoDb,
1196
+ });
1197
+
1198
+ // ─── context.admin — AdminEdgeBase-shaped internal proxy ───
1199
+ const admin: FunctionAdminContext = {
1200
+ table: (name: string) => adminDb('shared').table(name),
1201
+
1202
+ // ─── context.admin.db(namespace, id) — DB-first tenant access (§5) ───
1203
+ db: adminDb,
1204
+ auth: adminAuthContext,
1205
+ async sql(
1206
+ namespace: string,
1207
+ id: string | undefined,
1208
+ query: string,
1209
+ params?: unknown[],
1210
+ ): Promise<unknown[]> {
1211
+ if (options.env) {
1212
+ const dbBlock = options.config.databases?.[namespace];
1213
+ const isDynamicNamespace = !!(dbBlock?.instance || dbBlock?.access?.canCreate || dbBlock?.access?.access);
1214
+ if (isDynamicNamespace && !id) {
1215
+ throw new Error(`admin.sql() requires an id for dynamic namespace '${namespace}'.`);
1216
+ }
1217
+
1218
+ if (!id && shouldRouteToD1(namespace, options.config)) {
1219
+ const bindingName = getD1BindingName(namespace);
1220
+ const d1 = (options.env as unknown as Record<string, unknown>)[bindingName] as D1Database | undefined;
1221
+ if (!d1) {
1222
+ throw new Error(`D1 binding '${bindingName}' not found.`);
1223
+ }
1224
+ try {
1225
+ const stmt = d1.prepare(query);
1226
+ const bound = params && params.length > 0 ? stmt.bind(...params) : stmt;
1227
+ const result = await bound.all();
1228
+ const rows = (result.results ?? []) as unknown[];
1229
+ return rows;
1230
+ } catch (error) {
1231
+ const message = error instanceof Error ? error.message : 'SQL execution failed';
1232
+ throw new Error(message);
1233
+ }
1234
+ }
1235
+
1236
+ return executeDoSql({
1237
+ databaseNamespace: options.databaseNamespace,
1238
+ namespace,
1239
+ id,
1240
+ query,
1241
+ params: params ?? [],
1242
+ internal: true,
1243
+ });
1244
+ }
1245
+
1246
+ if (options.workerUrl && options.serviceKey) {
1247
+ // HTTP route: POST /api/sql → Worker → DatabaseDO (§11)
1248
+ const res = await fetch(`${options.workerUrl}/api/sql`, {
1249
+ method: 'POST',
1250
+ headers: {
1251
+ 'Content-Type': 'application/json',
1252
+ 'X-EdgeBase-Service-Key': options.serviceKey,
1253
+ },
1254
+ body: JSON.stringify({ namespace, id, sql: query, params: params ?? [] }),
1255
+ });
1256
+ if (!res.ok) {
1257
+ const err = (await res.json().catch(() => ({ message: 'SQL execution failed' }))) as {
1258
+ message: string;
1259
+ };
1260
+ throw new Error(err.message);
1261
+ }
1262
+ const data = (await res.json()) as {
1263
+ rows?: unknown[];
1264
+ items?: unknown[];
1265
+ results?: unknown[];
1266
+ };
1267
+ if (Array.isArray(data.rows)) return data.rows;
1268
+ if (Array.isArray(data.items)) return data.items;
1269
+ if (Array.isArray(data.results)) return data.results;
1270
+ return [];
1271
+ }
1272
+ throw new Error(
1273
+ 'admin.sql() requires workerUrl. Pass workerUrl to buildFunctionContext(), or use the external SDK.',
1274
+ );
1275
+ },
1276
+ async broadcast(
1277
+ channel: string,
1278
+ event: string,
1279
+ payload?: Record<string, unknown>,
1280
+ ): Promise<void> {
1281
+ if (options.env?.DATABASE_LIVE) {
1282
+ const hubId = options.env.DATABASE_LIVE.idFromName('database-live:hub');
1283
+ const stub = options.env.DATABASE_LIVE.get(hubId);
1284
+ const response = await stub.fetch(new Request('http://do/internal/broadcast', {
1285
+ method: 'POST',
1286
+ headers: { 'Content-Type': 'application/json' },
1287
+ body: JSON.stringify({ channel, event, payload: payload ?? {} }),
1288
+ }));
1289
+ if (!response.ok) {
1290
+ throw new Error(`client.broadcast() failed: ${response.status}`);
1291
+ }
1292
+ return;
1293
+ }
1294
+ if (options.workerUrl && options.serviceKey) {
1295
+ // HTTP route: POST /api/db/broadcast → Worker → DatabaseLiveDO
1296
+ const res = await fetch(`${options.workerUrl}/api/db/broadcast`, {
1297
+ method: 'POST',
1298
+ headers: {
1299
+ 'Content-Type': 'application/json',
1300
+ 'X-EdgeBase-Service-Key': options.serviceKey,
1301
+ },
1302
+ body: JSON.stringify({ channel, event, payload: payload ?? {} }),
1303
+ });
1304
+ if (!res.ok) throw new Error(`client.broadcast() failed: ${res.status}`);
1305
+ return;
1306
+ }
1307
+ throw new Error(
1308
+ 'admin.broadcast() requires workerUrl. Pass workerUrl to buildFunctionContext().',
1309
+ );
1310
+ },
1311
+
1312
+ // Inter-function calls
1313
+ functions: {
1314
+ async call(name: string, data?: unknown): Promise<unknown> {
1315
+ const MAX_CALL_DEPTH = 5;
1316
+ const currentDepth = options.callDepth ?? 0;
1317
+ if (currentDepth >= MAX_CALL_DEPTH) {
1318
+ throw new Error(
1319
+ `Function call depth exceeded (max ${MAX_CALL_DEPTH}). Possible circular call: ${name}`,
1320
+ );
1321
+ }
1322
+
1323
+ if (options.env) {
1324
+ const matched = matchRoute(name, 'POST');
1325
+ if (!matched) {
1326
+ throw new Error(`Function call '${name}' failed`);
1327
+ }
1328
+
1329
+ const safeName = name.split('/').map(encodeURIComponent).join('/');
1330
+ const nestedRequest = new Request(
1331
+ `${options.workerUrl ?? 'http://internal'}/api/functions/${safeName}`,
1332
+ {
1333
+ method: 'POST',
1334
+ headers: {
1335
+ 'Content-Type': 'application/json',
1336
+ ...(options.serviceKey
1337
+ ? { 'X-EdgeBase-Service-Key': options.serviceKey }
1338
+ : {}),
1339
+ 'X-EdgeBase-Call-Depth': String(currentDepth + 1),
1340
+ },
1341
+ body: JSON.stringify(data ?? {}),
1342
+ },
1343
+ );
1344
+ const nestedCtx = buildFunctionContext({
1345
+ ...options,
1346
+ request: nestedRequest,
1347
+ params: matched.params,
1348
+ callDepth: currentDepth + 1,
1349
+ });
1350
+
1351
+ const middlewares = getMiddlewareChain(matched.route.name);
1352
+ for (const middleware of middlewares) {
1353
+ await middleware(nestedCtx);
1354
+ }
1355
+
1356
+ const result = await matched.route.definition.handler(nestedCtx);
1357
+ if (result instanceof Response) {
1358
+ if (result.status === 204) return null;
1359
+ const contentType = result.headers.get('content-type') ?? '';
1360
+ if (contentType.includes('application/json')) {
1361
+ return result.json();
1362
+ }
1363
+ return result.text();
1364
+ }
1365
+ return result ?? null;
1366
+ }
1367
+
1368
+ if (options.workerUrl && options.serviceKey) {
1369
+ // HTTP route: POST /api/functions/{name} → Worker → function handler
1370
+ const safeName = name.split('/').map(encodeURIComponent).join('/');
1371
+ const res = await fetch(`${options.workerUrl}/api/functions/${safeName}`, {
1372
+ method: 'POST',
1373
+ headers: {
1374
+ 'Content-Type': 'application/json',
1375
+ 'X-EdgeBase-Service-Key': options.serviceKey,
1376
+ 'X-EdgeBase-Call-Depth': String(currentDepth + 1),
1377
+ },
1378
+ body: JSON.stringify(data ?? {}),
1379
+ });
1380
+ if (!res.ok) {
1381
+ const err = (await res
1382
+ .json()
1383
+ .catch(() => ({ message: `Function call '${name}' failed` }))) as { message: string };
1384
+ throw new Error(err.message);
1385
+ }
1386
+ return res.json();
1387
+ }
1388
+ throw new Error(
1389
+ 'admin.functions.call() requires workerUrl. Pass workerUrl to buildFunctionContext().',
1390
+ );
1391
+ },
1392
+ },
1393
+
1394
+ // KV / D1 / Vectorize proxies
1395
+ kv(namespace: string): FunctionKvProxy {
1396
+ return buildFunctionKvProxy(namespace, options.config, options.env, options.workerUrl, options.serviceKey);
1397
+ },
1398
+ d1(database: string): FunctionD1Proxy {
1399
+ return buildFunctionD1Proxy(database, options.config, options.env, options.workerUrl, options.serviceKey);
1400
+ },
1401
+ vector(index: string): FunctionVectorizeProxy {
1402
+ return buildFunctionVectorizeProxy(index, options.config, options.env, options.workerUrl, options.serviceKey);
1403
+ },
1404
+
1405
+ // Push notification management
1406
+ push: buildFunctionPushProxy(options.workerUrl, options.serviceKey),
1407
+ };
1408
+
1409
+ const ctx: FunctionContext = {
1410
+ request: options.request,
1411
+ auth: options.auth,
1412
+ admin,
1413
+ params: options.params ?? {},
1414
+ };
1415
+
1416
+ if (options.data) ctx.data = options.data;
1417
+
1418
+ // Storage injection — use provided proxy or auto-build from R2 binding
1419
+ if (options.storage !== undefined && options.storage !== null) {
1420
+ ctx.storage = options.storage as FunctionStorageProxy;
1421
+ } else if (options.env && (options.env as unknown as Record<string, unknown>).STORAGE) {
1422
+ ctx.storage = buildFunctionStorageProxy(
1423
+ (options.env as unknown as Record<string, unknown>).STORAGE as R2Bucket,
1424
+ 'default',
1425
+ options.env,
1426
+ options.workerUrl,
1427
+ );
1428
+ }
1429
+
1430
+ // Analytics injection — optional
1431
+ if (options.analytics !== undefined) {
1432
+ ctx.analytics = options.analytics;
1433
+ }
1434
+
1435
+ // Plugin config injection
1436
+ if (options.pluginName && options.config.plugins) {
1437
+ const matchedPlugin = options.config.plugins.find((p) => p.name === options.pluginName);
1438
+ if (matchedPlugin) {
1439
+ ctx.pluginConfig = matchedPlugin.config;
1440
+ }
1441
+ }
1442
+
1443
+ if (options.triggerInfo) ctx.trigger = options.triggerInfo;
1444
+
1445
+ return ctx;
1446
+ }
1447
+
1448
+ /**
1449
+ * Execute registered DB trigger functions for a given event.
1450
+ * Called from DatabaseDO CUD handlers via ctx.waitUntil().
1451
+ * Best-effort: errors are logged, never thrown.
1452
+ * §5: namespace/id injected as context.trigger for explicit DO access.
1453
+ */
1454
+ export async function executeDbTriggers(
1455
+ tableName2: string,
1456
+ event: 'insert' | 'update' | 'delete',
1457
+ data: { before?: Record<string, unknown>; after?: Record<string, unknown> },
1458
+ contextOptions: Omit<BuildFunctionContextOptions, 'data' | 'request' | 'auth'>,
1459
+ /** Namespace/id of the DO that fired the trigger (§5). */
1460
+ triggerOrigin: { namespace: string; id?: string },
1461
+ ): Promise<void> {
1462
+ const functions = getFunctionsByTrigger('db', {
1463
+ type: 'db',
1464
+ table: tableName2,
1465
+ event,
1466
+ } as DbTrigger);
1467
+ if (functions.length === 0) return;
1468
+
1469
+ const dummyRequest = new Request('http://internal/trigger', { method: 'POST' });
1470
+
1471
+ for (const { name, definition } of functions) {
1472
+ try {
1473
+ const ctx = buildFunctionContext({
1474
+ ...contextOptions,
1475
+ request: dummyRequest,
1476
+ auth: null,
1477
+ data,
1478
+ triggerInfo: {
1479
+ namespace: triggerOrigin.namespace,
1480
+ id: triggerOrigin.id,
1481
+ table: tableName2,
1482
+ event,
1483
+ },
1484
+ });
1485
+ await definition.handler(ctx);
1486
+ } catch (err) {
1487
+ // Best-effort — log and continue
1488
+ console.error(`[EdgeBase] DB trigger '${name}' (${tableName2}:${event}) failed:`, err);
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ // ─── KV / D1 / Vectorize Proxy Builders ───
1494
+
1495
+ function normalizeWorkerUrl(value: string): string {
1496
+ return value.trim().replace(/\/+$/, '');
1497
+ }
1498
+
1499
+ function readInternalWorkerUrl(
1500
+ env?: Pick<Env, 'EDGEBASE_INTERNAL_WORKER_URL'> | Record<string, unknown> | null,
1501
+ ): string | undefined {
1502
+ if (!env || typeof env !== 'object' || Array.isArray(env)) {
1503
+ return undefined;
1504
+ }
1505
+
1506
+ const candidate = (env as Record<string, unknown>).EDGEBASE_INTERNAL_WORKER_URL;
1507
+ if (typeof candidate !== 'string' || candidate.trim().length === 0) {
1508
+ return undefined;
1509
+ }
1510
+
1511
+ return normalizeWorkerUrl(candidate);
1512
+ }
1513
+
1514
+ /** Extract base URL (protocol + host) from a request URL. Used for self-fetch in hook/migration contexts. */
1515
+ export function getWorkerUrl(
1516
+ requestUrl: string,
1517
+ env?: Pick<Env, 'EDGEBASE_INTERNAL_WORKER_URL'> | Record<string, unknown> | null,
1518
+ ): string | undefined {
1519
+ const internalWorkerUrl = readInternalWorkerUrl(env);
1520
+ if (internalWorkerUrl) {
1521
+ return internalWorkerUrl;
1522
+ }
1523
+
1524
+ try {
1525
+ const u = new URL(requestUrl);
1526
+ return normalizeWorkerUrl(`${u.protocol}//${u.host}`);
1527
+ } catch {
1528
+ return undefined;
1529
+ }
1530
+ }
1531
+
1532
+ export function requireWorkerUrl(
1533
+ method: string,
1534
+ workerUrl?: string,
1535
+ serviceKey?: string,
1536
+ ): { url: string; key: string } {
1537
+ if (!workerUrl || !serviceKey) {
1538
+ throw new Error(
1539
+ `admin.${method}() requires workerUrl and serviceKey. Pass them to buildFunctionContext().`,
1540
+ );
1541
+ }
1542
+ return { url: workerUrl, key: serviceKey };
1543
+ }
1544
+
1545
+ export function buildFunctionKvProxy(
1546
+ namespace: string,
1547
+ config?: EdgeBaseConfig,
1548
+ env?: Env,
1549
+ workerUrl?: string,
1550
+ serviceKey?: string,
1551
+ ): FunctionKvProxy {
1552
+ const directBinding = (() => {
1553
+ if (!config || !env) return undefined;
1554
+ const kvConfig = config.kv?.[namespace];
1555
+ if (!kvConfig) return undefined;
1556
+ return (env as unknown as Record<string, unknown>)[kvConfig.binding] as KVNamespace | undefined;
1557
+ })();
1558
+
1559
+ return {
1560
+ async get(key: string): Promise<string | null> {
1561
+ if (directBinding) {
1562
+ return directBinding.get(key);
1563
+ }
1564
+ const { url, key: sk } = requireWorkerUrl('kv().get', workerUrl, serviceKey);
1565
+ const res = await fetch(`${url}/api/kv/${namespace}`, {
1566
+ method: 'POST',
1567
+ headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': sk },
1568
+ body: JSON.stringify({ action: 'get', key }),
1569
+ });
1570
+ if (!res.ok) throw new Error(`kv().get() failed: ${res.status}`);
1571
+ const data = (await res.json()) as { value: string | null };
1572
+ return data.value;
1573
+ },
1574
+ async set(key: string, value: string, options?: { ttl?: number }): Promise<void> {
1575
+ if (directBinding) {
1576
+ const putOptions: KVNamespacePutOptions = {};
1577
+ if (options?.ttl) putOptions.expirationTtl = options.ttl;
1578
+ await directBinding.put(key, value, putOptions);
1579
+ return;
1580
+ }
1581
+ const { url, key: sk } = requireWorkerUrl('kv().set', workerUrl, serviceKey);
1582
+ const res = await fetch(`${url}/api/kv/${namespace}`, {
1583
+ method: 'POST',
1584
+ headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': sk },
1585
+ body: JSON.stringify({ action: 'set', key, value, ttl: options?.ttl }),
1586
+ });
1587
+ if (!res.ok) throw new Error(`kv().set() failed: ${res.status}`);
1588
+ },
1589
+ async delete(key: string): Promise<void> {
1590
+ if (directBinding) {
1591
+ await directBinding.delete(key);
1592
+ return;
1593
+ }
1594
+ const { url, key: sk } = requireWorkerUrl('kv().delete', workerUrl, serviceKey);
1595
+ const res = await fetch(`${url}/api/kv/${namespace}`, {
1596
+ method: 'POST',
1597
+ headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': sk },
1598
+ body: JSON.stringify({ action: 'delete', key }),
1599
+ });
1600
+ if (!res.ok) throw new Error(`kv().delete() failed: ${res.status}`);
1601
+ },
1602
+ async list(options?: {
1603
+ prefix?: string;
1604
+ limit?: number;
1605
+ cursor?: string;
1606
+ }): Promise<{ keys: string[]; cursor?: string }> {
1607
+ if (directBinding) {
1608
+ const result = await directBinding.list({
1609
+ prefix: options?.prefix,
1610
+ limit: options?.limit,
1611
+ cursor: options?.cursor,
1612
+ });
1613
+ return {
1614
+ keys: result.keys.map((entry) => entry.name),
1615
+ cursor: result.list_complete ? undefined : result.cursor,
1616
+ };
1617
+ }
1618
+ const { url, key: sk } = requireWorkerUrl('kv().list', workerUrl, serviceKey);
1619
+ const res = await fetch(`${url}/api/kv/${namespace}`, {
1620
+ method: 'POST',
1621
+ headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': sk },
1622
+ body: JSON.stringify({ action: 'list', ...options }),
1623
+ });
1624
+ if (!res.ok) throw new Error(`kv().list() failed: ${res.status}`);
1625
+ return res.json() as Promise<{ keys: string[]; cursor?: string }>;
1626
+ },
1627
+ };
1628
+ }
1629
+
1630
+ export function buildFunctionD1Proxy(
1631
+ database: string,
1632
+ config?: EdgeBaseConfig,
1633
+ env?: Env,
1634
+ workerUrl?: string,
1635
+ serviceKey?: string,
1636
+ ): FunctionD1Proxy {
1637
+ return {
1638
+ async exec<T = Record<string, unknown>>(query: string, params?: unknown[]): Promise<T[]> {
1639
+ if (config && env) {
1640
+ const bindingName = config.d1?.[database]?.binding
1641
+ ?? (database === 'auth' ? 'AUTH_DB' : undefined)
1642
+ ?? (database === 'control' ? 'CONTROL_DB' : undefined)
1643
+ ?? getD1BindingName(database);
1644
+ const binding = (env as unknown as Record<string, unknown>)[bindingName] as D1Database | undefined;
1645
+ if (!binding) {
1646
+ throw new Error(`D1 binding '${bindingName}' not found.`);
1647
+ }
1648
+ try {
1649
+ const stmt = binding.prepare(query);
1650
+ const bound = params && params.length > 0 ? stmt.bind(...params) : stmt;
1651
+ const result = await bound.all();
1652
+ return (result.results ?? []) as T[];
1653
+ } catch (error) {
1654
+ const message = error instanceof Error ? error.message : 'D1 query failed';
1655
+ throw new Error(message);
1656
+ }
1657
+ }
1658
+ const { url, key: sk } = requireWorkerUrl('d1().exec', workerUrl, serviceKey);
1659
+ const res = await fetch(`${url}/api/d1/${database}`, {
1660
+ method: 'POST',
1661
+ headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': sk },
1662
+ body: JSON.stringify({ query, params }),
1663
+ });
1664
+ if (!res.ok) {
1665
+ const err = (await res.json().catch(() => ({ message: 'D1 query failed' }))) as {
1666
+ message: string;
1667
+ };
1668
+ throw new Error(err.message);
1669
+ }
1670
+ const data = (await res.json()) as { results: T[] };
1671
+ return data.results;
1672
+ },
1673
+ };
1674
+ }
1675
+
1676
+ export function buildFunctionVectorizeProxy(
1677
+ index: string,
1678
+ config?: EdgeBaseConfig,
1679
+ env?: Env,
1680
+ workerUrl?: string,
1681
+ serviceKey?: string,
1682
+ ): FunctionVectorizeProxy {
1683
+ const VECTOR_BATCH_LIMIT = 20;
1684
+ const directBinding = (() => {
1685
+ if (!config || !env) return undefined;
1686
+ const vectorConfig = config.vectorize?.[index];
1687
+ if (!vectorConfig) return undefined;
1688
+ const bindingName = vectorConfig.binding ?? `VECTORIZE_${index.toUpperCase()}`;
1689
+ return (env as unknown as Record<string, unknown>)[bindingName] as VectorizeIndex | undefined;
1690
+ })();
1691
+
1692
+ const normalizeValues = (values: unknown): number[] | undefined => {
1693
+ if (values instanceof Float32Array || values instanceof Float64Array) {
1694
+ return Array.from(values);
1695
+ }
1696
+ return Array.isArray(values) ? values as number[] : undefined;
1697
+ };
1698
+
1699
+ const mapMatches = (
1700
+ matches: Array<{
1701
+ id: string;
1702
+ score: number;
1703
+ values?: unknown;
1704
+ metadata?: Record<string, unknown>;
1705
+ namespace?: string;
1706
+ }>,
1707
+ ) => matches.map((match) => ({
1708
+ id: match.id,
1709
+ score: match.score,
1710
+ ...(match.values !== undefined ? { values: normalizeValues(match.values) } : {}),
1711
+ ...(match.metadata !== undefined ? { metadata: match.metadata } : {}),
1712
+ ...(match.namespace ? { namespace: match.namespace } : {}),
1713
+ }));
1714
+
1715
+ const withNamespace = <T extends { namespace?: string }>(vectors: T[], namespace?: string): T[] => {
1716
+ if (!namespace) return vectors;
1717
+ return vectors.map((vector) => (vector.namespace ? vector : { ...vector, namespace }));
1718
+ };
1719
+
1720
+ const chunkArray = <T>(items: T[], size: number): T[][] => {
1721
+ if (items.length === 0) return [];
1722
+ const chunks: T[][] = [];
1723
+ for (let index = 0; index < items.length; index += size) {
1724
+ chunks.push(items.slice(index, index + size));
1725
+ }
1726
+ return chunks;
1727
+ };
1728
+
1729
+ const post = async (body: Record<string, unknown>, label: string) => {
1730
+ if (directBinding) {
1731
+ switch (body.action) {
1732
+ case 'upsert': {
1733
+ const vectors = withNamespace(body.vectors as VectorizeVector[], body.namespace as string | undefined);
1734
+ let count = 0;
1735
+ let mutationId: string | undefined;
1736
+ for (const chunk of chunkArray(vectors, VECTOR_BATCH_LIMIT)) {
1737
+ const result = await directBinding.upsert(chunk);
1738
+ count += 'count' in result ? result.count : chunk.length;
1739
+ if ('mutationId' in result) {
1740
+ mutationId = (result as { mutationId: string }).mutationId;
1741
+ }
1742
+ }
1743
+ return { count, ...(mutationId ? { mutationId } : {}) };
1744
+ }
1745
+ case 'insert': {
1746
+ const vectors = withNamespace(body.vectors as VectorizeVector[], body.namespace as string | undefined);
1747
+ let count = 0;
1748
+ let mutationId: string | undefined;
1749
+ for (const chunk of chunkArray(vectors, VECTOR_BATCH_LIMIT)) {
1750
+ const result = await directBinding.insert(chunk);
1751
+ count += 'count' in result ? result.count : chunk.length;
1752
+ if ('mutationId' in result) {
1753
+ mutationId = (result as { mutationId: string }).mutationId;
1754
+ }
1755
+ }
1756
+ return { count, ...(mutationId ? { mutationId } : {}) };
1757
+ }
1758
+ case 'search': {
1759
+ const result = await directBinding.query(body.vector as number[], {
1760
+ topK: body.topK as number | undefined,
1761
+ filter: body.filter as VectorizeVectorMetadataFilter | undefined,
1762
+ namespace: body.namespace as string | undefined,
1763
+ returnValues: body.returnValues as boolean | undefined,
1764
+ returnMetadata: body.returnMetadata as boolean | 'all' | 'indexed' | 'none' | undefined,
1765
+ });
1766
+ return { matches: mapMatches(result.matches), count: result.count };
1767
+ }
1768
+ case 'queryById': {
1769
+ const queryById = (directBinding as unknown as {
1770
+ queryById?: (id: string, opts?: VectorizeQueryOptions) => Promise<VectorizeMatches>;
1771
+ }).queryById;
1772
+ if (typeof queryById !== 'function') {
1773
+ throw new Error('queryById is not available on this Vectorize binding');
1774
+ }
1775
+ const result = await queryById(body.vectorId as string, {
1776
+ topK: body.topK as number | undefined,
1777
+ filter: body.filter as VectorizeVectorMetadataFilter | undefined,
1778
+ namespace: body.namespace as string | undefined,
1779
+ returnValues: body.returnValues as boolean | undefined,
1780
+ returnMetadata: body.returnMetadata as boolean | 'all' | 'indexed' | 'none' | undefined,
1781
+ });
1782
+ return { matches: mapMatches(result.matches), count: result.count };
1783
+ }
1784
+ case 'getByIds': {
1785
+ const vectors = (
1786
+ await Promise.all(chunkArray(body.ids as string[], VECTOR_BATCH_LIMIT).map((chunk) => directBinding.getByIds(chunk)))
1787
+ ).flat();
1788
+ return {
1789
+ vectors: vectors.map((vector) => ({
1790
+ id: vector.id,
1791
+ ...(vector.values !== undefined ? { values: normalizeValues(vector.values) } : {}),
1792
+ ...(vector.metadata !== undefined ? { metadata: vector.metadata } : {}),
1793
+ ...(vector.namespace ? { namespace: vector.namespace } : {}),
1794
+ })),
1795
+ };
1796
+ }
1797
+ case 'delete': {
1798
+ let count = 0;
1799
+ let mutationId: string | undefined;
1800
+ for (const chunk of chunkArray(body.ids as string[], VECTOR_BATCH_LIMIT)) {
1801
+ const result = await directBinding.deleteByIds(chunk);
1802
+ count += 'count' in result ? result.count : chunk.length;
1803
+ if ('mutationId' in result) {
1804
+ mutationId = (result as { mutationId: string }).mutationId;
1805
+ }
1806
+ }
1807
+ return { count, ...(mutationId ? { mutationId } : {}) };
1808
+ }
1809
+ case 'describe': {
1810
+ const info = await directBinding.describe();
1811
+ const details = info as unknown as Record<string, unknown>;
1812
+ return {
1813
+ vectorCount: details.vectorCount ?? details.vectorsCount ?? 0,
1814
+ dimensions: details.dimensions ?? (details.config as Record<string, unknown> | undefined)?.dimensions ?? 0,
1815
+ metric: details.metric ?? (details.config as Record<string, unknown> | undefined)?.metric ?? 'cosine',
1816
+ ...('id' in details ? { id: details.id } : {}),
1817
+ ...('name' in details ? { name: details.name } : {}),
1818
+ ...('processedUpToDatetime' in details ? { processedUpToDatetime: details.processedUpToDatetime } : {}),
1819
+ ...('processedUpToMutation' in details ? { processedUpToMutation: details.processedUpToMutation } : {}),
1820
+ };
1821
+ }
1822
+ }
1823
+ }
1824
+ const { url, key: sk } = requireWorkerUrl(label, workerUrl, serviceKey);
1825
+ const res = await fetch(`${url}/api/vectorize/${index}`, {
1826
+ method: 'POST',
1827
+ headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': sk },
1828
+ body: JSON.stringify(body),
1829
+ });
1830
+ if (!res.ok) throw new Error(`${label} failed: ${res.status}`);
1831
+ return res.json() as Promise<Record<string, unknown>>;
1832
+ };
1833
+
1834
+ return {
1835
+ async upsert(vectors) {
1836
+ const data = await post({ action: 'upsert', vectors }, 'vector().upsert');
1837
+ return data as unknown as { ok: true; count?: number; mutationId?: string };
1838
+ },
1839
+ async insert(vectors) {
1840
+ const data = await post({ action: 'insert', vectors }, 'vector().insert');
1841
+ return data as unknown as { ok: true; count?: number; mutationId?: string };
1842
+ },
1843
+ async search(vector, options) {
1844
+ const data = await post(
1845
+ {
1846
+ action: 'search',
1847
+ vector,
1848
+ topK: options?.topK,
1849
+ filter: options?.filter,
1850
+ namespace: options?.namespace,
1851
+ returnValues: options?.returnValues,
1852
+ returnMetadata: options?.returnMetadata,
1853
+ },
1854
+ 'vector().search',
1855
+ );
1856
+ return (
1857
+ data as {
1858
+ matches: Array<{
1859
+ id: string;
1860
+ score: number;
1861
+ values?: number[];
1862
+ metadata?: Record<string, unknown>;
1863
+ namespace?: string;
1864
+ }>;
1865
+ }
1866
+ ).matches;
1867
+ },
1868
+ async queryById(vectorId, options) {
1869
+ const data = await post(
1870
+ {
1871
+ action: 'queryById',
1872
+ vectorId,
1873
+ topK: options?.topK,
1874
+ filter: options?.filter,
1875
+ namespace: options?.namespace,
1876
+ returnValues: options?.returnValues,
1877
+ returnMetadata: options?.returnMetadata,
1878
+ },
1879
+ 'vector().queryById',
1880
+ );
1881
+ return (
1882
+ data as {
1883
+ matches: Array<{
1884
+ id: string;
1885
+ score: number;
1886
+ values?: number[];
1887
+ metadata?: Record<string, unknown>;
1888
+ namespace?: string;
1889
+ }>;
1890
+ }
1891
+ ).matches;
1892
+ },
1893
+ async getByIds(ids) {
1894
+ const data = await post({ action: 'getByIds', ids }, 'vector().getByIds');
1895
+ return (
1896
+ data as {
1897
+ vectors: Array<{
1898
+ id: string;
1899
+ values?: number[];
1900
+ metadata?: Record<string, unknown>;
1901
+ namespace?: string;
1902
+ }>;
1903
+ }
1904
+ ).vectors;
1905
+ },
1906
+ async delete(ids) {
1907
+ const data = await post({ action: 'delete', ids }, 'vector().delete');
1908
+ return data as unknown as { ok: true; count?: number; mutationId?: string };
1909
+ },
1910
+ async describe() {
1911
+ const data = await post({ action: 'describe' }, 'vector().describe');
1912
+ return data as unknown as { vectorCount: number; dimensions: number; metric: string };
1913
+ },
1914
+ };
1915
+ }
1916
+
1917
+ // ─── Storage Proxy Builder ───
1918
+
1919
+ export function buildFunctionStorageProxy(
1920
+ r2: R2Bucket,
1921
+ bucket: string,
1922
+ env: Env,
1923
+ workerUrl?: string,
1924
+ ): FunctionStorageProxy {
1925
+ const prefix = (key: string) => `${bucket}/${key}`;
1926
+
1927
+ return {
1928
+ async put(key, value, options) {
1929
+ const httpMeta: R2PutOptions = {};
1930
+ if (options?.contentType) httpMeta.httpMetadata = { contentType: options.contentType };
1931
+ if (options?.customMetadata) httpMeta.customMetadata = options.customMetadata;
1932
+ await r2.put(prefix(key), value, httpMeta);
1933
+ },
1934
+
1935
+ async get(key) {
1936
+ const obj = await r2.get(prefix(key));
1937
+ if (!obj) return null;
1938
+ return {
1939
+ body: obj.body,
1940
+ contentType: (obj.httpMetadata?.contentType as string) ?? 'application/octet-stream',
1941
+ size: obj.size,
1942
+ customMetadata: (obj.customMetadata as Record<string, string>) ?? {},
1943
+ };
1944
+ },
1945
+
1946
+ async delete(key) {
1947
+ await r2.delete(prefix(key));
1948
+ },
1949
+
1950
+ async getSignedUrl(key, options) {
1951
+ const secret = (env as unknown as Record<string, string>).JWT_USER_SECRET;
1952
+ if (!secret) throw new Error('Signed URLs require JWT_USER_SECRET to be configured.');
1953
+ const expiresIn = options?.expiresIn ?? 3600;
1954
+ const expiresAt = Date.now() + expiresIn * 1000;
1955
+ const token = await createSignedToken(key, bucket, expiresAt, secret);
1956
+ const base = workerUrl ?? 'http://localhost:8787';
1957
+ return `${base}/api/storage/${encodeURIComponent(bucket)}/${key}?token=${token}`;
1958
+ },
1959
+
1960
+ async list(options) {
1961
+ const r2Options: R2ListOptions = {};
1962
+ if (options?.prefix) r2Options.prefix = prefix(options.prefix);
1963
+ else r2Options.prefix = `${bucket}/`;
1964
+ if (options?.limit) r2Options.limit = options.limit;
1965
+ if (options?.cursor) r2Options.cursor = options.cursor;
1966
+ const result = await r2.list(r2Options);
1967
+ const prefixLen = `${bucket}/`.length;
1968
+ return {
1969
+ keys: result.objects.map((obj) => ({
1970
+ key: obj.key.slice(prefixLen),
1971
+ size: obj.size,
1972
+ contentType: (obj.httpMetadata?.contentType as string) ?? 'application/octet-stream',
1973
+ })),
1974
+ cursor: result.truncated ? result.cursor : undefined,
1975
+ truncated: result.truncated,
1976
+ };
1977
+ },
1978
+
1979
+ async head(key) {
1980
+ const obj = await r2.head(prefix(key));
1981
+ if (!obj) return null;
1982
+ return {
1983
+ key,
1984
+ size: obj.size,
1985
+ contentType: (obj.httpMetadata?.contentType as string) ?? 'application/octet-stream',
1986
+ customMetadata: (obj.customMetadata as Record<string, string>) ?? {},
1987
+ };
1988
+ },
1989
+ };
1990
+ }
1991
+
1992
+ // ─── Push Proxy Builder ───
1993
+
1994
+ export function buildFunctionPushProxy(workerUrl?: string, serviceKey?: string): FunctionPushProxy {
1995
+ const postPush = async (path: string, body: Record<string, unknown>, label: string) => {
1996
+ const { url, key: sk } = requireWorkerUrl(label, workerUrl, serviceKey);
1997
+ const res = await fetch(`${url}/api/push/${path}`, {
1998
+ method: 'POST',
1999
+ headers: { 'Content-Type': 'application/json', 'X-EdgeBase-Service-Key': sk },
2000
+ body: JSON.stringify(body),
2001
+ });
2002
+ if (!res.ok) {
2003
+ const err = (await res.json().catch(() => ({ message: `${label} failed` }))) as {
2004
+ message: string;
2005
+ };
2006
+ throw new Error(err.message);
2007
+ }
2008
+ return res.json();
2009
+ };
2010
+
2011
+ const getPush = async (path: string, label: string) => {
2012
+ const { url, key: sk } = requireWorkerUrl(label, workerUrl, serviceKey);
2013
+ const res = await fetch(`${url}/api/push/${path}`, {
2014
+ method: 'GET',
2015
+ headers: { 'X-EdgeBase-Service-Key': sk },
2016
+ });
2017
+ if (!res.ok) {
2018
+ const err = (await res.json().catch(() => ({ message: `${label} failed` }))) as {
2019
+ message: string;
2020
+ };
2021
+ throw new Error(err.message);
2022
+ }
2023
+ return res.json();
2024
+ };
2025
+
2026
+ return {
2027
+ async send(userId, payload) {
2028
+ return postPush('send', { userId, payload }, 'push.send') as Promise<{
2029
+ sent: number;
2030
+ failed: number;
2031
+ removed: number;
2032
+ }>;
2033
+ },
2034
+ async sendMany(userIds, payload) {
2035
+ return postPush('send-many', { userIds, payload }, 'push.sendMany') as Promise<{
2036
+ sent: number;
2037
+ failed: number;
2038
+ removed: number;
2039
+ }>;
2040
+ },
2041
+ async sendToToken(token, payload, platform?) {
2042
+ return postPush(
2043
+ 'send-to-token',
2044
+ { token, payload, platform },
2045
+ 'push.sendToToken',
2046
+ ) as Promise<{ sent: number; failed: number; error?: string }>;
2047
+ },
2048
+ async sendToTopic(topic, payload) {
2049
+ return postPush('send-to-topic', { topic, payload }, 'push.sendToTopic') as Promise<{
2050
+ success: boolean;
2051
+ error?: string;
2052
+ }>;
2053
+ },
2054
+ async broadcast(payload) {
2055
+ return postPush('broadcast', { payload }, 'push.broadcast') as Promise<{
2056
+ success: boolean;
2057
+ error?: string;
2058
+ }>;
2059
+ },
2060
+ async getTokens(userId) {
2061
+ const data = (await getPush(
2062
+ `tokens?userId=${encodeURIComponent(userId)}`,
2063
+ 'push.getTokens',
2064
+ )) as {
2065
+ items: Array<{
2066
+ deviceId: string;
2067
+ platform: string;
2068
+ updatedAt: string;
2069
+ deviceInfo?: Record<string, string>;
2070
+ metadata?: Record<string, unknown>;
2071
+ }>;
2072
+ };
2073
+ return data.items;
2074
+ },
2075
+ async getLogs(userId, limit?) {
2076
+ const params = new URLSearchParams({ userId });
2077
+ if (limit !== undefined) params.set('limit', String(limit));
2078
+ const data = (await getPush(`logs?${params}`, 'push.getLogs')) as {
2079
+ items: Array<{
2080
+ sentAt: string;
2081
+ userId: string;
2082
+ platform: string;
2083
+ status: string;
2084
+ collapseId?: string;
2085
+ error?: string;
2086
+ }>;
2087
+ };
2088
+ return data.items;
2089
+ },
2090
+ };
2091
+ }