experimental-a2 0.20.0 → 0.21.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 (286) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-HnGmrOZZ.d.ts} +5 -5
  4. package/dist/{actor-C7b-1arn.d.ts.map → actor-HnGmrOZZ.d.ts.map} +1 -1
  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-t7w8xIKP.js.map → actor-shared-DFET5q16.js.map} +1 -1
  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-CvEnqVfo.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-CvEnqVfo.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-CzE672Q3.js} +9 -9
  17. package/dist/ai-CzE672Q3.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 +1 -1
  21. package/dist/ai-fx.js +3 -6
  22. package/dist/ai-fx.js.map +1 -1
  23. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-Bfk-Lycn.d.ts} +2 -2
  24. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-Bfk-Lycn.d.ts.map} +1 -1
  25. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  26. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  27. package/dist/ai-pi.d.ts +1 -1
  28. package/dist/ai-pi.js +3 -3
  29. package/dist/ai-pi.js.map +1 -1
  30. package/dist/ai-react.d.ts +5 -5
  31. package/dist/ai-react.js +2 -2
  32. package/dist/ai-server.d.ts +14 -17
  33. package/dist/ai-server.d.ts.map +1 -1
  34. package/dist/ai-server.js +80 -73
  35. package/dist/ai-server.js.map +1 -1
  36. package/dist/ai.d.ts +2 -2
  37. package/dist/ai.js +2 -2
  38. package/dist/{client-BhHLoNKF.js → client-9IeTsiAs.js} +5 -5
  39. package/dist/{client-BhHLoNKF.js.map → client-9IeTsiAs.js.map} +1 -1
  40. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  41. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  42. package/dist/client.d.ts +3 -3
  43. package/dist/client.js +1 -1
  44. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  45. package/dist/contract-CWukt01d.js.map +1 -0
  46. package/dist/index.d.ts +5 -5
  47. package/dist/index.js +3 -3
  48. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  49. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  50. package/dist/{internal-CscSY7K_.js → internal-Bc4PvfHk.js} +10 -3
  51. package/dist/internal-Bc4PvfHk.js.map +1 -0
  52. package/dist/otel.d.ts +1 -1
  53. package/dist/otel.js +1 -1
  54. package/dist/otel.js.map +1 -1
  55. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  56. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  57. package/dist/react.d.ts +1 -1
  58. package/dist/react.js +1 -1
  59. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  60. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  61. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  62. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  63. package/dist/runtime-consumer-BWmht60z.js +12 -0
  64. package/dist/runtime-consumer-BWmht60z.js.map +1 -0
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +2 -2
  69. package/dist/scheduler-vercel.js +3 -3
  70. package/dist/scheduler-vercel.js.map +1 -1
  71. package/dist/{server-DYAATFuT.d.ts → server-BshFZ0eL.d.ts} +108 -41
  72. package/dist/server-BshFZ0eL.d.ts.map +1 -0
  73. package/dist/{server-qyb5aJJG.js → server-DvIB8kj5.js} +387 -113
  74. package/dist/server-DvIB8kj5.js.map +1 -0
  75. package/dist/server.browser.js +1 -1
  76. package/dist/server.browser.js.map +1 -1
  77. package/dist/server.d.ts +4 -4
  78. package/dist/server.js +2 -2
  79. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-Bk_29eEw.d.ts} +2 -2
  80. package/dist/session-clear-Bk_29eEw.d.ts.map +1 -0
  81. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  82. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  83. package/dist/store-N8PXxDAS.js.map +1 -1
  84. package/dist/store-Z2qjw50n.d.ts +523 -0
  85. package/dist/store-Z2qjw50n.d.ts.map +1 -0
  86. package/dist/store-codec-DwrWlgQx.js +65 -0
  87. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  88. package/dist/store-memory.d.ts +1 -1
  89. package/dist/store-memory.d.ts.map +1 -1
  90. package/dist/store-memory.js +393 -210
  91. package/dist/store-memory.js.map +1 -1
  92. package/dist/{store-polling-7WJV0YWP.js → store-polling-DYNyxCev.js} +2 -2
  93. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-DYNyxCev.js.map} +1 -1
  94. package/dist/store-postgres.d.ts +1 -1
  95. package/dist/store-postgres.d.ts.map +1 -1
  96. package/dist/store-postgres.js +1586 -566
  97. package/dist/store-postgres.js.map +1 -1
  98. package/dist/{store-presence-polling-B1ixeqIS.js → store-presence-polling-CzQ0wdtM.js} +3 -3
  99. package/dist/{store-presence-polling-B1ixeqIS.js.map → store-presence-polling-CzQ0wdtM.js.map} +1 -1
  100. package/dist/store-redis-http.d.ts +1 -1
  101. package/dist/store-redis-http.js +4 -4
  102. package/dist/store-redis-notify-IiVdlYHc.js +2919 -0
  103. package/dist/store-redis-notify-IiVdlYHc.js.map +1 -0
  104. package/dist/store-redis.d.ts +1 -1
  105. package/dist/store-redis.js +2 -2
  106. package/dist/store-sqlite.d.ts +1 -1
  107. package/dist/store-sqlite.d.ts.map +1 -1
  108. package/dist/store-sqlite.js +517 -287
  109. package/dist/store-sqlite.js.map +1 -1
  110. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  111. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  112. package/dist/{wire-BKxH1vbP.js → wire-CZGqNZRx.js} +5 -4
  113. package/dist/wire-CZGqNZRx.js.map +1 -0
  114. package/docs/01-quickstart.mdx +21 -15
  115. package/docs/02-examples.mdx +1 -1
  116. package/docs/actors/01-introduction.mdx +8 -0
  117. package/docs/actors/02-concurrency.mdx +1 -1
  118. package/docs/agents/01-introduction.mdx +1 -1
  119. package/docs/agents/02-tools.mdx +12 -11
  120. package/docs/agents/03-conversations.mdx +30 -20
  121. package/docs/agents/04-harnesses.mdx +7 -3
  122. package/docs/client/03-presence.mdx +18 -14
  123. package/docs/contract/01-events.mdx +2 -2
  124. package/docs/contract/02-state.mdx +18 -14
  125. package/docs/contract/03-sessions.mdx +32 -25
  126. package/docs/index.mdx +29 -24
  127. package/docs/reference/01-core.mdx +4 -4
  128. package/docs/reference/02-server.mdx +245 -190
  129. package/docs/reference/04-ai.mdx +20 -19
  130. package/docs/reference/05-telemetry.mdx +27 -13
  131. package/docs/reference/06-errors.mdx +24 -18
  132. package/docs/reference/meta.ts +1 -1
  133. package/docs/schedulers/01-overview.mdx +8 -8
  134. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  135. package/docs/schedulers/03-qstash.mdx +4 -4
  136. package/docs/server/01-consumers.mdx +415 -0
  137. package/docs/server/02-timers.mdx +31 -21
  138. package/docs/server/03-cancellation.mdx +52 -44
  139. package/docs/server/04-routes.mdx +4 -1
  140. package/docs/server/05-sql-projections.mdx +391 -0
  141. package/docs/server/06-guarantees.mdx +55 -48
  142. package/docs/stores/01-overview.mdx +3 -3
  143. package/docs/stores/02-redis.mdx +11 -3
  144. package/docs/stores/03-redis-http.mdx +2 -2
  145. package/docs/stores/04-postgres.mdx +65 -14
  146. package/docs/stores/05-sqlite.mdx +5 -3
  147. package/docs/upgrades/01-overview.mdx +30 -0
  148. package/docs/upgrades/79-0-21.mdx +165 -0
  149. package/docs/upgrades/80-0-20.mdx +43 -0
  150. package/docs/upgrades/81-0-19.mdx +33 -0
  151. package/docs/upgrades/82-0-18.mdx +33 -0
  152. package/docs/upgrades/83-0-17.mdx +69 -0
  153. package/docs/upgrades/meta.ts +3 -0
  154. package/examples/playground/DEPLOY.md +39 -7
  155. package/examples/playground/app/a0/chat.test.tsx +2 -2
  156. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  157. package/examples/playground/app/a0/server.ts +4 -4
  158. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  159. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  160. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  161. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  162. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  163. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  164. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  165. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  166. package/examples/playground/app/a2slack/db/a2.ts +206 -0
  167. package/examples/playground/app/a2slack/db/index.ts +73 -0
  168. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  169. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  170. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  171. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  172. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  173. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  174. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  175. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  176. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +34 -0
  177. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  178. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  179. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  180. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  181. package/examples/playground/app/a2slack/events/route.ts +36 -0
  182. package/examples/playground/app/a2slack/identity.ts +44 -0
  183. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  184. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  185. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  186. package/examples/playground/app/a2slack/lag.ts +73 -0
  187. package/examples/playground/app/a2slack/layout.tsx +43 -0
  188. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  189. package/examples/playground/app/a2slack/model.test.ts +208 -0
  190. package/examples/playground/app/a2slack/model.ts +369 -0
  191. package/examples/playground/app/a2slack/models.test.ts +72 -0
  192. package/examples/playground/app/a2slack/page.tsx +10 -0
  193. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  194. package/examples/playground/app/a2slack/search/route.ts +40 -0
  195. package/examples/playground/app/a2slack/search-index.ts +108 -0
  196. package/examples/playground/app/a2slack/server.test.ts +315 -0
  197. package/examples/playground/app/a2slack/server.ts +55 -0
  198. package/examples/playground/app/a2slack/sessions.ts +50 -0
  199. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  200. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  201. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  202. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  203. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  204. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  205. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  206. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  207. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  208. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  209. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  210. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  211. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  212. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  213. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  214. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  215. package/examples/playground/app/agent/server.ts +3 -3
  216. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  217. package/examples/playground/app/chat/server.ts +45 -35
  218. package/examples/playground/app/components/demo-icons.tsx +7 -0
  219. package/examples/playground/app/components/demos.tsx +7 -0
  220. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  221. package/examples/playground/app/orders/page.tsx +1 -1
  222. package/examples/playground/app/orders/server.ts +49 -30
  223. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  224. package/examples/playground/app/recovery/page.tsx +4 -4
  225. package/examples/playground/app/recovery/server.ts +23 -32
  226. package/examples/playground/drizzle.config.ts +14 -0
  227. package/examples/playground/next.config.ts +6 -1
  228. package/examples/playground/package.json +12 -1
  229. package/examples/playground/scripts/migrate.mjs +31 -0
  230. package/examples/playground/vercel.json +9 -0
  231. package/examples/playground/vitest.drills.config.ts +32 -0
  232. package/package.json +5 -2
  233. package/sql/postgres/drizzle-schema.ts +206 -0
  234. package/sql/postgres/schema.sql +206 -67
  235. package/src/actor.ts +101 -71
  236. package/src/ai-agent.ts +149 -111
  237. package/src/ai-control-server.ts +8 -8
  238. package/src/ai-fx.ts +4 -6
  239. package/src/ai-harness.ts +10 -2
  240. package/src/ai-pi.ts +2 -2
  241. package/src/ai-server.ts +2 -2
  242. package/src/ai.ts +7 -7
  243. package/src/contract.ts +1 -1
  244. package/src/deterministic-id.ts +26 -3
  245. package/src/errors.ts +8 -5
  246. package/src/idempotent-replay.ts +0 -1
  247. package/src/index.ts +1 -1
  248. package/src/internal.ts +15 -5
  249. package/src/otel.ts +1 -1
  250. package/src/postgres-schema.ts +98 -57
  251. package/src/push-envelope.ts +20 -0
  252. package/src/runtime-consumer.ts +10 -0
  253. package/src/scheduler-task.ts +2 -2
  254. package/src/scheduler-vercel.ts +1 -1
  255. package/src/server.browser.ts +1 -1
  256. package/src/server.ts +774 -207
  257. package/src/session-socket.ts +1 -1
  258. package/src/store-codec.ts +66 -0
  259. package/src/store-memory.ts +581 -255
  260. package/src/store-postgres.ts +1984 -825
  261. package/src/store-redis-core.ts +1624 -1152
  262. package/src/store-redis-notify.ts +2 -0
  263. package/src/store-sqlite.ts +891 -486
  264. package/src/store.ts +227 -97
  265. package/src/telemetry.ts +2 -1
  266. package/src/wire.ts +1 -0
  267. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  268. package/dist/ai-mMiWul1T.js.map +0 -1
  269. package/dist/contract-BYKASlCV.js.map +0 -1
  270. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  271. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  272. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  273. package/dist/internal-CscSY7K_.js.map +0 -1
  274. package/dist/server-DYAATFuT.d.ts.map +0 -1
  275. package/dist/server-qyb5aJJG.js.map +0 -1
  276. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  277. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  278. package/dist/store-CyLUy86v.d.ts +0 -404
  279. package/dist/store-CyLUy86v.d.ts.map +0 -1
  280. package/dist/store-codec-DTG0Ftek.js +0 -10
  281. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  282. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  283. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  284. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  285. package/dist/wire-BKxH1vbP.js.map +0 -1
  286. package/docs/server/01-handlers.mdx +0 -227
@@ -0,0 +1,34 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1792155179052,
9
+ "tag": "0000_extensions",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1792155179654,
16
+ "tag": "0001_workspace",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1792182002919,
23
+ "tag": "0002_a2",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1792282388330,
30
+ "tag": "0003_a2_batch",
31
+ "breakpoints": true
32
+ }
33
+ ]
34
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Every read a2slack makes. Each one runs in a single repeatable-read
3
+ * transaction with the feed consumer's position, so the rows it returns are
4
+ * exactly the log through `through`: the client applies the events after it
5
+ * (model.ts) and misses nothing, twice or not at all.
6
+ */
7
+ import 'server-only'
8
+ import {
9
+ and,
10
+ asc,
11
+ count,
12
+ desc,
13
+ eq,
14
+ gt,
15
+ ilike,
16
+ inArray,
17
+ isNull,
18
+ lt,
19
+ max,
20
+ sql,
21
+ } from 'drizzle-orm'
22
+ import type {
23
+ ChatMessage,
24
+ InboxPage,
25
+ MessagePage,
26
+ Reason,
27
+ SearchResult,
28
+ ThreadPage,
29
+ } from '../model'
30
+ import { database, type Db } from '.'
31
+ import {
32
+ channels,
33
+ messages,
34
+ notifications,
35
+ positions,
36
+ reactions,
37
+ reads,
38
+ } from './schema'
39
+
40
+ /** The feed consumer's name, which its position is recorded under. */
41
+ export const FEED = 'a2slack-feed'
42
+
43
+ export const PAGE_SIZE = 50
44
+
45
+ type Tx = Parameters<Parameters<Db['transaction']>[0]>[0]
46
+
47
+ const snapshot = <T>(read: (tx: Tx) => Promise<T>): Promise<T> =>
48
+ database().then((db) =>
49
+ db.transaction(read, { isolationLevel: 'repeatable read' }),
50
+ )
51
+
52
+ /** How far `consumer` has read the log: the last event it applied, or 0. */
53
+ export async function position(db: Db, consumer: string): Promise<number> {
54
+ const [row] = await db
55
+ .select({ logIndex: positions.logIndex })
56
+ .from(positions)
57
+ .where(eq(positions.consumer, consumer))
58
+ return row?.logIndex ?? 0
59
+ }
60
+
61
+ /** The channel list, as of `through`: `channelsReducer`'s state. */
62
+ export const workspaceSnapshot = (): Promise<{
63
+ channels: string[]
64
+ opened: Record<string, number>
65
+ through: number
66
+ }> =>
67
+ snapshot(async (tx) => {
68
+ const through = await position(tx, FEED)
69
+ const rows = await tx
70
+ .select({ id: channels.id, logIndex: channels.logIndex })
71
+ .from(channels)
72
+ .orderBy(asc(channels.logIndex))
73
+ return {
74
+ channels: rows.map((row) => row.id),
75
+ opened: Object.fromEntries(rows.map((row) => [row.id, row.logIndex])),
76
+ through,
77
+ }
78
+ })
79
+
80
+ type MessageRow = typeof messages.$inferSelect
81
+
82
+ /** Rows as the client renders them: reactions, reply counts, and labels in place. */
83
+ async function hydrate(tx: Tx, rows: MessageRow[]): Promise<ChatMessage[]> {
84
+ if (rows.length === 0) return []
85
+ const ids = rows.map((row) => row.id)
86
+ const [reacted, replied] = await Promise.all([
87
+ tx
88
+ .select()
89
+ .from(reactions)
90
+ .where(inArray(reactions.messageId, ids))
91
+ .orderBy(asc(reactions.logIndex)),
92
+ tx
93
+ .select({
94
+ threadId: messages.threadId,
95
+ replies: count(),
96
+ last: max(messages.postedAt),
97
+ })
98
+ .from(messages)
99
+ .where(inArray(messages.threadId, ids))
100
+ .groupBy(messages.threadId),
101
+ ])
102
+ return rows.map((row) => {
103
+ const message: ChatMessage = {
104
+ id: row.id,
105
+ index: row.logIndex,
106
+ channel: row.channel,
107
+ author: { id: row.authorId, name: row.authorName },
108
+ text: row.text,
109
+ at: row.postedAt.toISOString(),
110
+ reactions: [],
111
+ replyCount: 0,
112
+ }
113
+ if (row.editedAt) message.editedAt = row.editedAt.toISOString()
114
+ if (row.deleted) message.deleted = true
115
+ if (row.threadId !== null) message.threadId = row.threadId
116
+ if (row.label !== null) message.label = row.label as ChatMessage['label']
117
+ if (!row.deleted) {
118
+ for (const reaction of reacted) {
119
+ if (reaction.messageId !== row.id) continue
120
+ const by = { id: reaction.authorId, name: reaction.authorName }
121
+ const existing = message.reactions.find(
122
+ (r) => r.emoji === reaction.emoji,
123
+ )
124
+ if (existing) existing.by.push(by)
125
+ else message.reactions.push({ emoji: reaction.emoji, by: [by] })
126
+ }
127
+ }
128
+ const thread = replied.find((reply) => reply.threadId === row.id)
129
+ if (thread) {
130
+ message.replyCount = thread.replies
131
+ if (thread.last) message.lastReplyAt = new Date(thread.last).toISOString()
132
+ }
133
+ return message
134
+ })
135
+ }
136
+
137
+ /** A channel's newest top-level messages, or those before log index `before`. */
138
+ export const messagePage = (
139
+ channel: string,
140
+ { before }: { before?: number } = {},
141
+ ): Promise<MessagePage> =>
142
+ snapshot(async (tx) => {
143
+ const through = await position(tx, FEED)
144
+ const rows = await tx
145
+ .select()
146
+ .from(messages)
147
+ .where(
148
+ and(
149
+ eq(messages.channel, channel),
150
+ isNull(messages.threadId),
151
+ before === undefined ? undefined : lt(messages.logIndex, before),
152
+ ),
153
+ )
154
+ .orderBy(desc(messages.logIndex))
155
+ .limit(PAGE_SIZE + 1)
156
+ const page = rows.slice(0, PAGE_SIZE).toReversed()
157
+ return {
158
+ messages: await hydrate(tx, page),
159
+ through,
160
+ more: rows.length > PAGE_SIZE,
161
+ }
162
+ })
163
+
164
+ /** A thread's root and every reply. */
165
+ export const threadPage = (rootId: string): Promise<ThreadPage> =>
166
+ snapshot(async (tx) => {
167
+ const through = await position(tx, FEED)
168
+ const [root] = await tx
169
+ .select()
170
+ .from(messages)
171
+ .where(eq(messages.id, rootId))
172
+ const replies = await tx
173
+ .select()
174
+ .from(messages)
175
+ .where(eq(messages.threadId, rootId))
176
+ .orderBy(asc(messages.logIndex))
177
+ const [hydratedRoot] = root ? await hydrate(tx, [root]) : [null]
178
+ return {
179
+ root: hydratedRoot ?? null,
180
+ replies: await hydrate(tx, replies),
181
+ through,
182
+ }
183
+ })
184
+
185
+ /** Someone's inbox: each thread's latest notification and its unread count. */
186
+ export const inboxPage = (userId: string): Promise<InboxPage> =>
187
+ snapshot(async (tx) => {
188
+ const through = await position(tx, FEED)
189
+ const [latest, unread] = await Promise.all([
190
+ tx
191
+ .selectDistinctOn([notifications.threadId])
192
+ .from(notifications)
193
+ .where(eq(notifications.userId, userId))
194
+ .orderBy(notifications.threadId, desc(notifications.logIndex)),
195
+ tx
196
+ .select({ threadId: notifications.threadId, unread: count() })
197
+ .from(notifications)
198
+ .leftJoin(
199
+ reads,
200
+ and(
201
+ eq(reads.userId, notifications.userId),
202
+ eq(reads.threadId, notifications.threadId),
203
+ ),
204
+ )
205
+ .where(
206
+ and(
207
+ eq(notifications.userId, userId),
208
+ gt(notifications.logIndex, sql`coalesce(${reads.through}, 0)`),
209
+ ),
210
+ )
211
+ .groupBy(notifications.threadId),
212
+ ])
213
+ const items = latest
214
+ .toSorted((a, b) => b.logIndex - a.logIndex)
215
+ .slice(0, PAGE_SIZE)
216
+ .map((row) => ({
217
+ threadId: row.threadId,
218
+ channel: row.channel,
219
+ reason: row.reason as Reason,
220
+ from: { id: row.fromId, name: row.fromName },
221
+ text: row.text,
222
+ messageId: row.messageId,
223
+ at: row.sentAt.toISOString(),
224
+ index: row.logIndex,
225
+ unread:
226
+ unread.find((thread) => thread.threadId === row.threadId)?.unread ??
227
+ 0,
228
+ }))
229
+ return { items, through }
230
+ })
231
+
232
+ /** Escapes LIKE's wildcards, so a query matches its own characters. */
233
+ const literal = (text: string): string => text.replaceAll(/[\\%_]/g, '\\$&')
234
+
235
+ /**
236
+ * The newest messages, across every channel, whose text contains `q`: search
237
+ * where Redis Search isn't configured.
238
+ */
239
+ export async function searchFeed(
240
+ q: string,
241
+ limit = PAGE_SIZE,
242
+ ): Promise<SearchResult[]> {
243
+ const db = await database()
244
+ const rows = await db
245
+ .select()
246
+ .from(messages)
247
+ .where(
248
+ and(eq(messages.deleted, false), ilike(messages.text, `%${literal(q)}%`)),
249
+ )
250
+ .orderBy(desc(messages.logIndex))
251
+ .limit(limit)
252
+ return rows.map((row) => {
253
+ const result: SearchResult = {
254
+ channel: row.channel,
255
+ id: row.id,
256
+ author: { id: row.authorId, name: row.authorName },
257
+ text: row.text,
258
+ at: row.postedAt.toISOString(),
259
+ }
260
+ if (row.threadId !== null) result.threadId = row.threadId
261
+ return result
262
+ })
263
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * a2slack's tables. A2's log is the source of truth; these hold what the app
3
+ * reads, built from the log by consumers (consumers/). Change them here, then
4
+ * `pnpm db:generate` writes the migration into ./migrations.
5
+ */
6
+ import {
7
+ bigint,
8
+ boolean,
9
+ index,
10
+ pgTable,
11
+ primaryKey,
12
+ text,
13
+ timestamp,
14
+ } from 'drizzle-orm/pg-core'
15
+
16
+ const logIndex = () => bigint({ mode: 'number' }).notNull()
17
+ const at = () => timestamp({ withTimezone: true }).notNull()
18
+
19
+ /** How far each consumer that keeps tables here has read the log. */
20
+ export const positions = pgTable('a2slack_positions', {
21
+ consumer: text().primaryKey(),
22
+ logIndex: logIndex(),
23
+ })
24
+
25
+ // ── The feed consumer's tables: everything a page shows. ──────────────
26
+
27
+ export const channels = pgTable('a2slack_channels', {
28
+ id: text().primaryKey(),
29
+ createdByName: text().notNull(),
30
+ createdAt: at(),
31
+ logIndex: logIndex(),
32
+ })
33
+
34
+ export const messages = pgTable(
35
+ 'a2slack_messages',
36
+ {
37
+ id: text().primaryKey(),
38
+ channel: text().notNull(),
39
+ /** The message's position in the workspace's A2 log. */
40
+ logIndex: logIndex(),
41
+ threadId: text(),
42
+ authorId: text().notNull(),
43
+ authorName: text().notNull(),
44
+ text: text().notNull(),
45
+ postedAt: at(),
46
+ editedAt: timestamp({ withTimezone: true }),
47
+ deleted: boolean().notNull().default(false),
48
+ label: text(),
49
+ },
50
+ (table) => [
51
+ // A channel's top-level messages, and a thread's replies, in log order.
52
+ index('a2slack_messages_feed').on(
53
+ table.channel,
54
+ table.threadId,
55
+ table.logIndex,
56
+ ),
57
+ index('a2slack_messages_thread').on(table.threadId, table.logIndex),
58
+ // Substring search (`ilike '%…%'`) without a scan, where Redis Search
59
+ // isn't configured.
60
+ index('a2slack_messages_text_trgm').using(
61
+ 'gin',
62
+ table.text.op('gin_trgm_ops'),
63
+ ),
64
+ ],
65
+ )
66
+
67
+ export const reactions = pgTable(
68
+ 'a2slack_reactions',
69
+ {
70
+ messageId: text().notNull(),
71
+ emoji: text().notNull(),
72
+ authorId: text().notNull(),
73
+ authorName: text().notNull(),
74
+ logIndex: logIndex(),
75
+ },
76
+ (table) => [
77
+ primaryKey({ columns: [table.messageId, table.emoji, table.authorId] }),
78
+ ],
79
+ )
80
+
81
+ /** Every notification, one row each; the inbox groups them by thread. */
82
+ export const notifications = pgTable(
83
+ 'a2slack_notifications',
84
+ {
85
+ id: text().primaryKey(),
86
+ userId: text().notNull(),
87
+ threadId: text().notNull(),
88
+ channel: text().notNull(),
89
+ reason: text().notNull(),
90
+ messageId: text().notNull(),
91
+ fromId: text().notNull(),
92
+ fromName: text().notNull(),
93
+ text: text().notNull(),
94
+ logIndex: logIndex(),
95
+ sentAt: at(),
96
+ },
97
+ (table) => [
98
+ index('a2slack_notifications_inbox').on(
99
+ table.userId,
100
+ table.threadId,
101
+ table.logIndex,
102
+ ),
103
+ ],
104
+ )
105
+
106
+ /** How far someone has read each thread's notifications. */
107
+ export const reads = pgTable(
108
+ 'a2slack_reads',
109
+ {
110
+ userId: text().notNull(),
111
+ threadId: text().notNull(),
112
+ through: logIndex(),
113
+ },
114
+ (table) => [primaryKey({ columns: [table.userId, table.threadId] })],
115
+ )
116
+
117
+ // ── The notify consumer's tables: who to tell. ────────────────────────
118
+
119
+ /** Everyone who has posted, by name, for resolving `@name`. */
120
+ export const people = pgTable(
121
+ 'a2slack_people',
122
+ {
123
+ id: text().primaryKey(),
124
+ name: text().notNull(),
125
+ /** The first message they posted. */
126
+ logIndex: logIndex(),
127
+ },
128
+ (table) => [index('a2slack_people_name').on(table.name)],
129
+ )
130
+
131
+ /** Who is in a thread: whoever posted in it or was named in it. */
132
+ export const threadMembers = pgTable(
133
+ 'a2slack_thread_members',
134
+ {
135
+ threadId: text().notNull(),
136
+ personId: text().notNull(),
137
+ /** When they joined it. */
138
+ logIndex: logIndex(),
139
+ },
140
+ (table) => [primaryKey({ columns: [table.threadId, table.personId] })],
141
+ )
142
+
143
+ // A2's own tables, so the same migrations create them: a2.ts is a copy of
144
+ // experimental-a2's sql/postgres/drizzle-schema.ts.
145
+ export * from './a2'