@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,1200 @@
1
+ /**
2
+ * LogsDO — Analytics log storage Durable Object.
3
+ *
4
+ * Provides SQLite-based log storage for Docker/self-hosted environments
5
+ * where Cloudflare Analytics Engine is not available.
6
+ *
7
+ * Architecture:
8
+ * - Single instance per project: `logs:main`
9
+ * - 3-tier pre-aggregation for fast reads:
10
+ * _logs_raw (24h) — exact per-request data
11
+ * _logs_hourly (90d) — hourly aggregates
12
+ * _logs_daily (forever) — daily aggregates
13
+ * - Alarm-based hourly aggregation + cleanup
14
+ *
15
+ * Internal Routes:
16
+ * POST /internal/logs/write — batch insert raw log entries
17
+ * GET /internal/logs/query — query aggregated analytics data
18
+ * GET /internal/logs/recent — query recent raw request logs
19
+ */
20
+ import { DurableObject } from 'cloudflare:workers';
21
+
22
+ const SERVER_ERROR_STATUS = 500;
23
+
24
+ interface LogsDOEnv {
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ export class LogsDO extends DurableObject<LogsDOEnv> {
29
+ private initialized = false;
30
+
31
+ // ─── Schema ───
32
+
33
+ private ensureSchema(): void {
34
+ if (this.initialized) return;
35
+ this.initialized = true;
36
+
37
+ const sql = this.ctx.storage.sql;
38
+
39
+ // Raw logs — exact per-request data, kept for 24 hours
40
+ sql.exec(`
41
+ CREATE TABLE IF NOT EXISTS _logs_raw (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ timestamp INTEGER NOT NULL,
44
+ method TEXT NOT NULL,
45
+ path TEXT NOT NULL,
46
+ status INTEGER NOT NULL,
47
+ duration REAL NOT NULL,
48
+ userId TEXT,
49
+ error TEXT,
50
+ category TEXT,
51
+ subcategory TEXT,
52
+ target1 TEXT,
53
+ target2 TEXT,
54
+ operation TEXT,
55
+ region TEXT,
56
+ requestSize INTEGER DEFAULT 0,
57
+ responseSize INTEGER DEFAULT 0,
58
+ resultCount INTEGER DEFAULT 0
59
+ )
60
+ `);
61
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_raw_ts ON _logs_raw(timestamp)`);
62
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_raw_cat ON _logs_raw(category)`);
63
+
64
+ // Hourly aggregates — kept for 90 days
65
+ sql.exec(`
66
+ CREATE TABLE IF NOT EXISTS _logs_hourly (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ hour_ts INTEGER NOT NULL,
69
+ category TEXT NOT NULL DEFAULT '',
70
+ subcategory TEXT NOT NULL DEFAULT '',
71
+ target1 TEXT NOT NULL DEFAULT '',
72
+ target2 TEXT NOT NULL DEFAULT '',
73
+ operation TEXT NOT NULL DEFAULT '',
74
+ region TEXT NOT NULL DEFAULT '',
75
+ request_count INTEGER NOT NULL DEFAULT 0,
76
+ error_count INTEGER NOT NULL DEFAULT 0,
77
+ avg_duration REAL NOT NULL DEFAULT 0,
78
+ p95_duration REAL NOT NULL DEFAULT 0,
79
+ unique_users INTEGER NOT NULL DEFAULT 0,
80
+ total_request_size INTEGER NOT NULL DEFAULT 0,
81
+ total_response_size INTEGER NOT NULL DEFAULT 0,
82
+ total_result_count INTEGER NOT NULL DEFAULT 0
83
+ )
84
+ `);
85
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_hourly_ts ON _logs_hourly(hour_ts)`);
86
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_hourly_cat ON _logs_hourly(hour_ts, category)`);
87
+
88
+ // Daily aggregates — kept permanently
89
+ sql.exec(`
90
+ CREATE TABLE IF NOT EXISTS _logs_daily (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ day_ts INTEGER NOT NULL,
93
+ category TEXT NOT NULL DEFAULT '',
94
+ subcategory TEXT NOT NULL DEFAULT '',
95
+ target1 TEXT NOT NULL DEFAULT '',
96
+ target2 TEXT NOT NULL DEFAULT '',
97
+ operation TEXT NOT NULL DEFAULT '',
98
+ region TEXT NOT NULL DEFAULT '',
99
+ request_count INTEGER NOT NULL DEFAULT 0,
100
+ error_count INTEGER NOT NULL DEFAULT 0,
101
+ avg_duration REAL NOT NULL DEFAULT 0,
102
+ p95_duration REAL NOT NULL DEFAULT 0,
103
+ unique_users INTEGER NOT NULL DEFAULT 0,
104
+ total_request_size INTEGER NOT NULL DEFAULT 0,
105
+ total_response_size INTEGER NOT NULL DEFAULT 0,
106
+ total_result_count INTEGER NOT NULL DEFAULT 0
107
+ )
108
+ `);
109
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_daily_ts ON _logs_daily(day_ts)`);
110
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_logs_daily_cat ON _logs_daily(day_ts, category)`);
111
+
112
+ // Custom events — raw events (90-day retention)
113
+ sql.exec(`
114
+ CREATE TABLE IF NOT EXISTS _events (
115
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ timestamp INTEGER NOT NULL,
117
+ userId TEXT,
118
+ eventName TEXT NOT NULL,
119
+ properties TEXT,
120
+ region TEXT
121
+ )
122
+ `);
123
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_ts ON _events(timestamp)`);
124
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_name ON _events(eventName)`);
125
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_user ON _events(userId)`);
126
+
127
+ // Custom events — daily aggregates (permanent)
128
+ sql.exec(`
129
+ CREATE TABLE IF NOT EXISTS _events_daily (
130
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
131
+ day_ts INTEGER NOT NULL,
132
+ eventName TEXT NOT NULL,
133
+ event_count INTEGER NOT NULL DEFAULT 0,
134
+ unique_users INTEGER NOT NULL DEFAULT 0
135
+ )
136
+ `);
137
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_daily_ts ON _events_daily(day_ts)`);
138
+ sql.exec(`CREATE INDEX IF NOT EXISTS idx_events_daily_name ON _events_daily(day_ts, eventName)`);
139
+
140
+ // Schedule first alarm if not already scheduled
141
+ this.scheduleNextAlarm();
142
+ }
143
+
144
+ // ─── Alarm ───
145
+
146
+ private scheduleNextAlarm(): void {
147
+ // Next full hour
148
+ const next = new Date();
149
+ next.setMinutes(0, 0, 0);
150
+ next.setHours(next.getHours() + 1);
151
+ this.ctx.storage.setAlarm(next.getTime());
152
+ }
153
+
154
+ async alarm(): Promise<void> {
155
+ this.ensureSchema();
156
+
157
+ try {
158
+ this.aggregateHourly();
159
+ this.aggregateDaily();
160
+ this.aggregateEvents();
161
+ this.cleanup();
162
+ } catch (err) {
163
+ console.error('[LogsDO] Alarm aggregation failed:', err);
164
+ }
165
+
166
+ // Reschedule
167
+ this.scheduleNextAlarm();
168
+ }
169
+
170
+ /**
171
+ * Aggregate raw logs older than 1 hour into _logs_hourly.
172
+ * Groups by (hour, category, subcategory, target1, target2, operation, region).
173
+ */
174
+ private aggregateHourly(): void {
175
+ const sql = this.ctx.storage.sql;
176
+ const now = Date.now();
177
+ // Aggregate everything older than 1 hour
178
+ const cutoff = now - 3600_000;
179
+
180
+ // Find the oldest raw log timestamp to determine range
181
+ const oldest = sql.exec(`SELECT MIN(timestamp) as min_ts FROM _logs_raw WHERE timestamp < ?`, cutoff).toArray();
182
+ if (!oldest.length || oldest[0].min_ts == null) return;
183
+
184
+ const minTs = oldest[0].min_ts as number;
185
+
186
+ // Process hour by hour
187
+ const startHour = Math.floor(minTs / 3600_000) * 3600_000;
188
+ const endHour = Math.floor(cutoff / 3600_000) * 3600_000;
189
+
190
+ for (let hourTs = startHour; hourTs <= endHour; hourTs += 3600_000) {
191
+ const hourEnd = hourTs + 3600_000;
192
+
193
+ // Check if already aggregated
194
+ const existing = sql.exec(
195
+ `SELECT COUNT(*) as cnt FROM _logs_hourly WHERE hour_ts = ?`,
196
+ hourTs,
197
+ ).toArray();
198
+ if (existing.length && (existing[0].cnt as number) > 0) continue;
199
+
200
+ // Aggregate
201
+ sql.exec(`
202
+ INSERT INTO _logs_hourly (hour_ts, category, subcategory, target1, target2, operation, region,
203
+ request_count, error_count, avg_duration, p95_duration, unique_users,
204
+ total_request_size, total_response_size, total_result_count)
205
+ SELECT
206
+ ? as hour_ts,
207
+ COALESCE(category, '') as category,
208
+ COALESCE(subcategory, '') as subcategory,
209
+ COALESCE(target1, '') as target1,
210
+ COALESCE(target2, '') as target2,
211
+ COALESCE(operation, '') as operation,
212
+ COALESCE(region, '') as region,
213
+ COUNT(*) as request_count,
214
+ SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1 ELSE 0 END) as error_count,
215
+ AVG(duration) as avg_duration,
216
+ 0 as p95_duration,
217
+ COUNT(DISTINCT userId) as unique_users,
218
+ SUM(COALESCE(requestSize, 0)) as total_request_size,
219
+ SUM(COALESCE(responseSize, 0)) as total_response_size,
220
+ SUM(COALESCE(resultCount, 0)) as total_result_count
221
+ FROM _logs_raw
222
+ WHERE timestamp >= ? AND timestamp < ?
223
+ GROUP BY category, subcategory, target1, target2, operation, region
224
+ `, hourTs, hourTs, hourEnd);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Aggregate hourly data older than 90 days into _logs_daily.
230
+ */
231
+ private aggregateDaily(): void {
232
+ const sql = this.ctx.storage.sql;
233
+ const now = Date.now();
234
+ const cutoff90d = now - 90 * 86400_000;
235
+
236
+ const oldest = sql.exec(
237
+ `SELECT MIN(hour_ts) as min_ts FROM _logs_hourly WHERE hour_ts < ?`,
238
+ cutoff90d,
239
+ ).toArray();
240
+ if (!oldest.length || oldest[0].min_ts == null) return;
241
+
242
+ const minTs = oldest[0].min_ts as number;
243
+ const startDay = Math.floor(minTs / 86400_000) * 86400_000;
244
+ const endDay = Math.floor(cutoff90d / 86400_000) * 86400_000;
245
+
246
+ for (let dayTs = startDay; dayTs <= endDay; dayTs += 86400_000) {
247
+ const dayEnd = dayTs + 86400_000;
248
+
249
+ const existing = sql.exec(
250
+ `SELECT COUNT(*) as cnt FROM _logs_daily WHERE day_ts = ?`,
251
+ dayTs,
252
+ ).toArray();
253
+ if (existing.length && (existing[0].cnt as number) > 0) continue;
254
+
255
+ sql.exec(`
256
+ INSERT INTO _logs_daily (day_ts, category, subcategory, target1, target2, operation, region,
257
+ request_count, error_count, avg_duration, p95_duration, unique_users,
258
+ total_request_size, total_response_size, total_result_count)
259
+ SELECT
260
+ ? as day_ts,
261
+ category, subcategory, target1, target2, operation, region,
262
+ SUM(request_count) as request_count,
263
+ SUM(error_count) as error_count,
264
+ CASE WHEN SUM(request_count) > 0
265
+ THEN SUM(avg_duration * request_count) / SUM(request_count)
266
+ ELSE 0 END as avg_duration,
267
+ MAX(p95_duration) as p95_duration,
268
+ SUM(unique_users) as unique_users,
269
+ SUM(total_request_size) as total_request_size,
270
+ SUM(total_response_size) as total_response_size,
271
+ SUM(total_result_count) as total_result_count
272
+ FROM _logs_hourly
273
+ WHERE hour_ts >= ? AND hour_ts < ?
274
+ GROUP BY category, subcategory, target1, target2, operation, region
275
+ `, dayTs, dayTs, dayEnd);
276
+ }
277
+
278
+ // Delete aggregated hourly rows
279
+ sql.exec(`DELETE FROM _logs_hourly WHERE hour_ts < ?`, cutoff90d);
280
+ }
281
+
282
+ /**
283
+ * Aggregate custom events older than 90 days into _events_daily.
284
+ */
285
+ private aggregateEvents(): void {
286
+ const sql = this.ctx.storage.sql;
287
+ const cutoff90d = Date.now() - 90 * 86400_000;
288
+
289
+ const oldest = sql.exec(
290
+ `SELECT MIN(timestamp) as min_ts FROM _events WHERE timestamp < ?`,
291
+ cutoff90d,
292
+ ).toArray();
293
+ if (!oldest.length || oldest[0].min_ts == null) return;
294
+
295
+ const minTs = oldest[0].min_ts as number;
296
+ const startDay = Math.floor(minTs / 86400_000) * 86400_000;
297
+ const endDay = Math.floor(cutoff90d / 86400_000) * 86400_000;
298
+
299
+ for (let dayTs = startDay; dayTs <= endDay; dayTs += 86400_000) {
300
+ const dayEnd = dayTs + 86400_000;
301
+
302
+ const existing = sql.exec(
303
+ `SELECT COUNT(*) as cnt FROM _events_daily WHERE day_ts = ?`,
304
+ dayTs,
305
+ ).toArray();
306
+ if (existing.length && (existing[0].cnt as number) > 0) continue;
307
+
308
+ sql.exec(`
309
+ INSERT INTO _events_daily (day_ts, eventName, event_count, unique_users)
310
+ SELECT
311
+ ? as day_ts,
312
+ eventName,
313
+ COUNT(*) as event_count,
314
+ COUNT(DISTINCT userId) as unique_users
315
+ FROM _events
316
+ WHERE timestamp >= ? AND timestamp < ?
317
+ GROUP BY eventName
318
+ `, dayTs, dayTs, dayEnd);
319
+ }
320
+
321
+ // Delete aggregated events
322
+ sql.exec(`DELETE FROM _events WHERE timestamp < ?`, cutoff90d);
323
+ }
324
+
325
+ /**
326
+ * Remove raw logs older than 24 hours (already aggregated into hourly).
327
+ */
328
+ private cleanup(): void {
329
+ const cutoff24h = Date.now() - 86400_000;
330
+ this.ctx.storage.sql.exec(`DELETE FROM _logs_raw WHERE timestamp < ?`, cutoff24h);
331
+ }
332
+
333
+ // ─── Request Handler ───
334
+
335
+ async fetch(request: Request): Promise<Response> {
336
+ this.ensureSchema();
337
+
338
+ const url = new URL(request.url);
339
+ const path = url.pathname;
340
+
341
+ if (path === '/internal/logs/write' && request.method === 'POST') {
342
+ return this.handleWrite(request);
343
+ }
344
+
345
+ if (path === '/internal/logs/query' && request.method === 'GET') {
346
+ return this.handleQuery(url);
347
+ }
348
+
349
+ if (path === '/internal/logs/history' && request.method === 'GET') {
350
+ return this.handleHistory(url);
351
+ }
352
+
353
+ if (path === '/internal/logs/recent' && request.method === 'GET') {
354
+ return this.handleRecent(url);
355
+ }
356
+
357
+ if (path === '/internal/events/write' && request.method === 'POST') {
358
+ return this.handleEventsWrite(request);
359
+ }
360
+
361
+ if (path === '/internal/events/query' && request.method === 'GET') {
362
+ return this.handleEventsQuery(url);
363
+ }
364
+
365
+ return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 });
366
+ }
367
+
368
+ // ─── Write Handler ───
369
+
370
+ private async handleWrite(request: Request): Promise<Response> {
371
+ try {
372
+ const body = (await request.json()) as { entries: Array<Record<string, unknown>> };
373
+ const entries = body.entries;
374
+ if (!Array.isArray(entries) || entries.length === 0) {
375
+ return new Response(JSON.stringify({ ok: true, count: 0 }), {
376
+ headers: { 'Content-Type': 'application/json' },
377
+ });
378
+ }
379
+
380
+ const sql = this.ctx.storage.sql;
381
+
382
+ for (const e of entries) {
383
+ sql.exec(`
384
+ INSERT INTO _logs_raw (timestamp, method, path, status, duration, userId, error,
385
+ category, subcategory, target1, target2, operation, region,
386
+ requestSize, responseSize, resultCount)
387
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
388
+ `,
389
+ (e.timestamp as number) || Date.now(),
390
+ (e.method as string) || '',
391
+ (e.path as string) || '',
392
+ (e.status as number) || 0,
393
+ (e.duration as number) || 0,
394
+ (e.userId as string) || null,
395
+ (e.error as string) || null,
396
+ (e.category as string) || '',
397
+ (e.subcategory as string) || '',
398
+ (e.target1 as string) || '',
399
+ (e.target2 as string) || '',
400
+ (e.operation as string) || '',
401
+ (e.region as string) || '',
402
+ (e.requestSize as number) || 0,
403
+ (e.responseSize as number) || 0,
404
+ (e.resultCount as number) || 0,
405
+ );
406
+ }
407
+
408
+ return new Response(JSON.stringify({ ok: true, count: entries.length }), {
409
+ headers: { 'Content-Type': 'application/json' },
410
+ });
411
+ } catch (err) {
412
+ console.error('[LogsDO] Write failed:', err);
413
+ return new Response(JSON.stringify({ error: 'Write failed' }), { status: 500 });
414
+ }
415
+ }
416
+
417
+ // ─── Query Handler ───
418
+
419
+ /**
420
+ * Query analytics data.
421
+ *
422
+ * Query params:
423
+ * range: '1h'|'6h'|'24h'|'7d'|'30d'|'90d' (default '24h')
424
+ * category: filter by category (optional)
425
+ * metric: 'overview'|'timeSeries'|'breakdown'|'topEndpoints' (default 'overview')
426
+ * groupBy: 'minute'|'tenMinute'|'hour'|'day' (default 'hour')
427
+ */
428
+ private handleQuery(url: URL): Response {
429
+ try {
430
+ const range = url.searchParams.get('range') || '24h';
431
+ const start = url.searchParams.get('start');
432
+ const end = url.searchParams.get('end');
433
+ const category = url.searchParams.get('category') || '';
434
+ const excludeCategory = url.searchParams.get('excludeCategory') || '';
435
+ const metric = url.searchParams.get('metric') || 'overview';
436
+ const groupBy = url.searchParams.get('groupBy') || 'hour';
437
+
438
+ const { startTs, endTs } = this.parseTimeRange(range, start, end);
439
+ const table = this.selectTable(range);
440
+ const tsCol = table === '_logs_raw' ? 'timestamp' : table === '_logs_hourly' ? 'hour_ts' : 'day_ts';
441
+
442
+ // Build combined category filter
443
+ const catParts: string[] = [];
444
+ if (category) catParts.push(`category = '${escapeSql(category)}'`);
445
+ if (excludeCategory) catParts.push(`category != '${escapeSql(excludeCategory)}'`);
446
+ const catFilter = catParts.length > 0 ? ` AND ${catParts.join(' AND ')}` : '';
447
+
448
+ const sql = this.ctx.storage.sql;
449
+
450
+ if (metric === 'overview') {
451
+ return this.queryOverview(sql, table, tsCol, startTs, endTs, catFilter, groupBy);
452
+ }
453
+
454
+ if (metric === 'timeSeries') {
455
+ return this.queryTimeSeries(sql, table, tsCol, startTs, endTs, catFilter, groupBy);
456
+ }
457
+
458
+ if (metric === 'breakdown') {
459
+ return this.queryBreakdown(sql, table, tsCol, startTs, endTs, catFilter, category);
460
+ }
461
+
462
+ if (metric === 'topEndpoints') {
463
+ return this.queryTopEndpoints(sql, table, tsCol, startTs, endTs, catFilter);
464
+ }
465
+
466
+ return jsonResponse({ error: 'Unknown metric' }, 400);
467
+ } catch (err) {
468
+ console.error('[LogsDO] Query failed:', err);
469
+ return jsonResponse({ error: 'Query failed' }, 500);
470
+ }
471
+ }
472
+
473
+ private handleRecent(url: URL): Response {
474
+ try {
475
+ const limit = Math.max(1, Math.min(parseInt(url.searchParams.get('limit') || '50', 10), 200));
476
+ const level = (url.searchParams.get('level') || '').toLowerCase();
477
+ const pathFilter = url.searchParams.get('path') || '';
478
+ const category = (url.searchParams.get('category') || '').toLowerCase();
479
+
480
+ const whereParts: string[] = [];
481
+ const params: Array<string | number> = [];
482
+
483
+ if (level === 'error') {
484
+ whereParts.push('status >= ?');
485
+ params.push(SERVER_ERROR_STATUS);
486
+ } else if (level === 'warn') {
487
+ whereParts.push('status >= ? AND status < ?');
488
+ params.push(300, SERVER_ERROR_STATUS);
489
+ } else if (level === 'info') {
490
+ whereParts.push('status >= ? AND status < ?');
491
+ params.push(200, 300);
492
+ }
493
+
494
+ if (pathFilter.trim()) {
495
+ whereParts.push('path LIKE ?');
496
+ params.push(`%${pathFilter.trim()}%`);
497
+ }
498
+
499
+ if (category && category !== 'all') {
500
+ whereParts.push('LOWER(category) = ?');
501
+ params.push(category);
502
+ }
503
+
504
+ const whereClause = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
505
+ const sql = this.ctx.storage.sql;
506
+ const rows = sql.exec(
507
+ `
508
+ SELECT
509
+ timestamp,
510
+ method,
511
+ path,
512
+ status,
513
+ duration,
514
+ userId,
515
+ error,
516
+ category,
517
+ subcategory,
518
+ target1,
519
+ target2,
520
+ operation,
521
+ region,
522
+ requestSize,
523
+ responseSize,
524
+ resultCount
525
+ FROM _logs_raw
526
+ ${whereClause}
527
+ ORDER BY timestamp DESC
528
+ LIMIT ?
529
+ `,
530
+ ...params,
531
+ limit,
532
+ ).toArray();
533
+
534
+ return jsonResponse({
535
+ logs: rows.map((row) => ({
536
+ timestamp: Number(row.timestamp ?? 0),
537
+ method: String(row.method ?? ''),
538
+ path: String(row.path ?? ''),
539
+ status: Number(row.status ?? 0),
540
+ duration: Number(row.duration ?? 0),
541
+ userId: row.userId ? String(row.userId) : undefined,
542
+ error: row.error ? String(row.error) : undefined,
543
+ category: String(row.category ?? ''),
544
+ subcategory: String(row.subcategory ?? ''),
545
+ target1: String(row.target1 ?? ''),
546
+ target2: String(row.target2 ?? ''),
547
+ operation: String(row.operation ?? ''),
548
+ region: String(row.region ?? ''),
549
+ requestSize: Number(row.requestSize ?? 0),
550
+ responseSize: Number(row.responseSize ?? 0),
551
+ resultCount: Number(row.resultCount ?? 0),
552
+ })),
553
+ total: rows.length,
554
+ });
555
+ } catch (err) {
556
+ console.error('[LogsDO] Recent logs query failed:', err);
557
+ return jsonResponse({ error: 'Recent logs query failed' }, 500);
558
+ }
559
+ }
560
+
561
+ private handleHistory(url: URL): Response {
562
+ try {
563
+ const category = url.searchParams.get('category') || '';
564
+ const excludeCategory = url.searchParams.get('excludeCategory') || '';
565
+ const catParts: string[] = [];
566
+ if (category) catParts.push(`category = '${escapeSql(category)}'`);
567
+ if (excludeCategory) catParts.push(`category != '${escapeSql(excludeCategory)}'`);
568
+ const whereClause = catParts.length > 0 ? ` WHERE ${catParts.join(' AND ')}` : '';
569
+
570
+ const sql = this.ctx.storage.sql;
571
+ const rows = sql.exec(
572
+ `
573
+ SELECT MIN(ts) as oldestTimestamp
574
+ FROM (
575
+ SELECT MIN(timestamp) as ts FROM _logs_raw${whereClause}
576
+ UNION ALL
577
+ SELECT MIN(hour_ts) as ts FROM _logs_hourly${whereClause}
578
+ UNION ALL
579
+ SELECT MIN(day_ts) as ts FROM _logs_daily${whereClause}
580
+ )
581
+ WHERE ts IS NOT NULL
582
+ `,
583
+ ).toArray();
584
+
585
+ const oldestTimestamp = rows[0]?.oldestTimestamp;
586
+ return jsonResponse({
587
+ oldestTimestamp:
588
+ oldestTimestamp == null || !Number.isFinite(Number(oldestTimestamp))
589
+ ? null
590
+ : Number(oldestTimestamp),
591
+ });
592
+ } catch (err) {
593
+ console.error('[LogsDO] History query failed:', err);
594
+ return jsonResponse({ error: 'History query failed' }, 500);
595
+ }
596
+ }
597
+
598
+ // ─── Query implementations ───
599
+
600
+ private queryOverview(
601
+ sql: SqlStorage, table: string, tsCol: string,
602
+ startTs: number, endTs: number, catFilter: string, groupBy: string,
603
+ ): Response {
604
+
605
+ // Summary
606
+ let summary: Record<string, unknown>;
607
+ if (table === '_logs_raw') {
608
+ const rows = sql.exec(`
609
+ SELECT
610
+ COUNT(*) as totalRequests,
611
+ SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1 ELSE 0 END) as totalErrors,
612
+ AVG(duration) as avgLatency,
613
+ COUNT(DISTINCT userId) as uniqueUsers
614
+ FROM ${table}
615
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
616
+ `, startTs, endTs).toArray();
617
+ summary = rows[0] || { totalRequests: 0, totalErrors: 0, avgLatency: 0, uniqueUsers: 0 };
618
+ } else {
619
+ const rows = sql.exec(`
620
+ SELECT
621
+ SUM(request_count) as totalRequests,
622
+ SUM(error_count) as totalErrors,
623
+ CASE WHEN SUM(request_count) > 0
624
+ THEN SUM(avg_duration * request_count) / SUM(request_count)
625
+ ELSE 0 END as avgLatency,
626
+ SUM(unique_users) as uniqueUsers
627
+ FROM ${table}
628
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
629
+ `, startTs, endTs).toArray();
630
+ summary = rows[0] || { totalRequests: 0, totalErrors: 0, avgLatency: 0, uniqueUsers: 0 };
631
+ }
632
+
633
+ // Time series
634
+ const bucketMs = this.groupByToMs(groupBy);
635
+ let timeSeries: Record<string, unknown>[];
636
+ if (table === '_logs_raw') {
637
+ timeSeries = sql.exec(`
638
+ SELECT
639
+ (CAST(${tsCol} / ? AS INTEGER) * ?) as ts,
640
+ COUNT(*) as value
641
+ FROM ${table}
642
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
643
+ GROUP BY ts
644
+ ORDER BY ts
645
+ `, bucketMs, bucketMs, startTs, endTs).toArray();
646
+ } else {
647
+ timeSeries = sql.exec(`
648
+ SELECT
649
+ (CAST(${tsCol} / ? AS INTEGER) * ?) as ts,
650
+ SUM(request_count) as value
651
+ FROM ${table}
652
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
653
+ GROUP BY ts
654
+ ORDER BY ts
655
+ `, bucketMs, bucketMs, startTs, endTs).toArray();
656
+ }
657
+
658
+ // Breakdown by category
659
+ let breakdown: Record<string, unknown>[];
660
+ if (table === '_logs_raw') {
661
+ breakdown = sql.exec(`
662
+ SELECT
663
+ COALESCE(category, 'other') as label,
664
+ COUNT(*) as count
665
+ FROM ${table}
666
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
667
+ GROUP BY category
668
+ ORDER BY count DESC
669
+ LIMIT 20
670
+ `, startTs, endTs).toArray();
671
+ } else {
672
+ breakdown = sql.exec(`
673
+ SELECT
674
+ category as label,
675
+ SUM(request_count) as count
676
+ FROM ${table}
677
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
678
+ GROUP BY category
679
+ ORDER BY count DESC
680
+ LIMIT 20
681
+ `, startTs, endTs).toArray();
682
+ }
683
+
684
+ // Add percentages
685
+ const totalCount = breakdown.reduce((sum, b) => sum + ((b.count as number) || 0), 0);
686
+ const breakdownWithPct = breakdown.map(b => ({
687
+ ...b,
688
+ percentage: totalCount > 0 ? Math.round(((b.count as number) / totalCount) * 1000) / 10 : 0,
689
+ }));
690
+
691
+ // Top endpoints
692
+ let topItems: Record<string, unknown>[];
693
+ if (table === '_logs_raw') {
694
+ topItems = sql.exec(`
695
+ SELECT
696
+ path as label,
697
+ COUNT(*) as count,
698
+ AVG(duration) as avgLatency,
699
+ ROUND(SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100, 1) as errorRate
700
+ FROM ${table}
701
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
702
+ GROUP BY path
703
+ ORDER BY count DESC
704
+ LIMIT 10
705
+ `, startTs, endTs).toArray();
706
+ } else {
707
+ topItems = sql.exec(`
708
+ SELECT
709
+ (category || ':' || operation) as label,
710
+ SUM(request_count) as count,
711
+ CASE WHEN SUM(request_count) > 0
712
+ THEN SUM(avg_duration * request_count) / SUM(request_count)
713
+ ELSE 0 END as avgLatency,
714
+ CASE WHEN SUM(request_count) > 0
715
+ THEN ROUND(SUM(error_count) * 100.0 / SUM(request_count), 1)
716
+ ELSE 0 END as errorRate
717
+ FROM ${table}
718
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
719
+ GROUP BY category, operation
720
+ ORDER BY count DESC
721
+ LIMIT 10
722
+ `, startTs, endTs).toArray();
723
+ }
724
+
725
+ return jsonResponse({
726
+ timeSeries: timeSeries.map(r => ({ timestamp: r.ts, value: r.value })),
727
+ summary,
728
+ breakdown: breakdownWithPct,
729
+ topItems,
730
+ });
731
+ }
732
+
733
+ private queryTimeSeries(
734
+ sql: SqlStorage, table: string, tsCol: string,
735
+ startTs: number, endTs: number, catFilter: string, groupBy: string,
736
+ ): Response {
737
+ const bucketMs = this.groupByToMs(groupBy);
738
+
739
+ let rows: Record<string, unknown>[];
740
+ if (table === '_logs_raw') {
741
+ rows = sql.exec(`
742
+ SELECT
743
+ (CAST(${tsCol} / ? AS INTEGER) * ?) as ts,
744
+ COUNT(*) as requests,
745
+ SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1 ELSE 0 END) as errors,
746
+ AVG(duration) as avgLatency,
747
+ COUNT(DISTINCT userId) as uniqueUsers
748
+ FROM ${table}
749
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
750
+ GROUP BY ts
751
+ ORDER BY ts
752
+ `, bucketMs, bucketMs, startTs, endTs).toArray();
753
+ } else {
754
+ rows = sql.exec(`
755
+ SELECT
756
+ (CAST(${tsCol} / ? AS INTEGER) * ?) as ts,
757
+ SUM(request_count) as requests,
758
+ SUM(error_count) as errors,
759
+ CASE WHEN SUM(request_count) > 0
760
+ THEN SUM(avg_duration * request_count) / SUM(request_count)
761
+ ELSE 0 END as avgLatency,
762
+ SUM(unique_users) as uniqueUsers
763
+ FROM ${table}
764
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
765
+ GROUP BY ts
766
+ ORDER BY ts
767
+ `, bucketMs, bucketMs, startTs, endTs).toArray();
768
+ }
769
+
770
+ return jsonResponse({
771
+ timeSeries: rows.map(r => ({
772
+ timestamp: r.ts,
773
+ requests: r.requests,
774
+ errors: r.errors,
775
+ avgLatency: r.avgLatency,
776
+ uniqueUsers: r.uniqueUsers,
777
+ })),
778
+ });
779
+ }
780
+
781
+ private queryBreakdown(
782
+ sql: SqlStorage, table: string, tsCol: string,
783
+ startTs: number, endTs: number, catFilter: string, category: string,
784
+ ): Response {
785
+
786
+ // If filtering by category, break down by subcategory; otherwise by category
787
+ const groupCol = category
788
+ ? 'subcategory'
789
+ : 'category';
790
+
791
+ let rows: Record<string, unknown>[];
792
+ if (table === '_logs_raw') {
793
+ rows = sql.exec(`
794
+ SELECT
795
+ COALESCE(${groupCol}, 'other') as label,
796
+ COUNT(*) as count,
797
+ AVG(duration) as avgLatency,
798
+ ROUND(SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100, 1) as errorRate
799
+ FROM ${table}
800
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
801
+ GROUP BY ${groupCol}
802
+ ORDER BY count DESC
803
+ LIMIT 20
804
+ `, startTs, endTs).toArray();
805
+ } else {
806
+ rows = sql.exec(`
807
+ SELECT
808
+ ${groupCol} as label,
809
+ SUM(request_count) as count,
810
+ CASE WHEN SUM(request_count) > 0
811
+ THEN SUM(avg_duration * request_count) / SUM(request_count)
812
+ ELSE 0 END as avgLatency,
813
+ CASE WHEN SUM(request_count) > 0
814
+ THEN ROUND(SUM(error_count) * 100.0 / SUM(request_count), 1)
815
+ ELSE 0 END as errorRate
816
+ FROM ${table}
817
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
818
+ GROUP BY ${groupCol}
819
+ ORDER BY count DESC
820
+ LIMIT 20
821
+ `, startTs, endTs).toArray();
822
+ }
823
+
824
+ const total = rows.reduce((sum, r) => sum + ((r.count as number) || 0), 0);
825
+ const withPct = rows.map(r => ({
826
+ ...r,
827
+ percentage: total > 0 ? Math.round(((r.count as number) / total) * 1000) / 10 : 0,
828
+ }));
829
+
830
+ return jsonResponse({ breakdown: withPct });
831
+ }
832
+
833
+ private queryTopEndpoints(
834
+ sql: SqlStorage, table: string, tsCol: string,
835
+ startTs: number, endTs: number, catFilter: string,
836
+ ): Response {
837
+
838
+ let rows: Record<string, unknown>[];
839
+ if (table === '_logs_raw') {
840
+ rows = sql.exec(`
841
+ SELECT
842
+ path as label,
843
+ COUNT(*) as count,
844
+ AVG(duration) as avgLatency,
845
+ ROUND(SUM(CASE WHEN status >= ${SERVER_ERROR_STATUS} THEN 1.0 ELSE 0.0 END) / COUNT(*) * 100, 1) as errorRate
846
+ FROM ${table}
847
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
848
+ GROUP BY path
849
+ ORDER BY count DESC
850
+ LIMIT 20
851
+ `, startTs, endTs).toArray();
852
+ } else {
853
+ rows = sql.exec(`
854
+ SELECT
855
+ (target1 || '/' || target2) as label,
856
+ SUM(request_count) as count,
857
+ CASE WHEN SUM(request_count) > 0
858
+ THEN SUM(avg_duration * request_count) / SUM(request_count)
859
+ ELSE 0 END as avgLatency,
860
+ CASE WHEN SUM(request_count) > 0
861
+ THEN ROUND(SUM(error_count) * 100.0 / SUM(request_count), 1)
862
+ ELSE 0 END as errorRate
863
+ FROM ${table}
864
+ WHERE ${tsCol} >= ? AND ${tsCol} < ?${catFilter}
865
+ GROUP BY target1, target2
866
+ ORDER BY count DESC
867
+ LIMIT 20
868
+ `, startTs, endTs).toArray();
869
+ }
870
+
871
+ return jsonResponse({ topItems: rows });
872
+ }
873
+
874
+ // ─── Events Write Handler ───
875
+
876
+ private async handleEventsWrite(request: Request): Promise<Response> {
877
+ try {
878
+ const body = (await request.json()) as { events: Array<Record<string, unknown>> };
879
+ const events = body.events;
880
+ if (!Array.isArray(events) || events.length === 0) {
881
+ return jsonResponse({ ok: true, count: 0 });
882
+ }
883
+
884
+ const sql = this.ctx.storage.sql;
885
+
886
+ for (const e of events) {
887
+ sql.exec(`
888
+ INSERT INTO _events (timestamp, userId, eventName, properties, region)
889
+ VALUES (?, ?, ?, ?, ?)
890
+ `,
891
+ (e.timestamp as number) || Date.now(),
892
+ (e.userId as string) || null,
893
+ (e.eventName as string) || '',
894
+ e.properties ? (typeof e.properties === 'string' ? e.properties : JSON.stringify(e.properties)) : null,
895
+ (e.region as string) || '',
896
+ );
897
+ }
898
+
899
+ return jsonResponse({ ok: true, count: events.length });
900
+ } catch (err) {
901
+ console.error('[LogsDO] Events write failed:', err);
902
+ return jsonResponse({ error: 'Events write failed' }, 500);
903
+ }
904
+ }
905
+
906
+ // ─── Events Query Handler ───
907
+
908
+ /**
909
+ * Query custom events.
910
+ *
911
+ * Query params:
912
+ * range: '1h'|'6h'|'24h'|'7d'|'30d'|'90d' (default '24h')
913
+ * event: filter by event name (optional)
914
+ * userId: filter by userId (optional)
915
+ * metric: 'list'|'count'|'timeSeries'|'topEvents' (default 'list')
916
+ * groupBy: 'minute'|'tenMinute'|'hour'|'day' (default 'hour')
917
+ * limit: max items for list (default 50)
918
+ * cursor: pagination cursor for list
919
+ */
920
+ private handleEventsQuery(url: URL): Response {
921
+ try {
922
+ const range = url.searchParams.get('range') || '24h';
923
+ const event = url.searchParams.get('event') || '';
924
+ const userId = url.searchParams.get('userId') || '';
925
+ const metric = url.searchParams.get('metric') || 'list';
926
+ const groupBy = url.searchParams.get('groupBy') || 'hour';
927
+ const limit = parseInt(url.searchParams.get('limit') || '50', 10);
928
+ const cursor = url.searchParams.get('cursor') || '';
929
+
930
+ const { startTs, endTs } = this.parseTimeRange(range);
931
+ const sql = this.ctx.storage.sql;
932
+
933
+ // Determine if we need _events or _events_daily
934
+ const useDaily = range !== '1h' && range !== '6h' && range !== '24h'
935
+ && range !== '7d' && range !== '30d' && range !== '90d';
936
+
937
+ if (metric === 'list') {
938
+ return this.queryEventsList(sql, startTs, endTs, event, userId, limit, cursor);
939
+ }
940
+
941
+ if (metric === 'count') {
942
+ return this.queryEventsCount(sql, startTs, endTs, event, userId);
943
+ }
944
+
945
+ if (metric === 'timeSeries') {
946
+ return this.queryEventsTimeSeries(sql, startTs, endTs, event, userId, groupBy, useDaily);
947
+ }
948
+
949
+ if (metric === 'topEvents') {
950
+ return this.queryEventsTop(sql, startTs, endTs, userId, limit, useDaily);
951
+ }
952
+
953
+ return jsonResponse({ error: 'Unknown metric' }, 400);
954
+ } catch (err) {
955
+ console.error('[LogsDO] Events query failed:', err);
956
+ return jsonResponse({ error: 'Events query failed' }, 500);
957
+ }
958
+ }
959
+
960
+ private queryEventsList(
961
+ sql: SqlStorage, startTs: number, endTs: number,
962
+ event: string, userId: string, limit: number, cursor: string,
963
+ ): Response {
964
+ let where = `WHERE timestamp >= ? AND timestamp < ?`;
965
+ const params: unknown[] = [startTs, endTs];
966
+
967
+ if (event) {
968
+ where += ` AND eventName = ?`;
969
+ params.push(event);
970
+ }
971
+ if (userId) {
972
+ where += ` AND userId = ?`;
973
+ params.push(userId);
974
+ }
975
+ if (cursor) {
976
+ where += ` AND id < ?`;
977
+ params.push(parseInt(cursor, 10));
978
+ }
979
+
980
+ const rows = sql.exec(
981
+ `SELECT id, timestamp, userId, eventName, properties FROM _events ${where} ORDER BY id DESC LIMIT ?`,
982
+ ...params, limit + 1,
983
+ ).toArray();
984
+
985
+ const hasMore = rows.length > limit;
986
+ const items = rows.slice(0, limit);
987
+
988
+ return jsonResponse({
989
+ events: items.map(r => ({
990
+ id: r.id,
991
+ timestamp: r.timestamp,
992
+ userId: r.userId,
993
+ eventName: r.eventName,
994
+ properties: r.properties ? JSON.parse(r.properties as string) : null,
995
+ })),
996
+ cursor: hasMore && items.length > 0 ? String(items[items.length - 1].id) : undefined,
997
+ hasMore,
998
+ });
999
+ }
1000
+
1001
+ private queryEventsCount(
1002
+ sql: SqlStorage, startTs: number, endTs: number,
1003
+ event: string, userId: string,
1004
+ ): Response {
1005
+ let where = `WHERE timestamp >= ? AND timestamp < ?`;
1006
+ const params: unknown[] = [startTs, endTs];
1007
+
1008
+ if (event) {
1009
+ where += ` AND eventName = ?`;
1010
+ params.push(event);
1011
+ }
1012
+ if (userId) {
1013
+ where += ` AND userId = ?`;
1014
+ params.push(userId);
1015
+ }
1016
+
1017
+ const rows = sql.exec(
1018
+ `SELECT COUNT(*) as totalEvents, COUNT(DISTINCT userId) as uniqueUsers FROM _events ${where}`,
1019
+ ...params,
1020
+ ).toArray();
1021
+
1022
+ const row = rows[0] || { totalEvents: 0, uniqueUsers: 0 };
1023
+ return jsonResponse({
1024
+ totalEvents: Number(row.totalEvents) || 0,
1025
+ uniqueUsers: Number(row.uniqueUsers) || 0,
1026
+ });
1027
+ }
1028
+
1029
+ private queryEventsTimeSeries(
1030
+ sql: SqlStorage, startTs: number, endTs: number,
1031
+ event: string, userId: string, groupBy: string, useDaily: boolean,
1032
+ ): Response {
1033
+ const bucketMs = this.groupByToMs(groupBy);
1034
+
1035
+ if (useDaily) {
1036
+ // Use _events_daily for ranges > 90d
1037
+ let where = `WHERE day_ts >= ? AND day_ts < ?`;
1038
+ const params: unknown[] = [startTs, endTs];
1039
+ if (event) {
1040
+ where += ` AND eventName = ?`;
1041
+ params.push(event);
1042
+ }
1043
+
1044
+ const rows = sql.exec(`
1045
+ SELECT
1046
+ (CAST(day_ts / ? AS INTEGER) * ?) as ts,
1047
+ SUM(event_count) as count
1048
+ FROM _events_daily
1049
+ ${where}
1050
+ GROUP BY ts
1051
+ ORDER BY ts
1052
+ `, bucketMs, bucketMs, ...params).toArray();
1053
+
1054
+ return jsonResponse({
1055
+ timeSeries: rows.map(r => ({ timestamp: r.ts, count: Number(r.count) || 0 })),
1056
+ });
1057
+ }
1058
+
1059
+ // Use _events for ranges ≤ 90d
1060
+ let where = `WHERE timestamp >= ? AND timestamp < ?`;
1061
+ const params: unknown[] = [startTs, endTs];
1062
+ if (event) {
1063
+ where += ` AND eventName = ?`;
1064
+ params.push(event);
1065
+ }
1066
+ if (userId) {
1067
+ where += ` AND userId = ?`;
1068
+ params.push(userId);
1069
+ }
1070
+
1071
+ const rows = sql.exec(`
1072
+ SELECT
1073
+ (CAST(timestamp / ? AS INTEGER) * ?) as ts,
1074
+ COUNT(*) as count
1075
+ FROM _events
1076
+ ${where}
1077
+ GROUP BY ts
1078
+ ORDER BY ts
1079
+ `, bucketMs, bucketMs, ...params).toArray();
1080
+
1081
+ return jsonResponse({
1082
+ timeSeries: rows.map(r => ({ timestamp: r.ts, count: Number(r.count) || 0 })),
1083
+ });
1084
+ }
1085
+
1086
+ private queryEventsTop(
1087
+ sql: SqlStorage, startTs: number, endTs: number,
1088
+ userId: string, limit: number, useDaily: boolean,
1089
+ ): Response {
1090
+ if (useDaily) {
1091
+ const where = `WHERE day_ts >= ? AND day_ts < ?`;
1092
+ const params: unknown[] = [startTs, endTs];
1093
+
1094
+ const rows = sql.exec(`
1095
+ SELECT
1096
+ eventName,
1097
+ SUM(event_count) as count,
1098
+ SUM(unique_users) as uniqueUsers
1099
+ FROM _events_daily
1100
+ ${where}
1101
+ GROUP BY eventName
1102
+ ORDER BY count DESC
1103
+ LIMIT ?
1104
+ `, ...params, limit).toArray();
1105
+
1106
+ return jsonResponse({ topEvents: rows });
1107
+ }
1108
+
1109
+ let where = `WHERE timestamp >= ? AND timestamp < ?`;
1110
+ const params: unknown[] = [startTs, endTs];
1111
+ if (userId) {
1112
+ where += ` AND userId = ?`;
1113
+ params.push(userId);
1114
+ }
1115
+
1116
+ const rows = sql.exec(`
1117
+ SELECT
1118
+ eventName,
1119
+ COUNT(*) as count,
1120
+ COUNT(DISTINCT userId) as uniqueUsers
1121
+ FROM _events
1122
+ ${where}
1123
+ GROUP BY eventName
1124
+ ORDER BY count DESC
1125
+ LIMIT ?
1126
+ `, ...params, limit).toArray();
1127
+
1128
+ return jsonResponse({ topEvents: rows });
1129
+ }
1130
+
1131
+ // ─── Utility ───
1132
+
1133
+ private parseTimeRange(range: string, start?: string | null, end?: string | null): { startTs: number; endTs: number } {
1134
+ if (start && end) {
1135
+ const startTs = new Date(start).getTime();
1136
+ const endTs = new Date(end).getTime();
1137
+ if (Number.isFinite(startTs) && Number.isFinite(endTs) && endTs >= startTs) {
1138
+ return { startTs, endTs };
1139
+ }
1140
+ }
1141
+
1142
+ const now = Date.now();
1143
+ const endTs = now;
1144
+ let startTs: number;
1145
+
1146
+ switch (range) {
1147
+ case '1h': startTs = now - 3600_000; break;
1148
+ case '6h': startTs = now - 6 * 3600_000; break;
1149
+ case '24h': startTs = now - 86400_000; break;
1150
+ case '7d': startTs = now - 7 * 86400_000; break;
1151
+ case '30d': startTs = now - 30 * 86400_000; break;
1152
+ case '90d': startTs = now - 90 * 86400_000; break;
1153
+ default: startTs = now - 86400_000; break;
1154
+ }
1155
+
1156
+ return { startTs, endTs };
1157
+ }
1158
+
1159
+ /**
1160
+ * Select the appropriate table based on time range:
1161
+ * ≤24h → _logs_raw (exact data)
1162
+ * ≤90d → _logs_hourly (aggregated)
1163
+ * >90d → _logs_daily (long-term)
1164
+ */
1165
+ private selectTable(range: string): string {
1166
+ switch (range) {
1167
+ case '1h':
1168
+ case '6h':
1169
+ case '24h': return '_logs_raw';
1170
+ case '7d':
1171
+ case '30d':
1172
+ case '90d': return '_logs_hourly';
1173
+ default: return '_logs_daily';
1174
+ }
1175
+ }
1176
+
1177
+ private groupByToMs(groupBy: string): number {
1178
+ switch (groupBy) {
1179
+ case 'minute': return 60_000;
1180
+ case 'tenMinute': return 600_000;
1181
+ case 'hour': return 3600_000;
1182
+ case 'day': return 86400_000;
1183
+ default: return 3600_000;
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ // ─── Helpers ───
1189
+
1190
+ function jsonResponse(data: unknown, status = 200): Response {
1191
+ return new Response(JSON.stringify(data), {
1192
+ status,
1193
+ headers: { 'Content-Type': 'application/json' },
1194
+ });
1195
+ }
1196
+
1197
+ /** Basic SQL string escaping to prevent injection in category/subcategory filters */
1198
+ function escapeSql(str: string): string {
1199
+ return str.replace(/'/g, "''");
1200
+ }