experimental-a2 0.4.0 → 0.5.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 +51 -0
- package/dist/{ai-B4YhEnfw.d.ts → ai-CFNeCrRl.d.ts} +3 -2
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +4 -3
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +4 -2
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +3 -1
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +2 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.js +2 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -0
- package/dist/{client-Bt4tAKi9.js → client-BKlyLiOU.js} +295 -85
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/{client-BrfDXQ8A.d.ts → client-D7mvIXrF.d.ts} +40 -4
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/contract-48bUMgcL.js +2 -0
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/contract-jIfaR085.d.ts +2 -1
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +2 -1
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +2 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +2 -0
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +2 -1
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +2 -0
- package/dist/devtools-server.js.map +1 -0
- package/dist/errors-BQuJpe82.js +2 -0
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/errors-W6nwJ-fm.d.ts +2 -1
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +121 -72
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +503 -178
- package/dist/http.js.map +1 -0
- package/dist/idempotent-replay-DuqEkYA7.js +2 -0
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspection-DaxB5jM2.js +2 -0
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/{internal-aEotMzu_.js → internal-DstsI6Re.js} +3 -1
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +2 -1
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +2 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +12 -3
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +5 -1
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +4 -3
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +4 -2
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +2 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -3
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +4 -2
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/{server-CcNnFnoW.js → server-C72KOw51.js} +100 -48
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/{server-YtPq7hjw.d.ts → server-DUF9pjsx.d.ts} +5 -4
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-C3sNAaBT.d.ts → store-DysUkTH3.d.ts} +10 -1
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js +2 -0
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +2 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +3 -2
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/store-memory.js +19 -11
- package/dist/store-memory.js.map +1 -0
- package/dist/{store-polling-DgrrAE3d.js → store-polling-dSeLxzfb.js} +3 -1
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/store-postgres.d.ts +3 -2
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/store-postgres.js +57 -1
- package/dist/store-postgres.js.map +1 -0
- package/dist/{store-redis-core-DWqx3F47.js → store-redis-core-BFLwz0Wj.js} +3 -1
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/store-redis-http.d.ts +3 -2
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/store-redis-http.js +4 -2
- package/dist/store-redis-http.js.map +1 -0
- package/dist/store-redis.d.ts +3 -2
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/store-redis.js +5 -3
- package/dist/store-redis.js.map +1 -0
- package/dist/store-sqlite.d.ts +3 -2
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/store-sqlite.js +3 -1
- package/dist/store-sqlite.js.map +1 -0
- package/dist/telemetry-BjYHTfh2.d.ts +2 -1
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.js +2 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +2 -1
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +2 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/{wire-DCUZBUlT.js → wire-BFQmSJ-9.js} +77 -15
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/guides/03-react.mdx +59 -39
- package/docs/guides/06-ai-agents.mdx +5 -27
- package/docs/guides/09-presence.mdx +19 -40
- package/docs/guides/10-transports.mdx +49 -40
- package/docs/reference/01-api.mdx +107 -26
- package/docs/reference/02-errors.mdx +4 -2
- package/package.json +2 -1
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* experimental-a2/store-memory — in-memory store backend (the test default).
|
|
3
|
+
*
|
|
4
|
+
* Implements the A2Store interface with zero dependencies. See
|
|
5
|
+
* specs/a2-implementation.md §3 for the contract; the conformance suite
|
|
6
|
+
* in test/conformance is the executable version of it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PresencePatch } from './contract.ts'
|
|
10
|
+
import { A2Error } from './errors.ts'
|
|
11
|
+
import { idempotentReplay } from './idempotent-replay.ts'
|
|
12
|
+
import { nullProtoRecord } from './internal.ts'
|
|
13
|
+
import {
|
|
14
|
+
RANDOM_IDS,
|
|
15
|
+
SYSTEM_CLOCK,
|
|
16
|
+
type A2Store,
|
|
17
|
+
type AppendEvent,
|
|
18
|
+
type Clock,
|
|
19
|
+
type Event,
|
|
20
|
+
type EventCause,
|
|
21
|
+
type IdSource,
|
|
22
|
+
type PresenceRow,
|
|
23
|
+
type StoredEvent,
|
|
24
|
+
type StoredSessionSummary,
|
|
25
|
+
type StoreStateRead,
|
|
26
|
+
} from './store.ts'
|
|
27
|
+
|
|
28
|
+
export type MemoryStoreOptions = {
|
|
29
|
+
/** Injectable clock — every stored timestamp comes from here. */
|
|
30
|
+
clock?: Clock
|
|
31
|
+
/** Injectable id source for generated event ids. */
|
|
32
|
+
ids?: IdSource
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Row = {
|
|
36
|
+
id: string
|
|
37
|
+
type: string
|
|
38
|
+
payload: unknown
|
|
39
|
+
index: number
|
|
40
|
+
sessionId: string
|
|
41
|
+
createdAt: Date
|
|
42
|
+
cause: EventCause | null
|
|
43
|
+
lane: string | null
|
|
44
|
+
processedAt: Date | null
|
|
45
|
+
processedByAttempt: number | null
|
|
46
|
+
returnedEventIds: string[] | null
|
|
47
|
+
firstClaimedAt: Date | null
|
|
48
|
+
lastClaimedAt: Date | null
|
|
49
|
+
attemptCount: number
|
|
50
|
+
claimHolder: string | null
|
|
51
|
+
claimExpiresAt: Date | null
|
|
52
|
+
failureCount: number
|
|
53
|
+
lastFailedAt: Date | null
|
|
54
|
+
lastFailedAttempt: number | null
|
|
55
|
+
lastError: string | null
|
|
56
|
+
failedAt: Date | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type PresenceEntry = {
|
|
60
|
+
value: unknown
|
|
61
|
+
seen: number
|
|
62
|
+
at: Date
|
|
63
|
+
expiresAt: Date
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type LaneQueue = { rows: Row[]; head: number }
|
|
67
|
+
type SessionDispatch = {
|
|
68
|
+
ready: Map<number, Row>
|
|
69
|
+
lanes: Map<string, LaneQueue>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const toStored = (row: Row): StoredEvent => ({
|
|
73
|
+
id: row.id,
|
|
74
|
+
type: row.type,
|
|
75
|
+
payload: structuredClone(row.payload),
|
|
76
|
+
index: row.index,
|
|
77
|
+
sessionId: row.sessionId,
|
|
78
|
+
createdAt: new Date(row.createdAt),
|
|
79
|
+
cause: row.cause ? { ...row.cause } : null,
|
|
80
|
+
lane: row.lane,
|
|
81
|
+
processedAt: row.processedAt ? new Date(row.processedAt) : null,
|
|
82
|
+
processedByAttempt: row.processedByAttempt,
|
|
83
|
+
returnedEventIds: row.returnedEventIds ? [...row.returnedEventIds] : null,
|
|
84
|
+
firstClaimedAt: row.firstClaimedAt ? new Date(row.firstClaimedAt) : null,
|
|
85
|
+
lastClaimedAt: row.lastClaimedAt ? new Date(row.lastClaimedAt) : null,
|
|
86
|
+
attemptCount: row.attemptCount,
|
|
87
|
+
claimHolder: row.claimHolder,
|
|
88
|
+
claimExpiresAt: row.claimExpiresAt ? new Date(row.claimExpiresAt) : null,
|
|
89
|
+
failureCount: row.failureCount,
|
|
90
|
+
lastFailedAt: row.lastFailedAt ? new Date(row.lastFailedAt) : null,
|
|
91
|
+
lastFailedAttempt: row.lastFailedAttempt,
|
|
92
|
+
lastError: row.lastError,
|
|
93
|
+
failedAt: row.failedAt ? new Date(row.failedAt) : null,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const toEvent = (row: Row): Event => ({
|
|
97
|
+
id: row.id,
|
|
98
|
+
type: row.type,
|
|
99
|
+
payload: structuredClone(row.payload),
|
|
100
|
+
index: row.index,
|
|
101
|
+
sessionId: row.sessionId,
|
|
102
|
+
createdAt: new Date(row.createdAt),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
106
|
+
const clock = options.clock ?? SYSTEM_CLOCK
|
|
107
|
+
const generateId = options.ids ?? RANDOM_IDS
|
|
108
|
+
|
|
109
|
+
const sessions = new Map<string, Row[]>()
|
|
110
|
+
const dispatch = new Map<string, SessionDispatch>()
|
|
111
|
+
/** Global unique index on event ids, like the SQL schema's. */
|
|
112
|
+
const byEventId = new Map<string, Row>()
|
|
113
|
+
/** Keyed by `${sessionId}\u0000${reducerName}` — a pure cache. */
|
|
114
|
+
const snapshots = new Map<
|
|
115
|
+
string,
|
|
116
|
+
{ index: number; state: unknown; updatedAt: Date }
|
|
117
|
+
>()
|
|
118
|
+
const streamSubscribers = new Map<string, Set<(row: Row) => void>>()
|
|
119
|
+
/** ns → participant → field → latest surviving write. */
|
|
120
|
+
const presenceRows = new Map<
|
|
121
|
+
string,
|
|
122
|
+
Map<string, Map<string, PresenceEntry>>
|
|
123
|
+
>()
|
|
124
|
+
const presenceSubscribers = new Map<
|
|
125
|
+
string,
|
|
126
|
+
Set<(patch: PresencePatch) => void>
|
|
127
|
+
>()
|
|
128
|
+
|
|
129
|
+
const notifyStreams = (sessionId: string, rows: Row[]): void => {
|
|
130
|
+
const subs = streamSubscribers.get(sessionId)
|
|
131
|
+
if (!subs) return
|
|
132
|
+
for (const listener of subs) {
|
|
133
|
+
for (const row of rows) listener(row)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rowsOf = (sessionId: string): Row[] => {
|
|
138
|
+
let rows = sessions.get(sessionId)
|
|
139
|
+
if (!rows) {
|
|
140
|
+
rows = []
|
|
141
|
+
sessions.set(sessionId, rows)
|
|
142
|
+
}
|
|
143
|
+
return rows
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const dispatchOf = (sessionId: string): SessionDispatch => {
|
|
147
|
+
let state = dispatch.get(sessionId)
|
|
148
|
+
if (!state) {
|
|
149
|
+
state = { ready: new Map(), lanes: new Map() }
|
|
150
|
+
dispatch.set(sessionId, state)
|
|
151
|
+
}
|
|
152
|
+
return state
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const enqueue = (sessionId: string, row: Row): void => {
|
|
156
|
+
if (row.processedAt !== null) return
|
|
157
|
+
const state = dispatchOf(sessionId)
|
|
158
|
+
if (row.lane === null) {
|
|
159
|
+
state.ready.set(row.index, row)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
let queue = state.lanes.get(row.lane)
|
|
163
|
+
if (!queue) {
|
|
164
|
+
queue = { rows: [], head: 0 }
|
|
165
|
+
state.lanes.set(row.lane, queue)
|
|
166
|
+
}
|
|
167
|
+
queue.rows.push(row)
|
|
168
|
+
if (queue.rows.length === 1) state.ready.set(row.index, row)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const settle = (sessionId: string, row: Row): void => {
|
|
172
|
+
const state = dispatchOf(sessionId)
|
|
173
|
+
state.ready.delete(row.index)
|
|
174
|
+
if (row.lane === null) return
|
|
175
|
+
const queue = state.lanes.get(row.lane)!
|
|
176
|
+
if (queue.rows[queue.head] !== row) return
|
|
177
|
+
queue.head += 1
|
|
178
|
+
if (queue.head === queue.rows.length) {
|
|
179
|
+
state.lanes.delete(row.lane)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
const next = queue.rows[queue.head]!
|
|
183
|
+
if (next.failedAt === null) state.ready.set(next.index, next)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const find = (sessionId: string, index: number): Row => {
|
|
187
|
+
const row = sessions.get(sessionId)?.[index - 1]
|
|
188
|
+
if (!row) {
|
|
189
|
+
throw new TypeError(
|
|
190
|
+
`no event at index ${index} in session '${sessionId}'`,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
return row
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const appendResult = (sessionId: string, events: StoredEvent[]) => ({
|
|
197
|
+
events,
|
|
198
|
+
hasPending: (sessions.get(sessionId) ?? []).some(
|
|
199
|
+
(row) => row.processedAt === null,
|
|
200
|
+
),
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const claim = (
|
|
204
|
+
row: Row,
|
|
205
|
+
holder: string,
|
|
206
|
+
now: Date,
|
|
207
|
+
expiresAt: Date,
|
|
208
|
+
): StoredEvent => {
|
|
209
|
+
row.attemptCount += 1
|
|
210
|
+
row.firstClaimedAt ??= new Date(now)
|
|
211
|
+
row.lastClaimedAt = new Date(now)
|
|
212
|
+
row.claimHolder = holder
|
|
213
|
+
row.claimExpiresAt = new Date(expiresAt)
|
|
214
|
+
return toStored(row)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const checkBatchIds = (
|
|
218
|
+
sessionId: string,
|
|
219
|
+
events: readonly (AppendEvent & { id?: string })[],
|
|
220
|
+
): Row[] => {
|
|
221
|
+
const supplied = events.filter(
|
|
222
|
+
(candidate): candidate is AppendEvent & { id: string } =>
|
|
223
|
+
candidate.id !== undefined,
|
|
224
|
+
)
|
|
225
|
+
const suppliedIds = new Set(supplied.map((candidate) => candidate.id))
|
|
226
|
+
if (suppliedIds.size !== supplied.length) {
|
|
227
|
+
throw new A2Error(
|
|
228
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
229
|
+
'batch contains the same event id more than once',
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
const existing = supplied
|
|
233
|
+
.map((candidate) => byEventId.get(candidate.id))
|
|
234
|
+
.filter((row): row is Row => row !== undefined)
|
|
235
|
+
const foreign = existing.find((row) => row.sessionId !== sessionId)
|
|
236
|
+
if (foreign) {
|
|
237
|
+
throw new A2Error(
|
|
238
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
239
|
+
`event id '${foreign.id}' already exists in another session`,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
return existing
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const insert = (
|
|
246
|
+
sessionId: string,
|
|
247
|
+
events: readonly AppendEvent[],
|
|
248
|
+
now: Date,
|
|
249
|
+
cause?: EventCause,
|
|
250
|
+
): Row[] => {
|
|
251
|
+
const rows = rowsOf(sessionId)
|
|
252
|
+
const base = rows.length === 0 ? 0 : rows[rows.length - 1]!.index
|
|
253
|
+
const inserted = events.map((candidate, offset): Row => ({
|
|
254
|
+
id: candidate.id ?? generateId(),
|
|
255
|
+
type: candidate.type,
|
|
256
|
+
payload: structuredClone(candidate.payload),
|
|
257
|
+
index: base + 1 + offset,
|
|
258
|
+
sessionId,
|
|
259
|
+
createdAt: new Date(now),
|
|
260
|
+
cause: cause
|
|
261
|
+
? { ...cause }
|
|
262
|
+
: candidate.cause
|
|
263
|
+
? { ...candidate.cause }
|
|
264
|
+
: null,
|
|
265
|
+
lane: candidate.lane ?? null,
|
|
266
|
+
processedAt: candidate.settled ? new Date(now) : null,
|
|
267
|
+
processedByAttempt: null,
|
|
268
|
+
returnedEventIds: null,
|
|
269
|
+
firstClaimedAt: null,
|
|
270
|
+
lastClaimedAt: null,
|
|
271
|
+
attemptCount: 0,
|
|
272
|
+
claimHolder: null,
|
|
273
|
+
claimExpiresAt: null,
|
|
274
|
+
failureCount: 0,
|
|
275
|
+
lastFailedAt: null,
|
|
276
|
+
lastFailedAttempt: null,
|
|
277
|
+
lastError: null,
|
|
278
|
+
failedAt: null,
|
|
279
|
+
}))
|
|
280
|
+
for (const row of inserted) {
|
|
281
|
+
if (byEventId.has(row.id)) {
|
|
282
|
+
throw new A2Error(
|
|
283
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
284
|
+
`event id '${row.id}' already exists`,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const row of inserted) {
|
|
289
|
+
rows.push(row)
|
|
290
|
+
byEventId.set(row.id, row)
|
|
291
|
+
enqueue(sessionId, row)
|
|
292
|
+
}
|
|
293
|
+
return inserted
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const readStateOf = (
|
|
297
|
+
sessionId: string,
|
|
298
|
+
reducerName: string,
|
|
299
|
+
): StoreStateRead => {
|
|
300
|
+
const snap = snapshots.get(`${sessionId}\u0000${reducerName}`)
|
|
301
|
+
const snapshot = snap
|
|
302
|
+
? { index: snap.index, state: structuredClone(snap.state) }
|
|
303
|
+
: null
|
|
304
|
+
const afterIndex = snapshot?.index ?? 0
|
|
305
|
+
return {
|
|
306
|
+
snapshot,
|
|
307
|
+
events: (sessions.get(sessionId) ?? [])
|
|
308
|
+
.filter((row) => row.index > afterIndex)
|
|
309
|
+
.map(toEvent),
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
async append(sessionId, events) {
|
|
315
|
+
if (events.length === 0) return appendResult(sessionId, [])
|
|
316
|
+
|
|
317
|
+
// Batch-scoped idempotency (a2-implementation.md §3): all ids
|
|
318
|
+
// already present → lost-ack retry, return the original rows;
|
|
319
|
+
// some present → the caller mixed sent and fresh events.
|
|
320
|
+
const existing = checkBatchIds(sessionId, events)
|
|
321
|
+
if (existing.length > 0) {
|
|
322
|
+
if (existing.length === events.length) {
|
|
323
|
+
return appendResult(
|
|
324
|
+
sessionId,
|
|
325
|
+
idempotentReplay(events, existing.map(toStored)),
|
|
326
|
+
)
|
|
327
|
+
}
|
|
328
|
+
throw new A2Error(
|
|
329
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
330
|
+
`batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Attempt-currency fence: a fresh handler append commits only while
|
|
335
|
+
// its causal attempt is still the parent's latest (store.ts `append`).
|
|
336
|
+
const rows = sessions.get(sessionId) ?? []
|
|
337
|
+
for (const event of events) {
|
|
338
|
+
if (!event.cause) continue
|
|
339
|
+
const parent = rows[event.cause.index - 1]
|
|
340
|
+
if (!parent) {
|
|
341
|
+
throw new TypeError(
|
|
342
|
+
`no event at index ${event.cause.index} in session '${sessionId}'`,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
if (
|
|
346
|
+
parent.attemptCount !== event.cause.attempt ||
|
|
347
|
+
parent.failedAt !== null
|
|
348
|
+
) {
|
|
349
|
+
throw new A2Error(
|
|
350
|
+
'SUPERSEDED_ATTEMPT',
|
|
351
|
+
`attempt ${event.cause.attempt} no longer owns event ${event.cause.index} in session '${sessionId}'`,
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const now = clock.now()
|
|
357
|
+
const inserted = insert(sessionId, events, now)
|
|
358
|
+
notifyStreams(sessionId, inserted)
|
|
359
|
+
return appendResult(sessionId, inserted.map(toStored))
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
async read(sessionId, opts) {
|
|
363
|
+
const rows = sessions.get(sessionId) ?? []
|
|
364
|
+
const start = Math.max(0, opts?.afterIndex ?? 0)
|
|
365
|
+
const end = Math.max(0, opts?.throughIndex ?? rows.length)
|
|
366
|
+
return rows.slice(start, end).map(toStored)
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async claimAvailable({
|
|
370
|
+
sessionId,
|
|
371
|
+
holder,
|
|
372
|
+
ttlMs,
|
|
373
|
+
expiresAtMs,
|
|
374
|
+
excludeIndexes,
|
|
375
|
+
}) {
|
|
376
|
+
const claimedAt = clock.now()
|
|
377
|
+
const now = claimedAt.getTime()
|
|
378
|
+
const expiresAt = new Date(expiresAtMs ?? now + ttlMs)
|
|
379
|
+
const excluded = new Set(excludeIndexes ?? [])
|
|
380
|
+
const eligible: Row[] = []
|
|
381
|
+
let retryAt: Date | null = null
|
|
382
|
+
|
|
383
|
+
for (const row of dispatchOf(sessionId).ready.values()) {
|
|
384
|
+
if (row.failedAt !== null || excluded.has(row.index)) continue
|
|
385
|
+
if (row.claimExpiresAt && row.claimExpiresAt.getTime() > now) {
|
|
386
|
+
if (!retryAt || row.claimExpiresAt < retryAt) {
|
|
387
|
+
retryAt = new Date(row.claimExpiresAt)
|
|
388
|
+
}
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
eligible.push(row)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (eligible.length === 0) {
|
|
395
|
+
return retryAt ? { outcome: 'busy', retryAt } : { outcome: 'settled' }
|
|
396
|
+
}
|
|
397
|
+
eligible.sort((a, b) => a.index - b.index)
|
|
398
|
+
return {
|
|
399
|
+
outcome: 'claimed',
|
|
400
|
+
events: eligible.map((row) => claim(row, holder, claimedAt, expiresAt)),
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
|
|
405
|
+
const now = clock.now()
|
|
406
|
+
const expiresAt = new Date(expiresAtMs ?? now.getTime() + ttlMs)
|
|
407
|
+
const requested = new Map(claims.map((c) => [c.index, c.attempt]))
|
|
408
|
+
const renewed: number[] = []
|
|
409
|
+
const superseded: number[] = []
|
|
410
|
+
for (const row of sessions.get(sessionId) ?? []) {
|
|
411
|
+
const attempt = requested.get(row.index)
|
|
412
|
+
if (attempt === undefined) continue
|
|
413
|
+
if (row.attemptCount > attempt) {
|
|
414
|
+
superseded.push(row.index)
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
if (
|
|
418
|
+
row.processedAt === null &&
|
|
419
|
+
row.failedAt === null &&
|
|
420
|
+
row.claimHolder === holder &&
|
|
421
|
+
row.claimExpiresAt !== null &&
|
|
422
|
+
row.claimExpiresAt > now
|
|
423
|
+
) {
|
|
424
|
+
row.claimExpiresAt = new Date(expiresAt)
|
|
425
|
+
renewed.push(row.index)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return { renewed, superseded }
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
async completeAttempt({ sessionId, index, attempt, events }) {
|
|
432
|
+
const parent = find(sessionId, index)
|
|
433
|
+
const cause = { index, attempt }
|
|
434
|
+
if (parent.processedAt !== null) {
|
|
435
|
+
if (parent.processedByAttempt !== attempt) {
|
|
436
|
+
return { outcome: 'superseded' }
|
|
437
|
+
}
|
|
438
|
+
const requestedIds = events.map((event) => event.id)
|
|
439
|
+
if (
|
|
440
|
+
parent.returnedEventIds === null ||
|
|
441
|
+
parent.returnedEventIds.length !== requestedIds.length ||
|
|
442
|
+
parent.returnedEventIds.some(
|
|
443
|
+
(id, offset) => id !== requestedIds[offset],
|
|
444
|
+
)
|
|
445
|
+
) {
|
|
446
|
+
throw new A2Error(
|
|
447
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
448
|
+
'completed attempt does not match the returned event batch',
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
const existing = checkBatchIds(sessionId, events)
|
|
452
|
+
if (
|
|
453
|
+
existing.length !== events.length ||
|
|
454
|
+
existing.some(
|
|
455
|
+
(row) =>
|
|
456
|
+
row.cause?.index !== index || row.cause.attempt !== attempt,
|
|
457
|
+
)
|
|
458
|
+
) {
|
|
459
|
+
throw new A2Error(
|
|
460
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
461
|
+
'completed attempt does not match the returned event batch',
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
outcome: 'completed',
|
|
466
|
+
events: idempotentReplay(events, existing.map(toStored)),
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (
|
|
470
|
+
parent.attemptCount !== attempt ||
|
|
471
|
+
parent.failedAt !== null ||
|
|
472
|
+
parent.claimHolder === null
|
|
473
|
+
) {
|
|
474
|
+
return { outcome: 'superseded' }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const existing = checkBatchIds(sessionId, events)
|
|
478
|
+
if (existing.length > 0) {
|
|
479
|
+
throw new A2Error(
|
|
480
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
481
|
+
'returned event batch contains already-appended ids',
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
const now = clock.now()
|
|
485
|
+
const inserted = insert(sessionId, events, now, cause)
|
|
486
|
+
parent.processedAt = new Date(now)
|
|
487
|
+
parent.processedByAttempt = attempt
|
|
488
|
+
parent.returnedEventIds = events.map((event) => event.id)
|
|
489
|
+
parent.claimHolder = null
|
|
490
|
+
parent.claimExpiresAt = null
|
|
491
|
+
settle(sessionId, parent)
|
|
492
|
+
notifyStreams(sessionId, inserted)
|
|
493
|
+
return { outcome: 'completed', events: inserted.map(toStored) }
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
|
|
497
|
+
const row = find(sessionId, index)
|
|
498
|
+
if (
|
|
499
|
+
row.processedAt ||
|
|
500
|
+
row.attemptCount !== attempt ||
|
|
501
|
+
(row.claimHolder === null && row.lastFailedAttempt !== attempt)
|
|
502
|
+
) {
|
|
503
|
+
return { outcome: 'superseded', failureCount: row.failureCount }
|
|
504
|
+
}
|
|
505
|
+
if (row.lastFailedAttempt === attempt && row.claimHolder === null) {
|
|
506
|
+
return {
|
|
507
|
+
outcome: row.failedAt ? 'dead_lettered' : 'failed',
|
|
508
|
+
failureCount: row.failureCount,
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (row.failedAt) {
|
|
512
|
+
return { outcome: 'dead_lettered', failureCount: row.failureCount }
|
|
513
|
+
}
|
|
514
|
+
row.failureCount += 1
|
|
515
|
+
row.lastError = error
|
|
516
|
+
const failedAt = clock.now()
|
|
517
|
+
row.lastFailedAt = new Date(failedAt)
|
|
518
|
+
row.lastFailedAttempt = attempt
|
|
519
|
+
row.claimHolder = null
|
|
520
|
+
row.claimExpiresAt = null
|
|
521
|
+
if (row.failureCount >= maxFailures) {
|
|
522
|
+
row.failedAt = new Date(failedAt)
|
|
523
|
+
dispatchOf(sessionId).ready.delete(row.index)
|
|
524
|
+
return {
|
|
525
|
+
outcome: 'dead_lettered',
|
|
526
|
+
failureCount: row.failureCount,
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return { outcome: 'failed', failureCount: row.failureCount }
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
async readState(sessionId, reducerName) {
|
|
533
|
+
return readStateOf(sessionId, reducerName)
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
async readStates(sessionIds, reducerName) {
|
|
537
|
+
return sessionIds.map((sessionId) => readStateOf(sessionId, reducerName))
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
async putSnapshot(sessionId, reducerName, index, state) {
|
|
541
|
+
const key = `${sessionId}\u0000${reducerName}`
|
|
542
|
+
const existing = snapshots.get(key)
|
|
543
|
+
// Guarded upsert: a slower concurrent writer must never clobber a
|
|
544
|
+
// further-along snapshot. A lost race costs a few refolded events
|
|
545
|
+
// next read — never correctness.
|
|
546
|
+
if (existing && existing.index >= index) return
|
|
547
|
+
snapshots.set(key, {
|
|
548
|
+
index,
|
|
549
|
+
state: structuredClone(state),
|
|
550
|
+
updatedAt: clock.now(),
|
|
551
|
+
})
|
|
552
|
+
},
|
|
553
|
+
|
|
554
|
+
presence: {
|
|
555
|
+
async set(ns, participant, values, meta) {
|
|
556
|
+
let participants = presenceRows.get(ns)
|
|
557
|
+
if (!participants) {
|
|
558
|
+
participants = new Map()
|
|
559
|
+
presenceRows.set(ns, participants)
|
|
560
|
+
}
|
|
561
|
+
let fields = participants.get(participant)
|
|
562
|
+
if (!fields) {
|
|
563
|
+
fields = new Map()
|
|
564
|
+
participants.set(participant, fields)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Expiry anchors on the storage's own clock — the sender's
|
|
568
|
+
// `at` orders writes but never extends or shortens a lifetime.
|
|
569
|
+
const expiresAtMs = clock.now().getTime() + meta.ttlMs
|
|
570
|
+
// Field-keyed and caller-named — null-prototype, like every
|
|
571
|
+
// presence map (see `nullProtoRecord`).
|
|
572
|
+
const applied: Record<string, unknown> = nullProtoRecord()
|
|
573
|
+
let appliedCount = 0
|
|
574
|
+
for (const [field, value] of Object.entries(values)) {
|
|
575
|
+
// Field-wise LWW by `at`; ties go to the incoming write, so
|
|
576
|
+
// same-stamp sets keep set-then-read intuition.
|
|
577
|
+
const existing = fields.get(field)
|
|
578
|
+
if (existing && existing.at > meta.at) continue
|
|
579
|
+
if (value === null) {
|
|
580
|
+
fields.delete(field)
|
|
581
|
+
} else {
|
|
582
|
+
fields.set(field, {
|
|
583
|
+
value: structuredClone(value),
|
|
584
|
+
seen: meta.seen,
|
|
585
|
+
at: new Date(meta.at),
|
|
586
|
+
expiresAt: new Date(expiresAtMs),
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
applied[field] = structuredClone(value)
|
|
590
|
+
appliedCount += 1
|
|
591
|
+
}
|
|
592
|
+
if (fields.size === 0) participants.delete(participant)
|
|
593
|
+
if (participants.size === 0) presenceRows.delete(ns)
|
|
594
|
+
|
|
595
|
+
// Only applied fields broadcast — a losing write repaints nothing,
|
|
596
|
+
// so subscribers stay consistent with what read() returns.
|
|
597
|
+
if (appliedCount === 0) return
|
|
598
|
+
const subs = presenceSubscribers.get(ns)
|
|
599
|
+
if (!subs) return
|
|
600
|
+
const patch: PresencePatch = {
|
|
601
|
+
participant,
|
|
602
|
+
values: applied,
|
|
603
|
+
seen: meta.seen,
|
|
604
|
+
at: new Date(meta.at),
|
|
605
|
+
}
|
|
606
|
+
for (const listener of subs) listener(patch)
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
async read(ns) {
|
|
610
|
+
const participants = presenceRows.get(ns)
|
|
611
|
+
if (!participants) return []
|
|
612
|
+
const now = clock.now()
|
|
613
|
+
const rows: PresenceRow[] = []
|
|
614
|
+
for (const [participant, fields] of participants) {
|
|
615
|
+
for (const [field, entry] of fields) {
|
|
616
|
+
// Expiry is enforced lazily on read — no timers; a silent
|
|
617
|
+
// participant's rows vanish the next time anyone looks.
|
|
618
|
+
if (entry.expiresAt <= now) {
|
|
619
|
+
fields.delete(field)
|
|
620
|
+
continue
|
|
621
|
+
}
|
|
622
|
+
rows.push({
|
|
623
|
+
participant,
|
|
624
|
+
field,
|
|
625
|
+
value: structuredClone(entry.value),
|
|
626
|
+
seen: entry.seen,
|
|
627
|
+
at: new Date(entry.at),
|
|
628
|
+
expiresAt: new Date(entry.expiresAt),
|
|
629
|
+
})
|
|
630
|
+
}
|
|
631
|
+
if (fields.size === 0) participants.delete(participant)
|
|
632
|
+
}
|
|
633
|
+
if (participants.size === 0) presenceRows.delete(ns)
|
|
634
|
+
return rows
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
subscribe(ns, onPatch) {
|
|
638
|
+
let subs = presenceSubscribers.get(ns)
|
|
639
|
+
if (!subs) {
|
|
640
|
+
subs = new Set()
|
|
641
|
+
presenceSubscribers.set(ns, subs)
|
|
642
|
+
}
|
|
643
|
+
subs.add(onPatch)
|
|
644
|
+
return () => {
|
|
645
|
+
subs.delete(onPatch)
|
|
646
|
+
if (subs.size === 0) presenceSubscribers.delete(ns)
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
inspect: {
|
|
652
|
+
async listSessions(inspectionOptions) {
|
|
653
|
+
const ids = [...sessions.keys()]
|
|
654
|
+
.filter(
|
|
655
|
+
(sessionId) =>
|
|
656
|
+
sessionId.startsWith(inspectionOptions.prefix) &&
|
|
657
|
+
(inspectionOptions.cursor === undefined ||
|
|
658
|
+
sessionId > inspectionOptions.cursor),
|
|
659
|
+
)
|
|
660
|
+
.toSorted()
|
|
661
|
+
const visible = ids.slice(0, inspectionOptions.limit)
|
|
662
|
+
const summaries = visible.map((sessionId): StoredSessionSummary => {
|
|
663
|
+
const rows = sessions.get(sessionId)!
|
|
664
|
+
const first = rows[0]!
|
|
665
|
+
let updatedAt = first.createdAt
|
|
666
|
+
let pendingCount = 0
|
|
667
|
+
let failedCount = 0
|
|
668
|
+
let attemptCount = 0
|
|
669
|
+
let failureCount = 0
|
|
670
|
+
for (const row of rows) {
|
|
671
|
+
attemptCount += row.attemptCount
|
|
672
|
+
failureCount += row.failureCount
|
|
673
|
+
if (row.processedAt === null && row.failedAt === null) {
|
|
674
|
+
pendingCount += 1
|
|
675
|
+
}
|
|
676
|
+
if (row.failedAt !== null) failedCount += 1
|
|
677
|
+
for (const timestamp of [
|
|
678
|
+
row.createdAt,
|
|
679
|
+
row.firstClaimedAt,
|
|
680
|
+
row.lastClaimedAt,
|
|
681
|
+
row.lastFailedAt,
|
|
682
|
+
row.processedAt,
|
|
683
|
+
row.failedAt,
|
|
684
|
+
]) {
|
|
685
|
+
if (timestamp && timestamp > updatedAt) updatedAt = timestamp
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
sessionId,
|
|
690
|
+
eventCount: rows.length,
|
|
691
|
+
pendingCount,
|
|
692
|
+
failedCount,
|
|
693
|
+
attemptCount,
|
|
694
|
+
failureCount,
|
|
695
|
+
firstEventAt: new Date(first.createdAt),
|
|
696
|
+
updatedAt: new Date(updatedAt),
|
|
697
|
+
}
|
|
698
|
+
})
|
|
699
|
+
return {
|
|
700
|
+
sessions: summaries,
|
|
701
|
+
cursor: ids.length > visible.length ? (visible.at(-1) ?? null) : null,
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
async readEvents(sessionId, pageOptions) {
|
|
705
|
+
const rows = sessions.get(sessionId) ?? []
|
|
706
|
+
const throughIndex = Math.min(
|
|
707
|
+
pageOptions.throughIndex ?? rows.length,
|
|
708
|
+
rows.length,
|
|
709
|
+
)
|
|
710
|
+
return {
|
|
711
|
+
events: rows
|
|
712
|
+
.slice(pageOptions.afterIndex, throughIndex)
|
|
713
|
+
.slice(0, pageOptions.limit)
|
|
714
|
+
.map(toStored),
|
|
715
|
+
throughIndex,
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
async listSnapshots(sessionId) {
|
|
719
|
+
const prefix = `${sessionId}\u0000`
|
|
720
|
+
return [...snapshots.entries()]
|
|
721
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
722
|
+
.map(([key, snapshot]) => ({
|
|
723
|
+
reducerName: key.slice(prefix.length),
|
|
724
|
+
index: snapshot.index,
|
|
725
|
+
updatedAt: new Date(snapshot.updatedAt),
|
|
726
|
+
}))
|
|
727
|
+
.toSorted((a, b) => a.reducerName.localeCompare(b.reducerName))
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
|
|
731
|
+
stream(sessionId, opts) {
|
|
732
|
+
const startAfter = opts?.startAfter ?? 0
|
|
733
|
+
return {
|
|
734
|
+
[Symbol.asyncIterator](): AsyncIterator<Event> {
|
|
735
|
+
let last = startAfter
|
|
736
|
+
const buffer: Row[] = []
|
|
737
|
+
let wake: (() => void) | null = null
|
|
738
|
+
let closed = false
|
|
739
|
+
|
|
740
|
+
const onRow = (row: Row): void => {
|
|
741
|
+
buffer.push(row)
|
|
742
|
+
wake?.()
|
|
743
|
+
}
|
|
744
|
+
let subs = streamSubscribers.get(sessionId)
|
|
745
|
+
if (!subs) {
|
|
746
|
+
subs = new Set()
|
|
747
|
+
streamSubscribers.set(sessionId, subs)
|
|
748
|
+
}
|
|
749
|
+
subs.add(onRow)
|
|
750
|
+
// Seed with history — synchronously, in the same tick as the
|
|
751
|
+
// subscription, so nothing can slip between the two.
|
|
752
|
+
const existing = sessions.get(sessionId) ?? []
|
|
753
|
+
buffer.unshift(...existing.filter((row) => row.index > startAfter))
|
|
754
|
+
|
|
755
|
+
const unsubscribe = (): void => {
|
|
756
|
+
subs.delete(onRow)
|
|
757
|
+
if (subs.size === 0) streamSubscribers.delete(sessionId)
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
async next(): Promise<IteratorResult<Event>> {
|
|
762
|
+
for (;;) {
|
|
763
|
+
while (buffer.length > 0) {
|
|
764
|
+
const row = buffer.shift()!
|
|
765
|
+
if (row.index <= last) continue
|
|
766
|
+
last = row.index
|
|
767
|
+
return { value: toStored(row), done: false }
|
|
768
|
+
}
|
|
769
|
+
if (closed) return { value: undefined, done: true }
|
|
770
|
+
// oxlint-disable-next-line no-await-in-loop -- wait-for-wake
|
|
771
|
+
await new Promise<void>((resolve) => {
|
|
772
|
+
wake = resolve
|
|
773
|
+
})
|
|
774
|
+
wake = null
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
async return(): Promise<IteratorResult<Event>> {
|
|
778
|
+
closed = true
|
|
779
|
+
unsubscribe()
|
|
780
|
+
wake?.()
|
|
781
|
+
return { value: undefined, done: true }
|
|
782
|
+
},
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
}
|
|
788
|
+
}
|