experimental-a2 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +169 -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-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
- 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.js → store-redis-core-DWqx3F47.js} +294 -156
- package/dist/store-redis-http.d.ts +21 -0
- package/dist/store-redis-http.js +70 -0
- package/dist/store-redis.d.ts +37 -0
- package/dist/store-redis.js +298 -0
- 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 +4 -5
- package/docs/concepts/01-contracts.mdx +21 -17
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +26 -29
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -54
- 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 +321 -60
- 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 +22 -46
- package/docs/reference/01-api.mdx +751 -102
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -6
- package/dist/internal-D6wNxTck.js +0 -36
- package/dist/log-memory.d.ts +0 -11
- package/dist/log-redis.d.ts +0 -31
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-DJgD2YWP.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { c as IdSource, i as Clock, t as A2Store } from "./store-C3sNAaBT.js";
|
|
2
|
+
//#region src/store-redis-http.d.ts
|
|
3
|
+
type RedisHttpStoreOptions = {
|
|
4
|
+
/** REST endpoint, e.g. `process.env.UPSTASH_REDIS_REST_URL`. */
|
|
5
|
+
url: string;
|
|
6
|
+
/** Bearer token, e.g. `process.env.UPSTASH_REDIS_REST_TOKEN`. */
|
|
7
|
+
token: string;
|
|
8
|
+
/** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */
|
|
9
|
+
keyPrefix?: string;
|
|
10
|
+
/** Injectable clock — every stored timestamp comes from here. */
|
|
11
|
+
clock?: Clock;
|
|
12
|
+
/** Injectable id source for generated event ids. */
|
|
13
|
+
ids?: IdSource;
|
|
14
|
+
};
|
|
15
|
+
type RedisHttpStore = A2Store & {
|
|
16
|
+
/** Nothing to disconnect; present so callers can treat stores uniformly. */
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
declare function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { RedisHttpStore, RedisHttpStoreOptions, redisHttp };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { t as A2Error } from "./errors-BQuJpe82.js";
|
|
2
|
+
import { n as pollingStream } from "./store-polling-DgrrAE3d.js";
|
|
3
|
+
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
|
|
4
|
+
import { t as createRedisStoreCore } from "./store-redis-core-DWqx3F47.js";
|
|
5
|
+
//#region src/store-redis-http.ts
|
|
6
|
+
/**
|
|
7
|
+
* experimental-a2/store-redis-http — the same Redis Streams store over a
|
|
8
|
+
* provider REST API (Upstash-shaped: POST one command as a JSON array,
|
|
9
|
+
* receive `{ result }` or `{ error }`). The storage semantics live in
|
|
10
|
+
* store-redis-core.ts, shared with experimental-a2/store-redis.
|
|
11
|
+
*
|
|
12
|
+
* Fully connectionless: every command is one `fetch`, so it works
|
|
13
|
+
* where a Redis-protocol connection cannot exist or cannot be spared.
|
|
14
|
+
* Without a connection there is no push and no pub/sub, so `stream()`
|
|
15
|
+
* is the shared activity-adaptive poll loop (25ms while events flow,
|
|
16
|
+
* backing off to a 250ms idle ceiling), and presence is the degraded
|
|
17
|
+
* tier: `set`/`read` from the shared core, no `subscribe`. No peer
|
|
18
|
+
* dependencies.
|
|
19
|
+
*/
|
|
20
|
+
function redisHttp(options) {
|
|
21
|
+
if (!options.url || !options.token) throw new TypeError("redisHttp() needs a url and a token");
|
|
22
|
+
const { url, token } = options;
|
|
23
|
+
const call = async (command, ...args) => {
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await fetch(url, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
authorization: `Bearer ${token}`,
|
|
30
|
+
"content-type": "application/json"
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify([command, ...args.map(String)])
|
|
33
|
+
});
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis rest request failed", { cause: err });
|
|
36
|
+
}
|
|
37
|
+
const body = await response.json().catch(() => null);
|
|
38
|
+
if (body === null || !response.ok || body.error !== void 0) throw new A2Error("STORE_UNAVAILABLE", `redis rest command failed: ${body?.error ?? `status ${response.status}`}`);
|
|
39
|
+
return body.result;
|
|
40
|
+
};
|
|
41
|
+
const core = createRedisStoreCore({
|
|
42
|
+
call,
|
|
43
|
+
clock: options.clock ?? SYSTEM_CLOCK,
|
|
44
|
+
ids: options.ids ?? RANDOM_IDS,
|
|
45
|
+
keyPrefix: options.keyPrefix ?? "a2"
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
append: core.append,
|
|
49
|
+
read: core.read,
|
|
50
|
+
claimAvailable: core.claimAvailable,
|
|
51
|
+
renewClaims: core.renewClaims,
|
|
52
|
+
completeAttempt: core.completeAttempt,
|
|
53
|
+
failAttempt: core.failAttempt,
|
|
54
|
+
readState: core.readState,
|
|
55
|
+
putSnapshot: core.putSnapshot,
|
|
56
|
+
presence: {
|
|
57
|
+
async set(ns, participant, values, meta) {
|
|
58
|
+
await core.presence.set(ns, participant, values, meta);
|
|
59
|
+
},
|
|
60
|
+
read: core.presence.read
|
|
61
|
+
},
|
|
62
|
+
inspect: core.inspect,
|
|
63
|
+
stream(sessionId, opts) {
|
|
64
|
+
return pollingStream((afterIndex) => core.readRange(sessionId, afterIndex), opts?.startAfter !== void 0 ? { startAfter: opts.startAfter } : {});
|
|
65
|
+
},
|
|
66
|
+
async close() {}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
export { redisHttp };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { c as IdSource, i as Clock, t as A2Store } from "./store-C3sNAaBT.js";
|
|
2
|
+
//#region src/store-redis.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The minimal client this backend needs — `ioredis` matches it
|
|
5
|
+
* structurally. `call` issues any command; `duplicate` opens the one
|
|
6
|
+
* shared subscriber connection; `on` delivers its pub/sub messages.
|
|
7
|
+
*
|
|
8
|
+
* The client must restore its subscriptions after a reconnect
|
|
9
|
+
* (`ioredis` does). One that doesn't stays correct — the safety
|
|
10
|
+
* re-read delivers everything — but every live feed silently degrades
|
|
11
|
+
* to safety-read latency from that point on.
|
|
12
|
+
*/
|
|
13
|
+
type RedisConnection = {
|
|
14
|
+
call(command: string, ...args: Array<string | number>): Promise<unknown>;
|
|
15
|
+
duplicate(): RedisConnection;
|
|
16
|
+
on(event: "message", listener: (channel: string, message: string) => void): unknown;
|
|
17
|
+
disconnect(): void;
|
|
18
|
+
};
|
|
19
|
+
type RedisStoreOptions = {
|
|
20
|
+
/** Creates an `ioredis` client lazily (optional peer dep `ioredis`). */
|
|
21
|
+
url?: string | undefined;
|
|
22
|
+
/** Bring your own client — anything `call`/`duplicate`/`on`/`disconnect`. */
|
|
23
|
+
client?: RedisConnection;
|
|
24
|
+
/** Key prefix — isolates multiple apps on one Redis. Default `'a2'`. */
|
|
25
|
+
keyPrefix?: string;
|
|
26
|
+
/** Injectable clock — every stored timestamp comes from here. */
|
|
27
|
+
clock?: Clock;
|
|
28
|
+
/** Injectable id source for generated event ids. */
|
|
29
|
+
ids?: IdSource;
|
|
30
|
+
};
|
|
31
|
+
type RedisStore = A2Store & {
|
|
32
|
+
/** Disconnect the command client and the shared subscriber. */
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
declare function redis(options?: RedisStoreOptions): RedisStore;
|
|
36
|
+
//#endregion
|
|
37
|
+
export { RedisConnection, RedisStore, RedisStoreOptions, redis };
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { t as A2Error } from "./errors-BQuJpe82.js";
|
|
2
|
+
import { r as NOTIFY_TIMINGS } from "./internal-aEotMzu_.js";
|
|
3
|
+
import { t as defaultSleep } from "./store-polling-DgrrAE3d.js";
|
|
4
|
+
import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
|
|
5
|
+
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
|
|
6
|
+
import { t as createRedisStoreCore } from "./store-redis-core-DWqx3F47.js";
|
|
7
|
+
//#region src/store-redis.ts
|
|
8
|
+
function redis(options = {}) {
|
|
9
|
+
const clock = options.clock ?? SYSTEM_CLOCK;
|
|
10
|
+
const ids = options.ids ?? RANDOM_IDS;
|
|
11
|
+
const prefix = options.keyPrefix ?? "a2";
|
|
12
|
+
if (!options.client && !options.url) throw new TypeError("redis() needs a url or an injected client");
|
|
13
|
+
const connection = retryableLazy(async () => {
|
|
14
|
+
if (options.client) return options.client;
|
|
15
|
+
return new (await (import("ioredis").catch(() => {
|
|
16
|
+
throw new A2Error("STORE_NOT_CONFIGURED", "experimental-a2/store-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
|
|
17
|
+
}))).default(options.url);
|
|
18
|
+
});
|
|
19
|
+
const client = connection.get;
|
|
20
|
+
const notifyChannel = (sessionId) => `${prefix}:${sessionId}:notify`;
|
|
21
|
+
const presenceChannel = (ns) => `${prefix}:${ns}:presence`;
|
|
22
|
+
const core = createRedisStoreCore({
|
|
23
|
+
call: async (command, ...args) => {
|
|
24
|
+
return (await client()).call(command, ...args);
|
|
25
|
+
},
|
|
26
|
+
clock,
|
|
27
|
+
ids,
|
|
28
|
+
keyPrefix: prefix,
|
|
29
|
+
notify: (sessionId, count) => {
|
|
30
|
+
client().then((c) => c.call("publish", notifyChannel(sessionId), count)).catch(() => {});
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
let severed = false;
|
|
34
|
+
let subscriber = null;
|
|
35
|
+
/** channel → parked-feed wake-ups, armed before each catch-up read. */
|
|
36
|
+
const wakers = /* @__PURE__ */ new Map();
|
|
37
|
+
/** channel → refcounted subscription held for each live iterator. */
|
|
38
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
39
|
+
/**
|
|
40
|
+
* channel → last presence-marker refresh, throttling the SET.
|
|
41
|
+
* Deliberately real time (`Date.now()`), not the injected clock:
|
|
42
|
+
* the marker's PX expiry runs on server real time, and the throttle
|
|
43
|
+
* must tick with the TTL it refreshes or time-traveling tests would
|
|
44
|
+
* desync the two. Process-local and never stored, so the injected
|
|
45
|
+
* clock's every-stored-timestamp pledge is untouched.
|
|
46
|
+
*/
|
|
47
|
+
const watchedRefreshedAt = /* @__PURE__ */ new Map();
|
|
48
|
+
/**
|
|
49
|
+
* `:presence` channel → live listeners. Same-instance patches are
|
|
50
|
+
* delivered here synchronously by `presence.set`, so subscribe/set
|
|
51
|
+
* ordering never depends on the SUBSCRIBE command settling; the
|
|
52
|
+
* channel carries only other instances' patches, with own echoes
|
|
53
|
+
* skipped by `src`.
|
|
54
|
+
*/
|
|
55
|
+
const presenceListeners = /* @__PURE__ */ new Map();
|
|
56
|
+
const instanceId = crypto.randomUUID();
|
|
57
|
+
const deliverRemotePresence = (listeners, message) => {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(message);
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (parsed.src === instanceId) return;
|
|
65
|
+
const patch = {
|
|
66
|
+
participant: parsed.participant,
|
|
67
|
+
values: parsed.values,
|
|
68
|
+
seen: parsed.seen,
|
|
69
|
+
at: new Date(parsed.at)
|
|
70
|
+
};
|
|
71
|
+
for (const listener of listeners) listener(patch);
|
|
72
|
+
};
|
|
73
|
+
const getSubscriber = () => {
|
|
74
|
+
subscriber ??= (async () => {
|
|
75
|
+
const conn = (await client()).duplicate();
|
|
76
|
+
conn.on("message", (channel, message) => {
|
|
77
|
+
const set = wakers.get(channel);
|
|
78
|
+
if (set) for (const wake of set) wake();
|
|
79
|
+
const listeners = presenceListeners.get(channel);
|
|
80
|
+
if (listeners) deliverRemotePresence(listeners, message);
|
|
81
|
+
});
|
|
82
|
+
return conn;
|
|
83
|
+
})();
|
|
84
|
+
return subscriber;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* The subscriber, gated on connection readiness — for channel
|
|
88
|
+
* commands only (`close` uses the ungated promise, so it can never
|
|
89
|
+
* hang on an unreachable server). SUBSCRIBE carries redis's
|
|
90
|
+
* ok-loading flag, so ioredis writes it mid-handshake, ahead of the
|
|
91
|
+
* ready check's INFO — which then finds the connection already in
|
|
92
|
+
* subscriber mode and fails the check (a spurious error event plus
|
|
93
|
+
* a reconnect). ECHO has no such flag: it resolves only once the
|
|
94
|
+
* connection is ready, so every channel command chained behind it
|
|
95
|
+
* lands after the check.
|
|
96
|
+
*/
|
|
97
|
+
let subscriberGate = null;
|
|
98
|
+
const gatedSubscriber = () => {
|
|
99
|
+
subscriberGate ??= getSubscriber().then(async (conn) => {
|
|
100
|
+
await conn.call("echo", "ready");
|
|
101
|
+
return conn;
|
|
102
|
+
});
|
|
103
|
+
return subscriberGate;
|
|
104
|
+
};
|
|
105
|
+
const acquireChannel = async (channel) => {
|
|
106
|
+
const existing = subscriptions.get(channel);
|
|
107
|
+
if (existing) {
|
|
108
|
+
existing.refs += 1;
|
|
109
|
+
try {
|
|
110
|
+
await existing.ready;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
existing.refs -= 1;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const lease = {
|
|
118
|
+
refs: 1,
|
|
119
|
+
ready: gatedSubscriber().then((conn) => conn.call("subscribe", channel))
|
|
120
|
+
};
|
|
121
|
+
subscriptions.set(channel, lease);
|
|
122
|
+
try {
|
|
123
|
+
await lease.ready;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (subscriptions.get(channel) === lease) subscriptions.delete(channel);
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const releaseChannel = (channel) => {
|
|
130
|
+
const lease = subscriptions.get(channel);
|
|
131
|
+
if (!lease) return;
|
|
132
|
+
lease.refs -= 1;
|
|
133
|
+
if (lease.refs > 0) return;
|
|
134
|
+
subscriptions.delete(channel);
|
|
135
|
+
watchedRefreshedAt.delete(channel);
|
|
136
|
+
gatedSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
|
|
137
|
+
};
|
|
138
|
+
const armWaker = (channel) => {
|
|
139
|
+
let wake;
|
|
140
|
+
const wakeup = new Promise((resolve) => {
|
|
141
|
+
wake = resolve;
|
|
142
|
+
});
|
|
143
|
+
let set = wakers.get(channel);
|
|
144
|
+
if (!set) {
|
|
145
|
+
set = /* @__PURE__ */ new Set();
|
|
146
|
+
wakers.set(channel, set);
|
|
147
|
+
}
|
|
148
|
+
set.add(wake);
|
|
149
|
+
return {
|
|
150
|
+
wakeup,
|
|
151
|
+
wake,
|
|
152
|
+
disarm: () => {
|
|
153
|
+
set.delete(wake);
|
|
154
|
+
if (set.size === 0) wakers.delete(channel);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
append: core.append,
|
|
160
|
+
read: core.read,
|
|
161
|
+
claimAvailable: core.claimAvailable,
|
|
162
|
+
renewClaims: core.renewClaims,
|
|
163
|
+
completeAttempt: core.completeAttempt,
|
|
164
|
+
failAttempt: core.failAttempt,
|
|
165
|
+
readState: core.readState,
|
|
166
|
+
putSnapshot: core.putSnapshot,
|
|
167
|
+
inspect: core.inspect,
|
|
168
|
+
presence: {
|
|
169
|
+
async set(ns, participant, values, meta) {
|
|
170
|
+
const patch = await core.presence.set(ns, participant, values, meta);
|
|
171
|
+
if (!patch) return;
|
|
172
|
+
const channel = presenceChannel(ns);
|
|
173
|
+
const listeners = presenceListeners.get(channel);
|
|
174
|
+
if (listeners) for (const listener of listeners) listener(patch);
|
|
175
|
+
const message = JSON.stringify({
|
|
176
|
+
src: instanceId,
|
|
177
|
+
participant: patch.participant,
|
|
178
|
+
values: patch.values,
|
|
179
|
+
seen: patch.seen,
|
|
180
|
+
at: patch.at.getTime()
|
|
181
|
+
});
|
|
182
|
+
await client().then((c) => c.call("publish", channel, message)).catch(() => {});
|
|
183
|
+
},
|
|
184
|
+
read: core.presence.read,
|
|
185
|
+
subscribe(ns, onPatch) {
|
|
186
|
+
const channel = presenceChannel(ns);
|
|
187
|
+
let listeners = presenceListeners.get(channel);
|
|
188
|
+
if (!listeners) {
|
|
189
|
+
listeners = /* @__PURE__ */ new Set();
|
|
190
|
+
presenceListeners.set(channel, listeners);
|
|
191
|
+
}
|
|
192
|
+
listeners.add(onPatch);
|
|
193
|
+
acquireChannel(channel).catch(() => {});
|
|
194
|
+
let stopped = false;
|
|
195
|
+
return () => {
|
|
196
|
+
if (stopped) return;
|
|
197
|
+
stopped = true;
|
|
198
|
+
listeners.delete(onPatch);
|
|
199
|
+
if (listeners.size === 0) presenceListeners.delete(channel);
|
|
200
|
+
releaseChannel(channel);
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
stream(sessionId, opts) {
|
|
205
|
+
const startAfter = opts?.startAfter ?? 0;
|
|
206
|
+
const channel = notifyChannel(sessionId);
|
|
207
|
+
return { [Symbol.asyncIterator]() {
|
|
208
|
+
let last = startAfter;
|
|
209
|
+
let buffer = [];
|
|
210
|
+
let closed = false;
|
|
211
|
+
let subscribed = false;
|
|
212
|
+
let pending = null;
|
|
213
|
+
let interrupt = null;
|
|
214
|
+
const release = () => {
|
|
215
|
+
if (!subscribed) return;
|
|
216
|
+
subscribed = false;
|
|
217
|
+
releaseChannel(channel);
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
async next() {
|
|
221
|
+
for (;;) {
|
|
222
|
+
if (closed) {
|
|
223
|
+
release();
|
|
224
|
+
return {
|
|
225
|
+
value: void 0,
|
|
226
|
+
done: true
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const row = buffer.shift();
|
|
230
|
+
if (row) {
|
|
231
|
+
last = row.index;
|
|
232
|
+
return {
|
|
233
|
+
value: row,
|
|
234
|
+
done: false
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
if (severed) {
|
|
238
|
+
release();
|
|
239
|
+
throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
|
|
240
|
+
}
|
|
241
|
+
const waker = armWaker(channel);
|
|
242
|
+
interrupt = waker.wake;
|
|
243
|
+
try {
|
|
244
|
+
if (!subscribed) {
|
|
245
|
+
await acquireChannel(channel);
|
|
246
|
+
subscribed = true;
|
|
247
|
+
}
|
|
248
|
+
const markedAt = watchedRefreshedAt.get(channel) ?? 0;
|
|
249
|
+
if (Date.now() - markedAt >= NOTIFY_TIMINGS.safetyReadMs) {
|
|
250
|
+
watchedRefreshedAt.set(channel, Date.now());
|
|
251
|
+
await core.markWatched(sessionId, NOTIFY_TIMINGS.safetyReadMs * 3);
|
|
252
|
+
}
|
|
253
|
+
buffer = await core.readEvents(sessionId, last);
|
|
254
|
+
if (buffer.length === 0 && !closed && !severed) {
|
|
255
|
+
pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
|
|
256
|
+
await Promise.race([waker.wakeup, pending.promise]);
|
|
257
|
+
pending.cancel();
|
|
258
|
+
pending = null;
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
release();
|
|
262
|
+
if (closed) return {
|
|
263
|
+
value: void 0,
|
|
264
|
+
done: true
|
|
265
|
+
};
|
|
266
|
+
if (err instanceof A2Error) throw err;
|
|
267
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
|
|
268
|
+
} finally {
|
|
269
|
+
waker.disarm();
|
|
270
|
+
interrupt = null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
async return() {
|
|
275
|
+
closed = true;
|
|
276
|
+
pending?.cancel();
|
|
277
|
+
interrupt?.();
|
|
278
|
+
release();
|
|
279
|
+
return {
|
|
280
|
+
value: void 0,
|
|
281
|
+
done: true
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
} };
|
|
286
|
+
},
|
|
287
|
+
async close() {
|
|
288
|
+
severed = true;
|
|
289
|
+
for (const set of wakers.values()) for (const wake of set) wake();
|
|
290
|
+
if (subscriber) (await subscriber.catch(() => null))?.disconnect();
|
|
291
|
+
const current = connection.peek();
|
|
292
|
+
if (!current) return;
|
|
293
|
+
(await current.catch(() => null))?.disconnect();
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
export { redis };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { c as IdSource, i as Clock, t as
|
|
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 { t 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
|
*/
|