experimental-a2 0.7.0 → 0.8.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 (150) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +27 -0
  3. package/README.md +29 -0
  4. package/dist/ai-server.d.ts +1 -1
  5. package/dist/ai-server.d.ts.map +1 -1
  6. package/dist/ai-server.js +13 -11
  7. package/dist/ai-server.js.map +1 -1
  8. package/dist/ai.d.ts +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/scheduler-qstash.d.ts +2 -2
  11. package/dist/scheduler-qstash.js +1 -1
  12. package/dist/scheduler-vercel.d.ts +2 -2
  13. package/dist/scheduler-vercel.js +1 -1
  14. package/dist/{server-286j79Mt.js → server-B2XNevQA.js} +123 -53
  15. package/dist/server-B2XNevQA.js.map +1 -0
  16. package/dist/{server-DgXmORIq.d.ts → server-DjPhHnbI.d.ts} +7 -4
  17. package/dist/server-DjPhHnbI.d.ts.map +1 -0
  18. package/dist/server.d.ts +3 -3
  19. package/dist/server.js +1 -1
  20. package/dist/store-N8PXxDAS.js.map +1 -1
  21. package/dist/{store-flRz1OWh.d.ts → store-RJO35BMj.d.ts} +25 -8
  22. package/dist/store-RJO35BMj.d.ts.map +1 -0
  23. package/dist/store-memory.d.ts +1 -1
  24. package/dist/store-memory.d.ts.map +1 -1
  25. package/dist/store-memory.js +79 -19
  26. package/dist/store-memory.js.map +1 -1
  27. package/dist/store-postgres.d.ts +1 -1
  28. package/dist/store-postgres.d.ts.map +1 -1
  29. package/dist/store-postgres.js +230 -101
  30. package/dist/store-postgres.js.map +1 -1
  31. package/dist/{store-redis-core-DEYO8Ryv.js → store-redis-core-DT01r4GZ.js} +167 -29
  32. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  33. package/dist/store-redis-http.d.ts +1 -1
  34. package/dist/store-redis-http.js +2 -2
  35. package/dist/store-redis-http.js.map +1 -1
  36. package/dist/store-redis.d.ts +1 -1
  37. package/dist/store-redis.js +2 -2
  38. package/dist/store-redis.js.map +1 -1
  39. package/dist/store-sqlite.d.ts +1 -1
  40. package/dist/store-sqlite.d.ts.map +1 -1
  41. package/dist/store-sqlite.js +103 -19
  42. package/dist/store-sqlite.js.map +1 -1
  43. package/docs/concepts/02-handlers.mdx +4 -0
  44. package/docs/concepts/04-state.mdx +57 -9
  45. package/docs/guides/06-ai-agents.mdx +2 -1
  46. package/docs/guides/07-examples.mdx +56 -0
  47. package/docs/index.mdx +13 -0
  48. package/docs/reference/01-api.mdx +39 -16
  49. package/examples/README.md +15 -0
  50. package/examples/playground/AGENTS.md +11 -0
  51. package/examples/playground/DEPLOY.md +106 -0
  52. package/examples/playground/README.md +19 -0
  53. package/examples/playground/activity-feed.test.ts +10 -0
  54. package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
  55. package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
  56. package/examples/playground/app/agent/events/route.ts +4 -0
  57. package/examples/playground/app/agent/model.ts +3 -0
  58. package/examples/playground/app/agent/new-agent-session.tsx +98 -0
  59. package/examples/playground/app/agent/page.tsx +25 -0
  60. package/examples/playground/app/agent/scheduler/route.ts +5 -0
  61. package/examples/playground/app/agent/server.ts +153 -0
  62. package/examples/playground/app/agent/session.ts +13 -0
  63. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
  64. package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
  65. package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
  66. package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
  67. package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
  68. package/examples/playground/app/canvas/events/route.ts +13 -0
  69. package/examples/playground/app/canvas/model.ts +94 -0
  70. package/examples/playground/app/canvas/open-canvas.tsx +40 -0
  71. package/examples/playground/app/canvas/page.tsx +20 -0
  72. package/examples/playground/app/canvas/server.ts +9 -0
  73. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
  74. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
  75. package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
  76. package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
  77. package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
  78. package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
  79. package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
  80. package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
  81. package/examples/playground/app/chat/[chatId]/session.ts +7 -0
  82. package/examples/playground/app/chat/events/route.ts +7 -0
  83. package/examples/playground/app/chat/model.test.ts +155 -0
  84. package/examples/playground/app/chat/model.ts +310 -0
  85. package/examples/playground/app/chat/new-conversation.tsx +16 -0
  86. package/examples/playground/app/chat/page.tsx +25 -0
  87. package/examples/playground/app/chat/scheduler/route.ts +5 -0
  88. package/examples/playground/app/chat/server.ts +184 -0
  89. package/examples/playground/app/components/activity-feed.tsx +54 -0
  90. package/examples/playground/app/components/connection-pill.tsx +29 -0
  91. package/examples/playground/app/counter/counter-client.tsx +72 -0
  92. package/examples/playground/app/counter/events/route.ts +4 -0
  93. package/examples/playground/app/counter/model.test.ts +36 -0
  94. package/examples/playground/app/counter/model.ts +31 -0
  95. package/examples/playground/app/counter/page.tsx +24 -0
  96. package/examples/playground/app/counter/server.ts +9 -0
  97. package/examples/playground/app/counter/session.ts +13 -0
  98. package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
  99. package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
  100. package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
  101. package/examples/playground/app/documents/[documentId]/session.ts +7 -0
  102. package/examples/playground/app/documents/events/route.ts +4 -0
  103. package/examples/playground/app/documents/model.ts +55 -0
  104. package/examples/playground/app/documents/open-document.tsx +40 -0
  105. package/examples/playground/app/documents/page.tsx +22 -0
  106. package/examples/playground/app/documents/server.ts +9 -0
  107. package/examples/playground/app/globals.css +2078 -0
  108. package/examples/playground/app/layout.tsx +44 -0
  109. package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
  110. package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
  111. package/examples/playground/app/orders/[orderId]/session.ts +11 -0
  112. package/examples/playground/app/orders/create/route.ts +30 -0
  113. package/examples/playground/app/orders/events/route.ts +4 -0
  114. package/examples/playground/app/orders/model.ts +79 -0
  115. package/examples/playground/app/orders/new-order-form.tsx +98 -0
  116. package/examples/playground/app/orders/page.tsx +22 -0
  117. package/examples/playground/app/orders/scheduler/route.ts +5 -0
  118. package/examples/playground/app/orders/server.ts +50 -0
  119. package/examples/playground/app/page.tsx +102 -0
  120. package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
  121. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
  122. package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
  123. package/examples/playground/app/recovery/events/route.ts +3 -0
  124. package/examples/playground/app/recovery/model.ts +55 -0
  125. package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
  126. package/examples/playground/app/recovery/page.tsx +22 -0
  127. package/examples/playground/app/recovery/scheduler/route.ts +7 -0
  128. package/examples/playground/app/recovery/server.ts +53 -0
  129. package/examples/playground/app/recovery/start/route.ts +41 -0
  130. package/examples/playground/css.d.ts +4 -0
  131. package/examples/playground/lib/store.ts +15 -0
  132. package/examples/playground/next-env.d.ts +5 -0
  133. package/examples/playground/next.config.ts +10 -0
  134. package/examples/playground/package.json +46 -0
  135. package/examples/playground/tsconfig.json +37 -0
  136. package/examples/playground/vercel.json +40 -0
  137. package/package.json +5 -2
  138. package/src/ai-server.ts +27 -10
  139. package/src/server.ts +242 -87
  140. package/src/store-memory.ts +138 -20
  141. package/src/store-postgres.ts +355 -138
  142. package/src/store-redis-core.ts +201 -27
  143. package/src/store-redis-http.ts +1 -1
  144. package/src/store-redis.ts +1 -1
  145. package/src/store-sqlite.ts +191 -34
  146. package/src/store.ts +27 -9
  147. package/dist/server-286j79Mt.js.map +0 -1
  148. package/dist/server-DgXmORIq.d.ts.map +0 -1
  149. package/dist/store-flRz1OWh.d.ts.map +0 -1
  150. package/dist/store-redis-core-DEYO8Ryv.js.map +0 -1
@@ -0,0 +1,44 @@
1
+ import type { Metadata } from 'next'
2
+ import type { ReactNode } from 'react'
3
+ import Link from 'next/link'
4
+ import './globals.css'
5
+
6
+ export const metadata: Metadata = {
7
+ title: 'a2 example',
8
+ description:
9
+ 'A durable event log for serverless apps — counters, agents, orders, streaming, cancellation, multiplayer documents, and a shared canvas.',
10
+ }
11
+
12
+ export default function RootLayout({
13
+ children,
14
+ }: {
15
+ children: ReactNode
16
+ }): ReactNode {
17
+ return (
18
+ <html lang="en">
19
+ <body>
20
+ <header className="site-header">
21
+ <nav>
22
+ <Link href="/" className="brand">
23
+ a2 <span>example</span>
24
+ </Link>
25
+ <Link href="/orders">Orders</Link>
26
+ <Link href="/chat">Chat</Link>
27
+ <Link href="/agent">Agent</Link>
28
+ <Link href="/canvas">Canvas</Link>
29
+ <Link href="/documents">Documents</Link>
30
+ <Link href="/recovery">Recovery</Link>
31
+ <Link href="/counter">Counter</Link>
32
+ </nav>
33
+ </header>
34
+ <main>{children}</main>
35
+ <footer className="site-footer">
36
+ <p>
37
+ Everything on these pages is a fold over an append-only log — check{' '}
38
+ <code>.a2/dev.db</code>.
39
+ </p>
40
+ </footer>
41
+ </body>
42
+ </html>
43
+ )
44
+ }
@@ -0,0 +1,140 @@
1
+ 'use client'
2
+ import type { ReactNode } from 'react'
3
+ import { useState } from 'react'
4
+ import type { OrderState } from '../model'
5
+ import { ActivityFeed } from '@/app/components/activity-feed'
6
+ import { ConnectionPill } from '@/app/components/connection-pill'
7
+ import { useSession } from './session'
8
+
9
+ const STATUS_LABEL: Record<OrderState['status'], string> = {
10
+ new: 'no events yet',
11
+ created: 'created',
12
+ notified: 'shop notified',
13
+ preparing: 'preparing',
14
+ 'courier-assigned': 'courier assigned',
15
+ delivering: 'out for delivery',
16
+ delivered: 'delivered',
17
+ }
18
+
19
+ /** The journey as steps, in log order — `status` maps to how far the
20
+ * fold has advanced through them. */
21
+ const JOURNEY: Array<{ status: OrderState['status']; label: string }> = [
22
+ { status: 'created', label: 'Created' },
23
+ { status: 'notified', label: 'Shop notified' },
24
+ { status: 'preparing', label: 'Preparing' },
25
+ { status: 'courier-assigned', label: 'Courier assigned' },
26
+ { status: 'delivering', label: 'Out for delivery' },
27
+ { status: 'delivered', label: 'Delivered' },
28
+ ]
29
+
30
+ export function OrderClient({ sessionId }: { sessionId: string }): ReactNode {
31
+ const { state, push, events, index, connection } = useSession()
32
+ const [error, setError] = useState<string | null>(null)
33
+
34
+ const reached = JOURNEY.findIndex((step) => step.status === state.status)
35
+ const delivered = state.status === 'delivered'
36
+ // Show the button only until the kitchen takes over; after that the
37
+ // journey finishes on its own.
38
+ const showStart =
39
+ state.status === 'new' ||
40
+ state.status === 'created' ||
41
+ state.status === 'notified'
42
+ const canStart = state.status === 'created' || state.status === 'notified'
43
+
44
+ const startPreparation = async (): Promise<void> => {
45
+ setError(null)
46
+ try {
47
+ await push({ type: 'shop.started', payload: {} })
48
+ } catch (err) {
49
+ // Rejected pushes roll back automatically; we just surface why.
50
+ setError(err instanceof Error ? err.message : String(err))
51
+ }
52
+ }
53
+
54
+ return (
55
+ <article>
56
+ <header className="session-header">
57
+ <h1>
58
+ Order <code>{sessionId}</code>
59
+ </h1>
60
+ <ConnectionPill connection={connection} />
61
+ <span
62
+ className={`badge${delivered ? ' done' : state.status === 'new' ? '' : ' live'}`}
63
+ >
64
+ {STATUS_LABEL[state.status]}
65
+ </span>
66
+ </header>
67
+
68
+ {state.status === 'new' ? (
69
+ <p className="hint">
70
+ This session has no events — an unknown id and an empty order look
71
+ identical, because state is only ever a fold over the log.
72
+ </p>
73
+ ) : (
74
+ <>
75
+ <ol className="timeline">
76
+ {JOURNEY.map((step, i) => (
77
+ <li
78
+ key={step.status}
79
+ className={
80
+ i < reached || delivered
81
+ ? 'done'
82
+ : i === reached
83
+ ? 'current'
84
+ : ''
85
+ }
86
+ >
87
+ {step.label}
88
+ </li>
89
+ ))}
90
+ </ol>
91
+ <dl className="facts">
92
+ <dt>Shop</dt>
93
+ <dd>{state.shopId ?? '—'}</dd>
94
+ <dt>Items</dt>
95
+ <dd>{state.items.join(', ') || '—'}</dd>
96
+ <dt>Courier</dt>
97
+ <dd>{state.driverId ?? 'not assigned yet'}</dd>
98
+ {state.etaMinutes !== null ? (
99
+ <>
100
+ <dt>ETA</dt>
101
+ <dd>~{state.etaMinutes} min</dd>
102
+ </>
103
+ ) : null}
104
+ </dl>
105
+ </>
106
+ )}
107
+
108
+ {showStart ? (
109
+ <>
110
+ <button
111
+ onClick={() => void startPreparation()}
112
+ disabled={!canStart}
113
+ title={canStart ? undefined : 'Create the order first'}
114
+ >
115
+ Start preparation
116
+ </button>
117
+ {error ? <p className="error">{error}</p> : null}
118
+ <p className="hint">
119
+ The button pushes <code>shop.started</code> optimistically — the
120
+ badge flips before the server acks. Everything after that is
121
+ handlers appending the next fact: courier assigned, picked up,
122
+ delivered — watch it arrive over the stream, hands off.
123
+ </p>
124
+ </>
125
+ ) : delivered ? (
126
+ <p className="hint">
127
+ Journey complete. Every hop above is an event in the log — the feed
128
+ below is the whole story, oldest first.
129
+ </p>
130
+ ) : (
131
+ <p className="hint">
132
+ Hands off from here — each status lands on its own as handlers append
133
+ the next fact.
134
+ </p>
135
+ )}
136
+
137
+ <ActivityFeed events={events} index={index} />
138
+ </article>
139
+ )
140
+ }
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from 'react'
2
+ import { ordersReducer } from '../model'
3
+ import { ordersServer } from '../server'
4
+ import { SessionProvider } from './session'
5
+ import { OrderClient } from './order-client'
6
+
7
+ export default async function OrderPage({
8
+ params,
9
+ }: {
10
+ params: Promise<{ orderId: string }>
11
+ }): Promise<ReactNode> {
12
+ const { orderId } = await params
13
+ // First paint costs no client JS: fold on the server, pass it down.
14
+ // `index` is the fold's frontier — the stream resumes exactly there.
15
+ const { state, index } = await ordersServer
16
+ .session(decodeURIComponent(orderId))
17
+ .state(ordersReducer)
18
+
19
+ return (
20
+ <SessionProvider
21
+ sessionId={decodeURIComponent(orderId)}
22
+ api="/orders/events"
23
+ initialState={state}
24
+ initialIndex={index}
25
+ >
26
+ <OrderClient sessionId={decodeURIComponent(orderId)} />
27
+ </SessionProvider>
28
+ )
29
+ }
@@ -0,0 +1,11 @@
1
+ 'use client'
2
+ /**
3
+ * The whole client/server seam: bind the reducer once, export the
4
+ * pair. Nothing in this file touches the machine module.
5
+ */
6
+ import { createReact } from 'experimental-a2/react'
7
+ import { ordersReducer } from '../model'
8
+
9
+ export const { SessionProvider, useSession } = createReact({
10
+ reducer: ordersReducer,
11
+ })
@@ -0,0 +1,30 @@
1
+ import { A2Error } from 'experimental-a2'
2
+ import { ordersServer } from '../server'
3
+
4
+ const errorResponse = (error: unknown): Response => {
5
+ if (error instanceof A2Error) {
6
+ return new Response(error.message, {
7
+ status: error.code === 'STORE_UNAVAILABLE' ? 503 : 400,
8
+ })
9
+ }
10
+ return new Response('internal error', { status: 500 })
11
+ }
12
+
13
+ export async function POST(request: Request): Promise<Response> {
14
+ try {
15
+ const { orderId, shopId, items } = await request.json()
16
+
17
+ if (typeof orderId !== 'string' || orderId.length === 0) {
18
+ throw new A2Error('INVALID_PAYLOAD', 'orderId must be a non-empty string')
19
+ }
20
+
21
+ const appended = await ordersServer.session(orderId).append({
22
+ type: 'created',
23
+ payload: { shopId, items },
24
+ })
25
+
26
+ return Response.json(appended)
27
+ } catch (error) {
28
+ return errorResponse(error)
29
+ }
30
+ }
@@ -0,0 +1,4 @@
1
+ import { ordersServer } from '../server'
2
+
3
+ export const GET = ordersServer.fetch
4
+ export const POST = ordersServer.fetch
@@ -0,0 +1,79 @@
1
+ import * as a2 from 'experimental-a2'
2
+ import { z } from 'zod'
3
+
4
+ export const orders = a2.contract({
5
+ name: 'orders',
6
+ events: {
7
+ created: z.object({
8
+ shopId: z.string(),
9
+ items: z.array(z.string()).min(1),
10
+ }),
11
+ 'shop.notified': z.object({}),
12
+ 'shop.started': z.object({}),
13
+ 'driver.notified': z.object({ driverId: z.string() }),
14
+ 'courier.picked_up': z.object({ etaMinutes: z.number().int().positive() }),
15
+ delivered: z.object({}),
16
+ },
17
+ })
18
+
19
+ const orderState = z.object({
20
+ status: z.enum([
21
+ 'new',
22
+ 'created',
23
+ 'notified',
24
+ 'preparing',
25
+ 'courier-assigned',
26
+ 'delivering',
27
+ 'delivered',
28
+ ]),
29
+ shopId: z.string().nullable(),
30
+ items: z.array(z.string()),
31
+ driverId: z.string().nullable(),
32
+ etaMinutes: z.number().nullable(),
33
+ })
34
+
35
+ export type OrderState = z.output<typeof orderState>
36
+
37
+ export const ordersReducer = orders
38
+ .reducer({
39
+ name: 'order-status',
40
+ initialState: {
41
+ status: 'new',
42
+ shopId: null,
43
+ items: [],
44
+ driverId: null,
45
+ etaMinutes: null,
46
+ },
47
+ stateSchema: orderState,
48
+ })
49
+ .fold((state, event) => {
50
+ switch (event.type) {
51
+ case 'created':
52
+ return {
53
+ ...state,
54
+ status: 'created',
55
+ shopId: event.payload.shopId,
56
+ items: event.payload.items,
57
+ }
58
+ case 'shop.notified':
59
+ return { ...state, status: 'notified' }
60
+ case 'shop.started':
61
+ return { ...state, status: 'preparing' }
62
+ case 'driver.notified':
63
+ return {
64
+ ...state,
65
+ status: 'courier-assigned',
66
+ driverId: event.payload.driverId,
67
+ }
68
+ case 'courier.picked_up':
69
+ return {
70
+ ...state,
71
+ status: 'delivering',
72
+ etaMinutes: event.payload.etaMinutes,
73
+ }
74
+ case 'delivered':
75
+ return { ...state, status: 'delivered', etaMinutes: null }
76
+ default:
77
+ return state
78
+ }
79
+ })
@@ -0,0 +1,98 @@
1
+ 'use client'
2
+ import type { FormEvent, ReactNode } from 'react'
3
+ import { useRouter } from 'next/navigation'
4
+ import { useState } from 'react'
5
+
6
+ const randomOrderId = (): string =>
7
+ `order-${Math.random().toString(36).slice(2, 8)}`
8
+
9
+ export function NewOrderForm(): ReactNode {
10
+ const router = useRouter()
11
+ const [orderId, setOrderId] = useState(randomOrderId)
12
+ const [shopId, setShopId] = useState('shop-7')
13
+ const [items, setItems] = useState('flat white, croissant')
14
+ const [existingId, setExistingId] = useState('')
15
+ const [error, setError] = useState<string | null>(null)
16
+ const [busy, setBusy] = useState(false)
17
+
18
+ const createOrder = async (e: FormEvent): Promise<void> => {
19
+ e.preventDefault()
20
+ setBusy(true)
21
+ setError(null)
22
+ try {
23
+ const res = await fetch('/orders/create', {
24
+ method: 'POST',
25
+ headers: { 'content-type': 'application/json' },
26
+ body: JSON.stringify({
27
+ orderId,
28
+ shopId,
29
+ items: items
30
+ .split(',')
31
+ .map((item) => item.trim())
32
+ .filter(Boolean),
33
+ }),
34
+ })
35
+ if (!res.ok) {
36
+ throw new Error(
37
+ (await res.text()) || `unexpected ${res.status} response`,
38
+ )
39
+ }
40
+ router.push(`/orders/${encodeURIComponent(orderId)}`)
41
+ } catch (err) {
42
+ setError(err instanceof Error ? err.message : String(err))
43
+ setBusy(false)
44
+ }
45
+ }
46
+
47
+ const openExisting = (e: FormEvent): void => {
48
+ e.preventDefault()
49
+ if (existingId.trim()) {
50
+ router.push(`/orders/${encodeURIComponent(existingId.trim())}`)
51
+ }
52
+ }
53
+
54
+ return (
55
+ <>
56
+ <form onSubmit={(e) => void createOrder(e)}>
57
+ <label>
58
+ Order id
59
+ <input
60
+ value={orderId}
61
+ onChange={(e) => setOrderId(e.target.value)}
62
+ required
63
+ />
64
+ </label>
65
+ <label>
66
+ Shop
67
+ <select value={shopId} onChange={(e) => setShopId(e.target.value)}>
68
+ <option value="shop-7">shop-7 — Third Wave Espresso</option>
69
+ <option value="shop-12">shop-12 — Brutalist Bagels</option>
70
+ <option value="shop-31">shop-31 — Soup, Reduced</option>
71
+ </select>
72
+ </label>
73
+ <label>
74
+ Items (comma-separated)
75
+ <input value={items} onChange={(e) => setItems(e.target.value)} />
76
+ </label>
77
+ <button type="submit" disabled={busy}>
78
+ {busy ? 'Appending…' : 'Create order'}
79
+ </button>
80
+ {error ? <p className="error">{error}</p> : null}
81
+ </form>
82
+
83
+ <form className="inline-form" onSubmit={openExisting}>
84
+ <label>
85
+ Open an existing order
86
+ <input
87
+ value={existingId}
88
+ onChange={(e) => setExistingId(e.target.value)}
89
+ placeholder="order-abc123"
90
+ />
91
+ </label>
92
+ <button type="submit" className="secondary">
93
+ Open
94
+ </button>
95
+ </form>
96
+ </>
97
+ )
98
+ }
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react'
2
+ import { NewOrderForm } from './new-order-form'
3
+
4
+ export default function OrdersPage(): ReactNode {
5
+ return (
6
+ <article>
7
+ <h1>Order tracker</h1>
8
+ <p className="lede">
9
+ Creating an order appends a single <code>created</code> event.
10
+ Everything after that — the shop notification, the courier, the delivery
11
+ itself — is handlers appending the next fact to the same log, all the
12
+ way to <code>delivered</code>.
13
+ </p>
14
+ <NewOrderForm />
15
+ <p className="hint">
16
+ Order ids are yours to choose: revisit{' '}
17
+ <code>/orders/&lt;your-id&gt;</code> any time — the log survives{' '}
18
+ <code>next dev</code> restarts.
19
+ </p>
20
+ </article>
21
+ )
22
+ }
@@ -0,0 +1,5 @@
1
+ import { ordersScheduler, ordersServer } from '../server'
2
+
3
+ export const POST =
4
+ ordersScheduler?.handler(ordersServer) ??
5
+ (() => new Response('orders scheduler is not configured', { status: 503 }))
@@ -0,0 +1,50 @@
1
+ import 'server-only'
2
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
3
+ import { createServer } from 'experimental-a2/server'
4
+ import { store } from '@/lib/store'
5
+ import { orders } from './model'
6
+
7
+ const wait = (ms: number): Promise<void> =>
8
+ new Promise((resolve) => setTimeout(resolve, ms))
9
+
10
+ const drivers = ['driver-ada', 'driver-grace', 'driver-alan', 'driver-edsger']
11
+
12
+ export const ordersScheduler =
13
+ process.env.VERCEL || process.env.A2_QUEUES
14
+ ? vercelQueues({ topic: 'a2-orders' })
15
+ : undefined
16
+
17
+ export const ordersServer = createServer({
18
+ ...(store ? { store } : {}),
19
+ ...(ordersScheduler ? { scheduler: ordersScheduler } : {}),
20
+ contract: orders,
21
+ handlers: {
22
+ created: async ({ session }) => {
23
+ await wait(800)
24
+ await session.append('notify-shop', {
25
+ type: 'shop.notified',
26
+ payload: {},
27
+ })
28
+ },
29
+ 'shop.started': async ({ session }) => {
30
+ await wait(1200)
31
+ const driverId = drivers[Math.floor(Math.random() * drivers.length)]!
32
+ await session.append('notify-driver', {
33
+ type: 'driver.notified',
34
+ payload: { driverId },
35
+ })
36
+ },
37
+ 'driver.notified': async ({ session }) => {
38
+ await wait(3000)
39
+ const etaMinutes = 3 + Math.floor(Math.random() * 5)
40
+ await session.append('courier-picked-up', {
41
+ type: 'courier.picked_up',
42
+ payload: { etaMinutes },
43
+ })
44
+ },
45
+ 'courier.picked_up': async ({ session }) => {
46
+ await wait(3500)
47
+ await session.append('delivered', { type: 'delivered', payload: {} })
48
+ },
49
+ },
50
+ })
@@ -0,0 +1,102 @@
1
+ import type { ReactNode } from 'react'
2
+ import Link from 'next/link'
3
+
4
+ export default function HomePage(): ReactNode {
5
+ return (
6
+ <article>
7
+ <h1>A durable event log, live in your browser</h1>
8
+ <p className="lede">
9
+ Seven small demos, one model: append an event, let a handler react, fold
10
+ the log into a view — on the server for the first paint, in the browser
11
+ for every moment after.
12
+ </p>
13
+
14
+ <div className="card-grid">
15
+ <Link className="card" href="/orders">
16
+ <h2>Order tracker</h2>
17
+ <p>
18
+ A form appends <code>created</code>, and handlers chain the rest —
19
+ shop notified, courier assigned, picked up, delivered — while the
20
+ page watches over a live stream. One optimistic click, then the
21
+ order runs itself to the end.
22
+ </p>
23
+ </Link>
24
+ <Link className="card" href="/chat">
25
+ <h2>Multiplayer chat + agent</h2>
26
+ <p>
27
+ Messages, “is typing…” presence, and an AI participant share one
28
+ stream. Mention <code>@agent</code>, or enable ambient mode for the
29
+ room. The model can stay silent or call a durable <code>post</code>{' '}
30
+ tool; its private generation appears only as “thinking” or
31
+ “writing.”
32
+ </p>
33
+ </Link>
34
+ <Link className="card" href="/agent">
35
+ <h2>Agent</h2>
36
+ <p>
37
+ A durable AI agent inspects a service, records tool calls, pauses
38
+ for approval, runs Bash in a persistent Vercel Sandbox, and sets
39
+ reminders that return as new user messages. Messages, progress,
40
+ tools, timers, and the human decision are all ordinary A2 events.
41
+ </p>
42
+ </Link>
43
+ <Link className="card" href="/canvas">
44
+ <h2>Multiplayer canvas</h2>
45
+ <p>
46
+ Shapes are durable events you can scrub back through after the
47
+ server snapshot. Cursors and drag ghosts are presence: replicated
48
+ live, stored never.
49
+ </p>
50
+ </Link>
51
+ <Link className="card" href="/documents">
52
+ <h2>Multiplayer document</h2>
53
+ <p>
54
+ Two CodeMirror editors share one Yjs document. A2 durably orders the
55
+ CRDT updates while Yjs Awareness — live cursors and selections —
56
+ rides the session's presence plane, never touching the log.
57
+ </p>
58
+ </Link>
59
+ <Link className="card" href="/recovery">
60
+ <h2>Cross-invocation recovery</h2>
61
+ <p>
62
+ Chain four 8-second handlers under a 20-second function limit, then
63
+ watch Vercel stop one mid-handler and A2 retry that pending event
64
+ under a fresh claim.
65
+ </p>
66
+ </Link>
67
+ <Link className="card" href="/counter">
68
+ <h2>Counter</h2>
69
+ <p>
70
+ Increment and decrement one shared value. Every click is a durable
71
+ event applied optimistically in the browser, then confirmed by the
72
+ stream — open two tabs and watch both folds converge.
73
+ </p>
74
+ </Link>
75
+ </div>
76
+
77
+ <section>
78
+ <h2>Where things live</h2>
79
+ <ul>
80
+ <li>
81
+ Each demo owns a <code>model.ts</code> with its contract, reducer,
82
+ and shared types.
83
+ </li>
84
+ <li>
85
+ Its <code>server.ts</code> implements handlers and configures the
86
+ production store and scheduler it actually needs.
87
+ </li>
88
+ <li>
89
+ Its <code>events/route.ts</code> exposes <code>server.fetch</code>{' '}
90
+ directly, adding only feature-specific authorization or WebSocket
91
+ capabilities.
92
+ </li>
93
+ <li>
94
+ Demos with handlers or timers own a private{' '}
95
+ <code>scheduler/route.ts</code> and queue topic. Counter, canvas,
96
+ and documents need no scheduler.
97
+ </li>
98
+ </ul>
99
+ </section>
100
+ </article>
101
+ )
102
+ }
@@ -0,0 +1,31 @@
1
+ import type { ReactNode } from 'react'
2
+ import { recoveryReducer } from '../model'
3
+ import { recoveryServer } from '../server'
4
+ import { RecoveryClient } from './recovery-client'
5
+ import { SessionProvider } from './session'
6
+
7
+ export default async function RecoveryRunPage({
8
+ params,
9
+ }: {
10
+ params: Promise<{ recoveryId: string }>
11
+ }): Promise<ReactNode> {
12
+ const { recoveryId } = await params
13
+ const sessionId = decodeURIComponent(recoveryId)
14
+ const { state, index } = await recoveryServer
15
+ .session(sessionId)
16
+ .state(recoveryReducer)
17
+
18
+ return (
19
+ <SessionProvider
20
+ sessionId={sessionId}
21
+ api="/recovery/events"
22
+ initialState={state}
23
+ initialIndex={index}
24
+ >
25
+ <RecoveryClient
26
+ sessionId={sessionId}
27
+ onVercel={Boolean(process.env.VERCEL)}
28
+ />
29
+ </SessionProvider>
30
+ )
31
+ }