@electric-sql/client 1.1.4 → 1.2.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/dist/cjs/index.cjs +426 -50
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/index.d.cts +229 -9
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.ts +229 -9
- package/dist/index.legacy-esm.js +419 -47
- package/dist/index.legacy-esm.js.map +1 -1
- package/dist/index.mjs +421 -49
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +254 -42
- package/src/column-mapper.ts +357 -0
- package/src/index.ts +7 -0
- package/src/parser.ts +45 -7
- package/src/up-to-date-tracker.ts +157 -0
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.
|
|
4
|
+
"version": "1.2.0",
|
|
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
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
SnapshotMetadata,
|
|
10
10
|
} from './types'
|
|
11
11
|
import { MessageParser, Parser, TransformFunction } from './parser'
|
|
12
|
+
import { ColumnMapper, encodeWhereClause } from './column-mapper'
|
|
12
13
|
import { getOffset, isUpToDateMessage, isChangeMessage } from './helpers'
|
|
13
14
|
import {
|
|
14
15
|
FetchError,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
InvalidSignalError,
|
|
18
19
|
MissingShapeHandleError,
|
|
19
20
|
ReservedParamError,
|
|
21
|
+
MissingHeadersError,
|
|
20
22
|
} from './error'
|
|
21
23
|
import {
|
|
22
24
|
BackoffDefaults,
|
|
@@ -58,6 +60,7 @@ import {
|
|
|
58
60
|
fetchEventSource,
|
|
59
61
|
} from '@microsoft/fetch-event-source'
|
|
60
62
|
import { expiredShapesCache } from './expired-shapes-cache'
|
|
63
|
+
import { upToDateTracker } from './up-to-date-tracker'
|
|
61
64
|
import { SnapshotTracker } from './snapshot-tracker'
|
|
62
65
|
|
|
63
66
|
const RESERVED_PARAMS: Set<ReservedParamKeys> = new Set([
|
|
@@ -292,8 +295,87 @@ export interface ShapeStreamOptions<T = never> {
|
|
|
292
295
|
fetchClient?: typeof fetch
|
|
293
296
|
backoffOptions?: BackoffOptions
|
|
294
297
|
parser?: Parser<T>
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Function to transform rows after parsing (e.g., for encryption, type coercion).
|
|
301
|
+
* Applied to data received from Electric.
|
|
302
|
+
*
|
|
303
|
+
* **Note**: If you're using `transformer` solely for column name transformation
|
|
304
|
+
* (e.g., snake_case → camelCase), consider using `columnMapper` instead, which
|
|
305
|
+
* provides bidirectional transformation and automatically encodes WHERE clauses.
|
|
306
|
+
*
|
|
307
|
+
* **Execution order** when both are provided:
|
|
308
|
+
* 1. `columnMapper.decode` runs first (renames columns)
|
|
309
|
+
* 2. `transformer` runs second (transforms values)
|
|
310
|
+
*
|
|
311
|
+
* @example
|
|
312
|
+
* ```typescript
|
|
313
|
+
* // For column renaming only - use columnMapper
|
|
314
|
+
* import { snakeCamelMapper } from '@electric-sql/client'
|
|
315
|
+
* const stream = new ShapeStream({ columnMapper: snakeCamelMapper() })
|
|
316
|
+
* ```
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* ```typescript
|
|
320
|
+
* // For value transformation (encryption, etc.) - use transformer
|
|
321
|
+
* const stream = new ShapeStream({
|
|
322
|
+
* transformer: (row) => ({
|
|
323
|
+
* ...row,
|
|
324
|
+
* encrypted_field: decrypt(row.encrypted_field)
|
|
325
|
+
* })
|
|
326
|
+
* })
|
|
327
|
+
* ```
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* ```typescript
|
|
331
|
+
* // Use both together
|
|
332
|
+
* const stream = new ShapeStream({
|
|
333
|
+
* columnMapper: snakeCamelMapper(), // Runs first: renames columns
|
|
334
|
+
* transformer: (row) => ({ // Runs second: transforms values
|
|
335
|
+
* ...row,
|
|
336
|
+
* encryptedData: decrypt(row.encryptedData)
|
|
337
|
+
* })
|
|
338
|
+
* })
|
|
339
|
+
* ```
|
|
340
|
+
*/
|
|
295
341
|
transformer?: TransformFunction<T>
|
|
296
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Bidirectional column name mapper for transforming between database column names
|
|
345
|
+
* (e.g., snake_case) and application column names (e.g., camelCase).
|
|
346
|
+
*
|
|
347
|
+
* The mapper handles both:
|
|
348
|
+
* - **Decoding**: Database → Application (applied to query results)
|
|
349
|
+
* - **Encoding**: Application → Database (applied to WHERE clauses)
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* ```typescript
|
|
353
|
+
* // Most common case: snake_case ↔ camelCase
|
|
354
|
+
* import { snakeCamelMapper } from '@electric-sql/client'
|
|
355
|
+
*
|
|
356
|
+
* const stream = new ShapeStream({
|
|
357
|
+
* url: 'http://localhost:3000/v1/shape',
|
|
358
|
+
* params: { table: 'todos' },
|
|
359
|
+
* columnMapper: snakeCamelMapper()
|
|
360
|
+
* })
|
|
361
|
+
* ```
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* ```typescript
|
|
365
|
+
* // Custom mapping
|
|
366
|
+
* import { createColumnMapper } from '@electric-sql/client'
|
|
367
|
+
*
|
|
368
|
+
* const stream = new ShapeStream({
|
|
369
|
+
* columnMapper: createColumnMapper({
|
|
370
|
+
* user_id: 'userId',
|
|
371
|
+
* project_id: 'projectId',
|
|
372
|
+
* created_at: 'createdAt'
|
|
373
|
+
* })
|
|
374
|
+
* })
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
columnMapper?: ColumnMapper
|
|
378
|
+
|
|
297
379
|
/**
|
|
298
380
|
* A function for handling shapestream errors.
|
|
299
381
|
*
|
|
@@ -368,16 +450,15 @@ export interface ShapeStreamInterface<T extends Row<unknown> = Row> {
|
|
|
368
450
|
|
|
369
451
|
forceDisconnectAndRefresh(): Promise<void>
|
|
370
452
|
|
|
371
|
-
requestSnapshot(params: {
|
|
372
|
-
where?: string
|
|
373
|
-
params?: Record<string, string>
|
|
374
|
-
limit: number
|
|
375
|
-
offset?: number
|
|
376
|
-
orderBy: string
|
|
377
|
-
}): Promise<{
|
|
453
|
+
requestSnapshot(params: SubsetParams): Promise<{
|
|
378
454
|
metadata: SnapshotMetadata
|
|
379
455
|
data: Array<Message<T>>
|
|
380
456
|
}>
|
|
457
|
+
|
|
458
|
+
fetchSnapshot(opts: SubsetParams): Promise<{
|
|
459
|
+
metadata: SnapshotMetadata
|
|
460
|
+
data: Array<ChangeMessage<T>>
|
|
461
|
+
}>
|
|
381
462
|
}
|
|
382
463
|
|
|
383
464
|
/**
|
|
@@ -482,6 +563,8 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
482
563
|
#activeSnapshotRequests = 0 // counter for concurrent snapshot requests
|
|
483
564
|
#midStreamPromise?: Promise<void>
|
|
484
565
|
#midStreamPromiseResolver?: () => void
|
|
566
|
+
#lastSeenCursor?: string // Last seen cursor from previous session (used to detect cached responses)
|
|
567
|
+
#currentFetchUrl?: URL // Current fetch URL for computing shape key
|
|
485
568
|
#lastSseConnectionStartTime?: number
|
|
486
569
|
#minSseConnectionDuration = 1000 // Minimum expected SSE connection duration (1 second)
|
|
487
570
|
#consecutiveShortSseConnections = 0
|
|
@@ -491,16 +574,44 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
491
574
|
#sseBackoffMaxDelay = 5000 // Maximum delay cap (ms)
|
|
492
575
|
#unsubscribeFromVisibilityChanges?: () => void
|
|
493
576
|
|
|
577
|
+
// Derived state: we're in replay mode if we have a last seen cursor
|
|
578
|
+
get #replayMode(): boolean {
|
|
579
|
+
return this.#lastSeenCursor !== undefined
|
|
580
|
+
}
|
|
581
|
+
|
|
494
582
|
constructor(options: ShapeStreamOptions<GetExtensions<T>>) {
|
|
495
583
|
this.options = { subscribe: true, ...options }
|
|
496
584
|
validateOptions(this.options)
|
|
497
585
|
this.#lastOffset = this.options.offset ?? `-1`
|
|
498
586
|
this.#liveCacheBuster = ``
|
|
499
587
|
this.#shapeHandle = this.options.handle
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
588
|
+
|
|
589
|
+
// Build transformer chain: columnMapper.decode -> transformer
|
|
590
|
+
// columnMapper transforms column names, transformer transforms values
|
|
591
|
+
let transformer: TransformFunction<GetExtensions<T>> | undefined
|
|
592
|
+
|
|
593
|
+
if (options.columnMapper) {
|
|
594
|
+
const applyColumnMapper = (
|
|
595
|
+
row: Row<GetExtensions<T>>
|
|
596
|
+
): Row<GetExtensions<T>> => {
|
|
597
|
+
const result: Record<string, unknown> = {}
|
|
598
|
+
for (const [dbKey, value] of Object.entries(row)) {
|
|
599
|
+
const appKey = options.columnMapper!.decode(dbKey)
|
|
600
|
+
result[appKey] = value
|
|
601
|
+
}
|
|
602
|
+
return result as Row<GetExtensions<T>>
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
transformer = options.transformer
|
|
606
|
+
? (row: Row<GetExtensions<T>>) =>
|
|
607
|
+
options.transformer!(applyColumnMapper(row))
|
|
608
|
+
: applyColumnMapper
|
|
609
|
+
} else {
|
|
610
|
+
transformer = options.transformer
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
this.#messageParser = new MessageParser<T>(options.parser, transformer)
|
|
614
|
+
|
|
504
615
|
this.#onError = this.options.onError
|
|
505
616
|
this.#mode = this.options.log ?? `full`
|
|
506
617
|
|
|
@@ -737,7 +848,13 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
737
848
|
// Add PostgreSQL-specific parameters
|
|
738
849
|
if (params) {
|
|
739
850
|
if (params.table) setQueryParam(fetchUrl, TABLE_QUERY_PARAM, params.table)
|
|
740
|
-
if (params.where
|
|
851
|
+
if (params.where && typeof params.where === `string`) {
|
|
852
|
+
const encodedWhere = encodeWhereClause(
|
|
853
|
+
params.where,
|
|
854
|
+
this.options.columnMapper?.encode
|
|
855
|
+
)
|
|
856
|
+
setQueryParam(fetchUrl, WHERE_QUERY_PARAM, encodedWhere)
|
|
857
|
+
}
|
|
741
858
|
if (params.columns)
|
|
742
859
|
setQueryParam(fetchUrl, COLUMNS_QUERY_PARAM, params.columns)
|
|
743
860
|
if (params.replica) setQueryParam(fetchUrl, REPLICA_PARAM, params.replica)
|
|
@@ -758,23 +875,40 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
758
875
|
}
|
|
759
876
|
|
|
760
877
|
if (subsetParams) {
|
|
761
|
-
if (subsetParams.where)
|
|
762
|
-
|
|
878
|
+
if (subsetParams.where && typeof subsetParams.where === `string`) {
|
|
879
|
+
const encodedWhere = encodeWhereClause(
|
|
880
|
+
subsetParams.where,
|
|
881
|
+
this.options.columnMapper?.encode
|
|
882
|
+
)
|
|
883
|
+
setQueryParam(fetchUrl, SUBSET_PARAM_WHERE, encodedWhere)
|
|
884
|
+
}
|
|
763
885
|
if (subsetParams.params)
|
|
764
|
-
|
|
886
|
+
// Serialize params as JSON to keep the parameter name constant for proxy configs
|
|
887
|
+
fetchUrl.searchParams.set(
|
|
888
|
+
SUBSET_PARAM_WHERE_PARAMS,
|
|
889
|
+
JSON.stringify(subsetParams.params)
|
|
890
|
+
)
|
|
765
891
|
if (subsetParams.limit)
|
|
766
892
|
setQueryParam(fetchUrl, SUBSET_PARAM_LIMIT, subsetParams.limit)
|
|
767
893
|
if (subsetParams.offset)
|
|
768
894
|
setQueryParam(fetchUrl, SUBSET_PARAM_OFFSET, subsetParams.offset)
|
|
769
|
-
if (subsetParams.orderBy)
|
|
770
|
-
|
|
895
|
+
if (subsetParams.orderBy && typeof subsetParams.orderBy === `string`) {
|
|
896
|
+
const encodedOrderBy = encodeWhereClause(
|
|
897
|
+
subsetParams.orderBy,
|
|
898
|
+
this.options.columnMapper?.encode
|
|
899
|
+
)
|
|
900
|
+
setQueryParam(fetchUrl, SUBSET_PARAM_ORDER_BY, encodedOrderBy)
|
|
901
|
+
}
|
|
771
902
|
}
|
|
772
903
|
|
|
773
904
|
// Add Electric's internal parameters
|
|
774
905
|
fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, this.#lastOffset)
|
|
775
906
|
fetchUrl.searchParams.set(LOG_MODE_QUERY_PARAM, this.#mode)
|
|
776
907
|
|
|
777
|
-
|
|
908
|
+
// Snapshot requests (with subsetParams) should never use live polling
|
|
909
|
+
const isSnapshotRequest = subsetParams !== undefined
|
|
910
|
+
|
|
911
|
+
if (this.#isUpToDate && !isSnapshotRequest) {
|
|
778
912
|
// If we are resuming from a paused state, we don't want to perform a live request
|
|
779
913
|
// because it could be a long poll that holds for 20sec
|
|
780
914
|
// and during all that time `isConnected` will be false
|
|
@@ -846,11 +980,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
846
980
|
this.#liveCacheBuster = liveCacheBuster
|
|
847
981
|
}
|
|
848
982
|
|
|
849
|
-
|
|
850
|
-
const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
|
|
851
|
-
return schemaHeader ? JSON.parse(schemaHeader) : {}
|
|
852
|
-
}
|
|
853
|
-
this.#schema = this.#schema ?? getSchema()
|
|
983
|
+
this.#schema = this.#schema ?? getSchemaFromHeaders(headers)
|
|
854
984
|
|
|
855
985
|
// NOTE: 204s are deprecated, the Electric server should not
|
|
856
986
|
// send these in latest versions but this is here for backwards
|
|
@@ -884,6 +1014,33 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
884
1014
|
this.#isMidStream = false
|
|
885
1015
|
// Resolve the promise waiting for mid-stream to end
|
|
886
1016
|
this.#midStreamPromiseResolver?.()
|
|
1017
|
+
|
|
1018
|
+
// Check if we should suppress this up-to-date notification
|
|
1019
|
+
// to prevent multiple renders from cached responses
|
|
1020
|
+
if (this.#replayMode && !isSseMessage) {
|
|
1021
|
+
// We're in replay mode (replaying cached responses during initial sync).
|
|
1022
|
+
// Check if the cursor has changed - cursors are time-based and always
|
|
1023
|
+
// increment, so a new cursor means fresh data from the server.
|
|
1024
|
+
const currentCursor = this.#liveCacheBuster
|
|
1025
|
+
|
|
1026
|
+
if (currentCursor === this.#lastSeenCursor) {
|
|
1027
|
+
// Same cursor = still replaying cached responses
|
|
1028
|
+
// Suppress this up-to-date notification
|
|
1029
|
+
return
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// We're either:
|
|
1034
|
+
// 1. Not in replay mode (normal operation), or
|
|
1035
|
+
// 2. This is a live/SSE message (always fresh), or
|
|
1036
|
+
// 3. Cursor has changed (exited replay mode with fresh data)
|
|
1037
|
+
// In all cases, notify subscribers and record the up-to-date.
|
|
1038
|
+
this.#lastSeenCursor = undefined // Exit replay mode
|
|
1039
|
+
|
|
1040
|
+
if (this.#currentFetchUrl) {
|
|
1041
|
+
const shapeKey = canonicalShapeKey(this.#currentFetchUrl)
|
|
1042
|
+
upToDateTracker.recordUpToDate(shapeKey, this.#liveCacheBuster)
|
|
1043
|
+
}
|
|
887
1044
|
}
|
|
888
1045
|
|
|
889
1046
|
// Filter messages using snapshot tracker
|
|
@@ -911,6 +1068,21 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
911
1068
|
headers: Record<string, string>
|
|
912
1069
|
resumingFromPause?: boolean
|
|
913
1070
|
}): Promise<void> {
|
|
1071
|
+
// Store current fetch URL for shape key computation
|
|
1072
|
+
this.#currentFetchUrl = opts.fetchUrl
|
|
1073
|
+
|
|
1074
|
+
// Check if we should enter replay mode (replaying cached responses)
|
|
1075
|
+
// This happens when we're starting fresh (offset=-1 or before first up-to-date)
|
|
1076
|
+
// and there's a recent up-to-date in localStorage (< 60s)
|
|
1077
|
+
if (!this.#isUpToDate && !this.#replayMode) {
|
|
1078
|
+
const shapeKey = canonicalShapeKey(opts.fetchUrl)
|
|
1079
|
+
const lastSeenCursor = upToDateTracker.shouldEnterReplayMode(shapeKey)
|
|
1080
|
+
if (lastSeenCursor) {
|
|
1081
|
+
// Enter replay mode and store the last seen cursor
|
|
1082
|
+
this.#lastSeenCursor = lastSeenCursor
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
914
1086
|
const useSse = this.options.liveSse ?? this.options.experimentalLiveSse
|
|
915
1087
|
if (
|
|
916
1088
|
this.#isUpToDate &&
|
|
@@ -1237,7 +1409,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1237
1409
|
}
|
|
1238
1410
|
|
|
1239
1411
|
/**
|
|
1240
|
-
* Request a snapshot for subset of data.
|
|
1412
|
+
* Request a snapshot for subset of data and inject it into the subscribed data stream.
|
|
1241
1413
|
*
|
|
1242
1414
|
* Only available when mode is `changes_only`.
|
|
1243
1415
|
* Returns the insertion point & the data, but more importantly injects the data
|
|
@@ -1275,16 +1447,7 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1275
1447
|
this.#pause()
|
|
1276
1448
|
}
|
|
1277
1449
|
|
|
1278
|
-
const {
|
|
1279
|
-
this.options.url,
|
|
1280
|
-
true,
|
|
1281
|
-
opts
|
|
1282
|
-
)
|
|
1283
|
-
|
|
1284
|
-
const { metadata, data } = await this.#fetchSnapshot(
|
|
1285
|
-
fetchUrl,
|
|
1286
|
-
requestHeaders
|
|
1287
|
-
)
|
|
1450
|
+
const { metadata, data } = await this.fetchSnapshot(opts)
|
|
1288
1451
|
|
|
1289
1452
|
const dataWithEndBoundary = (data as Array<Message<T>>).concat([
|
|
1290
1453
|
{ headers: { control: `snapshot-end`, ...metadata } },
|
|
@@ -1309,8 +1472,26 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1309
1472
|
}
|
|
1310
1473
|
}
|
|
1311
1474
|
|
|
1312
|
-
|
|
1313
|
-
|
|
1475
|
+
/**
|
|
1476
|
+
* Fetch a snapshot for subset of data.
|
|
1477
|
+
* Returns the metadata and the data, but does not inject it into the subscribed data stream.
|
|
1478
|
+
*
|
|
1479
|
+
* @param opts - The options for the snapshot request.
|
|
1480
|
+
* @returns The metadata and the data for the snapshot.
|
|
1481
|
+
*/
|
|
1482
|
+
async fetchSnapshot(opts: SubsetParams): Promise<{
|
|
1483
|
+
metadata: SnapshotMetadata
|
|
1484
|
+
data: Array<ChangeMessage<T>>
|
|
1485
|
+
}> {
|
|
1486
|
+
const { fetchUrl, requestHeaders } = await this.#constructUrl(
|
|
1487
|
+
this.options.url,
|
|
1488
|
+
true,
|
|
1489
|
+
opts
|
|
1490
|
+
)
|
|
1491
|
+
|
|
1492
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
1493
|
+
headers: requestHeaders,
|
|
1494
|
+
})
|
|
1314
1495
|
|
|
1315
1496
|
if (!response.ok) {
|
|
1316
1497
|
throw new FetchError(
|
|
@@ -1318,21 +1499,52 @@ export class ShapeStream<T extends Row<unknown> = Row>
|
|
|
1318
1499
|
undefined,
|
|
1319
1500
|
undefined,
|
|
1320
1501
|
Object.fromEntries([...response.headers.entries()]),
|
|
1321
|
-
|
|
1502
|
+
fetchUrl.toString()
|
|
1322
1503
|
)
|
|
1323
1504
|
}
|
|
1324
1505
|
|
|
1325
|
-
|
|
1326
|
-
const
|
|
1327
|
-
|
|
1328
|
-
|
|
1506
|
+
// Use schema from stream if available, otherwise extract from response header
|
|
1507
|
+
const schema: Schema =
|
|
1508
|
+
this.#schema ??
|
|
1509
|
+
getSchemaFromHeaders(response.headers, {
|
|
1510
|
+
required: true,
|
|
1511
|
+
url: fetchUrl.toString(),
|
|
1512
|
+
})
|
|
1513
|
+
|
|
1514
|
+
const { metadata, data: rawData } = await response.json()
|
|
1515
|
+
const data = this.#messageParser.parseSnapshotData<ChangeMessage<T>>(
|
|
1516
|
+
rawData,
|
|
1517
|
+
schema
|
|
1329
1518
|
)
|
|
1330
1519
|
|
|
1331
1520
|
return {
|
|
1332
1521
|
metadata,
|
|
1333
|
-
data
|
|
1522
|
+
data,
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Extracts the schema from response headers.
|
|
1529
|
+
* @param headers - The response headers
|
|
1530
|
+
* @param options - Options for schema extraction
|
|
1531
|
+
* @param options.required - If true, throws MissingHeadersError when header is missing. Defaults to false.
|
|
1532
|
+
* @param options.url - The URL to include in the error message if required is true
|
|
1533
|
+
* @returns The parsed schema, or an empty object if not required and header is missing
|
|
1534
|
+
* @throws {MissingHeadersError} if required is true and the header is missing
|
|
1535
|
+
*/
|
|
1536
|
+
function getSchemaFromHeaders(
|
|
1537
|
+
headers: Headers,
|
|
1538
|
+
options?: { required?: boolean; url?: string }
|
|
1539
|
+
): Schema {
|
|
1540
|
+
const schemaHeader = headers.get(SHAPE_SCHEMA_HEADER)
|
|
1541
|
+
if (!schemaHeader) {
|
|
1542
|
+
if (options?.required && options?.url) {
|
|
1543
|
+
throw new MissingHeadersError(options.url, [SHAPE_SCHEMA_HEADER])
|
|
1334
1544
|
}
|
|
1545
|
+
return {}
|
|
1335
1546
|
}
|
|
1547
|
+
return JSON.parse(schemaHeader)
|
|
1336
1548
|
}
|
|
1337
1549
|
|
|
1338
1550
|
/**
|