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.
Files changed (131) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/{actor-CqibLgjQ.d.ts → actor-Bqyb7gwf.d.ts} +3 -3
  3. package/dist/{actor-CqibLgjQ.d.ts.map → actor-Bqyb7gwf.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-client.js +2 -2
  6. package/dist/actor-react.d.ts +2 -2
  7. package/dist/actor-react.js +5 -5
  8. package/dist/{actor-shared-DFET5q16.js → actor-shared-B4OB6Qo7.js} +3 -3
  9. package/dist/{actor-shared-DFET5q16.js.map → actor-shared-B4OB6Qo7.js.map} +1 -1
  10. package/dist/actor.d.ts +1 -1
  11. package/dist/actor.js +4 -4
  12. package/dist/{ai-DAu77CDr.d.ts → ai-BZUu5-b7.d.ts} +2 -2
  13. package/dist/{ai-DAu77CDr.d.ts.map → ai-BZUu5-b7.d.ts.map} +1 -1
  14. package/dist/{ai-EEfKyc-z.js → ai-CCCNxd3L.js} +3 -3
  15. package/dist/{ai-EEfKyc-z.js.map → ai-CCCNxd3L.js.map} +1 -1
  16. package/dist/ai-client.d.ts +2 -2
  17. package/dist/ai-client.js +2 -2
  18. package/dist/ai-fx.d.ts +1 -1
  19. package/dist/ai-fx.js +25 -3
  20. package/dist/ai-fx.js.map +1 -1
  21. package/dist/{ai-harness-DTjkAr2T.d.ts → ai-harness-C8aV869s.d.ts} +2 -2
  22. package/dist/{ai-harness-DTjkAr2T.d.ts.map → ai-harness-C8aV869s.d.ts.map} +1 -1
  23. package/dist/ai-pi.d.ts +1 -1
  24. package/dist/ai-react.d.ts +4 -4
  25. package/dist/ai-react.js +2 -2
  26. package/dist/ai-server.d.ts +3 -3
  27. package/dist/ai-server.js +4 -4
  28. package/dist/ai.d.ts +1 -1
  29. package/dist/ai.js +1 -1
  30. package/dist/{client-CUMbS_38.d.ts → client-CCJASjUM.d.ts} +2 -2
  31. package/dist/{client-CUMbS_38.d.ts.map → client-CCJASjUM.d.ts.map} +1 -1
  32. package/dist/{client-BQS4i_V9.js → client-sq-djxhD.js} +4 -4
  33. package/dist/{client-BQS4i_V9.js.map → client-sq-djxhD.js.map} +1 -1
  34. package/dist/client.d.ts +2 -2
  35. package/dist/client.js +1 -1
  36. package/dist/{contract-CWukt01d.js → contract-DqfBSIqJ.js} +2 -2
  37. package/dist/{contract-CWukt01d.js.map → contract-DqfBSIqJ.js.map} +1 -1
  38. package/dist/index.d.ts +3 -3
  39. package/dist/index.js +3 -3
  40. package/dist/{initial-snapshot-D3Lve43o.d.ts → initial-snapshot-BA6xwv10.d.ts} +2 -2
  41. package/dist/initial-snapshot-BA6xwv10.d.ts.map +1 -0
  42. package/dist/{react-BoHvC3v9.d.ts → react-KC4eW7yG.d.ts} +3 -3
  43. package/dist/{react-BoHvC3v9.d.ts.map → react-KC4eW7yG.d.ts.map} +1 -1
  44. package/dist/react.d.ts +1 -1
  45. package/dist/react.js +1 -1
  46. package/dist/{reducer-CjmvZSAb.js → reducer-Bswr11ar.js} +2 -2
  47. package/dist/{reducer-CjmvZSAb.js.map → reducer-Bswr11ar.js.map} +1 -1
  48. package/dist/scheduler-qstash.d.ts +2 -2
  49. package/dist/scheduler-qstash.js +2 -2
  50. package/dist/scheduler-vercel.d.ts +2 -2
  51. package/dist/scheduler-vercel.js +1 -1
  52. package/dist/{server-qgez0Xny.d.ts → server-B9gu0qi1.d.ts} +9 -3
  53. package/dist/server-B9gu0qi1.d.ts.map +1 -0
  54. package/dist/{server-DtFrWxi3.js → server-DX7mM6nm.js} +22 -14
  55. package/dist/server-DX7mM6nm.js.map +1 -0
  56. package/dist/server.d.ts +2 -2
  57. package/dist/server.js +1 -1
  58. package/dist/{session-clear-wHd5GCpZ.js → session-clear-XJYpmUpg.js} +14 -2
  59. package/dist/session-clear-XJYpmUpg.js.map +1 -0
  60. package/dist/{session-clear-D1PFe7xl.d.ts → session-clear-vfonou9S.d.ts} +2 -2
  61. package/dist/session-clear-vfonou9S.d.ts.map +1 -0
  62. package/dist/{store-D0MhBvg6.d.ts → store-DEzcHZOA.d.ts} +11 -1
  63. package/dist/store-DEzcHZOA.d.ts.map +1 -0
  64. package/dist/store-N8PXxDAS.js.map +1 -1
  65. package/dist/{store-codec-DwrWlgQx.js → store-codec-pAG2oPgQ.js} +4 -4
  66. package/dist/store-codec-pAG2oPgQ.js.map +1 -0
  67. package/dist/store-memory.d.ts +1 -1
  68. package/dist/store-memory.d.ts.map +1 -1
  69. package/dist/store-memory.js +11 -4
  70. package/dist/store-memory.js.map +1 -1
  71. package/dist/store-postgres.d.ts +1 -1
  72. package/dist/store-postgres.d.ts.map +1 -1
  73. package/dist/store-postgres.js +26 -7
  74. package/dist/store-postgres.js.map +1 -1
  75. package/dist/store-redis-http.d.ts +1 -1
  76. package/dist/store-redis-http.js +2 -2
  77. package/dist/{store-redis-notify-CsJw_hfD.js → store-redis-notify-ys_-VrSP.js} +34 -18
  78. package/dist/store-redis-notify-ys_-VrSP.js.map +1 -0
  79. package/dist/store-redis.d.ts +1 -1
  80. package/dist/store-redis.js +2 -2
  81. package/dist/store-sqlite.d.ts +1 -1
  82. package/dist/store-sqlite.d.ts.map +1 -1
  83. package/dist/store-sqlite.js +11 -4
  84. package/dist/store-sqlite.js.map +1 -1
  85. package/dist/store-valkey.d.ts +33 -0
  86. package/dist/store-valkey.d.ts.map +1 -0
  87. package/dist/store-valkey.js +218 -0
  88. package/dist/store-valkey.js.map +1 -0
  89. package/dist/{wire-CV-Z7wz-.js → wire-BFE9sIQf.js} +3 -2
  90. package/dist/wire-BFE9sIQf.js.map +1 -0
  91. package/docs/client/03-presence.mdx +1 -0
  92. package/docs/contract/03-sessions.mdx +51 -0
  93. package/docs/reference/01-core.mdx +1 -0
  94. package/docs/reference/02-server.mdx +44 -4
  95. package/docs/reference/06-errors.mdx +1 -0
  96. package/docs/stores/01-overview.mdx +1 -0
  97. package/docs/stores/02-redis.mdx +3 -1
  98. package/docs/stores/07-valkey.mdx +97 -0
  99. package/examples/playground/app/a2slack/consumers/search.ts +1 -1
  100. package/examples/playground/app/a2slack/db/migrations/0005_a2slack_feed_v2.sql +7 -0
  101. package/examples/playground/app/a2slack/db/migrations/meta/0005_snapshot.json +1165 -0
  102. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
  103. package/examples/playground/app/a2slack/db/queries.ts +1 -1
  104. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +17 -14
  105. package/examples/playground/app/a2slack/search-index.ts +1 -1
  106. package/examples/playground/app/a2slack/server.ts +2 -2
  107. package/examples/playground/lib/store.ts +2 -0
  108. package/examples/playground/package.json +1 -1
  109. package/package.json +8 -2
  110. package/src/ai-fx.ts +46 -3
  111. package/src/errors.ts +39 -0
  112. package/src/idempotent-replay.ts +4 -3
  113. package/src/libfx.d.ts +2 -0
  114. package/src/server.ts +32 -9
  115. package/src/store-memory.ts +26 -4
  116. package/src/store-postgres.ts +36 -6
  117. package/src/store-redis-core.ts +40 -15
  118. package/src/store-redis-notify.ts +8 -2
  119. package/src/store-sqlite.ts +25 -4
  120. package/src/store-valkey.ts +376 -0
  121. package/src/store.ts +10 -0
  122. package/src/wire.ts +1 -0
  123. package/dist/initial-snapshot-D3Lve43o.d.ts.map +0 -1
  124. package/dist/server-DtFrWxi3.js.map +0 -1
  125. package/dist/server-qgez0Xny.d.ts.map +0 -1
  126. package/dist/session-clear-D1PFe7xl.d.ts.map +0 -1
  127. package/dist/session-clear-wHd5GCpZ.js.map +0 -1
  128. package/dist/store-D0MhBvg6.d.ts.map +0 -1
  129. package/dist/store-codec-DwrWlgQx.js.map +0 -1
  130. package/dist/store-redis-notify-CsJw_hfD.js.map +0 -1
  131. 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":";;;;;;AA0FA,MAAM,iBAAiB,EAAE,aAAa,CAAC,EAAE;;;;;AAMzC,MAAM,WAAW,WAA4C;CAC3D,IAAI;EACF,QAAQ,QAAQ;CAClB,QAAQ,CAAC;AACX;AAEA,MAAM,oBACJ,IAAI,QAAQ,qBAAqB,sBAAsB;;AAGzD,MAAM,qBAAqB;;;;;;;;;;AAmB3B,MAAM,eAAe,YAIf;CACJ,IAAI,UAA6B;CACjC,IAAI,SAAS;CAEb,MAAM,aAAa,OAAO,UAAuC;EAC/D,MAAM,SAAS,MAAM,MAAM;EAC3B,IAAI,MAAM,OAAO,OAAO;EACxB,IAAI;EACJ,IAAI;GACF,MAAM,QAAQ,KAAK,CACjB,MAAM,WACN,IAAI,SAAgB,GAAG,WAAW;IAChC,QAAQ,iBAAiB;KACvB,OACE,IAAI,QACF,qBACA,2BAA2B,QAAQ,UAAU,GAC/C,CACF;IACF,GAAG,QAAQ,SAAS;GACtB,CAAC,CACH,CAAC;EACH,SAAS,KAAK;GACZ,IAAI,eAAe,SAAS,MAAM;GAClC,MAAM,IAAI,QAAQ,qBAAqB,kBAAkB,EAAE,OAAO,IAAI,CAAC;EACzE,UAAU;GACR,aAAa,KAAK;EACpB;EACA,OAAO;CACT;CAEA,OAAO;EACL,MAAuB;GACrB,IAAI,QAAQ,OAAO,QAAQ,OAAO,YAAY,CAAC;GAC/C,IAAI,YAAY,QAAQ,WAAW,QAAQ,QAAQ,OAAO,SACxD,OAAO,WAAW,OAAO;GAC3B,QAAQ,SAAS,MAAM;GACvB,MAAM,QAAoB;IACxB,QAAQ;IACR,WAAW;IACX,OAAO;IACP,SAAS,QAAQ,OAAO,CAAC,CAAC,MAAM,WAAW;KACzC,IAAI,QAAQ,MAAM,YAAY;KAC9B,MAAM,SAAS,OAAO,OAAO;KAC7B,MAAM,SAAS;KAGf,OAAO,GAAG,eAAe,CAAC,CAAC;KAC3B,MAAM,YAAY,OAAO,QAAQ,CAAC,CAAC,WAC3B;MACJ,MAAM,QAAQ;KAEhB,IACC,QAAiB;MAChB,IAAI,YAAY,OAAO;OACrB,QAAQ,MAAM;OACd,UAAU;MACZ;MACA,MAAM;KACR,CACF;KACA,MAAW,UAAU,YAAY,CAAC,CAAC;KACnC,QAAQ,OAAO,MAAM;KACrB,OAAO;IACT,CAAC;GACH;GACA,UAAU;GACV,MAAW,QAAQ,YAAY;IAC7B,IAAI,YAAY,OAAO,UAAU;GACnC,CAAC;GACD,OAAO,WAAW,KAAK;EACzB;EACA,QAAc;GACZ,SAAS;GACT,QAAQ,SAAS,MAAM;GACvB,UAAU;EACZ;CACF;AACF;AAEA,SAAgB,OAAO,UAA8B,CAAC,GAAgB;CACpE,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,MAAM,QAAQ,OAAO;CAC3B,MAAM,SAAS,QAAQ,aAAa;CAEpC,IAAI,CAAC,QAAQ,OAAO,CAAC,QAAQ,eAC3B,MAAM,IAAI,UAAU,uCAAuC;CAE7D,MAAM,gBAAgB;EACpB,GAAG,QAAQ;EACX,GAAI,QAAQ,MAAM,EAAE,KAAK,QAAQ,IAAI,IAAI,CAAC;EAC1C,MAAM;CACR;CAGA,MAAM,SAAS,cAAc,YAA6B;EACxD,MAAM,MAAM,MAAM,OAAO,QAAQ,CAAC,YAAY;GAC5C,MAAM,IAAI,QACR,wBACA,gGACF;EACF,CAAC;EACD,OAAO;GACL,cAAc,IAAI,aAAa,aAAa;GAC5C,OAAO,EAAE,aAAa,GAAG,IAAI,WAAW,cAAc,OAAO,EAAE;EACjE;CACF,CAAC;CAED,MAAM,YAAY,cAAc,gBAAgB,WAAW;CAC3D,MAAM,WAAW,YAAY;EAC3B,QAAQ,OAAO;EACf,cAAc,CAAC;EACf;CACF,CAAC;;;;;;CAOD,MAAM,2BAAW,IAAI,IAAoB;CACzC,IAAI;CACJ,IAAI;CACJ,MAAM,aAAa,SAAiB,YAA0B;EAC5D,UAAU,SAAS,OAAO;CAC5B;CACA,MAAM,qBAA2B;EAC/B,KAAK,MAAM,WAAW,SAAS,KAAK,GAAG,cAAc,OAAO;CAC9D;;;;;;;;CAQA,MAAM,mBAAmB,OACvB,QACA,YACqB;EACrB,IAAI,CAAC,OAAO,WAAW,cAAc,qBACnC,MAAM,IAAI,QAAQ,qBAAqB,2BAA2B;EAEpE,IAAI;EACJ,MAAM,WAAW,IAAI,SAAgB,GAAG,WAAW;GACjD,QAAQ,iBAAiB;IACvB,OAAY,YAAY,OAAO,CAAC,CAAC,YAAY,CAAC,CAAC;IAC/C,OACE,IAAI,QACF,qBACA,6BAA6B,UAAU,GACzC,CACF;GACF,GAAG,SAAS;EACd,CAAC;EACD,IAAI;GACF,OAAO,MAAM,QAAQ,KAAK,CACxB,OAAO,UAAU,SAAS,SAAS,GACnC,QACF,CAAC;EACH,UAAU;GACR,aAAa,KAAK;EACpB;CACF;CACA,MAAM,aAAa,YAAY;EAC7B,QAAQ,OAAO;EACf,SAAS,WAAW;GAElB,IAAI,UAAU;GACd,OAAO,GAAG,eAAe;IACvB,WAAW;IACX,IAAI,UAAU,GAAG,aAAa;GAChC,CAAC;GACD,IAAI,SAAS,SAAS,GAAG;GACzB,QAAa,IACX,CAAC,GAAG,SAAS,KAAK,CAAC,CAAC,CAAC,KAAK,YACxB,OAAO,UAAU,SAAS,SAAS,CACrC,CACF,CAAC,CAAC,KAAK,oBAAoB,CAAC,CAAC;EAC/B;EACA;CACF,CAAC;CAED,MAAM,uBAAwC;EAC5C,MAAM,KAAK,SAAS,GAAG,MAAM;GAC3B,MAAM,UAAU,OAAO,KAAK,EAAE;GAC9B,MAAM,OAAO,QAAQ,YAAY;GACjC,IAAI,SAAS,aAAa;IACxB,MAAM,OAAO,OAAO,OAAO;IAC3B,IAAI;KAGF,MAAM,SAAS,MAAM,WAAW,IAAI;KACpC,SAAS,IAAI,SAAS,IAAI;KAC1B,OAAO,MAAM,iBAAiB,QAAQ,OAAO;IAC/C,SAAS,KAAK;KACZ,IAAI,SAAS,IAAI,OAAO,MAAM,MAAM,SAAS,OAAO,OAAO;KAC3D,MAAM;IACR;GACF;GACA,IAAI,SAAS,eAAe;IAC1B,SAAS,OAAO,OAAO;IACvB,QAAQ,MAAM,WAAW,IAAI,EAAA,CAAG,YAAY,OAAO;GACrD;GACA,QAAQ,MAAM,WAAW,IAAI,EAAA,CAAG,YAC9B,CAAC,SAAS,GAAG,KAAK,IAAI,MAAM,CAAC,GAC7B,cACF;EACF;EACA,iBAAiB;EACjB,GAAG,QAAQ,UAAU;GACnB,UAAU;EACZ;EACA,YAAY,WAAW;CACzB;CAEA,MAAM,oBAAqC;EACzC,MAAM,OAAO,SAAS,GAAG,UACtB,MAAM,SAAS,IAAI,EAAA,CAAG,YACrB,CAAC,SAAS,GAAG,KAAK,IAAI,MAAM,CAAC,GAC7B,cACF;EACF,iBAAiB;EACjB,UAAU,KAAA;EACV,YAAY,SAAS;CACvB;CAEA,OAAO,uBAAuB;EAC5B,cAAc,QAAQ,QAAQ,iBAAiB;EAC/C,WAAW,OAAO,SAAS,GAAG,SAAS;GACrC,MAAM,EAAE,UAAU,MAAM,OAAO,IAAI;GACnC,QAAQ,MAAM,SAAS,IAAI,EAAA,CAAG,YAC5B,CAAC,SAAS,GAAG,KAAK,IAAI,MAAM,CAAC,GAC7B,KACF;EACF;EACA;EACA;EACA,WAAW;EACX,cAAc,aAAa;GACzB,cAAc;EAChB;EACA,MAAM,cAAc;GAClB,SAAS,MAAM;EACjB;CACF,CAAC;AACH"}
@@ -1,4 +1,4 @@
1
- import { i as A2Error } from "./session-clear-wHd5GCpZ.js";
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-CV-Z7wz-.js.map
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?: { originIndex?: number; consumers?: readonly StoreConsumer[] },
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 latest read and a following append are not atomic. See
815
- [State](/contract/state#reducers) for joins that converge.
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 |
@@ -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-v1',
13
+ name: 'a2slack-search-v2',
14
14
  types: ['message.posted', 'message.edited', 'message.deleted'],
15
15
  lane: 'search',
16
16
  backfill: true,