@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/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
- let hasUpToDate = false
1001
- let hasSnapshotEnd = false
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
- write({
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
- // Skip snapshot-end tracking during buffered initial sync (will be extracted during atomic swap)
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
- hasUpToDate = true
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
- hasUpToDate = false
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 (hasUpToDate || hasSnapshotEnd) {
1111
- // PROGRESSIVE MODE: Atomic swap on first up-to-date
1112
- if (isBufferingInitialSync() && hasUpToDate) {
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
- write({
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
- // In eager mode, only commit on snapshot-end if we've already received
1159
- // the first up-to-date, because the snapshot-end in the log could be from
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 (hasUpToDate) {
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 snapshot-end in on-demand mode
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
- if (hasUpToDate || (hasSnapshotEnd && syncMode === `on-demand`)) {
1211
- batchCommitted.setState(() => true)
1212
- }
1502
+ batchCommitted.setState(() => true)
1503
+
1213
1504
  resolveMatchedPendingMatches()
1214
1505
  }
1215
1506
  })
@@ -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
+ }