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.
- package/CHANGELOG.md +11 -0
- package/dist/ai-fx.js +25 -3
- package/dist/ai-fx.js.map +1 -1
- package/dist/store-redis-http.js +1 -1
- package/dist/{store-redis-notify-CsJw_hfD.js → store-redis-notify-CBDVJxA_.js} +8 -3
- package/dist/{store-redis-notify-CsJw_hfD.js.map → store-redis-notify-CBDVJxA_.js.map} +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-valkey.d.ts +33 -0
- package/dist/store-valkey.d.ts.map +1 -0
- package/dist/store-valkey.js +218 -0
- package/dist/store-valkey.js.map +1 -0
- package/docs/client/03-presence.mdx +1 -0
- package/docs/reference/01-core.mdx +1 -0
- package/docs/stores/01-overview.mdx +1 -0
- package/docs/stores/02-redis.mdx +3 -1
- package/docs/stores/07-valkey.mdx +97 -0
- package/examples/playground/package.json +1 -1
- package/package.json +8 -2
- package/src/ai-fx.ts +46 -3
- package/src/libfx.d.ts +2 -0
- package/src/store-redis-notify.ts +8 -2
- package/src/store-valkey.ts +376 -0
package/dist/store-redis.js
CHANGED
|
@@ -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-
|
|
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 |
|
package/docs/stores/02-redis.mdx
CHANGED
|
@@ -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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "experimental-a2",
|
|
3
|
-
"version": "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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> => {
|