experimental-a2 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/dist/ai-B4YhEnfw.d.ts +333 -0
- package/dist/ai-server.d.ts +48 -9
- package/dist/ai-server.js +121 -49
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +231 -86
- package/dist/cli-B3VuxoDe.js +597 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +5 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +2 -0
- package/dist/client-BrfDXQ8A.d.ts +155 -0
- package/dist/client-Bt4tAKi9.js +798 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
- package/dist/devtools-J_jZ2vQf.d.ts +151 -0
- package/dist/devtools-kJJaORn-.js +338 -0
- package/dist/devtools-server.browser.js +1 -1
- package/dist/devtools-server.d.ts +2 -2
- package/dist/devtools-server.js +224 -43
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
- package/dist/http.d.ts +71 -13
- package/dist/http.js +302 -41
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
- package/dist/internal-aEotMzu_.js +209 -0
- package/dist/otel.d.ts +1 -1
- package/dist/platform-B4TnJtWu.js +32 -0
- package/dist/react.d.ts +37 -14
- package/dist/react.js +26 -15
- package/dist/scheduler-qstash.d.ts +78 -0
- package/dist/scheduler-qstash.js +499 -0
- package/dist/scheduler-task-BpzhPnRS.js +54 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
- package/dist/scheduler-vercel.js +226 -0
- package/dist/server-CcNnFnoW.js +1405 -0
- package/dist/server-YtPq7hjw.d.ts +260 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
- package/dist/store-codec-DTG0Ftek.js +8 -0
- package/dist/store-memory.d.ts +11 -0
- package/dist/{log-memory.js → store-memory.js} +127 -24
- package/dist/{log-polling-DZ1MiKLg.js → store-polling-DgrrAE3d.js} +5 -5
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
- package/dist/{log-postgres.js → store-postgres.js} +158 -24
- package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-DWqx3F47.js} +269 -66
- package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +7 -7
- package/dist/{log-redis-http.js → store-redis-http.js} +20 -12
- package/dist/{log-redis.d.ts → store-redis.d.ts} +6 -6
- package/dist/{log-redis.js → store-redis.js} +99 -39
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
- package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +4 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +101 -0
- package/dist/wire-DCUZBUlT.js +222 -0
- package/docs/01-quickstart.mdx +3 -3
- package/docs/concepts/01-contracts.mdx +18 -13
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +21 -21
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -49
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +20 -21
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +310 -61
- package/docs/guides/06-ai-agents.mdx +249 -49
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +284 -0
- package/docs/guides/10-transports.mdx +131 -0
- package/docs/index.mdx +18 -14
- package/docs/reference/01-api.mdx +751 -103
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -7
- package/dist/internal-gCd5qMry.js +0 -44
- package/dist/log-memory.d.ts +0 -11
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-BcLa4RFL.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -1,32 +1,10 @@
|
|
|
1
|
-
import { t as A2Error } from "./errors-
|
|
2
|
-
import {
|
|
1
|
+
import { t as A2Error } from "./errors-BQuJpe82.js";
|
|
2
|
+
import { r as NOTIFY_TIMINGS } from "./internal-aEotMzu_.js";
|
|
3
|
+
import { t as defaultSleep } from "./store-polling-DgrrAE3d.js";
|
|
3
4
|
import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
|
|
4
|
-
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./
|
|
5
|
-
import { t as
|
|
6
|
-
|
|
7
|
-
//#region src/log-redis.ts
|
|
8
|
-
/**
|
|
9
|
-
* experimental-a2/log-redis — the Redis-protocol log backend, on Redis
|
|
10
|
-
* Streams. The storage semantics live in log-redis-core.ts (shared
|
|
11
|
-
* with experimental-a2/log-redis-http); this module owns the connection and
|
|
12
|
-
* the live feed.
|
|
13
|
-
*
|
|
14
|
-
* `stream()` is notify-driven: writes to watched sessions fire a
|
|
15
|
-
* disposable PUBLISH wake-up (feeds hold a TTL'd presence marker the
|
|
16
|
-
* write script checks, so unwatched sessions cost no wake-up), one
|
|
17
|
-
* shared subscriber connection per backend serves every local feed,
|
|
18
|
-
* and each wake triggers an `XRANGE` catch-up read. The notification
|
|
19
|
-
* only decides when to read, never what — a lost one is healed by a
|
|
20
|
-
* safety re-read (NOTIFY_TIMINGS), so delivery never depends on
|
|
21
|
-
* pub/sub. Connections scale with processes (one command client plus
|
|
22
|
-
* one subscriber), not with concurrent viewers.
|
|
23
|
-
*
|
|
24
|
-
* Works with any Redis-protocol server on a single instance or a
|
|
25
|
-
* non-cluster provider (Upstash — durable by default — Redis, Valkey).
|
|
26
|
-
* Cluster mode is out: the atomic scripts span keys. `ioredis` is an
|
|
27
|
-
* optional peer dependency; pass `url`, or inject any client exposing
|
|
28
|
-
* `call`/`duplicate`/`on`/`disconnect`.
|
|
29
|
-
*/
|
|
5
|
+
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
|
|
6
|
+
import { t as createRedisStoreCore } from "./store-redis-core-DWqx3F47.js";
|
|
7
|
+
//#region src/store-redis.ts
|
|
30
8
|
function redis(options = {}) {
|
|
31
9
|
const clock = options.clock ?? SYSTEM_CLOCK;
|
|
32
10
|
const ids = options.ids ?? RANDOM_IDS;
|
|
@@ -35,12 +13,13 @@ function redis(options = {}) {
|
|
|
35
13
|
const connection = retryableLazy(async () => {
|
|
36
14
|
if (options.client) return options.client;
|
|
37
15
|
return new (await (import("ioredis").catch(() => {
|
|
38
|
-
throw new A2Error("
|
|
16
|
+
throw new A2Error("STORE_NOT_CONFIGURED", "experimental-a2/store-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
|
|
39
17
|
}))).default(options.url);
|
|
40
18
|
});
|
|
41
19
|
const client = connection.get;
|
|
42
20
|
const notifyChannel = (sessionId) => `${prefix}:${sessionId}:notify`;
|
|
43
|
-
const
|
|
21
|
+
const presenceChannel = (ns) => `${prefix}:${ns}:presence`;
|
|
22
|
+
const core = createRedisStoreCore({
|
|
44
23
|
call: async (command, ...args) => {
|
|
45
24
|
return (await client()).call(command, ...args);
|
|
46
25
|
},
|
|
@@ -66,18 +45,63 @@ function redis(options = {}) {
|
|
|
66
45
|
* clock's every-stored-timestamp pledge is untouched.
|
|
67
46
|
*/
|
|
68
47
|
const watchedRefreshedAt = /* @__PURE__ */ new Map();
|
|
48
|
+
/**
|
|
49
|
+
* `:presence` channel → live listeners. Same-instance patches are
|
|
50
|
+
* delivered here synchronously by `presence.set`, so subscribe/set
|
|
51
|
+
* ordering never depends on the SUBSCRIBE command settling; the
|
|
52
|
+
* channel carries only other instances' patches, with own echoes
|
|
53
|
+
* skipped by `src`.
|
|
54
|
+
*/
|
|
55
|
+
const presenceListeners = /* @__PURE__ */ new Map();
|
|
56
|
+
const instanceId = crypto.randomUUID();
|
|
57
|
+
const deliverRemotePresence = (listeners, message) => {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(message);
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (parsed.src === instanceId) return;
|
|
65
|
+
const patch = {
|
|
66
|
+
participant: parsed.participant,
|
|
67
|
+
values: parsed.values,
|
|
68
|
+
seen: parsed.seen,
|
|
69
|
+
at: new Date(parsed.at)
|
|
70
|
+
};
|
|
71
|
+
for (const listener of listeners) listener(patch);
|
|
72
|
+
};
|
|
69
73
|
const getSubscriber = () => {
|
|
70
74
|
subscriber ??= (async () => {
|
|
71
75
|
const conn = (await client()).duplicate();
|
|
72
|
-
conn.on("message", (channel) => {
|
|
76
|
+
conn.on("message", (channel, message) => {
|
|
73
77
|
const set = wakers.get(channel);
|
|
74
|
-
if (
|
|
75
|
-
|
|
78
|
+
if (set) for (const wake of set) wake();
|
|
79
|
+
const listeners = presenceListeners.get(channel);
|
|
80
|
+
if (listeners) deliverRemotePresence(listeners, message);
|
|
76
81
|
});
|
|
77
82
|
return conn;
|
|
78
83
|
})();
|
|
79
84
|
return subscriber;
|
|
80
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* The subscriber, gated on connection readiness — for channel
|
|
88
|
+
* commands only (`close` uses the ungated promise, so it can never
|
|
89
|
+
* hang on an unreachable server). SUBSCRIBE carries redis's
|
|
90
|
+
* ok-loading flag, so ioredis writes it mid-handshake, ahead of the
|
|
91
|
+
* ready check's INFO — which then finds the connection already in
|
|
92
|
+
* subscriber mode and fails the check (a spurious error event plus
|
|
93
|
+
* a reconnect). ECHO has no such flag: it resolves only once the
|
|
94
|
+
* connection is ready, so every channel command chained behind it
|
|
95
|
+
* lands after the check.
|
|
96
|
+
*/
|
|
97
|
+
let subscriberGate = null;
|
|
98
|
+
const gatedSubscriber = () => {
|
|
99
|
+
subscriberGate ??= getSubscriber().then(async (conn) => {
|
|
100
|
+
await conn.call("echo", "ready");
|
|
101
|
+
return conn;
|
|
102
|
+
});
|
|
103
|
+
return subscriberGate;
|
|
104
|
+
};
|
|
81
105
|
const acquireChannel = async (channel) => {
|
|
82
106
|
const existing = subscriptions.get(channel);
|
|
83
107
|
if (existing) {
|
|
@@ -92,7 +116,7 @@ function redis(options = {}) {
|
|
|
92
116
|
}
|
|
93
117
|
const lease = {
|
|
94
118
|
refs: 1,
|
|
95
|
-
ready:
|
|
119
|
+
ready: gatedSubscriber().then((conn) => conn.call("subscribe", channel))
|
|
96
120
|
};
|
|
97
121
|
subscriptions.set(channel, lease);
|
|
98
122
|
try {
|
|
@@ -109,7 +133,7 @@ function redis(options = {}) {
|
|
|
109
133
|
if (lease.refs > 0) return;
|
|
110
134
|
subscriptions.delete(channel);
|
|
111
135
|
watchedRefreshedAt.delete(channel);
|
|
112
|
-
|
|
136
|
+
gatedSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
|
|
113
137
|
};
|
|
114
138
|
const armWaker = (channel) => {
|
|
115
139
|
let wake;
|
|
@@ -141,11 +165,47 @@ function redis(options = {}) {
|
|
|
141
165
|
readState: core.readState,
|
|
142
166
|
putSnapshot: core.putSnapshot,
|
|
143
167
|
inspect: core.inspect,
|
|
168
|
+
presence: {
|
|
169
|
+
async set(ns, participant, values, meta) {
|
|
170
|
+
const patch = await core.presence.set(ns, participant, values, meta);
|
|
171
|
+
if (!patch) return;
|
|
172
|
+
const channel = presenceChannel(ns);
|
|
173
|
+
const listeners = presenceListeners.get(channel);
|
|
174
|
+
if (listeners) for (const listener of listeners) listener(patch);
|
|
175
|
+
const message = JSON.stringify({
|
|
176
|
+
src: instanceId,
|
|
177
|
+
participant: patch.participant,
|
|
178
|
+
values: patch.values,
|
|
179
|
+
seen: patch.seen,
|
|
180
|
+
at: patch.at.getTime()
|
|
181
|
+
});
|
|
182
|
+
await client().then((c) => c.call("publish", channel, message)).catch(() => {});
|
|
183
|
+
},
|
|
184
|
+
read: core.presence.read,
|
|
185
|
+
subscribe(ns, onPatch) {
|
|
186
|
+
const channel = presenceChannel(ns);
|
|
187
|
+
let listeners = presenceListeners.get(channel);
|
|
188
|
+
if (!listeners) {
|
|
189
|
+
listeners = /* @__PURE__ */ new Set();
|
|
190
|
+
presenceListeners.set(channel, listeners);
|
|
191
|
+
}
|
|
192
|
+
listeners.add(onPatch);
|
|
193
|
+
acquireChannel(channel).catch(() => {});
|
|
194
|
+
let stopped = false;
|
|
195
|
+
return () => {
|
|
196
|
+
if (stopped) return;
|
|
197
|
+
stopped = true;
|
|
198
|
+
listeners.delete(onPatch);
|
|
199
|
+
if (listeners.size === 0) presenceListeners.delete(channel);
|
|
200
|
+
releaseChannel(channel);
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
},
|
|
144
204
|
stream(sessionId, opts) {
|
|
145
|
-
const
|
|
205
|
+
const startAfter = opts?.startAfter ?? 0;
|
|
146
206
|
const channel = notifyChannel(sessionId);
|
|
147
207
|
return { [Symbol.asyncIterator]() {
|
|
148
|
-
let last =
|
|
208
|
+
let last = startAfter;
|
|
149
209
|
let buffer = [];
|
|
150
210
|
let closed = false;
|
|
151
211
|
let subscribed = false;
|
|
@@ -176,7 +236,7 @@ function redis(options = {}) {
|
|
|
176
236
|
}
|
|
177
237
|
if (severed) {
|
|
178
238
|
release();
|
|
179
|
-
throw new A2Error("
|
|
239
|
+
throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
|
|
180
240
|
}
|
|
181
241
|
const waker = armWaker(channel);
|
|
182
242
|
interrupt = waker.wake;
|
|
@@ -204,7 +264,7 @@ function redis(options = {}) {
|
|
|
204
264
|
done: true
|
|
205
265
|
};
|
|
206
266
|
if (err instanceof A2Error) throw err;
|
|
207
|
-
throw new A2Error("
|
|
267
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
|
|
208
268
|
} finally {
|
|
209
269
|
waker.disarm();
|
|
210
270
|
interrupt = null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { c as IdSource, i as Clock, t as
|
|
2
|
-
//#region src/
|
|
3
|
-
type
|
|
1
|
+
import { c as IdSource, i as Clock, t as A2Store } from "./store-C3sNAaBT.js";
|
|
2
|
+
//#region src/store-sqlite.d.ts
|
|
3
|
+
type SqliteStoreOptions = {
|
|
4
4
|
/** Database file path. Defaults to `.a2/dev.db`; `:memory:` works. */
|
|
5
5
|
path?: string;
|
|
6
6
|
/** Injectable clock — every stored timestamp comes from here. */
|
|
@@ -8,10 +8,10 @@ type SqliteLogOptions = {
|
|
|
8
8
|
/** Injectable id source for generated event ids. */
|
|
9
9
|
ids?: IdSource;
|
|
10
10
|
};
|
|
11
|
-
type
|
|
11
|
+
type SqliteStore = A2Store & {
|
|
12
12
|
/** Close the underlying database handle. */
|
|
13
13
|
close(): void;
|
|
14
14
|
};
|
|
15
|
-
declare function sqlite(options?:
|
|
15
|
+
declare function sqlite(options?: SqliteStoreOptions): SqliteStore;
|
|
16
16
|
//#endregion
|
|
17
|
-
export {
|
|
17
|
+
export { SqliteStore, SqliteStoreOptions, sqlite };
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import { t as A2Error } from "./errors-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { n as
|
|
1
|
+
import { t as A2Error } from "./errors-BQuJpe82.js";
|
|
2
|
+
import { n as pollingStream } from "./store-polling-DgrrAE3d.js";
|
|
3
|
+
import { t as idempotentReplay } from "./idempotent-replay-DuqEkYA7.js";
|
|
4
|
+
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
|
|
5
|
+
import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
|
|
5
6
|
import { mkdirSync } from "node:fs";
|
|
6
7
|
import { dirname, resolve } from "node:path";
|
|
7
8
|
import { DatabaseSync } from "node:sqlite";
|
|
8
|
-
//#region src/
|
|
9
|
+
//#region src/store-sqlite.ts
|
|
9
10
|
/**
|
|
10
|
-
* experimental-a2/
|
|
11
|
+
* experimental-a2/store-sqlite — sqlite store backend (the dev default, `.a2/dev.db`).
|
|
11
12
|
*
|
|
12
13
|
* Built on `node:sqlite` (Node ≥ 22.13) so it ships with zero
|
|
13
|
-
* dependencies. Implements the
|
|
14
|
+
* dependencies. Implements the A2Store interface; the conformance suite
|
|
14
15
|
* in test/conformance is the executable contract.
|
|
15
16
|
*
|
|
16
17
|
* The spec's `index` column is stored as `idx` (a2-implementation.md
|
|
@@ -66,6 +67,17 @@ create table if not exists a2_snapshots (
|
|
|
66
67
|
updated_at integer not null,
|
|
67
68
|
primary key (session_id, reducer_name)
|
|
68
69
|
) strict;
|
|
70
|
+
|
|
71
|
+
create table if not exists a2_presence (
|
|
72
|
+
ns text not null,
|
|
73
|
+
participant text not null,
|
|
74
|
+
field text not null,
|
|
75
|
+
value text not null,
|
|
76
|
+
seen integer not null,
|
|
77
|
+
at integer not null,
|
|
78
|
+
expires_at integer not null,
|
|
79
|
+
primary key (ns, participant, field)
|
|
80
|
+
) strict;
|
|
69
81
|
`;
|
|
70
82
|
/**
|
|
71
83
|
* Switching journal modes takes an exclusive lock, and — unlike normal
|
|
@@ -88,7 +100,7 @@ const wrap = (fn) => {
|
|
|
88
100
|
return fn();
|
|
89
101
|
} catch (err) {
|
|
90
102
|
if (err instanceof A2Error || err instanceof TypeError) throw err;
|
|
91
|
-
throw new A2Error("
|
|
103
|
+
throw new A2Error("STORE_UNAVAILABLE", "sqlite store operation failed", { cause: err });
|
|
92
104
|
}
|
|
93
105
|
};
|
|
94
106
|
const toDate = (ms) => ms === null ? null : new Date(Number(ms));
|
|
@@ -114,7 +126,7 @@ const toStored = (row) => ({
|
|
|
114
126
|
lane: row.lane,
|
|
115
127
|
processedAt: toDate(row.processed_at),
|
|
116
128
|
processedByAttempt: row.processed_by_attempt === null ? null : Number(row.processed_by_attempt),
|
|
117
|
-
returnedEventIds: row.returned_event_ids === null ? null :
|
|
129
|
+
returnedEventIds: row.returned_event_ids === null ? null : decodeReturnedEventIds(row.returned_event_ids),
|
|
118
130
|
firstClaimedAt: toDate(row.first_claimed_at),
|
|
119
131
|
lastClaimedAt: toDate(row.last_claimed_at),
|
|
120
132
|
attemptCount: Number(row.attempt_count),
|
|
@@ -175,6 +187,21 @@ function sqlite(options = {}) {
|
|
|
175
187
|
select 1 from a2_events
|
|
176
188
|
where session_id = ? and processed_at is null
|
|
177
189
|
) as has_pending`);
|
|
190
|
+
const upsertPresence = db.prepare(`insert into a2_presence (ns, participant, field, value, seen, at, expires_at)
|
|
191
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
192
|
+
on conflict (ns, participant, field) do update set
|
|
193
|
+
value = excluded.value,
|
|
194
|
+
seen = excluded.seen,
|
|
195
|
+
at = excluded.at,
|
|
196
|
+
expires_at = excluded.expires_at
|
|
197
|
+
where a2_presence.at <= excluded.at`);
|
|
198
|
+
const deletePresence = db.prepare(`delete from a2_presence
|
|
199
|
+
where ns = ? and participant = ? and field = ? and at <= ?`);
|
|
200
|
+
const sweepPresence = db.prepare("delete from a2_presence where ns = ? and expires_at <= ?");
|
|
201
|
+
const selectPresence = db.prepare(`select participant, field, value, seen, at, expires_at
|
|
202
|
+
from a2_presence
|
|
203
|
+
where ns = ? and expires_at > ?
|
|
204
|
+
order by participant, field`);
|
|
178
205
|
const selectByIds = (count) => db.prepare(`select stored.*,
|
|
179
206
|
exists(
|
|
180
207
|
select 1 from a2_events pending
|
|
@@ -221,6 +248,12 @@ function sqlite(options = {}) {
|
|
|
221
248
|
throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
|
|
222
249
|
}
|
|
223
250
|
}
|
|
251
|
+
for (const e of events) {
|
|
252
|
+
if (!e.cause) continue;
|
|
253
|
+
const parent = db.prepare("select attempt_count, failed_at from a2_events where session_id = ? and idx = ?").get(sessionId, e.cause.index);
|
|
254
|
+
if (!parent) throw new TypeError(`no event at index ${e.cause.index} in session '${sessionId}'`);
|
|
255
|
+
if (Number(parent.attempt_count) !== e.cause.attempt || parent.failed_at !== null) throw new A2Error("SUPERSEDED_ATTEMPT", `attempt ${e.cause.attempt} no longer owns event ${e.cause.index} in session '${sessionId}'`);
|
|
256
|
+
}
|
|
224
257
|
const status = appendStatus.get(sessionId, sessionId);
|
|
225
258
|
const base = Number(status.max);
|
|
226
259
|
const now = clock.now().getTime();
|
|
@@ -265,6 +298,10 @@ function sqlite(options = {}) {
|
|
|
265
298
|
conditions.push("idx > ?");
|
|
266
299
|
params.push(opts.afterIndex);
|
|
267
300
|
}
|
|
301
|
+
if (opts?.throughIndex !== void 0) {
|
|
302
|
+
conditions.push("idx <= ?");
|
|
303
|
+
params.push(opts.throughIndex);
|
|
304
|
+
}
|
|
268
305
|
return db.prepare(`select * from a2_events where ${conditions.join(" and ")} order by idx`).all(...params).map(toStored);
|
|
269
306
|
});
|
|
270
307
|
},
|
|
@@ -312,20 +349,39 @@ function sqlite(options = {}) {
|
|
|
312
349
|
};
|
|
313
350
|
}));
|
|
314
351
|
},
|
|
315
|
-
async renewClaims({ sessionId, holder,
|
|
316
|
-
if (
|
|
352
|
+
async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
|
|
353
|
+
if (claims.length === 0) return {
|
|
354
|
+
renewed: [],
|
|
355
|
+
superseded: []
|
|
356
|
+
};
|
|
317
357
|
return wrap(() => tx(() => {
|
|
318
358
|
const now = clock.now().getTime();
|
|
319
359
|
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
360
|
+
const renewed = [];
|
|
361
|
+
const superseded = [];
|
|
362
|
+
const currentAttempt = db.prepare("select attempt_count from a2_events where session_id = ? and idx = ?");
|
|
363
|
+
const extend = db.prepare(`update a2_events set claim_expires_at = ?
|
|
364
|
+
where session_id = ?
|
|
365
|
+
and idx = ?
|
|
366
|
+
and attempt_count = ?
|
|
367
|
+
and processed_at is null
|
|
368
|
+
and failed_at is null
|
|
369
|
+
and claim_holder = ?
|
|
370
|
+
and claim_expires_at > ?`);
|
|
371
|
+
for (const claim of claims) {
|
|
372
|
+
const row = currentAttempt.get(sessionId, claim.index);
|
|
373
|
+
if (!row) continue;
|
|
374
|
+
if (Number(row.attempt_count) > claim.attempt) {
|
|
375
|
+
superseded.push(claim.index);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const updated = extend.run(expiresAt, sessionId, claim.index, claim.attempt, holder, now);
|
|
379
|
+
if (Number(updated.changes) === 1) renewed.push(claim.index);
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
renewed: renewed.toSorted((a, b) => a - b),
|
|
383
|
+
superseded: superseded.toSorted((a, b) => a - b)
|
|
384
|
+
};
|
|
329
385
|
}));
|
|
330
386
|
},
|
|
331
387
|
async completeAttempt({ sessionId, index, attempt, events }) {
|
|
@@ -336,7 +392,7 @@ function sqlite(options = {}) {
|
|
|
336
392
|
if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
|
|
337
393
|
if (parent.processed_at !== null) {
|
|
338
394
|
if (Number(parent.processed_by_attempt) !== attempt) return { outcome: "superseded" };
|
|
339
|
-
const returnedIds = parent.returned_event_ids === null ? null :
|
|
395
|
+
const returnedIds = parent.returned_event_ids === null ? null : decodeReturnedEventIds(parent.returned_event_ids);
|
|
340
396
|
if (returnedIds === null || returnedIds.length !== ids.length || returnedIds.some((id, offset) => id !== ids[offset])) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "completed attempt does not match the returned event batch");
|
|
341
397
|
const existing = ids.length === 0 ? [] : selectByIds(ids.length).all(sessionId, ...ids);
|
|
342
398
|
const byId = new Map(existing.map((row) => [row.event_id, row]));
|
|
@@ -465,6 +521,32 @@ function sqlite(options = {}) {
|
|
|
465
521
|
where excluded.up_to_index > a2_snapshots.up_to_index`).run(sessionId, reducerName, index, JSON.stringify(state) ?? "null", clock.now().getTime());
|
|
466
522
|
});
|
|
467
523
|
},
|
|
524
|
+
presence: {
|
|
525
|
+
async set(ns, participant, values, meta) {
|
|
526
|
+
const entries = Object.entries(values);
|
|
527
|
+
if (entries.length === 0) return;
|
|
528
|
+
wrap(() => tx(() => {
|
|
529
|
+
const atMs = meta.at.getTime();
|
|
530
|
+
const expiresAtMs = clock.now().getTime() + meta.ttlMs;
|
|
531
|
+
for (const [field, value] of entries) if (value === null) deletePresence.run(ns, participant, field, atMs);
|
|
532
|
+
else upsertPresence.run(ns, participant, field, JSON.stringify(value) ?? "null", meta.seen, atMs, expiresAtMs);
|
|
533
|
+
}));
|
|
534
|
+
},
|
|
535
|
+
async read(ns) {
|
|
536
|
+
return wrap(() => {
|
|
537
|
+
const now = clock.now().getTime();
|
|
538
|
+
sweepPresence.run(ns, now);
|
|
539
|
+
return selectPresence.all(ns, now).map((row) => ({
|
|
540
|
+
participant: row.participant,
|
|
541
|
+
field: row.field,
|
|
542
|
+
value: JSON.parse(row.value),
|
|
543
|
+
seen: Number(row.seen),
|
|
544
|
+
at: new Date(Number(row.at)),
|
|
545
|
+
expiresAt: new Date(Number(row.expires_at))
|
|
546
|
+
}));
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
},
|
|
468
550
|
inspect: {
|
|
469
551
|
async listSessions(inspectionOptions) {
|
|
470
552
|
return wrap(() => {
|
|
@@ -513,6 +595,18 @@ function sqlite(options = {}) {
|
|
|
513
595
|
};
|
|
514
596
|
});
|
|
515
597
|
},
|
|
598
|
+
async readEvents(sessionId, pageOptions) {
|
|
599
|
+
return wrap(() => {
|
|
600
|
+
const currentFrontier = Number(db.prepare("select coalesce(max(idx), 0) as frontier from a2_events where session_id = ?").get(sessionId).frontier);
|
|
601
|
+
const throughIndex = Math.min(pageOptions.throughIndex ?? currentFrontier, currentFrontier);
|
|
602
|
+
return {
|
|
603
|
+
events: db.prepare(`select * from a2_events
|
|
604
|
+
where session_id = ? and idx > ? and idx <= ?
|
|
605
|
+
order by idx limit ?`).all(sessionId, pageOptions.afterIndex, throughIndex, pageOptions.limit).map(toStored),
|
|
606
|
+
throughIndex
|
|
607
|
+
};
|
|
608
|
+
});
|
|
609
|
+
},
|
|
516
610
|
async listSnapshots(sessionId) {
|
|
517
611
|
return wrap(() => {
|
|
518
612
|
return db.prepare(`select reducer_name, up_to_index, updated_at
|
|
@@ -528,7 +622,7 @@ function sqlite(options = {}) {
|
|
|
528
622
|
const readAfter = (afterIndex) => wrap(() => {
|
|
529
623
|
return db.prepare("select * from a2_events where session_id = ? and idx > ? order by idx").all(sessionId, afterIndex).map(toStored);
|
|
530
624
|
});
|
|
531
|
-
return pollingStream(readAfter, opts?.
|
|
625
|
+
return pollingStream(readAfter, opts?.startAfter !== void 0 ? { startAfter: opts.startAfter } : {});
|
|
532
626
|
},
|
|
533
627
|
close() {
|
|
534
628
|
db.close();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
//#region src/telemetry.d.ts
|
|
2
2
|
/**
|
|
3
3
|
* The A2Telemetry interface — the instrumentation seam. Same philosophy
|
|
4
|
-
* as
|
|
4
|
+
* as store backends: the interface lives in core, implementations ship as
|
|
5
5
|
* entry points (`experimental-a2/otel` adapts it to OpenTelemetry). Without one,
|
|
6
6
|
* every operation runs through a no-op wrapper.
|
|
7
7
|
*/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { a as EventDefs, n as Contract, p as Reducer, r as ContractEvent } from "./contract-jIfaR085.js";
|
|
2
|
+
import { c as DevtoolsCapture, l as DevtoolsCaptureEvent } from "./devtools-J_jZ2vQf.js";
|
|
3
|
+
//#region src/testing.d.ts
|
|
4
|
+
type CaptureSource = string | URL;
|
|
5
|
+
type LoadedCapture<D extends EventDefs> = {
|
|
6
|
+
/** The verified capture, including its footer and operational records. */
|
|
7
|
+
readonly capture: DevtoolsCapture;
|
|
8
|
+
/** The contract used to validate and type the captured event history. */
|
|
9
|
+
readonly contract: Contract<D>;
|
|
10
|
+
/** Contract-typed public events with `createdAt` revived to `Date`. */
|
|
11
|
+
readonly events: readonly ContractEvent<D>[];
|
|
12
|
+
/** Unmodified wire events, including dispatch and failure metadata. */
|
|
13
|
+
readonly rawEvents: readonly DevtoolsCaptureEvent[];
|
|
14
|
+
};
|
|
15
|
+
type ReplayCaptureOptions = {
|
|
16
|
+
/** Fold events through this log index, inclusive. Zero means no events. */
|
|
17
|
+
throughIndex?: number;
|
|
18
|
+
};
|
|
19
|
+
type CaptureReplay<S> = {
|
|
20
|
+
readonly state: S;
|
|
21
|
+
/** The last event index actually folded, or zero for the initial state. */
|
|
22
|
+
readonly index: number;
|
|
23
|
+
};
|
|
24
|
+
/** Validate an already-parsed capture against an application's contract. */
|
|
25
|
+
declare function prepareCapture<D extends EventDefs>(capture: DevtoolsCapture, contract: Contract<D>): Promise<LoadedCapture<D>>;
|
|
26
|
+
/** Load, integrity-check, and contract-type a `.a2log` file or URL. */
|
|
27
|
+
declare function loadCapture<D extends EventDefs>(source: CaptureSource, contract: Contract<D>): Promise<LoadedCapture<D>>;
|
|
28
|
+
/** Purely fold captured events through a reducer, optionally to a cutpoint. */
|
|
29
|
+
declare function replayCapture<D extends EventDefs, S>(loaded: LoadedCapture<D>, reducer: Reducer<D, S>, options?: ReplayCaptureOptions): CaptureReplay<S>;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { CaptureReplay, CaptureSource, LoadedCapture, ReplayCaptureOptions, loadCapture, prepareCapture, replayCapture };
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { n as validateSync } from "./validate-XKT4FSNn.js";
|
|
2
|
+
import { l as parseDevtoolsCapture, u as verifyDevtoolsCapture } from "./devtools-kJJaORn-.js";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
//#region src/testing.ts
|
|
6
|
+
/**
|
|
7
|
+
* Offline helpers for loading a captured A2 session and replaying its events
|
|
8
|
+
* through the application's current reducers. This module never constructs a
|
|
9
|
+
* server or dispatches handlers.
|
|
10
|
+
*/
|
|
11
|
+
const captureError = (message) => /* @__PURE__ */ new TypeError(`invalid A2 capture: ${message}`);
|
|
12
|
+
const typedEvents = (capture, contract) => {
|
|
13
|
+
const events = [];
|
|
14
|
+
for (const event of capture.events) {
|
|
15
|
+
const schema = Object.hasOwn(contract.events, event.type) ? contract.events[event.type] : void 0;
|
|
16
|
+
if (schema === void 0) throw captureError(`contract '${contract.name}' has no event type '${event.type}' at index ${event.index}`);
|
|
17
|
+
const result = validateSync(schema, structuredClone(event.payload), `captured event '${event.type}'`);
|
|
18
|
+
if (result.issues !== void 0) throw captureError(`event #${event.index} '${event.type}' has an invalid payload: ${result.issues.map((issue) => issue.message).join("; ")}`);
|
|
19
|
+
events.push({
|
|
20
|
+
id: event.id,
|
|
21
|
+
type: event.type,
|
|
22
|
+
payload: result.value,
|
|
23
|
+
index: event.index,
|
|
24
|
+
sessionId: event.sessionId,
|
|
25
|
+
createdAt: new Date(event.createdAt)
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return events;
|
|
29
|
+
};
|
|
30
|
+
/** Validate an already-parsed capture against an application's contract. */
|
|
31
|
+
async function prepareCapture(capture, contract) {
|
|
32
|
+
await verifyDevtoolsCapture(capture);
|
|
33
|
+
if (capture.contract !== contract.name) throw captureError(`expected contract '${contract.name}', received '${capture.contract}'`);
|
|
34
|
+
return {
|
|
35
|
+
capture,
|
|
36
|
+
contract,
|
|
37
|
+
events: typedEvents(capture, contract),
|
|
38
|
+
rawEvents: capture.events
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const displayUrl = (url) => {
|
|
42
|
+
if (url.protocol === "data:") return "data: URL";
|
|
43
|
+
const safe = new URL(url);
|
|
44
|
+
safe.username = "";
|
|
45
|
+
safe.password = "";
|
|
46
|
+
safe.search = "";
|
|
47
|
+
safe.hash = "";
|
|
48
|
+
return safe.href;
|
|
49
|
+
};
|
|
50
|
+
const fetchCapture = async (url) => {
|
|
51
|
+
const location = displayUrl(url);
|
|
52
|
+
let response;
|
|
53
|
+
try {
|
|
54
|
+
response = await fetch(url);
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`failed to load A2 capture from '${location}'`);
|
|
57
|
+
}
|
|
58
|
+
if (!response.ok) throw new Error(`failed to load A2 capture from '${location}': ${response.status} ${response.statusText}`);
|
|
59
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
60
|
+
};
|
|
61
|
+
const readCapture = async (source) => {
|
|
62
|
+
if (source instanceof URL) return source.protocol === "file:" ? readFile(fileURLToPath(source)) : fetchCapture(source);
|
|
63
|
+
if (/^[a-z]:[\\/]/i.test(source)) return readFile(source);
|
|
64
|
+
let url;
|
|
65
|
+
try {
|
|
66
|
+
url = new URL(source);
|
|
67
|
+
} catch {
|
|
68
|
+
return readFile(source);
|
|
69
|
+
}
|
|
70
|
+
return readCapture(url);
|
|
71
|
+
};
|
|
72
|
+
/** Load, integrity-check, and contract-type a `.a2log` file or URL. */
|
|
73
|
+
async function loadCapture(source, contract) {
|
|
74
|
+
const encoded = await readCapture(source);
|
|
75
|
+
return prepareCapture(parseDevtoolsCapture(encoded), contract);
|
|
76
|
+
}
|
|
77
|
+
const cloneInitial = (initialState) => {
|
|
78
|
+
try {
|
|
79
|
+
return structuredClone(initialState);
|
|
80
|
+
} catch {
|
|
81
|
+
return initialState;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
/** Purely fold captured events through a reducer, optionally to a cutpoint. */
|
|
85
|
+
function replayCapture(loaded, reducer, options = {}) {
|
|
86
|
+
const { throughIndex } = options;
|
|
87
|
+
if (throughIndex !== void 0 && (!Number.isInteger(throughIndex) || throughIndex < 0)) throw new TypeError("throughIndex must be a non-negative integer");
|
|
88
|
+
let state = cloneInitial(reducer.initialState);
|
|
89
|
+
let index = 0;
|
|
90
|
+
for (const event of loaded.events) {
|
|
91
|
+
if (throughIndex !== void 0 && event.index > throughIndex) break;
|
|
92
|
+
state = reducer.fold(state, event);
|
|
93
|
+
index = event.index;
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
state,
|
|
97
|
+
index
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
//#endregion
|
|
101
|
+
export { loadCapture, prepareCapture, replayCapture };
|