experimental-a2 0.1.0 → 0.3.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.
@@ -1,7 +1,7 @@
1
1
  import { t as A2Error } from "./errors-BJRMd-h6.js";
2
2
  import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
3
3
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
4
- import { t as pollingStream } from "./log-polling-6COoN60V.js";
4
+ import { n as pollingStream } from "./log-polling-DZ1MiKLg.js";
5
5
  import { mkdirSync } from "node:fs";
6
6
  import { dirname, resolve } from "node:path";
7
7
  import { DatabaseSync } from "node:sqlite";
@@ -1,4 +1,4 @@
1
- import { i as serverInternals, t as DRAIN_TIMINGS } from "./internal-D6wNxTck.js";
1
+ import { a as serverInternals, t as DRAIN_TIMINGS } from "./internal-gCd5qMry.js";
2
2
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
3
  import { n as SYSTEM_CLOCK } from "./log-yJbXUf72.js";
4
4
  //#region src/recovery-vercel.ts
@@ -1,6 +1,6 @@
1
1
  import { n as validateSync } from "./validate-XKT4FSNn.js";
2
2
  import { n as asLogUnavailable, t as A2Error } from "./errors-BJRMd-h6.js";
3
- import { i as serverInternals, t as DRAIN_TIMINGS } from "./internal-D6wNxTck.js";
3
+ import { a as serverInternals, t as DRAIN_TIMINGS } from "./internal-gCd5qMry.js";
4
4
  import { n as serverInspection } from "./inspection-E7qbD0Xj.js";
5
5
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
6
6
  //#region src/deterministic-id.ts
@@ -142,6 +142,9 @@ function nextClaimWindow() {
142
142
  function recoverySlot(dueAt) {
143
143
  return Math.ceil(dueAt / 1e3) * 1e3;
144
144
  }
145
+ function currentRecoverySlot(now) {
146
+ return Math.floor(now / 1e3) * 1e3;
147
+ }
145
148
  /**
146
149
  * The namespace separator between machine name and session id in
147
150
  * storage. Machines sharing one log backend (the dev-default sqlite
@@ -321,13 +324,15 @@ function createServer(options) {
321
324
  * the drain it schedules is created in the append's active context —
322
325
  * with a real tracer, causal work remains connected.
323
326
  */
324
- const appendCore = (sessionId, events, source, onAppended, cause, generatedIds) => telemetry.span("a2.append", {
327
+ const appendCore = (sessionId, events, source, mode, onAppended, cause, generatedIds) => telemetry.span("a2.append", {
325
328
  "a2.contract": name,
326
329
  "a2.session_id": sessionId,
327
330
  "a2.append.source": source,
331
+ "a2.append.mode": mode,
328
332
  "a2.append.types": events.map((e) => String(e.type)).join(","),
329
333
  "a2.append.count": events.length
330
334
  }, async (span) => {
335
+ if (mode === "dispatch" && !recovery) throw new TypeError("append.dispatch requires a configured recovery adapter");
331
336
  assertSessionId(sessionId);
332
337
  const validated = validateEvents(sessionId, events);
333
338
  if (cause) for (const [index, event] of validated.entries()) event.cause = generatedIds?.[index] ? {
@@ -345,9 +350,11 @@ function createServer(options) {
345
350
  throw asLogUnavailable(err);
346
351
  }
347
352
  const appended = rows.map(toPublic);
348
- const initialRecoveryAt = shouldHealSession && recovery && source === "external" ? recoverySlot(nextClaimWindow().recoveryAtMs) : void 0;
353
+ const initialRecoveryAt = shouldHealSession && recovery && source === "external" && mode === "inline" ? recoverySlot(nextClaimWindow().recoveryAtMs) : void 0;
349
354
  const initialArm = initialRecoveryAt !== void 0 ? startRecoveryArm(sessionId, initialRecoveryAt) : null;
350
- if (shouldHealSession) onAppended(appended, initialRecoveryAt);
355
+ const dispatchArm = shouldHealSession && mode === "dispatch" ? startRecoveryArm(sessionId, currentRecoverySlot(Date.now())) : null;
356
+ if (shouldHealSession && mode === "inline") onAppended(appended, initialRecoveryAt);
357
+ if (dispatchArm) await dispatchArm;
351
358
  if (initialArm) {
352
359
  let timeout = null;
353
360
  try {
@@ -472,7 +479,7 @@ function createServer(options) {
472
479
  id
473
480
  };
474
481
  }));
475
- return appendCore(publicSessionId, withIds, "handler", (_rows, recoveryDueAt) => scheduleDrain(publicSessionId, recoveryDueAt), {
482
+ return appendCore(publicSessionId, withIds, "handler", "inline", (_rows, recoveryDueAt) => scheduleDrain(publicSessionId, recoveryDueAt), {
476
483
  index: trigger.index,
477
484
  attempt
478
485
  }, generatedIds);
@@ -786,18 +793,21 @@ function createServer(options) {
786
793
  ...recoveryArm ? { recoveryArm } : {}
787
794
  };
788
795
  });
796
+ const appendExternal = async (sessionId, mode, events) => {
797
+ const pushed = events.filter((event) => Reflect.get(event, "~a2.pushed") === true);
798
+ if (pushed.length > 0) await options.validatePush?.({
799
+ sessionId,
800
+ events: pushed
801
+ });
802
+ return appendCore(sessionId, events, "external", mode, (_rows, recoveryDueAt) => scheduleDrain(sessionId, recoveryDueAt));
803
+ };
789
804
  const self = {
790
805
  contract: serverContract,
791
806
  session(id) {
792
807
  assertSessionId(id);
793
- const append = async (...events) => {
794
- const pushed = events.filter((event) => Reflect.get(event, "~a2.pushed") === true);
795
- if (pushed.length > 0) await options.validatePush?.({
796
- sessionId: id,
797
- events: pushed
798
- });
799
- return appendCore(id, events, "external", (_rows, recoveryDueAt) => scheduleDrain(id, recoveryDueAt));
800
- };
808
+ const appendInline = async (...events) => appendExternal(id, "inline", events);
809
+ const appendDispatch = async (...events) => appendExternal(id, "dispatch", events);
810
+ const append = Object.assign(appendInline, { dispatch: appendDispatch });
801
811
  return makeSession(id, append);
802
812
  },
803
813
  async drain(sessionId) {
package/dist/server.d.ts CHANGED
@@ -38,11 +38,15 @@ type LaneContext<D extends EventDefs, K extends keyof D & string = keyof D & str
38
38
  };
39
39
  };
40
40
  type Lane<D extends EventDefs, K extends keyof D & string = keyof D & string> = string | ((context: LaneContext<D, K>) => string);
41
- type SessionAppend<D extends EventDefs> = {
41
+ type SessionDispatch<D extends EventDefs> = {
42
42
  (...events: AppendInput<D>[]): Promise<ContractEvent<D>[]>;
43
43
  /** The push-route path: events from `parsePushBody`. */
44
44
  (...events: PushedEvent[]): Promise<ContractEvent<D>[]>;
45
45
  };
46
+ type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
47
+ /** Commit, then hand pending work directly to configured recovery. */
48
+ dispatch: SessionDispatch<D>;
49
+ };
46
50
  type HandlerAppend<D extends EventDefs> = (name: string, ...events: AppendInput<D>[]) => Promise<ContractEvent<D>[]>;
47
51
  /** A handle on one instance of the machine. Creating it does no I/O. */
48
52
  type Session<D extends EventDefs, Append = SessionAppend<D>> = {
@@ -148,4 +152,4 @@ type ServerOptions<D extends EventDefs> = {
148
152
  /** Implement a contract: bind its vocabulary to storage and reactions. */
149
153
  declare function createServer<D extends EventDefs>(options: ServerOptions<D>): A2Server<D>;
150
154
  //#endregion
151
- export { type A2Log, type A2LogInspection, A2Recovery, A2Server, AbortSpec, type AppendEvent, type AppendInput, type Clock, type Contract, type ContractEvent, type Event, type EventCause, type EventDefs, type FailAttemptResult, Handler, HandlerAppend, HandlerContext, HandlerEntry, type IdSource, Lane, LaneContext, type LogAppendResult, type LogClaimAvailableResult, type LogStateRead, PushValidationContext, PushedEvent, RecoverableServer, ServerOptions, Session, SessionAppend, type StoredEvent, type StoredSessionPage, type StoredSessionSummary, type StoredSnapshot, createServer };
155
+ export { type A2Log, type A2LogInspection, A2Recovery, A2Server, AbortSpec, type AppendEvent, type AppendInput, type Clock, type Contract, type ContractEvent, type Event, type EventCause, type EventDefs, type FailAttemptResult, Handler, HandlerAppend, HandlerContext, HandlerEntry, type IdSource, Lane, LaneContext, type LogAppendResult, type LogClaimAvailableResult, type LogStateRead, PushValidationContext, PushedEvent, RecoverableServer, ServerOptions, Session, SessionAppend, SessionDispatch, type StoredEvent, type StoredSessionPage, type StoredSessionSummary, type StoredSnapshot, createServer };
package/dist/server.js CHANGED
@@ -1,2 +1,2 @@
1
- import { t as createServer } from "./server-BWffWe5A.js";
1
+ import { t as createServer } from "./server-BcLa4RFL.js";
2
2
  export { createServer };
@@ -20,8 +20,7 @@ npm i experimental-a2 zod
20
20
  A contract is a name plus the events it understands: each key an event
21
21
  name, each value a schema. Zod here, though any
22
22
  [Standard Schema](https://standardschema.dev) validator works, and
23
- that's the entire upfront declaration. No state list, no transition
24
- table.
23
+ that's the contract's complete declaration.
25
24
 
26
25
  ```ts server/orders.ts
27
26
  import { z } from 'zod'
@@ -41,10 +41,9 @@ schemas and their type helpers. The server implements it,
41
41
  [reducers](/concepts/state) derive from it, and the browser types its pushes
42
42
  off it. One artifact, shared by every side of the wire.
43
43
 
44
- And that's the entire declaration. No states, no transition table. What
45
- happens on each event is defined where the contract is served; what
46
- things look like right now is defined by [reducers](/concepts/state). The
47
- contract just names the vocabulary.
44
+ The contract names the complete vocabulary. What happens on each event is
45
+ defined where the contract is served. [Reducers](/concepts/state) define what
46
+ things look like right now.
48
47
 
49
48
  :::tip
50
49
  Events are facts, so name them in past tense: `created`, `expired`,
@@ -43,10 +43,9 @@ processed marker says what finished. The watchdog says when to look again.
43
43
  +-------------+ +-------------------+
44
44
  ```
45
45
 
46
- A queue message names a session. It carries no event index or continuation
47
- state. Every wakeup runs the same drain, and the log decides what remains.
48
- Recovery and explicit `server.drain()` inspect the full session. Reads never
49
- dispatch handlers.
46
+ A queue message identifies a session. Every wakeup runs the same drain, and
47
+ the log decides what remains. Recovery and explicit `server.drain()` inspect
48
+ the full session. Reads never dispatch handlers.
50
49
 
51
50
  :::note[Recovery is not an event]
52
51
  A2 does not append `recovered` or `continued`. Recovery retries the same event.
@@ -170,6 +169,14 @@ telemetry span records `a2.append.armed = false`, but committed work continues.
170
169
  `ctx.session.append` rides the active session drain and adds no arm. Returned
171
170
  events enter the log as part of completion, then become eligible immediately.
172
171
 
172
+ `session.append.dispatch(...events)` chooses the other execution path. It
173
+ commits first, skips the current invocation's inline drain, and awaits an
174
+ immediate recovery send. It requires configured recovery. Queue delivery then
175
+ claims the same durable events through the normal drain path. If the send
176
+ fails, retry with the same explicit event IDs; the append itself may already
177
+ have committed. The recovery message is a wakeup, not worker affinity. An
178
+ already-active drain may claim the new work first.
179
+
173
180
  An event type without a handler settles in the append transaction with no
174
181
  dispatch attempt. It starts no drain or recovery arm when the session has no
175
182
  older pending work. If older handled work is pending, the append still wakes
@@ -231,11 +238,9 @@ Four eight-second handlers returned one after another do not fit together in a
231
238
  | t29 | Handler 3 finishes; handler 4 starts | Events 1 through 3 processed |
232
239
  | t37 | Handler 4 finishes | Session settled |
233
240
 
234
- The retry restarts handler 3; it does not resume its old call.
235
-
236
- One handler that always exceeds a fresh invocation cannot finish this way. A2
237
- cannot checkpoint arbitrary async code. Split the work into smaller events or
238
- increase the function duration.
241
+ The retry starts handler 3 again from its entry point. Keep each handler within
242
+ a fresh invocation. Split longer work into smaller events or increase the
243
+ function duration.
239
244
 
240
245
  ## Crash around a returned batch
241
246
 
@@ -1,17 +1,12 @@
1
1
  ---
2
2
  title: Timers and delays
3
- description: There is no sleep(). A delayed action is an event, delivered later, by anything that can make an HTTP call.
3
+ description: A delayed action is an event delivered later by anything that can make an HTTP call.
4
4
  ---
5
5
 
6
- ## There is no `sleep()`
6
+ ## Schedule delayed events
7
7
 
8
- Workflow engines let you sleep inside a function, then perform heroics to
9
- make that survive a serverless platform. A2 doesn't. If something should
10
- happen in five days, then in five days, something should append an event.
11
- That's scheduling.
12
-
13
- The scheduled thing is data (a session id and an event), not a suspended
14
- function. No closure has to survive the gap.
8
+ If something should happen in five days, schedule an HTTP call that appends
9
+ the event then. The scheduler stores the session id and event until delivery.
15
10
 
16
11
  ## Schedule an event
17
12
 
@@ -40,6 +40,19 @@ box; transaction-mode poolers included, which is exactly why polling
40
40
  is the default. `pg` is an optional peer dependency; pass
41
41
  `connectionString`, or inject your own pool as `client`.
42
42
 
43
+ Prefer Redis? `redis({ url })` from `experimental-a2/log-redis` stores each
44
+ session as a Redis Stream and streams push-natively: writes to watched
45
+ sessions publish a disposable wake-up (sessions nobody watches cost no extra
46
+ command), one shared subscriber connection per process serves every connected
47
+ viewer, and a safety re-read covers a lost wake-up within ten seconds.
48
+ Connections scale with your processes, not with your audience.
49
+ `ioredis` is an optional peer dependency; pass `url`, or inject a client.
50
+ Works on single instances and non-cluster providers such as Upstash, where
51
+ durability is on by default. When only a REST API is available,
52
+ `redisHttp({ url, token })` from `experimental-a2/log-redis-http` speaks the
53
+ same storage over `fetch`, holds no connections at all, and polls on the same
54
+ adaptive cadence as Postgres.
55
+
43
56
  This configures storage for A2's session logs. It does not connect A2 to your
44
57
  application tables or make them part of the append transaction. See
45
58
  [A2 and your database](/guides/application-data) for that boundary.
@@ -115,8 +128,32 @@ so they do not create an independent stream of watchdog callbacks.
115
128
  Events appended or returned by handlers ride their current execution window
116
129
  and add no recovery operation of their own.
117
130
 
118
- No cron, no sweep, no notification bookkeeping. The queue message is
119
- the recovery state, and the log is the only thing it consults.
131
+ The queue message carries the recovery state, and recovery consults the log.
132
+
133
+ ### Dispatch in a fresh invocation
134
+
135
+ Ordinary `append` starts handlers inline and uses recovery as the watchdog.
136
+ Use `append.dispatch` when the current request should only persist the input
137
+ and hand pending work directly to the queue:
138
+
139
+ ```ts app/api/imports/route.ts
140
+ import { ordersServer } from '@/server'
141
+
142
+ export async function POST(request: Request) {
143
+ const { orderId, shopId, items } = await request.json()
144
+ await ordersServer.session(orderId).append.dispatch({
145
+ id: `created:${orderId}`,
146
+ type: 'created',
147
+ payload: { shopId, items },
148
+ })
149
+ return Response.json({ accepted: true }, { status: 202 })
150
+ }
151
+ ```
152
+
153
+ The call awaits the immediate queue send and does not start an inline drain.
154
+ If the send fails, the event may already be durable. Its explicit ID makes the
155
+ retry idempotent. An existing drain for the same session can still claim the
156
+ event first; dispatch controls the wakeup path, not worker affinity.
120
157
 
121
158
  ## 3. When an event dead-letters
122
159
 
package/docs/index.mdx CHANGED
@@ -14,8 +14,8 @@ Handlers react to them, and usually return the next one. The browser follows
14
14
  the same log and folds the same state as the server. That's the whole model;
15
15
  the rest of this page is it happening.
16
16
 
17
- There is no declared state machine. Events record facts, reducers compute the
18
- current view, and handlers perform the reactions.
17
+ Events record facts, reducers compute the current view, and handlers perform
18
+ the reactions.
19
19
 
20
20
  ## Define a contract
21
21
 
@@ -39,8 +39,7 @@ export const orders = a2.contract({
39
39
 
40
40
  ## React to events
41
41
 
42
- Handlers are plain async functions: no determinism rules, no replay, no
43
- wrappers around side effects. Each one reacts to a fact and usually
42
+ Handlers are plain async functions. Each one reacts to a fact and usually
44
43
  returns the next one.
45
44
 
46
45
  ```ts server/orders.ts
@@ -178,32 +177,6 @@ twenty lines; [Live UI](/guides/react) wires it end to end.
178
177
 
179
178
  ## FAQ
180
179
 
181
- <details>
182
- <summary>Why not a workflow engine?</summary>
183
-
184
- Workflow engines replay your code from the top on every wake-up. So the
185
- code has to be deterministic, so every side effect gets wrapped in a step
186
- function, and `sleep()` becomes something magical instead of something
187
- you'd never call in a serverless function.
188
-
189
- A2's answer is older and simpler: write everything down. Every meaningful
190
- thing that happens is an event in a log. Handlers are stateless functions
191
- that react to one event at a time. State isn't stored. It's computed, by
192
- folding over the log whenever you need it. There's no orchestrator to
193
- operate.
194
-
195
- </details>
196
-
197
- <details>
198
- <summary>Isn't this just event sourcing?</summary>
199
-
200
- It's the useful core of it. A log of facts, state as a fold: the idea is
201
- decades old, and it's a good one. A2 cuts the ceremony that made it a big
202
- commitment. No command bus, no projection cluster, no upcasting
203
- framework. A contract, a log, handlers, reducers.
204
-
205
- </details>
206
-
207
180
  <details>
208
181
  <summary>Can I use A2 without handlers?</summary>
209
182
 
@@ -217,12 +190,11 @@ See [Events without handlers](/concepts/handlers#events-without-handlers).
217
190
  </details>
218
191
 
219
192
  <details>
220
- <summary>How do I wait five days?</summary>
193
+ <summary>How do I schedule an event for later?</summary>
221
194
 
222
- You don't sleep. You schedule an event. Anything that can deliver an
223
- HTTP call later (QStash, a cron, a payment provider's webhook) hits a
224
- route that appends. The scheduled thing is data, a session id plus an
225
- event, not a suspended function. See [Timers](/guides/timers).
195
+ Anything that can deliver an HTTP call later (QStash, a cron, a payment
196
+ provider's webhook) can hit a route that appends. Schedule the session id and
197
+ event as the request payload. See [Timers](/guides/timers).
226
198
 
227
199
  </details>
228
200
 
@@ -357,6 +357,30 @@ append still starts session healing when older handled work is pending. The
357
357
  log reports that session-wide pending state as part of the atomic append, so
358
358
  this decision needs no follow-up read.
359
359
 
360
+ ### `session.append.dispatch(...events)`
361
+
362
+ ```ts
363
+ session.append.dispatch(
364
+ ...events: Array<{ type: string; payload: unknown; id?: string }>
365
+ ): Promise<Event[]>
366
+ ```
367
+
368
+ Commits the same atomic batch, but sends pending work directly to configured
369
+ recovery instead of starting an inline drain. The call awaits acceptance of an
370
+ immediate recovery message. It throws before writing if the server has no
371
+ recovery adapter.
372
+
373
+ If the queue send fails, the events are already durable. Give them explicit
374
+ IDs and retry the same dispatch safely. A batch that leaves no pending handler
375
+ work does not send a recovery message. Dispatch chooses how this append wakes
376
+ the session; it does not reserve events for one worker. A drain that is already
377
+ active may still claim newly eligible work first.
378
+
379
+ `dispatch` exists only on a server session's top-level append. Handler-scoped
380
+ `ctx.session.append(name, ...events)` already runs inside an active drain and
381
+ does not expose `dispatch`. Its immediate children and atomically returned
382
+ children become eligible in that drain.
383
+
360
384
  ### `session.history()`
361
385
 
362
386
  ```ts
@@ -756,6 +780,7 @@ alert on are all mid-span.
756
780
  | Attribute | Span | When | Values |
757
781
  | --------------------- | ----------- | ----- | ----------------------------------------------------- |
758
782
  | `a2.append.source` | `a2.append` | start | `external` \| `handler` |
783
+ | `a2.append.mode` | `a2.append` | start | `inline` \| `dispatch` |
759
784
  | `a2.append.types` | `a2.append` | start | comma-joined event types |
760
785
  | `a2.append.count` | `a2.append` | start | batch size |
761
786
  | `a2.append.armed` | `a2.append` | mid | `false` when the recovery arm failed and this append degraded to append-driven healing |
@@ -835,6 +860,7 @@ dashboard returns 501 for those logs.
835
860
  | `experimental-a2/http` | route-side transport helpers | none |
836
861
  | `experimental-a2/log-postgres` | `postgres`: Postgres log backend | `pg` (or inject a client) |
837
862
  | `experimental-a2/log-redis` | `redis`: Redis Streams log backend, push-native streaming | `ioredis` (or inject a client) |
863
+ | `experimental-a2/log-redis-http` | `redisHttp`: the same Redis log over provider REST APIs (Upstash) | none |
838
864
  | `experimental-a2/log-sqlite` | SQLite log backend | none |
839
865
  | `experimental-a2/log-memory` | in-memory log backend | none |
840
866
  | `experimental-a2/recovery-vercel` | `vercelQueues` recovery | `@vercel/queue` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -40,6 +40,7 @@
40
40
  "./log-sqlite": "./dist/log-sqlite.js",
41
41
  "./log-postgres": "./dist/log-postgres.js",
42
42
  "./log-redis": "./dist/log-redis.js",
43
+ "./log-redis-http": "./dist/log-redis-http.js",
43
44
  "./recovery-vercel": "./dist/recovery-vercel.js",
44
45
  "./cache-indexeddb": "./dist/cache-indexeddb.js",
45
46
  "./otel": "./dist/otel.js",