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.
- package/AGENTS.md +11 -0
- package/CHANGELOG.md +27 -0
- package/README.md +29 -0
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +13 -11
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-286j79Mt.js → server-B2XNevQA.js} +123 -53
- package/dist/server-B2XNevQA.js.map +1 -0
- package/dist/{server-DgXmORIq.d.ts → server-DjPhHnbI.d.ts} +7 -4
- package/dist/server-DjPhHnbI.d.ts.map +1 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-flRz1OWh.d.ts → store-RJO35BMj.d.ts} +25 -8
- package/dist/store-RJO35BMj.d.ts.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +79 -19
- package/dist/store-memory.js.map +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +230 -101
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-redis-core-DEYO8Ryv.js → store-redis-core-DT01r4GZ.js} +167 -29
- package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +103 -19
- package/dist/store-sqlite.js.map +1 -1
- package/docs/concepts/02-handlers.mdx +4 -0
- package/docs/concepts/04-state.mdx +57 -9
- package/docs/guides/06-ai-agents.mdx +2 -1
- package/docs/guides/07-examples.mdx +56 -0
- package/docs/index.mdx +13 -0
- package/docs/reference/01-api.mdx +39 -16
- package/examples/README.md +15 -0
- package/examples/playground/AGENTS.md +11 -0
- package/examples/playground/DEPLOY.md +106 -0
- package/examples/playground/README.md +19 -0
- package/examples/playground/activity-feed.test.ts +10 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
- package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
- package/examples/playground/app/agent/events/route.ts +4 -0
- package/examples/playground/app/agent/model.ts +3 -0
- package/examples/playground/app/agent/new-agent-session.tsx +98 -0
- package/examples/playground/app/agent/page.tsx +25 -0
- package/examples/playground/app/agent/scheduler/route.ts +5 -0
- package/examples/playground/app/agent/server.ts +153 -0
- package/examples/playground/app/agent/session.ts +13 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
- package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
- package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
- package/examples/playground/app/canvas/events/route.ts +13 -0
- package/examples/playground/app/canvas/model.ts +94 -0
- package/examples/playground/app/canvas/open-canvas.tsx +40 -0
- package/examples/playground/app/canvas/page.tsx +20 -0
- package/examples/playground/app/canvas/server.ts +9 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
- package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
- package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
- package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
- package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
- package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
- package/examples/playground/app/chat/[chatId]/session.ts +7 -0
- package/examples/playground/app/chat/events/route.ts +7 -0
- package/examples/playground/app/chat/model.test.ts +155 -0
- package/examples/playground/app/chat/model.ts +310 -0
- package/examples/playground/app/chat/new-conversation.tsx +16 -0
- package/examples/playground/app/chat/page.tsx +25 -0
- package/examples/playground/app/chat/scheduler/route.ts +5 -0
- package/examples/playground/app/chat/server.ts +184 -0
- package/examples/playground/app/components/activity-feed.tsx +54 -0
- package/examples/playground/app/components/connection-pill.tsx +29 -0
- package/examples/playground/app/counter/counter-client.tsx +72 -0
- package/examples/playground/app/counter/events/route.ts +4 -0
- package/examples/playground/app/counter/model.test.ts +36 -0
- package/examples/playground/app/counter/model.ts +31 -0
- package/examples/playground/app/counter/page.tsx +24 -0
- package/examples/playground/app/counter/server.ts +9 -0
- package/examples/playground/app/counter/session.ts +13 -0
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
- package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
- package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
- package/examples/playground/app/documents/[documentId]/session.ts +7 -0
- package/examples/playground/app/documents/events/route.ts +4 -0
- package/examples/playground/app/documents/model.ts +55 -0
- package/examples/playground/app/documents/open-document.tsx +40 -0
- package/examples/playground/app/documents/page.tsx +22 -0
- package/examples/playground/app/documents/server.ts +9 -0
- package/examples/playground/app/globals.css +2078 -0
- package/examples/playground/app/layout.tsx +44 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
- package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
- package/examples/playground/app/orders/[orderId]/session.ts +11 -0
- package/examples/playground/app/orders/create/route.ts +30 -0
- package/examples/playground/app/orders/events/route.ts +4 -0
- package/examples/playground/app/orders/model.ts +79 -0
- package/examples/playground/app/orders/new-order-form.tsx +98 -0
- package/examples/playground/app/orders/page.tsx +22 -0
- package/examples/playground/app/orders/scheduler/route.ts +5 -0
- package/examples/playground/app/orders/server.ts +50 -0
- package/examples/playground/app/page.tsx +102 -0
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
- package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
- package/examples/playground/app/recovery/events/route.ts +3 -0
- package/examples/playground/app/recovery/model.ts +55 -0
- package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
- package/examples/playground/app/recovery/page.tsx +22 -0
- package/examples/playground/app/recovery/scheduler/route.ts +7 -0
- package/examples/playground/app/recovery/server.ts +53 -0
- package/examples/playground/app/recovery/start/route.ts +41 -0
- package/examples/playground/css.d.ts +4 -0
- package/examples/playground/lib/store.ts +15 -0
- package/examples/playground/next-env.d.ts +5 -0
- package/examples/playground/next.config.ts +10 -0
- package/examples/playground/package.json +46 -0
- package/examples/playground/tsconfig.json +37 -0
- package/examples/playground/vercel.json +40 -0
- package/package.json +5 -2
- package/src/ai-server.ts +27 -10
- package/src/server.ts +242 -87
- package/src/store-memory.ts +138 -20
- package/src/store-postgres.ts +355 -138
- package/src/store-redis-core.ts +201 -27
- package/src/store-redis-http.ts +1 -1
- package/src/store-redis.ts +1 -1
- package/src/store-sqlite.ts +191 -34
- package/src/store.ts +27 -9
- package/dist/server-286j79Mt.js.map +0 -1
- package/dist/server-DgXmORIq.d.ts.map +0 -1
- package/dist/store-flRz1OWh.d.ts.map +0 -1
- 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,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/<your-id></code> any time — the log survives{' '}
|
|
18
|
+
<code>next dev</code> restarts.
|
|
19
|
+
</p>
|
|
20
|
+
</article>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -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
|
+
}
|