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.
- package/CHANGELOG.md +128 -0
- package/dist/ai-server.browser.d.ts +1 -0
- package/dist/ai-server.browser.js +4 -0
- package/dist/ai-server.d.ts +65 -0
- package/dist/ai-server.js +494 -0
- package/dist/ai.d.ts +282 -0
- package/dist/ai.js +922 -0
- package/dist/cache-indexeddb.d.ts +1 -0
- package/dist/cache-indexeddb.js +0 -0
- package/dist/client.d.ts +90 -0
- package/dist/client.js +410 -0
- package/dist/contract-B0kAXoaL.js +60 -0
- package/dist/contract-DL8btVd9.d.ts +161 -0
- package/dist/devtools-server.browser.d.ts +1 -0
- package/dist/devtools-server.browser.js +4 -0
- package/dist/devtools-server.d.ts +22 -0
- package/dist/devtools-server.js +1087 -0
- package/dist/errors-BJRMd-h6.js +23 -0
- package/dist/errors-xL_JTXsY.d.ts +20 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +119 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/inspection-E7qbD0Xj.js +10 -0
- package/dist/internal-Dm8Ejnud.js +36 -0
- package/dist/log-Dg1I8NRr.d.ts +245 -0
- package/dist/log-memory.d.ts +11 -0
- package/dist/log-memory.js +345 -0
- package/dist/log-polling-RO7kclzR.js +83 -0
- package/dist/log-postgres.d.ts +40 -0
- package/dist/log-postgres.js +628 -0
- package/dist/log-redis.d.ts +31 -0
- package/dist/log-redis.js +711 -0
- package/dist/log-sqlite.d.ts +17 -0
- package/dist/log-sqlite.js +450 -0
- package/dist/log-yJbXUf72.js +5 -0
- package/dist/otel.d.ts +12 -0
- package/dist/otel.js +41 -0
- package/dist/react.d.ts +54 -0
- package/dist/react.js +85 -0
- package/dist/recovery-vercel.d.ts +60 -0
- package/dist/recovery-vercel.js +120 -0
- package/dist/retryable-lazy-DZWmHpii.js +19 -0
- package/dist/server-DYsnKTTy.js +780 -0
- package/dist/server.browser.d.ts +1 -0
- package/dist/server.browser.js +11 -0
- package/dist/server.d.ts +136 -0
- package/dist/server.js +2 -0
- package/dist/telemetry-C78al20p.d.ts +32 -0
- package/dist/validate-XKT4FSNn.js +28 -0
- package/dist/wire-2QpU1EtJ.js +62 -0
- package/docs/01-quickstart.mdx +214 -0
- package/docs/concepts/01-contracts.mdx +138 -0
- package/docs/concepts/02-handlers.mdx +146 -0
- package/docs/concepts/03-durability.mdx +230 -0
- package/docs/concepts/04-state.mdx +133 -0
- package/docs/guides/01-timers.mdx +85 -0
- package/docs/guides/02-cancellation.mdx +107 -0
- package/docs/guides/03-react.mdx +234 -0
- package/docs/guides/04-local-first.mdx +88 -0
- package/docs/guides/05-production.mdx +179 -0
- package/docs/guides/06-ai-agents.mdx +659 -0
- package/docs/guides/07-devtools.mdx +101 -0
- package/docs/guides/08-application-data.mdx +114 -0
- package/docs/index.mdx +282 -0
- package/docs/reference/01-api.mdx +637 -0
- package/docs/reference/02-errors.mdx +77 -0
- 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 };
|