experimental-a2 0.8.1 → 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/CHANGELOG.md +28 -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/meta.ts +1 -0
- package/docs/index.mdx +3 -0
- 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 +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
package/dist/actor.d.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { i as Contract, s as EventDefs, t as Reducer } from "./reducer-DJKWm3cp.js";
|
|
2
|
+
import { t as A2Store } from "./store-DGHeBtIQ.js";
|
|
3
|
+
import { m as Session, n as A2Server, t as A2Scheduler } from "./server-CKY3_lbw.js";
|
|
4
|
+
import { i as A2Telemetry } from "./telemetry-CpeclqB2.js";
|
|
5
|
+
import { a as ActorPresenceOf, c as ActorRefusedError, d as ActorSend, i as ActorPresenceMap, l as ActorScheduleOptions, n as ActorConcurrentContext, o as ActorPresenceValues, r as ActorContext, s as ActorProtocol, t as ActorClientEventDefs, u as ActorScheduleSend } from "./actor-shared-BACubf4x.js";
|
|
6
|
+
//#region src/actor.d.ts
|
|
7
|
+
/**
|
|
8
|
+
* One event's handler: ordinary server code. Serial by default — read
|
|
9
|
+
* `ctx.state`, perform I/O, mutate `ctx.state`, one atomic commit at
|
|
10
|
+
* return. `{ concurrent: true, handle }` opts the handler out of the
|
|
11
|
+
* lane for slow I/O: state becomes a snapshot read and mutations
|
|
12
|
+
* happen by sending events. The input is typed by the protocol; the
|
|
13
|
+
* wire hands handlers parsed JSON, so a wire-exposed event guards its
|
|
14
|
+
* input in its first line (`schema.parse(input)` if you like schemas —
|
|
15
|
+
* userland either way).
|
|
16
|
+
*/
|
|
17
|
+
type ActorHandler<D extends ActorProtocol, K extends keyof D["events"]> = ((ctx: ActorContext<D>, input: D["events"][K]) => void | Promise<void>) | {
|
|
18
|
+
concurrent: true;
|
|
19
|
+
handle: (ctx: ActorConcurrentContext<D>, input: D["events"][K]) => void | Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Mark a handler concurrent — off the lane, in parallel with the lane
|
|
23
|
+
* and with other concurrent handlers, `ctx.state()` a snapshot read.
|
|
24
|
+
* Sugar for the structural `{ concurrent: true, handle }` form:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* handlers: {
|
|
28
|
+
* transfer: concurrent(async (ctx, input) => {
|
|
29
|
+
* await bank.transfer(input, { idempotencyKey: input.ref })
|
|
30
|
+
* ctx.send.settle({ ref: input.ref })
|
|
31
|
+
* }),
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
declare function concurrent<Ctx, I>(handle: (ctx: Ctx, input: I) => void | Promise<void>): {
|
|
36
|
+
concurrent: true;
|
|
37
|
+
handle: (ctx: Ctx, input: I) => void | Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
type ActorOptions<D extends ActorProtocol> = {
|
|
40
|
+
/** Identity — prefixes storage keys; one contract per actor definition. */
|
|
41
|
+
name: string;
|
|
42
|
+
/** The initial state — checked against the protocol, a plain JSON tree. */
|
|
43
|
+
state: D["state"];
|
|
44
|
+
/**
|
|
45
|
+
* One handler per declared event — completeness and payload shapes
|
|
46
|
+
* are compile-checked against the protocol.
|
|
47
|
+
*/
|
|
48
|
+
handlers: { [K in keyof D["events"]]: ActorHandler<D, K>; };
|
|
49
|
+
store?: A2Store;
|
|
50
|
+
scheduler?: A2Scheduler;
|
|
51
|
+
telemetry?: A2Telemetry;
|
|
52
|
+
} & ([ActorPresenceOf<D>] extends [never] ? {
|
|
53
|
+
presence?: never;
|
|
54
|
+
} : {
|
|
55
|
+
/**
|
|
56
|
+
* The wire bit for the protocol's `presence` vocabulary — types
|
|
57
|
+
* erase, so the runtime needs one value to arm the presence
|
|
58
|
+
* lanes (frames on GET, envelopes on POST). Required exactly
|
|
59
|
+
* when the protocol declares `presence`; the vocabulary itself
|
|
60
|
+
* is typed there. Values are validated to the JSON floor and
|
|
61
|
+
* pass through `authorize` as `{ type: 'presence' }` operations;
|
|
62
|
+
* meaning stays with your renderers — treat values as user
|
|
63
|
+
* input.
|
|
64
|
+
*/
|
|
65
|
+
presence: true;
|
|
66
|
+
});
|
|
67
|
+
/**
|
|
68
|
+
* One HTTP operation `handle.fetch` is about to serve — the actor
|
|
69
|
+
* mirror of core's `A2Operation`. `authorize` sees it before any
|
|
70
|
+
* write or subscription; returning `false` answers 403.
|
|
71
|
+
*/
|
|
72
|
+
type ActorOperation = {
|
|
73
|
+
readonly type: "stream";
|
|
74
|
+
readonly id: string;
|
|
75
|
+
readonly startAfter: number;
|
|
76
|
+
} | {
|
|
77
|
+
readonly type: "call";
|
|
78
|
+
readonly id: string;
|
|
79
|
+
readonly event: string;
|
|
80
|
+
readonly input: unknown;
|
|
81
|
+
readonly messageId?: string;
|
|
82
|
+
} | {
|
|
83
|
+
readonly type: "presence";
|
|
84
|
+
readonly id: string;
|
|
85
|
+
readonly participant: string;
|
|
86
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
87
|
+
};
|
|
88
|
+
type ActorFetchOptions<D extends ActorProtocol> = {
|
|
89
|
+
/** Per-operation policy — authentication happened in your route. */
|
|
90
|
+
authorize?: (operation: ActorOperation) => boolean | Promise<boolean>;
|
|
91
|
+
/**
|
|
92
|
+
* Request-time projection: what this mount's audience sees. Applied
|
|
93
|
+
* to every state frame on the stream AND to call answers (the two
|
|
94
|
+
* untrusted lanes must agree, or the call lane leaks what the
|
|
95
|
+
* stream hides). The definition stays audience-agnostic — different
|
|
96
|
+
* routes project the same actor differently, and per-viewer views
|
|
97
|
+
* are just closures over the route's auth. Output is held to the
|
|
98
|
+
* JSON-tree floor like everything else. Transport-agnostic: applied
|
|
99
|
+
* at frame emission, so any wire the door speaks emits projected
|
|
100
|
+
* frames. Absent, the full state ships (the trusted default).
|
|
101
|
+
*/
|
|
102
|
+
view?: (state: D["state"]) => unknown;
|
|
103
|
+
};
|
|
104
|
+
type ActorSendOptions = {
|
|
105
|
+
/** Explicit message id — makes retries of this send idempotent. */
|
|
106
|
+
id?: string;
|
|
107
|
+
};
|
|
108
|
+
type ActorCallOptions = ActorSendOptions & {
|
|
109
|
+
/** How long to await the answer before rejecting the wait (default 30s). */
|
|
110
|
+
timeoutMs?: number;
|
|
111
|
+
};
|
|
112
|
+
type ActorCallResult<S> = {
|
|
113
|
+
/** The actor's state after this message was processed. */
|
|
114
|
+
state: S;
|
|
115
|
+
/** Log index of the state commit that answered this message. */
|
|
116
|
+
index: number;
|
|
117
|
+
};
|
|
118
|
+
type CallMethod<D extends ActorProtocol, K extends keyof D["events"]> = {} extends D["events"][K] ? (input?: D["events"][K], options?: ActorCallOptions) => Promise<ActorCallResult<D["state"]>> : (input: D["events"][K], options?: ActorCallOptions) => Promise<ActorCallResult<D["state"]>>;
|
|
119
|
+
type SendMethod<D extends ActorProtocol, K extends keyof D["events"]> = {} extends D["events"][K] ? (input?: D["events"][K], options?: ActorSendOptions) => Promise<{
|
|
120
|
+
id: string;
|
|
121
|
+
index: number;
|
|
122
|
+
}> : (input: D["events"][K], options?: ActorSendOptions) => Promise<{
|
|
123
|
+
id: string;
|
|
124
|
+
index: number;
|
|
125
|
+
}>;
|
|
126
|
+
/** One instance: typed calls and sends, reads, the managed door, the escape hatch. */
|
|
127
|
+
type ActorHandle<D extends ActorProtocol> = {
|
|
128
|
+
readonly id: string;
|
|
129
|
+
/**
|
|
130
|
+
* Send the event and await its answer: the state after the handler
|
|
131
|
+
* ran, or a rejection with `ActorRefusedError`. Serial events only —
|
|
132
|
+
* concurrent handlers produce no answer to await; `send` them.
|
|
133
|
+
*/
|
|
134
|
+
readonly call: { readonly [K in keyof D["events"]]: CallMethod<D, K>; };
|
|
135
|
+
/** Cast: validate, append the event durably, return without waiting. */
|
|
136
|
+
readonly send: { readonly [K in keyof D["events"]]: SendMethod<D, K>; };
|
|
137
|
+
/** Snapshot read — never queues behind pending messages. */
|
|
138
|
+
readonly state: () => Promise<ActorCallResult<D["state"]>>;
|
|
139
|
+
/**
|
|
140
|
+
* The managed door — the actor mirror of `server.fetch`, bound to
|
|
141
|
+
* this instance. GET streams the state plane (`a2.actor.state`
|
|
142
|
+
* commits only, `?index` resume, heartbeat, deadline rotation);
|
|
143
|
+
* POST is the call lane `createActorClient` speaks (`{ event,
|
|
144
|
+
* input, messageId? }` → the answer, 409 for a refusal). Your route
|
|
145
|
+
* authenticates and picks the instance; `authorize` sees every
|
|
146
|
+
* operation. Custom endpoints are route code around this call.
|
|
147
|
+
*/
|
|
148
|
+
readonly fetch: (request: Request, options?: ActorFetchOptions<D>) => Promise<Response>;
|
|
149
|
+
/** Escape hatch: the raw A2 session (history, stream, schedule). */
|
|
150
|
+
readonly session: Session<EventDefs>;
|
|
151
|
+
};
|
|
152
|
+
type ActorDefinition<D extends ActorProtocol> = {
|
|
153
|
+
readonly name: string;
|
|
154
|
+
/**
|
|
155
|
+
* Type-only carrier of the protocol, so clients deriving from
|
|
156
|
+
* `typeof def` can see the vocabularies (`experimental-a2/actor/react`
|
|
157
|
+
* types `presence` from it). Never a runtime value.
|
|
158
|
+
*/
|
|
159
|
+
readonly protocol?: D;
|
|
160
|
+
/** The assembled contract — declared events plus the reserved a2.actor.* pair. */
|
|
161
|
+
readonly contract: Contract<EventDefs>;
|
|
162
|
+
/** The underlying A2 server — `server.fetch` mounts the full session wire. */
|
|
163
|
+
readonly server: A2Server<EventDefs>;
|
|
164
|
+
/** The state fold — same identity as the client's follower fold. */
|
|
165
|
+
readonly reducer: Reducer<ActorClientEventDefs<D["state"]>, D["state"]>;
|
|
166
|
+
actor(id: string): ActorHandle<D>;
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Define and serve an actor over its protocol: a named, durable
|
|
170
|
+
* instance-per-id with serialized state handlers, concurrent I/O
|
|
171
|
+
* handlers, and typed self-sends.
|
|
172
|
+
*/
|
|
173
|
+
declare function actor<D extends ActorProtocol>(options: ActorOptions<D>): ActorDefinition<D>;
|
|
174
|
+
//#endregion
|
|
175
|
+
export { ActorCallOptions, ActorCallResult, type ActorConcurrentContext, type ActorContext, ActorDefinition, ActorFetchOptions, ActorHandle, ActorHandler, ActorOperation, ActorOptions, type ActorPresenceMap, type ActorPresenceOf, type ActorPresenceValues, type ActorProtocol, ActorRefusedError, type ActorScheduleOptions, type ActorScheduleSend, type ActorSend, ActorSendOptions, actor, concurrent };
|
|
176
|
+
//# sourceMappingURL=actor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"actor.d.ts","names":[],"sources":["../src/actor.ts"],"mappings":";;;;;;;;;;;;;;;;KA6GY,aAAa,UAAU,eAAe,gBAAgB,iBAC5D,KAAK,aAAa,IAAI,OAAO,YAAY,cAAc;EAEvD;EACA,SACE,KAAK,uBAAuB,IAC5B,OAAO,YAAY,cACT;;;;;;;;;;;;;;;;iBAiBF,WAAW,KAAK,GAC9B,SAAS,KAAK,KAAK,OAAO,aAAa;EACpC;EAAkB,SAAS,KAAK,KAAK,OAAO,aAAa;;KAIlD,aAAa,UAAU;;EAEjC;;EAEA,OAAO;;;;;EAKP,aAAa,WAAW,cAAc,aAAa,GAAG;EACtD,QAAQ;EACR,YAAY;EACZ,YAAY;MACR,gBAAgB;EAChB;;;;;;;;;;;;EAYA;;;;;;;KAQM;WAEG;WACA;WACA;;WAGA;WACA;WACA;WACA;WACA;;WAGA;WACA;WACA;WACA,QAAQ,SAAS;;KAGpB,kBAAkB,UAAU;;EAEtC,aAAa,WAAW,6BAA6B;;;;;;;;;;;;EAYrD,QAAQ,OAAO;;KAGL;;EAEV;;KAGU,mBAAmB;;EAE7B;;KAGU,gBAAgB;;EAE1B,OAAO;;EAEP;;KAGG,WACH,UAAU,eACV,gBAAgB,0BACH,YAAY,MAErB,QAAQ,YAAY,IACpB,UAAU,qBACP,QAAQ,gBAAgB,gBAE3B,OAAO,YAAY,IACnB,UAAU,qBACP,QAAQ,gBAAgB;KAE5B,WACH,UAAU,eACV,gBAAgB,0BACH,YAAY,MAErB,QAAQ,YAAY,IACpB,UAAU,qBACP;EAAU;EAAY;MAEzB,OAAO,YAAY,IACnB,UAAU,qBACP;EAAU;EAAY;;;KAGnB,YAAY,UAAU;WACvB;;;;;;WAMA,kBAAkB,WAAW,cAAc,WAAW,GAAG;;WAEzD,kBAAkB,WAAW,cAAc,WAAW,GAAG;;WAEzD,aAAa,QAAQ,gBAAgB;;;;;;;;;;WAUrC,QACP,SAAS,SACT,UAAU,kBAAkB,OACzB,QAAQ;;WAEJ,SAAS,QAAQ;;KAGhB,gBAAgB,UAAU;WAC3B;;;;;;WAMA,WAAW;;WAEX,UAAU,SAAS;;WAEnB,QAAQ,SAAS;;WAEjB,SAAS,QAAQ,qBAAqB,aAAa;EAC5D,MAAM,aAAa,YAAY;;;;;;;iBAsDjB,MAAM,UAAU,eAC9B,SAAS,aAAa,KACrB,gBAAgB"}
|
package/dist/actor.js
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { t as contract } from "./contract-48bUMgcL.js";
|
|
2
|
+
import { n as NonRetriableError, t as A2Error } from "./errors-DCk6ch5n.js";
|
|
3
|
+
import { a as parsePresenceSibling, i as sseResponse, r as setServerFetchHooks, t as createServer } from "./server-CBET-jSz.js";
|
|
4
|
+
import { a as actorFailedSchema, c as isJsonTree, i as ActorRefusedError, l as openPresenceDefs, n as ACTOR_LANE, o as actorReducer, r as ACTOR_STATE_EVENT, s as actorStateSchema, t as ACTOR_FAILED_EVENT } from "./actor-shared-DI7J5upy.js";
|
|
5
|
+
//#region src/actor.ts
|
|
6
|
+
/**
|
|
7
|
+
* experimental-a2/actor — durable actors over the log. Server-only
|
|
8
|
+
* (handlers are server code); the browser condition resolves to a
|
|
9
|
+
* throwing stub, and the browser surfaces are experimental-a2/actor/client
|
|
10
|
+
* and experimental-a2/actor/react.
|
|
11
|
+
*
|
|
12
|
+
* An actor is defined over a protocol — its state shape and event
|
|
13
|
+
* vocabulary, as types — and one complete `handlers` bag:
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* interface Counter {
|
|
17
|
+
* state: { count: number }
|
|
18
|
+
* events: { increment: object; add: { by: number } }
|
|
19
|
+
* }
|
|
20
|
+
* const counter = actor<Counter>({
|
|
21
|
+
* name: 'counter',
|
|
22
|
+
* state: { count: 0 },
|
|
23
|
+
* handlers: {
|
|
24
|
+
* increment: (ctx) => {
|
|
25
|
+
* ctx.state.count++
|
|
26
|
+
* },
|
|
27
|
+
* add: (ctx, input) => {
|
|
28
|
+
* ctx.state.count += input.by
|
|
29
|
+
* },
|
|
30
|
+
* },
|
|
31
|
+
* })
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* Handlers are serial by default: one at a time per instance (a lane —
|
|
35
|
+
* the state's write lock), `ctx.state` a mutable draft committed
|
|
36
|
+
* atomically with completion. A handler holding slow I/O opts out of
|
|
37
|
+
* the lane with `{ concurrent: true, handle }`: it runs in parallel,
|
|
38
|
+
* reads state as a snapshot, and mutates by sending events whose
|
|
39
|
+
* serial handlers decide against fresh state. `ctx.send.eventName(...)`
|
|
40
|
+
* is typed by the protocol and buffered — committed atomically with
|
|
41
|
+
* the handler's completion, so a reserve and its trigger can never
|
|
42
|
+
* half-happen.
|
|
43
|
+
*
|
|
44
|
+
* A thrown `NonRetriableError` is an answer (settled, delivered to a
|
|
45
|
+
* waiting caller); any other error retries like every A2 handler
|
|
46
|
+
* failure; a crash re-runs against the same pre-state. Everything
|
|
47
|
+
* compiles to contract + reducer + laned handlers + returned events —
|
|
48
|
+
* no new store operations, no new wire.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* Mark a handler concurrent — off the lane, in parallel with the lane
|
|
52
|
+
* and with other concurrent handlers, `ctx.state()` a snapshot read.
|
|
53
|
+
* Sugar for the structural `{ concurrent: true, handle }` form:
|
|
54
|
+
*
|
|
55
|
+
* ```ts
|
|
56
|
+
* handlers: {
|
|
57
|
+
* transfer: concurrent(async (ctx, input) => {
|
|
58
|
+
* await bank.transfer(input, { idempotencyKey: input.ref })
|
|
59
|
+
* ctx.send.settle({ ref: input.ref })
|
|
60
|
+
* }),
|
|
61
|
+
* }
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
function concurrent(handle) {
|
|
65
|
+
return {
|
|
66
|
+
concurrent: true,
|
|
67
|
+
handle
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const UNSAFE_EVENT_NAMES = /* @__PURE__ */ new Set([
|
|
71
|
+
"__proto__",
|
|
72
|
+
"constructor",
|
|
73
|
+
"prototype"
|
|
74
|
+
]);
|
|
75
|
+
const forbidden = () => Response.json({ error: "the operation is not authorized" }, { status: 403 });
|
|
76
|
+
const CALL_TIMEOUT_MS = 3e4;
|
|
77
|
+
const CALL_POLL_MS = 25;
|
|
78
|
+
/** Declared events accept any JSON tree; `undefined` normalizes to `{}`. */
|
|
79
|
+
const jsonInputSchema = { "~standard": {
|
|
80
|
+
version: 1,
|
|
81
|
+
vendor: "a2",
|
|
82
|
+
validate(value) {
|
|
83
|
+
if (value === void 0 || value === null) return { value: {} };
|
|
84
|
+
if (!isJsonTree(value)) return { issues: [{ message: "event input must be a plain JSON tree" }] };
|
|
85
|
+
return { value };
|
|
86
|
+
}
|
|
87
|
+
} };
|
|
88
|
+
const describeError = (error) => error instanceof Error ? error.message : String(error);
|
|
89
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
90
|
+
/**
|
|
91
|
+
* Define and serve an actor over its protocol: a named, durable
|
|
92
|
+
* instance-per-id with serialized state handlers, concurrent I/O
|
|
93
|
+
* handlers, and typed self-sends.
|
|
94
|
+
*/
|
|
95
|
+
function actor(options) {
|
|
96
|
+
const { name } = options;
|
|
97
|
+
const handlersByEvent = /* @__PURE__ */ new Map();
|
|
98
|
+
const events = {
|
|
99
|
+
[ACTOR_STATE_EVENT]: actorStateSchema(),
|
|
100
|
+
[ACTOR_FAILED_EVENT]: actorFailedSchema
|
|
101
|
+
};
|
|
102
|
+
for (const [eventName, def] of Object.entries(options.handlers)) {
|
|
103
|
+
if (UNSAFE_EVENT_NAMES.has(eventName)) throw new TypeError(`actor '${name}': '${eventName}' collides with object plumbing and cannot name an event`);
|
|
104
|
+
if (eventName.startsWith("a2.")) throw new TypeError(`actor '${name}': event names starting with 'a2.' are reserved`);
|
|
105
|
+
handlersByEvent.set(eventName, typeof def === "function" ? {
|
|
106
|
+
kind: "serial",
|
|
107
|
+
handle: def
|
|
108
|
+
} : {
|
|
109
|
+
kind: "concurrent",
|
|
110
|
+
handle: def.handle
|
|
111
|
+
});
|
|
112
|
+
events[eventName] = jsonInputSchema;
|
|
113
|
+
}
|
|
114
|
+
const reducer = actorReducer({
|
|
115
|
+
name,
|
|
116
|
+
state: options.state
|
|
117
|
+
});
|
|
118
|
+
const serverReducer = reducer;
|
|
119
|
+
const makeSchedule = (schedule) => {
|
|
120
|
+
const surface = {};
|
|
121
|
+
for (const eventName of handlersByEvent.keys()) Object.defineProperty(surface, eventName, {
|
|
122
|
+
value: (input, scheduleOptions) => {
|
|
123
|
+
const { name: taskName, ...timing } = scheduleOptions;
|
|
124
|
+
return schedule(taskName ?? eventName, timing, {
|
|
125
|
+
type: eventName,
|
|
126
|
+
payload: input ?? {}
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
enumerable: true,
|
|
130
|
+
configurable: true,
|
|
131
|
+
writable: false
|
|
132
|
+
});
|
|
133
|
+
return surface;
|
|
134
|
+
};
|
|
135
|
+
const makeSend = (buffer) => {
|
|
136
|
+
const send = {};
|
|
137
|
+
for (const eventName of handlersByEvent.keys()) Object.defineProperty(send, eventName, {
|
|
138
|
+
value: (input) => {
|
|
139
|
+
buffer.push({
|
|
140
|
+
type: eventName,
|
|
141
|
+
payload: input ?? {}
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
enumerable: true,
|
|
145
|
+
configurable: true,
|
|
146
|
+
writable: false
|
|
147
|
+
});
|
|
148
|
+
return send;
|
|
149
|
+
};
|
|
150
|
+
const handlers = {};
|
|
151
|
+
for (const [eventName, def] of handlersByEvent) {
|
|
152
|
+
if (def.kind === "concurrent") {
|
|
153
|
+
handlers[eventName] = async (ctx) => {
|
|
154
|
+
const sends = [];
|
|
155
|
+
try {
|
|
156
|
+
await def.handle({
|
|
157
|
+
id: ctx.event.id,
|
|
158
|
+
attempt: ctx.attempt,
|
|
159
|
+
signal: ctx.signal,
|
|
160
|
+
send: makeSend(sends),
|
|
161
|
+
schedule: makeSchedule((taskName, timing, event) => ctx.session.schedule(taskName, timing, event)),
|
|
162
|
+
state: async () => {
|
|
163
|
+
const { state, index } = await ctx.session.state(serverReducer, { through: "latest" });
|
|
164
|
+
return {
|
|
165
|
+
state,
|
|
166
|
+
index
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}, ctx.event.payload);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (!(error instanceof NonRetriableError)) throw error;
|
|
172
|
+
const payload = {
|
|
173
|
+
message: ctx.event.id,
|
|
174
|
+
event: eventName,
|
|
175
|
+
error: describeError(error)
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
type: ACTOR_FAILED_EVENT,
|
|
179
|
+
payload
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return sends;
|
|
183
|
+
};
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
handlers[eventName] = {
|
|
187
|
+
lane: ACTOR_LANE,
|
|
188
|
+
handler: async (ctx) => {
|
|
189
|
+
const { state } = await ctx.session.state(serverReducer, { through: "latest" });
|
|
190
|
+
const draft = structuredClone(state);
|
|
191
|
+
const sends = [];
|
|
192
|
+
try {
|
|
193
|
+
await def.handle({
|
|
194
|
+
state: draft,
|
|
195
|
+
id: ctx.event.id,
|
|
196
|
+
attempt: ctx.attempt,
|
|
197
|
+
signal: ctx.signal,
|
|
198
|
+
send: makeSend(sends),
|
|
199
|
+
schedule: makeSchedule((taskName, timing, event) => ctx.session.schedule(taskName, timing, event))
|
|
200
|
+
}, ctx.event.payload);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (!(error instanceof NonRetriableError)) throw error;
|
|
203
|
+
const payload = {
|
|
204
|
+
message: ctx.event.id,
|
|
205
|
+
event: eventName,
|
|
206
|
+
error: describeError(error)
|
|
207
|
+
};
|
|
208
|
+
return {
|
|
209
|
+
type: ACTOR_FAILED_EVENT,
|
|
210
|
+
payload
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const payload = {
|
|
214
|
+
state: draft,
|
|
215
|
+
event: eventName,
|
|
216
|
+
message: ctx.event.id
|
|
217
|
+
};
|
|
218
|
+
return [...sends, {
|
|
219
|
+
type: ACTOR_STATE_EVENT,
|
|
220
|
+
payload
|
|
221
|
+
}];
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const presenceEnabled = options.presence === true;
|
|
226
|
+
const contract$1 = presenceEnabled ? contract({
|
|
227
|
+
name,
|
|
228
|
+
events,
|
|
229
|
+
presence: openPresenceDefs
|
|
230
|
+
}) : contract({
|
|
231
|
+
name,
|
|
232
|
+
events
|
|
233
|
+
});
|
|
234
|
+
const server = createServer({
|
|
235
|
+
contract: contract$1,
|
|
236
|
+
handlers,
|
|
237
|
+
...options.store ? { store: options.store } : {},
|
|
238
|
+
...options.scheduler ? { scheduler: options.scheduler } : {},
|
|
239
|
+
...options.telemetry ? { telemetry: options.telemetry } : {}
|
|
240
|
+
});
|
|
241
|
+
setServerFetchHooks(server, { validateIngress: ({ events: pushed }) => {
|
|
242
|
+
for (const event of pushed) if (event.type.startsWith("a2.actor.")) throw new TypeError(`'${event.type}' is reserved — only a handler's completion can author it`);
|
|
243
|
+
} });
|
|
244
|
+
const makeHandle = (id) => {
|
|
245
|
+
const session = server.session(id);
|
|
246
|
+
const sendEvent = async (eventName, input, sendOptions) => {
|
|
247
|
+
const [message] = await session.append({
|
|
248
|
+
type: eventName,
|
|
249
|
+
payload: input ?? {},
|
|
250
|
+
...sendOptions?.id ? { id: sendOptions.id } : {}
|
|
251
|
+
});
|
|
252
|
+
if (!message) throw new Error("append returned no event");
|
|
253
|
+
return {
|
|
254
|
+
id: message.id,
|
|
255
|
+
index: message.index
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
const callEvent = async (eventName, input, callOptions) => {
|
|
259
|
+
if (handlersByEvent.get(eventName)?.kind === "concurrent") throw new TypeError(`actor '${name}': '${eventName}' is concurrent — it produces no answer to await; send it instead`);
|
|
260
|
+
const message = await sendEvent(eventName, input, callOptions);
|
|
261
|
+
const deadline = Date.now() + (callOptions?.timeoutMs ?? CALL_TIMEOUT_MS);
|
|
262
|
+
let cursor = message.index + 1;
|
|
263
|
+
for (;;) {
|
|
264
|
+
await server.drain(id);
|
|
265
|
+
const tail = await session.history({ gte: cursor });
|
|
266
|
+
for (const event of tail) {
|
|
267
|
+
if (event.type === "a2.actor.state") {
|
|
268
|
+
const payload = event.payload;
|
|
269
|
+
if (payload.message === message.id) return {
|
|
270
|
+
state: payload.state,
|
|
271
|
+
index: event.index
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (event.type === "a2.actor.failed") {
|
|
275
|
+
const payload = event.payload;
|
|
276
|
+
if (payload.message === message.id) throw new ActorRefusedError(eventName, message.id, payload.error);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const last = tail[tail.length - 1];
|
|
280
|
+
if (last) cursor = last.index + 1;
|
|
281
|
+
if (Date.now() >= deadline) throw new Error(`actor '${name}': timed out waiting for '${eventName}' (message ${message.id})`);
|
|
282
|
+
await sleep(CALL_POLL_MS);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const readState = async () => {
|
|
286
|
+
const { state, index } = await session.state(serverReducer);
|
|
287
|
+
return {
|
|
288
|
+
state,
|
|
289
|
+
index
|
|
290
|
+
};
|
|
291
|
+
};
|
|
292
|
+
const applyView = (view, state) => {
|
|
293
|
+
const viewed = view(state);
|
|
294
|
+
if (!isJsonTree(viewed)) throw new TypeError(`actor '${name}': the view returned a value that is not a plain JSON tree`);
|
|
295
|
+
return viewed;
|
|
296
|
+
};
|
|
297
|
+
const streamStates = async function* (startAfter, view) {
|
|
298
|
+
const source = presenceEnabled ? session.stream({
|
|
299
|
+
startAfter,
|
|
300
|
+
presence: true
|
|
301
|
+
}) : session.stream({ startAfter });
|
|
302
|
+
for await (const item of source) {
|
|
303
|
+
if (!("type" in item)) {
|
|
304
|
+
yield item;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const event = item;
|
|
308
|
+
if (event.type !== "a2.actor.state") continue;
|
|
309
|
+
if (!view) {
|
|
310
|
+
yield event;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const payload = event.payload;
|
|
314
|
+
yield {
|
|
315
|
+
...event,
|
|
316
|
+
payload: {
|
|
317
|
+
...payload,
|
|
318
|
+
state: applyView(view, payload.state)
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
const handleFetch = async (request, fetchOptions) => {
|
|
324
|
+
const authorized = async (operation) => await fetchOptions?.authorize?.(Object.freeze(operation)) !== false;
|
|
325
|
+
if (request.method === "GET") {
|
|
326
|
+
const url = new URL(request.url);
|
|
327
|
+
const raw = Number(url.searchParams.get("index"));
|
|
328
|
+
const startAfter = Number.isSafeInteger(raw) && raw >= 0 ? raw : 0;
|
|
329
|
+
if (!await authorized({
|
|
330
|
+
type: "stream",
|
|
331
|
+
id,
|
|
332
|
+
startAfter
|
|
333
|
+
})) return forbidden();
|
|
334
|
+
return sseResponse(streamStates(startAfter, fetchOptions?.view));
|
|
335
|
+
}
|
|
336
|
+
if (request.method !== "POST") return new Response(null, {
|
|
337
|
+
status: 405,
|
|
338
|
+
headers: { allow: "GET, POST" }
|
|
339
|
+
});
|
|
340
|
+
let body;
|
|
341
|
+
try {
|
|
342
|
+
body = await request.json();
|
|
343
|
+
} catch {
|
|
344
|
+
return Response.json({ error: "invalid JSON body" }, { status: 400 });
|
|
345
|
+
}
|
|
346
|
+
const record = typeof body === "object" && body !== null ? body : {};
|
|
347
|
+
if ("presence" in record) {
|
|
348
|
+
if (!presenceEnabled) return Response.json({ error: `actor '${name}' declares no presence` }, { status: 400 });
|
|
349
|
+
const pushedEvents = record["events"];
|
|
350
|
+
if (pushedEvents !== void 0 && (!Array.isArray(pushedEvents) || pushedEvents.length > 0)) return Response.json({ error: "the actor wire takes calls, not event pushes" }, { status: 400 });
|
|
351
|
+
let patch;
|
|
352
|
+
try {
|
|
353
|
+
const parsed = parsePresenceSibling(record["presence"]);
|
|
354
|
+
if (!parsed) throw new TypeError("presence must be an object");
|
|
355
|
+
patch = parsed;
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return Response.json({ error: describeError(error) }, { status: 400 });
|
|
358
|
+
}
|
|
359
|
+
if (!await authorized({
|
|
360
|
+
type: "presence",
|
|
361
|
+
id,
|
|
362
|
+
participant: patch.participant,
|
|
363
|
+
values: patch.values
|
|
364
|
+
})) return forbidden();
|
|
365
|
+
try {
|
|
366
|
+
await session.setPresence(patch);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof A2Error && (error.code === "INVALID_PAYLOAD" || error.code === "UNKNOWN_PRESENCE_FIELD")) return Response.json({ error: error.message }, { status: 400 });
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
return Response.json([]);
|
|
372
|
+
}
|
|
373
|
+
const eventName = record["event"];
|
|
374
|
+
if (typeof eventName !== "string" || handlersByEvent.get(eventName)?.kind !== "serial") return Response.json({ error: "unknown event" }, { status: 400 });
|
|
375
|
+
const messageId = record["messageId"];
|
|
376
|
+
if (messageId !== void 0 && typeof messageId !== "string") return Response.json({ error: "invalid messageId" }, { status: 400 });
|
|
377
|
+
const input = record["input"];
|
|
378
|
+
if (!await authorized({
|
|
379
|
+
type: "call",
|
|
380
|
+
id,
|
|
381
|
+
event: eventName,
|
|
382
|
+
input,
|
|
383
|
+
...messageId === void 0 ? {} : { messageId }
|
|
384
|
+
})) return forbidden();
|
|
385
|
+
try {
|
|
386
|
+
const result = await callEvent(eventName, input, messageId === void 0 ? void 0 : { id: messageId });
|
|
387
|
+
return Response.json(fetchOptions?.view ? {
|
|
388
|
+
state: applyView(fetchOptions.view, result.state),
|
|
389
|
+
index: result.index
|
|
390
|
+
} : result);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (error instanceof ActorRefusedError) return Response.json({
|
|
393
|
+
error: error.message,
|
|
394
|
+
event: error.event,
|
|
395
|
+
messageId: error.messageId
|
|
396
|
+
}, { status: 409 });
|
|
397
|
+
if (error instanceof A2Error && (error.code === "INVALID_PAYLOAD" || error.code === "UNKNOWN_EVENT_TYPE")) return Response.json({ error: error.message }, { status: 400 });
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
const call = {};
|
|
402
|
+
const send = {};
|
|
403
|
+
for (const eventName of handlersByEvent.keys()) {
|
|
404
|
+
Object.defineProperty(call, eventName, {
|
|
405
|
+
value: (input, callOptions) => callEvent(eventName, input, callOptions),
|
|
406
|
+
enumerable: true,
|
|
407
|
+
configurable: true,
|
|
408
|
+
writable: false
|
|
409
|
+
});
|
|
410
|
+
Object.defineProperty(send, eventName, {
|
|
411
|
+
value: (input, sendOptions) => sendEvent(eventName, input, sendOptions),
|
|
412
|
+
enumerable: true,
|
|
413
|
+
configurable: true,
|
|
414
|
+
writable: false
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
id,
|
|
419
|
+
session,
|
|
420
|
+
call,
|
|
421
|
+
send,
|
|
422
|
+
state: readState,
|
|
423
|
+
fetch: handleFetch
|
|
424
|
+
};
|
|
425
|
+
};
|
|
426
|
+
return {
|
|
427
|
+
name,
|
|
428
|
+
contract: contract$1,
|
|
429
|
+
server,
|
|
430
|
+
reducer,
|
|
431
|
+
actor: makeHandle
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
//#endregion
|
|
435
|
+
export { ActorRefusedError, actor, concurrent };
|
|
436
|
+
|
|
437
|
+
//# sourceMappingURL=actor.js.map
|