experimental-a2 0.22.0 → 0.23.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.
@@ -1,7 +1,7 @@
1
1
  import { i as A2Error } from "./session-clear-wHd5GCpZ.js";
2
2
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
3
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
4
- import { t as createRedisNotifyStore } from "./store-redis-notify-CsJw_hfD.js";
4
+ import { t as createRedisNotifyStore } from "./store-redis-notify-CBDVJxA_.js";
5
5
  //#region src/store-redis.ts
6
6
  /**
7
7
  * experimental-a2/store-redis — the Redis-protocol store backend, on Redis
@@ -0,0 +1,33 @@
1
+ import { r as Clock, s as IdSource, t as A2Store } from "./store-D0MhBvg6.js";
2
+ import { RedisClientOptions } from "redis";
3
+ //#region src/store-valkey.d.ts
4
+ /**
5
+ * node-redis `createClient` options. A2 reads replies in their RESP2
6
+ * shapes, so it always connects with `RESP: 2`, which rules out `RESP`
7
+ * and the options that need RESP3.
8
+ */
9
+ type ValkeyClientOptions = Omit<RedisClientOptions, "RESP" | "clientSideCache" | "emitInvalidate" | "maintNotifications">;
10
+ type ValkeyStoreOptions = {
11
+ /** `redis://` or `rediss://` URL. Overrides `clientOptions.url`. */
12
+ url?: string | undefined;
13
+ /**
14
+ * Options for both clients A2 creates: `socket` (TLS, connect timeout,
15
+ * `reconnectStrategy`), `credentialsProvider`, `commandOptions`,
16
+ * `disableOfflineQueue`, and so on.
17
+ */
18
+ clientOptions?: ValkeyClientOptions;
19
+ /** Key prefix — isolates multiple apps on one server. Default `'a2'`. */
20
+ keyPrefix?: string;
21
+ /** Injectable clock — every stored timestamp comes from here. */
22
+ clock?: Clock;
23
+ /** Injectable id source for generated event ids. */
24
+ ids?: IdSource;
25
+ };
26
+ type ValkeyStore = A2Store & {
27
+ /** Destroy the command client and the subscriber. */
28
+ close(): Promise<void>;
29
+ };
30
+ declare function valkey(options?: ValkeyStoreOptions): ValkeyStore;
31
+ //#endregion
32
+ export { ValkeyClientOptions, ValkeyStore, ValkeyStoreOptions, valkey };
33
+ //# sourceMappingURL=store-valkey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-valkey.d.ts","names":[],"sources":["../src/store-valkey.ts"],"mappings":";;;;;;;;KAuCY,sBAAsB,KAChC;KAIU;;EAEV;;;;;;EAMA,gBAAgB;;EAEhB;;EAEA,QAAQ;;EAER,MAAM;;KAGI,cAAc;;EAExB,SAAS;;iBAkJK,OAAO,UAAS,qBAA0B"}
@@ -0,0 +1,218 @@
1
+ import { i as A2Error } from "./session-clear-wHd5GCpZ.js";
2
+ import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
+ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
4
+ import { t as createRedisNotifyStore } from "./store-redis-notify-CBDVJxA_.js";
5
+ //#region src/store-valkey.ts
6
+ /** Overrides any `commandOptions.typeMapping`: replies decode as strings. */
7
+ const STRING_REPLIES = { typeMapping: {} };
8
+ /**
9
+ * node-redis 6 needs `destroy()` to release a client whose reconnect
10
+ * strategy gave up; node-redis 5 throws there instead.
11
+ */
12
+ const dispose = (client) => {
13
+ try {
14
+ client?.destroy();
15
+ } catch {}
16
+ };
17
+ const closedError = () => new A2Error("STORE_UNAVAILABLE", "the store was closed");
18
+ /** node-redis 6's default `commandOptions.timeout`; node-redis 5 has none. */
19
+ const DEFAULT_TIMEOUT_MS = 5e3;
20
+ /**
21
+ * One A2-owned client at a time. `get` hands a new client out once it
22
+ * first reports ready, waiting at most `timeoutMs` per call while it keeps
23
+ * connecting, so `disableOfflineQueue` cannot reject a cold start's first
24
+ * calls. Later reconnects follow node-redis's offline queue,
25
+ * `disableOfflineQueue`, and command timeouts. A client whose connect
26
+ * failed, or whose reconnect strategy gave up, is closed for good, so the
27
+ * next `get` replaces it.
28
+ */
29
+ const ownedClient = (options) => {
30
+ let current = null;
31
+ let closed = false;
32
+ const firstReady = async (entry) => {
33
+ const client = await entry.created;
34
+ if (entry.ready) return client;
35
+ let timer;
36
+ try {
37
+ await Promise.race([entry.connected, new Promise((_, reject) => {
38
+ timer = setTimeout(() => {
39
+ reject(new A2Error("STORE_UNAVAILABLE", `connect timed out after ${options.timeoutMs}ms`));
40
+ }, options.timeoutMs);
41
+ })]);
42
+ } catch (err) {
43
+ if (err instanceof A2Error) throw err;
44
+ throw new A2Error("STORE_UNAVAILABLE", "connect failed", { cause: err });
45
+ } finally {
46
+ clearTimeout(timer);
47
+ }
48
+ return client;
49
+ };
50
+ return {
51
+ get() {
52
+ if (closed) return Promise.reject(closedError());
53
+ if (current && (current.client === null || current.client.isOpen)) return firstReady(current);
54
+ dispose(current?.client);
55
+ const entry = {
56
+ client: null,
57
+ connected: null,
58
+ ready: false,
59
+ created: options.driver().then((driver) => {
60
+ if (closed) throw closedError();
61
+ const client = driver.create();
62
+ entry.client = client;
63
+ client.on("error", () => {});
64
+ entry.connected = client.connect().then(() => {
65
+ entry.ready = true;
66
+ }, (err) => {
67
+ if (current === entry) {
68
+ dispose(client);
69
+ current = null;
70
+ }
71
+ throw err;
72
+ });
73
+ entry.connected.catch(() => {});
74
+ options.opened(client);
75
+ return client;
76
+ })
77
+ };
78
+ current = entry;
79
+ entry.created.catch(() => {
80
+ if (current === entry) current = null;
81
+ });
82
+ return firstReady(entry);
83
+ },
84
+ close() {
85
+ closed = true;
86
+ dispose(current?.client);
87
+ current = null;
88
+ }
89
+ };
90
+ };
91
+ function valkey(options = {}) {
92
+ const clock = options.clock ?? SYSTEM_CLOCK;
93
+ const ids = options.ids ?? RANDOM_IDS;
94
+ const prefix = options.keyPrefix ?? "a2";
95
+ if (!options.url && !options.clientOptions) throw new TypeError("valkey() needs a url or clientOptions");
96
+ const clientOptions = {
97
+ ...options.clientOptions,
98
+ ...options.url ? { url: options.url } : {},
99
+ RESP: 2
100
+ };
101
+ const driver = retryableLazy(async () => {
102
+ const mod = await import("redis").catch(() => {
103
+ throw new A2Error("STORE_NOT_CONFIGURED", "experimental-a2/store-valkey needs the 'redis' package (optional peer dependency) — install it");
104
+ });
105
+ return {
106
+ create: () => mod.createClient(clientOptions),
107
+ bytes: { typeMapping: { [mod.RESP_TYPES.BLOB_STRING]: Buffer } }
108
+ };
109
+ });
110
+ const timeoutMs = clientOptions.commandOptions?.timeout ?? DEFAULT_TIMEOUT_MS;
111
+ const commands = ownedClient({
112
+ driver: driver.get,
113
+ opened: () => {},
114
+ timeoutMs
115
+ });
116
+ /**
117
+ * Channels the subscriber holds, restored on a replacement client. Each
118
+ * maps to the call that subscribed it, so a failed call only forgets its
119
+ * own subscription.
120
+ */
121
+ const channels = /* @__PURE__ */ new Map();
122
+ let deliver;
123
+ let reconnected;
124
+ const onMessage = (message, channel) => {
125
+ deliver?.(channel, message);
126
+ };
127
+ const resubscribed = () => {
128
+ for (const channel of channels.keys()) reconnected?.(channel);
129
+ };
130
+ /**
131
+ * node-redis queues SUBSCRIBE outside `sendCommand`, so neither
132
+ * `disableOfflineQueue` nor `commandOptions.timeout` applies to it: a
133
+ * subscribe sent while the subscriber reconnects would wait for the
134
+ * server indefinitely. Apply both here. A timed-out SUBSCRIBE stays
135
+ * queued, so an UNSUBSCRIBE queued behind it cancels it.
136
+ */
137
+ const subscribeBounded = async (client, channel) => {
138
+ if (!client.isReady && clientOptions.disableOfflineQueue) throw new A2Error("STORE_UNAVAILABLE", "the subscriber is offline");
139
+ let timer;
140
+ const timedOut = new Promise((_, reject) => {
141
+ timer = setTimeout(() => {
142
+ client.unsubscribe(channel).catch(() => {});
143
+ reject(new A2Error("STORE_UNAVAILABLE", `subscribe timed out after ${timeoutMs}ms`));
144
+ }, timeoutMs);
145
+ });
146
+ try {
147
+ return await Promise.race([client.subscribe(channel, onMessage), timedOut]);
148
+ } finally {
149
+ clearTimeout(timer);
150
+ }
151
+ };
152
+ const subscriber = ownedClient({
153
+ driver: driver.get,
154
+ opened: (client) => {
155
+ let readies = 0;
156
+ client.on("ready", () => {
157
+ readies += 1;
158
+ if (readies > 1) resubscribed();
159
+ });
160
+ if (channels.size === 0) return;
161
+ Promise.all([...channels.keys()].map((channel) => client.subscribe(channel, onMessage))).then(resubscribed, () => {});
162
+ },
163
+ timeoutMs
164
+ });
165
+ const subscriberConnection = {
166
+ async call(command, ...args) {
167
+ const channel = String(args[0]);
168
+ const name = command.toLowerCase();
169
+ if (name === "subscribe") {
170
+ const call = Symbol(channel);
171
+ try {
172
+ const client = await subscriber.get();
173
+ channels.set(channel, call);
174
+ return await subscribeBounded(client, channel);
175
+ } catch (err) {
176
+ if (channels.get(channel) === call) channels.delete(channel);
177
+ throw err;
178
+ }
179
+ }
180
+ if (name === "unsubscribe") {
181
+ channels.delete(channel);
182
+ return (await subscriber.get()).unsubscribe(channel);
183
+ }
184
+ return (await subscriber.get()).sendCommand([command, ...args.map(String)], STRING_REPLIES);
185
+ },
186
+ duplicate: () => subscriberConnection,
187
+ on(_event, listener) {
188
+ deliver = listener;
189
+ },
190
+ disconnect: subscriber.close
191
+ };
192
+ const commandConnection = {
193
+ call: async (command, ...args) => (await commands.get()).sendCommand([command, ...args.map(String)], STRING_REPLIES),
194
+ duplicate: () => subscriberConnection,
195
+ on: () => void 0,
196
+ disconnect: commands.close
197
+ };
198
+ return createRedisNotifyStore({
199
+ client: () => Promise.resolve(commandConnection),
200
+ callBytes: async (command, ...args) => {
201
+ const { bytes } = await driver.get();
202
+ return (await commands.get()).sendCommand([command, ...args.map(String)], bytes);
203
+ },
204
+ clock,
205
+ ids,
206
+ keyPrefix: prefix,
207
+ onReconnect: (listener) => {
208
+ reconnected = listener;
209
+ },
210
+ async closeClient() {
211
+ commands.close();
212
+ }
213
+ });
214
+ }
215
+ //#endregion
216
+ export { valkey };
217
+
218
+ //# sourceMappingURL=store-valkey.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-valkey.js","names":[],"sources":["../src/store-valkey.ts"],"sourcesContent":["/**\n * experimental-a2/store-valkey — the Redis store on node-redis connections,\n * for Valkey and Redis servers that need more than a URL: TLS settings,\n * rotating credentials (such as ElastiCache IAM tokens), reconnect\n * strategies, and timeouts. Storage and the live feed are the ones\n * experimental-a2/store-redis uses (store-redis-notify.ts); this module owns\n * the two connections.\n *\n * A2 creates both clients from the same options: one for commands, one\n * dedicated subscriber. node-redis restores subscriptions before it reports\n * a reconnect ready, so every feed and presence subscription on a channel\n * catches up right after a reconnect instead of waiting for the safety read.\n * A client whose reconnect strategy gives up is replaced on next use.\n *\n * Cluster mode is out: each script's keys share a session's hash tag, but\n * the store runs on one non-cluster connection for commands and one for\n * pub/sub. `redis` (node-redis) is an optional peer dependency.\n */\n\nimport type { RedisClientOptions } from 'redis'\nimport { A2Error } from './errors.ts'\nimport { retryableLazy } from './retryable-lazy.ts'\nimport {\n createRedisNotifyStore,\n type RedisConnection,\n} from './store-redis-notify.ts'\nimport {\n RANDOM_IDS,\n SYSTEM_CLOCK,\n type A2Store,\n type Clock,\n type IdSource,\n} from './store.ts'\n\n/**\n * node-redis `createClient` options. A2 reads replies in their RESP2\n * shapes, so it always connects with `RESP: 2`, which rules out `RESP`\n * and the options that need RESP3.\n */\nexport type ValkeyClientOptions = Omit<\n RedisClientOptions,\n 'RESP' | 'clientSideCache' | 'emitInvalidate' | 'maintNotifications'\n>\n\nexport type ValkeyStoreOptions = {\n /** `redis://` or `rediss://` URL. Overrides `clientOptions.url`. */\n url?: string | undefined\n /**\n * Options for both clients A2 creates: `socket` (TLS, connect timeout,\n * `reconnectStrategy`), `credentialsProvider`, `commandOptions`,\n * `disableOfflineQueue`, and so on.\n */\n clientOptions?: ValkeyClientOptions\n /** Key prefix — isolates multiple apps on one server. Default `'a2'`. */\n keyPrefix?: string\n /** Injectable clock — every stored timestamp comes from here. */\n clock?: Clock\n /** Injectable id source for generated event ids. */\n ids?: IdSource\n}\n\nexport type ValkeyStore = A2Store & {\n /** Destroy the command client and the subscriber. */\n close(): Promise<void>\n}\n\n/** The node-redis client surface this module uses. */\ntype Client = {\n readonly isOpen: boolean\n readonly isReady: boolean\n connect(): Promise<unknown>\n destroy(): void\n on(event: 'error' | 'ready', listener: () => void): unknown\n sendCommand(\n args: string[],\n options: { typeMapping: Record<number, unknown> },\n ): Promise<unknown>\n subscribe(\n channel: string,\n listener: (message: string, channel: string) => void,\n ): Promise<unknown>\n unsubscribe(channel: string): Promise<unknown>\n}\n\ntype Driver = {\n create(): Client\n bytes: { typeMapping: Record<number, unknown> }\n}\n\n/** Overrides any `commandOptions.typeMapping`: replies decode as strings. */\nconst STRING_REPLIES = { typeMapping: {} }\n\n/**\n * node-redis 6 needs `destroy()` to release a client whose reconnect\n * strategy gave up; node-redis 5 throws there instead.\n */\nconst dispose = (client: Client | null | undefined): void => {\n try {\n client?.destroy()\n } catch {}\n}\n\nconst closedError = (): A2Error =>\n new A2Error('STORE_UNAVAILABLE', 'the store was closed')\n\n/** node-redis 6's default `commandOptions.timeout`; node-redis 5 has none. */\nconst DEFAULT_TIMEOUT_MS = 5_000\n\ntype OwnedEntry = {\n client: Client | null\n created: Promise<Client>\n /** Settles when the client first reports ready, or its connect fails. */\n connected: Promise<unknown> | null\n ready: boolean\n}\n\n/**\n * One A2-owned client at a time. `get` hands a new client out once it\n * first reports ready, waiting at most `timeoutMs` per call while it keeps\n * connecting, so `disableOfflineQueue` cannot reject a cold start's first\n * calls. Later reconnects follow node-redis's offline queue,\n * `disableOfflineQueue`, and command timeouts. A client whose connect\n * failed, or whose reconnect strategy gave up, is closed for good, so the\n * next `get` replaces it.\n */\nconst ownedClient = (options: {\n driver: () => Promise<Driver>\n opened: (client: Client) => void\n timeoutMs: number\n}) => {\n let current: OwnedEntry | null = null\n let closed = false\n\n const firstReady = async (entry: OwnedEntry): Promise<Client> => {\n const client = await entry.created\n if (entry.ready) return client\n let timer: ReturnType<typeof setTimeout> | undefined\n try {\n await Promise.race([\n entry.connected,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n reject(\n new A2Error(\n 'STORE_UNAVAILABLE',\n `connect timed out after ${options.timeoutMs}ms`,\n ),\n )\n }, options.timeoutMs)\n }),\n ])\n } catch (err) {\n if (err instanceof A2Error) throw err\n throw new A2Error('STORE_UNAVAILABLE', 'connect failed', { cause: err })\n } finally {\n clearTimeout(timer)\n }\n return client\n }\n\n return {\n get(): Promise<Client> {\n if (closed) return Promise.reject(closedError())\n if (current && (current.client === null || current.client.isOpen))\n return firstReady(current)\n dispose(current?.client)\n const entry: OwnedEntry = {\n client: null,\n connected: null,\n ready: false,\n created: options.driver().then((driver) => {\n if (closed) throw closedError()\n const client = driver.create()\n entry.client = client\n // Without a listener, node-redis's 'error' event crashes the\n // process; failures still surface as command rejections.\n client.on('error', () => {})\n entry.connected = client.connect().then(\n () => {\n entry.ready = true\n return undefined\n },\n (err: unknown) => {\n if (current === entry) {\n dispose(client)\n current = null\n }\n throw err\n },\n )\n void entry.connected.catch(() => {})\n options.opened(client)\n return client\n }),\n }\n current = entry\n void entry.created.catch(() => {\n if (current === entry) current = null\n })\n return firstReady(entry)\n },\n close(): void {\n closed = true\n dispose(current?.client)\n current = null\n },\n }\n}\n\nexport function valkey(options: ValkeyStoreOptions = {}): ValkeyStore {\n const clock = options.clock ?? SYSTEM_CLOCK\n const ids = options.ids ?? RANDOM_IDS\n const prefix = options.keyPrefix ?? 'a2'\n\n if (!options.url && !options.clientOptions) {\n throw new TypeError('valkey() needs a url or clientOptions')\n }\n const clientOptions = {\n ...options.clientOptions,\n ...(options.url ? { url: options.url } : {}),\n RESP: 2 as const,\n }\n\n // Lazy init — constructing the backend does no I/O.\n const driver = retryableLazy(async (): Promise<Driver> => {\n const mod = await import('redis').catch(() => {\n throw new A2Error(\n 'STORE_NOT_CONFIGURED',\n \"experimental-a2/store-valkey needs the 'redis' package (optional peer dependency) — install it\",\n )\n })\n return {\n create: () => mod.createClient(clientOptions) as unknown as Client,\n bytes: { typeMapping: { [mod.RESP_TYPES.BLOB_STRING]: Buffer } },\n }\n })\n\n const timeoutMs = clientOptions.commandOptions?.timeout ?? DEFAULT_TIMEOUT_MS\n const commands = ownedClient({\n driver: driver.get,\n opened: () => {},\n timeoutMs,\n })\n\n /**\n * Channels the subscriber holds, restored on a replacement client. Each\n * maps to the call that subscribed it, so a failed call only forgets its\n * own subscription.\n */\n const channels = new Map<string, symbol>()\n let deliver: ((channel: string, message: string) => void) | undefined\n let reconnected: ((channel: string) => void) | undefined\n const onMessage = (message: string, channel: string): void => {\n deliver?.(channel, message)\n }\n const resubscribed = (): void => {\n for (const channel of channels.keys()) reconnected?.(channel)\n }\n /**\n * node-redis queues SUBSCRIBE outside `sendCommand`, so neither\n * `disableOfflineQueue` nor `commandOptions.timeout` applies to it: a\n * subscribe sent while the subscriber reconnects would wait for the\n * server indefinitely. Apply both here. A timed-out SUBSCRIBE stays\n * queued, so an UNSUBSCRIBE queued behind it cancels it.\n */\n const subscribeBounded = async (\n client: Client,\n channel: string,\n ): Promise<unknown> => {\n if (!client.isReady && clientOptions.disableOfflineQueue) {\n throw new A2Error('STORE_UNAVAILABLE', 'the subscriber is offline')\n }\n let timer: ReturnType<typeof setTimeout> | undefined\n const timedOut = new Promise<never>((_, reject) => {\n timer = setTimeout(() => {\n void client.unsubscribe(channel).catch(() => {})\n reject(\n new A2Error(\n 'STORE_UNAVAILABLE',\n `subscribe timed out after ${timeoutMs}ms`,\n ),\n )\n }, timeoutMs)\n })\n try {\n return await Promise.race([\n client.subscribe(channel, onMessage),\n timedOut,\n ])\n } finally {\n clearTimeout(timer)\n }\n }\n const subscriber = ownedClient({\n driver: driver.get,\n opened: (client) => {\n // node-redis restores subscriptions before a reconnect's 'ready'.\n let readies = 0\n client.on('ready', () => {\n readies += 1\n if (readies > 1) resubscribed()\n })\n if (channels.size === 0) return\n void Promise.all(\n [...channels.keys()].map((channel) =>\n client.subscribe(channel, onMessage),\n ),\n ).then(resubscribed, () => {})\n },\n timeoutMs,\n })\n\n const subscriberConnection: RedisConnection = {\n async call(command, ...args) {\n const channel = String(args[0])\n const name = command.toLowerCase()\n if (name === 'subscribe') {\n const call = Symbol(channel)\n try {\n // Restore only held channels while a replacement connects; a new\n // channel belongs to the bounded subscribe after first readiness.\n const client = await subscriber.get()\n channels.set(channel, call)\n return await subscribeBounded(client, channel)\n } catch (err) {\n if (channels.get(channel) === call) channels.delete(channel)\n throw err\n }\n }\n if (name === 'unsubscribe') {\n channels.delete(channel)\n return (await subscriber.get()).unsubscribe(channel)\n }\n return (await subscriber.get()).sendCommand(\n [command, ...args.map(String)],\n STRING_REPLIES,\n )\n },\n duplicate: () => subscriberConnection,\n on(_event, listener) {\n deliver = listener\n },\n disconnect: subscriber.close,\n }\n\n const commandConnection: RedisConnection = {\n call: async (command, ...args) =>\n (await commands.get()).sendCommand(\n [command, ...args.map(String)],\n STRING_REPLIES,\n ),\n duplicate: () => subscriberConnection,\n on: () => undefined,\n disconnect: commands.close,\n }\n\n return createRedisNotifyStore({\n client: () => Promise.resolve(commandConnection),\n callBytes: async (command, ...args) => {\n const { bytes } = await driver.get()\n return (await commands.get()).sendCommand(\n [command, ...args.map(String)],\n bytes,\n )\n },\n clock,\n ids,\n keyPrefix: prefix,\n onReconnect: (listener) => {\n reconnected = listener\n },\n async closeClient() {\n commands.close()\n },\n })\n}\n"],"mappings":"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"}
@@ -207,6 +207,7 @@ store has it, at two tiers:
207
207
  | Backend | Delivery |
208
208
  | --- | --- |
209
209
  | `store-redis` | push: TTL'd hash plus pub/sub patches |
210
+ | `store-valkey` | push: TTL'd hash plus pub/sub patches |
210
211
  | `store-redis-http` | push over HTTP subscriptions; adaptive polling when unsupported |
211
212
  | `store-memory` | in-process, immediate |
212
213
  | `store-postgres` | native notifications with `listenConnectionString`; adaptive polling by default |
@@ -20,6 +20,7 @@ description: "Every entry point, and the isomorphic `experimental-a2` core: cont
20
20
  | [`experimental-a2/store-postgres`](/stores/postgres) | `postgres`: Postgres store backend | `pg` (or inject a client) |
21
21
  | [`experimental-a2/store-redis`](/stores/redis) | `redis`: Redis Streams store backend, push-native streaming | `ioredis` (or inject a client) |
22
22
  | [`experimental-a2/store-redis-http`](/stores/redis-http) | `redisHttp`: the same Redis store over provider REST APIs (Upstash) | none |
23
+ | [`experimental-a2/store-valkey`](/stores/valkey) | `valkey`: the same Redis store on node-redis connections (Valkey, ElastiCache) | `redis` |
23
24
  | [`experimental-a2/store-sqlite`](/stores/sqlite) | SQLite store backend | none |
24
25
  | [`experimental-a2/store-memory`](/stores/memory) | in-memory store backend | none |
25
26
  | [`experimental-a2/scheduler-vercel`](/schedulers/vercel-queues) | `vercelQueues`: recovery and timers | `@vercel/queue` |
@@ -20,6 +20,7 @@ export const ordersServer = createServer({
20
20
  | Store | Use it for |
21
21
  | --- | --- |
22
22
  | [Redis](/stores/redis) | Production (recommended) |
23
+ | [Valkey](/stores/valkey) | Production, on node-redis: rotating credentials, TLS and reconnect control |
23
24
  | [Redis over HTTP](/stores/redis-http) | Production, when your provider only offers a REST API |
24
25
  | [Postgres](/stores/postgres) | Production |
25
26
  | [SQLite](/stores/sqlite) | Development: the default |
@@ -23,7 +23,9 @@ npm i ioredis
23
23
 
24
24
  Works on a single Redis or Valkey instance and on non-cluster providers such as
25
25
  Upstash, which is durable by default (use its TCP `rediss://` URL). There's
26
- nothing to set up: A2 creates its keys as it writes.
26
+ nothing to set up: A2 creates its keys as it writes. For rotating credentials or
27
+ custom TLS and reconnect settings, use the same store on node-redis:
28
+ [Valkey](/stores/valkey).
27
29
 
28
30
  ## Options
29
31
 
@@ -0,0 +1,97 @@
1
+ ---
2
+ title: Valkey
3
+ description: The Redis store on node-redis, for Valkey and Redis servers that need TLS settings, rotating credentials, or reconnect control.
4
+ ---
5
+
6
+ ```ts server/orders.ts
7
+ import { createServer } from 'experimental-a2/server'
8
+ import { valkey } from 'experimental-a2/store-valkey'
9
+ import { orders } from '@/contracts'
10
+
11
+ export const ordersServer = createServer({
12
+ contract: orders,
13
+ store: valkey({ url: process.env.VALKEY_URL }),
14
+ consumers: [
15
+ /* ... */
16
+ ],
17
+ })
18
+ ```
19
+
20
+ ```package-install
21
+ npm i redis
22
+ ```
23
+
24
+ Stores sessions exactly like [Redis](/stores/redis), on
25
+ [node-redis](https://github.com/redis/node-redis) connections instead of
26
+ `ioredis`. Use it with Valkey or Redis when a URL isn't enough: managed
27
+ servers such as ElastiCache that authenticate with short-lived tokens, custom
28
+ TLS settings, or your own reconnect and timeout policy. It needs a single
29
+ server or a non-cluster replication group: it holds one non-cluster
30
+ connection for commands and one for pub/sub.
31
+
32
+ ## Options
33
+
34
+ | Option | |
35
+ | --- | --- |
36
+ | `url` | a `redis://` or `rediss://` URL |
37
+ | `clientOptions` | node-redis `createClient` options for both connections, such as `socket`, `credentialsProvider`, `commandOptions`, `disableOfflineQueue` |
38
+ | `keyPrefix` | isolates several apps on one server. Default `'a2'` |
39
+ | `clock`, `ids` | test seams for timestamps and generated event ids |
40
+
41
+ Pass `url`, `clientOptions`, or both; `url` wins over `clientOptions.url`.
42
+ A2 always connects with `RESP: 2`, so `clientOptions` has no `RESP`, nor the
43
+ options that need RESP3 (`clientSideCache`, `emitInvalidate`,
44
+ `maintNotifications`).
45
+
46
+ ## Rotating credentials
47
+
48
+ An async credentials provider runs each time a connection authenticates,
49
+ including every reconnect, so each one gets a fresh token:
50
+
51
+ ```ts server/store.ts
52
+ import { valkey } from 'experimental-a2/store-valkey'
53
+
54
+ export const store = valkey({
55
+ clientOptions: {
56
+ socket: { host: 'my-cache.example.com', port: 6379, tls: true },
57
+ credentialsProvider: {
58
+ type: 'async-credentials-provider',
59
+ credentials: async () => {
60
+ // your token source, such as an ElastiCache IAM auth token:
61
+ // const password = await createAuthToken()
62
+ const password = 'token'
63
+ return { username: 'a2', password }
64
+ },
65
+ },
66
+ },
67
+ })
68
+ ```
69
+
70
+ ## Connections
71
+
72
+ A2 creates two clients from your options: one for commands, on the first
73
+ store call, and one subscriber, on the first live stream or presence
74
+ subscription. Every live stream and presence subscription in the process
75
+ shares that subscriber. `close()` destroys both.
76
+
77
+ - A new client is used once it first connects: calls wait for it, each for at
78
+ most `commandOptions.timeout` (five seconds without one), so a cold start
79
+ works with `disableOfflineQueue`.
80
+ - While a client reconnects, node-redis's own options decide what happens to
81
+ commands: they wait in its offline queue, bounded by
82
+ `commandOptions.timeout`, or fail at once with `disableOfflineQueue`. A2
83
+ applies the same rules to a new live stream's subscription, which node-redis
84
+ would otherwise queue without a bound.
85
+ - A reconnect follows your `socket.reconnectStrategy`. When the subscriber
86
+ reconnects, every live stream reads what it missed right away, without
87
+ waiting for the safety re-read.
88
+ - When a client's reconnect strategy gives up, A2 replaces it: the command
89
+ client on the next store call, the subscriber on the next new live stream or
90
+ presence subscription. Until then, live streams still deliver within the
91
+ ten-second safety re-read.
92
+ - A2 listens for each client's `'error'` events, so a dropped socket never
93
+ crashes the process. Failures surface as `STORE_UNAVAILABLE` from the call
94
+ that hit them.
95
+
96
+ The upgrade rules, key layout, and per-command costs are the same as
97
+ [Redis](/stores/redis).
@@ -39,7 +39,7 @@
39
39
  "ai": "^7.0.58",
40
40
  "codemirror": "^6.0.2",
41
41
  "drizzle-orm": "^0.45.3",
42
- "experimental-a2": "0.22.0",
42
+ "experimental-a2": "0.23.0",
43
43
  "geist": "^1.7.2",
44
44
  "ioredis": "^5.9.0",
45
45
  "libfx": "0.0.12",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Syncs your immutable event log, from server to browser: one contract, handlers, derived state, and a live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -61,6 +61,7 @@
61
61
  "./store-postgres": "./dist/store-postgres.js",
62
62
  "./store-redis": "./dist/store-redis.js",
63
63
  "./store-redis-http": "./dist/store-redis-http.js",
64
+ "./store-valkey": "./dist/store-valkey.js",
64
65
  "./scheduler-qstash": "./dist/scheduler-qstash.js",
65
66
  "./scheduler-vercel": "./dist/scheduler-vercel.js",
66
67
  "./cache-indexeddb": "./dist/cache-indexeddb.js",
@@ -77,7 +78,8 @@
77
78
  "ioredis": "^5.4.0",
78
79
  "libfx": "^0.0.12",
79
80
  "pg": "^8.11.0",
80
- "react": "^19.0.0"
81
+ "react": "^19.0.0",
82
+ "redis": "^5.8.0 || ^6.0.0"
81
83
  },
82
84
  "peerDependenciesMeta": {
83
85
  "@opentelemetry/api": {
@@ -109,6 +111,9 @@
109
111
  },
110
112
  "react": {
111
113
  "optional": true
114
+ },
115
+ "redis": {
116
+ "optional": true
112
117
  }
113
118
  },
114
119
  "devDependencies": {
@@ -119,6 +124,7 @@
119
124
  "@upstash/qstash": "^2.11.3",
120
125
  "ai": "^7.0.58",
121
126
  "ioredis": "^5.9.0",
127
+ "redis": "^6.2.1",
122
128
  "@opentelemetry/api": "^1.9.0",
123
129
  "@types/pg": "^8.15.0",
124
130
  "@types/ws": "^8.18.1",
package/src/ai-fx.ts CHANGED
@@ -70,6 +70,8 @@ const SNAPSHOT_VERSION = 1
70
70
  const CATALOG_TTL_MS = 60 * 60 * 1000
71
71
  /** The Gateway model catalog libfx reads for each new kernel. */
72
72
  const CATALOG_PATH = '/coding-agent/v1/models'
73
+ /** Times a step rebuilds its kernel for a stopped turn FX failed on replay. */
74
+ const REPLAY_REBUILDS = 3
73
75
 
74
76
  type StreamPart = { type: string } & Record<string, unknown>
75
77
  /** A recorded tool call, with the input the kernel reads from its text. */
@@ -144,7 +146,13 @@ type KernelHooks = {
144
146
  tool: (toolName: string, input: unknown) => unknown
145
147
  }
146
148
 
147
- type Kernel = { agent: FxAgent; hooks: KernelHooks; waiting: Waiting[] }
149
+ type Kernel = {
150
+ agent: FxAgent
151
+ hooks: KernelHooks
152
+ waiting: Waiting[]
153
+ /** Whether FX answered the kernel's latest prompt with an error. */
154
+ failed: boolean
155
+ }
148
156
 
149
157
  /**
150
158
  * A kernel a step left waiting on its tool calls. The session's next step in
@@ -249,8 +257,12 @@ async function runStep(
249
257
  await resume(controller, turns.at(-1)!, adopted)
250
258
  return
251
259
  }
252
- for (const plan of turns) {
260
+ let rebuilds = 0
261
+ let next = 0
262
+ while (next < turns.length) {
253
263
  if (stopped || step.signal.aborted) return
264
+ const plan = turns[next]!
265
+ next += 1
254
266
  // The step's abort cancels the turn; the turn outlives it when parked.
255
267
  const fxTurn = kernel!.agent.prompt(plan.prompt)
256
268
  const state: TurnState = {
@@ -282,6 +294,18 @@ async function runStep(
282
294
  throw diverged(
283
295
  `a recorded turn ended with ${result.stopReason}, not ${expected}`,
284
296
  )
297
+ if (kernel!.failed) {
298
+ // libfx's cancel aborts the request FX waits on as it sends the
299
+ // stop, and FX fails the turn when it reads the abort first. That
300
+ // kernel holds no interruption where the log has one, so the turns
301
+ // replay on a new kernel.
302
+ if (rebuilds === REPLAY_REBUILDS)
303
+ throw diverged('FX failed a stopped turn instead of cancelling it')
304
+ rebuilds += 1
305
+ await kernel!.agent.close().catch(() => {})
306
+ kernel = await create(read.saved)
307
+ next = 0
308
+ }
285
309
  continue
286
310
  }
287
311
  await finishLive(controller, state, drained)
@@ -577,7 +601,8 @@ async function runStep(
577
601
  const create = async (
578
602
  checkpoint: Uint8Array | undefined,
579
603
  ): Promise<Kernel> => {
580
- const created = { hooks, waiting: [] as Waiting[] }
604
+ const created = { hooks, waiting: [] as Waiting[], failed: false }
605
+ let prompt: unknown
581
606
  const agent = await createFxAgent({
582
607
  apiKey: credential.token,
583
608
  model: kernelModel(step),
@@ -591,6 +616,24 @@ async function runStep(
591
616
  })),
592
617
  ...(checkpoint === undefined ? {} : { checkpoint }),
593
618
  fetch: (input, init) => created.hooks.fetch(input, init),
619
+ // libfx reports a turn FX failed after its cancel as cancelled; only
620
+ // the ACP response to the turn's prompt tells the two apart.
621
+ onEvent: ({ type, message }) => {
622
+ const rpc = (message ?? {}) as {
623
+ id?: unknown
624
+ method?: unknown
625
+ error?: unknown
626
+ }
627
+ if (type === 'acp.send' && rpc.method === 'session/prompt') {
628
+ prompt = rpc.id
629
+ created.failed = false
630
+ } else if (
631
+ type === 'acp.receive' &&
632
+ rpc.method === undefined &&
633
+ rpc.id === prompt
634
+ )
635
+ created.failed = rpc.error !== undefined
636
+ },
594
637
  })
595
638
  return Object.assign(created, { agent })
596
639
  }
package/src/libfx.d.ts CHANGED
@@ -37,5 +37,7 @@ declare module 'libfx' {
37
37
  input: string | URL | Request,
38
38
  init?: RequestInit,
39
39
  ) => Promise<Response>
40
+ /** Diagnostics; `acp.send` and `acp.receive` carry each ACP message. */
41
+ onEvent?: (event: { type: string; message?: unknown }) => void
40
42
  }): Promise<FxAgent>
41
43
  }
@@ -217,11 +217,17 @@ export function createRedisNotifyStore(options: {
217
217
  */
218
218
  let subscriberGate: Promise<RedisConnection> | null = null
219
219
  const gatedSubscriber = (): Promise<RedisConnection> => {
220
- subscriberGate ??= getSubscriber().then(async (conn) => {
220
+ if (subscriberGate) return subscriberGate
221
+ const gate = getSubscriber().then(async (conn) => {
221
222
  await conn.call('echo', 'ready')
222
223
  return conn
223
224
  })
224
- return subscriberGate
225
+ subscriberGate = gate
226
+ // A failed check is retried by the next channel command.
227
+ void gate.catch(() => {
228
+ if (subscriberGate === gate) subscriberGate = null
229
+ })
230
+ return gate
225
231
  }
226
232
 
227
233
  const acquireChannel = async (channel: string): Promise<void> => {