experimental-a2 0.8.1 → 0.10.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/CHANGELOG.md +42 -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-DI7J5upy.js +127 -0
- package/dist/actor-shared-DI7J5upy.js.map +1 -0
- package/dist/actor-shared-USo5MyuF.d.ts +136 -0
- package/dist/actor-shared-USo5MyuF.d.ts.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 +14 -8
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +238 -58
- 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-DjPhHnbI.d.ts → server-DgCrSuhB.d.ts} +5 -3
- package/dist/server-DgCrSuhB.d.ts.map +1 -0
- package/dist/{server-B2XNevQA.js → server-DlLyvaSH.js} +140 -81
- package/dist/server-DlLyvaSH.js.map +1 -0
- 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/10-transports.mdx +72 -22
- package/docs/guides/meta.ts +1 -0
- package/docs/index.mdx +3 -0
- package/docs/reference/01-api.mdx +30 -12
- package/docs/reference/02-errors.mdx +33 -0
- package/docs/reference/meta.ts +1 -0
- package/examples/playground/app/page.tsx +10 -1
- 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/package.json +1 -1
- package/package.json +7 -1
- 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 +341 -88
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -1
- package/src/server-fetch.ts +51 -23
- package/src/server.ts +13 -3
- package/src/session-socket.ts +216 -81
- package/dist/contract-jIfaR085.d.ts.map +0 -1
- package/dist/server-B2XNevQA.js.map +0 -1
- package/dist/server-DjPhHnbI.d.ts.map +0 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { ActorRefusedError } from 'experimental-a2/actor/client'
|
|
5
|
+
import { useActor } from 'experimental-a2/actor/react'
|
|
6
|
+
import type { ActorSnapshot } from 'experimental-a2/actor/react'
|
|
7
|
+
import type { vault, VaultState } from '@/app/vault/server'
|
|
8
|
+
import { ActivityFeed } from '@/app/components/activity-feed'
|
|
9
|
+
import { ConnectionPill } from '@/app/components/connection-pill'
|
|
10
|
+
|
|
11
|
+
const ACTIVITY_LIMIT = 50
|
|
12
|
+
|
|
13
|
+
export function VaultClient({
|
|
14
|
+
vaultId,
|
|
15
|
+
initial,
|
|
16
|
+
}: {
|
|
17
|
+
vaultId: string
|
|
18
|
+
initial: ActorSnapshot<VaultState>
|
|
19
|
+
}): ReactNode {
|
|
20
|
+
const [participant] = useState(
|
|
21
|
+
() => `guest-${Math.random().toString(36).slice(2, 8)}`,
|
|
22
|
+
)
|
|
23
|
+
const { state, call, events, index, connection, presence, setPresence } =
|
|
24
|
+
useActor<typeof vault>({
|
|
25
|
+
api: `/vault/${vaultId}`,
|
|
26
|
+
id: vaultId,
|
|
27
|
+
initial,
|
|
28
|
+
participant,
|
|
29
|
+
})
|
|
30
|
+
const [answer, setAnswer] = useState<string | null>(null)
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setPresence({ viewing: true })
|
|
34
|
+
}, [setPresence])
|
|
35
|
+
const here = Object.keys(presence).length
|
|
36
|
+
|
|
37
|
+
const run = async (invoke: () => Promise<unknown>): Promise<void> => {
|
|
38
|
+
try {
|
|
39
|
+
await invoke()
|
|
40
|
+
setAnswer(null)
|
|
41
|
+
} catch (err) {
|
|
42
|
+
setAnswer(
|
|
43
|
+
err instanceof ActorRefusedError
|
|
44
|
+
? err.message
|
|
45
|
+
: err instanceof Error
|
|
46
|
+
? err.message
|
|
47
|
+
: String(err),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// deployed state may predate the `pending` field
|
|
53
|
+
const pending = Object.entries(state.pending ?? {})
|
|
54
|
+
const visibleEvents = events.slice(-ACTIVITY_LIMIT)
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<article>
|
|
58
|
+
<header className="session-header">
|
|
59
|
+
<h1>Vault</h1>
|
|
60
|
+
<ConnectionPill connection={connection} />
|
|
61
|
+
<span className="badge live">shared</span>
|
|
62
|
+
{here > 1 ? <span className="badge">{here} viewing</span> : null}
|
|
63
|
+
</header>
|
|
64
|
+
<p className="lede">
|
|
65
|
+
One durable actor per vault id — serial brain, concurrent muscle. A
|
|
66
|
+
withdrawal is a saga: the action <em>reserves</em> instantly (balance
|
|
67
|
+
drops, a pending transfer appears), a <em>task</em> runs the slow
|
|
68
|
+
2-second transfer off the lane, and its result comes back as a message
|
|
69
|
+
that <em>settles</em> or <em>refunds</em>. The lane is never blocked:
|
|
70
|
+
keep depositing while transfers are in flight.
|
|
71
|
+
</p>
|
|
72
|
+
|
|
73
|
+
<section className="counter-panel" aria-label="Vault controls">
|
|
74
|
+
<output className="counter-value" aria-live="polite">
|
|
75
|
+
${state.balance}
|
|
76
|
+
</output>
|
|
77
|
+
<div className="counter-actions">
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={() => void run(() => call.deposit({ amount: 25 }))}
|
|
81
|
+
>
|
|
82
|
+
Deposit $25
|
|
83
|
+
</button>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
className="secondary"
|
|
87
|
+
onClick={() => void run(() => call.withdraw({ amount: 60 }))}
|
|
88
|
+
>
|
|
89
|
+
Withdraw $60
|
|
90
|
+
</button>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
className="secondary"
|
|
94
|
+
onClick={() => void run(() => call.withdraw({ amount: 19 }))}
|
|
95
|
+
>
|
|
96
|
+
Withdraw $19 (bounces)
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
<p className="hint" aria-live="polite">
|
|
100
|
+
{pending.length > 0
|
|
101
|
+
? `${pending.length} transfer${pending.length === 1 ? '' : 's'} in flight: ${pending
|
|
102
|
+
.map(([, amount]) => `$${amount}`)
|
|
103
|
+
.join(', ')}`
|
|
104
|
+
: `${state.deposits} deposits, ${state.withdrawals} settled withdrawals`}
|
|
105
|
+
</p>
|
|
106
|
+
</section>
|
|
107
|
+
|
|
108
|
+
{answer ? <p className="error">refused: {answer}</p> : null}
|
|
109
|
+
<p className="hint">
|
|
110
|
+
Watch the state plane below narrate the saga: <code>withdraw</code> (the
|
|
111
|
+
reserve commits with the transfer trigger, atomically), then two seconds
|
|
112
|
+
later <code>settle</code> — or <code>refund</code> for amounts ending in
|
|
113
|
+
9, the demo bank's bounce policy. Withdrawing more than the balance
|
|
114
|
+
is refused instantly by the guard; the reserve means in-flight transfers
|
|
115
|
+
can never oversell the balance, from any number of tabs.
|
|
116
|
+
</p>
|
|
117
|
+
|
|
118
|
+
<ActivityFeed events={visibleEvents} index={index} />
|
|
119
|
+
{events.length > ACTIVITY_LIMIT ? (
|
|
120
|
+
<p className="hint">Showing the latest {ACTIVITY_LIMIT} events.</p>
|
|
121
|
+
) : null}
|
|
122
|
+
</article>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vault saga, tested against a real (memory) store — the whole
|
|
3
|
+
* durable pipeline runs: appends, lanes, claims, atomic commits,
|
|
4
|
+
* concurrent tasks. Nothing is mocked; the store and the fake bank's
|
|
5
|
+
* latency are injected through the factory, and every test gets an
|
|
6
|
+
* isolated vault.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest'
|
|
9
|
+
import { ActorRefusedError } from 'experimental-a2/actor/client'
|
|
10
|
+
import { memory } from 'experimental-a2/store-memory'
|
|
11
|
+
import { createVault } from './vault'
|
|
12
|
+
|
|
13
|
+
const makeVault = (transferMs = 25) =>
|
|
14
|
+
createVault({ store: memory(), transferMs })
|
|
15
|
+
|
|
16
|
+
describe('vault saga', () => {
|
|
17
|
+
it('reserve → transfer → settle: the happy path', async () => {
|
|
18
|
+
const vault = makeVault().actor('acct')
|
|
19
|
+
await vault.call.deposit({ amount: 100 })
|
|
20
|
+
|
|
21
|
+
// the withdraw answers at reserve time: balance already down,
|
|
22
|
+
// the transfer visible as pending
|
|
23
|
+
const reserved = await vault.call.withdraw({ amount: 60 })
|
|
24
|
+
expect(reserved.state.balance).toBe(40)
|
|
25
|
+
expect(Object.values(reserved.state.pending)).toEqual([60])
|
|
26
|
+
expect(reserved.state.withdrawals).toBe(0)
|
|
27
|
+
|
|
28
|
+
// the settle arrives as a message once the transfer completes
|
|
29
|
+
await expect
|
|
30
|
+
.poll(async () => (await vault.state()).state, { timeout: 5_000 })
|
|
31
|
+
.toEqual({ balance: 40, deposits: 1, withdrawals: 1, pending: {} })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('reserve → transfer bounces → refund: the compensation path', async () => {
|
|
35
|
+
const vault = makeVault().actor('acct')
|
|
36
|
+
await vault.call.deposit({ amount: 100 })
|
|
37
|
+
|
|
38
|
+
const reserved = await vault.call.withdraw({ amount: 19 }) // ends in 9: bounces
|
|
39
|
+
expect(reserved.state.balance).toBe(81)
|
|
40
|
+
|
|
41
|
+
await expect
|
|
42
|
+
.poll(async () => (await vault.state()).state, { timeout: 5_000 })
|
|
43
|
+
.toEqual({ balance: 100, deposits: 1, withdrawals: 0, pending: {} })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('the reserve prevents overselling while a transfer is in flight', async () => {
|
|
47
|
+
// a slow bank, so the second withdraw races the in-flight transfer
|
|
48
|
+
const vault = makeVault(500).actor('acct')
|
|
49
|
+
await vault.call.deposit({ amount: 100 })
|
|
50
|
+
|
|
51
|
+
await vault.call.withdraw({ amount: 60 })
|
|
52
|
+
// the transfer has not settled — but the money is already reserved
|
|
53
|
+
await expect(vault.call.withdraw({ amount: 60 })).rejects.toThrow(
|
|
54
|
+
'insufficient funds: the balance is 40',
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('the lane stays free while transfers are in flight', async () => {
|
|
59
|
+
const vault = makeVault(500).actor('acct')
|
|
60
|
+
await vault.call.deposit({ amount: 100 })
|
|
61
|
+
await vault.call.withdraw({ amount: 60 })
|
|
62
|
+
|
|
63
|
+
// the deposit answers immediately, while pending proves the
|
|
64
|
+
// transfer is still running — the task holds no lane
|
|
65
|
+
const during = await vault.call.deposit({ amount: 5 })
|
|
66
|
+
expect(during.state.balance).toBe(45)
|
|
67
|
+
expect(Object.keys(during.state.pending)).toHaveLength(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('interleavings reorder but never corrupt: the mixed scenario', async () => {
|
|
71
|
+
const vault = makeVault().actor('acct')
|
|
72
|
+
await vault.call.deposit({ amount: 100 })
|
|
73
|
+
await vault.call.withdraw({ amount: 19 }) // will bounce → refund
|
|
74
|
+
await vault.call.deposit({ amount: 25 })
|
|
75
|
+
await vault.call.withdraw({ amount: 60 }) // will settle
|
|
76
|
+
|
|
77
|
+
// whatever order the two transfers resolve in, the final state is
|
|
78
|
+
// the same — every decision ran serialized against fresh state
|
|
79
|
+
await expect
|
|
80
|
+
.poll(async () => (await vault.state()).state, { timeout: 5_000 })
|
|
81
|
+
.toEqual({
|
|
82
|
+
balance: 65, // 100 − 19 + 25 − 60 + 19 (refund)
|
|
83
|
+
deposits: 2,
|
|
84
|
+
withdrawals: 1,
|
|
85
|
+
pending: {},
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('finalizers are idempotent: a manual refund wins, the late settle no-ops', async () => {
|
|
90
|
+
const vault = makeVault(300).actor('acct')
|
|
91
|
+
await vault.call.deposit({ amount: 100 })
|
|
92
|
+
|
|
93
|
+
const reserved = await vault.call.withdraw({ amount: 60 })
|
|
94
|
+
const ref = Object.keys(reserved.state.pending)[0]!
|
|
95
|
+
|
|
96
|
+
// support intervenes before the bank answers
|
|
97
|
+
const refunded = await vault.call.refund({ ref })
|
|
98
|
+
expect(refunded.state.balance).toBe(100)
|
|
99
|
+
expect(refunded.state.pending).toEqual({})
|
|
100
|
+
|
|
101
|
+
// the transfer later settles into a void: pending[ref] is gone, so
|
|
102
|
+
// the settle no-ops — no double-credit, no phantom withdrawal
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
104
|
+
const { state } = await vault.state()
|
|
105
|
+
expect(state).toEqual({
|
|
106
|
+
balance: 100,
|
|
107
|
+
deposits: 1,
|
|
108
|
+
withdrawals: 0,
|
|
109
|
+
pending: {},
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('state predating the pending field is defaulted in code (deploy drift)', async () => {
|
|
114
|
+
const vault = makeVault().actor('legacy')
|
|
115
|
+
// seed a commit written by an older deployment: no `pending` field
|
|
116
|
+
await vault.session.append({
|
|
117
|
+
type: 'a2.actor.state',
|
|
118
|
+
payload: {
|
|
119
|
+
state: { balance: 100, deposits: 1, withdrawals: 0 },
|
|
120
|
+
event: 'legacy',
|
|
121
|
+
message: 'legacy-1',
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
// new code reads the old shape and defaults the new field
|
|
125
|
+
const reserved = await vault.call.withdraw({ amount: 60 })
|
|
126
|
+
expect(reserved.state.balance).toBe(40)
|
|
127
|
+
expect(Object.keys(reserved.state.pending)).toHaveLength(1)
|
|
128
|
+
await expect
|
|
129
|
+
.poll(async () => (await vault.state()).state.pending, {
|
|
130
|
+
timeout: 5_000,
|
|
131
|
+
})
|
|
132
|
+
.toEqual({})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('guards refuse garbage with an answer, not a retry', async () => {
|
|
136
|
+
const vault = makeVault().actor('acct')
|
|
137
|
+
await expect(vault.call.withdraw({ amount: -5 })).rejects.toThrowError(
|
|
138
|
+
ActorRefusedError,
|
|
139
|
+
)
|
|
140
|
+
await expect(vault.call.withdraw({ amount: -5 })).rejects.toThrow(
|
|
141
|
+
'amount must be between 1 and 1,000,000',
|
|
142
|
+
)
|
|
143
|
+
// the refusals are history, not corruption
|
|
144
|
+
const { state } = await vault.state()
|
|
145
|
+
expect(state.balance).toBe(0)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "experimental-a2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -39,6 +39,12 @@
|
|
|
39
39
|
"browser": "./dist/ai-server.browser.js",
|
|
40
40
|
"default": "./dist/ai-server.js"
|
|
41
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",
|
|
42
48
|
"./store-memory": "./dist/store-memory.js",
|
|
43
49
|
"./store-sqlite": "./dist/store-sqlite.js",
|
|
44
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
|
+
}
|