experimental-a2 0.20.0 → 0.22.0

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/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. package/docs/server/01-handlers.mdx +0 -227
@@ -0,0 +1,708 @@
1
+ /**
2
+ * Drills for a2slack's consumers and projections, against a throwaway Redis
3
+ * and Postgres: a load run, a rebuild, a poisoned event, a Postgres outage,
4
+ * two versions of a consumer at once, and the load run again with A2 on
5
+ * Postgres. Each prints what it measured, and the whole run is written to
6
+ * .a2/drills/. Sizes come from DRILL_MESSAGES, DRILL_CONCURRENCY,
7
+ * DRILL_SUBSCRIBERS, and DRILL_SERVERS.
8
+ *
9
+ * pnpm --filter playground drills
10
+ */
11
+ // oxlint-disable no-await-in-loop, no-console -- drills run steps in order and report as they go
12
+ import { mkdirSync, writeFileSync } from 'node:fs'
13
+ import { sql } from 'drizzle-orm'
14
+ import type { A2Store, Consumer } from 'experimental-a2/server'
15
+ import { afterAll, beforeAll, expect, it } from 'vitest'
16
+ import {
17
+ idle,
18
+ recorder,
19
+ sleep,
20
+ startServices,
21
+ summarize,
22
+ timerScheduler,
23
+ until,
24
+ type Run,
25
+ type Services,
26
+ } from './harness'
27
+
28
+ const MESSAGES = Number(process.env.DRILL_MESSAGES ?? 10_000)
29
+ const CONCURRENCY = Number(process.env.DRILL_CONCURRENCY ?? 20)
30
+ const SUBSCRIBERS = Number(process.env.DRILL_SUBSCRIBERS ?? 20)
31
+ const SERVERS = Number(process.env.DRILL_SERVERS ?? 4)
32
+
33
+ const CHANNELS = Array.from({ length: 20 }, (_, i) => `room-${i + 1}`)
34
+ const USERS = Array.from({ length: 50 }, (_, i) => ({
35
+ id: `user-${i + 1}`,
36
+ name: `user${i + 1}`,
37
+ }))
38
+ const WORDS =
39
+ 'deploy review ship merge plan draft launch bug fix test build release ' +
40
+ 'sync cache queue index search thread channel message reply mention ' +
41
+ 'design schema table cursor lane log event store redis postgres drizzle'
42
+ const words = WORDS.split(' ')
43
+
44
+ const pick = <T>(items: readonly T[]): T =>
45
+ items[Math.floor(Math.random() * items.length)]!
46
+
47
+ const text = (): string =>
48
+ Array.from({ length: 4 + Math.floor(Math.random() * 8) }, () =>
49
+ pick(words),
50
+ ).join(' ')
51
+
52
+ let services: Services
53
+ const results: Record<string, unknown> = {}
54
+
55
+ // Loaded once DATABASE_URL points at the drill's Postgres.
56
+ let a2slack: Awaited<ReturnType<typeof load>>
57
+
58
+ async function load() {
59
+ const [
60
+ { redis },
61
+ { postgres },
62
+ { consumer, createServer },
63
+ { drizzle },
64
+ { migrate },
65
+ model,
66
+ feedModule,
67
+ { notify },
68
+ { welcome },
69
+ queries,
70
+ { database },
71
+ ] = await Promise.all([
72
+ import('experimental-a2/store-redis'),
73
+ import('experimental-a2/store-postgres'),
74
+ import('experimental-a2/server'),
75
+ import('drizzle-orm/node-postgres'),
76
+ import('drizzle-orm/node-postgres/migrator'),
77
+ import('../model'),
78
+ import('../consumers/feed'),
79
+ import('../consumers/notify'),
80
+ import('../consumers/welcome'),
81
+ import('../db/queries'),
82
+ import('../db'),
83
+ ])
84
+ return {
85
+ redis,
86
+ postgres,
87
+ consumer,
88
+ createServer,
89
+ drizzle,
90
+ migrate,
91
+ model,
92
+ feed: feedModule.feed,
93
+ feedEvent: feedModule.feedEvent,
94
+ notify,
95
+ welcome,
96
+ queries,
97
+ database,
98
+ }
99
+ }
100
+
101
+ type Workspace = (typeof a2slack)['model']['workspace']
102
+
103
+ const servers = (
104
+ store: A2Store,
105
+ consumers: Consumer<Workspace['events']>[],
106
+ telemetry: ReturnType<typeof recorder>['telemetry'],
107
+ many = SERVERS,
108
+ ) =>
109
+ Array.from({ length: many }, () => {
110
+ const { scheduler, deliverTo } = timerScheduler()
111
+ const server = a2slack.createServer({
112
+ contract: a2slack.model.workspace,
113
+ store,
114
+ scheduler,
115
+ consumers,
116
+ telemetry,
117
+ })
118
+ deliverTo(server)
119
+ return server
120
+ })
121
+
122
+ type WorkspaceServer = ReturnType<typeof servers>[number]
123
+
124
+ const session = (server: WorkspaceServer) =>
125
+ server.session(a2slack.model.WORKSPACE)
126
+
127
+ /** Every table a2slack keeps, emptied, for a run that starts a new log. */
128
+ async function emptyTables(): Promise<void> {
129
+ const db = await a2slack.database()
130
+ await db.execute(
131
+ sql.raw(
132
+ 'truncate a2slack_channels, a2slack_messages, a2slack_notifications, a2slack_reactions, a2slack_reads, a2slack_positions, a2slack_people, a2slack_thread_members',
133
+ ),
134
+ )
135
+ }
136
+
137
+ /** The rows of a raw query; the app's database type leaves them untyped. */
138
+ async function rows<T>(query: string): Promise<T[]> {
139
+ const db = await a2slack.database()
140
+ return ((await db.execute(sql.raw(query))) as unknown as { rows: T[] }).rows
141
+ }
142
+
143
+ async function count(table: string): Promise<number> {
144
+ const [row] = await rows<{ n: number }>(
145
+ `select count(*)::int as n from ${table}`,
146
+ )
147
+ return row!.n
148
+ }
149
+
150
+ /** Counts in the log of what the tables should hold. */
151
+ async function logCounts(server: WorkspaceServer) {
152
+ const events = await session(server).history()
153
+ const reactions = new Set<string>()
154
+ for (const event of events) {
155
+ if (event.type === 'reaction.added')
156
+ reactions.add(
157
+ `${event.payload.messageId}|${event.payload.emoji}|${event.payload.by.id}`,
158
+ )
159
+ if (event.type === 'reaction.removed')
160
+ reactions.delete(
161
+ `${event.payload.messageId}|${event.payload.emoji}|${event.payload.by.id}`,
162
+ )
163
+ }
164
+ return {
165
+ head: events.at(-1)?.index ?? 0,
166
+ messages: events.filter((event) => event.type === 'message.posted').length,
167
+ notifications: events.filter((event) => event.type === 'notification.sent')
168
+ .length,
169
+ channels: new Set(
170
+ events.flatMap((event) =>
171
+ event.type === 'channel.created' ? [event.payload.id] : [],
172
+ ),
173
+ ).size,
174
+ reactions: reactions.size,
175
+ }
176
+ }
177
+
178
+ async function tableCounts() {
179
+ return {
180
+ messages: await count('a2slack_messages'),
181
+ notifications: await count('a2slack_notifications'),
182
+ channels: await count('a2slack_channels'),
183
+ reactions: await count('a2slack_reactions'),
184
+ }
185
+ }
186
+
187
+ /** Live subscribers to the workspace: how long each event took to reach them. */
188
+ function subscribe(
189
+ from: WorkspaceServer[],
190
+ subscribers: number,
191
+ startAfter: number,
192
+ ) {
193
+ const delays: number[] = []
194
+ const last = Array.from({ length: subscribers }, () => 0)
195
+ const streams = last.map((_, s) => {
196
+ const stream = session(from[s % from.length]!).stream({ startAfter })
197
+ return stream[Symbol.asyncIterator]()
198
+ })
199
+ const done = Promise.all(
200
+ streams.map(async (stream, s) => {
201
+ for (;;) {
202
+ const next = await stream.next()
203
+ if (next.done) return
204
+ const event = next.value
205
+ if (event.type === 'a2.cleared') continue
206
+ if (event.type === 'message.posted')
207
+ delays.push(Date.now() - event.createdAt.getTime())
208
+ last[s] = event.index
209
+ }
210
+ }),
211
+ )
212
+ return {
213
+ delays,
214
+ last,
215
+ /** Ends every subscription, even one waiting for its next event. */
216
+ async close() {
217
+ await Promise.all(streams.map((stream) => stream.return?.()))
218
+ await done
219
+ },
220
+ }
221
+ }
222
+
223
+ /** Posts `messages` messages and reactions from `concurrency` writers. */
224
+ async function post(
225
+ to: WorkspaceServer[],
226
+ messages: number,
227
+ concurrency: number,
228
+ {
229
+ onSent,
230
+ prefix = '',
231
+ }: { onSent?: (n: number) => Promise<void> | void; prefix?: string } = {},
232
+ ) {
233
+ const latencies: number[] = []
234
+ const roots = new Map<string, string[]>()
235
+ const recent: { id: string; channel: string }[] = []
236
+ let next = 0
237
+ const started = performance.now()
238
+ await Promise.all(
239
+ Array.from({ length: concurrency }, async () => {
240
+ for (;;) {
241
+ const n = next
242
+ next += 1
243
+ if (n >= messages) return
244
+ const target = session(to[n % to.length]!)
245
+ const author = pick(USERS)
246
+ const channel = pick(CHANNELS)
247
+ const t0 = performance.now()
248
+ if (Math.random() < 0.05 && recent.length > 0) {
249
+ const message = pick(recent)
250
+ await target.append({
251
+ type: 'reaction.added',
252
+ payload: {
253
+ channel: message.channel,
254
+ messageId: message.id,
255
+ emoji: pick(['👍', '🎉', '👀']),
256
+ by: author,
257
+ },
258
+ })
259
+ } else {
260
+ const thread = roots.get(channel) ?? []
261
+ const threadId =
262
+ Math.random() < 0.15 && thread.length > 0 ? pick(thread) : undefined
263
+ const mention = Math.random() < 0.1 ? ` @${pick(USERS).name}` : ''
264
+ const [posted] = await target.append({
265
+ type: 'message.posted',
266
+ payload: {
267
+ channel,
268
+ author,
269
+ text: `${prefix}${text()}${mention}`,
270
+ ...(threadId ? { threadId } : {}),
271
+ },
272
+ })
273
+ if (!threadId) {
274
+ thread.push(posted!.id)
275
+ if (thread.length > 50) thread.shift()
276
+ roots.set(channel, thread)
277
+ }
278
+ recent.push({ id: posted!.id, channel })
279
+ if (recent.length > 200) recent.shift()
280
+ }
281
+ latencies.push(performance.now() - t0)
282
+ await onSent?.(n)
283
+ }
284
+ }),
285
+ )
286
+ const endedAt = performance.now()
287
+ return { latencies, seconds: (endedAt - started) / 1000, endedAt }
288
+ }
289
+
290
+ /** Completion lag per consumer: append to the end of its successful run. */
291
+ function lagByConsumer(runs: readonly Run[]) {
292
+ const by = new Map<string, number[]>()
293
+ const failures = new Map<string, number>()
294
+ for (const run of runs) {
295
+ if (run.failed) {
296
+ failures.set(run.consumer, (failures.get(run.consumer) ?? 0) + 1)
297
+ continue
298
+ }
299
+ const lags = by.get(run.consumer) ?? []
300
+ lags.push(run.ageMs + run.durationMs)
301
+ by.set(run.consumer, lags)
302
+ }
303
+ return Object.fromEntries(
304
+ [...by].map(([consumer, lags]) => [
305
+ consumer,
306
+ {
307
+ lagMs: summarize(lags),
308
+ runMs: summarize(
309
+ runs
310
+ .filter((run) => run.consumer === consumer && !run.failed)
311
+ .map((run) => run.durationMs),
312
+ ),
313
+ failures: failures.get(consumer) ?? 0,
314
+ },
315
+ ]),
316
+ )
317
+ }
318
+
319
+ async function timed(times: number, fn: () => Promise<unknown>) {
320
+ const durations: number[] = []
321
+ for (let i = 0; i < times; i += 1) {
322
+ const t0 = performance.now()
323
+ await fn()
324
+ durations.push(performance.now() - t0)
325
+ }
326
+ return summarize(durations)
327
+ }
328
+
329
+ /** A full load run on `store`: post, follow, catch up, check, and read. */
330
+ async function loadRun(store: A2Store, label: string) {
331
+ const { telemetry, runs } = recorder()
332
+ const to = servers(
333
+ store,
334
+ [a2slack.welcome, a2slack.feed, a2slack.notify],
335
+ telemetry,
336
+ )
337
+ for (const channel of CHANNELS)
338
+ await session(to[0]!).append({
339
+ type: 'channel.created',
340
+ payload: { id: channel, by: pick(USERS) },
341
+ })
342
+ await until(
343
+ () => idle(store, ['welcome', 'a2slack-feed', 'a2slack-notify']),
344
+ {
345
+ timeoutMs: 60_000,
346
+ },
347
+ )
348
+ const before = await logCounts(to[0]!)
349
+ const followers = subscribe(to, SUBSCRIBERS, before.head)
350
+
351
+ const posted = await post(to, MESSAGES, CONCURRENCY)
352
+ const catchUpMs = await until(
353
+ () => idle(store, ['welcome', 'a2slack-feed', 'a2slack-notify']),
354
+ { timeoutMs: 10 * 60_000, everyMs: 100 },
355
+ )
356
+ const log = await logCounts(to[0]!)
357
+ const followMs = await until(
358
+ () => followers.last.every((index) => index >= log.head),
359
+ { timeoutMs: 60_000 },
360
+ )
361
+ await followers.close()
362
+ const tables = await tableCounts()
363
+ const db = await a2slack.database()
364
+ const feedPosition = await a2slack.queries.position(db, 'a2slack-feed')
365
+
366
+ const [root] = await rows<{ id: string }>(
367
+ "select id from a2slack_messages where thread_id is null and channel = 'room-1' order by log_index desc limit 1",
368
+ )
369
+ const anyRoot = root?.id
370
+ const reads = {
371
+ messagePage: await timed(10, () =>
372
+ a2slack.queries.messagePage(pick(CHANNELS)),
373
+ ),
374
+ threadPage: await timed(10, () =>
375
+ a2slack.queries.threadPage(anyRoot ?? ''),
376
+ ),
377
+ inboxPage: await timed(10, () => a2slack.queries.inboxPage(pick(USERS).id)),
378
+ search: await timed(10, () => a2slack.queries.searchFeed(pick(words))),
379
+ }
380
+
381
+ const result = {
382
+ messages: MESSAGES,
383
+ concurrency: CONCURRENCY,
384
+ subscribers: SUBSCRIBERS,
385
+ servers: SERVERS,
386
+ events: log.head,
387
+ appendsPerSecond: Math.round(MESSAGES / posted.seconds),
388
+ appendMs: summarize(posted.latencies),
389
+ catchUpAfterLastAppendMs: Math.round(catchUpMs),
390
+ totalSeconds: Math.round(posted.seconds + catchUpMs / 1000),
391
+ consumers: lagByConsumer(runs),
392
+ stream: {
393
+ deliveryMs: summarize(followers.delays),
394
+ allSubscribersAtHeadMs: Math.round(followMs),
395
+ },
396
+ exactlyOnce: {
397
+ log,
398
+ tables,
399
+ feedPosition,
400
+ ok:
401
+ tables.messages === log.messages &&
402
+ tables.notifications === log.notifications &&
403
+ tables.channels === log.channels &&
404
+ tables.reactions === log.reactions &&
405
+ feedPosition === log.head,
406
+ },
407
+ readsMs: reads,
408
+ }
409
+ results[label] = result
410
+ console.log(`\n${label}\n${JSON.stringify(result, null, 2)}`)
411
+ return { result, to, log, tables }
412
+ }
413
+
414
+ beforeAll(async () => {
415
+ services = await startServices()
416
+ process.env.DATABASE_URL = services.databaseUrl
417
+ a2slack = await load()
418
+ const db = a2slack.drizzle({ connection: services.databaseUrl })
419
+ await a2slack.migrate(db, {
420
+ migrationsFolder: new URL('../db/migrations', import.meta.url).pathname,
421
+ migrationsTable: 'a2slack_migrations',
422
+ migrationsSchema: 'public',
423
+ })
424
+ await db.$client.end()
425
+ })
426
+
427
+ afterAll(() => {
428
+ const dir = new URL('../../../.a2/drills/', import.meta.url).pathname
429
+ mkdirSync(dir, { recursive: true })
430
+ const file = `${dir}a2slack-${new Date().toISOString().replace(/[:.]/g, '-')}.json`
431
+ writeFileSync(file, JSON.stringify(results, null, 2))
432
+ console.log(`\nresults ${file}`)
433
+ services?.close()
434
+ })
435
+
436
+ let redisStore: A2Store
437
+
438
+ beforeAll(() => {
439
+ redisStore = a2slack.redis({ url: services.redisUrl, keyPrefix: 'drill' })
440
+ })
441
+
442
+ it('load: A2 on Redis, the tables in Postgres', async () => {
443
+ const { result } = await loadRun(redisStore, 'load (Redis)')
444
+ expect(result.exactlyOnce.ok).toBe(true)
445
+ })
446
+
447
+ it('rebuild: a renamed feed fills empty tables from the whole log', async () => {
448
+ const before = await tableCounts()
449
+ const db = await a2slack.database()
450
+ await db.execute(
451
+ sql.raw(
452
+ "truncate a2slack_channels, a2slack_messages, a2slack_notifications, a2slack_reactions, a2slack_reads; delete from a2slack_positions where consumer = 'a2slack-feed'",
453
+ ),
454
+ )
455
+ const { telemetry, runs } = recorder()
456
+ const feedV2 = a2slack
457
+ .consumer({
458
+ contract: a2slack.model.workspace,
459
+ name: 'a2slack-feed-v2',
460
+ lane: 'feed-v2',
461
+ backfill: true,
462
+ })
463
+ .run(async ({ event }) => {
464
+ await a2slack.feedEvent(await a2slack.database(), event)
465
+ })
466
+ const [server] = servers(redisStore, [feedV2], telemetry, 1)
467
+ const t0 = performance.now()
468
+ const consumed = await session(server!).consume(feedV2)
469
+ const ms = performance.now() - t0
470
+ const after = await tableCounts()
471
+ const result = {
472
+ events: consumed.index,
473
+ ms: Math.round(ms),
474
+ eventsPerSecond: Math.round(consumed.index / (ms / 1000)),
475
+ runMs: summarize(runs.map((run) => run.durationMs)),
476
+ before,
477
+ after,
478
+ ok: JSON.stringify(before) === JSON.stringify(after),
479
+ }
480
+ results.rebuild = result
481
+ console.log(`\nrebuild\n${JSON.stringify(result, null, 2)}`)
482
+ expect(result.ok).toBe(true)
483
+ })
484
+
485
+ it('poison: one bad event dead-letters its consumer alone, and redrive resumes it', async () => {
486
+ const db = await a2slack.database()
487
+ await db.execute(
488
+ sql.raw(
489
+ 'create table if not exists drill_seen (event_id text primary key)',
490
+ ),
491
+ )
492
+ let fixed = false
493
+ const seen = a2slack
494
+ .consumer({
495
+ contract: a2slack.model.workspace,
496
+ name: 'drill-seen',
497
+ types: ['message.posted'],
498
+ lane: 'seen',
499
+ })
500
+ .run(async ({ event }) => {
501
+ if (event.payload.text.includes('POISON') && !fixed)
502
+ throw new Error('poisoned')
503
+ await (
504
+ await a2slack.database()
505
+ ).execute(
506
+ sql`insert into drill_seen (event_id) values (${event.id}) on conflict do nothing`,
507
+ )
508
+ })
509
+ const { telemetry, runs } = recorder()
510
+ const to = servers(
511
+ redisStore,
512
+ [a2slack.feed, a2slack.notify, seen],
513
+ telemetry,
514
+ 2,
515
+ )
516
+ await session(to[0]!).consume(seen, { through: 0 })
517
+ // The consumer starts with the first event appended after it exists.
518
+ const { index: startedAt } = await session(to[0]!).state()
519
+ await post(to, 50, 5)
520
+ const [poison] = await session(to[0]!).append({
521
+ type: 'message.posted',
522
+ payload: { channel: 'room-1', author: USERS[0]!, text: 'POISON pill' },
523
+ })
524
+ await post(to, 50, 5)
525
+
526
+ const t0 = performance.now()
527
+ await until(() => idle(redisStore, ['a2slack-feed', 'a2slack-notify']), {
528
+ timeoutMs: 60_000,
529
+ })
530
+ const othersCaughtUpMs = performance.now() - t0
531
+ const stuck = (await redisStore.cursors('a2slack\u001fworkspace')).find(
532
+ (cursor) => cursor.consumer === 'drill-seen',
533
+ )
534
+ let probes = 0
535
+ const deadLetteredMs = await until(
536
+ async () => {
537
+ probes += 1
538
+ if (process.env.DRILL_DEBUG && probes % 10 === 0) {
539
+ const cursor = (
540
+ await redisStore.cursors('a2slack\u001fworkspace')
541
+ ).find((c) => c.consumer === 'drill-seen')
542
+ console.log(
543
+ 'drill-seen',
544
+ JSON.stringify({
545
+ position: cursor?.position,
546
+ head: cursor?.head,
547
+ attempts: cursor?.attemptCount,
548
+ failures: cursor?.failureCount,
549
+ holder: cursor?.claimHolder,
550
+ expires: cursor?.claimExpiresAt,
551
+ now: new Date(),
552
+ }),
553
+ )
554
+ }
555
+ return (await session(to[0]!).deadLetters()).some(
556
+ (letter) => letter.consumer === 'drill-seen',
557
+ )
558
+ },
559
+ { timeoutMs: 10 * 60_000, everyMs: 1000 },
560
+ )
561
+ const consumeError = await session(to[0]!)
562
+ .consume(seen)
563
+ .then(() => null)
564
+ .catch((error: { code?: string }) => error.code)
565
+
566
+ fixed = true
567
+ const redriven = await session(to[0]!).redrive(
568
+ { consumer: 'drill-seen', index: poison!.index },
569
+ { action: 'retry' },
570
+ )
571
+ await session(to[0]!).consume(seen)
572
+ const seenRows = await count('drill_seen')
573
+ // Every message posted once the consumer existed, the poison included.
574
+ const posted = (await session(to[0]!).history({ gte: startedAt + 1 })).filter(
575
+ (event) => event.type === 'message.posted',
576
+ ).length
577
+ const result = {
578
+ othersCaughtUpWhileStuckMs: Math.round(othersCaughtUpMs),
579
+ stuckAt: {
580
+ position: stuck?.position,
581
+ head: stuck?.head,
582
+ poison: poison!.index,
583
+ },
584
+ deadLetteredAfterMs: Math.round(deadLetteredMs + othersCaughtUpMs),
585
+ failedAttempts: runs.filter(
586
+ (run) => run.consumer === 'drill-seen' && run.failed,
587
+ ).length,
588
+ consumeWhileDeadLettered: consumeError,
589
+ redriven: redriven.redriven,
590
+ rowsAfterRedrive: seenRows,
591
+ messagesPosted: posted,
592
+ ok:
593
+ seenRows === posted &&
594
+ redriven.redriven &&
595
+ consumeError === 'CONSUMER_DEAD_LETTERED',
596
+ }
597
+ results.poison = result
598
+ console.log(`\npoison\n${JSON.stringify(result, null, 2)}`)
599
+ expect(result.ok).toBe(true)
600
+ })
601
+
602
+ it('outage: Postgres goes away mid-load, and every event still lands once', async () => {
603
+ const { telemetry, runs } = recorder()
604
+ const to = servers(
605
+ redisStore,
606
+ [a2slack.welcome, a2slack.feed, a2slack.notify],
607
+ telemetry,
608
+ )
609
+ let downAt = 0
610
+ let upAt = 0
611
+ const outage = (async () => {
612
+ await sleep(2_000)
613
+ services.stopPostgres()
614
+ downAt = performance.now()
615
+ await sleep(15_000)
616
+ services.startPostgres()
617
+ upAt = performance.now()
618
+ })()
619
+ // About 100 appends a second for 30s: they go on through the outage and
620
+ // after it, so drains keep starting.
621
+ const posted = await post(to, 3_000, 10, {
622
+ onSent: async () => {
623
+ await sleep(100)
624
+ },
625
+ })
626
+ await outage
627
+ await until(
628
+ () => idle(redisStore, ['welcome', 'a2slack-feed', 'a2slack-notify']),
629
+ { timeoutMs: 5 * 60_000, everyMs: 200 },
630
+ )
631
+ const idleAt = performance.now()
632
+ const log = await logCounts(to[0]!)
633
+ const tables = await tableCounts()
634
+ const db = await a2slack.database()
635
+ const feedPosition = await a2slack.queries.position(db, 'a2slack-feed')
636
+ const deadLetters = await session(to[0]!).deadLetters()
637
+ const result = {
638
+ appended: 3_000,
639
+ appendMs: summarize(posted.latencies),
640
+ postgresDownMs: Math.round(upAt - downAt),
641
+ appendsEndedAfterRestartMs: Math.round(posted.endedAt - upAt),
642
+ idleAfterRestartMs: Math.round(idleAt - upAt),
643
+ failedRuns: runs.filter((run) => run.failed).length,
644
+ deadLetters: deadLetters.length,
645
+ exactlyOnce: {
646
+ log,
647
+ tables,
648
+ feedPosition,
649
+ ok:
650
+ tables.messages === log.messages &&
651
+ tables.notifications === log.notifications &&
652
+ tables.reactions === log.reactions &&
653
+ feedPosition === log.head,
654
+ },
655
+ }
656
+ results.outage = result
657
+ console.log(`\noutage\n${JSON.stringify(result, null, 2)}`)
658
+ expect(result.exactlyOnce.ok).toBe(true)
659
+ expect(deadLetters).toEqual([])
660
+ })
661
+
662
+ it('versions: two deployments run different code under one consumer name', async () => {
663
+ const db = await a2slack.database()
664
+ await db.execute(
665
+ sql.raw(
666
+ 'create table if not exists drill_versions (event_id text primary key, version text not null)',
667
+ ),
668
+ )
669
+ const version = (tag: string) =>
670
+ a2slack
671
+ .consumer({
672
+ contract: a2slack.model.workspace,
673
+ name: 'drill-version',
674
+ types: ['message.posted'],
675
+ lane: 'version',
676
+ })
677
+ .run(async ({ event }) => {
678
+ await (
679
+ await a2slack.database()
680
+ ).execute(
681
+ sql`insert into drill_versions (event_id, version) values (${event.id}, ${tag}) on conflict do nothing`,
682
+ )
683
+ })
684
+ const { telemetry } = recorder()
685
+ const v1 = version('v1')
686
+ const [oldServer] = servers(redisStore, [v1], telemetry, 1)
687
+ const [newServer] = servers(redisStore, [version('v2')], telemetry, 1)
688
+ await session(oldServer!).consume(v1, { through: 0 })
689
+ await post([oldServer!, newServer!], 200, 10)
690
+ await until(() => idle(redisStore, ['drill-version']), { timeoutMs: 60_000 })
691
+ const handled = await rows<{ version: string; n: number }>(
692
+ 'select version, count(*)::int as n from drill_versions group by version order by version',
693
+ )
694
+ const result = {
695
+ events: 200,
696
+ handledBy: Object.fromEntries(handled.map((row) => [row.version, row.n])),
697
+ }
698
+ results.versions = result
699
+ console.log(`\nversions\n${JSON.stringify(result, null, 2)}`)
700
+ expect(handled.reduce((sum, row) => sum + row.n, 0)).toBeGreaterThan(0)
701
+ })
702
+
703
+ it('load: A2 on Postgres, beside its tables', async () => {
704
+ await emptyTables()
705
+ const store = a2slack.postgres({ connectionString: services.databaseUrl })
706
+ const { result } = await loadRun(store, 'load (Postgres)')
707
+ expect(result.exactlyOnce.ok).toBe(true)
708
+ })