experimental-a2 0.22.0 → 0.24.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 +19 -0
- package/dist/{actor-CqibLgjQ.d.ts → actor-Bqyb7gwf.d.ts} +3 -3
- package/dist/{actor-CqibLgjQ.d.ts.map → actor-Bqyb7gwf.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +2 -2
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-DFET5q16.js → actor-shared-B4OB6Qo7.js} +3 -3
- package/dist/{actor-shared-DFET5q16.js.map → actor-shared-B4OB6Qo7.js.map} +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +4 -4
- package/dist/{ai-DAu77CDr.d.ts → ai-BZUu5-b7.d.ts} +2 -2
- package/dist/{ai-DAu77CDr.d.ts.map → ai-BZUu5-b7.d.ts.map} +1 -1
- package/dist/{ai-EEfKyc-z.js → ai-CCCNxd3L.js} +3 -3
- package/dist/{ai-EEfKyc-z.js.map → ai-CCCNxd3L.js.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.js +25 -3
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-DTjkAr2T.d.ts → ai-harness-C8aV869s.d.ts} +2 -2
- package/dist/{ai-harness-DTjkAr2T.d.ts.map → ai-harness-C8aV869s.d.ts.map} +1 -1
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-react.d.ts +4 -4
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.js +4 -4
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/{client-CUMbS_38.d.ts → client-CCJASjUM.d.ts} +2 -2
- package/dist/{client-CUMbS_38.d.ts.map → client-CCJASjUM.d.ts.map} +1 -1
- package/dist/{client-BQS4i_V9.js → client-sq-djxhD.js} +4 -4
- package/dist/{client-BQS4i_V9.js.map → client-sq-djxhD.js.map} +1 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CWukt01d.js → contract-DqfBSIqJ.js} +2 -2
- package/dist/{contract-CWukt01d.js.map → contract-DqfBSIqJ.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-D3Lve43o.d.ts → initial-snapshot-BA6xwv10.d.ts} +2 -2
- package/dist/initial-snapshot-BA6xwv10.d.ts.map +1 -0
- package/dist/{react-BoHvC3v9.d.ts → react-KC4eW7yG.d.ts} +3 -3
- package/dist/{react-BoHvC3v9.d.ts.map → react-KC4eW7yG.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CjmvZSAb.js → reducer-Bswr11ar.js} +2 -2
- package/dist/{reducer-CjmvZSAb.js.map → reducer-Bswr11ar.js.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-qgez0Xny.d.ts → server-B9gu0qi1.d.ts} +9 -3
- package/dist/server-B9gu0qi1.d.ts.map +1 -0
- package/dist/{server-DtFrWxi3.js → server-DX7mM6nm.js} +22 -14
- package/dist/server-DX7mM6nm.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-wHd5GCpZ.js → session-clear-XJYpmUpg.js} +14 -2
- package/dist/session-clear-XJYpmUpg.js.map +1 -0
- package/dist/{session-clear-D1PFe7xl.d.ts → session-clear-vfonou9S.d.ts} +2 -2
- package/dist/session-clear-vfonou9S.d.ts.map +1 -0
- package/dist/{store-D0MhBvg6.d.ts → store-DEzcHZOA.d.ts} +11 -1
- package/dist/store-DEzcHZOA.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/{store-codec-DwrWlgQx.js → store-codec-pAG2oPgQ.js} +4 -4
- package/dist/store-codec-pAG2oPgQ.js.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +11 -4
- package/dist/store-memory.js.map +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +26 -7
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/{store-redis-notify-CsJw_hfD.js → store-redis-notify-ys_-VrSP.js} +34 -18
- package/dist/store-redis-notify-ys_-VrSP.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +11 -4
- package/dist/store-sqlite.js.map +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/dist/{wire-CV-Z7wz-.js → wire-BFE9sIQf.js} +3 -2
- package/dist/wire-BFE9sIQf.js.map +1 -0
- package/docs/client/03-presence.mdx +1 -0
- package/docs/contract/03-sessions.mdx +51 -0
- package/docs/reference/01-core.mdx +1 -0
- package/docs/reference/02-server.mdx +44 -4
- package/docs/reference/06-errors.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/app/a2slack/consumers/search.ts +1 -1
- package/examples/playground/app/a2slack/db/migrations/0005_a2slack_feed_v2.sql +7 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0005_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
- package/examples/playground/app/a2slack/db/queries.ts +1 -1
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +17 -14
- package/examples/playground/app/a2slack/search-index.ts +1 -1
- package/examples/playground/app/a2slack/server.ts +2 -2
- package/examples/playground/lib/store.ts +2 -0
- package/examples/playground/package.json +1 -1
- package/package.json +8 -2
- package/src/ai-fx.ts +46 -3
- package/src/errors.ts +39 -0
- package/src/idempotent-replay.ts +4 -3
- package/src/libfx.d.ts +2 -0
- package/src/server.ts +32 -9
- package/src/store-memory.ts +26 -4
- package/src/store-postgres.ts +36 -6
- package/src/store-redis-core.ts +40 -15
- package/src/store-redis-notify.ts +8 -2
- package/src/store-sqlite.ts +25 -4
- package/src/store-valkey.ts +376 -0
- package/src/store.ts +10 -0
- package/src/wire.ts +1 -0
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +0 -1
- package/dist/server-DtFrWxi3.js.map +0 -1
- package/dist/server-qgez0Xny.d.ts.map +0 -1
- package/dist/session-clear-D1PFe7xl.d.ts.map +0 -1
- package/dist/session-clear-wHd5GCpZ.js.map +0 -1
- package/dist/store-D0MhBvg6.d.ts.map +0 -1
- package/dist/store-codec-DwrWlgQx.js.map +0 -1
- package/dist/store-redis-notify-CsJw_hfD.js.map +0 -1
- package/dist/wire-CV-Z7wz-.js.map +0 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { i as A2Error } from "./session-clear-XJYpmUpg.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-ys_-VrSP.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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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as A2Error } from "./session-clear-
|
|
1
|
+
import { i as A2Error } from "./session-clear-XJYpmUpg.js";
|
|
2
2
|
import { y as nullProtoRecord } from "./internal-CvsrprnU.js";
|
|
3
3
|
import { i as takeBuffered, n as isSerializedEvent } from "./stream-buffer-DrTyiIuo.js";
|
|
4
4
|
//#region src/wire.ts
|
|
@@ -176,6 +176,7 @@ const ERROR_STATUS = {
|
|
|
176
176
|
PARTIAL_DUPLICATE_BATCH: 400,
|
|
177
177
|
SUPERSEDED_ATTEMPT: 409,
|
|
178
178
|
SESSION_CLEARED: 409,
|
|
179
|
+
LAST_INDEX_MISMATCH: 409,
|
|
179
180
|
CLAIM_EXPIRED: 409,
|
|
180
181
|
STORE_UNAVAILABLE: 503,
|
|
181
182
|
STORE_NOT_CONFIGURED: 500,
|
|
@@ -407,4 +408,4 @@ const sessionTag = (frame) => {
|
|
|
407
408
|
//#endregion
|
|
408
409
|
export { stateSnapshotToWire as A, serializedEventToWireJson as C, socketFrameFor as D, socketEventsFor as E, socketSubscribedFor as O, presenceSnapshotToWire as S, socketErrorAckFor as T, isWirePresenceSnapshot as _, errorFromWire as a, presencePatchToWire as b, eventFromCompactWire as c, heldEventBatch as d, isCompactWireEvent as f, isWirePresencePatch as g, isWireEvent as h, compactEventJson as i, wireVersion as j, socketUnsubscribedFor as k, eventFromWire as l, isStreamEvent as m, SOCKET_PING_FRAME as n, errorStatus as o, isStateSnapshotFrame as p, asA2Error as r, errorToWire as s, EVENT_WRITE_CHARS as t, eventToWire as u, parseSocketFrame as v, socketAckFor as w, presenceSnapshotFromWire as x, presencePatchFromWire as y };
|
|
409
410
|
|
|
410
|
-
//# sourceMappingURL=wire-
|
|
411
|
+
//# sourceMappingURL=wire-BFE9sIQf.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wire-BFE9sIQf.js","names":[],"sources":["../src/wire.ts"],"sourcesContent":["/**\n * The wire format shared by server.fetch and experimental-a2/client\n * (browser side): events as JSON with ISO timestamps, and the A2Error\n * envelope. Internal module — both entry points re-export what's\n * public.\n */\n\nimport { A2Error, type A2ErrorCode, type A2ServerErrorCode } from './errors.ts'\nimport { nullProtoRecord } from './internal.ts'\nimport type { Event } from './store.ts'\nimport {\n isSerializedEvent,\n takeBuffered,\n type HeldEvent,\n type SerializedEvent,\n} from './stream-buffer.ts'\nimport type {\n PresenceMap,\n PresencePatch,\n PresenceSnapshot,\n} from './contract.ts'\n\nexport type WireEvent = {\n id: string\n type: string\n payload: unknown\n index: number\n sessionId: string\n /** ISO 8601 — revived to a Date on the client so reducers see the\n * same shape on both sides of the wire. */\n createdAt: string\n}\n\nexport function eventToWire(event: Event): WireEvent {\n return {\n id: event.id,\n type: event.type,\n payload: event.payload,\n index: event.index,\n sessionId: event.sessionId,\n createdAt: event.createdAt.toISOString(),\n }\n}\n\n// Consecutive frames usually repeat a stream's session, an event type, and\n// the timestamp of events appended together; each keeps its last encoding.\nconst lastEncoding = <T>(encode: (value: T) => string) => {\n let last: T | undefined\n let text = ''\n return (value: T): string => {\n if (value !== last) {\n text = encode(value)\n last = value\n }\n return text\n }\n}\nconst isoJson = lastEncoding((ms: number) =>\n JSON.stringify(new Date(ms).toISOString()),\n)\nconst sessionJson = lastEncoding((id: string) => JSON.stringify(id))\nconst typeJson = lastEncoding((type: string) => JSON.stringify(type))\n\n/**\n * `JSON.stringify(eventToWire(event))` for the parsed event, built around the\n * stored payload text instead of parsing and re-serializing it.\n */\nexport function serializedEventToWireJson(event: SerializedEvent): string {\n return `{\"id\":${JSON.stringify(event.id)},\"type\":${typeJson(event.type)},\"payload\":${event.payloadJson},\"index\":${event.index},\"sessionId\":${sessionJson(event.sessionId)},\"createdAt\":${isoJson(event.createdAtMs)}}`\n}\n\n/**\n * Wire version 2: what a client opts into with `wire=2` on the stream URL.\n * Live events travel in batches, without the `sessionId` the connection\n * already names, with `createdAt` as epoch milliseconds. Any other value,\n * or none, selects version 1.\n */\nexport type WireVersion = 1 | 2\n\nexport function wireVersion(value: string | null): WireVersion {\n return value === '2' ? 2 : 1\n}\n\n/**\n * Events already held by a stream join one write while it is shorter than\n * this many characters, so a write can exceed it by the event that crosses it.\n */\nexport const EVENT_WRITE_CHARS: number = 64 * 1024\n\n/** Stream items that are events, as opposed to snapshot or presence frames. */\nexport function isStreamEvent(item: object): item is HeldEvent {\n return !('kind' in item) && !('snapshot' in item) && !('participant' in item)\n}\n\n/**\n * `first` and the events `iterator` already holds, encoded, until the batch\n * reaches `EVENT_WRITE_CHARS`; `last` is the batch's final event.\n */\nexport function heldEventBatch(options: {\n first: HeldEvent\n iterator: object\n encode: (event: HeldEvent) => string\n}): { events: string[]; last: HeldEvent } {\n const { iterator, encode } = options\n let last = options.first\n const events = [encode(last)]\n let chars = events[0]!.length\n while (chars < EVENT_WRITE_CHARS) {\n const held = takeBuffered(iterator)\n if (!held) break\n const json = encode(held)\n events.push(json)\n chars += json.length + 1\n last = held\n }\n return { events, last }\n}\n\n/** A version 2 live event: `WireEvent` minus `sessionId`, ms timestamp. */\nexport type CompactWireEvent = {\n id: string\n type: string\n payload: unknown\n index: number\n createdAt: number\n}\n\n/** One version 2 event as JSON, splicing a held row's stored payload. */\nexport function compactEventJson(event: HeldEvent): string {\n if (!isSerializedEvent(event))\n return JSON.stringify({\n id: event.id,\n type: event.type,\n payload: event.payload,\n index: event.index,\n createdAt: event.createdAt.getTime(),\n })\n return `{\"id\":${JSON.stringify(event.id)},\"type\":${typeJson(event.type)},\"payload\":${event.payloadJson},\"index\":${event.index},\"createdAt\":${event.createdAtMs}}`\n}\n\nexport function isCompactWireEvent(value: unknown): value is CompactWireEvent {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n typeof v['id'] === 'string' &&\n typeof v['type'] === 'string' &&\n typeof v['index'] === 'number' &&\n typeof v['createdAt'] === 'number' &&\n Number.isFinite(v['createdAt'])\n )\n}\n\nexport function eventFromCompactWire(\n wire: CompactWireEvent,\n sessionId: string,\n): Event {\n return {\n id: wire.id,\n type: wire.type,\n payload: wire.payload,\n index: wire.index,\n sessionId,\n createdAt: new Date(wire.createdAt),\n }\n}\n\nexport function eventFromWire(wire: WireEvent): Event {\n return {\n id: wire.id,\n type: wire.type,\n payload: wire.payload,\n index: wire.index,\n sessionId: wire.sessionId,\n createdAt: new Date(wire.createdAt),\n }\n}\n\nexport function isWireEvent(value: unknown): value is WireEvent {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n typeof v['id'] === 'string' &&\n typeof v['type'] === 'string' &&\n typeof v['index'] === 'number' &&\n typeof v['sessionId'] === 'string' &&\n typeof v['createdAt'] === 'string'\n )\n}\n\n// ── the presence wire pair ───────────────────────────────────────────\n\n/** The `event: presence` SSE frame body: one patch as JSON. */\nexport type WirePresencePatch = {\n participant: string\n values: Record<string, unknown>\n seen: number\n /** ISO 8601 — revived to a Date on the client. */\n at: string\n}\n\n/**\n * The `event: presence-snapshot` SSE frame body: the in-memory\n * `PresenceSnapshot` shape verbatim, with each per-field `at`\n * serialized to ISO 8601.\n */\nexport type WirePresenceSnapshot = {\n snapshot: {\n [participant: string]: {\n [field: string]: { value: unknown; seen: number; at: string }\n }\n }\n}\n\nexport function presencePatchToWire(patch: PresencePatch): WirePresencePatch {\n return {\n participant: patch.participant,\n values: patch.values,\n seen: patch.seen,\n at: patch.at.toISOString(),\n }\n}\n\nexport function presencePatchFromWire(wire: WirePresencePatch): PresencePatch {\n return {\n participant: wire.participant,\n values: wire.values,\n seen: wire.seen,\n at: new Date(wire.at),\n }\n}\n\n// Participant and field keys come off the wire, so every object keyed\n// by them is built null-prototype — see `nullProtoRecord`.\nexport function presenceSnapshotToWire(\n snapshot: PresenceSnapshot,\n): WirePresenceSnapshot {\n const wire: WirePresenceSnapshot['snapshot'] = nullProtoRecord()\n for (const [participant, fields] of Object.entries(snapshot.snapshot)) {\n const wireFields: WirePresenceSnapshot['snapshot'][string] =\n nullProtoRecord()\n for (const [field, entry] of Object.entries(fields)) {\n if (entry === undefined) continue\n wireFields[field] = {\n value: entry.value,\n seen: entry.seen,\n at: entry.at.toISOString(),\n }\n }\n wire[participant] = wireFields\n }\n return { snapshot: wire }\n}\n\nexport function presenceSnapshotFromWire(\n wire: WirePresenceSnapshot,\n): PresenceSnapshot {\n const map: PresenceMap = nullProtoRecord()\n for (const [participant, fields] of Object.entries(wire.snapshot)) {\n const revived: PresenceMap[string] = nullProtoRecord()\n for (const [field, entry] of Object.entries(fields)) {\n revived[field] = {\n value: entry.value,\n seen: entry.seen,\n at: new Date(entry.at),\n }\n }\n map[participant] = revived\n }\n return { snapshot: map }\n}\n\nexport function isWirePresencePatch(\n value: unknown,\n): value is WirePresencePatch {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n typeof v['participant'] === 'string' &&\n v['values'] !== null &&\n typeof v['values'] === 'object' &&\n !Array.isArray(v['values']) &&\n typeof v['seen'] === 'number' &&\n typeof v['at'] === 'string'\n )\n}\n\nexport function isWirePresenceSnapshot(\n value: unknown,\n): value is WirePresenceSnapshot {\n if (value === null || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return (\n v['snapshot'] !== null &&\n typeof v['snapshot'] === 'object' &&\n !Array.isArray(v['snapshot'])\n )\n}\n\n// ── the A2Error envelope ─────────────────────────────────────────────\n\nexport type WireError = {\n error: { code: A2ServerErrorCode; message: string; details?: unknown }\n}\n\nconst ERROR_STATUS: Record<A2ServerErrorCode, number> = {\n INVALID_PAYLOAD: 400,\n FORBIDDEN: 403,\n UNKNOWN_EVENT_TYPE: 400,\n PARTIAL_DUPLICATE_BATCH: 400,\n SUPERSEDED_ATTEMPT: 409,\n SESSION_CLEARED: 409,\n LAST_INDEX_MISMATCH: 409,\n CLAIM_EXPIRED: 409,\n STORE_UNAVAILABLE: 503,\n STORE_NOT_CONFIGURED: 500,\n UNKNOWN_PRESENCE_FIELD: 400,\n PRESENCE_NOT_SUPPORTED: 500,\n CONSUMER_DEAD_LETTERED: 500,\n}\n\nexport function errorStatus(code: A2ErrorCode): number {\n return ERROR_STATUS[isServerErrorCode(code) ? code : 'STORE_UNAVAILABLE']\n}\n\nexport function errorToWire(error: A2Error): WireError {\n const failure = asA2Error(error)\n const body: WireError = {\n error: {\n code: failure.code as A2ServerErrorCode,\n message: failure.message,\n },\n }\n if (failure.details !== undefined) body.error.details = failure.details\n return body\n}\n\n/** Wrap an arbitrary thrown value for the wire: known server codes pass through,\n * anything else becomes STORE_UNAVAILABLE — from the client's\n * perspective an unknown server failure is retryable-once, not a\n * protocol contract. */\nexport function asA2Error(error: unknown): A2Error {\n return error instanceof A2Error && isServerErrorCode(error.code)\n ? error\n : new A2Error('STORE_UNAVAILABLE', 'internal error', { cause: error })\n}\n\n/** Rebuild an A2Error from a wire body; null if the body isn't one. */\nexport function errorFromWire(body: unknown): A2Error | null {\n if (body === null || typeof body !== 'object') return null\n const err = (body as { error?: unknown }).error\n if (err === null || typeof err !== 'object') return null\n const { code, message, details } = err as Record<string, unknown>\n if (typeof code !== 'string' || !isServerErrorCode(code)) return null\n return new A2Error(code, String(message ?? code), {\n details,\n })\n}\n\nconst isServerErrorCode = (code: string): code is A2ServerErrorCode =>\n Object.hasOwn(ERROR_STATUS, code)\n\n// ── the ws frame layer ───────────────────────────────────────────────\n// The SSE lanes reframed for a socket (specs/a2-api.md §13): every\n// message is one JSON text frame, and a frame is its wire payload plus\n// a `kind` discriminant (plus `req` where a reply must correlate).\n// Unknown kinds are skipped by both sides — the same\n// forward-compatibility rule as named SSE frames.\n//\n// The multiplexed superset rides the same frames: `subscribe` /\n// `unsubscribe` up-frames open and close per-session lanes on one\n// socket, `sessionId` tags route everything else. Single-session\n// frames (no tags) keep parsing unchanged — a route opts into\n// multiplexing by choosing the multi-session server handler, never by\n// breaking the old protocol.\n\n/**\n * Server → client, parsed: a live-stream item, a push ack, a\n * subscription lifecycle notice, or the heartbeat. `parseSocketFrame`\n * yields these; frames of unknown kind (or ones failing their shape\n * guard) come back `null`. `sessionId` is present on frames from a\n * multiplexed socket and absent on a single-session one.\n */\nexport type StateSnapshotFrame = {\n kind: 'snapshot'\n sessionId: string\n name: string\n index: number\n state: unknown\n stateUndefined?: true\n /** The session's latest clear marker, when it has been cleared. */\n clearIndex?: number\n}\n\nexport function stateSnapshotToWire(\n frame: StateSnapshotFrame,\n): StateSnapshotFrame {\n return frame.state === undefined\n ? { ...frame, state: null, stateUndefined: true }\n : frame\n}\n\nexport function isStateSnapshotFrame(\n value: unknown,\n): value is StateSnapshotFrame {\n if (value === null || typeof value !== 'object') return false\n const frame = value as Record<string, unknown>\n return (\n frame['kind'] === 'snapshot' &&\n typeof frame['sessionId'] === 'string' &&\n typeof frame['name'] === 'string' &&\n frame['name'].length > 0 &&\n typeof frame['index'] === 'number' &&\n Number.isSafeInteger(frame['index']) &&\n frame['index'] >= 0 &&\n Object.hasOwn(frame, 'state') &&\n (!Object.hasOwn(frame, 'stateUndefined') ||\n (frame['stateUndefined'] === true && frame['state'] === null)) &&\n (!Object.hasOwn(frame, 'clearIndex') ||\n (typeof frame['clearIndex'] === 'number' &&\n Number.isSafeInteger(frame['clearIndex']) &&\n frame['clearIndex'] > 0 &&\n frame['clearIndex'] <= frame['index']))\n )\n}\n\nexport type SubscriptionStart =\n | { index: number; bootstrap?: never; reducer?: string }\n | { bootstrap: true; reducer: string; index?: never }\n\nexport type SocketDownFrame =\n | StateSnapshotFrame\n | { kind: 'event'; event: WireEvent }\n | { kind: 'events'; events: CompactWireEvent[]; sessionId?: string }\n | { kind: 'presence'; patch: WirePresencePatch; sessionId?: string }\n | {\n kind: 'presence-snapshot'\n snapshot: WirePresenceSnapshot\n sessionId?: string\n }\n | { kind: 'ack'; req: number; events: WireEvent[]; sessionId?: string }\n | { kind: 'ack'; req: number; error: A2Error; sessionId?: string }\n | { kind: 'subscribed'; sessionId: string; attempt?: number }\n | {\n kind: 'unsubscribed'\n sessionId: string\n attempt?: number\n reason?: string\n error?: A2Error\n }\n | { kind: 'ping' }\n\n/**\n * Client → server: the push envelope's two planes. `sessionId` is\n * implied by the socket on a single-session connection and required by\n * the multiplexed handler. Plain JSON on the wire; the server parses\n * and validates them through the same seams as `parsePushBody`.\n */\nexport type SocketPushFrame = {\n kind: 'push'\n /** Client-local ack correlator — opaque to the server. */\n req: number\n events: Array<{ type: string; payload: unknown; id?: string }>\n sessionId?: string\n}\n\nexport type SocketPresenceFrame = {\n kind: 'presence'\n participant: string\n values: Record<string, unknown>\n seen?: number\n at?: number\n sessionId?: string\n}\n\n/** One session lane in a subscribe frame. The server echoes `attempt`\n * on that lane's `subscribed`/`unsubscribed` answers, so a client that\n * re-subscribed before an answer arrived can tell the answers apart. */\nexport type SocketSubscribeEntry = {\n id: string\n attempt?: number\n} & SubscriptionStart\n\n/** Open per-session lanes on a multiplexed socket. `index` is each\n * session's exclusive resume frontier — `stream({ startAfter })`. */\nexport type SocketSubscribeFrame = {\n kind: 'subscribe'\n sessions: SocketSubscribeEntry[]\n}\n\nexport type SocketUnsubscribeFrame = {\n kind: 'unsubscribe'\n sessions: string[]\n}\n\nexport type SocketUpFrame =\n | SocketPushFrame\n | SocketPresenceFrame\n | SocketSubscribeFrame\n | SocketUnsubscribeFrame\n\nexport const SOCKET_PING_FRAME: string = JSON.stringify({ kind: 'ping' })\n\n/** One stream item as a socket frame — `sseResponse`'s framing over\n * the same codecs, with `kind` instead of an SSE event name. On a\n * multiplexed socket presence frames carry the `sessionId` tag; events\n * already carry theirs in the wire event. */\nexport function socketFrameFor(\n item: Event | PresencePatch | PresenceSnapshot | StateSnapshotFrame,\n sessionId?: string,\n): string {\n const tag = sessionId === undefined ? {} : { sessionId }\n if ('kind' in item) return JSON.stringify(stateSnapshotToWire(item))\n if ('snapshot' in item) {\n return JSON.stringify({\n kind: 'presence-snapshot',\n ...presenceSnapshotToWire(item),\n ...tag,\n })\n }\n if ('participant' in item) {\n return JSON.stringify({\n kind: 'presence',\n ...presencePatchToWire(item),\n ...tag,\n })\n }\n return JSON.stringify({ kind: 'event', ...eventToWire(item) })\n}\n\n/** A version 2 batch frame from compact event JSON; a multiplexed\n * socket names the batch's session once. */\nexport function socketEventsFor(events: string[], sessionId?: string): string {\n const tag =\n sessionId === undefined ? '' : `\"sessionId\":${JSON.stringify(sessionId)},`\n return `{\"kind\":\"events\",${tag}\"events\":[${events.join(',')}]}`\n}\n\nexport function socketAckFor(\n req: number,\n events: Event[],\n sessionId?: string,\n): string {\n return JSON.stringify({\n kind: 'ack',\n req,\n events: events.map(eventToWire),\n ...(sessionId === undefined ? {} : { sessionId }),\n })\n}\n\nexport function socketErrorAckFor(\n req: number,\n error: A2Error,\n sessionId?: string,\n): string {\n return JSON.stringify({\n kind: 'ack',\n req,\n ...errorToWire(error),\n ...(sessionId === undefined ? {} : { sessionId }),\n })\n}\n\nexport function socketSubscribedFor(options: {\n sessionId: string\n attempt?: number | undefined\n}): string {\n const { sessionId, attempt } = options\n return JSON.stringify({\n kind: 'subscribed',\n sessionId,\n ...(attempt === undefined ? {} : { attempt }),\n })\n}\n\n/** The subscription-over notice: with a `reason` the server rejected\n * or lost the session's stream, without one it ended cleanly. */\nexport function socketUnsubscribedFor(options: {\n sessionId: string\n attempt?: number | undefined\n reason?: string\n error?: A2Error\n}): string {\n const { sessionId, attempt, reason, error } = options\n return JSON.stringify({\n kind: 'unsubscribed',\n sessionId,\n ...(attempt === undefined ? {} : { attempt }),\n ...(reason === undefined ? {} : { reason }),\n ...(error === undefined ? {} : errorToWire(error)),\n })\n}\n\nconst isAttempt = (value: unknown): value is number =>\n typeof value === 'number' && Number.isSafeInteger(value) && value >= 0\n\nexport function parseSocketFrame(data: string): SocketDownFrame | null {\n let parsed: unknown\n try {\n parsed = JSON.parse(data)\n } catch {\n return null\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n return null\n }\n const frame = parsed as Record<string, unknown>\n switch (frame['kind']) {\n case 'snapshot':\n if (!isStateSnapshotFrame(parsed))\n throw new A2Error('INVALID_PAYLOAD', 'invalid bootstrap snapshot')\n return parsed\n case 'ping':\n return { kind: 'ping' }\n case 'event': {\n if (!isWireEvent(parsed)) return null\n const { id, type, payload, index, sessionId, createdAt } = parsed\n return {\n kind: 'event',\n event: { id, type, payload, index, sessionId, createdAt },\n }\n }\n case 'events': {\n // Dropping a batch would leave a gap the frontier then skips past.\n const events = frame['events']\n if (!Array.isArray(events) || !events.every(isCompactWireEvent))\n throw new A2Error('INVALID_PAYLOAD', 'invalid event batch')\n return { kind: 'events', events, ...sessionTag(frame) }\n }\n case 'presence': {\n if (!isWirePresencePatch(parsed)) return null\n const { participant, values, seen, at } = parsed\n return {\n kind: 'presence',\n patch: { participant, values, seen, at },\n ...sessionTag(frame),\n }\n }\n case 'presence-snapshot': {\n if (!isWirePresenceSnapshot(parsed)) return null\n return {\n kind: 'presence-snapshot',\n snapshot: { snapshot: parsed.snapshot },\n ...sessionTag(frame),\n }\n }\n case 'subscribed': {\n const { sessionId, attempt } = frame\n if (typeof sessionId !== 'string') return null\n if (attempt !== undefined && !isAttempt(attempt)) return null\n return {\n kind: 'subscribed',\n sessionId,\n ...(attempt === undefined ? {} : { attempt }),\n }\n }\n case 'unsubscribed': {\n const { sessionId, attempt } = frame\n if (typeof sessionId !== 'string') return null\n if (attempt !== undefined && !isAttempt(attempt)) return null\n const reason = frame['reason']\n const error =\n errorFromWire(parsed) ??\n ('error' in frame\n ? new A2Error(\n 'STORE_UNAVAILABLE',\n 'unintelligible subscription error',\n )\n : null)\n return {\n kind: 'unsubscribed',\n sessionId,\n ...(attempt === undefined ? {} : { attempt }),\n ...(typeof reason === 'string' ? { reason } : {}),\n ...(error === null ? {} : { error }),\n }\n }\n case 'ack': {\n const req = frame['req']\n if (typeof req !== 'number') return null\n const tag = sessionTag(frame)\n const events = frame['events']\n if (Array.isArray(events) && events.every(isWireEvent)) {\n return { kind: 'ack', req, events, ...tag }\n }\n const error = errorFromWire(parsed)\n if (error) return { kind: 'ack', req, error, ...tag }\n // A correlatable ack must never be dropped: its waiter would hang\n // forever behind a healthy socket (pings keep the watchdog fed).\n // An outcome this client cannot interpret — an error code from a\n // newer server, an event shape that fails a guard — degrades to\n // a lost ack: retryable, and the client-generated ids make the\n // retry idempotent even if the append actually committed.\n return {\n kind: 'ack',\n req,\n error: new A2Error('STORE_UNAVAILABLE', 'unintelligible ack'),\n ...tag,\n }\n }\n default:\n return null\n }\n}\n\nconst sessionTag = (frame: Record<string, unknown>): { sessionId?: string } => {\n const sessionId = frame['sessionId']\n return typeof sessionId === 'string' ? { sessionId } : {}\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 |
|
|
@@ -23,6 +23,57 @@ serve millions of order sessions through the same server and consumers.
|
|
|
23
23
|
Collections, joins, search, and constraints across many records belong in
|
|
24
24
|
[your database](#sessions-and-your-database).
|
|
25
25
|
|
|
26
|
+
## Appending from what you read
|
|
27
|
+
|
|
28
|
+
```ts server/expire-order.ts
|
|
29
|
+
import { A2Error } from 'experimental-a2'
|
|
30
|
+
import { ordersServer } from './orders'
|
|
31
|
+
|
|
32
|
+
export async function expireOrder({ orderId, seenIndex, requestId }: {
|
|
33
|
+
orderId: string
|
|
34
|
+
seenIndex: number
|
|
35
|
+
requestId: string // the client's id for this request, the same on each retry
|
|
36
|
+
}) {
|
|
37
|
+
// here's where you'd do auth, or any other checks
|
|
38
|
+
try {
|
|
39
|
+
return await ordersServer.session(orderId).append.ifLastIndex(seenIndex, {
|
|
40
|
+
id: requestId,
|
|
41
|
+
type: 'expired',
|
|
42
|
+
payload: {},
|
|
43
|
+
})
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err instanceof A2Error && err.code === 'LAST_INDEX_MISMATCH') {
|
|
46
|
+
// the order changed after it was read: read it again and decide again
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
throw err
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`session.append.ifLastIndex(index, ...events)` appends only while `index` is
|
|
55
|
+
still the session's head, the index of its last event. Pass the head your
|
|
56
|
+
decision was based on: `session.state()` returns it with the state, and an
|
|
57
|
+
append returns each event's index.
|
|
58
|
+
|
|
59
|
+
The store checks the head in the same atomic step as the write, so of two
|
|
60
|
+
callers that read the same head, at most one lands. The other gets an `A2Error`
|
|
61
|
+
with code `LAST_INDEX_MISMATCH` and nothing written; its `err.details` is
|
|
62
|
+
`{ index }`, the head the store found. Any event moves the head, not only the
|
|
63
|
+
ones your check is about, so a busy session refuses more often.
|
|
64
|
+
|
|
65
|
+
Give each event an id that is unique to this writer's decision and the same on
|
|
66
|
+
each of its retries, such as an id the client generates for the request. A
|
|
67
|
+
batch whose ids are all in the session already returns its rows whatever the
|
|
68
|
+
head is now, so a retry after a lost response gets the events it wrote. Ids
|
|
69
|
+
built from the index alone are shared by every writer that read it, so they
|
|
70
|
+
fit only when each of those writers would append the same events: otherwise a
|
|
71
|
+
writer whose events happen to match another's gets that writer's rows back as
|
|
72
|
+
its own.
|
|
73
|
+
|
|
74
|
+
A [clear](#clearing-a-session) moves the head to its marker, so an index read
|
|
75
|
+
before it never matches after it.
|
|
76
|
+
|
|
26
77
|
## Clearing a session
|
|
27
78
|
|
|
28
79
|
```ts server/clear-order.ts
|
|
@@ -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` |
|
|
@@ -300,7 +300,11 @@ interface A2Store {
|
|
|
300
300
|
append(
|
|
301
301
|
sessionId: string,
|
|
302
302
|
events: AppendEvent[],
|
|
303
|
-
options?: {
|
|
303
|
+
options?: {
|
|
304
|
+
originIndex?: number
|
|
305
|
+
ifLastIndex?: number
|
|
306
|
+
consumers?: readonly StoreConsumer[]
|
|
307
|
+
},
|
|
304
308
|
): Promise<StoreAppendResult>
|
|
305
309
|
|
|
306
310
|
cursors(sessionId: string): Promise<StoredCursor[]>
|
|
@@ -386,7 +390,7 @@ other consumer is neither pending nor claimed. Omitted, every cursor counts.
|
|
|
386
390
|
|
|
387
391
|
| Method | Atomic effect |
|
|
388
392
|
| --- | --- |
|
|
389
|
-
| `append` | Write a consecutive batch. Each event's `targets` start work: a lane target queues the event in that consumer's lane cursor, a null lane creates a cursor for that event alone. Each log consumer in `consumers` whose types the batch carries gets its cursor created (at the start of the log with `backfill`, otherwise just before the batch) or, when idle, a head. Report pending work for the served consumers as `hasPending`. An event with a `cause` commits only while that cursor's head is `cause.index`, `cause.attempt` is its latest attempt, and it isn't dead-lettered; otherwise throw `SUPERSEDED_ATTEMPT`. The check serializes against a concurrent claim, after the idempotent-replay path. |
|
|
393
|
+
| `append` | Write a consecutive batch. Each event's `targets` start work: a lane target queues the event in that consumer's lane cursor, a null lane creates a cursor for that event alone. Each log consumer in `consumers` whose types the batch carries gets its cursor created (at the start of the log with `backfill`, otherwise just before the batch) or, when idle, a head. Report pending work for the served consumers as `hasPending`. An event with a `cause` commits only while that cursor's head is `cause.index`, `cause.attempt` is its latest attempt, and it isn't dead-lettered; otherwise throw `SUPERSEDED_ATTEMPT`. The check serializes against a concurrent claim, after the idempotent-replay path. With `ifLastIndex`, write only while the session's head, its last index including a clear marker, is that index; otherwise throw `LAST_INDEX_MISMATCH` with `details: { index }`, the head. Check it last, after the replay path and both fences, in the same atomic step. Under `ifLastIndex`, ids that conflict (mixed, or not matching their rows) throw `LAST_INDEX_MISMATCH` instead of `PARTIAL_DUPLICATE_BATCH` when the head isn't `ifLastIndex`, read in the same step as the ids. |
|
|
390
394
|
| `cursors` | List every log and lane cursor, and every concurrent cursor that still has a head, by consumer, then lane, with concurrent cursors last by head. Lane and concurrent cursors whose work lies at or below the latest clear marker are not listed. |
|
|
391
395
|
| `claimAvailable` | Claim every eligible head of the served consumers, in event order, then by consumer. A claim records its holder, expiry, timestamps, and next `attemptCount`. Taking over a lapsed claim, one its holder never completed or failed, adds one to `failureCount`; when that reaches `maxFailures`, dead-letter the head instead of claiming it: advance `attemptCount` to fence the lapsed attempt, leave `lastFailedAttempt` unchanged, and return it with `failedAt` set. Excluded heads stay unclaimed and keep blocking their lane. A claim may report `head`, the session's head when the claim was made. |
|
|
392
396
|
| `renewClaims` | Extend the listed live claims the holder still owns. `renewed` lists those; `superseded` lists claims whose head a newer attempt took, or whose cursor moved past it. An expired claim no successor took is in neither and may still complete; renewal never revives it. |
|
|
@@ -548,6 +552,40 @@ scheduler instead of starting an inline drain. See
|
|
|
548
552
|
runs inside an active drain, where its immediate and returned children
|
|
549
553
|
become eligible, so it has no `dispatch`.
|
|
550
554
|
|
|
555
|
+
### `session.append.ifLastIndex(index, ...events)`
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
session.append.ifLastIndex(
|
|
559
|
+
index: number,
|
|
560
|
+
...events: Array<{ type: string; payload: unknown; id?: string }>
|
|
561
|
+
): Promise<Event[]>
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Commits the same atomic batch as `session.append`, only while `index` is still
|
|
565
|
+
the session's head: the index of its last event. See
|
|
566
|
+
[Appending from what you read](/contract/sessions#appending-from-what-you-read).
|
|
567
|
+
|
|
568
|
+
- The store checks the head in the same atomic step as the write, so of two
|
|
569
|
+
writers that read the same head, at most one lands.
|
|
570
|
+
- When the head is anything else, nothing is written and nothing runs: it
|
|
571
|
+
throws an `A2Error` with code `LAST_INDEX_MISMATCH`, and `details` is
|
|
572
|
+
`{ index }`, the head the store found.
|
|
573
|
+
- A batch whose ids are all in the session already returns its rows, whatever
|
|
574
|
+
the head is now. Give each event an id unique to this writer's decision and
|
|
575
|
+
stable across its retries, such as a request id the client generates. Ids
|
|
576
|
+
built from `index` alone are shared by every writer that read it, so they
|
|
577
|
+
fit only when each of them would append the same events.
|
|
578
|
+
- Ids that conflict with committed events, in a batch that mixes them with
|
|
579
|
+
fresh ones or doesn't match the rows they name, throw `LAST_INDEX_MISMATCH`
|
|
580
|
+
once the head has moved past `index`, as when another writer's batch took
|
|
581
|
+
them first. While the head is still `index`, they throw
|
|
582
|
+
`PARTIAL_DUPLICATE_BATCH`.
|
|
583
|
+
- A clear's marker is the head after it, so an index read before a clear never
|
|
584
|
+
matches after it.
|
|
585
|
+
- `index` is a non-negative safe integer, and the batch has at least one
|
|
586
|
+
event; otherwise it throws a `TypeError` before writing.
|
|
587
|
+
- Exists only on a server session's top-level append, like `dispatch`.
|
|
588
|
+
|
|
551
589
|
### `session.schedule(name, timing, ...events)`
|
|
552
590
|
|
|
553
591
|
```ts
|
|
@@ -811,8 +849,10 @@ client to resume there. See [State](/contract/state).
|
|
|
811
849
|
only when later committed events are intentionally part of the decision.
|
|
812
850
|
- A numeric `through` is an inclusive, non-negative safe integer;
|
|
813
851
|
`through: 0` returns the reducer's initial state.
|
|
814
|
-
- A
|
|
815
|
-
|
|
852
|
+
- A read and a following append are not atomic. To append only if nothing has
|
|
853
|
+
landed since the read, pass its `index` to
|
|
854
|
+
[`session.append.ifLastIndex`](#sessionappendiflastindexindex-events). In a
|
|
855
|
+
consumer, see [State](/contract/state#reducers) for joins that converge.
|
|
816
856
|
- Never runs consumers or waits for pending work. Snapshot write-back
|
|
817
857
|
uses platform `waitUntil`, and its failures never fail the completed read:
|
|
818
858
|
they go to `a2.snapshot` telemetry or the default console sink, without
|
|
@@ -42,6 +42,7 @@ export async function POST(req: Request) {
|
|
|
42
42
|
| `PARTIAL_DUPLICATE_BATCH` | a batch mixes already-appended and fresh events, or contains the same id twice. Event ids are unique per session, so an id used in another session never conflicts. Always a caller bug; nothing was written | no |
|
|
43
43
|
| `SUPERSEDED_ATTEMPT` | a consumer's append or clear comes from an attempt that a recovery claim or a session clear replaced; nothing was written. Also the abort reason on that attempt's `ctx.signal` | no |
|
|
44
44
|
| `SESSION_CLEARED` | a state read reaches below the latest clear, a consumer reads state past a clear of its event, or an actor call is cleared before it receives its answer | no |
|
|
45
|
+
| `LAST_INDEX_MISMATCH` | [`session.append.ifLastIndex()`](/reference/server#sessionappendiflastindexindex-events) found another head, including when another batch took its ids after the index it read; nothing was written. `details` is `{ index }`, the head it found. Read again and decide again; an identical retry would fail the same way | no |
|
|
45
46
|
| `CLAIM_EXPIRED` | never thrown; only the `ctx.signal` abort reason when the attempt's lease lapsed without renewal (assumed loss, decided locally, not proof) | no |
|
|
46
47
|
| `CONSUMER_DEAD_LETTERED` | `session.consume()` reached an event that dead-lettered for that consumer. Resolve it with `session.redrive()`, then consume again | no |
|
|
47
48
|
| `STORE_UNAVAILABLE` | the store backend failed; nothing was written. The original error is the `cause` | **yes** |
|
|
@@ -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).
|
|
@@ -10,7 +10,7 @@ import { deleteDocument, putDocument, setDocumentText } from '../search-index'
|
|
|
10
10
|
|
|
11
11
|
export const searchIndex = consumer({
|
|
12
12
|
contract: workspace,
|
|
13
|
-
name: 'a2slack-search-
|
|
13
|
+
name: 'a2slack-search-v2',
|
|
14
14
|
types: ['message.posted', 'message.edited', 'message.deleted'],
|
|
15
15
|
lane: 'search',
|
|
16
16
|
backfill: true,
|