experimental-a2 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 056cf86: Add `session.append.dispatch(...events)` to persist events and hand pending
8
+ handler work directly to configured recovery instead of starting it inline.
9
+
3
10
  ## 0.1.0
4
11
 
5
12
  ### Minor Changes
package/dist/ai-server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { t as A2Error } from "./errors-BJRMd-h6.js";
2
- import { t as createServer } from "./server-BWffWe5A.js";
2
+ import { t as createServer } from "./server-DJgD2YWP.js";
3
3
  import { convertToModelMessages, stepCountIs, streamText, toUIMessageStream } from "ai";
4
4
  //#region src/ai-sdk-step.ts
5
5
  const CONTROLLED_SETTINGS = [
@@ -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-DJgD2YWP.js";
2
2
  export { createServer };
@@ -170,6 +170,14 @@ telemetry span records `a2.append.armed = false`, but committed work continues.
170
170
  `ctx.session.append` rides the active session drain and adds no arm. Returned
171
171
  events enter the log as part of completion, then become eligible immediately.
172
172
 
173
+ `session.append.dispatch(...events)` chooses the other execution path. It
174
+ commits first, skips the current invocation's inline drain, and awaits an
175
+ immediate recovery send. It requires configured recovery. Queue delivery then
176
+ claims the same durable events through the normal drain path. If the send
177
+ fails, retry with the same explicit event IDs; the append itself may already
178
+ have committed. The recovery message is a wakeup, not worker affinity. An
179
+ already-active drain may claim the new work first.
180
+
173
181
  An event type without a handler settles in the append transaction with no
174
182
  dispatch attempt. It starts no drain or recovery arm when the session has no
175
183
  older pending work. If older handled work is pending, the append still wakes
@@ -118,6 +118,31 @@ and add no recovery operation of their own.
118
118
  No cron, no sweep, no notification bookkeeping. The queue message is
119
119
  the recovery state, and the log is the only thing it consults.
120
120
 
121
+ ### Dispatch in a fresh invocation
122
+
123
+ Ordinary `append` starts handlers inline and uses recovery as the watchdog.
124
+ Use `append.dispatch` when the current request should only persist the input
125
+ and hand pending work directly to the queue:
126
+
127
+ ```ts app/api/imports/route.ts
128
+ import { ordersServer } from '@/server'
129
+
130
+ export async function POST(request: Request) {
131
+ const { orderId, shopId, items } = await request.json()
132
+ await ordersServer.session(orderId).append.dispatch({
133
+ id: `created:${orderId}`,
134
+ type: 'created',
135
+ payload: { shopId, items },
136
+ })
137
+ return Response.json({ accepted: true }, { status: 202 })
138
+ }
139
+ ```
140
+
141
+ The call awaits the immediate queue send and does not start an inline drain.
142
+ If the send fails, the event may already be durable. Its explicit ID makes the
143
+ retry idempotent. An existing drain for the same session can still claim the
144
+ event first; dispatch controls the wakeup path, not worker affinity.
145
+
121
146
  ## 3. When an event dead-letters
122
147
 
123
148
  After ten caught handler failures, A2 stops retrying an event. It blocks later
@@ -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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",