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.
Files changed (98) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/actor-client.d.ts +46 -0
  3. package/dist/actor-client.d.ts.map +1 -0
  4. package/dist/actor-client.js +54 -0
  5. package/dist/actor-client.js.map +1 -0
  6. package/dist/actor-react.d.ts +54 -0
  7. package/dist/actor-react.d.ts.map +1 -0
  8. package/dist/actor-react.js +79 -0
  9. package/dist/actor-react.js.map +1 -0
  10. package/dist/actor-shared-DI7J5upy.js +127 -0
  11. package/dist/actor-shared-DI7J5upy.js.map +1 -0
  12. package/dist/actor-shared-USo5MyuF.d.ts +136 -0
  13. package/dist/actor-shared-USo5MyuF.d.ts.map +1 -0
  14. package/dist/actor.browser.d.ts +1 -0
  15. package/dist/actor.browser.js +13 -0
  16. package/dist/actor.browser.js.map +1 -0
  17. package/dist/actor.d.ts +176 -0
  18. package/dist/actor.d.ts.map +1 -0
  19. package/dist/actor.js +437 -0
  20. package/dist/actor.js.map +1 -0
  21. package/dist/ai-server.d.ts +2 -2
  22. package/dist/ai-server.js +2 -2
  23. package/dist/ai.d.ts +2 -2
  24. package/dist/client.d.ts +14 -8
  25. package/dist/client.d.ts.map +1 -1
  26. package/dist/client.js +238 -58
  27. package/dist/client.js.map +1 -1
  28. package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
  29. package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
  30. package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
  31. package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
  32. package/dist/index.d.ts +16 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -2
  35. package/dist/react.d.ts +1 -1
  36. package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
  37. package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
  38. package/dist/scheduler-qstash.d.ts +2 -2
  39. package/dist/scheduler-qstash.js +2 -2
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +1 -1
  42. package/dist/{server-DjPhHnbI.d.ts → server-DgCrSuhB.d.ts} +5 -3
  43. package/dist/server-DgCrSuhB.d.ts.map +1 -0
  44. package/dist/{server-B2XNevQA.js → server-DlLyvaSH.js} +140 -81
  45. package/dist/server-DlLyvaSH.js.map +1 -0
  46. package/dist/server.d.ts +3 -3
  47. package/dist/server.js +1 -1
  48. package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
  49. package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
  50. package/dist/store-memory.d.ts +1 -1
  51. package/dist/store-memory.js +2 -2
  52. package/dist/store-postgres.d.ts +1 -1
  53. package/dist/store-postgres.js +2 -2
  54. package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
  55. package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
  56. package/dist/store-redis-http.d.ts +1 -1
  57. package/dist/store-redis-http.js +2 -2
  58. package/dist/store-redis.d.ts +1 -1
  59. package/dist/store-redis.js +2 -2
  60. package/dist/store-sqlite.d.ts +1 -1
  61. package/dist/store-sqlite.js +2 -2
  62. package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
  63. package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
  64. package/docs/actors/01-introduction.mdx +189 -0
  65. package/docs/actors/02-concurrency.mdx +154 -0
  66. package/docs/actors/03-timers.mdx +120 -0
  67. package/docs/actors/04-routes.mdx +352 -0
  68. package/docs/actors/meta.ts +1 -0
  69. package/docs/concepts/meta.ts +1 -0
  70. package/docs/guides/10-transports.mdx +72 -22
  71. package/docs/guides/meta.ts +1 -0
  72. package/docs/index.mdx +3 -0
  73. package/docs/reference/01-api.mdx +30 -12
  74. package/docs/reference/02-errors.mdx +33 -0
  75. package/docs/reference/meta.ts +1 -0
  76. package/examples/playground/app/page.tsx +10 -1
  77. package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
  78. package/examples/playground/app/vault/page.tsx +12 -0
  79. package/examples/playground/app/vault/server.ts +9 -0
  80. package/examples/playground/app/vault/vault-client.tsx +124 -0
  81. package/examples/playground/app/vault/vault.test.ts +147 -0
  82. package/examples/playground/app/vault/vault.ts +119 -0
  83. package/examples/playground/package.json +1 -1
  84. package/package.json +7 -1
  85. package/src/actor-client.ts +132 -0
  86. package/src/actor-react.ts +143 -0
  87. package/src/actor-shared.ts +356 -0
  88. package/src/actor.browser.ts +12 -0
  89. package/src/actor.ts +914 -0
  90. package/src/client.ts +341 -88
  91. package/src/errors.ts +15 -0
  92. package/src/index.ts +1 -1
  93. package/src/server-fetch.ts +51 -23
  94. package/src/server.ts +13 -3
  95. package/src/session-socket.ts +216 -81
  96. package/dist/contract-jIfaR085.d.ts.map +0 -1
  97. package/dist/server-B2XNevQA.js.map +0 -1
  98. package/dist/server-DjPhHnbI.d.ts.map +0 -1
@@ -0,0 +1,9 @@
1
+ import 'server-only'
2
+ import { store } from '@/lib/store'
3
+ import { createVault } from './vault'
4
+
5
+ export type { VaultState } from './vault'
6
+
7
+ export const vault: ReturnType<typeof createVault> = createVault(
8
+ store ? { store } : {},
9
+ )
@@ -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&apos;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
+ }
@@ -22,7 +22,7 @@
22
22
  "@vercel/sandbox": "^3.0.0",
23
23
  "ai": "^7.0.58",
24
24
  "codemirror": "^6.0.2",
25
- "experimental-a2": "0.8.1",
25
+ "experimental-a2": "0.10.0",
26
26
  "ioredis": "^5.9.0",
27
27
  "next": "^16.3.0",
28
28
  "pg": "^8.16.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.8.1",
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
+ }