@electric-sql/experimental 0.1.2-beta.2 → 0.1.2-beta.3

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.
@@ -0,0 +1,457 @@
1
+ import {
2
+ ShapeStream,
3
+ isChangeMessage,
4
+ isControlMessage,
5
+ } from '@electric-sql/client'
6
+ import type {
7
+ ChangeMessage,
8
+ ControlMessage,
9
+ FetchError,
10
+ MaybePromise,
11
+ Row,
12
+ ShapeStreamOptions,
13
+ } from '@electric-sql/client'
14
+
15
+ interface MultiShapeStreamOptions<
16
+ TShapeRows extends {
17
+ [K: string]: Row<unknown>
18
+ } = {
19
+ [K: string]: Row<unknown>
20
+ },
21
+ > {
22
+ shapes: {
23
+ [K in keyof TShapeRows]:
24
+ | ShapeStreamOptions<TShapeRows[K]>
25
+ | ShapeStream<TShapeRows[K]>
26
+ }
27
+ start?: boolean
28
+ checkForUpdatesAfterMs?: number // milliseconds
29
+ }
30
+
31
+ interface MultiShapeChangeMessage<
32
+ T extends Row<unknown>,
33
+ ShapeNames extends string,
34
+ > extends ChangeMessage<T> {
35
+ shape: ShapeNames
36
+ }
37
+
38
+ interface MultiShapeControlMessage<ShapeNames extends string>
39
+ extends ControlMessage {
40
+ shape: ShapeNames
41
+ }
42
+
43
+ type MultiShapeMessage<T extends Row<unknown>, ShapeNames extends string> =
44
+ | MultiShapeChangeMessage<T, ShapeNames>
45
+ | MultiShapeControlMessage<ShapeNames>
46
+
47
+ export type MultiShapeMessages<
48
+ TShapeRows extends {
49
+ [K: string]: Row<unknown>
50
+ },
51
+ > = {
52
+ [K in keyof TShapeRows & string]: MultiShapeMessage<TShapeRows[K], K>
53
+ }[keyof TShapeRows & string]
54
+
55
+ export interface MultiShapeStreamInterface<
56
+ TShapeRows extends {
57
+ [K: string]: Row<unknown>
58
+ },
59
+ > {
60
+ shapes: { [K in keyof TShapeRows]: ShapeStream<TShapeRows[K]> }
61
+ checkForUpdatesAfterMs?: number
62
+
63
+ subscribe(
64
+ callback: (
65
+ messages: MultiShapeMessages<TShapeRows>[]
66
+ ) => MaybePromise<void>,
67
+ onError?: (error: FetchError | Error) => void
68
+ ): () => void
69
+ unsubscribeAll(): void
70
+
71
+ lastSyncedAt(): number | undefined
72
+ lastSynced(): number
73
+ isConnected(): boolean
74
+ isLoading(): boolean
75
+
76
+ isUpToDate: boolean
77
+ }
78
+
79
+ /**
80
+ * A multi-shape stream is a stream that can subscribe to multiple shapes.
81
+ * It ensures that all shapes will receive at least an `up-to-date` message from
82
+ * Electric within the `checkForUpdatesAfterMs` interval.
83
+ *
84
+ * @constructor
85
+ * @param {MultiShapeStreamOptions} options - configure the multi-shape stream
86
+ * @example
87
+ * ```ts
88
+ * const multiShapeStream = new MultiShapeStream({
89
+ * shapes: {
90
+ * shape1: {
91
+ * url: 'http://localhost:3000/v1/shape1',
92
+ * },
93
+ * shape2: {
94
+ * url: 'http://localhost:3000/v1/shape2',
95
+ * },
96
+ * },
97
+ * })
98
+ *
99
+ * multiShapeStream.subscribe((msgs) => {
100
+ * console.log(msgs)
101
+ * })
102
+ *
103
+ * // or with ShapeStream instances
104
+ * const multiShapeStream = new MultiShapeStream({
105
+ * shapes: {
106
+ * shape1: new ShapeStream({ url: 'http://localhost:3000/v1/shape1' }),
107
+ * shape2: new ShapeStream({ url: 'http://localhost:3000/v1/shape2' }),
108
+ * },
109
+ * })
110
+ * ```
111
+ */
112
+
113
+ export class MultiShapeStream<
114
+ TShapeRows extends {
115
+ [K: string]: Row<unknown>
116
+ },
117
+ > implements MultiShapeStreamInterface<TShapeRows>
118
+ {
119
+ #shapes: { [K in keyof TShapeRows]: ShapeStream<TShapeRows[K]> }
120
+ #started = false
121
+ checkForUpdatesAfterMs?: number
122
+
123
+ #checkForUpdatesTimeout?: ReturnType<typeof setTimeout> | undefined
124
+
125
+ // We keep track of the last lsn of data and up-to-date messages for each shape
126
+ // so that we can skip checkForUpdates if the lsn of the up-to-date message is
127
+ // greater than the last lsn of data.
128
+ #lastDataLsns: { [K in keyof TShapeRows]: number }
129
+ #lastUpToDateLsns: { [K in keyof TShapeRows]: number }
130
+
131
+ readonly #subscribers = new Map<
132
+ number,
133
+ [
134
+ (messages: MultiShapeMessages<TShapeRows>[]) => MaybePromise<void>,
135
+ ((error: Error) => void) | undefined,
136
+ ]
137
+ >()
138
+
139
+ constructor(options: MultiShapeStreamOptions<TShapeRows>) {
140
+ const {
141
+ start = true, // By default we start the multi-shape stream
142
+ checkForUpdatesAfterMs = 100, // Force a check for updates after 100ms
143
+ shapes,
144
+ } = options
145
+ this.checkForUpdatesAfterMs = checkForUpdatesAfterMs
146
+ this.#shapes = Object.fromEntries(
147
+ Object.entries(shapes).map(([key, shape]) => [
148
+ key,
149
+ shape instanceof ShapeStream
150
+ ? shape
151
+ : new ShapeStream<TShapeRows[typeof key]>({
152
+ ...shape,
153
+ start: false,
154
+ }),
155
+ ])
156
+ ) as { [K in keyof TShapeRows]: ShapeStream<TShapeRows[K]> }
157
+ this.#lastDataLsns = Object.fromEntries(
158
+ Object.entries(shapes).map(([key]) => [key, -Infinity])
159
+ ) as { [K in keyof TShapeRows]: number }
160
+ this.#lastUpToDateLsns = Object.fromEntries(
161
+ Object.entries(shapes).map(([key]) => [key, -Infinity])
162
+ ) as { [K in keyof TShapeRows]: number }
163
+ if (start) this.#start()
164
+ }
165
+
166
+ #start() {
167
+ if (this.#started) throw new Error(`Cannot start multi-shape stream twice`)
168
+ for (const [key, shape] of this.#shapeEntries()) {
169
+ if (shape.hasStarted()) {
170
+ // The multi-shape stream needs to be started together as a whole, and so we
171
+ // have to check that a shape is not already started.
172
+ throw new Error(`Shape ${key} already started`)
173
+ }
174
+ shape.subscribe(
175
+ async (messages) => {
176
+ // Whats the max lsn of the up-to-date messages?
177
+ const upToDateLsns = messages
178
+ .filter(isControlMessage)
179
+ .map(({ headers }) => (headers.global_last_seen_lsn as number) ?? 0)
180
+ if (upToDateLsns.length > 0) {
181
+ const maxUpToDateLsn = Math.max(...upToDateLsns)
182
+ const lastMaxUpToDateLsn = this.#lastUpToDateLsns[key]
183
+ if (maxUpToDateLsn > lastMaxUpToDateLsn) {
184
+ this.#lastUpToDateLsns[key] = maxUpToDateLsn
185
+ }
186
+ }
187
+
188
+ // Whats the max lsn of the data messages?
189
+ const dataLsns = messages
190
+ .filter(isChangeMessage)
191
+ .map(({ headers }) => (headers.lsn as number) ?? 0)
192
+ if (dataLsns.length > 0) {
193
+ const maxDataLsn = Math.max(...dataLsns)
194
+ const lastMaxDataLsn = this.#lastDataLsns[key]
195
+ if (maxDataLsn > lastMaxDataLsn) {
196
+ this.#lastDataLsns[key] = maxDataLsn
197
+ }
198
+ // There is new data, so we need to schedule a check for updates on
199
+ // other shapes
200
+ this.#scheduleCheckForUpdates()
201
+ }
202
+
203
+ // Publish the messages to the multi-shape stream subscribers
204
+ const multiShapeMessages = messages.map(
205
+ (message) =>
206
+ ({
207
+ ...message,
208
+ shape: key,
209
+ }) as MultiShapeMessages<TShapeRows>
210
+ )
211
+ await this._publish(multiShapeMessages)
212
+ },
213
+ (error) => this.#onError(error)
214
+ )
215
+ }
216
+ this.#started = true
217
+ }
218
+
219
+ #scheduleCheckForUpdates() {
220
+ this.#checkForUpdatesTimeout ??= setTimeout(() => {
221
+ this.#checkForUpdates()
222
+ this.#checkForUpdatesTimeout = undefined
223
+ }, this.checkForUpdatesAfterMs)
224
+ }
225
+
226
+ async #checkForUpdates() {
227
+ const maxDataLsn = Math.max(...Object.values(this.#lastDataLsns))
228
+ const refreshPromises = this.#shapeEntries()
229
+ .filter(([key]) => {
230
+ // We only need to refresh shapes that have not seen an up-to-date message
231
+ // lower than the max lsn of the data messages we have received.
232
+ const lastUpToDateLsn = this.#lastUpToDateLsns[key]
233
+ return lastUpToDateLsn < maxDataLsn
234
+ })
235
+ .map(([_, shape]) => {
236
+ return shape.forceDisconnectAndRefresh()
237
+ })
238
+ await Promise.all(refreshPromises)
239
+ }
240
+
241
+ #onError(error: Error) {
242
+ // TODO: we probably want to disconnect all shapes here on the first error
243
+ this.#subscribers.forEach(([_, errorFn]) => {
244
+ errorFn?.(error)
245
+ })
246
+ }
247
+
248
+ protected async _publish(
249
+ messages: MultiShapeMessages<TShapeRows>[]
250
+ ): Promise<void> {
251
+ await Promise.all(
252
+ Array.from(this.#subscribers.values()).map(async ([callback, __]) => {
253
+ try {
254
+ await callback(messages)
255
+ } catch (err) {
256
+ queueMicrotask(() => {
257
+ throw err
258
+ })
259
+ }
260
+ })
261
+ )
262
+ }
263
+
264
+ /**
265
+ * Returns an array of the shape entries.
266
+ * Ensures that the shape entries are typed, as `Object.entries`
267
+ * will not type the entries correctly.
268
+ */
269
+ #shapeEntries() {
270
+ return Object.entries(this.#shapes) as [
271
+ keyof TShapeRows & string,
272
+ ShapeStream<TShapeRows[string]>,
273
+ ][]
274
+ }
275
+
276
+ /**
277
+ * The ShapeStreams that are being subscribed to.
278
+ */
279
+ get shapes() {
280
+ return this.#shapes
281
+ }
282
+
283
+ subscribe(
284
+ callback: (
285
+ messages: MultiShapeMessages<TShapeRows>[]
286
+ ) => MaybePromise<void>,
287
+ onError?: (error: FetchError | Error) => void
288
+ ) {
289
+ const subscriptionId = Math.random()
290
+
291
+ this.#subscribers.set(subscriptionId, [callback, onError])
292
+ if (!this.#started) this.#start()
293
+
294
+ return () => {
295
+ this.#subscribers.delete(subscriptionId)
296
+ }
297
+ }
298
+
299
+ unsubscribeAll(): void {
300
+ this.#subscribers.clear()
301
+ }
302
+
303
+ /** Unix time at which we last synced. Undefined when `isLoading` is true. */
304
+ lastSyncedAt(): number | undefined {
305
+ // Min of all the lastSyncedAt values
306
+ return Math.min(
307
+ ...this.#shapeEntries().map(
308
+ ([_, shape]) => shape.lastSyncedAt() ?? Infinity
309
+ )
310
+ )
311
+ }
312
+
313
+ /** Time elapsed since last sync (in ms). Infinity if we did not yet sync. */
314
+ lastSynced(): number {
315
+ const lastSyncedAt = this.lastSyncedAt()
316
+ if (lastSyncedAt === undefined) return Infinity
317
+ return Date.now() - lastSyncedAt
318
+ }
319
+
320
+ /** Indicates if we are connected to the Electric sync service. */
321
+ isConnected(): boolean {
322
+ return this.#shapeEntries().every(([_, shape]) => shape.isConnected())
323
+ }
324
+
325
+ /** True during initial fetch. False afterwise. */
326
+ isLoading(): boolean {
327
+ return this.#shapeEntries().some(([_, shape]) => shape.isLoading())
328
+ }
329
+
330
+ get isUpToDate() {
331
+ return this.#shapeEntries().every(([_, shape]) => shape.isUpToDate)
332
+ }
333
+ }
334
+
335
+ /**
336
+ * A transactional multi-shape stream is a multi-shape stream that emits the
337
+ * messages in transactional batches, ensuring that all shapes will receive
338
+ * at least an `up-to-date` message from Electric within the `checkForUpdatesAfterMs`
339
+ * interval.
340
+ * It uses the `lsn` metadata to infer transaction boundaries, and the `op_position`
341
+ * metadata to sort the messages within a transaction.
342
+ *
343
+ * @constructor
344
+ * @param {MultiShapeStreamOptions} options - configure the multi-shape stream
345
+ * @example
346
+ * ```ts
347
+ * const transactionalMultiShapeStream = new TransactionalMultiShapeStream({
348
+ * shapes: {
349
+ * shape1: {
350
+ * url: 'http://localhost:3000/v1/shape1',
351
+ * },
352
+ * shape2: {
353
+ * url: 'http://localhost:3000/v1/shape2',
354
+ * },
355
+ * },
356
+ * })
357
+ *
358
+ * transactionalMultiShapeStream.subscribe((msgs) => {
359
+ * console.log(msgs)
360
+ * })
361
+ *
362
+ * // or with ShapeStream instances
363
+ * const transactionalMultiShapeStream = new TransactionalMultiShapeStream({
364
+ * shapes: {
365
+ * shape1: new ShapeStream({ url: 'http://localhost:3000/v1/shape1' }),
366
+ * shape2: new ShapeStream({ url: 'http://localhost:3000/v1/shape2' }),
367
+ * },
368
+ * })
369
+ * ```
370
+ */
371
+
372
+ export class TransactionalMultiShapeStream<
373
+ TShapeRows extends {
374
+ [K: string]: Row<unknown>
375
+ },
376
+ > extends MultiShapeStream<TShapeRows> {
377
+ #changeMessages = new Map<number, MultiShapeMessage<Row<unknown>, string>[]>()
378
+ #completeLsns: {
379
+ [K in keyof TShapeRows]: number
380
+ }
381
+
382
+ constructor(options: MultiShapeStreamOptions<TShapeRows>) {
383
+ super(options)
384
+ this.#completeLsns = Object.fromEntries(
385
+ Object.entries(options.shapes).map(([key]) => [key, -Infinity])
386
+ ) as { [K in keyof TShapeRows]: number }
387
+ }
388
+
389
+ #getLowestCompleteLsn() {
390
+ return Math.min(...Object.values(this.#completeLsns))
391
+ }
392
+
393
+ protected async _publish(
394
+ messages: MultiShapeMessages<TShapeRows>[]
395
+ ): Promise<void> {
396
+ this.#accumulate(messages)
397
+ const lowestCompleteLsn = this.#getLowestCompleteLsn()
398
+ const lsnsToPublish = [...this.#changeMessages.keys()].filter(
399
+ (lsn) => lsn <= lowestCompleteLsn
400
+ )
401
+ const messagesToPublish = lsnsToPublish
402
+ .sort((a, b) => a - b)
403
+ .map((lsn) =>
404
+ this.#changeMessages.get(lsn)?.sort((a, b) => {
405
+ const { headers: aHeaders } = a
406
+ const { headers: bHeaders } = b
407
+ if (
408
+ typeof aHeaders.op_position !== `number` ||
409
+ typeof bHeaders.op_position !== `number`
410
+ ) {
411
+ return 0 // op_position is not present on the snapshot message
412
+ }
413
+ return aHeaders.op_position - bHeaders.op_position
414
+ })
415
+ )
416
+ .filter((messages) => messages !== undefined)
417
+ .flat() as MultiShapeMessages<TShapeRows>[]
418
+ lsnsToPublish.forEach((lsn) => {
419
+ this.#changeMessages.delete(lsn)
420
+ })
421
+ if (messagesToPublish.length > 0) {
422
+ await super._publish(messagesToPublish)
423
+ }
424
+ }
425
+
426
+ #accumulate(messages: MultiShapeMessages<TShapeRows>[]) {
427
+ const isUpToDate = this.isUpToDate
428
+ messages.forEach((message) => {
429
+ const { shape, headers } = message
430
+ if (isChangeMessage(message)) {
431
+ // The snapshot message does not have an lsn, so we use 0
432
+ const lsn = typeof headers.lsn === `number` ? headers.lsn : 0
433
+ if (!this.#changeMessages.has(lsn)) {
434
+ this.#changeMessages.set(lsn, [])
435
+ }
436
+ this.#changeMessages.get(lsn)?.push(message)
437
+ if (
438
+ isUpToDate && // All shapes must be up to date
439
+ typeof headers.last === `boolean` &&
440
+ headers.last === true
441
+ ) {
442
+ this.#completeLsns[shape] = Math.max(this.#completeLsns[shape], lsn)
443
+ }
444
+ } else if (isControlMessage(message)) {
445
+ if (headers.control === `up-to-date`) {
446
+ if (typeof headers.global_last_seen_lsn !== `number`) {
447
+ throw new Error(`global_last_seen_lsn is not a number`)
448
+ }
449
+ this.#completeLsns[shape] = Math.max(
450
+ this.#completeLsns[shape],
451
+ headers.global_last_seen_lsn
452
+ )
453
+ }
454
+ }
455
+ })
456
+ }
457
+ }