@tanstack/electric-db-collection 0.2.15 → 0.2.16
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 +191 -33
- package/dist/cjs/electric.cjs.map +1 -1
- package/dist/cjs/tag-index.cjs +67 -0
- package/dist/cjs/tag-index.cjs.map +1 -0
- package/dist/cjs/tag-index.d.cts +66 -0
- package/dist/esm/electric.js +191 -33
- package/dist/esm/electric.js.map +1 -1
- package/dist/esm/tag-index.d.ts +66 -0
- package/dist/esm/tag-index.js +67 -0
- package/dist/esm/tag-index.js.map +1 -0
- package/package.json +3 -3
- package/src/electric.ts +334 -43
- package/src/tag-index.ts +160 -0
package/src/electric.ts
CHANGED
|
@@ -14,8 +14,24 @@ import {
|
|
|
14
14
|
TimeoutWaitingForTxIdError,
|
|
15
15
|
} from './errors'
|
|
16
16
|
import { compileSQL } from './sql-compiler'
|
|
17
|
+
import {
|
|
18
|
+
addTagToIndex,
|
|
19
|
+
findRowsMatchingPattern,
|
|
20
|
+
getTagLength,
|
|
21
|
+
isMoveOutMessage,
|
|
22
|
+
removeTagFromIndex,
|
|
23
|
+
tagMatchesPattern,
|
|
24
|
+
} from './tag-index'
|
|
25
|
+
import type {
|
|
26
|
+
MoveOutPattern,
|
|
27
|
+
MoveTag,
|
|
28
|
+
ParsedMoveTag,
|
|
29
|
+
RowId,
|
|
30
|
+
TagIndex,
|
|
31
|
+
} from './tag-index'
|
|
17
32
|
import type {
|
|
18
33
|
BaseCollectionConfig,
|
|
34
|
+
ChangeMessageOrDeleteKeyMessage,
|
|
19
35
|
CollectionConfig,
|
|
20
36
|
DeleteMutationFnParams,
|
|
21
37
|
InsertMutationFnParams,
|
|
@@ -288,6 +304,15 @@ function isSnapshotEndMessage<T extends Row<unknown>>(
|
|
|
288
304
|
return isControlMessage(message) && message.headers.control === `snapshot-end`
|
|
289
305
|
}
|
|
290
306
|
|
|
307
|
+
function isSubsetEndMessage<T extends Row<unknown>>(
|
|
308
|
+
message: Message<T>,
|
|
309
|
+
): message is ControlMessage & { headers: { control: `subset-end` } } {
|
|
310
|
+
return (
|
|
311
|
+
isControlMessage(message) &&
|
|
312
|
+
(message.headers.control as string) === `subset-end`
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
|
|
291
316
|
function parseSnapshotMessage(message: SnapshotEndMessage): PostgresSnapshot {
|
|
292
317
|
return {
|
|
293
318
|
xmin: message.headers.xmin,
|
|
@@ -871,6 +896,231 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
871
896
|
// Store for the relation schema information
|
|
872
897
|
const relationSchema = new Store<string | undefined>(undefined)
|
|
873
898
|
|
|
899
|
+
const tagCache = new Map<MoveTag, ParsedMoveTag>()
|
|
900
|
+
|
|
901
|
+
// Parses a tag string into a MoveTag.
|
|
902
|
+
// It memoizes the result parsed tag such that future calls
|
|
903
|
+
// for the same tag string return the same MoveTag array.
|
|
904
|
+
const parseTag = (tag: MoveTag): ParsedMoveTag => {
|
|
905
|
+
const cachedTag = tagCache.get(tag)
|
|
906
|
+
if (cachedTag) {
|
|
907
|
+
return cachedTag
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const parsedTag = tag.split(`|`)
|
|
911
|
+
tagCache.set(tag, parsedTag)
|
|
912
|
+
return parsedTag
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Tag tracking state
|
|
916
|
+
const rowTagSets = new Map<RowId, Set<MoveTag>>()
|
|
917
|
+
const tagIndex: TagIndex = []
|
|
918
|
+
let tagLength: number | undefined = undefined
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Initialize the tag index with the correct length
|
|
922
|
+
*/
|
|
923
|
+
const initializeTagIndex = (length: number): void => {
|
|
924
|
+
if (tagIndex.length < length) {
|
|
925
|
+
// Extend the index array to the required length
|
|
926
|
+
for (let i = tagIndex.length; i < length; i++) {
|
|
927
|
+
tagIndex[i] = new Map()
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Add tags to a row and update the tag index
|
|
934
|
+
*/
|
|
935
|
+
const addTagsToRow = (
|
|
936
|
+
tags: Array<MoveTag>,
|
|
937
|
+
rowId: RowId,
|
|
938
|
+
rowTagSet: Set<MoveTag>,
|
|
939
|
+
): void => {
|
|
940
|
+
for (const tag of tags) {
|
|
941
|
+
const parsedTag = parseTag(tag)
|
|
942
|
+
|
|
943
|
+
// Infer tag length from first tag
|
|
944
|
+
if (tagLength === undefined) {
|
|
945
|
+
tagLength = getTagLength(parsedTag)
|
|
946
|
+
initializeTagIndex(tagLength)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Validate tag length matches
|
|
950
|
+
const currentTagLength = getTagLength(parsedTag)
|
|
951
|
+
if (currentTagLength !== tagLength) {
|
|
952
|
+
debug(
|
|
953
|
+
`${collectionId ? `[${collectionId}] ` : ``}Tag length mismatch: expected ${tagLength}, got ${currentTagLength}`,
|
|
954
|
+
)
|
|
955
|
+
continue
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
rowTagSet.add(tag)
|
|
959
|
+
addTagToIndex(parsedTag, rowId, tagIndex, tagLength)
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Remove tags from a row and update the tag index
|
|
965
|
+
*/
|
|
966
|
+
const removeTagsFromRow = (
|
|
967
|
+
removedTags: Array<MoveTag>,
|
|
968
|
+
rowId: RowId,
|
|
969
|
+
rowTagSet: Set<MoveTag>,
|
|
970
|
+
): void => {
|
|
971
|
+
if (tagLength === undefined) {
|
|
972
|
+
return
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
for (const tag of removedTags) {
|
|
976
|
+
const parsedTag = parseTag(tag)
|
|
977
|
+
rowTagSet.delete(tag)
|
|
978
|
+
removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength)
|
|
979
|
+
// We aggresively evict the tag from the cache
|
|
980
|
+
// if this tag is shared with another row
|
|
981
|
+
// and is not removed from that other row
|
|
982
|
+
// then next time we encounter the tag it will be parsed again
|
|
983
|
+
tagCache.delete(tag)
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Process tags for a change message (add and remove tags)
|
|
989
|
+
*/
|
|
990
|
+
const processTagsForChangeMessage = (
|
|
991
|
+
tags: Array<MoveTag> | undefined,
|
|
992
|
+
removedTags: Array<MoveTag> | undefined,
|
|
993
|
+
rowId: RowId,
|
|
994
|
+
): Set<MoveTag> => {
|
|
995
|
+
// Initialize tag set for this row if it doesn't exist (needed for checking deletion)
|
|
996
|
+
if (!rowTagSets.has(rowId)) {
|
|
997
|
+
rowTagSets.set(rowId, new Set())
|
|
998
|
+
}
|
|
999
|
+
const rowTagSet = rowTagSets.get(rowId)!
|
|
1000
|
+
|
|
1001
|
+
// Add new tags
|
|
1002
|
+
if (tags) {
|
|
1003
|
+
addTagsToRow(tags, rowId, rowTagSet)
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Remove tags
|
|
1007
|
+
if (removedTags) {
|
|
1008
|
+
removeTagsFromRow(removedTags, rowId, rowTagSet)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
return rowTagSet
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Clear all tag tracking state (used when truncating)
|
|
1016
|
+
*/
|
|
1017
|
+
const clearTagTrackingState = (): void => {
|
|
1018
|
+
rowTagSets.clear()
|
|
1019
|
+
tagIndex.length = 0
|
|
1020
|
+
tagLength = undefined
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Remove all tags for a row from both the tag set and the index
|
|
1025
|
+
* Used when a row is deleted
|
|
1026
|
+
*/
|
|
1027
|
+
const clearTagsForRow = (rowId: RowId): void => {
|
|
1028
|
+
if (tagLength === undefined) {
|
|
1029
|
+
return
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const rowTagSet = rowTagSets.get(rowId)
|
|
1033
|
+
if (!rowTagSet) {
|
|
1034
|
+
return
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Remove each tag from the index
|
|
1038
|
+
for (const tag of rowTagSet) {
|
|
1039
|
+
const parsedTag = parseTag(tag)
|
|
1040
|
+
const currentTagLength = getTagLength(parsedTag)
|
|
1041
|
+
if (currentTagLength === tagLength) {
|
|
1042
|
+
removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength)
|
|
1043
|
+
}
|
|
1044
|
+
tagCache.delete(tag)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Remove the row from the tag sets map
|
|
1048
|
+
rowTagSets.delete(rowId)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Remove matching tags from a row based on a pattern
|
|
1053
|
+
* Returns true if the row's tag set is now empty
|
|
1054
|
+
*/
|
|
1055
|
+
const removeMatchingTagsFromRow = (
|
|
1056
|
+
rowId: RowId,
|
|
1057
|
+
pattern: MoveOutPattern,
|
|
1058
|
+
): boolean => {
|
|
1059
|
+
const rowTagSet = rowTagSets.get(rowId)
|
|
1060
|
+
if (!rowTagSet) {
|
|
1061
|
+
return false
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Find tags that match this pattern and remove them
|
|
1065
|
+
for (const tag of rowTagSet) {
|
|
1066
|
+
const parsedTag = parseTag(tag)
|
|
1067
|
+
if (tagMatchesPattern(parsedTag, pattern)) {
|
|
1068
|
+
rowTagSet.delete(tag)
|
|
1069
|
+
removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength!)
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Check if row's tag set is now empty
|
|
1074
|
+
if (rowTagSet.size === 0) {
|
|
1075
|
+
rowTagSets.delete(rowId)
|
|
1076
|
+
return true
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
return false
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Process move-out event: remove matching tags from rows and delete rows with empty tag sets
|
|
1084
|
+
*/
|
|
1085
|
+
const processMoveOutEvent = (
|
|
1086
|
+
patterns: Array<MoveOutPattern>,
|
|
1087
|
+
begin: () => void,
|
|
1088
|
+
write: (message: ChangeMessageOrDeleteKeyMessage<T>) => void,
|
|
1089
|
+
transactionStarted: boolean,
|
|
1090
|
+
): boolean => {
|
|
1091
|
+
if (tagLength === undefined) {
|
|
1092
|
+
debug(
|
|
1093
|
+
`${collectionId ? `[${collectionId}] ` : ``}Received move-out message but no tag length set yet, ignoring`,
|
|
1094
|
+
)
|
|
1095
|
+
return transactionStarted
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
let txStarted = transactionStarted
|
|
1099
|
+
|
|
1100
|
+
// Process all patterns and collect rows to delete
|
|
1101
|
+
for (const pattern of patterns) {
|
|
1102
|
+
// Find all rows that match this pattern
|
|
1103
|
+
const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex)
|
|
1104
|
+
|
|
1105
|
+
for (const rowId of affectedRowIds) {
|
|
1106
|
+
if (removeMatchingTagsFromRow(rowId, pattern)) {
|
|
1107
|
+
// Delete rows with empty tag sets
|
|
1108
|
+
if (!txStarted) {
|
|
1109
|
+
begin()
|
|
1110
|
+
txStarted = true
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
write({
|
|
1114
|
+
type: `delete`,
|
|
1115
|
+
key: rowId,
|
|
1116
|
+
})
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
return txStarted
|
|
1122
|
+
}
|
|
1123
|
+
|
|
874
1124
|
/**
|
|
875
1125
|
* Get the sync metadata for insert operations
|
|
876
1126
|
* @returns Record containing relation information
|
|
@@ -983,6 +1233,38 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
983
1233
|
syncMode === `progressive` && !hasReceivedUpToDate
|
|
984
1234
|
const bufferedMessages: Array<Message<T>> = [] // Buffer change messages during initial sync
|
|
985
1235
|
|
|
1236
|
+
/**
|
|
1237
|
+
* Process a change message: handle tags and write the mutation
|
|
1238
|
+
*/
|
|
1239
|
+
const processChangeMessage = (changeMessage: Message<T>) => {
|
|
1240
|
+
if (!isChangeMessage(changeMessage)) {
|
|
1241
|
+
return
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Process tags if present
|
|
1245
|
+
const tags = changeMessage.headers.tags
|
|
1246
|
+
const removedTags = changeMessage.headers.removed_tags
|
|
1247
|
+
const hasTags = tags || removedTags
|
|
1248
|
+
|
|
1249
|
+
const rowId = collection.getKeyFromItem(changeMessage.value)
|
|
1250
|
+
const operation = changeMessage.headers.operation
|
|
1251
|
+
|
|
1252
|
+
if (operation === `delete`) {
|
|
1253
|
+
clearTagsForRow(rowId)
|
|
1254
|
+
} else if (hasTags) {
|
|
1255
|
+
processTagsForChangeMessage(tags, removedTags, rowId)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
write({
|
|
1259
|
+
type: changeMessage.headers.operation,
|
|
1260
|
+
value: changeMessage.value,
|
|
1261
|
+
// Include the primary key and relation info in the metadata
|
|
1262
|
+
metadata: {
|
|
1263
|
+
...changeMessage.headers,
|
|
1264
|
+
},
|
|
1265
|
+
})
|
|
1266
|
+
}
|
|
1267
|
+
|
|
986
1268
|
// Create deduplicated loadSubset wrapper for non-eager modes
|
|
987
1269
|
// This prevents redundant snapshot requests when multiple concurrent
|
|
988
1270
|
// live queries request overlapping or subset predicates
|
|
@@ -997,8 +1279,8 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
997
1279
|
})
|
|
998
1280
|
|
|
999
1281
|
unsubscribeStream = stream.subscribe((messages: Array<Message<T>>) => {
|
|
1000
|
-
|
|
1001
|
-
let
|
|
1282
|
+
// Track commit point type - up-to-date takes precedence as it also triggers progressive mode atomic swap
|
|
1283
|
+
let commitPoint: `up-to-date` | `subset-end` | null = null
|
|
1002
1284
|
|
|
1003
1285
|
// Clear the current batch buffer at the START of processing a new batch
|
|
1004
1286
|
// This preserves messages from the previous batch until new ones arrive,
|
|
@@ -1008,7 +1290,7 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1008
1290
|
|
|
1009
1291
|
for (const message of messages) {
|
|
1010
1292
|
// Add message to current batch buffer (for race condition handling)
|
|
1011
|
-
if (isChangeMessage(message)) {
|
|
1293
|
+
if (isChangeMessage(message) || isMoveOutMessage(message)) {
|
|
1012
1294
|
currentBatchMessages.setState((currentBuffer) => {
|
|
1013
1295
|
const newBuffer = [...currentBuffer, message]
|
|
1014
1296
|
// Limit buffer size for safety
|
|
@@ -1065,23 +1347,35 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1065
1347
|
transactionStarted = true
|
|
1066
1348
|
}
|
|
1067
1349
|
|
|
1068
|
-
|
|
1069
|
-
type: message.headers.operation,
|
|
1070
|
-
value: message.value,
|
|
1071
|
-
// Include the primary key and relation info in the metadata
|
|
1072
|
-
metadata: {
|
|
1073
|
-
...message.headers,
|
|
1074
|
-
},
|
|
1075
|
-
})
|
|
1350
|
+
processChangeMessage(message)
|
|
1076
1351
|
}
|
|
1077
1352
|
} else if (isSnapshotEndMessage(message)) {
|
|
1078
|
-
//
|
|
1353
|
+
// Track postgres snapshot metadata for resolving awaiting mutations
|
|
1354
|
+
// Skip during buffered initial sync (will be extracted during atomic swap)
|
|
1079
1355
|
if (!isBufferingInitialSync()) {
|
|
1080
1356
|
newSnapshots.push(parseSnapshotMessage(message))
|
|
1081
1357
|
}
|
|
1082
|
-
hasSnapshotEnd = true
|
|
1083
1358
|
} else if (isUpToDateMessage(message)) {
|
|
1084
|
-
|
|
1359
|
+
// up-to-date takes precedence - also triggers progressive mode atomic swap
|
|
1360
|
+
commitPoint = `up-to-date`
|
|
1361
|
+
} else if (isSubsetEndMessage(message)) {
|
|
1362
|
+
// subset-end triggers commit but not progressive mode atomic swap
|
|
1363
|
+
if (commitPoint !== `up-to-date`) {
|
|
1364
|
+
commitPoint = `subset-end`
|
|
1365
|
+
}
|
|
1366
|
+
} else if (isMoveOutMessage(message)) {
|
|
1367
|
+
// Handle move-out event: buffer if buffering, otherwise process immediately
|
|
1368
|
+
if (isBufferingInitialSync()) {
|
|
1369
|
+
bufferedMessages.push(message)
|
|
1370
|
+
} else {
|
|
1371
|
+
// Normal processing: process move-out immediately
|
|
1372
|
+
transactionStarted = processMoveOutEvent(
|
|
1373
|
+
message.headers.patterns,
|
|
1374
|
+
begin,
|
|
1375
|
+
write,
|
|
1376
|
+
transactionStarted,
|
|
1377
|
+
)
|
|
1378
|
+
}
|
|
1085
1379
|
} else if (isMustRefetchMessage(message)) {
|
|
1086
1380
|
debug(
|
|
1087
1381
|
`${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`,
|
|
@@ -1095,21 +1389,23 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1095
1389
|
|
|
1096
1390
|
truncate()
|
|
1097
1391
|
|
|
1392
|
+
// Clear tag tracking state
|
|
1393
|
+
clearTagTrackingState()
|
|
1394
|
+
|
|
1098
1395
|
// Reset the loadSubset deduplication state since we're starting fresh
|
|
1099
1396
|
// This ensures that previously loaded predicates don't prevent refetching after truncate
|
|
1100
1397
|
loadSubsetDedupe?.reset()
|
|
1101
1398
|
|
|
1102
1399
|
// Reset flags so we continue accumulating changes until next up-to-date
|
|
1103
|
-
|
|
1104
|
-
hasSnapshotEnd = false
|
|
1400
|
+
commitPoint = null
|
|
1105
1401
|
hasReceivedUpToDate = false // Reset for progressive mode (isBufferingInitialSync will reflect this)
|
|
1106
1402
|
bufferedMessages.length = 0 // Clear buffered messages
|
|
1107
1403
|
}
|
|
1108
1404
|
}
|
|
1109
1405
|
|
|
1110
|
-
if (
|
|
1111
|
-
// PROGRESSIVE MODE: Atomic swap on first up-to-date
|
|
1112
|
-
if (isBufferingInitialSync() &&
|
|
1406
|
+
if (commitPoint !== null) {
|
|
1407
|
+
// PROGRESSIVE MODE: Atomic swap on first up-to-date (not subset-end)
|
|
1408
|
+
if (isBufferingInitialSync() && commitPoint === `up-to-date`) {
|
|
1113
1409
|
debug(
|
|
1114
1410
|
`${collectionId ? `[${collectionId}] ` : ``}Progressive mode: Performing atomic swap with ${bufferedMessages.length} buffered messages`,
|
|
1115
1411
|
)
|
|
@@ -1120,16 +1416,13 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1120
1416
|
// Truncate to clear all snapshot data
|
|
1121
1417
|
truncate()
|
|
1122
1418
|
|
|
1419
|
+
// Clear tag tracking state for atomic swap
|
|
1420
|
+
clearTagTrackingState()
|
|
1421
|
+
|
|
1123
1422
|
// Apply all buffered change messages and extract txids/snapshots
|
|
1124
1423
|
for (const bufferedMsg of bufferedMessages) {
|
|
1125
1424
|
if (isChangeMessage(bufferedMsg)) {
|
|
1126
|
-
|
|
1127
|
-
type: bufferedMsg.headers.operation,
|
|
1128
|
-
value: bufferedMsg.value,
|
|
1129
|
-
metadata: {
|
|
1130
|
-
...bufferedMsg.headers,
|
|
1131
|
-
},
|
|
1132
|
-
})
|
|
1425
|
+
processChangeMessage(bufferedMsg)
|
|
1133
1426
|
|
|
1134
1427
|
// Extract txids from buffered messages (will be committed to store after transaction)
|
|
1135
1428
|
if (hasTxids(bufferedMsg)) {
|
|
@@ -1140,6 +1433,14 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1140
1433
|
} else if (isSnapshotEndMessage(bufferedMsg)) {
|
|
1141
1434
|
// Extract snapshots from buffered messages (will be committed to store after transaction)
|
|
1142
1435
|
newSnapshots.push(parseSnapshotMessage(bufferedMsg))
|
|
1436
|
+
} else if (isMoveOutMessage(bufferedMsg)) {
|
|
1437
|
+
// Process buffered move-out messages during atomic swap
|
|
1438
|
+
processMoveOutEvent(
|
|
1439
|
+
bufferedMsg.headers.patterns,
|
|
1440
|
+
begin,
|
|
1441
|
+
write,
|
|
1442
|
+
transactionStarted,
|
|
1443
|
+
)
|
|
1143
1444
|
}
|
|
1144
1445
|
}
|
|
1145
1446
|
|
|
@@ -1155,25 +1456,16 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1155
1456
|
)
|
|
1156
1457
|
} else {
|
|
1157
1458
|
// Normal mode or on-demand: commit transaction if one was started
|
|
1158
|
-
//
|
|
1159
|
-
|
|
1160
|
-
// a significant period before the stream is actually up to date
|
|
1161
|
-
const shouldCommit =
|
|
1162
|
-
hasUpToDate || syncMode === `on-demand` || hasReceivedUpToDate
|
|
1163
|
-
|
|
1164
|
-
if (transactionStarted && shouldCommit) {
|
|
1459
|
+
// Both up-to-date and subset-end trigger a commit
|
|
1460
|
+
if (transactionStarted) {
|
|
1165
1461
|
commit()
|
|
1166
1462
|
transactionStarted = false
|
|
1167
1463
|
}
|
|
1168
1464
|
}
|
|
1169
|
-
|
|
1170
|
-
if (hasUpToDate || (hasSnapshotEnd && syncMode === `on-demand`)) {
|
|
1171
|
-
// Mark the collection as ready now that sync is up to date
|
|
1172
|
-
wrappedMarkReady(isBufferingInitialSync())
|
|
1173
|
-
}
|
|
1465
|
+
wrappedMarkReady(isBufferingInitialSync())
|
|
1174
1466
|
|
|
1175
1467
|
// Track that we've received the first up-to-date for progressive mode
|
|
1176
|
-
if (
|
|
1468
|
+
if (commitPoint === `up-to-date`) {
|
|
1177
1469
|
hasReceivedUpToDate = true
|
|
1178
1470
|
}
|
|
1179
1471
|
|
|
@@ -1204,12 +1496,11 @@ function createElectricSync<T extends Row<unknown>>(
|
|
|
1204
1496
|
return seen
|
|
1205
1497
|
})
|
|
1206
1498
|
|
|
1207
|
-
// Resolve all matched pending matches on up-to-date or
|
|
1499
|
+
// Resolve all matched pending matches on up-to-date or subset-end
|
|
1208
1500
|
// Set batchCommitted BEFORE resolving to avoid timing window where late awaitMatch
|
|
1209
1501
|
// calls could register as "matched" after resolver pass already ran
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
}
|
|
1502
|
+
batchCommitted.setState(() => true)
|
|
1503
|
+
|
|
1213
1504
|
resolveMatchedPendingMatches()
|
|
1214
1505
|
}
|
|
1215
1506
|
})
|
package/src/tag-index.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Import Row and Message types for the isEventMessage function
|
|
2
|
+
import type { Message, Row } from '@electric-sql/client'
|
|
3
|
+
|
|
4
|
+
export type RowId = string | number
|
|
5
|
+
export type MoveTag = string
|
|
6
|
+
export type ParsedMoveTag = Array<string>
|
|
7
|
+
export type Position = number
|
|
8
|
+
export type Value = string
|
|
9
|
+
export type MoveOutPattern = {
|
|
10
|
+
pos: Position
|
|
11
|
+
value: Value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TAG_WILDCARD = `_`
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Event message type for move-out events
|
|
18
|
+
*/
|
|
19
|
+
export interface EventMessage {
|
|
20
|
+
headers: {
|
|
21
|
+
event: `move-out`
|
|
22
|
+
patterns: Array<MoveOutPattern>
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Tag index structure: array indexed by position, maps value to set of row IDs.
|
|
28
|
+
* For example:
|
|
29
|
+
* ```example
|
|
30
|
+
* const tag1 = [a, b, c]
|
|
31
|
+
* const tag2 = [a, b, d]
|
|
32
|
+
* const tag3 = [a, d, e]
|
|
33
|
+
*
|
|
34
|
+
* // Index is:
|
|
35
|
+
* [
|
|
36
|
+
* new Map([a -> <rows with a on index 0>])
|
|
37
|
+
* new Map([b -> <rows with b on index 1>, d -> <rows with d on index 1>])
|
|
38
|
+
* new Map([c -> <rows with c on index 2>, d -> <rows with d on index 2>, e -> <rows with e on index 2>])
|
|
39
|
+
* ]
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export type TagIndex = Array<Map<Value, Set<RowId>>>
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Abstraction to get the value at a specific position in a tag
|
|
46
|
+
*/
|
|
47
|
+
export function getValue(tag: ParsedMoveTag, position: Position): Value {
|
|
48
|
+
if (position >= tag.length) {
|
|
49
|
+
throw new Error(`Position out of bounds`)
|
|
50
|
+
}
|
|
51
|
+
return tag[position]!
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Abstraction to extract position and value from a pattern.
|
|
56
|
+
*/
|
|
57
|
+
function getPositionalValue(pattern: MoveOutPattern): {
|
|
58
|
+
pos: number
|
|
59
|
+
value: string
|
|
60
|
+
} {
|
|
61
|
+
return pattern
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Abstraction to get the length of a tag
|
|
66
|
+
*/
|
|
67
|
+
export function getTagLength(tag: ParsedMoveTag): number {
|
|
68
|
+
return tag.length
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if a tag matches a pattern.
|
|
73
|
+
* A tag matches if the value at the pattern's position equals the pattern's value,
|
|
74
|
+
* or if the value at that position is "_" (wildcard).
|
|
75
|
+
*/
|
|
76
|
+
export function tagMatchesPattern(
|
|
77
|
+
tag: ParsedMoveTag,
|
|
78
|
+
pattern: MoveOutPattern,
|
|
79
|
+
): boolean {
|
|
80
|
+
const { pos, value } = getPositionalValue(pattern)
|
|
81
|
+
const tagValue = getValue(tag, pos)
|
|
82
|
+
return tagValue === value || tagValue === TAG_WILDCARD
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Add a tag to the index for efficient pattern matching
|
|
87
|
+
*/
|
|
88
|
+
export function addTagToIndex(
|
|
89
|
+
tag: ParsedMoveTag,
|
|
90
|
+
rowId: RowId,
|
|
91
|
+
index: TagIndex,
|
|
92
|
+
tagLength: number,
|
|
93
|
+
): void {
|
|
94
|
+
for (let i = 0; i < tagLength; i++) {
|
|
95
|
+
const value = getValue(tag, i)
|
|
96
|
+
|
|
97
|
+
// Only index non-wildcard values
|
|
98
|
+
if (value !== TAG_WILDCARD) {
|
|
99
|
+
const positionIndex = index[i]!
|
|
100
|
+
if (!positionIndex.has(value)) {
|
|
101
|
+
positionIndex.set(value, new Set())
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const tags = positionIndex.get(value)!
|
|
105
|
+
tags.add(rowId)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remove a tag from the index
|
|
112
|
+
*/
|
|
113
|
+
export function removeTagFromIndex(
|
|
114
|
+
tag: ParsedMoveTag,
|
|
115
|
+
rowId: RowId,
|
|
116
|
+
index: TagIndex,
|
|
117
|
+
tagLength: number,
|
|
118
|
+
): void {
|
|
119
|
+
for (let i = 0; i < tagLength; i++) {
|
|
120
|
+
const value = getValue(tag, i)
|
|
121
|
+
|
|
122
|
+
// Only remove non-wildcard values
|
|
123
|
+
if (value !== TAG_WILDCARD) {
|
|
124
|
+
const positionIndex = index[i]
|
|
125
|
+
if (positionIndex) {
|
|
126
|
+
const rowSet = positionIndex.get(value)
|
|
127
|
+
if (rowSet) {
|
|
128
|
+
rowSet.delete(rowId)
|
|
129
|
+
|
|
130
|
+
// Clean up empty sets
|
|
131
|
+
if (rowSet.size === 0) {
|
|
132
|
+
positionIndex.delete(value)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Find all rows that match a given pattern
|
|
142
|
+
*/
|
|
143
|
+
export function findRowsMatchingPattern(
|
|
144
|
+
pattern: MoveOutPattern,
|
|
145
|
+
index: TagIndex,
|
|
146
|
+
): Set<RowId> {
|
|
147
|
+
const { pos, value } = getPositionalValue(pattern)
|
|
148
|
+
const positionIndex = index[pos]
|
|
149
|
+
const rowSet = positionIndex?.get(value)
|
|
150
|
+
return rowSet ?? new Set()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check if a message is an event message with move-out event
|
|
155
|
+
*/
|
|
156
|
+
export function isMoveOutMessage<T extends Row<unknown>>(
|
|
157
|
+
message: Message<T>,
|
|
158
|
+
): message is Message<T> & EventMessage {
|
|
159
|
+
return message.headers.event === `move-out`
|
|
160
|
+
}
|