experimental-a2 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -0
- package/CHANGELOG.md +36 -0
- package/README.md +29 -0
- package/dist/actor-client.d.ts +46 -0
- package/dist/actor-client.d.ts.map +1 -0
- package/dist/actor-client.js +54 -0
- package/dist/actor-client.js.map +1 -0
- package/dist/actor-react.d.ts +54 -0
- package/dist/actor-react.d.ts.map +1 -0
- package/dist/actor-react.js +79 -0
- package/dist/actor-react.js.map +1 -0
- package/dist/actor-shared-BACubf4x.d.ts +136 -0
- package/dist/actor-shared-BACubf4x.d.ts.map +1 -0
- package/dist/actor-shared-DI7J5upy.js +127 -0
- package/dist/actor-shared-DI7J5upy.js.map +1 -0
- package/dist/actor.browser.d.ts +1 -0
- package/dist/actor.browser.js +13 -0
- package/dist/actor.browser.js.map +1 -0
- package/dist/actor.d.ts +176 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +437 -0
- package/dist/actor.js.map +1 -0
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +2 -2
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
- package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
- package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
- package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/react.d.ts +1 -1
- package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
- package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-B2XNevQA.js → server-CBET-jSz.js} +6 -6
- package/dist/server-CBET-jSz.js.map +1 -0
- package/dist/{server-DjPhHnbI.d.ts → server-CKY3_lbw.d.ts} +3 -3
- package/dist/{server-DjPhHnbI.d.ts.map → server-CKY3_lbw.d.ts.map} +1 -1
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
- package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +2 -2
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +2 -2
- package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
- package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +2 -2
- package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
- package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +189 -0
- package/docs/actors/02-concurrency.mdx +154 -0
- package/docs/actors/03-timers.mdx +120 -0
- package/docs/actors/04-routes.mdx +352 -0
- package/docs/actors/meta.ts +1 -0
- package/docs/concepts/meta.ts +1 -0
- package/docs/guides/07-examples.mdx +56 -0
- package/docs/guides/meta.ts +1 -0
- package/docs/index.mdx +16 -0
- package/docs/reference/02-errors.mdx +33 -0
- package/docs/reference/meta.ts +1 -0
- 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 +111 -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/app/vault/[vaultId]/route.ts +19 -0
- package/examples/playground/app/vault/page.tsx +12 -0
- package/examples/playground/app/vault/server.ts +9 -0
- package/examples/playground/app/vault/vault-client.tsx +124 -0
- package/examples/playground/app/vault/vault.test.ts +147 -0
- package/examples/playground/app/vault/vault.ts +119 -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 +11 -2
- package/src/actor-client.ts +132 -0
- package/src/actor-react.ts +143 -0
- package/src/actor-shared.ts +356 -0
- package/src/actor.browser.ts +12 -0
- package/src/actor.ts +914 -0
- package/src/client.ts +9 -1
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -1
- package/src/server.ts +13 -3
- package/dist/contract-jIfaR085.d.ts.map +0 -1
- package/dist/server-B2XNevQA.js.map +0 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vault actor — a durable mailbox with memory, and muscle.
|
|
3
|
+
*
|
|
4
|
+
* Defined over a protocol: state and events, as types. Handlers are
|
|
5
|
+
* serial by default (the brain: hold the lane, decide against fresh
|
|
6
|
+
* state, one atomic commit); `{ concurrent: true }` opts a handler out
|
|
7
|
+
* of the lane (the muscle: slow I/O in parallel, results sent home as
|
|
8
|
+
* events). A withdrawal is the classic saga — reserve (serial) →
|
|
9
|
+
* transfer (concurrent) → settle or refund (serial) — each step one
|
|
10
|
+
* atomic commit, every crash landing between named states. The reserve
|
|
11
|
+
* and its transfer trigger commit together: `ctx.send` is buffered
|
|
12
|
+
* into the same atomic operation as the state change, so the saga can
|
|
13
|
+
* never half-start.
|
|
14
|
+
*
|
|
15
|
+
* A factory, deliberately: the store and the transfer latency are
|
|
16
|
+
* injected, so tests build isolated vaults on a memory store with a
|
|
17
|
+
* fast clock (vault.test.ts) while server.ts builds the real one.
|
|
18
|
+
*/
|
|
19
|
+
import { NonRetriableError } from 'experimental-a2'
|
|
20
|
+
import { actor, concurrent } from 'experimental-a2/actor'
|
|
21
|
+
import type { A2Store } from 'experimental-a2/server'
|
|
22
|
+
|
|
23
|
+
interface Vault {
|
|
24
|
+
state: {
|
|
25
|
+
balance: number
|
|
26
|
+
deposits: number
|
|
27
|
+
withdrawals: number
|
|
28
|
+
/** In-flight transfers: ref → reserved amount. */
|
|
29
|
+
pending: Record<string, number>
|
|
30
|
+
}
|
|
31
|
+
events: {
|
|
32
|
+
deposit: { amount: number }
|
|
33
|
+
withdraw: { amount: number }
|
|
34
|
+
settle: { ref: string }
|
|
35
|
+
refund: { ref: string }
|
|
36
|
+
transfer: { ref: string; amount: number }
|
|
37
|
+
}
|
|
38
|
+
/** Ephemeral audience state — never in the log, invisible to handlers. */
|
|
39
|
+
presence: { viewing: boolean }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** For client props — type-only imports of this module are erased. */
|
|
43
|
+
export type VaultState = Vault['state']
|
|
44
|
+
|
|
45
|
+
// Types are compile-time claims — the route is public, so wire input
|
|
46
|
+
// gets a first-line runtime guard (bring zod and `.parse` if you
|
|
47
|
+
// prefer schemas; it's userland either way).
|
|
48
|
+
function guardAmount(input: { amount: number }): number {
|
|
49
|
+
const amount = input?.amount
|
|
50
|
+
if (typeof amount !== 'number' || !Number.isInteger(amount)) {
|
|
51
|
+
throw new NonRetriableError('amount must be an integer')
|
|
52
|
+
}
|
|
53
|
+
if (amount <= 0 || amount > 1_000_000) {
|
|
54
|
+
throw new NonRetriableError('amount must be between 1 and 1,000,000')
|
|
55
|
+
}
|
|
56
|
+
return amount
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createVault(options?: {
|
|
60
|
+
store?: A2Store
|
|
61
|
+
/** The fake bank's latency — tests inject a fast one. */
|
|
62
|
+
transferMs?: number
|
|
63
|
+
}) {
|
|
64
|
+
const transferMs = options?.transferMs ?? 2_000
|
|
65
|
+
const store = options?.store
|
|
66
|
+
return actor<Vault>({
|
|
67
|
+
name: 'vault',
|
|
68
|
+
state: { balance: 0, deposits: 0, withdrawals: 0, pending: {} },
|
|
69
|
+
presence: true,
|
|
70
|
+
|
|
71
|
+
handlers: {
|
|
72
|
+
deposit: (ctx, input) => {
|
|
73
|
+
const amount = guardAmount(input)
|
|
74
|
+
ctx.state.balance += amount
|
|
75
|
+
ctx.state.deposits += 1
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
withdraw: (ctx, input) => {
|
|
79
|
+
ctx.state.pending ??= {}
|
|
80
|
+
const amount = guardAmount(input)
|
|
81
|
+
if (ctx.state.balance < amount) {
|
|
82
|
+
throw new NonRetriableError(
|
|
83
|
+
`insufficient funds: the balance is ${ctx.state.balance}`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
ctx.state.balance -= amount
|
|
87
|
+
ctx.state.pending[ctx.id] = amount
|
|
88
|
+
ctx.send.transfer({ ref: ctx.id, amount })
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
settle: (ctx, input) => {
|
|
92
|
+
ctx.state.pending ??= {}
|
|
93
|
+
if (ctx.state.pending[input.ref] === undefined) return
|
|
94
|
+
delete ctx.state.pending[input.ref]
|
|
95
|
+
ctx.state.withdrawals += 1
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
refund: (ctx, input) => {
|
|
99
|
+
ctx.state.pending ??= {}
|
|
100
|
+
const amount = ctx.state.pending[input.ref]
|
|
101
|
+
if (amount === undefined) return
|
|
102
|
+
delete ctx.state.pending[input.ref]
|
|
103
|
+
ctx.state.balance += amount
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
transfer: concurrent(async (ctx, input) => {
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, transferMs))
|
|
108
|
+
// demo bank policy: amounts ending in 9 bounce → compensation
|
|
109
|
+
if (input.amount % 10 === 9) {
|
|
110
|
+
ctx.send.refund({ ref: input.ref })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
ctx.send.settle({ ref: input.ref })
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
...(store ? { store } : {}),
|
|
118
|
+
})
|
|
119
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
import { postgres, type PostgresStore } from 'experimental-a2/store-postgres'
|
|
3
|
+
import { redis, type RedisStore } from 'experimental-a2/store-redis'
|
|
4
|
+
|
|
5
|
+
const redisUrl = process.env.REDIS_URL
|
|
6
|
+
const postgresUrl = process.env.DATABASE_URL?.replace(
|
|
7
|
+
/sslmode=(require|prefer|verify-ca)/,
|
|
8
|
+
'sslmode=verify-full',
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export const store: RedisStore | PostgresStore | undefined = redisUrl
|
|
12
|
+
? redis({ url: redisUrl })
|
|
13
|
+
: postgresUrl
|
|
14
|
+
? postgres({ connectionString: postgresUrl })
|
|
15
|
+
: undefined
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// No workarounds needed: experimental-a2/server is a separate server-only entry
|
|
2
|
+
// point, so client bundles never reach a store backend; the sqlite dev
|
|
3
|
+
// default carries its own turbopackIgnore annotation in a2's dist, so
|
|
4
|
+
// output tracing stays scoped without externalizing anything; and
|
|
5
|
+
// Next 16 transpiles this file with the TS7 (tsgo) compiler.
|
|
6
|
+
import type { NextConfig } from 'next'
|
|
7
|
+
|
|
8
|
+
const nextConfig: NextConfig = {}
|
|
9
|
+
|
|
10
|
+
export default nextConfig
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "a2-playground",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Complete Next.js examples for experimental-a2.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"packageManager": "pnpm@11.20.0",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22.18"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "next dev",
|
|
13
|
+
"build": "next build",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"typecheck": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@codemirror/state": "^6.7.1",
|
|
19
|
+
"@codemirror/view": "^6.43.8",
|
|
20
|
+
"@vercel/functions": "^3.9.3",
|
|
21
|
+
"@vercel/queue": "^0.2.0",
|
|
22
|
+
"@vercel/sandbox": "^3.0.0",
|
|
23
|
+
"ai": "^7.0.58",
|
|
24
|
+
"codemirror": "^6.0.2",
|
|
25
|
+
"experimental-a2": "0.9.0",
|
|
26
|
+
"ioredis": "^5.9.0",
|
|
27
|
+
"next": "^16.3.0",
|
|
28
|
+
"pg": "^8.16.0",
|
|
29
|
+
"react": "^19.0.0",
|
|
30
|
+
"react-dom": "^19.0.0",
|
|
31
|
+
"server-only": "^0.0.1",
|
|
32
|
+
"ws": "^8.21.3",
|
|
33
|
+
"y-codemirror.next": "^0.3.5",
|
|
34
|
+
"y-protocols": "^1.0.7",
|
|
35
|
+
"yjs": "^13.6.32",
|
|
36
|
+
"zod": "^4.4.3"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^24.0.0",
|
|
40
|
+
"@types/react": "^19.0.0",
|
|
41
|
+
"@types/react-dom": "^19.0.0",
|
|
42
|
+
"@types/ws": "^8.18.1",
|
|
43
|
+
"typescript": "^7.0.2",
|
|
44
|
+
"vitest": "^4.1.10"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"module": "esnext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noUncheckedIndexedAccess": true,
|
|
11
|
+
"noFallthroughCasesInSwitch": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"noEmit": true,
|
|
18
|
+
"allowJs": true,
|
|
19
|
+
"incremental": true,
|
|
20
|
+
"plugins": [
|
|
21
|
+
{
|
|
22
|
+
"name": "next"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"paths": {
|
|
26
|
+
"@/*": ["./*"]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"include": [
|
|
30
|
+
"next-env.d.ts",
|
|
31
|
+
"**/*.ts",
|
|
32
|
+
"**/*.tsx",
|
|
33
|
+
".next/types/**/*.ts",
|
|
34
|
+
".next/dev/types/**/*.ts"
|
|
35
|
+
],
|
|
36
|
+
"exclude": ["node_modules"]
|
|
37
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"functions": {
|
|
3
|
+
"app/orders/scheduler/route.ts": {
|
|
4
|
+
"experimentalTriggers": [
|
|
5
|
+
{
|
|
6
|
+
"type": "queue/v2beta",
|
|
7
|
+
"topic": "a2-orders",
|
|
8
|
+
"retryAfterSeconds": 30
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"app/chat/scheduler/route.ts": {
|
|
13
|
+
"experimentalTriggers": [
|
|
14
|
+
{
|
|
15
|
+
"type": "queue/v2beta",
|
|
16
|
+
"topic": "a2-chat",
|
|
17
|
+
"retryAfterSeconds": 30
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"app/agent/scheduler/route.ts": {
|
|
22
|
+
"experimentalTriggers": [
|
|
23
|
+
{
|
|
24
|
+
"type": "queue/v2beta",
|
|
25
|
+
"topic": "a2-agent",
|
|
26
|
+
"retryAfterSeconds": 30
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"app/recovery/scheduler/route.ts": {
|
|
31
|
+
"experimentalTriggers": [
|
|
32
|
+
{
|
|
33
|
+
"type": "queue/v2beta",
|
|
34
|
+
"topic": "a2-recovery",
|
|
35
|
+
"retryAfterSeconds": 30
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "experimental-a2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Durable sync and reactions for things with a lifecycle: one event log, derived state, and live client per session.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
22
22
|
"docs",
|
|
23
|
+
"examples",
|
|
23
24
|
"src",
|
|
24
|
-
"
|
|
25
|
+
"AGENTS.md",
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"README.md"
|
|
25
28
|
],
|
|
26
29
|
"exports": {
|
|
27
30
|
".": "./dist/index.js",
|
|
@@ -36,6 +39,12 @@
|
|
|
36
39
|
"browser": "./dist/ai-server.browser.js",
|
|
37
40
|
"default": "./dist/ai-server.js"
|
|
38
41
|
},
|
|
42
|
+
"./actor": {
|
|
43
|
+
"browser": "./dist/actor.browser.js",
|
|
44
|
+
"default": "./dist/actor.js"
|
|
45
|
+
},
|
|
46
|
+
"./actor/client": "./dist/actor-client.js",
|
|
47
|
+
"./actor/react": "./dist/actor-react.js",
|
|
39
48
|
"./store-memory": "./dist/store-memory.js",
|
|
40
49
|
"./store-sqlite": "./dist/store-sqlite.js",
|
|
41
50
|
"./store-postgres": "./dist/store-postgres.js",
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* experimental-a2/actor/client — typed calls to an actor over HTTP.
|
|
3
|
+
* Framework-agnostic: no React, no framework machinery; works in any
|
|
4
|
+
* browser code and server-to-server.
|
|
5
|
+
*
|
|
6
|
+
* `client.call` mirrors the server handle's `call` record over the
|
|
7
|
+
* call lane `handle.fetch` serves — POST `{ event, input, messageId? }`
|
|
8
|
+
* → the answer (post-state), a 409 refusal (revived as
|
|
9
|
+
* `ActorRefusedError`), or a 400. Both ends of that wire are library
|
|
10
|
+
* code: the types here are backed by `handle.fetch`, not by a
|
|
11
|
+
* hand-written convention.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import type { vault } from '@/app/vault/server' // type-only, erased
|
|
15
|
+
* const client = createActorClient<typeof vault>({ api: `/vault/${id}` })
|
|
16
|
+
* await client.call.withdraw({ amount: 60 }) // ActorRefusedError on refusal
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { ActorRefusedError } from './actor-shared.ts'
|
|
21
|
+
import type { ActorCallOptions, ActorCallResult } from './actor.ts'
|
|
22
|
+
|
|
23
|
+
export { ActorRefusedError }
|
|
24
|
+
|
|
25
|
+
/** The loose shape of an `actor()` definition — carried type-only. */
|
|
26
|
+
export type AnyActorDefinition = {
|
|
27
|
+
actor(id: string): {
|
|
28
|
+
state(): Promise<{ state: unknown; index: number }>
|
|
29
|
+
call: object
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type HandleOf<D extends AnyActorDefinition> = ReturnType<D['actor']>
|
|
34
|
+
|
|
35
|
+
export type ActorStateOf<D extends AnyActorDefinition> = Awaited<
|
|
36
|
+
ReturnType<HandleOf<D>['state']>
|
|
37
|
+
>['state']
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The server handle's `call` record mirrored to the client, with
|
|
41
|
+
* answers carrying `View` — the projected shape when the route mounts
|
|
42
|
+
* `fetch(req, { view })`, the full state otherwise (the default).
|
|
43
|
+
*/
|
|
44
|
+
export type ActorClientCall<
|
|
45
|
+
D extends AnyActorDefinition,
|
|
46
|
+
View = ActorStateOf<D>,
|
|
47
|
+
> = {
|
|
48
|
+
readonly [K in keyof HandleOf<D>['call']]: HandleOf<D>['call'][K] extends (
|
|
49
|
+
...args: infer P
|
|
50
|
+
) => Promise<{ state: unknown; index: number }>
|
|
51
|
+
? (...args: P) => Promise<{ state: View; index: number }>
|
|
52
|
+
: never
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ActorClient<
|
|
56
|
+
D extends AnyActorDefinition,
|
|
57
|
+
View = ActorStateOf<D>,
|
|
58
|
+
> = {
|
|
59
|
+
/** Typed calls over POST — the response is the answer. */
|
|
60
|
+
readonly call: ActorClientCall<D, View>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type CreateActorClientOptions = {
|
|
64
|
+
/** The route whose handler delegates to `handle.fetch` for this instance. */
|
|
65
|
+
api: string
|
|
66
|
+
/** Injectable transport (tests, custom auth headers). */
|
|
67
|
+
fetch?: typeof globalThis.fetch
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const CALL_TIMEOUT_MS = 30_000
|
|
71
|
+
|
|
72
|
+
const callOverWire = async (
|
|
73
|
+
fetchImpl: typeof globalThis.fetch,
|
|
74
|
+
api: string,
|
|
75
|
+
event: string,
|
|
76
|
+
input: unknown,
|
|
77
|
+
options?: ActorCallOptions,
|
|
78
|
+
): Promise<ActorCallResult<unknown>> => {
|
|
79
|
+
const response = await fetchImpl(api, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'content-type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
event,
|
|
84
|
+
...(input === undefined ? {} : { input }),
|
|
85
|
+
...(options?.id === undefined ? {} : { messageId: options.id }),
|
|
86
|
+
}),
|
|
87
|
+
signal: AbortSignal.timeout(options?.timeoutMs ?? CALL_TIMEOUT_MS),
|
|
88
|
+
})
|
|
89
|
+
const body = (await response.json().catch(() => ({}))) as Record<
|
|
90
|
+
string,
|
|
91
|
+
unknown
|
|
92
|
+
>
|
|
93
|
+
if (response.status === 409 && typeof body['error'] === 'string') {
|
|
94
|
+
throw new ActorRefusedError(
|
|
95
|
+
typeof body['event'] === 'string' ? body['event'] : event,
|
|
96
|
+
typeof body['messageId'] === 'string' ? body['messageId'] : '',
|
|
97
|
+
body['error'],
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
const message =
|
|
102
|
+
typeof body['error'] === 'string'
|
|
103
|
+
? body['error']
|
|
104
|
+
: `actor call failed (${response.status})`
|
|
105
|
+
throw new Error(message)
|
|
106
|
+
}
|
|
107
|
+
return body as ActorCallResult<unknown>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A typed call surface for one actor instance's route. When the route
|
|
112
|
+
* mounts a `view`, pass its shape as the second type argument so call
|
|
113
|
+
* answers carry the projected state:
|
|
114
|
+
* `createActorClient<typeof vault, PublicVault>({ api })`.
|
|
115
|
+
*/
|
|
116
|
+
export function createActorClient<
|
|
117
|
+
D extends AnyActorDefinition,
|
|
118
|
+
View = ActorStateOf<D>,
|
|
119
|
+
>(options: CreateActorClientOptions): ActorClient<D, View> {
|
|
120
|
+
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
|
|
121
|
+
const call = new Proxy(
|
|
122
|
+
{},
|
|
123
|
+
{
|
|
124
|
+
get: (_target, event) =>
|
|
125
|
+
typeof event === 'string'
|
|
126
|
+
? (input?: unknown, callOptions?: ActorCallOptions) =>
|
|
127
|
+
callOverWire(fetchImpl, options.api, event, input, callOptions)
|
|
128
|
+
: undefined,
|
|
129
|
+
},
|
|
130
|
+
) as ActorClientCall<D, View>
|
|
131
|
+
return { call }
|
|
132
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
/**
|
|
3
|
+
* experimental-a2/actor/react — use an actor from React: live state
|
|
4
|
+
* plus typed calls, one hook. A thin wrapper: the state pump follows
|
|
5
|
+
* the SSE that `handle.fetch` serves (frontier resume, reconnection,
|
|
6
|
+
* heartbeat watchdog — both ends library code), and `call` is
|
|
7
|
+
* `createActorClient`'s typed surface (the framework-agnostic
|
|
8
|
+
* primitive for everything that isn't React).
|
|
9
|
+
*
|
|
10
|
+
* ```tsx
|
|
11
|
+
* // server component
|
|
12
|
+
* const initial = await vault.actor(id).state()
|
|
13
|
+
* // client component — typed by the server module, erased at build
|
|
14
|
+
* const { state, call, connection } = useActor<typeof vault>({
|
|
15
|
+
* api: `/vault/${id}`, // your route: auth, then handle.fetch
|
|
16
|
+
* id,
|
|
17
|
+
* initial,
|
|
18
|
+
* })
|
|
19
|
+
* await call.withdraw({ amount: 60 }) // ActorRefusedError on refusal
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* The fold is library code (last write wins over state commits), so no
|
|
23
|
+
* reducers, schemas, or contracts appear in browser code. Live, not
|
|
24
|
+
* optimistic: handlers are server code deciding against serialized
|
|
25
|
+
* fresh state, so a local guess would be wrong exactly when the actor
|
|
26
|
+
* matters. On a view-projected mount, pass the projected shape as the
|
|
27
|
+
* second type argument — `useActor<typeof vault, PublicVault>` — and
|
|
28
|
+
* `initial`, `state`, and call answers all carry it.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { ACTOR_STATE_EVENT, openPresenceDefs } from './actor-shared.ts'
|
|
32
|
+
import type { ActorPresenceMap, ActorPresenceValues } from './actor-shared.ts'
|
|
33
|
+
import {
|
|
34
|
+
createActorClient,
|
|
35
|
+
type ActorClientCall,
|
|
36
|
+
type ActorStateOf,
|
|
37
|
+
type AnyActorDefinition,
|
|
38
|
+
} from './actor-client.ts'
|
|
39
|
+
import { createClient } from './client.ts'
|
|
40
|
+
import type { A2Client, Connection } from './client.ts'
|
|
41
|
+
import type { ContractEvent, EventDefs, PresenceDefs } from './contract.ts'
|
|
42
|
+
import type { Reducer } from './reducer.ts'
|
|
43
|
+
import { useSession } from './react.ts'
|
|
44
|
+
|
|
45
|
+
/** The server-to-client handoff: `await def.actor(id).state()`. */
|
|
46
|
+
export type ActorSnapshot<S> = { state: S; index: number }
|
|
47
|
+
|
|
48
|
+
export type UseActorOptions<View> = {
|
|
49
|
+
/** The route whose handler delegates to `handle.fetch` for this instance. */
|
|
50
|
+
api: string
|
|
51
|
+
/** The instance id. */
|
|
52
|
+
id: string
|
|
53
|
+
/** The server-rendered fold — the stream resumes after `index`. */
|
|
54
|
+
initial: ActorSnapshot<View>
|
|
55
|
+
/** This client's presence identity — required to call `setPresence`. */
|
|
56
|
+
participant?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The definition's presence vocabulary, `never` when undeclared. */
|
|
60
|
+
type PresenceOf<D extends AnyActorDefinition> = D extends {
|
|
61
|
+
protocol?: infer P
|
|
62
|
+
}
|
|
63
|
+
? NonNullable<P> extends { presence: infer Values extends object }
|
|
64
|
+
? Values
|
|
65
|
+
: never
|
|
66
|
+
: never
|
|
67
|
+
|
|
68
|
+
export type UseActorResult<D extends AnyActorDefinition, View> = {
|
|
69
|
+
/** The actor's state, live: every commit streams in and replaces it. */
|
|
70
|
+
state: View
|
|
71
|
+
/** The stream frontier. */
|
|
72
|
+
index: number
|
|
73
|
+
connection: Connection
|
|
74
|
+
/** Typed calls over POST — the response is the answer. */
|
|
75
|
+
call: ActorClientCall<D, View>
|
|
76
|
+
/** The state-commit feed this browser has observed. */
|
|
77
|
+
events: ContractEvent<EventDefs>[]
|
|
78
|
+
/**
|
|
79
|
+
* The replicated presence map: participant → field → latest value.
|
|
80
|
+
* Peer-authored — render values like user input. Empty (and
|
|
81
|
+
* `setPresence` untyped-callable-with-nothing) when the protocol
|
|
82
|
+
* declares no presence.
|
|
83
|
+
*/
|
|
84
|
+
presence: ActorPresenceMap<PresenceOf<D>>
|
|
85
|
+
/**
|
|
86
|
+
* Announce this client's presence fields (throttled, resent on
|
|
87
|
+
* reconnect; `null` clears a field). Requires `participant`.
|
|
88
|
+
*/
|
|
89
|
+
setPresence: (values: ActorPresenceValues<PresenceOf<D>>) => void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The follower fold, untyped on purpose: the state type comes from the
|
|
94
|
+
* caller's `initial` handoff, and the fold never inspects state.
|
|
95
|
+
* `initialState` is never rendered — `initial` is required, so every
|
|
96
|
+
* session hydrates before it connects.
|
|
97
|
+
*/
|
|
98
|
+
const followerReducer: Reducer<EventDefs, unknown, PresenceDefs> = {
|
|
99
|
+
name: 'a2.actor.follower.v1',
|
|
100
|
+
events: {},
|
|
101
|
+
// The open vocabulary ('*' catch-all): the browser cannot know a
|
|
102
|
+
// server-defined field list, so the server stays the authority and
|
|
103
|
+
// the protocol types honest callers at compile time.
|
|
104
|
+
presence: openPresenceDefs,
|
|
105
|
+
fold: (state, event) =>
|
|
106
|
+
event.type === ACTOR_STATE_EVENT
|
|
107
|
+
? (event.payload as { state: unknown }).state
|
|
108
|
+
: state,
|
|
109
|
+
initialState: undefined,
|
|
110
|
+
stateSchema: undefined,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const clients = new Map<string, A2Client<EventDefs, unknown, PresenceDefs>>()
|
|
114
|
+
|
|
115
|
+
/** Use one actor instance: live state plus typed calls. No provider. */
|
|
116
|
+
export function useActor<D extends AnyActorDefinition, View = ActorStateOf<D>>(
|
|
117
|
+
options: UseActorOptions<View>,
|
|
118
|
+
): UseActorResult<D, View> {
|
|
119
|
+
const { api } = options
|
|
120
|
+
let client = clients.get(api)
|
|
121
|
+
if (!client) {
|
|
122
|
+
client = createClient({ reducer: followerReducer, api })
|
|
123
|
+
clients.set(api, client)
|
|
124
|
+
}
|
|
125
|
+
const session = useSession(client, options.id, {
|
|
126
|
+
hydrate: options.initial,
|
|
127
|
+
...(options.participant === undefined
|
|
128
|
+
? {}
|
|
129
|
+
: { participant: options.participant }),
|
|
130
|
+
})
|
|
131
|
+
const { call } = createActorClient<D, View>({ api })
|
|
132
|
+
return {
|
|
133
|
+
state: session.state as View,
|
|
134
|
+
index: session.index,
|
|
135
|
+
connection: session.connection,
|
|
136
|
+
call,
|
|
137
|
+
events: session.events,
|
|
138
|
+
presence: session.presence as ActorPresenceMap<PresenceOf<D>>,
|
|
139
|
+
setPresence: session.setPresence as (
|
|
140
|
+
values: ActorPresenceValues<PresenceOf<D>>,
|
|
141
|
+
) => void,
|
|
142
|
+
}
|
|
143
|
+
}
|