@electric-sql/client 1.1.3 → 1.1.5

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/client.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  InvalidSignalError,
18
18
  MissingShapeHandleError,
19
19
  ReservedParamError,
20
+ MissingHeadersError,
20
21
  } from './error'
21
22
  import {
22
23
  BackoffDefaults,
@@ -368,16 +369,15 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
368
369
 
369
370
  forceDisconnectAndRefresh(): Promise<void>
370
371
 
371
- requestSnapshot(params: {
372
- where?: string
373
- params?: Record<string, string>
374
- limit: number
375
- offset?: number
376
- orderBy: string
377
- }): Promise<{
372
+ requestSnapshot(params: SubsetParams): Promise<{
378
373
  metadata: SnapshotMetadata
379
374
  data: Array<Message<T>>
380
375
  }>
376
+
377
+ fetchSnapshot(opts: SubsetParams): Promise<{
378
+ metadata: SnapshotMetadata
379
+ data: Array<ChangeMessage<T>>
380
+ }>
381
381
  }
382
382
 
383
383
  /**
@@ -489,6 +489,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
489
489
  #sseFallbackToLongPolling = false
490
490
  #sseBackoffBaseDelay = 100 // Base delay for exponential backoff (ms)
491
491
  #sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
492
+ #unsubscribeFromVisibilityChanges?: () => void
492
493
 
493
494
  constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
494
495
  this.options = { subscribe: true, ...options }
@@ -656,9 +657,17 @@ export class ShapeStream<T extends Row<unknown> = Row>
656
657
  }
657
658
 
658
659
  if (e instanceof FetchBackoffAbortError) {
660
+ // Check current state - it may have changed due to concurrent pause/resume calls
661
+ // from the visibility change handler during the async fetch operation.
662
+ // TypeScript's flow analysis doesn't account for concurrent state changes.
663
+ const currentState = this.#state as
664
+ | `active`
665
+ | `pause-requested`
666
+ | `paused`
659
667
  if (
660
668
  requestAbortController.signal.aborted &&
661
- requestAbortController.signal.reason === PAUSE_STREAM
669
+ requestAbortController.signal.reason === PAUSE_STREAM &&
670
+ currentState === `pause-requested`
662
671
  ) {
663
672
  this.#state = `paused`
664
673
  }
@@ -765,7 +774,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
765
774
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
766
775
  fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, this.#mode)
767
776
 
768
- if (this.#isUpToDate) {
777
+ // Snapshot requests (with subsetParams) should never use live polling
778
+ const isSnapshotRequest = subsetParams !== undefined
779
+
780
+ if (this.#isUpToDate && !isSnapshotRequest) {
769
781
  // If we are resuming from a paused state, we don't want to perform a live request
770
782
  // because it could be a long poll that holds for 20sec
771
783
  // and during all that time `isConnected` will be false
@@ -837,11 +849,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
837
849
  this.#liveCacheBuster = liveCacheBuster
838
850
  }
839
851
 
840
- const getSchema = (): Schema => {
841
- const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
842
- return schemaHeader ? JSON.parse(schemaHeader) : {}
843
- }
844
- this.#schema = this.#schema ?? getSchema()
852
+ this.#schema = this.#schema ?? getSchemaFromHeaders(headers)
845
853
 
846
854
  // NOTE: 204s are deprecated, the Electric server should not
847
855
  // send these in latest versions but this is here for backwards
@@ -1045,7 +1053,15 @@ export class ShapeStream<T extends Row<unknown> = Row>
1045
1053
  }
1046
1054
 
1047
1055
  #resume() {
1048
- if (this.#started && this.#state === `paused`) {
1056
+ if (
1057
+ this.#started &&
1058
+ (this.#state === `paused` || this.#state === `pause-requested`)
1059
+ ) {
1060
+ // If we're resuming from pause-requested state, we need to set state back to active
1061
+ // to prevent the pause from completing
1062
+ if (this.#state === `pause-requested`) {
1063
+ this.#state = `active`
1064
+ }
1049
1065
  this.#start()
1050
1066
  }
1051
1067
  }
@@ -1066,6 +1082,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1066
1082
 
1067
1083
  unsubscribeAll(): void {
1068
1084
  this.#subscribers.clear()
1085
+ this.#unsubscribeFromVisibilityChanges?.()
1069
1086
  }
1070
1087
 
1071
1088
  /** Unix time at which we last synced. Undefined when `isLoading` is true. */
@@ -1192,6 +1209,11 @@ export class ShapeStream<T extends Row<unknown> = Row>
1192
1209
  }
1193
1210
 
1194
1211
  document.addEventListener(`visibilitychange`, visibilityHandler)
1212
+
1213
+ // Store cleanup function to remove the event listener
1214
+ this.#unsubscribeFromVisibilityChanges = () => {
1215
+ document.removeEventListener(`visibilitychange`, visibilityHandler)
1216
+ }
1195
1217
  }
1196
1218
  }
1197
1219
 
@@ -1214,7 +1236,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1214
1236
  }
1215
1237
 
1216
1238
  /**
1217
- * Request a snapshot for subset of data.
1239
+ * Request a snapshot for subset of data and inject it into the subscribed data stream.
1218
1240
  *
1219
1241
  * Only available when mode is `changes_only`.
1220
1242
  * Returns the insertion point & the data, but more importantly injects the data
@@ -1252,16 +1274,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1252
1274
  this.#pause()
1253
1275
  }
1254
1276
 
1255
- const { fetchUrl, requestHeaders } = await this.#constructUrl(
1256
- this.options.url,
1257
- true,
1258
- opts
1259
- )
1260
-
1261
- const { metadata, data } = await this.#fetchSnapshot(
1262
- fetchUrl,
1263
- requestHeaders
1264
- )
1277
+ const { metadata, data } = await this.fetchSnapshot(opts)
1265
1278
 
1266
1279
  const dataWithEndBoundary = (data as Array<Message<T>>).concat([
1267
1280
  { headers: { control: `snapshot-end`, ...metadata } },
@@ -1286,8 +1299,26 @@ export class ShapeStream<T extends Row<unknown> = Row>
1286
1299
  }
1287
1300
  }
1288
1301
 
1289
- async #fetchSnapshot(url: URL, headers: Record<string, string>) {
1290
- const response = await this.#fetchClient(url.toString(), { headers })
1302
+ /**
1303
+ * Fetch a snapshot for subset of data.
1304
+ * Returns the metadata and the data, but does not inject it into the subscribed data stream.
1305
+ *
1306
+ * @param opts - The options for the snapshot request.
1307
+ * @returns The metadata and the data for the snapshot.
1308
+ */
1309
+ async fetchSnapshot(opts: SubsetParams): Promise<{
1310
+ metadata: SnapshotMetadata
1311
+ data: Array<ChangeMessage<T>>
1312
+ }> {
1313
+ const { fetchUrl, requestHeaders } = await this.#constructUrl(
1314
+ this.options.url,
1315
+ true,
1316
+ opts
1317
+ )
1318
+
1319
+ const response = await this.#fetchClient(fetchUrl.toString(), {
1320
+ headers: requestHeaders,
1321
+ })
1291
1322
 
1292
1323
  if (!response.ok) {
1293
1324
  throw new FetchError(
@@ -1295,21 +1326,52 @@ export class ShapeStream<T extends Row<unknown> = Row>
1295
1326
  undefined,
1296
1327
  undefined,
1297
1328
  Object.fromEntries([...response.headers.entries()]),
1298
- url.toString()
1329
+ fetchUrl.toString()
1299
1330
  )
1300
1331
  }
1301
1332
 
1302
- const { metadata, data } = await response.json()
1303
- const batch = this.#messageParser.parse<Array<ChangeMessage<T>>>(
1304
- JSON.stringify(data),
1305
- this.#schema!
1333
+ // Use schema from stream if available, otherwise extract from response header
1334
+ const schema: Schema =
1335
+ this.#schema ??
1336
+ getSchemaFromHeaders(response.headers, {
1337
+ required: true,
1338
+ url: fetchUrl.toString(),
1339
+ })
1340
+
1341
+ const { metadata, data: rawData } = await response.json()
1342
+ const data = this.#messageParser.parseSnapshotData<ChangeMessage<T>>(
1343
+ rawData,
1344
+ schema
1306
1345
  )
1307
1346
 
1308
1347
  return {
1309
1348
  metadata,
1310
- data: batch,
1349
+ data,
1350
+ }
1351
+ }
1352
+ }
1353
+
1354
+ /**
1355
+ * Extracts the schema from response headers.
1356
+ * @param headers - The response headers
1357
+ * @param options - Options for schema extraction
1358
+ * @param options.required - If true, throws MissingHeadersError when header is missing. Defaults to false.
1359
+ * @param options.url - The URL to include in the error message if required is true
1360
+ * @returns The parsed schema, or an empty object if not required and header is missing
1361
+ * @throws {MissingHeadersError} if required is true and the header is missing
1362
+ */
1363
+ function getSchemaFromHeaders(
1364
+ headers: Headers,
1365
+ options?: { required?: boolean; url?: string }
1366
+ ): Schema {
1367
+ const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
1368
+ if (!schemaHeader) {
1369
+ if (options?.required && options?.url) {
1370
+ throw new MissingHeadersError(options.url, [SHAPE_SCHEMA_HEADER])
1311
1371
  }
1372
+ return {}
1312
1373
  }
1374
+ return JSON.parse(schemaHeader)
1313
1375
  }
1314
1376
 
1315
1377
  /**
package/src/parser.ts CHANGED
@@ -122,18 +122,56 @@ export class MessageParser<T extends Row<unknown>> {
122
122
  typeof value === `object` &&
123
123
  value !== null
124
124
  ) {
125
- // Parse the row values
126
- const row = value as Record<string, Value<GetExtensions<T>>>
127
- Object.keys(row).forEach((key) => {
128
- row[key] = this.parseRow(key, row[key] as NullableToken, schema)
129
- })
130
-
131
- if (this.transformer) value = this.transformer(value)
125
+ return this.transformMessageValue(value, schema)
132
126
  }
133
127
  return value
134
128
  }) as Result
135
129
  }
136
130
 
131
+ /**
132
+ * Parse an array of ChangeMessages from a snapshot response.
133
+ * Applies type parsing and transformations to the value and old_value properties.
134
+ */
135
+ parseSnapshotData<Result>(
136
+ messages: Array<unknown>,
137
+ schema: Schema
138
+ ): Array<Result> {
139
+ return messages.map((message) => {
140
+ const msg = message as Record<string, unknown>
141
+
142
+ // Transform the value property if it exists
143
+ if (msg.value && typeof msg.value === `object` && msg.value !== null) {
144
+ msg.value = this.transformMessageValue(msg.value, schema)
145
+ }
146
+
147
+ // Transform the old_value property if it exists
148
+ if (
149
+ msg.old_value &&
150
+ typeof msg.old_value === `object` &&
151
+ msg.old_value !== null
152
+ ) {
153
+ msg.old_value = this.transformMessageValue(msg.old_value, schema)
154
+ }
155
+
156
+ return msg as Result
157
+ })
158
+ }
159
+
160
+ /**
161
+ * Transform a message value or old_value object by parsing its columns.
162
+ */
163
+ private transformMessageValue(
164
+ value: unknown,
165
+ schema: Schema
166
+ ): Row<GetExtensions<T>> {
167
+ const row = value as Record<string, Value<GetExtensions<T>>>
168
+ Object.keys(row).forEach((key) => {
169
+ row[key] = this.parseRow(key, row[key] as NullableToken, schema)
170
+ })
171
+
172
+ return this.transformer ? this.transformer(row) : row
173
+ }
174
+
137
175
  // Parses the message values using the provided parser based on the schema information
138
176
  private parseRow(
139
177
  key: string,