experimental-a2 0.10.0 → 0.11.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 (121) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/actor-D_54lz_1.d.ts +310 -0
  3. package/dist/actor-D_54lz_1.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +13 -4
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +63 -7
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -4
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +16 -2
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/actor.d.ts +2 -176
  13. package/dist/actor.js +13 -2
  14. package/dist/actor.js.map +1 -1
  15. package/dist/ai-server.d.ts +1 -1
  16. package/dist/ai-server.js +2 -2
  17. package/dist/ai.d.ts +1 -1
  18. package/dist/ai.js +1 -1
  19. package/dist/client-Bf6uSEAk.js +1342 -0
  20. package/dist/client-Bf6uSEAk.js.map +1 -0
  21. package/dist/client-P_NNNRM-.d.ts +243 -0
  22. package/dist/client-P_NNNRM-.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -208
  24. package/dist/client.js +2 -1206
  25. package/dist/errors-DCk6ch5n.js.map +1 -1
  26. package/dist/errors-DvhSXnxk.d.ts +28 -0
  27. package/dist/errors-DvhSXnxk.d.ts.map +1 -0
  28. package/dist/index.d.ts +3 -35
  29. package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
  30. package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
  31. package/dist/platform-B4TnJtWu.js +34 -0
  32. package/dist/platform-B4TnJtWu.js.map +1 -0
  33. package/dist/react.d.ts +3 -1
  34. package/dist/react.d.ts.map +1 -1
  35. package/dist/react.js +2 -1
  36. package/dist/react.js.map +1 -1
  37. package/dist/scheduler-qstash.d.ts +2 -2
  38. package/dist/scheduler-qstash.js +3 -2
  39. package/dist/scheduler-qstash.js.map +1 -1
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +2 -2
  42. package/dist/{server-DlLyvaSH.js → server-Dkz2a84E.js} +165 -62
  43. package/dist/server-Dkz2a84E.js.map +1 -0
  44. package/dist/{server-DgCrSuhB.d.ts → server-DwPrMqHB.d.ts} +2 -2
  45. package/dist/{server-DgCrSuhB.d.ts.map → server-DwPrMqHB.d.ts.map} +1 -1
  46. package/dist/server.d.ts +2 -2
  47. package/dist/server.js +1 -1
  48. package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
  49. package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
  50. package/dist/store-N8PXxDAS.js.map +1 -1
  51. package/dist/store-memory.d.ts +1 -1
  52. package/dist/store-memory.js +1 -1
  53. package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
  54. package/dist/store-polling-CmxUbV93.js.map +1 -0
  55. package/dist/store-postgres.d.ts +6 -4
  56. package/dist/store-postgres.d.ts.map +1 -1
  57. package/dist/store-postgres.js +701 -53
  58. package/dist/store-postgres.js.map +1 -1
  59. package/dist/store-presence-polling-C7-XZyW9.js +94 -0
  60. package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
  61. package/dist/store-redis-http.d.ts +2 -2
  62. package/dist/store-redis-http.d.ts.map +1 -1
  63. package/dist/store-redis-http.js +184 -38
  64. package/dist/store-redis-http.js.map +1 -1
  65. package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
  66. package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
  67. package/dist/store-redis.d.ts +5 -13
  68. package/dist/store-redis.d.ts.map +1 -1
  69. package/dist/store-redis.js +27 -271
  70. package/dist/store-redis.js.map +1 -1
  71. package/dist/store-sqlite.d.ts +1 -1
  72. package/dist/store-sqlite.js +1 -1
  73. package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
  74. package/dist/wire-BO5wWCb1.js.map +1 -0
  75. package/docs/actors/04-routes.mdx +40 -8
  76. package/docs/guides/03-react.mdx +52 -10
  77. package/docs/guides/05-production.mdx +54 -5
  78. package/docs/guides/09-presence.mdx +12 -7
  79. package/docs/guides/10-transports.mdx +15 -2
  80. package/docs/reference/01-api.mdx +60 -16
  81. package/docs/reference/02-errors.mdx +45 -11
  82. package/examples/playground/package.json +1 -1
  83. package/package.json +1 -1
  84. package/src/actor-client.ts +114 -14
  85. package/src/actor-react.ts +18 -3
  86. package/src/actor.ts +16 -1
  87. package/src/client-errors.ts +51 -0
  88. package/src/client.ts +253 -96
  89. package/src/errors.ts +4 -9
  90. package/src/index.ts +1 -1
  91. package/src/internal.ts +1 -1
  92. package/src/postgres-notification-scope.ts +134 -0
  93. package/src/postgres-notifications.ts +244 -0
  94. package/src/postgres-pool.ts +65 -0
  95. package/src/postgres-resources.ts +185 -0
  96. package/src/presence-recovery.ts +120 -0
  97. package/src/react.ts +3 -0
  98. package/src/redis-http-subscriptions.ts +199 -0
  99. package/src/server.ts +146 -19
  100. package/src/session-socket.ts +25 -7
  101. package/src/sse.ts +17 -0
  102. package/src/store-polling.ts +32 -12
  103. package/src/store-postgres.ts +292 -75
  104. package/src/store-presence-polling.ts +125 -0
  105. package/src/store-redis-core.ts +37 -30
  106. package/src/store-redis-http.ts +51 -45
  107. package/src/store-redis-notify.ts +464 -0
  108. package/src/store-redis.ts +9 -364
  109. package/src/store.ts +3 -4
  110. package/src/stream-activity.ts +32 -0
  111. package/src/wire.ts +39 -15
  112. package/dist/actor-shared-USo5MyuF.d.ts +0 -136
  113. package/dist/actor-shared-USo5MyuF.d.ts.map +0 -1
  114. package/dist/actor.d.ts.map +0 -1
  115. package/dist/client.d.ts.map +0 -1
  116. package/dist/client.js.map +0 -1
  117. package/dist/index.d.ts.map +0 -1
  118. package/dist/server-DlLyvaSH.js.map +0 -1
  119. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  120. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  121. package/dist/wire--yji6mO3.js.map +0 -1
@@ -59,7 +59,8 @@ invalidates, and the next read refolds from raw events. See
59
59
 
60
60
  ### `A2Error`
61
61
 
62
- Everything A2 throws. One class, discriminated by `code`. See
62
+ The shared base for A2 failures, discriminated by `code`. The session
63
+ client exposes its `A2ClientError` subclass. See
63
64
  [Errors](/reference/errors).
64
65
 
65
66
  ## `experimental-a2/server`
@@ -793,25 +794,27 @@ transition and the destination should adopt the same optimistic session.
793
794
  | --------------- | ------------------------------------------------------ |
794
795
  | `sessionId` | which session to subscribe to |
795
796
  | `initialState` | server-rendered state |
796
- | `initialIndex` | the fold's frontier, where the stream resumes |
797
+ | `initialIndex` | the fold's frontier, where a new session starts streaming |
797
798
  | `initialEvents` | optional earlier raw events for a history UI |
798
799
  | `participant` | overrides the client's `participant`; one of the two is required to call `setPresence` |
799
800
 
800
801
  Opens the stream on mount, closes it on unmount, reconnects with
801
- backoff from the current frontier. `participant` binds at the session's
802
+ backoff from its last streamed event. `participant` binds at the session's
802
803
  first resolution; a changed provider prop is ignored for the session's
803
804
  runtime lifetime.
804
805
 
805
806
  `initialState` and `initialIndex` are the complete server-to-client handoff.
806
- The stream resumes after `initialIndex`, including events appended between the
807
- server render and the connection. `initialEvents` only preloads the raw
807
+ For a new session, the stream starts after `initialIndex`, including events
808
+ appended between the server render and the connection. `initialEvents` only preloads the raw
808
809
  `events` feed for a UI that needs entries from before that frontier. It does
809
- not affect state hydration or stream resumption.
810
+ not affect state hydration or stream resumption. Later snapshots advance
811
+ state while preserving the stream's resume position, so delayed events
812
+ still enter the feed without folding into state again.
810
813
 
811
814
  ### `useSession()`
812
815
 
813
816
  ```ts
814
- const { state, push, events, index, loadHistory, history, connection,
817
+ const { state, push, reconnect, events, index, loadHistory, history, connection,
815
818
  presence, setPresence } = useSession()
816
819
  ```
817
820
 
@@ -819,7 +822,7 @@ const { state, push, events, index, loadHistory, history, connection,
819
822
  shared reducer. `events` is the raw event feed: observed, explicitly
820
823
  seeded, or backscrolled. Unless `initialEvents` seeds earlier entries or
821
824
  `loadHistory` fetches them, it begins after `initialIndex`. `index` is
822
- the stream frontier, the `lastSeenIndex` for
825
+ the latest server-confirmed state index, the `lastSeenIndex` for
823
826
  [cancellation](/guides/cancellation).
824
827
 
825
828
  `loadHistory({ before?, limit? })` backscrolls: it fetches a bounded
@@ -828,10 +831,9 @@ query parameters) and merges it into `events`, deduped, ordered, and
828
831
  shared across every handle of the session. It resolves with the events
829
832
  in the requested range. Defaults walk backward 50 at a time from the
830
833
  oldest loaded event; `before` is an exclusive upper bound. After a
831
- hydrate jump (returning to a session whose frontier advanced while
832
- away), default paging still continues from the oldest loaded event;
833
- pass an explicit `before` to fill the gap between the old feed and the
834
- new frontier. It never touches `state` or the optimistic overlay. Calls
834
+ newer snapshot hydrates the session, default paging still continues from
835
+ the oldest loaded event. Pass an explicit `before` to read another range.
836
+ It never touches `state` or the optimistic overlay. Calls
835
837
  serialize per session and already-loaded ranges are not refetched.
836
838
  `history` is the progress: `{ loading, complete, oldestLoaded }`, where
837
839
  `complete` means the feed reaches index 1 (or the log is empty). The `ws` api has no history lane; there `loadHistory`
@@ -856,7 +858,7 @@ contract; it still never folds presence). `setPresence` requires a
856
858
  [Presence](/guides/presence).
857
859
 
858
860
  `push` appends optimistically: validated locally, rolled back on
859
- rejection, retried only for `STORE_UNAVAILABLE`. Awaiting it gives the
861
+ rejection, retried for `STORE_UNAVAILABLE` and transport failures. Awaiting it gives the
860
862
  server ack; the same result carries `confirmed`, a lazy promise for
861
863
  the later moment when the live stream has delivered the batch back and
862
864
  the view shows server truth:
@@ -875,18 +877,31 @@ only for the previous call's ack or final rejection, not for `confirmed`.
875
877
  Other session IDs and clients remain concurrent; they converge on the log's
876
878
  durable order.
877
879
 
878
- A rejected push rejects both promises with the same `A2Error`;
880
+ A rejected push rejects both promises with the same `A2ClientError`;
879
881
  `confirmed` is materialized only when accessed, so ignoring it costs
880
882
  nothing. It also reads as intent: `await push(...).confirmed` is
881
883
  "continue once this is server truth".
882
884
 
885
+ `session.reconnect(): void` immediately restarts a leased subscription,
886
+ including one paused by the reconnection policy,
887
+ from its current cursor. It preserves state, pending writes, and leases, and
888
+ does nothing without an active lease. The React `useSession()` result exposes
889
+ the same `reconnect` function. Use it after a custom REST mutation; it requests
890
+ a restart rather than returning a promise for catch-up completion. A
891
+ multiplexed WebSocket keeps its physical socket and other session lanes;
892
+ a single-session WebSocket replaces its socket.
893
+
883
894
  `connection` is a discriminated union; impossible states are
884
895
  unrepresentable (an `error` only exists while disconnected):
885
896
 
886
897
  ```ts
898
+ // in a client module:
899
+ import type { A2ClientError } from 'experimental-a2/client'
900
+
887
901
  type Connection =
888
902
  | { status: 'idle' }
889
- | { status: 'connecting'; reconnects: number; error: Error | null }
903
+ | { status: 'connecting'; reconnects: number; error: A2ClientError | null }
904
+ | { status: 'paused'; reconnects: number; error: A2ClientError | null }
890
905
  | { status: 'live'; reconnects: number }
891
906
  | { status: 'closed' }
892
907
  ```
@@ -1206,6 +1221,11 @@ createClient(options: {
1206
1221
  api: ClientApi
1207
1222
  gcTime?: number // idle session lifetime; 5 minutes by default
1208
1223
  participant?: string // default presence identity for every session
1224
+ reconnect?: (failure: {
1225
+ sessionId: string
1226
+ error: A2ClientError | null
1227
+ attempt: number
1228
+ }) => number | false
1209
1229
  }): A2Client
1210
1230
 
1211
1231
  type ClientApi =
@@ -1217,6 +1237,30 @@ type ClientApi =
1217
1237
  type SessionUrl = string | ((sessionId: string) => string)
1218
1238
  ```
1219
1239
 
1240
+ `reconnect` runs after a failed connection or a stream ending, including a
1241
+ clean end with `error: null`. Return the delay in milliseconds before the next
1242
+ attempt, or `false` to pause automatic reconnection. Delays must be finite,
1243
+ non-negative, and at most 2,147,483,647 ms. The callback is synchronous; a
1244
+ throw or invalid result pauses the connection with an `A2ClientError`.
1245
+
1246
+ `attempt` starts at 1 for the first retry. It counts consecutive attempts
1247
+ per session and resets when the stream becomes live or `session.reconnect()`
1248
+ explicitly restarts it. It is separate from the lifetime `reconnects` count.
1249
+ Without a callback, A2 retries indefinitely with delays starting at 500 ms,
1250
+ doubling up to five seconds. The callback does not run for an explicit
1251
+ restart, an idle-stream refresh, or a caller closing the stream.
1252
+
1253
+ While paused, `connection.status` is `paused` and its last error remains
1254
+ available. State, hydration, optimistic writes, and connection leases remain
1255
+ intact. Another consumer taking a lease does not resume a paused stream.
1256
+ Call `session.reconnect()` to resume immediately with a fresh attempt count.
1257
+ Releasing the last lease still closes the stream. A later first lease starts
1258
+ a new connection. Push retries are independent of this policy; a push does
1259
+ not resume a paused stream. An acknowledged push's `confirmed` promise waits
1260
+ until streamed events or a newer hydrated snapshot cover the acknowledged
1261
+ state index. Resumption preserves the stream's own position, even when
1262
+ hydration has advanced state while paused.
1263
+
1220
1264
  A `SessionUrl` function runs for each HTTP request or WebSocket connection.
1221
1265
  Use it for session-specific paths. A2 still adds its `sessionId` and read-bound
1222
1266
  query parameters. The function does not authorize that ID. Bind any path scope
@@ -1229,7 +1273,7 @@ subscription with frontier resume and reconnection, the optimistic push
1229
1273
  queue with ack/rollback, and the local fold. `client.session(id, {
1230
1274
  initialState?, initialIndex?, initialEvents?, participant? })` returns a
1231
1275
  handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
1232
- contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
1276
+ contract), `push()`, `loadHistory()`, `connect()`, `reconnect()`, and `close()`.
1233
1277
  `connect()` takes a lease on the live stream and returns its release:
1234
1278
  leases refcount per handle (the first connects, releasing the last
1235
1279
  closes, releasing twice is a no-op), so independent consumers of one
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  title: Errors
3
- description: One error class, stable codes, and clear rules about what append will never throw for.
3
+ description: Stable server and client error codes, transport details, and append guarantees.
4
4
  ---
5
5
 
6
6
  ## `A2Error`
7
7
 
8
- Everything A2 throws is an `A2Error`, a single class discriminated by
9
- `code`, not a subclass hierarchy. One class serializes cleanly across the
10
- push-route boundary and keeps `instanceof` working after bundling.
8
+ A2 failures carry an `A2Error` with a stable `code`. The session client exposes
9
+ `A2ClientError`, which extends `A2Error`, for both server failures and failures
10
+ observed locally. Existing `instanceof A2Error` checks also match client errors.
11
11
 
12
12
  ```ts
13
13
  // e.g. in a route handler:
@@ -45,9 +45,39 @@ export async function POST(req: Request) {
45
45
  | `STORE_UNAVAILABLE` | the store backend failed; nothing was written, original error as `cause` | **yes** |
46
46
  | `STORE_NOT_CONFIGURED` | production boot with no `store` configured (during `next build` page collection, deferred to first use) | no |
47
47
 
48
- Retryability is derivable from the code. `STORE_UNAVAILABLE` is the only one
49
- worth retrying; the rest are deterministic caller bugs, and retrying them
50
- is wasted work.
48
+ Of these server codes, only `STORE_UNAVAILABLE` is automatically retried by
49
+ client pushes. Reconnection has its own caller-configurable policy.
50
+
51
+ ## `A2ClientError`
52
+
53
+ Import `A2ClientError` from `experimental-a2/client`. Connection failures,
54
+ push failures, history read failures, and local A2 validation failures use
55
+ this class. Server failures keep their `code`, `message`, and `details`.
56
+ Unexpected thrown values are retained as `cause`. Invalid API arguments can
57
+ still throw native `TypeError` or `RangeError` exceptions.
58
+
59
+ | Client code | Meaning |
60
+ | --- | --- |
61
+ | `TIMEOUT` | A2 detected a stream stall or an unconfirmed subscription. `phase` is `stream` or `subscribe`; `timeoutMs` is the expired deadline. |
62
+ | `HTTP_ERROR` | An unsuccessful HTTP response without a recognized A2 error body. `status` carries the HTTP status. |
63
+ | `CONNECTION_FAILED` | The transport could not establish or retain a connection. WebSocket close events preserve `closeCode` and `closeReason` when available. |
64
+ | `UNKNOWN` | An unexpected client failure. The original value is available as `cause`. |
65
+
66
+ HTTP failures retain `status` even when they carry a recognized server code.
67
+ A browser WebSocket handshake rejection has no readable HTTP status or body;
68
+ it remains `CONNECTION_FAILED`. A2 does not infer an authorization failure or
69
+ a timeout from an opaque socket error.
70
+
71
+ The stream watchdog detects 35 seconds of silence after connection. A
72
+ multiplexed subscription also has 35 seconds to receive confirmation. There
73
+ is no A2-owned initial connection deadline for HTTP or single-session
74
+ WebSockets. Clean stream termination has no error: the reconnection callback
75
+ and connection snapshot carry `null`.
76
+
77
+ Pushes retain their three-attempt budget for `STORE_UNAVAILABLE` and client
78
+ transport failures. The reconnection callback controls only the live stream.
79
+ Pausing that stream preserves the writer; it does not guarantee that a push
80
+ can succeed while its WebSocket is unavailable.
51
81
 
52
82
  On `PARTIAL_DUPLICATE_BATCH`: retrying an *identical* batch is not an
53
83
  error. It's an idempotent success, and you get the previously appended
@@ -60,7 +90,7 @@ nothing is written.
60
90
 
61
91
  ## NonRetriableError
62
92
 
63
- The second exported error class. Throw it (or a subclass, matched by
93
+ Throw it (or a subclass, matched by
64
94
  `instanceof`) from a handler when the failure is deterministic: the
65
95
  event dead-letters immediately instead of consuming the ten-failure
66
96
  retry budget, because a guard that refuses this attempt will refuse
@@ -110,6 +140,10 @@ with a mapped status: `INVALID_PAYLOAD`, `UNKNOWN_EVENT_TYPE`, and
110
140
  `PARTIAL_DUPLICATE_BATCH` are 400; `FORBIDDEN` is 403;
111
141
  `SUPERSEDED_ATTEMPT` is 409; `STORE_UNAVAILABLE` is 503.
112
142
 
113
- The client's `push` deserializes the body back into an `A2Error`, so client
114
- and server code branch on identical codes. `push` auto-retries only
115
- `STORE_UNAVAILABLE`. `server.fetch` owns the HTTP serialization.
143
+ The session client deserializes the body into an `A2ClientError`, so client
144
+ and server code branch on identical server codes. `server.fetch` owns HTTP
145
+ serialization. Multiplexed WebSocket subscription failures carry the same
146
+ error envelope in their `unsubscribed` frame, alongside the legacy `reason`
147
+ string. Older reason-only frames remain readable as `CONNECTION_FAILED`.
148
+ Unknown server exceptions become `STORE_UNAVAILABLE`; their private causes
149
+ are not sent to clients.
@@ -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.10.0",
25
+ "experimental-a2": "0.11.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.10.0",
3
+ "version": "0.11.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",
@@ -22,6 +22,16 @@ import type { ActorCallOptions, ActorCallResult } from './actor.ts'
22
22
 
23
23
  export { ActorRefusedError }
24
24
 
25
+ export class ActorRequestError extends Error {
26
+ readonly status: number | undefined
27
+
28
+ constructor(message: string, options?: { status?: number; cause?: unknown }) {
29
+ super(message, { cause: options?.cause })
30
+ this.name = 'ActorRequestError'
31
+ this.status = options?.status
32
+ }
33
+ }
34
+
25
35
  /** The loose shape of an `actor()` definition — carried type-only. */
26
36
  export type AnyActorDefinition = {
27
37
  actor(id: string): {
@@ -58,13 +68,15 @@ export type ActorClient<
58
68
  > = {
59
69
  /** Typed calls over POST — the response is the answer. */
60
70
  readonly call: ActorClientCall<D, View>
71
+ readonly state: () => Promise<ActorCallResult<View>>
61
72
  }
62
73
 
63
- export type CreateActorClientOptions = {
74
+ export type CreateActorClientOptions<View = unknown> = {
64
75
  /** The route whose handler delegates to `handle.fetch` for this instance. */
65
76
  api: string
66
77
  /** Injectable transport (tests, custom auth headers). */
67
78
  fetch?: typeof globalThis.fetch
79
+ onSnapshot?: (snapshot: ActorCallResult<View>) => void
68
80
  }
69
81
 
70
82
  const CALL_TIMEOUT_MS = 30_000
@@ -76,7 +88,7 @@ const callOverWire = async (
76
88
  input: unknown,
77
89
  options?: ActorCallOptions,
78
90
  ): Promise<ActorCallResult<unknown>> => {
79
- const response = await fetchImpl(api, {
91
+ const response = await requestOverWire(fetchImpl, api, {
80
92
  method: 'POST',
81
93
  headers: { 'content-type': 'application/json' },
82
94
  body: JSON.stringify({
@@ -86,10 +98,7 @@ const callOverWire = async (
86
98
  }),
87
99
  signal: AbortSignal.timeout(options?.timeoutMs ?? CALL_TIMEOUT_MS),
88
100
  })
89
- const body = (await response.json().catch(() => ({}))) as Record<
90
- string,
91
- unknown
92
- >
101
+ const body = await responseBody(response)
93
102
  if (response.status === 409 && typeof body['error'] === 'string') {
94
103
  throw new ActorRefusedError(
95
104
  typeof body['event'] === 'string' ? body['event'] : event,
@@ -102,7 +111,53 @@ const callOverWire = async (
102
111
  typeof body['error'] === 'string'
103
112
  ? body['error']
104
113
  : `actor call failed (${response.status})`
105
- throw new Error(message)
114
+ throw new ActorRequestError(message, { status: response.status })
115
+ }
116
+ return snapshotBody(body, response.status)
117
+ }
118
+
119
+ const requestOverWire = async (
120
+ fetchImpl: typeof globalThis.fetch,
121
+ api: string,
122
+ init: RequestInit,
123
+ ): Promise<Response> => {
124
+ try {
125
+ return await fetchImpl(api, init)
126
+ } catch (cause) {
127
+ throw new ActorRequestError('actor request did not receive a response', {
128
+ cause,
129
+ })
130
+ }
131
+ }
132
+
133
+ const responseBody = async (
134
+ response: Response,
135
+ ): Promise<Record<string, unknown>> => {
136
+ try {
137
+ const body: unknown = await response.json()
138
+ if (!body || typeof body !== 'object' || Array.isArray(body))
139
+ throw new Error('invalid response')
140
+ return body as Record<string, unknown>
141
+ } catch (cause) {
142
+ throw new ActorRequestError('actor request returned an invalid response', {
143
+ status: response.status,
144
+ cause,
145
+ })
146
+ }
147
+ }
148
+
149
+ const snapshotBody = (
150
+ body: Record<string, unknown>,
151
+ status: number,
152
+ ): ActorCallResult<unknown> => {
153
+ if (
154
+ !('state' in body) ||
155
+ !Number.isSafeInteger(body['index']) ||
156
+ (body['index'] as number) < 0
157
+ ) {
158
+ throw new ActorRequestError('actor request returned an invalid snapshot', {
159
+ status,
160
+ })
106
161
  }
107
162
  return body as ActorCallResult<unknown>
108
163
  }
@@ -116,17 +171,62 @@ const callOverWire = async (
116
171
  export function createActorClient<
117
172
  D extends AnyActorDefinition,
118
173
  View = ActorStateOf<D>,
119
- >(options: CreateActorClientOptions): ActorClient<D, View> {
174
+ >(options: CreateActorClientOptions<View>): ActorClient<D, View> {
120
175
  const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
176
+ const adopt = (snapshot: ActorCallResult<unknown>): ActorCallResult<View> => {
177
+ const result = snapshot as ActorCallResult<View>
178
+ options.onSnapshot?.(result)
179
+ return result
180
+ }
181
+ const state = async (): Promise<ActorCallResult<View>> => {
182
+ const api = options.api.split('#')[0]!
183
+ const separator = api.includes('?') ? '&' : '?'
184
+ const response = await requestOverWire(
185
+ fetchImpl,
186
+ `${api}${separator}snapshot=1`,
187
+ {
188
+ cache: 'no-store',
189
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
190
+ },
191
+ )
192
+ const body = await responseBody(response)
193
+ if (!response.ok) {
194
+ throw new ActorRequestError(
195
+ typeof body['error'] === 'string'
196
+ ? body['error']
197
+ : `actor state read failed (${response.status})`,
198
+ { status: response.status },
199
+ )
200
+ }
201
+ return adopt(snapshotBody(body, response.status))
202
+ }
203
+ const methods = new Map<
204
+ string,
205
+ (
206
+ input?: unknown,
207
+ callOptions?: ActorCallOptions,
208
+ ) => Promise<ActorCallResult<View>>
209
+ >()
121
210
  const call = new Proxy(
122
211
  {},
123
212
  {
124
- get: (_target, event) =>
125
- typeof event === 'string'
126
- ? (input?: unknown, callOptions?: ActorCallOptions) =>
127
- callOverWire(fetchImpl, options.api, event, input, callOptions)
128
- : undefined,
213
+ get: (_target, event) => {
214
+ if (typeof event !== 'string') return undefined
215
+ let method = methods.get(event)
216
+ if (!method) {
217
+ method = (input, callOptions) =>
218
+ callOverWire(
219
+ fetchImpl,
220
+ options.api,
221
+ event,
222
+ input,
223
+ callOptions,
224
+ ).then(adopt)
225
+ methods.set(event, method)
226
+ }
227
+ return method
228
+ },
129
229
  },
130
230
  ) as ActorClientCall<D, View>
131
- return { call }
231
+ return { call, state }
132
232
  }
@@ -41,6 +41,7 @@ import type { A2Client, Connection } from './client.ts'
41
41
  import type { ContractEvent, EventDefs, PresenceDefs } from './contract.ts'
42
42
  import type { Reducer } from './reducer.ts'
43
43
  import { useSession } from './react.ts'
44
+ import { useMemo } from 'react'
44
45
 
45
46
  /** The server-to-client handoff: `await def.actor(id).state()`. */
46
47
  export type ActorSnapshot<S> = { state: S; index: number }
@@ -66,13 +67,14 @@ type PresenceOf<D extends AnyActorDefinition> = D extends {
66
67
  : never
67
68
 
68
69
  export type UseActorResult<D extends AnyActorDefinition, View> = {
69
- /** The actor's state, live: every commit streams in and replaces it. */
70
+ /** The live view, advanced by call answers, refreshes, and stream commits. */
70
71
  state: View
71
- /** The stream frontier. */
72
+ /** The latest adopted state index. */
72
73
  index: number
73
74
  connection: Connection
74
75
  /** Typed calls over POST — the response is the answer. */
75
76
  call: ActorClientCall<D, View>
77
+ refresh: () => Promise<ActorSnapshot<View>>
76
78
  /** The state-commit feed this browser has observed. */
77
79
  events: ContractEvent<EventDefs>[]
78
80
  /**
@@ -128,12 +130,25 @@ export function useActor<D extends AnyActorDefinition, View = ActorStateOf<D>>(
128
130
  ? {}
129
131
  : { participant: options.participant }),
130
132
  })
131
- const { call } = createActorClient<D, View>({ api })
133
+ const { call, state: refresh } = useMemo(
134
+ () =>
135
+ createActorClient<D, View>({
136
+ api,
137
+ onSnapshot: ({ state, index }) => {
138
+ client.session(options.id, {
139
+ initialState: state,
140
+ initialIndex: index,
141
+ })
142
+ },
143
+ }),
144
+ [api, client, options.id],
145
+ )
132
146
  return {
133
147
  state: session.state as View,
134
148
  index: session.index,
135
149
  connection: session.connection,
136
150
  call,
151
+ refresh,
137
152
  events: session.events,
138
153
  presence: session.presence as ActorPresenceMap<PresenceOf<D>>,
139
154
  setPresence: session.setPresence as (
package/src/actor.ts CHANGED
@@ -169,9 +169,10 @@ export type ActorOptions<D extends ActorProtocol> = {
169
169
  /**
170
170
  * One HTTP operation `handle.fetch` is about to serve — the actor
171
171
  * mirror of core's `A2Operation`. `authorize` sees it before any
172
- * write or subscription; returning `false` answers 403.
172
+ * read, write, or subscription; returning `false` answers 403.
173
173
  */
174
174
  export type ActorOperation =
175
+ | { readonly type: 'state'; readonly id: string }
175
176
  | {
176
177
  readonly type: 'stream'
177
178
  readonly id: string
@@ -736,6 +737,19 @@ export function actor<D extends ActorProtocol>(
736
737
 
737
738
  if (request.method === 'GET') {
738
739
  const url = new URL(request.url)
740
+ if (url.searchParams.has('snapshot')) {
741
+ if (!(await authorized({ type: 'state', id }))) return forbidden()
742
+ const result = await readState()
743
+ return Response.json(
744
+ {
745
+ state: fetchOptions?.view
746
+ ? applyView(fetchOptions.view, result.state)
747
+ : result.state,
748
+ index: result.index,
749
+ },
750
+ { headers: { 'cache-control': 'private, no-store' } },
751
+ )
752
+ }
739
753
  const raw = Number(url.searchParams.get('index'))
740
754
  const startAfter = Number.isSafeInteger(raw) && raw >= 0 ? raw : 0
741
755
  if (!(await authorized({ type: 'stream', id, startAfter }))) {
@@ -851,6 +865,7 @@ export function actor<D extends ActorProtocol>(
851
865
  index: result.index,
852
866
  }
853
867
  : result,
868
+ { headers: { 'cache-control': 'private, no-store' } },
854
869
  )
855
870
  } catch (error) {
856
871
  if (error instanceof ActorRefusedError) {
@@ -0,0 +1,51 @@
1
+ import { A2Error, type A2ErrorCode } from './errors.ts'
2
+
3
+ export type A2ClientErrorOptions = {
4
+ details?: unknown
5
+ cause?: unknown
6
+ status?: number
7
+ closeCode?: number
8
+ closeReason?: string
9
+ phase?: 'stream' | 'subscribe'
10
+ timeoutMs?: number
11
+ }
12
+
13
+ export class A2ClientError extends A2Error {
14
+ readonly status: number | undefined
15
+ readonly closeCode: number | undefined
16
+ readonly closeReason: string | undefined
17
+ readonly phase: 'stream' | 'subscribe' | undefined
18
+ readonly timeoutMs: number | undefined
19
+
20
+ constructor(
21
+ code: A2ErrorCode,
22
+ message: string,
23
+ options?: A2ClientErrorOptions,
24
+ ) {
25
+ super(code, message, options)
26
+ this.name = 'A2ClientError'
27
+ this.status = options?.status
28
+ this.closeCode = options?.closeCode
29
+ this.closeReason = options?.closeReason
30
+ this.phase = options?.phase
31
+ this.timeoutMs = options?.timeoutMs
32
+ }
33
+ }
34
+
35
+ export function asClientError(
36
+ error: unknown,
37
+ options?: { code?: A2ErrorCode },
38
+ ): A2ClientError {
39
+ if (error instanceof A2ClientError) return error
40
+ if (error instanceof A2Error) {
41
+ return new A2ClientError(error.code, error.message, {
42
+ details: error.details,
43
+ cause: error,
44
+ })
45
+ }
46
+ return new A2ClientError(
47
+ options?.code ?? 'UNKNOWN',
48
+ error instanceof Error ? error.message : 'client operation failed',
49
+ { cause: error },
50
+ )
51
+ }