experimental-a2 0.0.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 (68) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/ai-server.browser.d.ts +1 -0
  3. package/dist/ai-server.browser.js +4 -0
  4. package/dist/ai-server.d.ts +65 -0
  5. package/dist/ai-server.js +494 -0
  6. package/dist/ai.d.ts +282 -0
  7. package/dist/ai.js +922 -0
  8. package/dist/cache-indexeddb.d.ts +1 -0
  9. package/dist/cache-indexeddb.js +0 -0
  10. package/dist/client.d.ts +90 -0
  11. package/dist/client.js +410 -0
  12. package/dist/contract-B0kAXoaL.js +60 -0
  13. package/dist/contract-DL8btVd9.d.ts +161 -0
  14. package/dist/devtools-server.browser.d.ts +1 -0
  15. package/dist/devtools-server.browser.js +4 -0
  16. package/dist/devtools-server.d.ts +22 -0
  17. package/dist/devtools-server.js +1087 -0
  18. package/dist/errors-BJRMd-h6.js +23 -0
  19. package/dist/errors-xL_JTXsY.d.ts +20 -0
  20. package/dist/http.d.ts +44 -0
  21. package/dist/http.js +119 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.js +3 -0
  24. package/dist/inspection-E7qbD0Xj.js +10 -0
  25. package/dist/internal-Dm8Ejnud.js +36 -0
  26. package/dist/log-Dg1I8NRr.d.ts +245 -0
  27. package/dist/log-memory.d.ts +11 -0
  28. package/dist/log-memory.js +345 -0
  29. package/dist/log-polling-RO7kclzR.js +83 -0
  30. package/dist/log-postgres.d.ts +40 -0
  31. package/dist/log-postgres.js +628 -0
  32. package/dist/log-redis.d.ts +31 -0
  33. package/dist/log-redis.js +711 -0
  34. package/dist/log-sqlite.d.ts +17 -0
  35. package/dist/log-sqlite.js +450 -0
  36. package/dist/log-yJbXUf72.js +5 -0
  37. package/dist/otel.d.ts +12 -0
  38. package/dist/otel.js +41 -0
  39. package/dist/react.d.ts +54 -0
  40. package/dist/react.js +85 -0
  41. package/dist/recovery-vercel.d.ts +60 -0
  42. package/dist/recovery-vercel.js +120 -0
  43. package/dist/retryable-lazy-DZWmHpii.js +19 -0
  44. package/dist/server-DYsnKTTy.js +780 -0
  45. package/dist/server.browser.d.ts +1 -0
  46. package/dist/server.browser.js +11 -0
  47. package/dist/server.d.ts +136 -0
  48. package/dist/server.js +2 -0
  49. package/dist/telemetry-C78al20p.d.ts +32 -0
  50. package/dist/validate-XKT4FSNn.js +28 -0
  51. package/dist/wire-2QpU1EtJ.js +62 -0
  52. package/docs/01-quickstart.mdx +214 -0
  53. package/docs/concepts/01-contracts.mdx +138 -0
  54. package/docs/concepts/02-handlers.mdx +146 -0
  55. package/docs/concepts/03-durability.mdx +230 -0
  56. package/docs/concepts/04-state.mdx +133 -0
  57. package/docs/guides/01-timers.mdx +85 -0
  58. package/docs/guides/02-cancellation.mdx +107 -0
  59. package/docs/guides/03-react.mdx +234 -0
  60. package/docs/guides/04-local-first.mdx +88 -0
  61. package/docs/guides/05-production.mdx +179 -0
  62. package/docs/guides/06-ai-agents.mdx +659 -0
  63. package/docs/guides/07-devtools.mdx +101 -0
  64. package/docs/guides/08-application-data.mdx +114 -0
  65. package/docs/index.mdx +282 -0
  66. package/docs/reference/01-api.mdx +637 -0
  67. package/docs/reference/02-errors.mdx +77 -0
  68. package/package.json +111 -0
@@ -0,0 +1,628 @@
1
+ import { t as A2Error } from "./errors-BJRMd-h6.js";
2
+ import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
+ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
4
+ import { t as pollingStream } from "./log-polling-RO7kclzR.js";
5
+ //#region src/log-postgres.ts
6
+ /**
7
+ * a2/log-postgres — postgres log backend (the production path).
8
+ *
9
+ * Implements the A2Log interface over any client that speaks the tiny
10
+ * `PostgresClient` shape below — a `pg` Pool matches it structurally
11
+ * (and is what `connectionString` creates, lazily; `pg` is an optional
12
+ * peer dependency). The conformance suite in test/conformance is the
13
+ * executable contract, run against PGlite (real Postgres, in-process)
14
+ * and against a real server when `TEST_DATABASE_URL` is set.
15
+ *
16
+ * Semantics mirror the sqlite backend: `index` is stored as `idx`,
17
+ * timestamps are epoch milliseconds written from the injected clock —
18
+ * never SQL `now()` — and `stream()` is a poll loop. (A LISTEN/NOTIFY
19
+ * upgrade can land later without any API change; callers never branch
20
+ * on the transport.) Appends serialize per session via
21
+ * `pg_advisory_xact_lock`, so concurrent writers can't race the index
22
+ * assignment.
23
+ */
24
+ const SCHEMA = `
25
+ create table if not exists a2_events (
26
+ session_id text not null,
27
+ idx bigint not null,
28
+ event_type text not null,
29
+ payload jsonb not null,
30
+ event_id text not null,
31
+ created_at bigint not null,
32
+ cause jsonb,
33
+ processed_at bigint,
34
+ processed_by_attempt integer,
35
+ first_claimed_at bigint,
36
+ last_claimed_at bigint,
37
+ attempt_count integer not null default 0,
38
+ failure_count integer not null default 0,
39
+ last_failed_at bigint,
40
+ last_failed_attempt integer,
41
+ last_error text,
42
+ failed_at bigint,
43
+ primary key (session_id, idx)
44
+ );
45
+
46
+ -- CREATE TABLE IF NOT EXISTS leaves an older A2 table untouched. Keep
47
+ -- additive lifecycle changes self-healing: an old warm deployment can recreate
48
+ -- its embedded schema after an operator drops the tables during a rollout.
49
+ alter table a2_events add column if not exists cause jsonb;
50
+ alter table a2_events add column if not exists processed_by_attempt integer;
51
+ alter table a2_events add column if not exists first_claimed_at bigint;
52
+ alter table a2_events add column if not exists last_claimed_at bigint;
53
+ alter table a2_events
54
+ add column if not exists attempt_count integer not null default 0;
55
+ alter table a2_events
56
+ add column if not exists failure_count integer not null default 0;
57
+ alter table a2_events add column if not exists last_failed_at bigint;
58
+ alter table a2_events add column if not exists last_failed_attempt integer;
59
+
60
+ create unique index if not exists a2_events_event_id on a2_events (event_id);
61
+ create index if not exists a2_events_unprocessed
62
+ on a2_events (session_id, idx) where processed_at is null;
63
+
64
+ create table if not exists a2_leases (
65
+ session_id text primary key,
66
+ holder text not null,
67
+ expires_at bigint not null
68
+ );
69
+
70
+ create table if not exists a2_snapshots (
71
+ session_id text not null,
72
+ reducer_name text not null,
73
+ up_to_index bigint not null,
74
+ state jsonb not null,
75
+ updated_at bigint not null,
76
+ primary key (session_id, reducer_name)
77
+ );
78
+ `;
79
+ const asNumber = (value) => Number(value);
80
+ const toDate = (ms) => ms === null || ms === void 0 ? null : new Date(asNumber(ms));
81
+ const toCause = (raw) => {
82
+ if (raw === null || raw === void 0) return null;
83
+ const value = typeof raw === "string" ? JSON.parse(raw) : raw;
84
+ if (!Number.isInteger(value.index) || Number(value.index) < 1 || !Number.isInteger(value.attempt) || Number(value.attempt) < 1) throw new TypeError("stored event has an invalid cause");
85
+ return {
86
+ index: Number(value.index),
87
+ attempt: Number(value.attempt)
88
+ };
89
+ };
90
+ const toStored = (row) => ({
91
+ id: row["event_id"],
92
+ type: row["event_type"],
93
+ payload: row["payload"],
94
+ index: asNumber(row["idx"]),
95
+ sessionId: row["session_id"],
96
+ createdAt: new Date(asNumber(row["created_at"])),
97
+ cause: toCause(row["cause"]),
98
+ processedAt: toDate(row["processed_at"]),
99
+ processedByAttempt: row["processed_by_attempt"] === null || row["processed_by_attempt"] === void 0 ? null : asNumber(row["processed_by_attempt"]),
100
+ firstClaimedAt: toDate(row["first_claimed_at"]),
101
+ lastClaimedAt: toDate(row["last_claimed_at"]),
102
+ attemptCount: asNumber(row["attempt_count"]),
103
+ failureCount: asNumber(row["failure_count"]),
104
+ lastFailedAt: toDate(row["last_failed_at"]),
105
+ lastFailedAttempt: row["last_failed_attempt"] === null || row["last_failed_attempt"] === void 0 ? null : asNumber(row["last_failed_attempt"]),
106
+ lastError: row["last_error"] ?? null,
107
+ failedAt: toDate(row["failed_at"])
108
+ });
109
+ const toEvent = (row) => ({
110
+ id: row["event_id"],
111
+ type: row["event_type"],
112
+ payload: row["payload"],
113
+ index: asNumber(row["idx"]),
114
+ sessionId: row["session_id"],
115
+ createdAt: new Date(asNumber(row["created_at"]))
116
+ });
117
+ const wrap = async (fn) => {
118
+ try {
119
+ return await fn();
120
+ } catch (err) {
121
+ if (err instanceof A2Error || err instanceof TypeError) throw err;
122
+ throw new A2Error("LOG_UNAVAILABLE", "postgres log operation failed", { cause: err });
123
+ }
124
+ };
125
+ const jsonb = (value) => JSON.stringify(value) ?? "null";
126
+ const initializeSchema = async (client) => {
127
+ if (!client.connect) {
128
+ await client.query(SCHEMA);
129
+ return;
130
+ }
131
+ const conn = await client.connect();
132
+ try {
133
+ await conn.query("begin");
134
+ await conn.query("select pg_advisory_xact_lock(hashtext('a2:postgres:schema'))");
135
+ await conn.query(SCHEMA);
136
+ await conn.query("commit");
137
+ } catch (err) {
138
+ await conn.query("rollback").catch(() => {});
139
+ throw err;
140
+ } finally {
141
+ conn.release();
142
+ }
143
+ };
144
+ function postgres(options = {}) {
145
+ const clock = options.clock ?? SYSTEM_CLOCK;
146
+ const generateId = options.ids ?? RANDOM_IDS;
147
+ if (!options.client && !options.connectionString) throw new TypeError("postgres() needs a connectionString or an injected client");
148
+ const connection = retryableLazy(async () => {
149
+ let owned = null;
150
+ try {
151
+ let c = options.client;
152
+ if (!c) {
153
+ c = new (await (import("pg").catch(() => {
154
+ throw new A2Error("LOG_NOT_CONFIGURED", "a2/log-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client");
155
+ }))).default.Pool({ connectionString: options.connectionString });
156
+ owned = c;
157
+ }
158
+ await initializeSchema(c);
159
+ return c;
160
+ } catch (err) {
161
+ if (owned?.end) await owned.end().catch(() => {});
162
+ throw err;
163
+ }
164
+ });
165
+ const client = connection.get;
166
+ let mutex = Promise.resolve();
167
+ const withTx = async (fn) => {
168
+ const c = await client();
169
+ if (c.connect) {
170
+ const conn = await c.connect();
171
+ try {
172
+ await conn.query("begin");
173
+ const result = await fn((text, values) => conn.query(text, values));
174
+ await conn.query("commit");
175
+ return result;
176
+ } catch (err) {
177
+ await conn.query("rollback").catch(() => {});
178
+ throw err;
179
+ } finally {
180
+ conn.release();
181
+ }
182
+ }
183
+ const run = mutex.then(async () => {
184
+ await c.query("begin");
185
+ try {
186
+ const result = await fn((text, values) => c.query(text, values));
187
+ await c.query("commit");
188
+ return result;
189
+ } catch (err) {
190
+ await c.query("rollback").catch(() => {});
191
+ throw err;
192
+ }
193
+ });
194
+ mutex = run.catch(() => {});
195
+ return run;
196
+ };
197
+ return {
198
+ async append(sessionId, events) {
199
+ if (events.length === 0) return [];
200
+ return wrap(() => withTx(async (query) => {
201
+ await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
202
+ const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
203
+ if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
204
+ if (suppliedIds.length > 0) {
205
+ const { rows: existing } = await query("select * from a2_events where event_id = any($1) order by idx", [suppliedIds]);
206
+ if (existing.length > 0) {
207
+ const foreign = existing.find((row) => row["session_id"] !== sessionId);
208
+ if (foreign) throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${String(foreign["event_id"])}' already exists in another session`);
209
+ if (existing.length === events.length) return existing.map(toStored);
210
+ throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
211
+ }
212
+ }
213
+ const { rows: maxRows } = await query("select coalesce(max(idx), 0) as max from a2_events where session_id = $1", [sessionId]);
214
+ const base = asNumber(maxRows[0]?.["max"]);
215
+ const now = clock.now().getTime();
216
+ const inserted = [];
217
+ for (let i = 0; i < events.length; i += 1) {
218
+ const e = events[i];
219
+ const id = e.id ?? generateId();
220
+ const index = base + 1 + i;
221
+ await query(`insert into a2_events
222
+ (session_id, idx, event_type, payload, event_id, created_at,
223
+ cause)
224
+ values ($1, $2, $3, $4::jsonb, $5, $6, $7::jsonb)`, [
225
+ sessionId,
226
+ index,
227
+ e.type,
228
+ jsonb(e.payload),
229
+ id,
230
+ now,
231
+ e.cause ? jsonb(e.cause) : null
232
+ ]);
233
+ inserted.push({
234
+ id,
235
+ type: e.type,
236
+ payload: structuredClone(e.payload),
237
+ index,
238
+ sessionId,
239
+ createdAt: new Date(now),
240
+ cause: e.cause ? { ...e.cause } : null,
241
+ processedAt: null,
242
+ processedByAttempt: null,
243
+ firstClaimedAt: null,
244
+ lastClaimedAt: null,
245
+ attemptCount: 0,
246
+ failureCount: 0,
247
+ lastFailedAt: null,
248
+ lastFailedAttempt: null,
249
+ lastError: null,
250
+ failedAt: null
251
+ });
252
+ }
253
+ return inserted;
254
+ }));
255
+ },
256
+ async read(sessionId, opts) {
257
+ return wrap(async () => {
258
+ const c = await client();
259
+ const conditions = ["session_id = $1"];
260
+ const params = [sessionId];
261
+ if (opts?.afterIndex !== void 0) {
262
+ params.push(opts.afterIndex);
263
+ conditions.push(`idx > $${params.length}`);
264
+ }
265
+ if (opts?.unprocessedOnly) conditions.push("processed_at is null");
266
+ const { rows } = await c.query(`select * from a2_events where ${conditions.join(" and ")} order by idx`, params);
267
+ return rows.map(toStored);
268
+ });
269
+ },
270
+ async claimNext({ sessionId, holder, ttlMs, expiresAtMs, maxIndex }) {
271
+ return wrap(async () => {
272
+ const c = await client();
273
+ const now = clock.now().getTime();
274
+ const expiresAt = expiresAtMs ?? now + ttlMs;
275
+ const { rows } = await c.query(`with head as materialized (
276
+ select e.* from a2_events e
277
+ where e.session_id = $1 and e.processed_at is null
278
+ order by e.idx limit 1 for update of e
279
+ ), eligible as materialized (
280
+ select * from head
281
+ where failed_at is null
282
+ and ($5::bigint is null or idx <= $5)
283
+ ), won as (
284
+ insert into a2_leases (session_id, holder, expires_at)
285
+ select $1, $2, $3 from eligible
286
+ on conflict (session_id) do update set
287
+ holder = excluded.holder,
288
+ expires_at = excluded.expires_at
289
+ where a2_leases.expires_at <= $4 or a2_leases.holder = $2
290
+ returning holder
291
+ ), claimed as (
292
+ update a2_events e set
293
+ attempt_count = e.attempt_count + 1,
294
+ first_claimed_at = coalesce(e.first_claimed_at, $4),
295
+ last_claimed_at = $4
296
+ from eligible, won
297
+ where e.session_id = $1
298
+ and e.idx = eligible.idx
299
+ and e.processed_at is null
300
+ returning e.*
301
+ )
302
+ select case
303
+ when exists (select 1 from claimed) then 'claimed'
304
+ when exists (select 1 from eligible) then 'busy'
305
+ else 'settled'
306
+ end as outcome, claimed.*
307
+ from (values (1)) as singleton(n)
308
+ left join claimed on true`, [
309
+ sessionId,
310
+ holder,
311
+ expiresAt,
312
+ now,
313
+ maxIndex ?? null
314
+ ]);
315
+ const row = rows[0];
316
+ if (row["outcome"] === "claimed") return {
317
+ outcome: "claimed",
318
+ event: toStored(row)
319
+ };
320
+ return { outcome: row["outcome"] };
321
+ });
322
+ },
323
+ async completeAndClaimNext({ sessionId, holder, completedIndex, attempt, maxIndex }) {
324
+ return wrap(async () => {
325
+ const c = await client();
326
+ const now = clock.now().getTime();
327
+ const { rows } = await c.query(`with current as materialized (
328
+ select e.* from a2_events e
329
+ where e.session_id = $1 and e.idx = $3
330
+ for update of e
331
+ ), completed as (
332
+ update a2_events e set
333
+ processed_at = $5,
334
+ processed_by_attempt = $4
335
+ from current
336
+ where e.session_id = $1
337
+ and e.idx = $3
338
+ and current.processed_at is null
339
+ and current.attempt_count = $4
340
+ returning e.idx
341
+ ), head as materialized (
342
+ select e.* from a2_events e, completed
343
+ where e.session_id = $1
344
+ and e.idx > $3
345
+ and e.processed_at is null
346
+ order by e.idx limit 1 for update of e
347
+ ), eligible as materialized (
348
+ select * from head
349
+ where failed_at is null
350
+ and ($6::bigint is null or idx <= $6)
351
+ ), owned as materialized (
352
+ select l.session_id from a2_leases l, eligible
353
+ where l.session_id = $1
354
+ and l.holder = $2
355
+ and l.expires_at > $5
356
+ for update of l
357
+ ), claimed as (
358
+ update a2_events e set
359
+ attempt_count = e.attempt_count + 1,
360
+ first_claimed_at = coalesce(e.first_claimed_at, $5),
361
+ last_claimed_at = $5
362
+ from eligible, owned
363
+ where e.session_id = $1
364
+ and e.idx = eligible.idx
365
+ and e.processed_at is null
366
+ returning e.*
367
+ )
368
+ select case
369
+ when not exists (select 1 from current) then 'missing'
370
+ when not exists (select 1 from completed) then 'superseded'
371
+ when exists (select 1 from claimed) then 'claimed'
372
+ when not exists (select 1 from eligible) then 'settled'
373
+ else 'busy'
374
+ end as outcome, claimed.*
375
+ from (values (1)) as singleton(n)
376
+ left join claimed on true`, [
377
+ sessionId,
378
+ holder,
379
+ completedIndex,
380
+ attempt,
381
+ now,
382
+ maxIndex ?? null
383
+ ]);
384
+ const row = rows[0];
385
+ if (row["outcome"] === "missing") throw new TypeError(`no event at index ${completedIndex} in session '${sessionId}'`);
386
+ if (row["outcome"] === "claimed") return {
387
+ outcome: "claimed",
388
+ event: toStored(row)
389
+ };
390
+ return { outcome: row["outcome"] };
391
+ });
392
+ },
393
+ async markProcessed(sessionId, index) {
394
+ await wrap(async () => {
395
+ const { rows } = await (await client()).query(`update a2_events
396
+ set processed_at = coalesce(processed_at, $1)
397
+ where session_id = $2 and idx = $3
398
+ returning idx`, [
399
+ clock.now().getTime(),
400
+ sessionId,
401
+ index
402
+ ]);
403
+ if (rows.length === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
404
+ });
405
+ },
406
+ async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
407
+ return wrap(async () => {
408
+ const { rows } = await (await client()).query(`with current as materialized (
409
+ select e.* from a2_events e
410
+ where e.session_id = $1 and e.idx = $2
411
+ for update of e
412
+ ), updated as (
413
+ update a2_events e set
414
+ failure_count = e.failure_count + 1,
415
+ last_error = $4,
416
+ last_failed_at = $6,
417
+ last_failed_attempt = $3,
418
+ failed_at = case
419
+ when e.failure_count + 1 >= $5 then $6
420
+ else e.failed_at
421
+ end
422
+ from current
423
+ where e.session_id = $1
424
+ and e.idx = $2
425
+ and current.processed_at is null
426
+ and current.failed_at is null
427
+ and current.attempt_count = $3
428
+ returning e.failure_count, e.failed_at
429
+ )
430
+ select case
431
+ when not exists (select 1 from current) then 'missing'
432
+ when exists (
433
+ select 1 from updated where failed_at is not null
434
+ ) then 'dead_lettered'
435
+ when exists (select 1 from updated) then 'failed'
436
+ when exists (
437
+ select 1 from current
438
+ where processed_at is not null or attempt_count <> $3
439
+ ) then 'superseded'
440
+ when exists (
441
+ select 1 from current where failed_at is not null
442
+ ) then 'dead_lettered'
443
+ else 'superseded'
444
+ end as outcome,
445
+ coalesce(
446
+ (select failure_count from updated),
447
+ (select failure_count from current)
448
+ ) as failure_count`, [
449
+ sessionId,
450
+ index,
451
+ attempt,
452
+ error,
453
+ maxFailures,
454
+ clock.now().getTime()
455
+ ]);
456
+ const row = rows[0];
457
+ if (!row || row["outcome"] === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
458
+ return {
459
+ outcome: row["outcome"],
460
+ failureCount: asNumber(row["failure_count"])
461
+ };
462
+ });
463
+ },
464
+ async markFailed(sessionId, index) {
465
+ await wrap(async () => {
466
+ const { rows } = await (await client()).query("update a2_events set failed_at = $1 where session_id = $2 and idx = $3 returning idx", [
467
+ clock.now().getTime(),
468
+ sessionId,
469
+ index
470
+ ]);
471
+ if (rows.length === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
472
+ });
473
+ },
474
+ async readState(sessionId, reducerName) {
475
+ return wrap(async () => {
476
+ const { rows } = await (await client()).query(`with snapshot as materialized (
477
+ select up_to_index, state
478
+ from a2_snapshots
479
+ where session_id = $1 and reducer_name = $2
480
+ )
481
+ select 0 as row_order, 'snapshot' as row_kind,
482
+ snapshot.up_to_index as snapshot_index,
483
+ snapshot.state as snapshot_state,
484
+ null::text as session_id, null::bigint as idx,
485
+ null::text as event_type, null::jsonb as payload,
486
+ null::text as event_id, null::bigint as created_at
487
+ from snapshot
488
+ union all
489
+ select 1 as row_order, 'event' as row_kind,
490
+ null::bigint as snapshot_index,
491
+ null::jsonb as snapshot_state,
492
+ event.session_id, event.idx, event.event_type,
493
+ event.payload, event.event_id, event.created_at
494
+ from a2_events event
495
+ where event.session_id = $1
496
+ and event.idx > coalesce((select up_to_index from snapshot), 0)
497
+ order by row_order, idx`, [sessionId, reducerName]);
498
+ const snapshot = rows.find((row) => row["row_kind"] === "snapshot");
499
+ return {
500
+ snapshot: snapshot ? {
501
+ index: asNumber(snapshot["snapshot_index"]),
502
+ state: snapshot["snapshot_state"]
503
+ } : null,
504
+ events: rows.filter((row) => row["row_kind"] === "event").map(toEvent)
505
+ };
506
+ });
507
+ },
508
+ async putSnapshot(sessionId, reducerName, index, state) {
509
+ await wrap(async () => {
510
+ await (await client()).query(`insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)
511
+ values ($1, $2, $3, $4::jsonb, $5)
512
+ on conflict (session_id, reducer_name) do update set
513
+ up_to_index = excluded.up_to_index,
514
+ state = excluded.state,
515
+ updated_at = excluded.updated_at
516
+ where excluded.up_to_index > a2_snapshots.up_to_index`, [
517
+ sessionId,
518
+ reducerName,
519
+ index,
520
+ jsonb(state),
521
+ clock.now().getTime()
522
+ ]);
523
+ });
524
+ },
525
+ inspect: {
526
+ async listSessions(inspectionOptions) {
527
+ return wrap(async () => {
528
+ const c = await client();
529
+ const conditions = ["left(session_id, length($1)) = $1"];
530
+ const params = [inspectionOptions.prefix];
531
+ if (inspectionOptions.cursor !== void 0) {
532
+ params.push(inspectionOptions.cursor);
533
+ conditions.push(`session_id > $${params.length}`);
534
+ }
535
+ params.push(inspectionOptions.limit + 1);
536
+ const { rows } = await c.query(`select
537
+ session_id,
538
+ count(*) as event_count,
539
+ count(*) filter (where processed_at is null and failed_at is null) as pending_count,
540
+ count(*) filter (where failed_at is not null) as failed_count,
541
+ sum(attempt_count) as attempt_count,
542
+ sum(failure_count) as failure_count,
543
+ min(created_at) as first_event_at,
544
+ max(greatest(
545
+ created_at,
546
+ coalesce(first_claimed_at, 0),
547
+ coalesce(last_claimed_at, 0),
548
+ coalesce(last_failed_at, 0),
549
+ coalesce(processed_at, 0),
550
+ coalesce(failed_at, 0)
551
+ )) as updated_at
552
+ from a2_events
553
+ where ${conditions.join(" and ")}
554
+ group by session_id
555
+ order by session_id
556
+ limit $${params.length}`, params);
557
+ const hasMore = rows.length > inspectionOptions.limit;
558
+ const visible = rows.slice(0, inspectionOptions.limit);
559
+ return {
560
+ sessions: visible.map((row) => ({
561
+ sessionId: row["session_id"],
562
+ eventCount: asNumber(row["event_count"]),
563
+ pendingCount: asNumber(row["pending_count"]),
564
+ failedCount: asNumber(row["failed_count"]),
565
+ attemptCount: asNumber(row["attempt_count"]),
566
+ failureCount: asNumber(row["failure_count"]),
567
+ firstEventAt: new Date(asNumber(row["first_event_at"])),
568
+ updatedAt: new Date(asNumber(row["updated_at"]))
569
+ })),
570
+ cursor: hasMore ? visible.at(-1)?.["session_id"] ?? null : null
571
+ };
572
+ });
573
+ },
574
+ async listSnapshots(sessionId) {
575
+ return wrap(async () => {
576
+ const { rows } = await (await client()).query(`select reducer_name, up_to_index, updated_at
577
+ from a2_snapshots where session_id = $1 order by reducer_name`, [sessionId]);
578
+ return rows.map((row) => ({
579
+ reducerName: row["reducer_name"],
580
+ index: asNumber(row["up_to_index"]),
581
+ updatedAt: new Date(asNumber(row["updated_at"]))
582
+ }));
583
+ });
584
+ }
585
+ },
586
+ stream(sessionId, opts) {
587
+ const readAfter = (afterIndex) => wrap(async () => {
588
+ const { rows } = await (await client()).query("select * from a2_events where session_id = $1 and idx > $2 order by idx", [sessionId, afterIndex]);
589
+ return rows.map(toStored);
590
+ });
591
+ return pollingStream(readAfter, opts?.startAt !== void 0 ? { startAt: opts.startAt } : {});
592
+ },
593
+ lease: {
594
+ async acquire({ sessionId, holder, ttlMs, expiresAtMs }) {
595
+ return wrap(async () => {
596
+ const c = await client();
597
+ const now = clock.now().getTime();
598
+ const expiresAt = expiresAtMs ?? now + ttlMs;
599
+ const { rows } = await c.query(`insert into a2_leases (session_id, holder, expires_at)
600
+ values ($1, $2, $3)
601
+ on conflict (session_id) do update set
602
+ holder = excluded.holder,
603
+ expires_at = excluded.expires_at
604
+ where a2_leases.expires_at <= $4 or a2_leases.holder = $2
605
+ returning holder`, [
606
+ sessionId,
607
+ holder,
608
+ expiresAt,
609
+ now
610
+ ]);
611
+ return rows.length === 1;
612
+ });
613
+ },
614
+ async release({ sessionId, holder }) {
615
+ await wrap(async () => {
616
+ await (await client()).query("delete from a2_leases where session_id = $1 and holder = $2", [sessionId, holder]);
617
+ });
618
+ }
619
+ },
620
+ async close() {
621
+ const current = connection.peek();
622
+ if (!current) return;
623
+ await (await current).end?.();
624
+ }
625
+ };
626
+ }
627
+ //#endregion
628
+ export { postgres };
@@ -0,0 +1,31 @@
1
+ import { c as IdSource, i as Clock, t as A2Log } from "./log-Dg1I8NRr.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 };