@electric-sql/y-electric 0.1.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/src/types.ts ADDED
@@ -0,0 +1,115 @@
1
+ import {
2
+ GetExtensions,
3
+ Offset,
4
+ Row,
5
+ ShapeStreamOptions,
6
+ } from '@electric-sql/client'
7
+ import * as decoding from 'lib0/decoding'
8
+ import * as awarenessProtocol from 'y-protocols/awareness'
9
+ import * as Y from 'yjs'
10
+
11
+ export type ConnectivityStatus = `connected` | `disconnected` | `connecting`
12
+
13
+ /**
14
+ * A function that handles send errors.
15
+ * @param response The http response from the server if the server returned a response.
16
+ * @param error An exception raised by the fetch client if the server did not return a response.
17
+ * @returns A promise that resolves to true if the send request should be retried.
18
+ */
19
+ export type SendErrorRetryHandler = ({
20
+ response,
21
+ error,
22
+ }: {
23
+ response?: Response
24
+ error?: unknown
25
+ }) => Promise<boolean>
26
+
27
+ /**
28
+ * The Observable interface for the YElectric provider.
29
+ *
30
+ * @event resumeState emitted when the provider sends or receives an update. This is mainly consumed by ResumeStateProvider to persist the resume state.
31
+ * @event sync Emitted when the provider receives an up-to-date control message from the server, meaning that the client caught up with latest changes from the server.
32
+ * @event synced same as @event sync.
33
+ * @event status Emitted when the provider's connectivity status changes.
34
+ * @event "connection-close" Emitted when the client disconnects from the server, by unsubscribing from shapes.
35
+ */
36
+ export type YProvider = {
37
+ resumeState: (resumeState: ResumeState) => void
38
+ sync: (state: boolean) => void
39
+ synced: (state: boolean) => void
40
+ status: (status: {
41
+ status: `connecting` | `connected` | `disconnected`
42
+ }) => void
43
+ // eslint-disable-next-line quotes
44
+ 'connection-close': () => void
45
+ }
46
+
47
+ /**
48
+ * The Observable interface for a ResumeStateProvider
49
+ * A resume state provider is used to persist the sync state of a document
50
+ * This is composed of:
51
+ * - The document shape offset and handle
52
+ * - The awareness shape offset and handle (optional)
53
+ * - The state vector of the document synced to the server (optional)
54
+ */
55
+ export type ElectricResumeStateProvider = {
56
+ synced: (state: ResumeState) => void
57
+ }
58
+
59
+ /**
60
+ * Options for the ElectricProvider.
61
+ *
62
+ * @template RowWithDocumentUpdate The type of the row that contains the document update.
63
+ * @template RowWithAwarenessUpdate (optional) The type of the row that contains the awareness update.
64
+ * @param documentUpdates Options for the document updates.
65
+ * @param documentUpdates.shape Options for the document updates shape.
66
+ * @param documentUpdates.sendUrl The URL to send the document updates to.
67
+ * @param documentUpdates.getUpdateFromRow A function that returns the update column from the row.
68
+ * @param documentUpdates.sendErrorRetryHandler (optional) A function that handles send errors.
69
+ * @param awarenessUpdates (optional) Options for the awareness updates.
70
+ * @param awarenessUpdates.shape Options for the awareness updates shape.
71
+ * @param awarenessUpdates.sendUrl The URL to send the awareness updates to.
72
+ * @param awarenessUpdates.getUpdateFromRow A function that returns the update column from the row.
73
+ * @param awarenessUpdates.sendErrorRetryHandler (optional) A function that handles send errors.
74
+ * @param resumeState (optional) The resume state to use for the provider. If no resume state the provider will fetch the entire shape.
75
+ * @param connect (optional) Whether to automatically connect upon initialization.
76
+ * @param fetchClient (optional) Custom fetch implementation to use for send requests.
77
+ */
78
+ export type ElectricProviderOptions<
79
+ RowWithDocumentUpdate extends Row<decoding.Decoder>,
80
+ RowWithAwarenessUpdate extends Row<decoding.Decoder> = never,
81
+ > = {
82
+ doc: Y.Doc
83
+ documentUpdates: {
84
+ shape: ShapeStreamOptions<GetExtensions<RowWithDocumentUpdate>>
85
+ sendUrl: string | URL
86
+ getUpdateFromRow: (row: RowWithDocumentUpdate) => decoding.Decoder
87
+ sendErrorRetryHandler?: SendErrorRetryHandler
88
+ }
89
+ awarenessUpdates?: {
90
+ shape: ShapeStreamOptions<GetExtensions<RowWithAwarenessUpdate>>
91
+ sendUrl: string | URL
92
+ protocol: awarenessProtocol.Awareness
93
+ getUpdateFromRow: (row: RowWithAwarenessUpdate) => decoding.Decoder
94
+ sendErrorRetryHandler?: SendErrorRetryHandler
95
+ }
96
+ resumeState?: ResumeState
97
+ connect?: boolean
98
+ fetchClient?: typeof fetch
99
+ }
100
+
101
+ export type ResumeState = {
102
+ document?: {
103
+ offset: Offset
104
+ handle: string
105
+ }
106
+ awareness?: {
107
+ offset: Offset
108
+ handle: string
109
+ }
110
+
111
+ // The vector of the document at the time of the last sync.
112
+ // When the provider starts, it batches the diff between this
113
+ // vector and the current state of the document to send upstream.
114
+ stableStateVector?: Uint8Array
115
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,23 @@
1
+ import * as decoding from 'lib0/decoding'
2
+
3
+ /**
4
+ * Convert a hex string from PostgreSQL's bytea format to a Uint8Array
5
+ */
6
+ const hexStringToUint8Array = (hexString: string) => {
7
+ const cleanHexString = hexString.startsWith(`\\x`)
8
+ ? hexString.slice(2)
9
+ : hexString
10
+ return new Uint8Array(
11
+ cleanHexString.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))
12
+ )
13
+ }
14
+
15
+ /**
16
+ * Utility to parse hex string bytea data to a decoder for YJS operations
17
+ */
18
+ export const parseToDecoder = {
19
+ bytea: (hexString: string) => {
20
+ const uint8Array = hexStringToUint8Array(hexString)
21
+ return decoding.createDecoder(uint8Array)
22
+ },
23
+ }
@@ -0,0 +1,465 @@
1
+ import * as encoding from 'lib0/encoding'
2
+ import * as decoding from 'lib0/decoding'
3
+ import * as awarenessProtocol from 'y-protocols/awareness'
4
+ import { ObservableV2 } from 'lib0/observable'
5
+ import * as env from 'lib0/environment'
6
+ import * as Y from 'yjs'
7
+ import {
8
+ GetExtensions,
9
+ isChangeMessage,
10
+ isControlMessage,
11
+ Message,
12
+ Offset,
13
+ Row,
14
+ ShapeStream,
15
+ ShapeStreamOptions,
16
+ } from '@electric-sql/client'
17
+ import {
18
+ YProvider,
19
+ ResumeState,
20
+ SendErrorRetryHandler,
21
+ ElectricProviderOptions,
22
+ } from './types'
23
+
24
+ type AwarenessUpdate = {
25
+ added: number[]
26
+ updated: number[]
27
+ removed: number[]
28
+ }
29
+
30
+ export class ElectricProvider<
31
+ RowWithDocumentUpdate extends Row<decoding.Decoder> = never,
32
+ RowWithAwarenessUpdate extends Row<decoding.Decoder> = never,
33
+ > extends ObservableV2<YProvider> {
34
+ private doc: Y.Doc
35
+
36
+ private documentUpdates: {
37
+ shape: ShapeStreamOptions<GetExtensions<RowWithDocumentUpdate>>
38
+ sendUrl: string | URL
39
+ getUpdateFromRow: (row: RowWithDocumentUpdate) => decoding.Decoder
40
+ sendErrorRetryHandler?: SendErrorRetryHandler
41
+ }
42
+
43
+ private awarenessUpdates?: {
44
+ shape: ShapeStreamOptions<GetExtensions<RowWithAwarenessUpdate>>
45
+ sendUrl: string | URL
46
+ protocol: awarenessProtocol.Awareness
47
+ getUpdateFromRow: (row: RowWithAwarenessUpdate) => decoding.Decoder
48
+ sendErrorRetryHandler?: SendErrorRetryHandler
49
+ }
50
+
51
+ private _connected: boolean = false
52
+ private _synced: boolean = false
53
+
54
+ private resumeState: ResumeState
55
+ private sendingPendingChanges: boolean = false
56
+ private pendingChanges: Uint8Array | null = null
57
+ private sendingAwarenessState: boolean = false
58
+ private pendingAwarenessUpdate: AwarenessUpdate | null = null
59
+
60
+ private documentUpdateHandler: (
61
+ update: Uint8Array,
62
+ origin: unknown,
63
+ doc: Y.Doc,
64
+ transaction: Y.Transaction
65
+ ) => void
66
+ private awarenessUpdateHandler?: (
67
+ update: AwarenessUpdate,
68
+ origin: unknown
69
+ ) => void
70
+
71
+ private exitHandler: () => void
72
+ private unsubscribeShapes?: () => void
73
+
74
+ private fetchClient?: typeof fetch
75
+
76
+ /**
77
+ * Creates a new ElectricProvider instance that connects YJS documents to Electric SQL.
78
+ *
79
+ * @constructor
80
+ * @param {ElectricProviderOptions} options - Configuration options for the provider
81
+ * @param {Y.Doc} options.doc - The YJS document to be synchronized
82
+ * @param {Object} options.documentUpdates - Document updates configuration
83
+ * @param {ShapeStreamOptions} options.documentUpdates.shape - Options for the document updates shape stream
84
+ * @param {string|URL} options.documentUpdates.sendUrl - URL endpoint for sending document updates
85
+ * @param {Function} options.documentUpdates.getUpdateFromRow - Function to extract document update from row
86
+ * @param {SendErrorRetryHandler} [options.documentUpdates.sendErrorRetryHandler] - Error handler for retrying document updates
87
+ * @param {Object} [options.awarenessUpdates] - Awareness updates configuration (optional)
88
+ * @param {ShapeStreamOptions} options.awarenessUpdates.shape - Options for the awareness updates shape stream
89
+ * @param {string|URL} options.awarenessUpdates.sendUrl - URL endpoint for sending awareness updates
90
+ * @param {awarenessProtocol.Awareness} options.awarenessUpdates.protocol - Awareness protocol instance
91
+ * @param {Function} options.awarenessUpdates.getUpdateFromRow - Function to extract awareness update from row
92
+ * @param {SendErrorRetryHandler} [options.awarenessUpdates.sendErrorRetryHandler] - Error handler for retrying awareness updates
93
+ * @param {ResumeState} [options.resumeState] - Resume state for the provider
94
+ * @param {boolean} [options.connect=true] - Whether to automatically connect upon initialization
95
+ * @param {typeof fetch} [options.fetchClient] - Custom fetch implementation to use for HTTP requests
96
+ */
97
+ constructor({
98
+ doc,
99
+ documentUpdates: documentUpdatesConfig,
100
+ awarenessUpdates: awarenessUpdatesConfig,
101
+ resumeState,
102
+ connect = true,
103
+ fetchClient,
104
+ }: ElectricProviderOptions<RowWithDocumentUpdate, RowWithAwarenessUpdate>) {
105
+ super()
106
+
107
+ this.doc = doc
108
+ this.documentUpdates = documentUpdatesConfig
109
+ this.awarenessUpdates = awarenessUpdatesConfig
110
+ this.resumeState = resumeState ?? {}
111
+
112
+ this.fetchClient = fetchClient
113
+
114
+ this.exitHandler = () => {
115
+ if (env.isNode && typeof process !== `undefined`) {
116
+ process.on(`exit`, this.destroy.bind(this))
117
+ }
118
+ }
119
+
120
+ this.documentUpdateHandler = this.doc.on(
121
+ `update`,
122
+ this.applyDocumentUpdate.bind(this)
123
+ )
124
+ if (this.awarenessUpdates) {
125
+ this.awarenessUpdateHandler = this.applyAwarenessUpdate.bind(this)
126
+ this.awarenessUpdates.protocol.on(`update`, this.awarenessUpdateHandler!)
127
+ }
128
+
129
+ // enqueue unsynced changes from document if the
130
+ // resume state provides the document state vector
131
+ if (this.resumeState?.stableStateVector) {
132
+ this.pendingChanges = Y.encodeStateAsUpdate(
133
+ this.doc,
134
+ this.resumeState.stableStateVector
135
+ )
136
+ }
137
+
138
+ if (connect) {
139
+ this.connect()
140
+ }
141
+ }
142
+
143
+ get synced() {
144
+ return this._synced
145
+ }
146
+
147
+ set synced(state) {
148
+ if (this._synced !== state) {
149
+ this._synced = state
150
+ this.emit(`synced`, [state])
151
+ this.emit(`sync`, [state])
152
+ }
153
+ }
154
+
155
+ set connected(state) {
156
+ if (this._connected !== state) {
157
+ this._connected = state
158
+ if (state) {
159
+ this.sendOperations()
160
+ }
161
+ this.emit(`status`, [{ status: state ? `connected` : `disconnected` }])
162
+ }
163
+ }
164
+
165
+ get connected() {
166
+ return this._connected
167
+ }
168
+
169
+ private batch(update: Uint8Array) {
170
+ if (this.pendingChanges) {
171
+ this.pendingChanges = Y.mergeUpdates([this.pendingChanges, update])
172
+ } else {
173
+ this.pendingChanges = update
174
+ }
175
+ }
176
+
177
+ destroy() {
178
+ this.disconnect()
179
+
180
+ this.doc.off(`update`, this.documentUpdateHandler)
181
+ this.awarenessUpdates?.protocol.off(`update`, this.awarenessUpdateHandler!)
182
+
183
+ if (env.isNode && typeof process !== `undefined`) {
184
+ process.off(`exit`, this.exitHandler!)
185
+ }
186
+ super.destroy()
187
+ }
188
+
189
+ disconnect() {
190
+ this.unsubscribeShapes?.()
191
+
192
+ if (!this.connected) {
193
+ return
194
+ }
195
+
196
+ if (this.awarenessUpdates) {
197
+ awarenessProtocol.removeAwarenessStates(
198
+ this.awarenessUpdates.protocol,
199
+ Array.from(this.awarenessUpdates.protocol.getStates().keys()).filter(
200
+ (client) => client !== this.awarenessUpdates!.protocol.clientID
201
+ ),
202
+ this
203
+ )
204
+
205
+ // try to notifying other clients that we are disconnecting
206
+ awarenessProtocol.removeAwarenessStates(
207
+ this.awarenessUpdates.protocol,
208
+ [this.awarenessUpdates.protocol.clientID],
209
+ `local`
210
+ )
211
+
212
+ this.awarenessUpdates.protocol.setLocalState({})
213
+ }
214
+
215
+ // TODO: await for events before closing
216
+ this.emit(`connection-close`, [])
217
+
218
+ this.pendingAwarenessUpdate = null
219
+
220
+ this.connected = false
221
+ this.synced = false
222
+ }
223
+
224
+ connect() {
225
+ if (this.connected) {
226
+ return
227
+ }
228
+ const abortController = new AbortController()
229
+
230
+ const operationsStream = new ShapeStream<RowWithDocumentUpdate>({
231
+ ...this.documentUpdates.shape,
232
+ ...this.resumeState.document,
233
+ signal: abortController.signal,
234
+ })
235
+
236
+ const operationsShapeUnsubscribe = operationsStream.subscribe(
237
+ (messages) => {
238
+ this.operationsShapeHandler(
239
+ messages,
240
+ operationsStream.lastOffset,
241
+ operationsStream.shapeHandle!
242
+ )
243
+ }
244
+ )
245
+
246
+ let awarenessShapeUnsubscribe: () => void | undefined
247
+ if (this.awarenessUpdates) {
248
+ const awarenessStream = new ShapeStream<RowWithAwarenessUpdate>({
249
+ ...this.awarenessUpdates.shape,
250
+ ...this.resumeState.awareness,
251
+ signal: abortController.signal,
252
+ })
253
+
254
+ awarenessShapeUnsubscribe = awarenessStream.subscribe((messages) => {
255
+ this.awarenessShapeHandler(
256
+ messages,
257
+ awarenessStream.lastOffset,
258
+ awarenessStream.shapeHandle!
259
+ )
260
+ })
261
+ }
262
+
263
+ this.unsubscribeShapes = () => {
264
+ abortController.abort()
265
+ operationsShapeUnsubscribe()
266
+ awarenessShapeUnsubscribe?.()
267
+ this.unsubscribeShapes = undefined
268
+ }
269
+
270
+ this.emit(`status`, [{ status: `connecting` }])
271
+ }
272
+
273
+ private operationsShapeHandler(
274
+ messages: Message<RowWithDocumentUpdate>[],
275
+ offset: Offset,
276
+ handle: string
277
+ ) {
278
+ for (const message of messages) {
279
+ if (isChangeMessage(message)) {
280
+ const decoder = this.documentUpdates.getUpdateFromRow(message.value)
281
+ while (decoder.pos !== decoder.arr.length) {
282
+ const operation = decoding.readVarUint8Array(decoder)
283
+ Y.applyUpdate(this.doc, operation, `server`)
284
+ }
285
+ } else if (
286
+ isControlMessage(message) &&
287
+ message.headers.control === `up-to-date`
288
+ ) {
289
+ this.resumeState.document = {
290
+ offset,
291
+ handle,
292
+ }
293
+
294
+ if (!this.sendingPendingChanges) {
295
+ this.synced = true
296
+ this.resumeState.stableStateVector = Y.encodeStateVector(this.doc)
297
+ }
298
+ this.emit(`resumeState`, [this.resumeState])
299
+ this.connected = true
300
+ }
301
+ }
302
+ }
303
+
304
+ // TODO: add an optional throttler that batches updates
305
+ // before pushing to the server
306
+ private async applyDocumentUpdate(update: Uint8Array, origin: unknown) {
307
+ // don't re-send updates from electric
308
+ if (origin === `server`) {
309
+ return
310
+ }
311
+
312
+ this.batch(update)
313
+ this.sendOperations()
314
+ }
315
+
316
+ private async sendOperations() {
317
+ if (!this.connected || this.sendingPendingChanges) {
318
+ return
319
+ }
320
+
321
+ try {
322
+ this.sendingPendingChanges = true
323
+ while (
324
+ this.pendingChanges &&
325
+ this.pendingChanges.length > 2 &&
326
+ this.connected
327
+ ) {
328
+ const sending = this.pendingChanges
329
+ this.pendingChanges = null
330
+
331
+ const encoder = encoding.createEncoder()
332
+ encoding.writeVarUint8Array(encoder, sending)
333
+
334
+ const success = await send(
335
+ encoder,
336
+ this.documentUpdates.sendUrl,
337
+ this.fetchClient ?? fetch,
338
+ this.documentUpdates.sendErrorRetryHandler
339
+ )
340
+ if (!success) {
341
+ this.batch(sending)
342
+ this.disconnect()
343
+ }
344
+ }
345
+ // no more pending changes, move stableStateVector forward
346
+ this.resumeState.stableStateVector = Y.encodeStateVector(this.doc)
347
+ this.emit(`resumeState`, [this.resumeState])
348
+ } finally {
349
+ this.sendingPendingChanges = false
350
+ }
351
+ }
352
+
353
+ private async applyAwarenessUpdate(
354
+ awarenessUpdate: AwarenessUpdate,
355
+ origin: unknown
356
+ ) {
357
+ if (origin !== `local` || !this.connected) {
358
+ return
359
+ }
360
+
361
+ this.pendingAwarenessUpdate = awarenessUpdate
362
+
363
+ if (this.sendingAwarenessState) {
364
+ return
365
+ }
366
+
367
+ this.sendingAwarenessState = true
368
+
369
+ try {
370
+ while (this.pendingAwarenessUpdate && this.connected) {
371
+ const update = this.pendingAwarenessUpdate
372
+ this.pendingAwarenessUpdate = null
373
+
374
+ const { added, updated, removed } = update
375
+ const changedClients = added.concat(updated).concat(removed)
376
+ const encoder = encoding.createEncoder()
377
+
378
+ encoding.writeVarUint8Array(
379
+ encoder,
380
+ awarenessProtocol.encodeAwarenessUpdate(
381
+ this.awarenessUpdates!.protocol,
382
+ changedClients
383
+ )
384
+ )
385
+ const success = await send(
386
+ encoder,
387
+ this.awarenessUpdates!.sendUrl,
388
+ this.fetchClient ?? fetch,
389
+ this.awarenessUpdates!.sendErrorRetryHandler
390
+ )
391
+ if (!success) {
392
+ this.disconnect()
393
+ }
394
+ }
395
+ } finally {
396
+ this.sendingAwarenessState = false
397
+ }
398
+ }
399
+
400
+ private awarenessShapeHandler(
401
+ messages: Message<RowWithAwarenessUpdate>[],
402
+ offset: Offset,
403
+ handle: string
404
+ ) {
405
+ for (const message of messages) {
406
+ if (isChangeMessage(message)) {
407
+ if (message.headers.operation === `delete`) {
408
+ awarenessProtocol.removeAwarenessStates(
409
+ this.awarenessUpdates!.protocol,
410
+ [Number(message.value.client_id)],
411
+ `remote`
412
+ )
413
+ } else {
414
+ const decoder = this.awarenessUpdates!.getUpdateFromRow(message.value)
415
+ awarenessProtocol.applyAwarenessUpdate(
416
+ this.awarenessUpdates!.protocol,
417
+ decoding.readVarUint8Array(decoder),
418
+ this
419
+ )
420
+ }
421
+ } else if (
422
+ isControlMessage(message) &&
423
+ message.headers.control === `up-to-date`
424
+ ) {
425
+ this.resumeState.awareness = {
426
+ offset: offset,
427
+ handle: handle,
428
+ }
429
+ this.emit(`resumeState`, [this.resumeState])
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ async function send(
436
+ encoder: encoding.Encoder,
437
+ endpoint: string | URL,
438
+ fetchClient: typeof fetch,
439
+ retryHandler?: SendErrorRetryHandler
440
+ ): Promise<boolean> {
441
+ let response: Response | undefined
442
+ const op = encoding.toUint8Array(encoder)
443
+
444
+ try {
445
+ response = await fetchClient(endpoint!, {
446
+ method: `PUT`,
447
+ headers: {
448
+ 'Content-Type': `application/octet-stream`,
449
+ },
450
+ body: op,
451
+ })
452
+
453
+ if (!response.ok) {
454
+ throw new Error(`Server did not return 2xx`)
455
+ }
456
+
457
+ return true
458
+ } catch (error) {
459
+ const shouldRetry = await (retryHandler?.({
460
+ response,
461
+ error,
462
+ }) ?? false)
463
+ return shouldRetry
464
+ }
465
+ }