@electric-sql/client 1.1.4 → 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@electric-sql/client",
3
3
  "description": "Postgres everywhere - your data, in sync, wherever you need it.",
4
- "version": "1.1.4",
4
+ "version": "1.1.5",
5
5
  "author": "ElectricSQL team and contributors.",
6
6
  "bugs": {
7
7
  "url": "https://github.com/electric-sql/electric/issues"
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
  /**
@@ -774,7 +774,10 @@ export class ShapeStream<T extends Row<unknown> = Row>
774
774
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
775
775
  fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, this.#mode)
776
776
 
777
- if (this.#isUpToDate) {
777
+ // Snapshot requests (with subsetParams) should never use live polling
778
+ const isSnapshotRequest = subsetParams !== undefined
779
+
780
+ if (this.#isUpToDate && !isSnapshotRequest) {
778
781
  // If we are resuming from a paused state, we don't want to perform a live request
779
782
  // because it could be a long poll that holds for 20sec
780
783
  // and during all that time `isConnected` will be false
@@ -846,11 +849,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
846
849
  this.#liveCacheBuster = liveCacheBuster
847
850
  }
848
851
 
849
- const getSchema = (): Schema => {
850
- const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
851
- return schemaHeader ? JSON.parse(schemaHeader) : {}
852
- }
853
- this.#schema = this.#schema ?? getSchema()
852
+ this.#schema = this.#schema ?? getSchemaFromHeaders(headers)
854
853
 
855
854
  // NOTE: 204s are deprecated, the Electric server should not
856
855
  // send these in latest versions but this is here for backwards
@@ -1237,7 +1236,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1237
1236
  }
1238
1237
 
1239
1238
  /**
1240
- * Request a snapshot for subset of data.
1239
+ * Request a snapshot for subset of data and inject it into the subscribed data stream.
1241
1240
  *
1242
1241
  * Only available when mode is `changes_only`.
1243
1242
  * Returns the insertion point & the data, but more importantly injects the data
@@ -1275,16 +1274,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
1275
1274
  this.#pause()
1276
1275
  }
1277
1276
 
1278
- const { fetchUrl, requestHeaders } = await this.#constructUrl(
1279
- this.options.url,
1280
- true,
1281
- opts
1282
- )
1283
-
1284
- const { metadata, data } = await this.#fetchSnapshot(
1285
- fetchUrl,
1286
- requestHeaders
1287
- )
1277
+ const { metadata, data } = await this.fetchSnapshot(opts)
1288
1278
 
1289
1279
  const dataWithEndBoundary = (data as Array<Message<T>>).concat([
1290
1280
  { headers: { control: `snapshot-end`, ...metadata } },
@@ -1309,8 +1299,26 @@ export class ShapeStream<T extends Row<unknown> = Row>
1309
1299
  }
1310
1300
  }
1311
1301
 
1312
- async #fetchSnapshot(url: URL, headers: Record<string, string>) {
1313
- 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
+ })
1314
1322
 
1315
1323
  if (!response.ok) {
1316
1324
  throw new FetchError(
@@ -1318,21 +1326,52 @@ export class ShapeStream<T extends Row<unknown> = Row>
1318
1326
  undefined,
1319
1327
  undefined,
1320
1328
  Object.fromEntries([...response.headers.entries()]),
1321
- url.toString()
1329
+ fetchUrl.toString()
1322
1330
  )
1323
1331
  }
1324
1332
 
1325
- const { metadata, data } = await response.json()
1326
- const batch = this.#messageParser.parse<Array<ChangeMessage<T>>>(
1327
- JSON.stringify(data),
1328
- 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
1329
1345
  )
1330
1346
 
1331
1347
  return {
1332
1348
  metadata,
1333
- 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])
1334
1371
  }
1372
+ return {}
1335
1373
  }
1374
+ return JSON.parse(schemaHeader)
1336
1375
  }
1337
1376
 
1338
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,