@tanstack/electric-db-collection 0.2.42 → 0.3.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/src/electric.ts CHANGED
@@ -16,15 +16,21 @@ import {
16
16
  import { compileSQL } from './sql-compiler'
17
17
  import {
18
18
  addTagToIndex,
19
+ deriveDisjunctPositions,
19
20
  findRowsMatchingPattern,
20
21
  getTagLength,
22
+ isMoveInMessage,
21
23
  isMoveOutMessage,
24
+ parseTag as parseTagString,
22
25
  removeTagFromIndex,
26
+ rowVisible,
23
27
  tagMatchesPattern,
24
28
  } from './tag-index'
25
29
  import type { ColumnEncoder } from './sql-compiler'
26
30
  import type {
27
- MoveOutPattern,
31
+ ActiveConditions,
32
+ DisjunctPositions,
33
+ MovePattern,
28
34
  MoveTag,
29
35
  ParsedMoveTag,
30
36
  RowId,
@@ -981,16 +987,16 @@ function createElectricSync<T extends Row<unknown>>(
981
987
 
982
988
  const tagCache = new Map<MoveTag, ParsedMoveTag>()
983
989
 
984
- // Parses a tag string into a MoveTag.
990
+ // Parses a tag string into a ParsedMoveTag.
985
991
  // It memoizes the result parsed tag such that future calls
986
- // for the same tag string return the same MoveTag array.
992
+ // for the same tag string return the same ParsedMoveTag array.
987
993
  const parseTag = (tag: MoveTag): ParsedMoveTag => {
988
994
  const cachedTag = tagCache.get(tag)
989
995
  if (cachedTag) {
990
996
  return cachedTag
991
997
  }
992
998
 
993
- const parsedTag = tag.split(`|`)
999
+ const parsedTag = parseTagString(tag)
994
1000
  tagCache.set(tag, parsedTag)
995
1001
  return parsedTag
996
1002
  }
@@ -1000,6 +1006,11 @@ function createElectricSync<T extends Row<unknown>>(
1000
1006
  const tagIndex: TagIndex = []
1001
1007
  let tagLength: number | undefined = undefined
1002
1008
 
1009
+ // DNF state: active_conditions are per-row, disjunct_positions are global
1010
+ // (fixed by the shape's WHERE clause, derived once from the first tagged message).
1011
+ const rowActiveConditions = new Map<RowId, ActiveConditions>()
1012
+ let disjunctPositions: DisjunctPositions | undefined = undefined
1013
+
1003
1014
  /**
1004
1015
  * Initialize the tag index with the correct length
1005
1016
  */
@@ -1074,6 +1085,7 @@ function createElectricSync<T extends Row<unknown>>(
1074
1085
  tags: Array<MoveTag> | undefined,
1075
1086
  removedTags: Array<MoveTag> | undefined,
1076
1087
  rowId: RowId,
1088
+ activeConditions?: ActiveConditions,
1077
1089
  ): Set<MoveTag> => {
1078
1090
  // Initialize tag set for this row if it doesn't exist (needed for checking deletion)
1079
1091
  if (!rowTagSets.has(rowId)) {
@@ -1084,6 +1096,12 @@ function createElectricSync<T extends Row<unknown>>(
1084
1096
  // Add new tags
1085
1097
  if (tags) {
1086
1098
  addTagsToRow(tags, rowId, rowTagSet)
1099
+
1100
+ // Derive disjunct positions once — they are fixed by the shape's WHERE clause.
1101
+ if (disjunctPositions === undefined) {
1102
+ const parsedTags = tags.map(parseTag)
1103
+ disjunctPositions = deriveDisjunctPositions(parsedTags)
1104
+ }
1087
1105
  }
1088
1106
 
1089
1107
  // Remove tags
@@ -1091,6 +1109,11 @@ function createElectricSync<T extends Row<unknown>>(
1091
1109
  removeTagsFromRow(removedTags, rowId, rowTagSet)
1092
1110
  }
1093
1111
 
1112
+ // Store active conditions if provided (overwrite on re-send)
1113
+ if (activeConditions && activeConditions.length > 0) {
1114
+ rowActiveConditions.set(rowId, [...activeConditions])
1115
+ }
1116
+
1094
1117
  return rowTagSet
1095
1118
  }
1096
1119
 
@@ -1101,6 +1124,8 @@ function createElectricSync<T extends Row<unknown>>(
1101
1124
  rowTagSets.clear()
1102
1125
  tagIndex.length = 0
1103
1126
  tagLength = undefined
1127
+ rowActiveConditions.clear()
1128
+ disjunctPositions = undefined
1104
1129
  }
1105
1130
 
1106
1131
  /**
@@ -1129,22 +1154,45 @@ function createElectricSync<T extends Row<unknown>>(
1129
1154
 
1130
1155
  // Remove the row from the tag sets map
1131
1156
  rowTagSets.delete(rowId)
1157
+ rowActiveConditions.delete(rowId)
1132
1158
  }
1133
1159
 
1134
1160
  /**
1135
1161
  * Remove matching tags from a row based on a pattern
1136
- * Returns true if the row's tag set is now empty
1162
+ * Returns true if the row should be deleted (no longer visible)
1137
1163
  */
1138
1164
  const removeMatchingTagsFromRow = (
1139
1165
  rowId: RowId,
1140
- pattern: MoveOutPattern,
1166
+ pattern: MovePattern,
1141
1167
  ): boolean => {
1142
1168
  const rowTagSet = rowTagSets.get(rowId)
1143
1169
  if (!rowTagSet) {
1144
1170
  return false
1145
1171
  }
1146
1172
 
1147
- // Find tags that match this pattern and remove them
1173
+ // DNF mode: check visibility using active conditions.
1174
+ // Tag index entries are preserved so that move-in can re-activate positions.
1175
+ const activeConditions = rowActiveConditions.get(rowId)
1176
+ if (activeConditions && disjunctPositions) {
1177
+ // Set the condition at this pattern's position to false
1178
+ activeConditions[pattern.pos] = false
1179
+
1180
+ if (!rowVisible(activeConditions, disjunctPositions)) {
1181
+ // Row is no longer visible — clean up all state including tag index
1182
+ for (const tag of rowTagSet) {
1183
+ const parsedTag = parseTag(tag)
1184
+ removeTagFromIndex(parsedTag, rowId, tagIndex, tagLength!)
1185
+ tagCache.delete(tag)
1186
+ }
1187
+ rowTagSets.delete(rowId)
1188
+ rowActiveConditions.delete(rowId)
1189
+ return true
1190
+ }
1191
+ return false
1192
+ }
1193
+
1194
+ // Simple shape (no subquery dependencies — server sends no active_conditions):
1195
+ // Remove matching tags and delete if tag set is empty
1148
1196
  for (const tag of rowTagSet) {
1149
1197
  const parsedTag = parseTag(tag)
1150
1198
  if (tagMatchesPattern(parsedTag, pattern)) {
@@ -1153,7 +1201,6 @@ function createElectricSync<T extends Row<unknown>>(
1153
1201
  }
1154
1202
  }
1155
1203
 
1156
- // Check if row's tag set is now empty
1157
1204
  if (rowTagSet.size === 0) {
1158
1205
  rowTagSets.delete(rowId)
1159
1206
  return true
@@ -1166,7 +1213,7 @@ function createElectricSync<T extends Row<unknown>>(
1166
1213
  * Process move-out event: remove matching tags from rows and delete rows with empty tag sets
1167
1214
  */
1168
1215
  const processMoveOutEvent = (
1169
- patterns: Array<MoveOutPattern>,
1216
+ patterns: Array<MovePattern>,
1170
1217
  begin: () => void,
1171
1218
  write: (message: ChangeMessageOrDeleteKeyMessage<T>) => void,
1172
1219
  transactionStarted: boolean,
@@ -1204,6 +1251,30 @@ function createElectricSync<T extends Row<unknown>>(
1204
1251
  return txStarted
1205
1252
  }
1206
1253
 
1254
+ /**
1255
+ * Process move-in event: re-activate conditions for rows matching the patterns.
1256
+ * This is a silent operation — no messages are emitted to the collection.
1257
+ */
1258
+ const processMoveInEvent = (patterns: Array<MovePattern>): void => {
1259
+ if (tagLength === undefined) {
1260
+ debug(
1261
+ `${collectionId ? `[${collectionId}] ` : ``}Received move-in message but no tag length set yet, ignoring`,
1262
+ )
1263
+ return
1264
+ }
1265
+
1266
+ for (const pattern of patterns) {
1267
+ const affectedRowIds = findRowsMatchingPattern(pattern, tagIndex)
1268
+
1269
+ for (const rowId of affectedRowIds) {
1270
+ const activeConditions = rowActiveConditions.get(rowId)
1271
+ if (activeConditions) {
1272
+ activeConditions[pattern.pos] = true
1273
+ }
1274
+ }
1275
+ }
1276
+ }
1277
+
1207
1278
  /**
1208
1279
  * Get the sync metadata for insert operations
1209
1280
  * @returns Record containing relation information
@@ -1433,6 +1504,11 @@ function createElectricSync<T extends Row<unknown>>(
1433
1504
  const removedTags = changeMessage.headers.removed_tags
1434
1505
  const hasTags = tags || removedTags
1435
1506
 
1507
+ // Extract active_conditions from headers (DNF support)
1508
+ const activeConditions = changeMessage.headers.active_conditions as
1509
+ | ActiveConditions
1510
+ | undefined
1511
+
1436
1512
  const rowId = collection.getKeyFromItem(changeMessage.value)
1437
1513
  const operation = changeMessage.headers.operation
1438
1514
 
@@ -1453,7 +1529,12 @@ function createElectricSync<T extends Row<unknown>>(
1453
1529
  if (isDelete) {
1454
1530
  clearTagsForRow(rowId)
1455
1531
  } else if (hasTags) {
1456
- processTagsForChangeMessage(tags, removedTags, rowId)
1532
+ processTagsForChangeMessage(
1533
+ tags,
1534
+ removedTags,
1535
+ rowId,
1536
+ activeConditions,
1537
+ )
1457
1538
  }
1458
1539
 
1459
1540
  write({
@@ -1496,7 +1577,11 @@ function createElectricSync<T extends Row<unknown>>(
1496
1577
 
1497
1578
  for (const message of messages) {
1498
1579
  // Add message to current batch buffer (for race condition handling)
1499
- if (isChangeMessage(message) || isMoveOutMessage(message)) {
1580
+ if (
1581
+ isChangeMessage(message) ||
1582
+ isMoveOutMessage(message) ||
1583
+ isMoveInMessage(message)
1584
+ ) {
1500
1585
  currentBatchMessages.setState((currentBuffer) => {
1501
1586
  const newBuffer = [...currentBuffer, message]
1502
1587
  // Limit buffer size for safety
@@ -1593,6 +1678,14 @@ function createElectricSync<T extends Row<unknown>>(
1593
1678
  transactionStarted,
1594
1679
  )
1595
1680
  }
1681
+ } else if (isMoveInMessage(message)) {
1682
+ // Handle move-in event: re-activate conditions for matching rows.
1683
+ // Buffer if buffering, otherwise process immediately.
1684
+ if (isBufferingInitialSync() && !transactionStarted) {
1685
+ bufferedMessages.push(message)
1686
+ } else {
1687
+ processMoveInEvent(message.headers.patterns)
1688
+ }
1596
1689
  } else if (isMustRefetchMessage(message)) {
1597
1690
  debug(
1598
1691
  `${collectionId ? `[${collectionId}] ` : ``}Received must-refetch message, starting transaction with truncate`,
@@ -1672,6 +1765,9 @@ function createElectricSync<T extends Row<unknown>>(
1672
1765
  write,
1673
1766
  transactionStarted,
1674
1767
  )
1768
+ } else if (isMoveInMessage(bufferedMsg)) {
1769
+ // Process buffered move-in messages during atomic swap
1770
+ processMoveInEvent(bufferedMsg.headers.patterns)
1675
1771
  }
1676
1772
  }
1677
1773
 
package/src/tag-index.ts CHANGED
@@ -3,23 +3,32 @@ import type { Message, Row } from '@electric-sql/client'
3
3
 
4
4
  export type RowId = string | number
5
5
  export type MoveTag = string
6
- export type ParsedMoveTag = Array<string>
6
+ export type ParsedMoveTag = Array<string | NonParticipating>
7
7
  export type Position = number
8
8
  export type Value = string
9
- export type MoveOutPattern = {
9
+ export type MovePattern = {
10
10
  pos: Position
11
11
  value: Value
12
12
  }
13
13
 
14
- const TAG_WILDCARD = `_`
14
+ /**
15
+ * Sentinel value for tag positions where the disjunct does not participate
16
+ * in that condition. These positions are not indexed and won't match any
17
+ * move pattern.
18
+ */
19
+ export const NON_PARTICIPATING = null
20
+ export type NonParticipating = typeof NON_PARTICIPATING
21
+
22
+ export type ActiveConditions = Array<boolean>
23
+ export type DisjunctPositions = Array<Array<number>>
15
24
 
16
25
  /**
17
- * Event message type for move-out events
26
+ * Event message type for move-out and move-in events
18
27
  */
19
28
  export interface EventMessage {
20
29
  headers: {
21
- event: `move-out`
22
- patterns: Array<MoveOutPattern>
30
+ event: `move-out` | `move-in`
31
+ patterns: Array<MovePattern>
23
32
  }
24
33
  }
25
34
 
@@ -41,10 +50,21 @@ export interface EventMessage {
41
50
  */
42
51
  export type TagIndex = Array<Map<Value, Set<RowId>>>
43
52
 
53
+ /**
54
+ * Parse a tag string into a ParsedMoveTag.
55
+ * Splits on `/` delimiter and maps empty strings to {@link NON_PARTICIPATING}.
56
+ */
57
+ export function parseTag(tag: MoveTag): ParsedMoveTag {
58
+ return tag.split(`/`).map((s) => (s === `` ? NON_PARTICIPATING : s))
59
+ }
60
+
44
61
  /**
45
62
  * Abstraction to get the value at a specific position in a tag
46
63
  */
47
- export function getValue(tag: ParsedMoveTag, position: Position): Value {
64
+ export function getValue(
65
+ tag: ParsedMoveTag,
66
+ position: Position,
67
+ ): string | NonParticipating {
48
68
  if (position >= tag.length) {
49
69
  throw new Error(`Position out of bounds`)
50
70
  }
@@ -54,7 +74,7 @@ export function getValue(tag: ParsedMoveTag, position: Position): Value {
54
74
  /**
55
75
  * Abstraction to extract position and value from a pattern.
56
76
  */
57
- function getPositionalValue(pattern: MoveOutPattern): {
77
+ function getPositionalValue(pattern: MovePattern): {
58
78
  pos: number
59
79
  value: string
60
80
  } {
@@ -70,16 +90,16 @@ export function getTagLength(tag: ParsedMoveTag): number {
70
90
 
71
91
  /**
72
92
  * 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).
93
+ * A tag matches if the value at the pattern's position equals the pattern's value.
94
+ * {@link NON_PARTICIPATING} positions naturally don't match any string value.
75
95
  */
76
96
  export function tagMatchesPattern(
77
97
  tag: ParsedMoveTag,
78
- pattern: MoveOutPattern,
98
+ pattern: MovePattern,
79
99
  ): boolean {
80
100
  const { pos, value } = getPositionalValue(pattern)
81
101
  const tagValue = getValue(tag, pos)
82
- return tagValue === value || tagValue === TAG_WILDCARD
102
+ return tagValue === value
83
103
  }
84
104
 
85
105
  /**
@@ -94,8 +114,7 @@ export function addTagToIndex(
94
114
  for (let i = 0; i < tagLength; i++) {
95
115
  const value = getValue(tag, i)
96
116
 
97
- // Only index non-wildcard values
98
- if (value !== TAG_WILDCARD) {
117
+ if (value !== NON_PARTICIPATING) {
99
118
  const positionIndex = index[i]!
100
119
  if (!positionIndex.has(value)) {
101
120
  positionIndex.set(value, new Set())
@@ -119,8 +138,7 @@ export function removeTagFromIndex(
119
138
  for (let i = 0; i < tagLength; i++) {
120
139
  const value = getValue(tag, i)
121
140
 
122
- // Only remove non-wildcard values
123
- if (value !== TAG_WILDCARD) {
141
+ if (value !== NON_PARTICIPATING) {
124
142
  const positionIndex = index[i]
125
143
  if (positionIndex) {
126
144
  const rowSet = positionIndex.get(value)
@@ -141,7 +159,7 @@ export function removeTagFromIndex(
141
159
  * Find all rows that match a given pattern
142
160
  */
143
161
  export function findRowsMatchingPattern(
144
- pattern: MoveOutPattern,
162
+ pattern: MovePattern,
145
163
  index: TagIndex,
146
164
  ): Set<RowId> {
147
165
  const { pos, value } = getPositionalValue(pattern)
@@ -150,6 +168,38 @@ export function findRowsMatchingPattern(
150
168
  return rowSet ?? new Set()
151
169
  }
152
170
 
171
+ /**
172
+ * Derive disjunct positions from parsed tags.
173
+ * For each tag (= disjunct), collect the indices of participating positions.
174
+ * E.g., ["hash_a", NON_PARTICIPATING, "hash_b"] → [0, 2]
175
+ */
176
+ export function deriveDisjunctPositions(
177
+ tags: Array<ParsedMoveTag>,
178
+ ): DisjunctPositions {
179
+ return tags.map((tag) => {
180
+ const positions: Array<number> = []
181
+ for (let i = 0; i < tag.length; i++) {
182
+ if (tag[i] !== NON_PARTICIPATING) {
183
+ positions.push(i)
184
+ }
185
+ }
186
+ return positions
187
+ })
188
+ }
189
+
190
+ /**
191
+ * Evaluate whether a row is visible given active conditions and disjunct positions.
192
+ * Returns true if ANY disjunct has ALL its positions as true in activeConditions.
193
+ */
194
+ export function rowVisible(
195
+ activeConditions: ActiveConditions,
196
+ disjunctPositions: DisjunctPositions,
197
+ ): boolean {
198
+ return disjunctPositions.some((positions) =>
199
+ positions.every((pos) => activeConditions[pos]),
200
+ )
201
+ }
202
+
153
203
  /**
154
204
  * Check if a message is an event message with move-out event
155
205
  */
@@ -158,3 +208,12 @@ export function isMoveOutMessage<T extends Row<unknown>>(
158
208
  ): message is Message<T> & EventMessage {
159
209
  return message.headers.event === `move-out`
160
210
  }
211
+
212
+ /**
213
+ * Check if a message is an event message with move-in event
214
+ */
215
+ export function isMoveInMessage<T extends Row<unknown>>(
216
+ message: Message<T>,
217
+ ): message is Message<T> & EventMessage {
218
+ return message.headers.event === `move-in`
219
+ }