experimental-a2 0.2.0 → 0.4.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 (92) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/ai-B4YhEnfw.d.ts +333 -0
  3. package/dist/ai-server.d.ts +48 -9
  4. package/dist/ai-server.js +121 -49
  5. package/dist/ai.d.ts +2 -303
  6. package/dist/ai.js +231 -86
  7. package/dist/cli-B3VuxoDe.js +597 -0
  8. package/dist/cli-bin.d.ts +1 -0
  9. package/dist/cli-bin.js +5 -0
  10. package/dist/cli.d.ts +19 -0
  11. package/dist/cli.js +2 -0
  12. package/dist/client-BrfDXQ8A.d.ts +155 -0
  13. package/dist/client-Bt4tAKi9.js +798 -0
  14. package/dist/client.d.ts +2 -90
  15. package/dist/client.js +1 -409
  16. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
  17. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
  18. package/dist/devtools-J_jZ2vQf.d.ts +151 -0
  19. package/dist/devtools-kJJaORn-.js +338 -0
  20. package/dist/devtools-server.browser.js +1 -1
  21. package/dist/devtools-server.d.ts +2 -2
  22. package/dist/devtools-server.js +224 -43
  23. package/dist/devtools.d.ts +2 -0
  24. package/dist/devtools.js +2 -0
  25. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
  26. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
  27. package/dist/http.d.ts +71 -13
  28. package/dist/http.js +302 -41
  29. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +2 -2
  32. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
  33. package/dist/internal-aEotMzu_.js +209 -0
  34. package/dist/otel.d.ts +1 -1
  35. package/dist/platform-B4TnJtWu.js +32 -0
  36. package/dist/react.d.ts +37 -14
  37. package/dist/react.js +26 -15
  38. package/dist/scheduler-qstash.d.ts +78 -0
  39. package/dist/scheduler-qstash.js +499 -0
  40. package/dist/scheduler-task-BpzhPnRS.js +54 -0
  41. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
  42. package/dist/scheduler-vercel.js +226 -0
  43. package/dist/server-CcNnFnoW.js +1405 -0
  44. package/dist/server-YtPq7hjw.d.ts +260 -0
  45. package/dist/server.d.ts +4 -155
  46. package/dist/server.js +2 -2
  47. package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
  48. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
  49. package/dist/store-codec-DTG0Ftek.js +8 -0
  50. package/dist/store-memory.d.ts +11 -0
  51. package/dist/{log-memory.js → store-memory.js} +127 -24
  52. package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
  53. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
  54. package/dist/{log-postgres.js → store-postgres.js} +158 -24
  55. package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
  56. package/dist/store-redis-http.d.ts +21 -0
  57. package/dist/store-redis-http.js +70 -0
  58. package/dist/store-redis.d.ts +37 -0
  59. package/dist/store-redis.js +298 -0
  60. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
  61. package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
  62. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
  63. package/dist/testing.browser.d.ts +1 -0
  64. package/dist/testing.browser.js +4 -0
  65. package/dist/testing.d.ts +31 -0
  66. package/dist/testing.js +101 -0
  67. package/dist/wire-DCUZBUlT.js +222 -0
  68. package/docs/01-quickstart.mdx +4 -5
  69. package/docs/concepts/01-contracts.mdx +21 -17
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +26 -29
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -54
  74. package/docs/guides/02-cancellation.mdx +30 -4
  75. package/docs/guides/03-react.mdx +20 -21
  76. package/docs/guides/04-local-first.mdx +1 -1
  77. package/docs/guides/05-production.mdx +321 -60
  78. package/docs/guides/06-ai-agents.mdx +249 -49
  79. package/docs/guides/07-devtools.mdx +137 -12
  80. package/docs/guides/08-application-data.mdx +1 -1
  81. package/docs/guides/09-presence.mdx +284 -0
  82. package/docs/guides/10-transports.mdx +131 -0
  83. package/docs/index.mdx +22 -46
  84. package/docs/reference/01-api.mdx +751 -102
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -6
  87. package/dist/internal-D6wNxTck.js +0 -36
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/log-redis.d.ts +0 -31
  90. package/dist/recovery-vercel.js +0 -119
  91. package/dist/server-DJgD2YWP.js +0 -877
  92. package/dist/wire-BVsgR8o9.js +0 -62
@@ -36,11 +36,15 @@ export async function POST(req: Request) {
36
36
  | ------------------------- | ------------------------------------------------------------------ | --------- |
37
37
  | `INVALID_PAYLOAD` | a payload fails its schema; nothing was written, and `details` carries the issues ([Standard Schema](https://standardschema.dev) format) | no |
38
38
  | `UNKNOWN_EVENT_TYPE` | an event type isn't in the contract's `events` map | no |
39
+ | `UNKNOWN_PRESENCE_FIELD` | a presence field isn't in the contract's `presence` map | no |
40
+ | `PRESENCE_NOT_SUPPORTED` | the contract declares `presence` but the configured store has no presence capability (thrown at construction; for environment-resolved stores, deferred to the first store use) | no |
39
41
  | `PARTIAL_DUPLICATE_BATCH` | a batch mixed already-appended and fresh events | no |
40
- | `LOG_UNAVAILABLE` | the log backend failed; nothing was written, original error as `cause` | **yes** |
41
- | `LOG_NOT_CONFIGURED` | production boot with no `log` configured (during `next build` page collection, deferred to first use) | no |
42
+ | `SUPERSEDED_ATTEMPT` | a handler append from an attempt a recovery claim has replaced; nothing was written, the successor owns the event. Also the abort reason on that handler's `ctx.signal` | no |
43
+ | `CLAIM_EXPIRED` | never thrown; only the `ctx.signal` abort reason when the attempt's lease lapsed without renewal (assumed loss, decided locally, not proof) | no |
44
+ | `STORE_UNAVAILABLE` | the store backend failed; nothing was written, original error as `cause` | **yes** |
45
+ | `STORE_NOT_CONFIGURED` | production boot with no `store` configured (during `next build` page collection, deferred to first use) | no |
42
46
 
43
- Retryability is derivable from the code. `LOG_UNAVAILABLE` is the only one
47
+ Retryability is derivable from the code. `STORE_UNAVAILABLE` is the only one
44
48
  worth retrying; the rest are deterministic caller bugs, and retrying them
45
49
  is wasted work.
46
50
 
@@ -69,9 +73,10 @@ The push route serializes an `A2Error` as:
69
73
  ```
70
74
 
71
75
  with a mapped status: `INVALID_PAYLOAD`, `UNKNOWN_EVENT_TYPE`, and
72
- `PARTIAL_DUPLICATE_BATCH` are 400; `LOG_UNAVAILABLE` is 503.
76
+ `PARTIAL_DUPLICATE_BATCH` are 400; `SUPERSEDED_ATTEMPT` is 409;
77
+ `STORE_UNAVAILABLE` is 503.
73
78
 
74
79
  The client's `push` deserializes the body back into an `A2Error`, so client
75
80
  and server code branch on identical codes. `push` auto-retries only
76
- `LOG_UNAVAILABLE`. The serializer pair ships in `experimental-a2/http`, alongside
81
+ `STORE_UNAVAILABLE`. The serializer pair ships in `experimental-a2/http`, alongside
77
82
  `parsePushBody` and `sseResponse`.
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.2.0",
3
+ "version": "0.4.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",
7
+ "bin": {
8
+ "a2": "./dist/cli-bin.js"
9
+ },
7
10
  "sideEffects": false,
8
11
  "engines": {
9
12
  "node": ">=22.13"
@@ -36,21 +39,29 @@
36
39
  "default": "./dist/ai-server.js"
37
40
  },
38
41
  "./http": "./dist/http.js",
39
- "./log-memory": "./dist/log-memory.js",
40
- "./log-sqlite": "./dist/log-sqlite.js",
41
- "./log-postgres": "./dist/log-postgres.js",
42
- "./log-redis": "./dist/log-redis.js",
43
- "./recovery-vercel": "./dist/recovery-vercel.js",
42
+ "./store-memory": "./dist/store-memory.js",
43
+ "./store-sqlite": "./dist/store-sqlite.js",
44
+ "./store-postgres": "./dist/store-postgres.js",
45
+ "./store-redis": "./dist/store-redis.js",
46
+ "./store-redis-http": "./dist/store-redis-http.js",
47
+ "./scheduler-qstash": "./dist/scheduler-qstash.js",
48
+ "./scheduler-vercel": "./dist/scheduler-vercel.js",
44
49
  "./cache-indexeddb": "./dist/cache-indexeddb.js",
45
50
  "./otel": "./dist/otel.js",
51
+ "./devtools": "./dist/devtools.js",
46
52
  "./devtools/server": {
47
53
  "browser": "./dist/devtools-server.browser.js",
48
54
  "default": "./dist/devtools-server.js"
49
55
  },
56
+ "./testing": {
57
+ "browser": "./dist/testing.browser.js",
58
+ "default": "./dist/testing.js"
59
+ },
50
60
  "./package.json": "./package.json"
51
61
  },
52
62
  "peerDependencies": {
53
63
  "@opentelemetry/api": "^1.9.0",
64
+ "@upstash/qstash": "^2.11.0",
54
65
  "@vercel/queue": "*",
55
66
  "ai": "^7.0.0",
56
67
  "ioredis": "^5.4.0",
@@ -61,6 +72,9 @@
61
72
  "@opentelemetry/api": {
62
73
  "optional": true
63
74
  },
75
+ "@upstash/qstash": {
76
+ "optional": true
77
+ },
64
78
  "@vercel/queue": {
65
79
  "optional": true
66
80
  },
@@ -79,12 +93,15 @@
79
93
  },
80
94
  "devDependencies": {
81
95
  "@electric-sql/pglite": "^0.3.14",
96
+ "@upstash/qstash": "^2.11.3",
82
97
  "ai": "^7.0.58",
83
98
  "ioredis": "^5.9.0",
84
99
  "@opentelemetry/api": "^1.9.0",
85
100
  "@types/pg": "^8.15.0",
101
+ "@types/ws": "^8.18.1",
86
102
  "@vercel/queue": "^0.2.0",
87
103
  "pg": "^8.16.0",
104
+ "ws": "^8.21.3",
88
105
  "@testing-library/react": "^16.1.0",
89
106
  "@types/react": "^19.0.0",
90
107
  "esbuild": "^0.25.0",
@@ -1,36 +0,0 @@
1
- //#region src/internal.ts
2
- const serverInternals = /* @__PURE__ */ new WeakMap();
3
- /**
4
- * Drain timing knobs. Mutable only as a white-box test seam. Claim
5
- * heartbeats run on real timers, so tests shrink these to keep suites
6
- * fast. Production always uses the defaults.
7
- */
8
- const DRAIN_TIMINGS = {
9
- claimTtlMs: 5e3,
10
- claimHeartbeatMs: 2e3,
11
- recoveryGraceMs: 1e3,
12
- recoveryArmTimeoutMs: 2e3
13
- };
14
- /**
15
- * Stream liveness knobs — the server-side SSE heartbeat and the
16
- * client-side stall watchdog. Paired by design: the client declares a
17
- * connection dead after roughly two missed heartbeats. Mutable only as
18
- * a white-box test seam; production always uses the defaults.
19
- */
20
- /**
21
- * Poll cadence for the poll-based log streams (sqlite, postgres) —
22
- * owned, not configurable: adaptive polling removed the operator's
23
- * reason to tune it (the floor keeps active streams smooth; the
24
- * ceiling bounds idle cost). Mutable only as a white-box test seam so
25
- * suites don't wait out real idle gaps.
26
- */
27
- const POLL_TIMINGS = {
28
- activeFloorMs: 25,
29
- idleCeilingMs: 250
30
- };
31
- const STREAM_TIMINGS = {
32
- sseHeartbeatMs: 15e3,
33
- stallTimeoutMs: 35e3
34
- };
35
- //#endregion
36
- export { serverInternals as i, POLL_TIMINGS as n, STREAM_TIMINGS as r, DRAIN_TIMINGS as t };
@@ -1,11 +0,0 @@
1
- import { c as IdSource, i as Clock, t as A2Log } from "./log-ldf5g8Cx.js";
2
- //#region src/log-memory.d.ts
3
- type MemoryLogOptions = {
4
- /** Injectable clock — every stored timestamp comes from here. */
5
- clock?: Clock;
6
- /** Injectable id source for generated event ids. */
7
- ids?: IdSource;
8
- };
9
- declare function memory(options?: MemoryLogOptions): A2Log;
10
- //#endregion
11
- export { MemoryLogOptions, memory };
@@ -1,31 +0,0 @@
1
- import { c as IdSource, i as Clock, t as A2Log } from "./log-ldf5g8Cx.js";
2
- //#region src/log-redis.d.ts
3
- /**
4
- * The minimal client this backend needs — `ioredis` matches it
5
- * structurally. `call` issues any command; `duplicate` opens the
6
- * dedicated connection each blocking live feed holds.
7
- */
8
- type RedisConnection = {
9
- call(command: string, ...args: Array<string | number>): Promise<unknown>;
10
- duplicate(): RedisConnection;
11
- disconnect(): void;
12
- };
13
- type RedisLogOptions = {
14
- /** Creates an `ioredis` client lazily (optional peer dep `ioredis`). */
15
- url?: string | undefined;
16
- /** Bring your own client — anything `call`/`duplicate`/`disconnect`. */
17
- client?: RedisConnection;
18
- /** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */
19
- keyPrefix?: string;
20
- /** Injectable clock — every stored timestamp comes from here. */
21
- clock?: Clock;
22
- /** Injectable id source for generated event ids. */
23
- ids?: IdSource;
24
- };
25
- type RedisLog = A2Log & {
26
- /** Disconnect the client and every live stream connection. */
27
- close(): Promise<void>;
28
- };
29
- declare function redis(options?: RedisLogOptions): RedisLog;
30
- //#endregion
31
- export { RedisConnection, RedisLog, RedisLogOptions, redis };
@@ -1,119 +0,0 @@
1
- import { i as serverInternals, t as DRAIN_TIMINGS } from "./internal-D6wNxTck.js";
2
- import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
- import { n as SYSTEM_CLOCK } from "./log-yJbXUf72.js";
4
- //#region src/recovery-vercel.ts
5
- /**
6
- * experimental-a2/recovery-vercel — queue-backed recovery over Vercel Queues.
7
- *
8
- * `vercelQueues()` returns an `A2Recovery` (a2-implementation.md §9):
9
- *
10
- * - `arm` rounds the requested watchdog time up to a one-second slot.
11
- * One message per `(contract, session, dueAt)` coalesces appends,
12
- * claim renewals and racing callbacks. `DuplicateMessageError` is
13
- * success because that due-time slot is already durable.
14
- * - `handler(...servers)` is the delivery route: look up the server for
15
- * the contract named in the message and drain it. Settled drains ack;
16
- * contention arms a later watchdog before acking; handler failure keeps
17
- * the current message and redelivers with backoff.
18
- *
19
- * `@vercel/queue` is an optional peer dependency, imported lazily. The
20
- * `transport` option is the injection seam — tests run a real little
21
- * in-memory queue through the same interface.
22
- */
23
- /**
24
- * The "not settled yet" signal for a stalled handler — an expected state,
25
- * not a transport failure. The default transport classifies it in
26
- * `@vercel/queue`'s `retry` option and reschedules the delivery quietly
27
- * (`{ afterSeconds }` → visibility change → 200 response, no error log)
28
- * instead of letting it surface as a thrown 500. Busy deliveries use a
29
- * fresh watchdog instead. Custom transports should treat this class the
30
- * same way when their protocol has a polite reschedule.
31
- */
32
- var RecoveryUnsettled = class extends Error {};
33
- /** Vercel Queues rejects shorter visibility changes. */
34
- const MIN_VISIBILITY_SECONDS = 30;
35
- /** Redelivery backoff for retry states: base, 2×, 4×… bounded. */
36
- const unsettledBackoffSeconds = (delaySeconds, deliveryCount) => Math.min(Math.max(delaySeconds * 2 ** Math.max(deliveryCount - 1, 0), MIN_VISIBILITY_SECONDS), 60);
37
- const vercelQueueTransport = async (delaySeconds) => {
38
- const queue = await import("@vercel/queue").catch(() => {
39
- throw new Error("experimental-a2/recovery-vercel needs the '@vercel/queue' package (optional peer dependency) — install it, or inject a transport");
40
- });
41
- return {
42
- send: (topic, message, options) => queue.send(topic, message, options),
43
- handleCallback: (handler) => queue.handleCallback(handler, {
44
- visibilityTimeoutSeconds: MIN_VISIBILITY_SECONDS,
45
- retry: (error, metadata) => error instanceof RecoveryUnsettled ? { afterSeconds: unsettledBackoffSeconds(delaySeconds, metadata.deliveryCount) } : void 0
46
- }),
47
- isDuplicate: (error) => error instanceof queue.DuplicateMessageError
48
- };
49
- };
50
- function vercelQueues(options = {}) {
51
- const topic = options.topic ?? "a2";
52
- const delaySeconds = options.delaySeconds ?? 5;
53
- const clock = options.clock ?? SYSTEM_CLOCK;
54
- const transport = retryableLazy(() => options.transport ? Promise.resolve(options.transport) : vercelQueueTransport(delaySeconds)).get;
55
- const sendWatchdog = async (opts) => {
56
- const t = await transport();
57
- const nowMs = clock.now().getTime();
58
- const requestedDueAt = opts.dueAt ?? nowMs + delaySeconds * 1e3;
59
- const dueAt = Math.ceil(requestedDueAt / 1e3) * 1e3;
60
- const recoveryId = `${opts.contract}:${opts.sessionId}:watchdog:${dueAt}`;
61
- try {
62
- await t.send(topic, {
63
- contract: opts.contract,
64
- sessionId: opts.sessionId,
65
- dueAt
66
- }, {
67
- delaySeconds: Math.max(0, Math.ceil((dueAt - nowMs) / 1e3)),
68
- idempotencyKey: recoveryId
69
- });
70
- } catch (err) {
71
- if (t.isDuplicate(err)) return;
72
- throw err;
73
- }
74
- };
75
- const successorDueAt = (message) => {
76
- const minimumDueAt = clock.now().getTime() + DRAIN_TIMINGS.claimTtlMs + DRAIN_TIMINGS.recoveryGraceMs;
77
- if (message.dueAt === void 0) return minimumDueAt;
78
- const beats = Math.max(1, Math.ceil((minimumDueAt - message.dueAt) / DRAIN_TIMINGS.claimHeartbeatMs));
79
- return message.dueAt + beats * DRAIN_TIMINGS.claimHeartbeatMs;
80
- };
81
- return {
82
- async arm({ contract, sessionId, dueAt }) {
83
- await sendWatchdog({
84
- contract,
85
- sessionId,
86
- ...dueAt !== void 0 ? { dueAt } : {}
87
- });
88
- },
89
- handler(...servers) {
90
- const byContract = /* @__PURE__ */ new Map();
91
- for (const server of servers) byContract.set(server.contract.name, server);
92
- return async (req) => {
93
- return (await transport()).handleCallback(async (message) => {
94
- const server = byContract.get(message.contract);
95
- if (!server) throw new Error(`a2 recovery: no server for contract '${message.contract}' — pass it to recovery.handler(...)`);
96
- const internals = serverInternals.get(server);
97
- const nextDueAt = successorDueAt(message);
98
- const result = internals ? await internals.recoveryDrain(message.sessionId, { recoveryDueAt: nextDueAt }) : {
99
- ...await server.drain(message.sessionId),
100
- outcome: "unknown"
101
- };
102
- if (result.settled) return;
103
- if (result.outcome === "busy") {
104
- if (result.recoveryArm) await result.recoveryArm;
105
- else await sendWatchdog({
106
- contract: message.contract,
107
- sessionId: message.sessionId,
108
- dueAt: nextDueAt
109
- });
110
- return;
111
- }
112
- throw new RecoveryUnsettled(`a2 recovery: session '${message.sessionId}' of '${message.contract}' not settled yet (${result.outcome}) — rescheduled for redelivery`);
113
- })(req);
114
- };
115
- }
116
- };
117
- }
118
- //#endregion
119
- export { RecoveryUnsettled, vercelQueues };