@tanstack/electric-db-collection 0.2.41 → 0.2.42
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/electric.cjs +120 -12
- package/dist/cjs/electric.cjs.map +1 -1
- package/dist/esm/electric.js +120 -12
- package/dist/esm/electric.js.map +1 -1
- package/package.json +5 -5
- package/src/electric.ts +160 -12
package/src/electric.ts
CHANGED
|
@@ -47,6 +47,7 @@ import type {
|
|
|
47
47
|
ControlMessage,
|
|
48
48
|
GetExtensions,
|
|
49
49
|
Message,
|
|
50
|
+
Offset,
|
|
50
51
|
PostgresSnapshot,
|
|
51
52
|
Row,
|
|
52
53
|
ShapeStreamOptions,
|
|
@@ -322,6 +323,46 @@ function parseSnapshotMessage(message: SnapshotEndMessage): PostgresSnapshot {
|
|
|
322
323
|
}
|
|
323
324
|
}
|
|
324
325
|
|
|
326
|
+
function toStableSerializable(value: unknown): unknown {
|
|
327
|
+
if (value == null) {
|
|
328
|
+
return value
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (Array.isArray(value)) {
|
|
332
|
+
return value.map((entry) => toStableSerializable(entry))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (value instanceof Date) {
|
|
336
|
+
return value.toISOString()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (typeof value === `object`) {
|
|
340
|
+
const record = value as Record<string, unknown>
|
|
341
|
+
const stableRecord: Record<string, unknown> = {}
|
|
342
|
+
const keys = Object.keys(record).sort((left, right) =>
|
|
343
|
+
left < right ? -1 : left > right ? 1 : 0,
|
|
344
|
+
)
|
|
345
|
+
for (const key of keys) {
|
|
346
|
+
stableRecord[key] = toStableSerializable(record[key])
|
|
347
|
+
}
|
|
348
|
+
return stableRecord
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return value
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function getStableShapeIdentity(shapeOptions: {
|
|
355
|
+
url: string
|
|
356
|
+
params?: Record<string, unknown>
|
|
357
|
+
}): string {
|
|
358
|
+
return JSON.stringify(
|
|
359
|
+
toStableSerializable({
|
|
360
|
+
url: shapeOptions.url,
|
|
361
|
+
params: shapeOptions.params ?? null,
|
|
362
|
+
}),
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
325
366
|
// Check if a message contains txids in its headers
|
|
326
367
|
function hasTxids<T extends Row<unknown>>(
|
|
327
368
|
message: Message<T>,
|
|
@@ -666,26 +707,29 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
666
707
|
if (hasSnapshot) return true
|
|
667
708
|
|
|
668
709
|
return new Promise((resolve, reject) => {
|
|
710
|
+
const cleanup = () => {
|
|
711
|
+
clearTimeout(timeoutId)
|
|
712
|
+
subSeenTxids.unsubscribe()
|
|
713
|
+
subSeenSnapshots.unsubscribe()
|
|
714
|
+
}
|
|
715
|
+
|
|
669
716
|
const timeoutId = setTimeout(() => {
|
|
670
|
-
|
|
671
|
-
unsubscribeSeenSnapshots()
|
|
717
|
+
cleanup()
|
|
672
718
|
reject(new TimeoutWaitingForTxIdError(txId, config.id))
|
|
673
719
|
}, timeout)
|
|
674
720
|
|
|
675
|
-
const
|
|
721
|
+
const subSeenTxids = seenTxids.subscribe(() => {
|
|
676
722
|
if (seenTxids.state.has(txId)) {
|
|
677
723
|
debug(
|
|
678
724
|
`${config.id ? `[${config.id}] ` : ``}awaitTxId found match for txid %o`,
|
|
679
725
|
txId,
|
|
680
726
|
)
|
|
681
|
-
|
|
682
|
-
unsubscribeSeenTxids()
|
|
683
|
-
unsubscribeSeenSnapshots()
|
|
727
|
+
cleanup()
|
|
684
728
|
resolve(true)
|
|
685
729
|
}
|
|
686
730
|
})
|
|
687
731
|
|
|
688
|
-
const
|
|
732
|
+
const subSeenSnapshots = seenSnapshots.subscribe(() => {
|
|
689
733
|
const visibleSnapshot = seenSnapshots.state.find((snapshot) =>
|
|
690
734
|
isVisibleInSnapshot(txId, snapshot),
|
|
691
735
|
)
|
|
@@ -695,9 +739,7 @@ export function electricCollectionOptions<T extends Row<unknown>>(
|
|
|
695
739
|
txId,
|
|
696
740
|
visibleSnapshot,
|
|
697
741
|
)
|
|
698
|
-
|
|
699
|
-
unsubscribeSeenSnapshots()
|
|
700
|
-
unsubscribeSeenTxids()
|
|
742
|
+
cleanup()
|
|
701
743
|
resolve(true)
|
|
702
744
|
}
|
|
703
745
|
})
|
|
@@ -1181,7 +1223,61 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1181
1223
|
|
|
1182
1224
|
return {
|
|
1183
1225
|
sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
|
|
1184
|
-
const {
|
|
1226
|
+
const {
|
|
1227
|
+
begin,
|
|
1228
|
+
write,
|
|
1229
|
+
commit,
|
|
1230
|
+
markReady,
|
|
1231
|
+
truncate,
|
|
1232
|
+
collection,
|
|
1233
|
+
metadata,
|
|
1234
|
+
} = params
|
|
1235
|
+
const readPersistedResumeState = () => {
|
|
1236
|
+
const persistedResumeState = metadata?.collection.get(`electric:resume`)
|
|
1237
|
+
if (!persistedResumeState || typeof persistedResumeState !== `object`) {
|
|
1238
|
+
return undefined
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const record = persistedResumeState as Record<string, unknown>
|
|
1242
|
+
if (
|
|
1243
|
+
record.kind === `resume` &&
|
|
1244
|
+
typeof record.offset === `string` &&
|
|
1245
|
+
typeof record.handle === `string` &&
|
|
1246
|
+
typeof record.shapeId === `string` &&
|
|
1247
|
+
typeof record.updatedAt === `number`
|
|
1248
|
+
) {
|
|
1249
|
+
return {
|
|
1250
|
+
kind: `resume` as const,
|
|
1251
|
+
offset: record.offset,
|
|
1252
|
+
handle: record.handle,
|
|
1253
|
+
shapeId: record.shapeId,
|
|
1254
|
+
updatedAt: record.updatedAt,
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (record.kind === `reset` && typeof record.updatedAt === `number`) {
|
|
1259
|
+
return {
|
|
1260
|
+
kind: `reset` as const,
|
|
1261
|
+
updatedAt: record.updatedAt,
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
return undefined
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const persistedResumeState = readPersistedResumeState()
|
|
1269
|
+
const shapeIdentity = getStableShapeIdentity({
|
|
1270
|
+
url: shapeOptions.url,
|
|
1271
|
+
params: shapeOptions.params as Record<string, unknown> | undefined,
|
|
1272
|
+
})
|
|
1273
|
+
const hasIncompatiblePersistedResume =
|
|
1274
|
+
persistedResumeState?.kind === `resume` &&
|
|
1275
|
+
persistedResumeState.shapeId !== shapeIdentity
|
|
1276
|
+
const canUsePersistedResume =
|
|
1277
|
+
shapeOptions.offset === undefined &&
|
|
1278
|
+
shapeOptions.handle === undefined &&
|
|
1279
|
+
persistedResumeState?.kind === `resume` &&
|
|
1280
|
+
!hasIncompatiblePersistedResume
|
|
1185
1281
|
|
|
1186
1282
|
// Wrap markReady to wait for test hook in progressive mode
|
|
1187
1283
|
let progressiveReadyGate: Promise<void> | null = null
|
|
@@ -1239,7 +1335,15 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1239
1335
|
// In on-demand mode, we only need the changes from the point of time the collection was created
|
|
1240
1336
|
// so we default to `now` when there is no saved offset.
|
|
1241
1337
|
offset:
|
|
1242
|
-
shapeOptions.offset ??
|
|
1338
|
+
shapeOptions.offset ??
|
|
1339
|
+
(canUsePersistedResume
|
|
1340
|
+
? (persistedResumeState.offset as Offset)
|
|
1341
|
+
: syncMode === `on-demand`
|
|
1342
|
+
? `now`
|
|
1343
|
+
: undefined),
|
|
1344
|
+
handle:
|
|
1345
|
+
shapeOptions.handle ??
|
|
1346
|
+
(canUsePersistedResume ? persistedResumeState.handle : undefined),
|
|
1243
1347
|
signal: abortController.signal,
|
|
1244
1348
|
onError: (errorParams) => {
|
|
1245
1349
|
// Just immediately mark ready if there's an error to avoid blocking
|
|
@@ -1280,6 +1384,42 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1280
1384
|
// duplicate key errors when the row's data has changed between requests.
|
|
1281
1385
|
const syncedKeys = new Set<string | number>()
|
|
1282
1386
|
|
|
1387
|
+
const stageResumeMetadata = () => {
|
|
1388
|
+
if (!metadata) {
|
|
1389
|
+
return
|
|
1390
|
+
}
|
|
1391
|
+
const shapeHandle = stream.shapeHandle
|
|
1392
|
+
const lastOffset = stream.lastOffset
|
|
1393
|
+
if (!shapeHandle || lastOffset === `-1`) {
|
|
1394
|
+
return
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
metadata.collection.set(`electric:resume`, {
|
|
1398
|
+
kind: `resume`,
|
|
1399
|
+
offset: lastOffset,
|
|
1400
|
+
handle: shapeHandle,
|
|
1401
|
+
shapeId: shapeIdentity,
|
|
1402
|
+
updatedAt: Date.now(),
|
|
1403
|
+
})
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
const commitResetResumeMetadataImmediately = () => {
|
|
1407
|
+
if (!metadata) {
|
|
1408
|
+
return
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
begin({ immediate: true })
|
|
1412
|
+
metadata.collection.set(`electric:resume`, {
|
|
1413
|
+
kind: `reset`,
|
|
1414
|
+
updatedAt: Date.now(),
|
|
1415
|
+
})
|
|
1416
|
+
commit()
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
if (hasIncompatiblePersistedResume) {
|
|
1420
|
+
commitResetResumeMetadataImmediately()
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1283
1423
|
/**
|
|
1284
1424
|
* Process a change message: handle tags and write the mutation
|
|
1285
1425
|
*/
|
|
@@ -1458,6 +1598,8 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1458
1598
|
`${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`,
|
|
1459
1599
|
)
|
|
1460
1600
|
|
|
1601
|
+
commitResetResumeMetadataImmediately()
|
|
1602
|
+
|
|
1461
1603
|
// Start a transaction and truncate the collection
|
|
1462
1604
|
if (!transactionStarted) {
|
|
1463
1605
|
begin()
|
|
@@ -1534,6 +1676,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1534
1676
|
}
|
|
1535
1677
|
|
|
1536
1678
|
// Commit the atomic swap
|
|
1679
|
+
stageResumeMetadata()
|
|
1537
1680
|
commit()
|
|
1538
1681
|
|
|
1539
1682
|
// Exit buffering phase by marking that we've received up-to-date
|
|
@@ -1547,8 +1690,13 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1547
1690
|
// Normal mode or on-demand: commit transaction if one was started
|
|
1548
1691
|
// Both up-to-date and subset-end trigger a commit
|
|
1549
1692
|
if (transactionStarted) {
|
|
1693
|
+
stageResumeMetadata()
|
|
1550
1694
|
commit()
|
|
1551
1695
|
transactionStarted = false
|
|
1696
|
+
} else if (commitPoint === `up-to-date` && metadata) {
|
|
1697
|
+
begin()
|
|
1698
|
+
stageResumeMetadata()
|
|
1699
|
+
commit()
|
|
1552
1700
|
}
|
|
1553
1701
|
}
|
|
1554
1702
|
wrappedMarkReady(isBufferingInitialSync())
|