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,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* experimental-a2/store-valkey — the Redis store on node-redis connections,
|
|
3
|
+
* for Valkey and Redis servers that need more than a URL: TLS settings,
|
|
4
|
+
* rotating credentials (such as ElastiCache IAM tokens), reconnect
|
|
5
|
+
* strategies, and timeouts. Storage and the live feed are the ones
|
|
6
|
+
* experimental-a2/store-redis uses (store-redis-notify.ts); this module owns
|
|
7
|
+
* the two connections.
|
|
8
|
+
*
|
|
9
|
+
* A2 creates both clients from the same options: one for commands, one
|
|
10
|
+
* dedicated subscriber. node-redis restores subscriptions before it reports
|
|
11
|
+
* a reconnect ready, so every feed and presence subscription on a channel
|
|
12
|
+
* catches up right after a reconnect instead of waiting for the safety read.
|
|
13
|
+
* A client whose reconnect strategy gives up is replaced on next use.
|
|
14
|
+
*
|
|
15
|
+
* Cluster mode is out: each script's keys share a session's hash tag, but
|
|
16
|
+
* the store runs on one non-cluster connection for commands and one for
|
|
17
|
+
* pub/sub. `redis` (node-redis) is an optional peer dependency.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { RedisClientOptions } from 'redis'
|
|
21
|
+
import { A2Error } from './errors.ts'
|
|
22
|
+
import { retryableLazy } from './retryable-lazy.ts'
|
|
23
|
+
import {
|
|
24
|
+
createRedisNotifyStore,
|
|
25
|
+
type RedisConnection,
|
|
26
|
+
} from './store-redis-notify.ts'
|
|
27
|
+
import {
|
|
28
|
+
RANDOM_IDS,
|
|
29
|
+
SYSTEM_CLOCK,
|
|
30
|
+
type A2Store,
|
|
31
|
+
type Clock,
|
|
32
|
+
type IdSource,
|
|
33
|
+
} from './store.ts'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* node-redis `createClient` options. A2 reads replies in their RESP2
|
|
37
|
+
* shapes, so it always connects with `RESP: 2`, which rules out `RESP`
|
|
38
|
+
* and the options that need RESP3.
|
|
39
|
+
*/
|
|
40
|
+
export type ValkeyClientOptions = Omit<
|
|
41
|
+
RedisClientOptions,
|
|
42
|
+
'RESP' | 'clientSideCache' | 'emitInvalidate' | 'maintNotifications'
|
|
43
|
+
>
|
|
44
|
+
|
|
45
|
+
export type ValkeyStoreOptions = {
|
|
46
|
+
/** `redis://` or `rediss://` URL. Overrides `clientOptions.url`. */
|
|
47
|
+
url?: string | undefined
|
|
48
|
+
/**
|
|
49
|
+
* Options for both clients A2 creates: `socket` (TLS, connect timeout,
|
|
50
|
+
* `reconnectStrategy`), `credentialsProvider`, `commandOptions`,
|
|
51
|
+
* `disableOfflineQueue`, and so on.
|
|
52
|
+
*/
|
|
53
|
+
clientOptions?: ValkeyClientOptions
|
|
54
|
+
/** Key prefix — isolates multiple apps on one server. Default `'a2'`. */
|
|
55
|
+
keyPrefix?: string
|
|
56
|
+
/** Injectable clock — every stored timestamp comes from here. */
|
|
57
|
+
clock?: Clock
|
|
58
|
+
/** Injectable id source for generated event ids. */
|
|
59
|
+
ids?: IdSource
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ValkeyStore = A2Store & {
|
|
63
|
+
/** Destroy the command client and the subscriber. */
|
|
64
|
+
close(): Promise<void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The node-redis client surface this module uses. */
|
|
68
|
+
type Client = {
|
|
69
|
+
readonly isOpen: boolean
|
|
70
|
+
readonly isReady: boolean
|
|
71
|
+
connect(): Promise<unknown>
|
|
72
|
+
destroy(): void
|
|
73
|
+
on(event: 'error' | 'ready', listener: () => void): unknown
|
|
74
|
+
sendCommand(
|
|
75
|
+
args: string[],
|
|
76
|
+
options: { typeMapping: Record<number, unknown> },
|
|
77
|
+
): Promise<unknown>
|
|
78
|
+
subscribe(
|
|
79
|
+
channel: string,
|
|
80
|
+
listener: (message: string, channel: string) => void,
|
|
81
|
+
): Promise<unknown>
|
|
82
|
+
unsubscribe(channel: string): Promise<unknown>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type Driver = {
|
|
86
|
+
create(): Client
|
|
87
|
+
bytes: { typeMapping: Record<number, unknown> }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Overrides any `commandOptions.typeMapping`: replies decode as strings. */
|
|
91
|
+
const STRING_REPLIES = { typeMapping: {} }
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* node-redis 6 needs `destroy()` to release a client whose reconnect
|
|
95
|
+
* strategy gave up; node-redis 5 throws there instead.
|
|
96
|
+
*/
|
|
97
|
+
const dispose = (client: Client | null | undefined): void => {
|
|
98
|
+
try {
|
|
99
|
+
client?.destroy()
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const closedError = (): A2Error =>
|
|
104
|
+
new A2Error('STORE_UNAVAILABLE', 'the store was closed')
|
|
105
|
+
|
|
106
|
+
/** node-redis 6's default `commandOptions.timeout`; node-redis 5 has none. */
|
|
107
|
+
const DEFAULT_TIMEOUT_MS = 5_000
|
|
108
|
+
|
|
109
|
+
type OwnedEntry = {
|
|
110
|
+
client: Client | null
|
|
111
|
+
created: Promise<Client>
|
|
112
|
+
/** Settles when the client first reports ready, or its connect fails. */
|
|
113
|
+
connected: Promise<unknown> | null
|
|
114
|
+
ready: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One A2-owned client at a time. `get` hands a new client out once it
|
|
119
|
+
* first reports ready, waiting at most `timeoutMs` per call while it keeps
|
|
120
|
+
* connecting, so `disableOfflineQueue` cannot reject a cold start's first
|
|
121
|
+
* calls. Later reconnects follow node-redis's offline queue,
|
|
122
|
+
* `disableOfflineQueue`, and command timeouts. A client whose connect
|
|
123
|
+
* failed, or whose reconnect strategy gave up, is closed for good, so the
|
|
124
|
+
* next `get` replaces it.
|
|
125
|
+
*/
|
|
126
|
+
const ownedClient = (options: {
|
|
127
|
+
driver: () => Promise<Driver>
|
|
128
|
+
opened: (client: Client) => void
|
|
129
|
+
timeoutMs: number
|
|
130
|
+
}) => {
|
|
131
|
+
let current: OwnedEntry | null = null
|
|
132
|
+
let closed = false
|
|
133
|
+
|
|
134
|
+
const firstReady = async (entry: OwnedEntry): Promise<Client> => {
|
|
135
|
+
const client = await entry.created
|
|
136
|
+
if (entry.ready) return client
|
|
137
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
138
|
+
try {
|
|
139
|
+
await Promise.race([
|
|
140
|
+
entry.connected,
|
|
141
|
+
new Promise<never>((_, reject) => {
|
|
142
|
+
timer = setTimeout(() => {
|
|
143
|
+
reject(
|
|
144
|
+
new A2Error(
|
|
145
|
+
'STORE_UNAVAILABLE',
|
|
146
|
+
`connect timed out after ${options.timeoutMs}ms`,
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
}, options.timeoutMs)
|
|
150
|
+
}),
|
|
151
|
+
])
|
|
152
|
+
} catch (err) {
|
|
153
|
+
if (err instanceof A2Error) throw err
|
|
154
|
+
throw new A2Error('STORE_UNAVAILABLE', 'connect failed', { cause: err })
|
|
155
|
+
} finally {
|
|
156
|
+
clearTimeout(timer)
|
|
157
|
+
}
|
|
158
|
+
return client
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
get(): Promise<Client> {
|
|
163
|
+
if (closed) return Promise.reject(closedError())
|
|
164
|
+
if (current && (current.client === null || current.client.isOpen))
|
|
165
|
+
return firstReady(current)
|
|
166
|
+
dispose(current?.client)
|
|
167
|
+
const entry: OwnedEntry = {
|
|
168
|
+
client: null,
|
|
169
|
+
connected: null,
|
|
170
|
+
ready: false,
|
|
171
|
+
created: options.driver().then((driver) => {
|
|
172
|
+
if (closed) throw closedError()
|
|
173
|
+
const client = driver.create()
|
|
174
|
+
entry.client = client
|
|
175
|
+
// Without a listener, node-redis's 'error' event crashes the
|
|
176
|
+
// process; failures still surface as command rejections.
|
|
177
|
+
client.on('error', () => {})
|
|
178
|
+
entry.connected = client.connect().then(
|
|
179
|
+
() => {
|
|
180
|
+
entry.ready = true
|
|
181
|
+
return undefined
|
|
182
|
+
},
|
|
183
|
+
(err: unknown) => {
|
|
184
|
+
if (current === entry) {
|
|
185
|
+
dispose(client)
|
|
186
|
+
current = null
|
|
187
|
+
}
|
|
188
|
+
throw err
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
void entry.connected.catch(() => {})
|
|
192
|
+
options.opened(client)
|
|
193
|
+
return client
|
|
194
|
+
}),
|
|
195
|
+
}
|
|
196
|
+
current = entry
|
|
197
|
+
void entry.created.catch(() => {
|
|
198
|
+
if (current === entry) current = null
|
|
199
|
+
})
|
|
200
|
+
return firstReady(entry)
|
|
201
|
+
},
|
|
202
|
+
close(): void {
|
|
203
|
+
closed = true
|
|
204
|
+
dispose(current?.client)
|
|
205
|
+
current = null
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function valkey(options: ValkeyStoreOptions = {}): ValkeyStore {
|
|
211
|
+
const clock = options.clock ?? SYSTEM_CLOCK
|
|
212
|
+
const ids = options.ids ?? RANDOM_IDS
|
|
213
|
+
const prefix = options.keyPrefix ?? 'a2'
|
|
214
|
+
|
|
215
|
+
if (!options.url && !options.clientOptions) {
|
|
216
|
+
throw new TypeError('valkey() needs a url or clientOptions')
|
|
217
|
+
}
|
|
218
|
+
const clientOptions = {
|
|
219
|
+
...options.clientOptions,
|
|
220
|
+
...(options.url ? { url: options.url } : {}),
|
|
221
|
+
RESP: 2 as const,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Lazy init — constructing the backend does no I/O.
|
|
225
|
+
const driver = retryableLazy(async (): Promise<Driver> => {
|
|
226
|
+
const mod = await import('redis').catch(() => {
|
|
227
|
+
throw new A2Error(
|
|
228
|
+
'STORE_NOT_CONFIGURED',
|
|
229
|
+
"experimental-a2/store-valkey needs the 'redis' package (optional peer dependency) — install it",
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
return {
|
|
233
|
+
create: () => mod.createClient(clientOptions) as unknown as Client,
|
|
234
|
+
bytes: { typeMapping: { [mod.RESP_TYPES.BLOB_STRING]: Buffer } },
|
|
235
|
+
}
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const timeoutMs = clientOptions.commandOptions?.timeout ?? DEFAULT_TIMEOUT_MS
|
|
239
|
+
const commands = ownedClient({
|
|
240
|
+
driver: driver.get,
|
|
241
|
+
opened: () => {},
|
|
242
|
+
timeoutMs,
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Channels the subscriber holds, restored on a replacement client. Each
|
|
247
|
+
* maps to the call that subscribed it, so a failed call only forgets its
|
|
248
|
+
* own subscription.
|
|
249
|
+
*/
|
|
250
|
+
const channels = new Map<string, symbol>()
|
|
251
|
+
let deliver: ((channel: string, message: string) => void) | undefined
|
|
252
|
+
let reconnected: ((channel: string) => void) | undefined
|
|
253
|
+
const onMessage = (message: string, channel: string): void => {
|
|
254
|
+
deliver?.(channel, message)
|
|
255
|
+
}
|
|
256
|
+
const resubscribed = (): void => {
|
|
257
|
+
for (const channel of channels.keys()) reconnected?.(channel)
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* node-redis queues SUBSCRIBE outside `sendCommand`, so neither
|
|
261
|
+
* `disableOfflineQueue` nor `commandOptions.timeout` applies to it: a
|
|
262
|
+
* subscribe sent while the subscriber reconnects would wait for the
|
|
263
|
+
* server indefinitely. Apply both here. A timed-out SUBSCRIBE stays
|
|
264
|
+
* queued, so an UNSUBSCRIBE queued behind it cancels it.
|
|
265
|
+
*/
|
|
266
|
+
const subscribeBounded = async (
|
|
267
|
+
client: Client,
|
|
268
|
+
channel: string,
|
|
269
|
+
): Promise<unknown> => {
|
|
270
|
+
if (!client.isReady && clientOptions.disableOfflineQueue) {
|
|
271
|
+
throw new A2Error('STORE_UNAVAILABLE', 'the subscriber is offline')
|
|
272
|
+
}
|
|
273
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
274
|
+
const timedOut = new Promise<never>((_, reject) => {
|
|
275
|
+
timer = setTimeout(() => {
|
|
276
|
+
void client.unsubscribe(channel).catch(() => {})
|
|
277
|
+
reject(
|
|
278
|
+
new A2Error(
|
|
279
|
+
'STORE_UNAVAILABLE',
|
|
280
|
+
`subscribe timed out after ${timeoutMs}ms`,
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
}, timeoutMs)
|
|
284
|
+
})
|
|
285
|
+
try {
|
|
286
|
+
return await Promise.race([
|
|
287
|
+
client.subscribe(channel, onMessage),
|
|
288
|
+
timedOut,
|
|
289
|
+
])
|
|
290
|
+
} finally {
|
|
291
|
+
clearTimeout(timer)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const subscriber = ownedClient({
|
|
295
|
+
driver: driver.get,
|
|
296
|
+
opened: (client) => {
|
|
297
|
+
// node-redis restores subscriptions before a reconnect's 'ready'.
|
|
298
|
+
let readies = 0
|
|
299
|
+
client.on('ready', () => {
|
|
300
|
+
readies += 1
|
|
301
|
+
if (readies > 1) resubscribed()
|
|
302
|
+
})
|
|
303
|
+
if (channels.size === 0) return
|
|
304
|
+
void Promise.all(
|
|
305
|
+
[...channels.keys()].map((channel) =>
|
|
306
|
+
client.subscribe(channel, onMessage),
|
|
307
|
+
),
|
|
308
|
+
).then(resubscribed, () => {})
|
|
309
|
+
},
|
|
310
|
+
timeoutMs,
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const subscriberConnection: RedisConnection = {
|
|
314
|
+
async call(command, ...args) {
|
|
315
|
+
const channel = String(args[0])
|
|
316
|
+
const name = command.toLowerCase()
|
|
317
|
+
if (name === 'subscribe') {
|
|
318
|
+
const call = Symbol(channel)
|
|
319
|
+
try {
|
|
320
|
+
// Restore only held channels while a replacement connects; a new
|
|
321
|
+
// channel belongs to the bounded subscribe after first readiness.
|
|
322
|
+
const client = await subscriber.get()
|
|
323
|
+
channels.set(channel, call)
|
|
324
|
+
return await subscribeBounded(client, channel)
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (channels.get(channel) === call) channels.delete(channel)
|
|
327
|
+
throw err
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (name === 'unsubscribe') {
|
|
331
|
+
channels.delete(channel)
|
|
332
|
+
return (await subscriber.get()).unsubscribe(channel)
|
|
333
|
+
}
|
|
334
|
+
return (await subscriber.get()).sendCommand(
|
|
335
|
+
[command, ...args.map(String)],
|
|
336
|
+
STRING_REPLIES,
|
|
337
|
+
)
|
|
338
|
+
},
|
|
339
|
+
duplicate: () => subscriberConnection,
|
|
340
|
+
on(_event, listener) {
|
|
341
|
+
deliver = listener
|
|
342
|
+
},
|
|
343
|
+
disconnect: subscriber.close,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const commandConnection: RedisConnection = {
|
|
347
|
+
call: async (command, ...args) =>
|
|
348
|
+
(await commands.get()).sendCommand(
|
|
349
|
+
[command, ...args.map(String)],
|
|
350
|
+
STRING_REPLIES,
|
|
351
|
+
),
|
|
352
|
+
duplicate: () => subscriberConnection,
|
|
353
|
+
on: () => undefined,
|
|
354
|
+
disconnect: commands.close,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return createRedisNotifyStore({
|
|
358
|
+
client: () => Promise.resolve(commandConnection),
|
|
359
|
+
callBytes: async (command, ...args) => {
|
|
360
|
+
const { bytes } = await driver.get()
|
|
361
|
+
return (await commands.get()).sendCommand(
|
|
362
|
+
[command, ...args.map(String)],
|
|
363
|
+
bytes,
|
|
364
|
+
)
|
|
365
|
+
},
|
|
366
|
+
clock,
|
|
367
|
+
ids,
|
|
368
|
+
keyPrefix: prefix,
|
|
369
|
+
onReconnect: (listener) => {
|
|
370
|
+
reconnected = listener
|
|
371
|
+
},
|
|
372
|
+
async closeClient() {
|
|
373
|
+
commands.close()
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -326,12 +326,22 @@ export interface A2Store {
|
|
|
326
326
|
* a `cause` at or below the latest clear marker throws
|
|
327
327
|
* `SUPERSEDED_ATTEMPT`, an `originIndex` there throws `SESSION_CLEARED`,
|
|
328
328
|
* and ids whose rows lie below the marker are free to reuse.
|
|
329
|
+
*
|
|
330
|
+
* `ifLastIndex` conditions a non-empty batch on the session's head, the
|
|
331
|
+
* last index including any clear marker: when the head is anything else,
|
|
332
|
+
* throw `lastIndexMismatchError` carrying the head and write nothing. It is
|
|
333
|
+
* checked last, after the replay path and both fences, inside the same
|
|
334
|
+
* atomic operation, never as a read before it. A conditional batch whose
|
|
335
|
+
* ids conflict (mixed, or not matching their rows) reports that head
|
|
336
|
+
* instead of `PARTIAL_DUPLICATE_BATCH` when it isn't `ifLastIndex`
|
|
337
|
+
* (`duplicateBatchError`).
|
|
329
338
|
*/
|
|
330
339
|
append(
|
|
331
340
|
sessionId: string,
|
|
332
341
|
events: AppendEvent[],
|
|
333
342
|
options?: {
|
|
334
343
|
originIndex?: number
|
|
344
|
+
ifLastIndex?: number
|
|
335
345
|
consumers?: readonly StoreConsumer[]
|
|
336
346
|
},
|
|
337
347
|
): Promise<StoreAppendResult>
|
package/src/wire.ts
CHANGED
|
@@ -309,6 +309,7 @@ const ERROR_STATUS: Record<A2ServerErrorCode, number> = {
|
|
|
309
309
|
PARTIAL_DUPLICATE_BATCH: 400,
|
|
310
310
|
SUPERSEDED_ATTEMPT: 409,
|
|
311
311
|
SESSION_CLEARED: 409,
|
|
312
|
+
LAST_INDEX_MISMATCH: 409,
|
|
312
313
|
CLAIM_EXPIRED: 409,
|
|
313
314
|
STORE_UNAVAILABLE: 503,
|
|
314
315
|
STORE_NOT_CONFIGURED: 500,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"initial-snapshot-D3Lve43o.d.ts","names":[],"sources":["../src/errors.ts","../src/initial-snapshot.ts"],"mappings":";;KAAY;KAcA,cACV;cAEW,gBAAgB;WAClB,MAAM;;WAEN;EAET,YACE,MAAM,aACN,iBACA;IAAY;IAAmB;;;;;;;;;;;cA6CtB,0BAA0B;EACrC,YAAY,iBAAiB;IAAY;;;;;KCrE/B,gBAAgB,KACxB,cAAc,wBAGd,QAAQ,cAAc"}
|