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,17 @@
|
|
|
1
|
+
import { c as IdSource, i as Clock, t as A2Log } from "./log-Dg1I8NRr.js";
|
|
2
|
+
//#region src/log-sqlite.d.ts
|
|
3
|
+
type SqliteLogOptions = {
|
|
4
|
+
/** Database file path. Defaults to `.a2/dev.db`; `:memory:` works. */
|
|
5
|
+
path?: string;
|
|
6
|
+
/** Injectable clock — every stored timestamp comes from here. */
|
|
7
|
+
clock?: Clock;
|
|
8
|
+
/** Injectable id source for generated event ids. */
|
|
9
|
+
ids?: IdSource;
|
|
10
|
+
};
|
|
11
|
+
type SqliteLog = A2Log & {
|
|
12
|
+
/** Close the underlying database handle. */
|
|
13
|
+
close(): void;
|
|
14
|
+
};
|
|
15
|
+
declare function sqlite(options?: SqliteLogOptions): SqliteLog;
|
|
16
|
+
//#endregion
|
|
17
|
+
export { SqliteLog, SqliteLogOptions, sqlite };
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { t as A2Error } from "./errors-BJRMd-h6.js";
|
|
2
|
+
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
|
|
3
|
+
import { t as pollingStream } from "./log-polling-RO7kclzR.js";
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
//#region src/log-sqlite.ts
|
|
8
|
+
/**
|
|
9
|
+
* a2/log-sqlite — sqlite log backend (the dev default, `.a2/dev.db`).
|
|
10
|
+
*
|
|
11
|
+
* Built on `node:sqlite` (Node ≥ 22.13) so it ships with zero
|
|
12
|
+
* dependencies. Implements the A2Log interface; the conformance suite
|
|
13
|
+
* in test/conformance is the executable contract.
|
|
14
|
+
*
|
|
15
|
+
* The spec's `index` column is stored as `idx` (a2-implementation.md
|
|
16
|
+
* §11 — `index` collides with a reserved word) and mapped back at the
|
|
17
|
+
* API boundary. Timestamps are epoch milliseconds written from the
|
|
18
|
+
* injected clock — never SQL `now()` — so tests can time-travel against
|
|
19
|
+
* real storage.
|
|
20
|
+
*/
|
|
21
|
+
const SCHEMA = `
|
|
22
|
+
create table if not exists a2_events (
|
|
23
|
+
session_id text not null,
|
|
24
|
+
idx integer not null,
|
|
25
|
+
event_type text not null,
|
|
26
|
+
payload text not null,
|
|
27
|
+
event_id text not null,
|
|
28
|
+
created_at integer not null,
|
|
29
|
+
cause text,
|
|
30
|
+
processed_at integer,
|
|
31
|
+
processed_by_attempt integer,
|
|
32
|
+
first_claimed_at integer,
|
|
33
|
+
last_claimed_at integer,
|
|
34
|
+
attempt_count integer not null default 0,
|
|
35
|
+
failure_count integer not null default 0,
|
|
36
|
+
last_failed_at integer,
|
|
37
|
+
last_failed_attempt integer,
|
|
38
|
+
last_error text,
|
|
39
|
+
failed_at integer,
|
|
40
|
+
primary key (session_id, idx)
|
|
41
|
+
) strict;
|
|
42
|
+
|
|
43
|
+
create unique index if not exists a2_events_event_id on a2_events (event_id);
|
|
44
|
+
create index if not exists a2_events_unprocessed
|
|
45
|
+
on a2_events (session_id, idx) where processed_at is null;
|
|
46
|
+
|
|
47
|
+
create table if not exists a2_leases (
|
|
48
|
+
session_id text primary key,
|
|
49
|
+
holder text not null,
|
|
50
|
+
expires_at integer not null
|
|
51
|
+
) strict;
|
|
52
|
+
|
|
53
|
+
create table if not exists a2_snapshots (
|
|
54
|
+
session_id text not null,
|
|
55
|
+
reducer_name text not null,
|
|
56
|
+
up_to_index integer not null,
|
|
57
|
+
state text not null,
|
|
58
|
+
updated_at integer not null,
|
|
59
|
+
primary key (session_id, reducer_name)
|
|
60
|
+
) strict;
|
|
61
|
+
`;
|
|
62
|
+
/**
|
|
63
|
+
* Switching journal modes takes an exclusive lock, and — unlike normal
|
|
64
|
+
* statements — the switch can return SQLITE_BUSY without consulting the
|
|
65
|
+
* busy handler while several connections race it (instances cold-booting
|
|
66
|
+
* against one file). Bounded synchronous retry; the window is boot-only
|
|
67
|
+
* and tiny. Found by the multi-process torture test.
|
|
68
|
+
*/
|
|
69
|
+
const setWalJournalMode = (db) => {
|
|
70
|
+
for (let attempt = 0;; attempt += 1) try {
|
|
71
|
+
db.exec("pragma journal_mode = wal");
|
|
72
|
+
return;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (!(err.errcode === 5) || attempt >= 100) throw err;
|
|
75
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const wrap = (fn) => {
|
|
79
|
+
try {
|
|
80
|
+
return fn();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err instanceof A2Error || err instanceof TypeError) throw err;
|
|
83
|
+
throw new A2Error("LOG_UNAVAILABLE", "sqlite log operation failed", { cause: err });
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const toDate = (ms) => ms === null ? null : new Date(Number(ms));
|
|
87
|
+
const toCause = (json) => {
|
|
88
|
+
if (json === null) return null;
|
|
89
|
+
const value = JSON.parse(json);
|
|
90
|
+
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");
|
|
91
|
+
return {
|
|
92
|
+
index: Number(value.index),
|
|
93
|
+
attempt: Number(value.attempt)
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
const toStored = (row) => ({
|
|
97
|
+
id: row.event_id,
|
|
98
|
+
type: row.event_type,
|
|
99
|
+
payload: JSON.parse(row.payload),
|
|
100
|
+
index: Number(row.idx),
|
|
101
|
+
sessionId: row.session_id,
|
|
102
|
+
createdAt: new Date(Number(row.created_at)),
|
|
103
|
+
cause: toCause(row.cause),
|
|
104
|
+
processedAt: toDate(row.processed_at),
|
|
105
|
+
processedByAttempt: row.processed_by_attempt === null ? null : Number(row.processed_by_attempt),
|
|
106
|
+
firstClaimedAt: toDate(row.first_claimed_at),
|
|
107
|
+
lastClaimedAt: toDate(row.last_claimed_at),
|
|
108
|
+
attemptCount: Number(row.attempt_count),
|
|
109
|
+
failureCount: Number(row.failure_count),
|
|
110
|
+
lastFailedAt: toDate(row.last_failed_at),
|
|
111
|
+
lastFailedAttempt: row.last_failed_attempt === null ? null : Number(row.last_failed_attempt),
|
|
112
|
+
lastError: row.last_error,
|
|
113
|
+
failedAt: toDate(row.failed_at)
|
|
114
|
+
});
|
|
115
|
+
const toEvent = (row) => ({
|
|
116
|
+
id: row.event_id,
|
|
117
|
+
type: row.event_type,
|
|
118
|
+
payload: JSON.parse(row.payload),
|
|
119
|
+
index: Number(row.idx),
|
|
120
|
+
sessionId: row.session_id,
|
|
121
|
+
createdAt: new Date(Number(row.created_at))
|
|
122
|
+
});
|
|
123
|
+
const requireChange = (changes, what) => {
|
|
124
|
+
if (Number(changes) === 0) throw new TypeError(what);
|
|
125
|
+
};
|
|
126
|
+
function sqlite(options = {}) {
|
|
127
|
+
const path = options.path ?? ".a2/dev.db";
|
|
128
|
+
const clock = options.clock ?? SYSTEM_CLOCK;
|
|
129
|
+
const generateId = options.ids ?? RANDOM_IDS;
|
|
130
|
+
if (path !== ":memory:") mkdirSync(dirname(resolve(
|
|
131
|
+
/* turbopackIgnore: true */
|
|
132
|
+
path
|
|
133
|
+
)), { recursive: true });
|
|
134
|
+
const db = new DatabaseSync(path);
|
|
135
|
+
db.exec("pragma busy_timeout = 5000");
|
|
136
|
+
setWalJournalMode(db);
|
|
137
|
+
db.exec("pragma synchronous = normal");
|
|
138
|
+
db.exec(SCHEMA);
|
|
139
|
+
const insertEvent = db.prepare(`insert into a2_events
|
|
140
|
+
(session_id, idx, event_type, payload, event_id, created_at,
|
|
141
|
+
cause)
|
|
142
|
+
values (?, ?, ?, ?, ?, ?, ?)`);
|
|
143
|
+
const maxIdx = db.prepare("select coalesce(max(idx), 0) as max from a2_events where session_id = ?");
|
|
144
|
+
const selectByIds = (count) => db.prepare(`select * from a2_events where event_id in (${Array.from({ length: count }, () => "?").join(", ")})
|
|
145
|
+
order by idx`);
|
|
146
|
+
const tx = (fn) => {
|
|
147
|
+
db.exec("begin immediate");
|
|
148
|
+
try {
|
|
149
|
+
const result = fn();
|
|
150
|
+
db.exec("commit");
|
|
151
|
+
return result;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
try {
|
|
154
|
+
db.exec("rollback");
|
|
155
|
+
} catch {}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
async append(sessionId, events) {
|
|
161
|
+
if (events.length === 0) return [];
|
|
162
|
+
return wrap(() => tx(() => {
|
|
163
|
+
const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
|
|
164
|
+
if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
165
|
+
if (suppliedIds.length > 0) {
|
|
166
|
+
const existing = selectByIds(suppliedIds.length).all(...suppliedIds);
|
|
167
|
+
if (existing.length > 0) {
|
|
168
|
+
const foreign = existing.find((row) => row.session_id !== sessionId);
|
|
169
|
+
if (foreign) throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${foreign.event_id}' already exists in another session`);
|
|
170
|
+
if (existing.length === events.length) return existing.map(toStored);
|
|
171
|
+
throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const base = Number(maxIdx.get(sessionId).max);
|
|
175
|
+
const now = clock.now().getTime();
|
|
176
|
+
return events.map((e, i) => {
|
|
177
|
+
const id = e.id ?? generateId();
|
|
178
|
+
const index = base + 1 + i;
|
|
179
|
+
insertEvent.run(sessionId, index, e.type, JSON.stringify(e.payload) ?? "null", id, now, e.cause ? JSON.stringify(e.cause) : null);
|
|
180
|
+
return {
|
|
181
|
+
id,
|
|
182
|
+
type: e.type,
|
|
183
|
+
payload: structuredClone(e.payload),
|
|
184
|
+
index,
|
|
185
|
+
sessionId,
|
|
186
|
+
createdAt: new Date(now),
|
|
187
|
+
cause: e.cause ? { ...e.cause } : null,
|
|
188
|
+
processedAt: null,
|
|
189
|
+
processedByAttempt: null,
|
|
190
|
+
firstClaimedAt: null,
|
|
191
|
+
lastClaimedAt: null,
|
|
192
|
+
attemptCount: 0,
|
|
193
|
+
failureCount: 0,
|
|
194
|
+
lastFailedAt: null,
|
|
195
|
+
lastFailedAttempt: null,
|
|
196
|
+
lastError: null,
|
|
197
|
+
failedAt: null
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
}));
|
|
201
|
+
},
|
|
202
|
+
async read(sessionId, opts) {
|
|
203
|
+
return wrap(() => {
|
|
204
|
+
const conditions = ["session_id = ?"];
|
|
205
|
+
const params = [sessionId];
|
|
206
|
+
if (opts?.afterIndex !== void 0) {
|
|
207
|
+
conditions.push("idx > ?");
|
|
208
|
+
params.push(opts.afterIndex);
|
|
209
|
+
}
|
|
210
|
+
if (opts?.unprocessedOnly) conditions.push("processed_at is null");
|
|
211
|
+
return db.prepare(`select * from a2_events where ${conditions.join(" and ")} order by idx`).all(...params).map(toStored);
|
|
212
|
+
});
|
|
213
|
+
},
|
|
214
|
+
async claimNext({ sessionId, holder, ttlMs, expiresAtMs, maxIndex }) {
|
|
215
|
+
return wrap(() => tx(() => {
|
|
216
|
+
const next = db.prepare(`select * from a2_events
|
|
217
|
+
where session_id = ? and processed_at is null
|
|
218
|
+
order by idx limit 1`).get(sessionId);
|
|
219
|
+
if (!next || next.failed_at !== null || maxIndex !== void 0 && Number(next.idx) > maxIndex) return { outcome: "settled" };
|
|
220
|
+
const now = clock.now().getTime();
|
|
221
|
+
const current = db.prepare("select holder, expires_at from a2_leases where session_id = ?").get(sessionId);
|
|
222
|
+
if (current && Number(current.expires_at) > now && current.holder !== holder) return { outcome: "busy" };
|
|
223
|
+
db.prepare(`insert into a2_leases (session_id, holder, expires_at) values (?, ?, ?)
|
|
224
|
+
on conflict (session_id) do update set
|
|
225
|
+
holder = excluded.holder,
|
|
226
|
+
expires_at = excluded.expires_at`).run(sessionId, holder, expiresAtMs ?? now + ttlMs);
|
|
227
|
+
const claimed = db.prepare(`update a2_events
|
|
228
|
+
set attempt_count = attempt_count + 1,
|
|
229
|
+
first_claimed_at = coalesce(first_claimed_at, ?),
|
|
230
|
+
last_claimed_at = ?
|
|
231
|
+
where session_id = ? and idx = ? and processed_at is null
|
|
232
|
+
returning *`).get(now, now, sessionId, next.idx);
|
|
233
|
+
if (!claimed) throw new TypeError(`no pending event at index ${String(next.idx)} in session '${sessionId}'`);
|
|
234
|
+
return {
|
|
235
|
+
outcome: "claimed",
|
|
236
|
+
event: toStored(claimed)
|
|
237
|
+
};
|
|
238
|
+
}));
|
|
239
|
+
},
|
|
240
|
+
async completeAndClaimNext({ sessionId, holder, completedIndex, attempt, maxIndex }) {
|
|
241
|
+
return wrap(() => tx(() => {
|
|
242
|
+
const now = clock.now().getTime();
|
|
243
|
+
if (!db.prepare(`update a2_events
|
|
244
|
+
set processed_at = ?, processed_by_attempt = ?
|
|
245
|
+
where session_id = ? and idx = ?
|
|
246
|
+
and processed_at is null and attempt_count = ?
|
|
247
|
+
returning idx`).get(now, attempt, sessionId, completedIndex, attempt)) {
|
|
248
|
+
if (!db.prepare("select 1 from a2_events where session_id = ? and idx = ?").get(sessionId, completedIndex)) throw new TypeError(`no event at index ${completedIndex} in session '${sessionId}'`);
|
|
249
|
+
return { outcome: "superseded" };
|
|
250
|
+
}
|
|
251
|
+
const next = db.prepare(`select * from a2_events
|
|
252
|
+
where session_id = ? and processed_at is null
|
|
253
|
+
order by idx limit 1`).get(sessionId);
|
|
254
|
+
if (!next || next.failed_at !== null || maxIndex !== void 0 && Number(next.idx) > maxIndex) return { outcome: "settled" };
|
|
255
|
+
const lease = db.prepare("select holder, expires_at from a2_leases where session_id = ?").get(sessionId);
|
|
256
|
+
if (!lease || lease.holder !== holder || Number(lease.expires_at) <= now) return { outcome: "busy" };
|
|
257
|
+
const claimed = db.prepare(`update a2_events
|
|
258
|
+
set attempt_count = attempt_count + 1,
|
|
259
|
+
first_claimed_at = coalesce(first_claimed_at, ?),
|
|
260
|
+
last_claimed_at = ?
|
|
261
|
+
where session_id = ? and idx = ? and processed_at is null
|
|
262
|
+
returning *`).get(now, now, sessionId, next.idx);
|
|
263
|
+
if (!claimed) throw new TypeError(`no pending event at index ${String(next.idx)} in session '${sessionId}'`);
|
|
264
|
+
return {
|
|
265
|
+
outcome: "claimed",
|
|
266
|
+
event: toStored(claimed)
|
|
267
|
+
};
|
|
268
|
+
}));
|
|
269
|
+
},
|
|
270
|
+
async markProcessed(sessionId, index) {
|
|
271
|
+
wrap(() => {
|
|
272
|
+
const { changes } = db.prepare(`update a2_events
|
|
273
|
+
set processed_at = coalesce(processed_at, ?)
|
|
274
|
+
where session_id = ? and idx = ?`).run(clock.now().getTime(), sessionId, index);
|
|
275
|
+
requireChange(changes, `no event at index ${index} in session '${sessionId}'`);
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
|
|
279
|
+
return wrap(() => tx(() => {
|
|
280
|
+
const current = db.prepare("select * from a2_events where session_id = ? and idx = ?").get(sessionId, index);
|
|
281
|
+
if (!current) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
282
|
+
const failureCount = Number(current.failure_count);
|
|
283
|
+
if (current.processed_at !== null || Number(current.attempt_count) !== attempt) return {
|
|
284
|
+
outcome: "superseded",
|
|
285
|
+
failureCount
|
|
286
|
+
};
|
|
287
|
+
if (current.failed_at !== null) return {
|
|
288
|
+
outcome: "dead_lettered",
|
|
289
|
+
failureCount
|
|
290
|
+
};
|
|
291
|
+
const now = clock.now().getTime();
|
|
292
|
+
const nextFailureCount = failureCount + 1;
|
|
293
|
+
const deadLettered = nextFailureCount >= maxFailures;
|
|
294
|
+
db.prepare(`update a2_events set
|
|
295
|
+
failure_count = ?,
|
|
296
|
+
last_error = ?,
|
|
297
|
+
last_failed_at = ?,
|
|
298
|
+
last_failed_attempt = ?,
|
|
299
|
+
failed_at = ?
|
|
300
|
+
where session_id = ? and idx = ?`).run(nextFailureCount, error, now, attempt, deadLettered ? now : null, sessionId, index);
|
|
301
|
+
return {
|
|
302
|
+
outcome: deadLettered ? "dead_lettered" : "failed",
|
|
303
|
+
failureCount: nextFailureCount
|
|
304
|
+
};
|
|
305
|
+
}));
|
|
306
|
+
},
|
|
307
|
+
async markFailed(sessionId, index) {
|
|
308
|
+
wrap(() => {
|
|
309
|
+
const { changes } = db.prepare("update a2_events set failed_at = ? where session_id = ? and idx = ?").run(clock.now().getTime(), sessionId, index);
|
|
310
|
+
requireChange(changes, `no event at index ${index} in session '${sessionId}'`);
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
async readState(sessionId, reducerName) {
|
|
314
|
+
return wrap(() => {
|
|
315
|
+
const rows = db.prepare(`with snapshot as materialized (
|
|
316
|
+
select up_to_index, state
|
|
317
|
+
from a2_snapshots
|
|
318
|
+
where session_id = ? and reducer_name = ?
|
|
319
|
+
)
|
|
320
|
+
select 0 as row_order, 'snapshot' as row_kind,
|
|
321
|
+
snapshot.up_to_index as snapshot_index,
|
|
322
|
+
snapshot.state as snapshot_state,
|
|
323
|
+
null as session_id, null as idx, null as event_type,
|
|
324
|
+
null as payload, null as event_id, null as created_at,
|
|
325
|
+
null as cause, null as processed_at,
|
|
326
|
+
null as processed_by_attempt, null as first_claimed_at,
|
|
327
|
+
null as last_claimed_at, null as attempt_count,
|
|
328
|
+
null as failure_count, null as last_failed_at,
|
|
329
|
+
null as last_failed_attempt, null as last_error,
|
|
330
|
+
null as failed_at
|
|
331
|
+
from snapshot
|
|
332
|
+
union all
|
|
333
|
+
select 1 as row_order, 'event' as row_kind,
|
|
334
|
+
null as snapshot_index, null as snapshot_state,
|
|
335
|
+
event.*
|
|
336
|
+
from a2_events as event
|
|
337
|
+
where event.session_id = ?
|
|
338
|
+
and event.idx > coalesce((select up_to_index from snapshot), 0)
|
|
339
|
+
order by row_order, idx`).all(sessionId, reducerName, sessionId);
|
|
340
|
+
const snapshot = rows.find((row) => row.row_kind === "snapshot");
|
|
341
|
+
return {
|
|
342
|
+
snapshot: snapshot ? {
|
|
343
|
+
index: Number(snapshot.snapshot_index),
|
|
344
|
+
state: JSON.parse(snapshot.snapshot_state)
|
|
345
|
+
} : null,
|
|
346
|
+
events: rows.filter((row) => row.row_kind === "event").map(toEvent)
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
},
|
|
350
|
+
async putSnapshot(sessionId, reducerName, index, state) {
|
|
351
|
+
wrap(() => {
|
|
352
|
+
db.prepare(`insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)
|
|
353
|
+
values (?, ?, ?, ?, ?)
|
|
354
|
+
on conflict (session_id, reducer_name) do update set
|
|
355
|
+
up_to_index = excluded.up_to_index,
|
|
356
|
+
state = excluded.state,
|
|
357
|
+
updated_at = excluded.updated_at
|
|
358
|
+
where excluded.up_to_index > a2_snapshots.up_to_index`).run(sessionId, reducerName, index, JSON.stringify(state) ?? "null", clock.now().getTime());
|
|
359
|
+
});
|
|
360
|
+
},
|
|
361
|
+
inspect: {
|
|
362
|
+
async listSessions(inspectionOptions) {
|
|
363
|
+
return wrap(() => {
|
|
364
|
+
const conditions = ["substr(session_id, 1, ?) = ?"];
|
|
365
|
+
const params = [inspectionOptions.prefix.length, inspectionOptions.prefix];
|
|
366
|
+
if (inspectionOptions.cursor !== void 0) {
|
|
367
|
+
conditions.push("session_id > ?");
|
|
368
|
+
params.push(inspectionOptions.cursor);
|
|
369
|
+
}
|
|
370
|
+
params.push(inspectionOptions.limit + 1);
|
|
371
|
+
const rows = db.prepare(`select
|
|
372
|
+
session_id,
|
|
373
|
+
count(*) as event_count,
|
|
374
|
+
sum(case when processed_at is null and failed_at is null then 1 else 0 end) as pending_count,
|
|
375
|
+
sum(case when failed_at is not null then 1 else 0 end) as failed_count,
|
|
376
|
+
sum(attempt_count) as attempt_count,
|
|
377
|
+
sum(failure_count) as failure_count,
|
|
378
|
+
min(created_at) as first_event_at,
|
|
379
|
+
max(max(
|
|
380
|
+
created_at,
|
|
381
|
+
coalesce(first_claimed_at, 0),
|
|
382
|
+
coalesce(last_claimed_at, 0),
|
|
383
|
+
coalesce(last_failed_at, 0),
|
|
384
|
+
coalesce(processed_at, 0),
|
|
385
|
+
coalesce(failed_at, 0)
|
|
386
|
+
)) as updated_at
|
|
387
|
+
from a2_events
|
|
388
|
+
where ${conditions.join(" and ")}
|
|
389
|
+
group by session_id
|
|
390
|
+
order by session_id
|
|
391
|
+
limit ?`).all(...params);
|
|
392
|
+
const hasMore = rows.length > inspectionOptions.limit;
|
|
393
|
+
const visible = rows.slice(0, inspectionOptions.limit);
|
|
394
|
+
return {
|
|
395
|
+
sessions: visible.map((row) => ({
|
|
396
|
+
sessionId: row.session_id,
|
|
397
|
+
eventCount: Number(row.event_count),
|
|
398
|
+
pendingCount: Number(row.pending_count),
|
|
399
|
+
failedCount: Number(row.failed_count),
|
|
400
|
+
attemptCount: Number(row.attempt_count),
|
|
401
|
+
failureCount: Number(row.failure_count),
|
|
402
|
+
firstEventAt: new Date(Number(row.first_event_at)),
|
|
403
|
+
updatedAt: new Date(Number(row.updated_at))
|
|
404
|
+
})),
|
|
405
|
+
cursor: hasMore ? visible.at(-1)?.session_id ?? null : null
|
|
406
|
+
};
|
|
407
|
+
});
|
|
408
|
+
},
|
|
409
|
+
async listSnapshots(sessionId) {
|
|
410
|
+
return wrap(() => {
|
|
411
|
+
return db.prepare(`select reducer_name, up_to_index, updated_at
|
|
412
|
+
from a2_snapshots where session_id = ? order by reducer_name`).all(sessionId).map((row) => ({
|
|
413
|
+
reducerName: row.reducer_name,
|
|
414
|
+
index: Number(row.up_to_index),
|
|
415
|
+
updatedAt: new Date(Number(row.updated_at))
|
|
416
|
+
}));
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
stream(sessionId, opts) {
|
|
421
|
+
const readAfter = (afterIndex) => wrap(() => {
|
|
422
|
+
return db.prepare("select * from a2_events where session_id = ? and idx > ? order by idx").all(sessionId, afterIndex).map(toStored);
|
|
423
|
+
});
|
|
424
|
+
return pollingStream(readAfter, opts?.startAt !== void 0 ? { startAt: opts.startAt } : {});
|
|
425
|
+
},
|
|
426
|
+
lease: {
|
|
427
|
+
async acquire({ sessionId, holder, ttlMs, expiresAtMs }) {
|
|
428
|
+
return wrap(() => tx(() => {
|
|
429
|
+
const now = clock.now().getTime();
|
|
430
|
+
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
431
|
+
const current = db.prepare("select * from a2_leases where session_id = ?").get(sessionId);
|
|
432
|
+
if (current && Number(current.expires_at) > now && current.holder !== holder) return false;
|
|
433
|
+
db.prepare(`insert into a2_leases (session_id, holder, expires_at) values (?, ?, ?)
|
|
434
|
+
on conflict (session_id) do update set holder = excluded.holder, expires_at = excluded.expires_at`).run(sessionId, holder, expiresAt);
|
|
435
|
+
return true;
|
|
436
|
+
}));
|
|
437
|
+
},
|
|
438
|
+
async release({ sessionId, holder }) {
|
|
439
|
+
wrap(() => {
|
|
440
|
+
db.prepare("delete from a2_leases where session_id = ? and holder = ?").run(sessionId, holder);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
close() {
|
|
445
|
+
db.close();
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
//#endregion
|
|
450
|
+
export { sqlite };
|
package/dist/otel.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { i as A2Telemetry } from "./telemetry-C78al20p.js";
|
|
2
|
+
import { Tracer } from "@opentelemetry/api";
|
|
3
|
+
//#region src/otel.d.ts
|
|
4
|
+
type OtelOptions = {
|
|
5
|
+
/** Bring your own tracer; defaults to `trace.getTracer(tracerName)`. */
|
|
6
|
+
tracer?: Tracer;
|
|
7
|
+
/** Tracer name for the global provider. Default `'a2'`. */
|
|
8
|
+
tracerName?: string;
|
|
9
|
+
};
|
|
10
|
+
declare function otel(options?: OtelOptions): A2Telemetry;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { OtelOptions, otel };
|
package/dist/otel.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
|
2
|
+
//#region src/otel.ts
|
|
3
|
+
/**
|
|
4
|
+
* a2/otel — OpenTelemetry adapter for the A2Telemetry interface.
|
|
5
|
+
*
|
|
6
|
+
* Peer-depends on `@opentelemetry/api` (optional). Spans nest through
|
|
7
|
+
* the active context, so with a context manager registered (any real
|
|
8
|
+
* OTel setup, e.g. `@vercel/otel`) the happy path lands in one trace:
|
|
9
|
+
* request → a2.append → a2.drain → a2.event → the handler's own spans.
|
|
10
|
+
*/
|
|
11
|
+
const fail = (span, error) => {
|
|
12
|
+
span.recordException(error instanceof Error ? error : new Error(String(error)));
|
|
13
|
+
span.setStatus({
|
|
14
|
+
code: SpanStatusCode.ERROR,
|
|
15
|
+
message: error instanceof Error ? error.message : String(error)
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
function otel(options = {}) {
|
|
19
|
+
const tracer = options.tracer ?? trace.getTracer(options.tracerName ?? "a2");
|
|
20
|
+
return { span(name, attributes, fn) {
|
|
21
|
+
return tracer.startActiveSpan(name, { attributes }, async (span) => {
|
|
22
|
+
try {
|
|
23
|
+
return await fn({
|
|
24
|
+
setAttribute: (key, value) => {
|
|
25
|
+
span.setAttribute(key, value);
|
|
26
|
+
},
|
|
27
|
+
recordError: (error) => {
|
|
28
|
+
fail(span, error);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
} catch (error) {
|
|
32
|
+
fail(span, error);
|
|
33
|
+
throw error;
|
|
34
|
+
} finally {
|
|
35
|
+
span.end();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
} };
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { otel };
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { i as EventDefs, r as ContractEvent, s as Reducer, t as AppendInput } from "./contract-DL8btVd9.js";
|
|
2
|
+
import { A2Client, Connection, PushResult } from "./client.js";
|
|
3
|
+
import { ReactElement, ReactNode } from "react";
|
|
4
|
+
//#region src/react.d.ts
|
|
5
|
+
type SessionProviderProps<D extends EventDefs, S> = {
|
|
6
|
+
/** Which session to subscribe to. */
|
|
7
|
+
sessionId: string;
|
|
8
|
+
/** Base path of the route exposing GET (stream) and POST (push). */
|
|
9
|
+
api: string;
|
|
10
|
+
/** The server-rendered fold — the first paint, no client JS needed. */
|
|
11
|
+
initialState: S;
|
|
12
|
+
/** The fold's frontier — the stream resumes exactly there. */
|
|
13
|
+
initialIndex: number;
|
|
14
|
+
/** Server-rendered event history through `initialIndex`. */
|
|
15
|
+
initialEvents?: ContractEvent<D>[];
|
|
16
|
+
children?: ReactNode;
|
|
17
|
+
};
|
|
18
|
+
type BoundSessionProviderProps<D extends EventDefs, S> = Omit<SessionProviderProps<D, S>, "api">;
|
|
19
|
+
type UseSessionResult<D extends EventDefs, S> = {
|
|
20
|
+
/** The live view, folded through the shared reducer. */
|
|
21
|
+
state: S;
|
|
22
|
+
/** The raw observed feed `state` is folded from. */
|
|
23
|
+
events: ContractEvent<D>[];
|
|
24
|
+
/** The stream frontier — `lastSeenIndex` for cancellation. */
|
|
25
|
+
index: number;
|
|
26
|
+
/**
|
|
27
|
+
* The connection, as a discriminated union — `error` exists only
|
|
28
|
+
* while disconnected; "reconnecting…" is
|
|
29
|
+
* `status === 'connecting' && reconnects > 0`.
|
|
30
|
+
*/
|
|
31
|
+
connection: Connection;
|
|
32
|
+
/**
|
|
33
|
+
* Typed optimistic append. Awaiting it gives the server ack;
|
|
34
|
+
* `.confirmed` resolves when the live stream has delivered the batch
|
|
35
|
+
* back. Rejects with the server's A2Error codes.
|
|
36
|
+
*/
|
|
37
|
+
push: (...events: AppendInput<D>[]) => PushResult<D>;
|
|
38
|
+
};
|
|
39
|
+
type A2React<D extends EventDefs, S> = {
|
|
40
|
+
SessionProvider: (props: SessionProviderProps<D, S>) => ReactElement;
|
|
41
|
+
useSession: () => UseSessionResult<D, S>;
|
|
42
|
+
};
|
|
43
|
+
type BoundA2React<D extends EventDefs, S> = {
|
|
44
|
+
SessionProvider: (props: BoundSessionProviderProps<D, S>) => ReactElement;
|
|
45
|
+
useSession: () => UseSessionResult<D, S>;
|
|
46
|
+
};
|
|
47
|
+
declare function createReact<D extends EventDefs, S>(options: {
|
|
48
|
+
client: A2Client<D, S>;
|
|
49
|
+
}): BoundA2React<D, S>;
|
|
50
|
+
declare function createReact<D extends EventDefs, S>(options: {
|
|
51
|
+
reducer: Reducer<D, S>;
|
|
52
|
+
}): A2React<D, S>;
|
|
53
|
+
//#endregion
|
|
54
|
+
export { A2React, BoundA2React, BoundSessionProviderProps, SessionProviderProps, UseSessionResult, createReact };
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createClient } from "./client.js";
|
|
3
|
+
import { createContext, createElement, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
|
|
4
|
+
//#region src/react.ts
|
|
5
|
+
/**
|
|
6
|
+
* a2/react — React bindings over a2/client.
|
|
7
|
+
*
|
|
8
|
+
* `createReact({ client })` is a factory (like createContext): call it
|
|
9
|
+
* once in a `'use client'` module with a shared A2 client and export the
|
|
10
|
+
* bound pair. Server components import `SessionProvider` from that module
|
|
11
|
+
* as a client reference; client components import `useSession` from the
|
|
12
|
+
* same file. The shared client also makes imperative pushes and provider
|
|
13
|
+
* renders resolve the same live session. Everything is typed by the
|
|
14
|
+
* reducer value — no type arguments, and nothing here ever touches the
|
|
15
|
+
* machine module.
|
|
16
|
+
*/
|
|
17
|
+
function createReact(options) {
|
|
18
|
+
const Context = createContext(null);
|
|
19
|
+
const providerMounts = /* @__PURE__ */ new WeakMap();
|
|
20
|
+
const resolveSession = "client" in options ? (sessionId, _api, initialState, initialIndex, initialEvents) => options.client.session(sessionId, {
|
|
21
|
+
initialState,
|
|
22
|
+
initialIndex,
|
|
23
|
+
...initialEvents === void 0 ? {} : { initialEvents }
|
|
24
|
+
}) : (() => {
|
|
25
|
+
const clients = /* @__PURE__ */ new Map();
|
|
26
|
+
return (sessionId, api, initialState, initialIndex, initialEvents) => {
|
|
27
|
+
if (api === void 0) throw new TypeError("SessionProvider requires api when createReact binds a reducer");
|
|
28
|
+
let client = clients.get(api);
|
|
29
|
+
if (!client) {
|
|
30
|
+
client = createClient({
|
|
31
|
+
reducer: options.reducer,
|
|
32
|
+
api
|
|
33
|
+
});
|
|
34
|
+
clients.set(api, client);
|
|
35
|
+
}
|
|
36
|
+
return client.session(sessionId, {
|
|
37
|
+
initialState,
|
|
38
|
+
initialIndex,
|
|
39
|
+
...initialEvents === void 0 ? {} : { initialEvents }
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
function SessionProvider(props) {
|
|
44
|
+
const { sessionId, initialState, initialIndex, initialEvents } = props;
|
|
45
|
+
const api = "api" in props ? props.api : void 0;
|
|
46
|
+
const client = useMemo(() => resolveSession(sessionId, api, initialState, initialIndex, initialEvents), [
|
|
47
|
+
sessionId,
|
|
48
|
+
api,
|
|
49
|
+
initialState,
|
|
50
|
+
initialIndex,
|
|
51
|
+
initialEvents
|
|
52
|
+
]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const mounts = providerMounts.get(client) ?? 0;
|
|
55
|
+
providerMounts.set(client, mounts + 1);
|
|
56
|
+
if (mounts === 0) client.connect();
|
|
57
|
+
return () => {
|
|
58
|
+
const remaining = (providerMounts.get(client) ?? 1) - 1;
|
|
59
|
+
if (remaining === 0) {
|
|
60
|
+
providerMounts.delete(client);
|
|
61
|
+
client.close();
|
|
62
|
+
} else providerMounts.set(client, remaining);
|
|
63
|
+
};
|
|
64
|
+
}, [client]);
|
|
65
|
+
return createElement(Context.Provider, { value: client }, props.children);
|
|
66
|
+
}
|
|
67
|
+
function useSession() {
|
|
68
|
+
const client = useContext(Context);
|
|
69
|
+
if (!client) throw new Error("useSession must be rendered inside its matching SessionProvider");
|
|
70
|
+
const snapshot = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
|
|
71
|
+
return useMemo(() => ({
|
|
72
|
+
state: snapshot.state,
|
|
73
|
+
events: snapshot.events,
|
|
74
|
+
index: snapshot.index,
|
|
75
|
+
connection: snapshot.connection,
|
|
76
|
+
push: client.push
|
|
77
|
+
}), [snapshot, client]);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
SessionProvider,
|
|
81
|
+
useSession
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
export { createReact };
|