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
@@ -0,0 +1,21 @@
1
+ import { c as IdSource, i as Clock, t as A2Store } from "./store-C3sNAaBT.js";
2
+ //#region src/store-redis-http.d.ts
3
+ type RedisHttpStoreOptions = {
4
+ /** REST endpoint, e.g. `process.env.UPSTASH_REDIS_REST_URL`. */
5
+ url: string;
6
+ /** Bearer token, e.g. `process.env.UPSTASH_REDIS_REST_TOKEN`. */
7
+ token: string;
8
+ /** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */
9
+ keyPrefix?: string;
10
+ /** Injectable clock — every stored timestamp comes from here. */
11
+ clock?: Clock;
12
+ /** Injectable id source for generated event ids. */
13
+ ids?: IdSource;
14
+ };
15
+ type RedisHttpStore = A2Store & {
16
+ /** Nothing to disconnect; present so callers can treat stores uniformly. */
17
+ close(): Promise<void>;
18
+ };
19
+ declare function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore;
20
+ //#endregion
21
+ export { RedisHttpStore, RedisHttpStoreOptions, redisHttp };
@@ -0,0 +1,70 @@
1
+ import { t as A2Error } from "./errors-BQuJpe82.js";
2
+ import { n as pollingStream } from "./store-polling-DgrrAE3d.js";
3
+ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
4
+ import { t as createRedisStoreCore } from "./store-redis-core-DWqx3F47.js";
5
+ //#region src/store-redis-http.ts
6
+ /**
7
+ * experimental-a2/store-redis-http — the same Redis Streams store over a
8
+ * provider REST API (Upstash-shaped: POST one command as a JSON array,
9
+ * receive `{ result }` or `{ error }`). The storage semantics live in
10
+ * store-redis-core.ts, shared with experimental-a2/store-redis.
11
+ *
12
+ * Fully connectionless: every command is one `fetch`, so it works
13
+ * where a Redis-protocol connection cannot exist or cannot be spared.
14
+ * Without a connection there is no push and no pub/sub, so `stream()`
15
+ * is the shared activity-adaptive poll loop (25ms while events flow,
16
+ * backing off to a 250ms idle ceiling), and presence is the degraded
17
+ * tier: `set`/`read` from the shared core, no `subscribe`. No peer
18
+ * dependencies.
19
+ */
20
+ function redisHttp(options) {
21
+ if (!options.url || !options.token) throw new TypeError("redisHttp() needs a url and a token");
22
+ const { url, token } = options;
23
+ const call = async (command, ...args) => {
24
+ let response;
25
+ try {
26
+ response = await fetch(url, {
27
+ method: "POST",
28
+ headers: {
29
+ authorization: `Bearer ${token}`,
30
+ "content-type": "application/json"
31
+ },
32
+ body: JSON.stringify([command, ...args.map(String)])
33
+ });
34
+ } catch (err) {
35
+ throw new A2Error("STORE_UNAVAILABLE", "redis rest request failed", { cause: err });
36
+ }
37
+ const body = await response.json().catch(() => null);
38
+ if (body === null || !response.ok || body.error !== void 0) throw new A2Error("STORE_UNAVAILABLE", `redis rest command failed: ${body?.error ?? `status ${response.status}`}`);
39
+ return body.result;
40
+ };
41
+ const core = createRedisStoreCore({
42
+ call,
43
+ clock: options.clock ?? SYSTEM_CLOCK,
44
+ ids: options.ids ?? RANDOM_IDS,
45
+ keyPrefix: options.keyPrefix ?? "a2"
46
+ });
47
+ return {
48
+ append: core.append,
49
+ read: core.read,
50
+ claimAvailable: core.claimAvailable,
51
+ renewClaims: core.renewClaims,
52
+ completeAttempt: core.completeAttempt,
53
+ failAttempt: core.failAttempt,
54
+ readState: core.readState,
55
+ putSnapshot: core.putSnapshot,
56
+ presence: {
57
+ async set(ns, participant, values, meta) {
58
+ await core.presence.set(ns, participant, values, meta);
59
+ },
60
+ read: core.presence.read
61
+ },
62
+ inspect: core.inspect,
63
+ stream(sessionId, opts) {
64
+ return pollingStream((afterIndex) => core.readRange(sessionId, afterIndex), opts?.startAfter !== void 0 ? { startAfter: opts.startAfter } : {});
65
+ },
66
+ async close() {}
67
+ };
68
+ }
69
+ //#endregion
70
+ export { redisHttp };
@@ -0,0 +1,37 @@
1
+ import { c as IdSource, i as Clock, t as A2Store } from "./store-C3sNAaBT.js";
2
+ //#region src/store-redis.d.ts
3
+ /**
4
+ * The minimal client this backend needs — `ioredis` matches it
5
+ * structurally. `call` issues any command; `duplicate` opens the one
6
+ * shared subscriber connection; `on` delivers its pub/sub messages.
7
+ *
8
+ * The client must restore its subscriptions after a reconnect
9
+ * (`ioredis` does). One that doesn't stays correct — the safety
10
+ * re-read delivers everything — but every live feed silently degrades
11
+ * to safety-read latency from that point on.
12
+ */
13
+ type RedisConnection = {
14
+ call(command: string, ...args: Array<string | number>): Promise<unknown>;
15
+ duplicate(): RedisConnection;
16
+ on(event: "message", listener: (channel: string, message: string) => void): unknown;
17
+ disconnect(): void;
18
+ };
19
+ type RedisStoreOptions = {
20
+ /** Creates an `ioredis` client lazily (optional peer dep `ioredis`). */
21
+ url?: string | undefined;
22
+ /** Bring your own client — anything `call`/`duplicate`/`on`/`disconnect`. */
23
+ client?: RedisConnection;
24
+ /** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */
25
+ keyPrefix?: string;
26
+ /** Injectable clock — every stored timestamp comes from here. */
27
+ clock?: Clock;
28
+ /** Injectable id source for generated event ids. */
29
+ ids?: IdSource;
30
+ };
31
+ type RedisStore = A2Store & {
32
+ /** Disconnect the command client and the shared subscriber. */
33
+ close(): Promise<void>;
34
+ };
35
+ declare function redis(options?: RedisStoreOptions): RedisStore;
36
+ //#endregion
37
+ export { RedisConnection, RedisStore, RedisStoreOptions, redis };
@@ -0,0 +1,298 @@
1
+ import { t as A2Error } from "./errors-BQuJpe82.js";
2
+ import { r as NOTIFY_TIMINGS } from "./internal-aEotMzu_.js";
3
+ import { t as defaultSleep } from "./store-polling-DgrrAE3d.js";
4
+ import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
5
+ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
6
+ import { t as createRedisStoreCore } from "./store-redis-core-DWqx3F47.js";
7
+ //#region src/store-redis.ts
8
+ function redis(options = {}) {
9
+ const clock = options.clock ?? SYSTEM_CLOCK;
10
+ const ids = options.ids ?? RANDOM_IDS;
11
+ const prefix = options.keyPrefix ?? "a2";
12
+ if (!options.client && !options.url) throw new TypeError("redis() needs a url or an injected client");
13
+ const connection = retryableLazy(async () => {
14
+ if (options.client) return options.client;
15
+ return new (await (import("ioredis").catch(() => {
16
+ throw new A2Error("STORE_NOT_CONFIGURED", "experimental-a2/store-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
17
+ }))).default(options.url);
18
+ });
19
+ const client = connection.get;
20
+ const notifyChannel = (sessionId) => `${prefix}:${sessionId}:notify`;
21
+ const presenceChannel = (ns) => `${prefix}:${ns}:presence`;
22
+ const core = createRedisStoreCore({
23
+ call: async (command, ...args) => {
24
+ return (await client()).call(command, ...args);
25
+ },
26
+ clock,
27
+ ids,
28
+ keyPrefix: prefix,
29
+ notify: (sessionId, count) => {
30
+ client().then((c) => c.call("publish", notifyChannel(sessionId), count)).catch(() => {});
31
+ }
32
+ });
33
+ let severed = false;
34
+ let subscriber = null;
35
+ /** channel → parked-feed wake-ups, armed before each catch-up read. */
36
+ const wakers = /* @__PURE__ */ new Map();
37
+ /** channel → refcounted subscription held for each live iterator. */
38
+ const subscriptions = /* @__PURE__ */ new Map();
39
+ /**
40
+ * channel → last presence-marker refresh, throttling the SET.
41
+ * Deliberately real time (`Date.now()`), not the injected clock:
42
+ * the marker's PX expiry runs on server real time, and the throttle
43
+ * must tick with the TTL it refreshes or time-traveling tests would
44
+ * desync the two. Process-local and never stored, so the injected
45
+ * clock's every-stored-timestamp pledge is untouched.
46
+ */
47
+ const watchedRefreshedAt = /* @__PURE__ */ new Map();
48
+ /**
49
+ * `:presence` channel → live listeners. Same-instance patches are
50
+ * delivered here synchronously by `presence.set`, so subscribe/set
51
+ * ordering never depends on the SUBSCRIBE command settling; the
52
+ * channel carries only other instances' patches, with own echoes
53
+ * skipped by `src`.
54
+ */
55
+ const presenceListeners = /* @__PURE__ */ new Map();
56
+ const instanceId = crypto.randomUUID();
57
+ const deliverRemotePresence = (listeners, message) => {
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(message);
61
+ } catch {
62
+ return;
63
+ }
64
+ if (parsed.src === instanceId) return;
65
+ const patch = {
66
+ participant: parsed.participant,
67
+ values: parsed.values,
68
+ seen: parsed.seen,
69
+ at: new Date(parsed.at)
70
+ };
71
+ for (const listener of listeners) listener(patch);
72
+ };
73
+ const getSubscriber = () => {
74
+ subscriber ??= (async () => {
75
+ const conn = (await client()).duplicate();
76
+ conn.on("message", (channel, message) => {
77
+ const set = wakers.get(channel);
78
+ if (set) for (const wake of set) wake();
79
+ const listeners = presenceListeners.get(channel);
80
+ if (listeners) deliverRemotePresence(listeners, message);
81
+ });
82
+ return conn;
83
+ })();
84
+ return subscriber;
85
+ };
86
+ /**
87
+ * The subscriber, gated on connection readiness — for channel
88
+ * commands only (`close` uses the ungated promise, so it can never
89
+ * hang on an unreachable server). SUBSCRIBE carries redis's
90
+ * ok-loading flag, so ioredis writes it mid-handshake, ahead of the
91
+ * ready check's INFO — which then finds the connection already in
92
+ * subscriber mode and fails the check (a spurious error event plus
93
+ * a reconnect). ECHO has no such flag: it resolves only once the
94
+ * connection is ready, so every channel command chained behind it
95
+ * lands after the check.
96
+ */
97
+ let subscriberGate = null;
98
+ const gatedSubscriber = () => {
99
+ subscriberGate ??= getSubscriber().then(async (conn) => {
100
+ await conn.call("echo", "ready");
101
+ return conn;
102
+ });
103
+ return subscriberGate;
104
+ };
105
+ const acquireChannel = async (channel) => {
106
+ const existing = subscriptions.get(channel);
107
+ if (existing) {
108
+ existing.refs += 1;
109
+ try {
110
+ await existing.ready;
111
+ } catch (err) {
112
+ existing.refs -= 1;
113
+ throw err;
114
+ }
115
+ return;
116
+ }
117
+ const lease = {
118
+ refs: 1,
119
+ ready: gatedSubscriber().then((conn) => conn.call("subscribe", channel))
120
+ };
121
+ subscriptions.set(channel, lease);
122
+ try {
123
+ await lease.ready;
124
+ } catch (err) {
125
+ if (subscriptions.get(channel) === lease) subscriptions.delete(channel);
126
+ throw err;
127
+ }
128
+ };
129
+ const releaseChannel = (channel) => {
130
+ const lease = subscriptions.get(channel);
131
+ if (!lease) return;
132
+ lease.refs -= 1;
133
+ if (lease.refs > 0) return;
134
+ subscriptions.delete(channel);
135
+ watchedRefreshedAt.delete(channel);
136
+ gatedSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
137
+ };
138
+ const armWaker = (channel) => {
139
+ let wake;
140
+ const wakeup = new Promise((resolve) => {
141
+ wake = resolve;
142
+ });
143
+ let set = wakers.get(channel);
144
+ if (!set) {
145
+ set = /* @__PURE__ */ new Set();
146
+ wakers.set(channel, set);
147
+ }
148
+ set.add(wake);
149
+ return {
150
+ wakeup,
151
+ wake,
152
+ disarm: () => {
153
+ set.delete(wake);
154
+ if (set.size === 0) wakers.delete(channel);
155
+ }
156
+ };
157
+ };
158
+ return {
159
+ append: core.append,
160
+ read: core.read,
161
+ claimAvailable: core.claimAvailable,
162
+ renewClaims: core.renewClaims,
163
+ completeAttempt: core.completeAttempt,
164
+ failAttempt: core.failAttempt,
165
+ readState: core.readState,
166
+ putSnapshot: core.putSnapshot,
167
+ inspect: core.inspect,
168
+ presence: {
169
+ async set(ns, participant, values, meta) {
170
+ const patch = await core.presence.set(ns, participant, values, meta);
171
+ if (!patch) return;
172
+ const channel = presenceChannel(ns);
173
+ const listeners = presenceListeners.get(channel);
174
+ if (listeners) for (const listener of listeners) listener(patch);
175
+ const message = JSON.stringify({
176
+ src: instanceId,
177
+ participant: patch.participant,
178
+ values: patch.values,
179
+ seen: patch.seen,
180
+ at: patch.at.getTime()
181
+ });
182
+ await client().then((c) => c.call("publish", channel, message)).catch(() => {});
183
+ },
184
+ read: core.presence.read,
185
+ subscribe(ns, onPatch) {
186
+ const channel = presenceChannel(ns);
187
+ let listeners = presenceListeners.get(channel);
188
+ if (!listeners) {
189
+ listeners = /* @__PURE__ */ new Set();
190
+ presenceListeners.set(channel, listeners);
191
+ }
192
+ listeners.add(onPatch);
193
+ acquireChannel(channel).catch(() => {});
194
+ let stopped = false;
195
+ return () => {
196
+ if (stopped) return;
197
+ stopped = true;
198
+ listeners.delete(onPatch);
199
+ if (listeners.size === 0) presenceListeners.delete(channel);
200
+ releaseChannel(channel);
201
+ };
202
+ }
203
+ },
204
+ stream(sessionId, opts) {
205
+ const startAfter = opts?.startAfter ?? 0;
206
+ const channel = notifyChannel(sessionId);
207
+ return { [Symbol.asyncIterator]() {
208
+ let last = startAfter;
209
+ let buffer = [];
210
+ let closed = false;
211
+ let subscribed = false;
212
+ let pending = null;
213
+ let interrupt = null;
214
+ const release = () => {
215
+ if (!subscribed) return;
216
+ subscribed = false;
217
+ releaseChannel(channel);
218
+ };
219
+ return {
220
+ async next() {
221
+ for (;;) {
222
+ if (closed) {
223
+ release();
224
+ return {
225
+ value: void 0,
226
+ done: true
227
+ };
228
+ }
229
+ const row = buffer.shift();
230
+ if (row) {
231
+ last = row.index;
232
+ return {
233
+ value: row,
234
+ done: false
235
+ };
236
+ }
237
+ if (severed) {
238
+ release();
239
+ throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
240
+ }
241
+ const waker = armWaker(channel);
242
+ interrupt = waker.wake;
243
+ try {
244
+ if (!subscribed) {
245
+ await acquireChannel(channel);
246
+ subscribed = true;
247
+ }
248
+ const markedAt = watchedRefreshedAt.get(channel) ?? 0;
249
+ if (Date.now() - markedAt >= NOTIFY_TIMINGS.safetyReadMs) {
250
+ watchedRefreshedAt.set(channel, Date.now());
251
+ await core.markWatched(sessionId, NOTIFY_TIMINGS.safetyReadMs * 3);
252
+ }
253
+ buffer = await core.readEvents(sessionId, last);
254
+ if (buffer.length === 0 && !closed && !severed) {
255
+ pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
256
+ await Promise.race([waker.wakeup, pending.promise]);
257
+ pending.cancel();
258
+ pending = null;
259
+ }
260
+ } catch (err) {
261
+ release();
262
+ if (closed) return {
263
+ value: void 0,
264
+ done: true
265
+ };
266
+ if (err instanceof A2Error) throw err;
267
+ throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
268
+ } finally {
269
+ waker.disarm();
270
+ interrupt = null;
271
+ }
272
+ }
273
+ },
274
+ async return() {
275
+ closed = true;
276
+ pending?.cancel();
277
+ interrupt?.();
278
+ release();
279
+ return {
280
+ value: void 0,
281
+ done: true
282
+ };
283
+ }
284
+ };
285
+ } };
286
+ },
287
+ async close() {
288
+ severed = true;
289
+ for (const set of wakers.values()) for (const wake of set) wake();
290
+ if (subscriber) (await subscriber.catch(() => null))?.disconnect();
291
+ const current = connection.peek();
292
+ if (!current) return;
293
+ (await current.catch(() => null))?.disconnect();
294
+ }
295
+ };
296
+ }
297
+ //#endregion
298
+ export { redis };
@@ -1,6 +1,6 @@
1
- import { c as IdSource, i as Clock, t as A2Log } from "./log-ldf5g8Cx.js";
2
- //#region src/log-sqlite.d.ts
3
- type SqliteLogOptions = {
1
+ import { c as IdSource, i as Clock, t as A2Store } from "./store-C3sNAaBT.js";
2
+ //#region src/store-sqlite.d.ts
3
+ type SqliteStoreOptions = {
4
4
  /** Database file path. Defaults to `.a2/dev.db`; `:memory:` works. */
5
5
  path?: string;
6
6
  /** Injectable clock — every stored timestamp comes from here. */
@@ -8,10 +8,10 @@ type SqliteLogOptions = {
8
8
  /** Injectable id source for generated event ids. */
9
9
  ids?: IdSource;
10
10
  };
11
- type SqliteLog = A2Log & {
11
+ type SqliteStore = A2Store & {
12
12
  /** Close the underlying database handle. */
13
13
  close(): void;
14
14
  };
15
- declare function sqlite(options?: SqliteLogOptions): SqliteLog;
15
+ declare function sqlite(options?: SqliteStoreOptions): SqliteStore;
16
16
  //#endregion
17
- export { SqliteLog, SqliteLogOptions, sqlite };
17
+ export { SqliteStore, SqliteStoreOptions, sqlite };
@@ -1,16 +1,17 @@
1
- import { t as A2Error } from "./errors-BJRMd-h6.js";
2
- import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
3
- import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
4
- import { t as pollingStream } from "./log-polling-6COoN60V.js";
1
+ import { t as A2Error } from "./errors-BQuJpe82.js";
2
+ import { n as pollingStream } from "./store-polling-DgrrAE3d.js";
3
+ import { t as idempotentReplay } from "./idempotent-replay-DuqEkYA7.js";
4
+ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
5
+ import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
5
6
  import { mkdirSync } from "node:fs";
6
7
  import { dirname, resolve } from "node:path";
7
8
  import { DatabaseSync } from "node:sqlite";
8
- //#region src/log-sqlite.ts
9
+ //#region src/store-sqlite.ts
9
10
  /**
10
- * experimental-a2/log-sqlite — sqlite log backend (the dev default, `.a2/dev.db`).
11
+ * experimental-a2/store-sqlite — sqlite store backend (the dev default, `.a2/dev.db`).
11
12
  *
12
13
  * Built on `node:sqlite` (Node ≥ 22.13) so it ships with zero
13
- * dependencies. Implements the A2Log interface; the conformance suite
14
+ * dependencies. Implements the A2Store interface; the conformance suite
14
15
  * in test/conformance is the executable contract.
15
16
  *
16
17
  * The spec's `index` column is stored as `idx` (a2-implementation.md
@@ -66,6 +67,17 @@ create table if not exists a2_snapshots (
66
67
  updated_at integer not null,
67
68
  primary key (session_id, reducer_name)
68
69
  ) strict;
70
+
71
+ create table if not exists a2_presence (
72
+ ns text not null,
73
+ participant text not null,
74
+ field text not null,
75
+ value text not null,
76
+ seen integer not null,
77
+ at integer not null,
78
+ expires_at integer not null,
79
+ primary key (ns, participant, field)
80
+ ) strict;
69
81
  `;
70
82
  /**
71
83
  * Switching journal modes takes an exclusive lock, and — unlike normal
@@ -88,7 +100,7 @@ const wrap = (fn) => {
88
100
  return fn();
89
101
  } catch (err) {
90
102
  if (err instanceof A2Error || err instanceof TypeError) throw err;
91
- throw new A2Error("LOG_UNAVAILABLE", "sqlite log operation failed", { cause: err });
103
+ throw new A2Error("STORE_UNAVAILABLE", "sqlite store operation failed", { cause: err });
92
104
  }
93
105
  };
94
106
  const toDate = (ms) => ms === null ? null : new Date(Number(ms));
@@ -114,7 +126,7 @@ const toStored = (row) => ({
114
126
  lane: row.lane,
115
127
  processedAt: toDate(row.processed_at),
116
128
  processedByAttempt: row.processed_by_attempt === null ? null : Number(row.processed_by_attempt),
117
- returnedEventIds: row.returned_event_ids === null ? null : JSON.parse(row.returned_event_ids),
129
+ returnedEventIds: row.returned_event_ids === null ? null : decodeReturnedEventIds(row.returned_event_ids),
118
130
  firstClaimedAt: toDate(row.first_claimed_at),
119
131
  lastClaimedAt: toDate(row.last_claimed_at),
120
132
  attemptCount: Number(row.attempt_count),
@@ -175,6 +187,21 @@ function sqlite(options = {}) {
175
187
  select 1 from a2_events
176
188
  where session_id = ? and processed_at is null
177
189
  ) as has_pending`);
190
+ const upsertPresence = db.prepare(`insert into a2_presence (ns, participant, field, value, seen, at, expires_at)
191
+ values (?, ?, ?, ?, ?, ?, ?)
192
+ on conflict (ns, participant, field) do update set
193
+ value = excluded.value,
194
+ seen = excluded.seen,
195
+ at = excluded.at,
196
+ expires_at = excluded.expires_at
197
+ where a2_presence.at <= excluded.at`);
198
+ const deletePresence = db.prepare(`delete from a2_presence
199
+ where ns = ? and participant = ? and field = ? and at <= ?`);
200
+ const sweepPresence = db.prepare("delete from a2_presence where ns = ? and expires_at <= ?");
201
+ const selectPresence = db.prepare(`select participant, field, value, seen, at, expires_at
202
+ from a2_presence
203
+ where ns = ? and expires_at > ?
204
+ order by participant, field`);
178
205
  const selectByIds = (count) => db.prepare(`select stored.*,
179
206
  exists(
180
207
  select 1 from a2_events pending
@@ -221,6 +248,12 @@ function sqlite(options = {}) {
221
248
  throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
222
249
  }
223
250
  }
251
+ for (const e of events) {
252
+ if (!e.cause) continue;
253
+ const parent = db.prepare("select attempt_count, failed_at from a2_events where session_id = ? and idx = ?").get(sessionId, e.cause.index);
254
+ if (!parent) throw new TypeError(`no event at index ${e.cause.index} in session '${sessionId}'`);
255
+ if (Number(parent.attempt_count) !== e.cause.attempt || parent.failed_at !== null) throw new A2Error("SUPERSEDED_ATTEMPT", `attempt ${e.cause.attempt} no longer owns event ${e.cause.index} in session '${sessionId}'`);
256
+ }
224
257
  const status = appendStatus.get(sessionId, sessionId);
225
258
  const base = Number(status.max);
226
259
  const now = clock.now().getTime();
@@ -265,6 +298,10 @@ function sqlite(options = {}) {
265
298
  conditions.push("idx > ?");
266
299
  params.push(opts.afterIndex);
267
300
  }
301
+ if (opts?.throughIndex !== void 0) {
302
+ conditions.push("idx <= ?");
303
+ params.push(opts.throughIndex);
304
+ }
268
305
  return db.prepare(`select * from a2_events where ${conditions.join(" and ")} order by idx`).all(...params).map(toStored);
269
306
  });
270
307
  },
@@ -312,20 +349,39 @@ function sqlite(options = {}) {
312
349
  };
313
350
  }));
314
351
  },
315
- async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
316
- if (indexes.length === 0) return [];
352
+ async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
353
+ if (claims.length === 0) return {
354
+ renewed: [],
355
+ superseded: []
356
+ };
317
357
  return wrap(() => tx(() => {
318
358
  const now = clock.now().getTime();
319
359
  const expiresAt = expiresAtMs ?? now + ttlMs;
320
- const placeholders = indexes.map(() => "?").join(", ");
321
- return db.prepare(`update a2_events set claim_expires_at = ?
322
- where session_id = ?
323
- and idx in (${placeholders})
324
- and processed_at is null
325
- and failed_at is null
326
- and claim_holder = ?
327
- and claim_expires_at > ?
328
- returning idx`).all(expiresAt, sessionId, ...indexes, holder, now).map((row) => Number(row.idx)).toSorted((a, b) => a - b);
360
+ const renewed = [];
361
+ const superseded = [];
362
+ const currentAttempt = db.prepare("select attempt_count from a2_events where session_id = ? and idx = ?");
363
+ const extend = db.prepare(`update a2_events set claim_expires_at = ?
364
+ where session_id = ?
365
+ and idx = ?
366
+ and attempt_count = ?
367
+ and processed_at is null
368
+ and failed_at is null
369
+ and claim_holder = ?
370
+ and claim_expires_at > ?`);
371
+ for (const claim of claims) {
372
+ const row = currentAttempt.get(sessionId, claim.index);
373
+ if (!row) continue;
374
+ if (Number(row.attempt_count) > claim.attempt) {
375
+ superseded.push(claim.index);
376
+ continue;
377
+ }
378
+ const updated = extend.run(expiresAt, sessionId, claim.index, claim.attempt, holder, now);
379
+ if (Number(updated.changes) === 1) renewed.push(claim.index);
380
+ }
381
+ return {
382
+ renewed: renewed.toSorted((a, b) => a - b),
383
+ superseded: superseded.toSorted((a, b) => a - b)
384
+ };
329
385
  }));
330
386
  },
331
387
  async completeAttempt({ sessionId, index, attempt, events }) {
@@ -336,7 +392,7 @@ function sqlite(options = {}) {
336
392
  if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
337
393
  if (parent.processed_at !== null) {
338
394
  if (Number(parent.processed_by_attempt) !== attempt) return { outcome: "superseded" };
339
- const returnedIds = parent.returned_event_ids === null ? null : JSON.parse(parent.returned_event_ids);
395
+ const returnedIds = parent.returned_event_ids === null ? null : decodeReturnedEventIds(parent.returned_event_ids);
340
396
  if (returnedIds === null || returnedIds.length !== ids.length || returnedIds.some((id, offset) => id !== ids[offset])) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "completed attempt does not match the returned event batch");
341
397
  const existing = ids.length === 0 ? [] : selectByIds(ids.length).all(sessionId, ...ids);
342
398
  const byId = new Map(existing.map((row) => [row.event_id, row]));
@@ -465,6 +521,32 @@ function sqlite(options = {}) {
465
521
  where excluded.up_to_index > a2_snapshots.up_to_index`).run(sessionId, reducerName, index, JSON.stringify(state) ?? "null", clock.now().getTime());
466
522
  });
467
523
  },
524
+ presence: {
525
+ async set(ns, participant, values, meta) {
526
+ const entries = Object.entries(values);
527
+ if (entries.length === 0) return;
528
+ wrap(() => tx(() => {
529
+ const atMs = meta.at.getTime();
530
+ const expiresAtMs = clock.now().getTime() + meta.ttlMs;
531
+ for (const [field, value] of entries) if (value === null) deletePresence.run(ns, participant, field, atMs);
532
+ else upsertPresence.run(ns, participant, field, JSON.stringify(value) ?? "null", meta.seen, atMs, expiresAtMs);
533
+ }));
534
+ },
535
+ async read(ns) {
536
+ return wrap(() => {
537
+ const now = clock.now().getTime();
538
+ sweepPresence.run(ns, now);
539
+ return selectPresence.all(ns, now).map((row) => ({
540
+ participant: row.participant,
541
+ field: row.field,
542
+ value: JSON.parse(row.value),
543
+ seen: Number(row.seen),
544
+ at: new Date(Number(row.at)),
545
+ expiresAt: new Date(Number(row.expires_at))
546
+ }));
547
+ });
548
+ }
549
+ },
468
550
  inspect: {
469
551
  async listSessions(inspectionOptions) {
470
552
  return wrap(() => {
@@ -513,6 +595,18 @@ function sqlite(options = {}) {
513
595
  };
514
596
  });
515
597
  },
598
+ async readEvents(sessionId, pageOptions) {
599
+ return wrap(() => {
600
+ const currentFrontier = Number(db.prepare("select coalesce(max(idx), 0) as frontier from a2_events where session_id = ?").get(sessionId).frontier);
601
+ const throughIndex = Math.min(pageOptions.throughIndex ?? currentFrontier, currentFrontier);
602
+ return {
603
+ events: db.prepare(`select * from a2_events
604
+ where session_id = ? and idx > ? and idx <= ?
605
+ order by idx limit ?`).all(sessionId, pageOptions.afterIndex, throughIndex, pageOptions.limit).map(toStored),
606
+ throughIndex
607
+ };
608
+ });
609
+ },
516
610
  async listSnapshots(sessionId) {
517
611
  return wrap(() => {
518
612
  return db.prepare(`select reducer_name, up_to_index, updated_at
@@ -528,7 +622,7 @@ function sqlite(options = {}) {
528
622
  const readAfter = (afterIndex) => wrap(() => {
529
623
  return db.prepare("select * from a2_events where session_id = ? and idx > ? order by idx").all(sessionId, afterIndex).map(toStored);
530
624
  });
531
- return pollingStream(readAfter, opts?.startAt !== void 0 ? { startAt: opts.startAt } : {});
625
+ return pollingStream(readAfter, opts?.startAfter !== void 0 ? { startAfter: opts.startAfter } : {});
532
626
  },
533
627
  close() {
534
628
  db.close();
@@ -1,7 +1,7 @@
1
1
  //#region src/telemetry.d.ts
2
2
  /**
3
3
  * The A2Telemetry interface — the instrumentation seam. Same philosophy
4
- * as log backends: the interface lives in core, implementations ship as
4
+ * as store backends: the interface lives in core, implementations ship as
5
5
  * entry points (`experimental-a2/otel` adapts it to OpenTelemetry). Without one,
6
6
  * every operation runs through a no-op wrapper.
7
7
  */