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,391 @@
1
+ ---
2
+ title: SQL projections
3
+ description: Keep tables in your own database from A2's logs with a consumer, so lists, filters, and joins are SQL queries.
4
+ ---
5
+
6
+ ## The tables
7
+
8
+ ```ts db/schema.ts
9
+ import { bigint, pgTable, primaryKey, text } from 'drizzle-orm/pg-core'
10
+
11
+ /** The order list: one row per order. */
12
+ export const orderList = pgTable('order_list', {
13
+ orderId: text('order_id').primaryKey(),
14
+ shopId: text('shop_id').notNull(),
15
+ status: text('status').notNull(),
16
+ })
17
+
18
+ /** The last event each consumer applied from each session's log. */
19
+ export const positions = pgTable(
20
+ 'positions',
21
+ {
22
+ consumer: text('consumer').notNull(),
23
+ sessionId: text('session_id').notNull(),
24
+ logIndex: bigint('log_index', { mode: 'number' }).notNull(),
25
+ },
26
+ (table) => [primaryKey({ columns: [table.consumer, table.sessionId] })],
27
+ )
28
+ ```
29
+
30
+ ```ts db/client.ts
31
+ import { drizzle } from 'drizzle-orm/node-postgres'
32
+ import * as schema from '@/db/schema'
33
+
34
+ export const db = drizzle({
35
+ connection: { connectionString: process.env.DATABASE_URL },
36
+ schema,
37
+ })
38
+ ```
39
+
40
+ A2 keeps each session's log. When people list, filter, and join across
41
+ sessions, read tables in your database instead, kept from the logs by a
42
+ [consumer](/server/consumers): a projection. This page keeps an order list
43
+ with Drizzle on Postgres, as an example. The pattern is the same with any SQL
44
+ database and client.
45
+
46
+ The order list has a row per order. `positions` has a row per consumer and
47
+ session: the index of the last event that consumer applied to your tables.
48
+ Your migrations create both, like any table. A2's own tables can live in the
49
+ same database; see [Postgres](/stores/postgres#with-drizzle).
50
+
51
+ ## Why a position row
52
+
53
+ A2 keeps each consumer's cursor, its place in a session's log, in A2's store,
54
+ and moves it after `run` returns. It fences its own writes by attempt: an
55
+ append, a returned batch, or a cursor move from an attempt that lost its claim
56
+ is refused. Your SQL commits in your own transaction, which A2 can't fence or
57
+ make atomic with its cursor. Two things follow:
58
+
59
+ - **A crash between your commit and the cursor move retries the event.** Your
60
+ rows are in, the cursor isn't, and A2 runs the event again.
61
+ - **An attempt that lost its claim can still commit.** Its claim lapsed while
62
+ it ran, a newer attempt applied the event and the ones after it, and then
63
+ the old attempt commits an older event over newer rows.
64
+
65
+ The position row fences both. It commits in the same transaction as your rows,
66
+ and a transaction writes only when it moves the position forward, so each
67
+ event lands in your tables once, in log order.
68
+
69
+ ## Claim the event first
70
+
71
+ ```ts server/order-list.ts
72
+ import { eq, lt } from 'drizzle-orm'
73
+ import { consumer } from 'experimental-a2/server'
74
+ import { orders } from '@/contracts'
75
+ import { db } from '@/db/client'
76
+ import { orderList, positions } from '@/db/schema'
77
+
78
+ const name = 'order-list-v1'
79
+
80
+ export const orderListTable = consumer({
81
+ contract: orders,
82
+ name,
83
+ lane: 'order-list',
84
+ backfill: true,
85
+ }).run(async ({ event }) => {
86
+ await db.transaction(async (tx) => {
87
+ const claimed = await tx
88
+ .insert(positions)
89
+ .values({ consumer: name, sessionId: event.sessionId, logIndex: event.index })
90
+ .onConflictDoUpdate({
91
+ target: [positions.consumer, positions.sessionId],
92
+ set: { logIndex: event.index },
93
+ setWhere: lt(positions.logIndex, event.index),
94
+ })
95
+ .returning({ logIndex: positions.logIndex })
96
+ // this event, or a later one, is already in:
97
+ if (claimed.length === 0) return
98
+
99
+ if (event.type === 'created') {
100
+ await tx.insert(orderList).values({
101
+ orderId: event.sessionId,
102
+ shopId: event.payload.shopId,
103
+ status: 'created',
104
+ })
105
+ } else {
106
+ await tx
107
+ .update(orderList)
108
+ .set({ status: event.type })
109
+ .where(eq(orderList.orderId, event.sessionId))
110
+ }
111
+ })
112
+ })
113
+ ```
114
+
115
+ The transaction's first statement claims the event. It inserts the session's
116
+ position, or moves it to this event's index if it's behind, and returns the
117
+ row when it did. No row back means this event, or a later one, is already in
118
+ your tables: return, and the transaction changes nothing. Otherwise write your
119
+ rows. They commit with the position, or none of it does.
120
+
121
+ In plain SQL, it's the same statement on Postgres and SQLite:
122
+
123
+ ```sql
124
+ insert into positions (consumer, session_id, log_index)
125
+ values ($1, $2, $3)
126
+ on conflict (consumer, session_id) do update
127
+ set log_index = excluded.log_index
128
+ where positions.log_index < excluded.log_index
129
+ returning 1
130
+ ```
131
+
132
+ It comes first so a duplicate waits before it does any work. On Postgres, at
133
+ the default read committed level, two transactions that claim the same event
134
+ can't both get the row back: the second one's insert waits for the first to
135
+ commit, then checks the condition again against the committed position, finds
136
+ it caught up, and returns nothing. If the first rolls back, the second claims
137
+ the event. Reading the position first with `select ... for update` doesn't
138
+ cover a session's first event: there's no row to lock yet, so both
139
+ transactions read nothing and both apply it.
140
+
141
+ ## The consumer
142
+
143
+ ```ts server/orders.ts
144
+ import { createServer } from 'experimental-a2/server'
145
+ import { postgres } from 'experimental-a2/store-postgres'
146
+ import { orders } from '@/contracts'
147
+ import { orderListTable } from '@/server/order-list'
148
+
149
+ export const ordersServer = createServer({
150
+ contract: orders,
151
+ store: postgres({ connectionString: process.env.DATABASE_URL }),
152
+ consumers: [orderListTable],
153
+ })
154
+ ```
155
+
156
+ - **A string lane runs a session's events in order.** One at a time, in log
157
+ order, one transaction each. Sessions run independently.
158
+ - **`backfill` fills it from history.** It reads each session's log from its
159
+ first event, not only events appended after it exists. A session starts on
160
+ its next append, or when you [consume](#read-your-writes) it. Nothing
161
+ enumerates sessions, so to fill the table for orders that never change
162
+ again, consume each one.
163
+ - **A rebuild is a new name.** See [Rebuild it](#rebuild-it).
164
+
165
+ ## Read it
166
+
167
+ ```ts server/order-list.ts
168
+ export function shopOrders(shopId: string) {
169
+ return db.select().from(orderList).where(eq(orderList.shopId, shopId))
170
+ }
171
+ ```
172
+
173
+ Lists, filters, and joins are queries against your tables.
174
+
175
+ ## Read your writes
176
+
177
+ ```ts server/place-order.ts
178
+ import { orderListTable, shopOrders } from '@/server/order-list'
179
+ import { ordersServer } from '@/server/orders'
180
+
181
+ export async function placeOrder(orderId: string, shopId: string) {
182
+ const order = ordersServer.session(orderId)
183
+ const [created] = await order.append({
184
+ type: 'created',
185
+ payload: { shopId, items: [] },
186
+ })
187
+ await order.consume(orderListTable, { through: created.index })
188
+ // the new order is in the list:
189
+ return shopOrders(shopId)
190
+ }
191
+ ```
192
+
193
+ Your tables lag the log a little. Before a read that has to include a write
194
+ you just made, [consume](/server/consumers#run-a-consumer-now-with-consume)
195
+ the projection through the index the append returned. It returns once your
196
+ tables have that event.
197
+
198
+ A consumer that reads your tables waits the same way:
199
+
200
+ ```ts server/receipts.ts
201
+ import { consumer } from 'experimental-a2/server'
202
+ import { orders } from '@/contracts'
203
+ import { orderListTable } from '@/server/order-list'
204
+
205
+ export const receipts = consumer({
206
+ contract: orders,
207
+ name: 'receipts',
208
+ types: ['shop.notified'],
209
+ }).run(async ({ event, session }) => {
210
+ await session.consume(orderListTable, { through: event.index })
211
+ // the order list has this event; read it:
212
+ // const [order] = await db.select().from(orderList).where(eq(orderList.orderId, event.sessionId))
213
+ })
214
+ ```
215
+
216
+ Pass `event.index - 1` instead when the consumer applies the triggering event
217
+ itself and needs your tables only up to it. A consumer can't consume itself.
218
+
219
+ `consume` takes the session's lock in A2's store on every call, even when your
220
+ tables are caught up, and while another invocation holds the next event, it
221
+ polls until that one finishes. On a hot path, read your position row first,
222
+ and consume only when it's behind the index you need:
223
+
224
+ ```ts server/read-through.ts
225
+ import { and, eq } from 'drizzle-orm'
226
+ import { db } from '@/db/client'
227
+ import { positions } from '@/db/schema'
228
+ import { orderListTable } from '@/server/order-list'
229
+ import { ordersServer } from '@/server/orders'
230
+
231
+ export async function readThrough(orderId: string, index: number) {
232
+ const [position] = await db
233
+ .select({ logIndex: positions.logIndex })
234
+ .from(positions)
235
+ .where(
236
+ and(
237
+ eq(positions.consumer, orderListTable.name),
238
+ eq(positions.sessionId, orderId),
239
+ ),
240
+ )
241
+ if ((position?.logIndex ?? 0) < index) {
242
+ await ordersServer.session(orderId).consume(orderListTable, { through: index })
243
+ }
244
+ }
245
+ ```
246
+
247
+ ## Live views from the table
248
+
249
+ ```ts server/order-view.ts
250
+ import { and, eq } from 'drizzle-orm'
251
+ import { db } from '@/db/client'
252
+ import { orderList, positions } from '@/db/schema'
253
+ import { orderListTable } from '@/server/order-list'
254
+
255
+ /** One order's row, and the log index it reflects. */
256
+ export function orderView(orderId: string) {
257
+ return db.transaction(
258
+ async (tx) => {
259
+ const [position] = await tx
260
+ .select({ logIndex: positions.logIndex })
261
+ .from(positions)
262
+ .where(
263
+ and(
264
+ eq(positions.consumer, orderListTable.name),
265
+ eq(positions.sessionId, orderId),
266
+ ),
267
+ )
268
+ const [order] = await tx
269
+ .select()
270
+ .from(orderList)
271
+ .where(eq(orderList.orderId, orderId))
272
+ return { order, through: position?.logIndex ?? 0 }
273
+ },
274
+ { isolationLevel: 'repeatable read' },
275
+ )
276
+ }
277
+ ```
278
+
279
+ A view of one session can load its rows from your tables and stay live with
280
+ the session's [stream](/client/react). Read the rows and the position in one
281
+ snapshot, a repeatable read transaction on Postgres, and return them together.
282
+ The client follows the stream and applies only the events after `through`, so
283
+ it neither misses an event nor applies one twice.
284
+
285
+ ## SQLite
286
+
287
+ ```ts server/order-list-sqlite.ts
288
+ import { DatabaseSync } from 'node:sqlite'
289
+ import { consumer } from 'experimental-a2/server'
290
+ import { orders } from '@/contracts'
291
+
292
+ // your migrations create order_list and positions in this file:
293
+ const db = new DatabaseSync('.a2/app.db')
294
+ const name = 'order-list-v1'
295
+
296
+ const claim = db.prepare(`
297
+ insert into positions (consumer, session_id, log_index)
298
+ values (?, ?, ?)
299
+ on conflict (consumer, session_id) do update
300
+ set log_index = excluded.log_index
301
+ where positions.log_index < excluded.log_index
302
+ returning 1
303
+ `)
304
+ const insertOrder = db.prepare(
305
+ 'insert into order_list (order_id, shop_id, status) values (?, ?, ?)',
306
+ )
307
+ const setStatus = db.prepare(
308
+ 'update order_list set status = ? where order_id = ?',
309
+ )
310
+
311
+ export const orderListTable = consumer({
312
+ contract: orders,
313
+ name,
314
+ lane: 'order-list',
315
+ backfill: true,
316
+ }).run(async ({ event }) => {
317
+ // synchronous from begin to commit, with nothing awaited in between:
318
+ db.exec('begin immediate')
319
+ try {
320
+ if (claim.get(name, event.sessionId, event.index) !== undefined) {
321
+ if (event.type === 'created') {
322
+ insertOrder.run(event.sessionId, event.payload.shopId, 'created')
323
+ } else {
324
+ setStatus.run(event.type, event.sessionId)
325
+ }
326
+ }
327
+ db.exec('commit')
328
+ } catch (error) {
329
+ db.exec('rollback')
330
+ throw error
331
+ }
332
+ })
333
+ ```
334
+
335
+ The claim is the same statement. Run the transaction synchronously, from
336
+ `begin immediate` to `commit`, with nothing awaited in between:
337
+ `node:sqlite`'s `DatabaseSync` runs each statement on the calling thread, so
338
+ nothing else runs in the middle, and two of your own transactions never
339
+ interleave on one handle.
340
+
341
+ It's required when your tables share a file with A2's
342
+ [SQLite store](/stores/sqlite). A2 writes on the same thread, through its own
343
+ handle, and SQLite lets one writer hold a file at a time. If your transaction
344
+ awaited while it held the file, an A2 write to it would block the thread on
345
+ A2's five-second busy timeout, and your transaction couldn't reach `commit`
346
+ until that write gave up.
347
+
348
+ ## Throughput and lag
349
+
350
+ A string lane runs a session's events one at a time, one transaction each, so
351
+ a projection keeps up with a session as long as its transactions outpace that
352
+ session's appends. In the playground's a2slack drills, with Redis and Postgres
353
+ on the same machine, the feed projection, which keeps a whole workspace in one
354
+ session, ran an event in about 0.4 ms and rebuilt at about 1,800 events a
355
+ second. Posting 10,000 messages in under a second put it up to about six
356
+ seconds behind. A remote database adds at least a round trip to every event.
357
+
358
+ Watch it with each `a2.event` span's `a2.event.lag`, how many events the
359
+ session had appended after the one it claimed, and `a2.event.age_ms`, how old
360
+ that event was when its run started, grouped by `a2.event.consumer`. See
361
+ [Telemetry](/reference/telemetry#the-span-catalogue). When a session's appends
362
+ keep outrunning its projection, keep each transaction to the claim and your
363
+ writes, and do slower work, like calling an API, in a consumer of its own.
364
+
365
+ ## Rebuild it
366
+
367
+ To change what a projection writes, or to fill it again, give its consumer a
368
+ new name, such as `order-list-v2`. A new name is a new consumer: it has no
369
+ cursors in A2 and no rows in `positions`, and `backfill` fills it from each
370
+ session's first event, as each session next runs.
371
+
372
+ Point it at new tables, created by the migration that ships it, and switch
373
+ your reads to them once it has caught up. A deployment still running
374
+ `order-list-v1` keeps writing to the old ones until it's gone. To reuse the
375
+ old tables instead, truncate them, along with the old name's rows in
376
+ `positions`, in a migration that runs once no deployment runs the old name:
377
+ an earlier deployment that still does keeps writing to tables you emptied.
378
+
379
+ ## Point in time
380
+
381
+ The order list holds each order as of its latest event. To read one as of an
382
+ earlier index, such as next to an old event, keep a versions table too: a row
383
+ per event, keyed by session and log index, written in the same transaction as
384
+ the rest. The row as of index N is the newest at or below it:
385
+
386
+ ```sql
387
+ select status from order_versions
388
+ where order_id = $1 and log_index <= $2
389
+ order by log_index desc
390
+ limit 1
391
+ ```
@@ -1,45 +1,49 @@
1
1
  ---
2
2
  title: Guarantees
3
- description: Appends are effectively-once, handlers are at-least-once, and retries keep event identity across crashes.
3
+ description: Appends are effectively-once, consumers are at-least-once, and retries keep event identity across crashes.
4
4
  ---
5
5
 
6
6
  ## In short
7
7
 
8
8
  - **Appends are effectively-once.** Event ids deduplicate writes. A batch commits
9
- before its handlers start; they may start before `append` resolves.
10
- - **Handlers are at-least-once.** They may run again after a crash, so
11
- [side effects use idempotency keys](/server/handlers#handlers-can-run-twice).
9
+ before its consumers start; they may start before `append` resolves.
10
+ - **Consumers are at-least-once.** They may run again after a crash, so
11
+ [side effects use idempotency keys](/server/consumers#consumers-can-run-twice).
12
12
  - **The log stays ordered.** Every session has one immutable append order.
13
- - **Handlers are concurrent by default.** Log order does not serialize them.
14
- Only events in the same lane run one at a time in log order.
13
+ - **Consumers are concurrent by default.** Log order does not serialize them.
14
+ Only events in the same lane of one consumer run one at a time in log order.
15
+ - **Every consumer keeps its own place.** One consumer's failures, retries, and
16
+ dead letters never hold up another.
15
17
 
16
18
  ## One event, many attempts
17
19
 
18
- An event is pending until A2 marks it processed. Every wakeup (an append, a
19
- scheduler watchdog, or `server.drain()`) claims the session's eligible events,
20
- and the log decides what remains. Reads never dispatch handlers.
20
+ A consumer keeps a durable cursor in every session: where it has gotten to,
21
+ and the event it runs next, its head. An event is pending for a consumer until
22
+ that consumer's cursor moves past it. Every wakeup (an append, a scheduler
23
+ watchdog, or `server.drain()`) claims the session's eligible heads, and the
24
+ cursors decide what remains. Reads never dispatch consumers.
21
25
 
22
26
  Each claim expires. If its holder dies, the claim lapses and the next wakeup
23
- claims the event again with a larger `ctx.attempt`. The retry gets the same
27
+ claims the head again with a larger `ctx.attempt`. The retry gets the same
24
28
  event id, index, type, and payload. A kill after the claim can use up an
25
- attempt before your code runs, and that attempt counts toward the event's
26
- [failure budget](#when-a-handler-keeps-failing). An older attempt can never
29
+ attempt before your code runs, and that attempt counts toward the head's
30
+ [failure budget](#when-a-consumer-keeps-failing). An older attempt can never
27
31
  overwrite a newer one's completion or failure.
28
32
 
29
33
  :::note[Recovery is not an event]
30
34
  A2 does not append `recovered` or `continued`. Recovery retries the same event.
31
- Your log contains only application facts.
35
+ Your log contains only application facts; cursors live beside it.
32
36
  :::
33
37
 
34
38
  ## Immediate and dependent events
35
39
 
36
- [`ctx.session.append(name, ...events)`](/server/handlers#append-what-should-happen-now)
40
+ [`ctx.session.append(name, ...events)`](/server/consumers#append-what-should-happen-now)
37
41
  commits immediately: its events stay in the log if the parent later crashes,
38
42
  and run concurrently with it unless both share a lane.
39
- [Returned events](/server/handlers#return-what-happens-after-success) commit
40
- with the parent's completion, in one transaction.
43
+ [Returned events](/server/consumers#return-what-happens-after-success) commit
44
+ with the cursor's advance past the parent, in one transaction.
41
45
 
42
- If the handler throws or dies before that transaction, neither the completion
46
+ If `run` throws or dies before that transaction, neither the completion
43
47
  nor the returned events exist, and the retry produces the same deterministic
44
48
  ids. If the transaction commits but its acknowledgement is lost, the retry gets
45
49
  the committed rows back. A partial or changed batch is rejected.
@@ -51,11 +55,11 @@ the external system.
51
55
 
52
56
  ## Lanes
53
57
 
54
- [Lanes](/server/handlers#lanes) are session-scoped serial groups. Only the
55
- lowest-index unfinished event in a lane is eligible, so a failure blocks later
56
- events in that lane, not the whole session. Unlaned events and different lanes
57
- run concurrently. A2 stores the lane at append, so a changed lane resolver
58
- affects only new events.
58
+ [Lanes](/server/consumers#lanes) are serial groups of one consumer in one
59
+ session. Only the lowest-index unfinished event in a lane is eligible, so a
60
+ failure blocks later events in that lane, not the whole session and not other
61
+ consumers. Unlaned events and different lanes run concurrently. A2 stores each
62
+ consumer's lane at append, so a changed lane resolver affects only new events.
59
63
 
60
64
  A lane controls A2 dispatch, not the outside world. A2 claims every eligible
61
65
  event, with no global concurrency limit or priority queue. Put capacity limits
@@ -64,55 +68,57 @@ runtime.
64
68
 
65
69
  ## Append and scheduling
66
70
 
67
- A top-level append commits before any wakeup. A2 then starts the handlers
68
- inline and, with a scheduler, arms a watchdog in parallel. Handlers never wait
69
- for the queue, and a handler may start before `append` returns. `append` waits
70
- up to two seconds for the watchdog send, never for handlers. A failed arm
71
- records `a2.append.armed = false` on the trace. Handler appends and returned
72
- events join the running drain and add no arm.
71
+ A top-level append commits before any wakeup. A2 then starts the consumers
72
+ inline and, with a scheduler, arms a watchdog in parallel. Consumers never wait
73
+ for the queue, and one may start before `append` returns. `append` waits up to
74
+ two seconds for the watchdog send, never for consumers. A failed arm records
75
+ `a2.append.armed = false` on the trace. Consumer appends and returned events
76
+ join the running drain and add no arm.
73
77
 
74
78
  [`append.dispatch`](/schedulers/overview#dispatch-in-a-fresh-invocation) skips
75
79
  the inline drain and hands the work to the scheduler instead.
76
80
 
77
- An event type without a handler settles in the append transaction. It starts
78
- no drain or arm unless older handled work in the session is still pending.
81
+ An event no consumer takes starts no work. It starts no drain or arm unless
82
+ older work in the session is still pending. A dead letter is not pending: it
83
+ waits for a redrive, which wakes the session itself.
79
84
 
80
85
  ## Causal trees are durable
81
86
 
82
- Every event a handler appends or returns stores a `cause`: the triggering
83
- event's index and dispatch attempt. Top-level appends store `null`. Children
84
- of one handler are siblings, nested handlers add levels, and a retried child
85
- keeps its original cause.
87
+ Every event a consumer appends or returns stores a `cause`: the consumer, its
88
+ lane, the triggering event's index, and the attempt. Top-level appends store
89
+ `null`. Children of one run are siblings, nested runs add levels, and a
90
+ retried child keeps its original cause.
86
91
 
87
92
  ## A chain that crosses the function timeout
88
93
 
89
- A function killed mid-handler restarts that handler from its entry point in a
90
- fresh invocation: same event, new attempt. Events already processed stay
91
- processed, so a chain of handlers can outlast one invocation, but each handler
94
+ A function killed mid-run restarts that consumer's `run` from its entry point
95
+ in a fresh invocation: same event, new attempt. Events a cursor already moved
96
+ past stay done, so a chain of events can outlast one invocation, but each run
92
97
  must fit in a fresh one: an attempt that runs out of time is a failed attempt,
93
- and a handler that never fits dead-letters after ten. Split longer work into
98
+ and a run that never fits dead-letters after ten. Split longer work into
94
99
  smaller events or increase the function duration. On Vercel, A2 caps the claim window
95
100
  at the function deadline when the platform exposes it, so a retry starts
96
101
  promptly after a timeout. Once the deadline passes, A2 claims nothing more in
97
102
  that invocation, so a timeout costs the event one attempt.
98
103
 
99
- ## When a handler keeps failing
104
+ ## When a consumer keeps failing
100
105
 
101
- A caught failure counts against that event, releases its claim, and leaves
102
- other eligible events running. A2 holds it back for a delay that starts at
106
+ A caught failure counts against that consumer's head, releases its claim, and
107
+ leaves other eligible heads running. A2 holds it back for a delay that starts at
103
108
  one second and doubles with each failed attempt up to 30 seconds, then retries
104
109
  it on the session's next wake. With a scheduler, that's a few seconds at first,
105
110
  then the delay itself. An attempt that ends before it finishes, because its
106
111
  process timed out, crashed, or lost its claim, counts too, once the next
107
- attempt takes its claim over. Ten failed attempts dead-letter the event: later
108
- events in its lane stay blocked, while unlaned events and other lanes continue.
112
+ attempt takes its claim over. Ten failed attempts dead-letter the event for
113
+ that consumer: later events in its lane stay blocked, while unlaned events,
114
+ other lanes, and other consumers continue.
109
115
  A crash costs every event it interrupts one attempt, so a session whose work
110
116
  keeps outrunning its function dead-letters: bound its fan-out, or put work that
111
117
  waits on the same resource in one lane. A `NonRetriableError`, or a returned
112
118
  batch the store refuses because it reuses an event id, dead-letters the event
113
119
  on that first attempt instead: a retry would fail the same way. The event
114
120
  waits until you
115
- [retry or skip it](/server/handlers#resolve-a-dead-letter) with
121
+ [retry or skip it](/server/consumers#resolve-a-dead-letter) with
116
122
  `session.redrive()`.
117
123
 
118
124
  ## Limits
@@ -123,13 +129,14 @@ waits until you
123
129
  | No scheduler configured | A later top-level append or explicit `drain()` wakes the session. Reads never do. |
124
130
  | Scheduler invocation dies after claiming but before arming | Its unacknowledged queue delivery is the slower fallback. |
125
131
  | Store backend unavailable | Safe progress stops until it returns. |
126
- | External effect succeeds before A2 completion | The handler may repeat it. Use idempotency or external fencing. |
132
+ | External effect succeeds before A2 completion | The consumer may repeat it. Use idempotency or external fencing. |
127
133
 
128
134
  ## What append never throws for
129
135
 
130
- A failing handler never makes `append` throw. Handler failures are retried and,
131
- if persistent, [dead-letter the event](#when-a-handler-keeps-failing).
136
+ A failing consumer never makes `append` throw. Its failures are retried and,
137
+ if persistent, [dead-letter the event](#when-a-consumer-keeps-failing) for
138
+ that consumer.
132
139
 
133
- Append success means the event is durable, not that its handlers finished. An
140
+ Append success means the event is durable, not that its consumers finished. An
134
141
  identical batch retry returns rows with the same ids. A mixed batch throws
135
142
  [`PARTIAL_DUPLICATE_BATCH`](/reference/errors).
@@ -11,9 +11,9 @@ import { orders } from '@/contracts'
11
11
  export const ordersServer = createServer({
12
12
  contract: orders,
13
13
  store: redis({ url: process.env.REDIS_URL }),
14
- handlers: {
14
+ consumers: [
15
15
  /* ... */
16
- },
16
+ ],
17
17
  })
18
18
  ```
19
19
 
@@ -34,4 +34,4 @@ A store holds A2's session histories. It doesn't connect A2 to your
34
34
  application tables; see
35
35
  [Sessions and your database](/contract/sessions#sessions-and-your-database).
36
36
 
37
- Servers with handlers or timers also need a [scheduler](/schedulers/overview).
37
+ Servers with consumers or timers also need a [scheduler](/schedulers/overview).
@@ -11,9 +11,9 @@ import { orders } from '@/contracts'
11
11
  export const ordersServer = createServer({
12
12
  contract: orders,
13
13
  store: redis({ url: process.env.REDIS_URL }),
14
- handlers: {
14
+ consumers: [
15
15
  /* ... */
16
- },
16
+ ],
17
17
  })
18
18
  ```
19
19
 
@@ -34,6 +34,14 @@ set up: A2 creates its keys as it writes.
34
34
  | `keyPrefix` | isolates several apps on one Redis. Default `'a2'` |
35
35
  | `clock`, `ids` | test seams for timestamps and generated event ids |
36
36
 
37
+ ## Upgrade
38
+
39
+ The store records its key layout in `<keyPrefix>:schema`, and refuses a prefix
40
+ marked with another one with `STORE_NOT_CONFIGURED`. A release that changes
41
+ the layout says what to do on its [upgrade page](/upgrades/overview).
42
+ [0.21](/upgrades/0-21) reads what earlier releases wrote under the same
43
+ prefix.
44
+
37
45
  ## How it works
38
46
 
39
47
  - Each session is a Redis Stream.
@@ -45,5 +53,5 @@ set up: A2 creates its keys as it writes.
45
53
  ## Cost per command
46
54
 
47
55
  On providers that bill per command, such as Upstash, appending an event with a
48
- handler and running it takes 24 commands, and an idle live stream takes 6 a
56
+ consumer and running it takes 19 commands, and an idle live stream takes 6 a
49
57
  minute.
@@ -15,9 +15,9 @@ if (!url || !token) throw new Error('set the Upstash REST URL and token')
15
15
  export const ordersServer = createServer({
16
16
  contract: orders,
17
17
  store: redisHttp({ url, token }),
18
- handlers: {
18
+ consumers: [
19
19
  /* ... */
20
- },
20
+ ],
21
21
  })
22
22
  ```
23
23