@tanstack/db 0.5.33 → 0.6.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.
Files changed (273) hide show
  1. package/dist/cjs/collection/change-events.cjs.map +1 -1
  2. package/dist/cjs/collection/change-events.d.cts +3 -2
  3. package/dist/cjs/collection/changes.cjs +13 -4
  4. package/dist/cjs/collection/changes.cjs.map +1 -1
  5. package/dist/cjs/collection/changes.d.cts +10 -1
  6. package/dist/cjs/collection/cleanup-queue.cjs +89 -0
  7. package/dist/cjs/collection/cleanup-queue.cjs.map +1 -0
  8. package/dist/cjs/collection/cleanup-queue.d.cts +30 -0
  9. package/dist/cjs/collection/events.cjs +14 -0
  10. package/dist/cjs/collection/events.cjs.map +1 -1
  11. package/dist/cjs/collection/events.d.cts +39 -1
  12. package/dist/cjs/collection/index.cjs +66 -28
  13. package/dist/cjs/collection/index.cjs.map +1 -1
  14. package/dist/cjs/collection/index.d.cts +49 -36
  15. package/dist/cjs/collection/indexes.cjs +211 -62
  16. package/dist/cjs/collection/indexes.cjs.map +1 -1
  17. package/dist/cjs/collection/indexes.d.cts +27 -17
  18. package/dist/cjs/collection/lifecycle.cjs +5 -22
  19. package/dist/cjs/collection/lifecycle.cjs.map +1 -1
  20. package/dist/cjs/collection/lifecycle.d.cts +0 -1
  21. package/dist/cjs/collection/mutations.cjs +18 -0
  22. package/dist/cjs/collection/mutations.cjs.map +1 -1
  23. package/dist/cjs/collection/mutations.d.cts +1 -0
  24. package/dist/cjs/collection/state.cjs +381 -53
  25. package/dist/cjs/collection/state.cjs.map +1 -1
  26. package/dist/cjs/collection/state.d.cts +65 -1
  27. package/dist/cjs/collection/subscription.cjs +6 -0
  28. package/dist/cjs/collection/subscription.cjs.map +1 -1
  29. package/dist/cjs/collection/subscription.d.cts +4 -0
  30. package/dist/cjs/collection/sync.cjs +108 -1
  31. package/dist/cjs/collection/sync.cjs.map +1 -1
  32. package/dist/cjs/collection/sync.d.cts +2 -0
  33. package/dist/cjs/collection/transaction-metadata.cjs +5 -0
  34. package/dist/cjs/collection/transaction-metadata.cjs.map +1 -0
  35. package/dist/cjs/collection/transaction-metadata.d.cts +1 -0
  36. package/dist/cjs/errors.cjs +8 -0
  37. package/dist/cjs/errors.cjs.map +1 -1
  38. package/dist/cjs/errors.d.cts +3 -0
  39. package/dist/cjs/index.cjs +22 -4
  40. package/dist/cjs/index.cjs.map +1 -1
  41. package/dist/cjs/index.d.cts +11 -3
  42. package/dist/cjs/indexes/auto-index.cjs +13 -6
  43. package/dist/cjs/indexes/auto-index.cjs.map +1 -1
  44. package/dist/cjs/indexes/base-index.cjs +0 -3
  45. package/dist/cjs/indexes/base-index.cjs.map +1 -1
  46. package/dist/cjs/indexes/base-index.d.cts +2 -6
  47. package/dist/cjs/indexes/basic-index.cjs +361 -0
  48. package/dist/cjs/indexes/basic-index.cjs.map +1 -0
  49. package/dist/cjs/indexes/basic-index.d.cts +102 -0
  50. package/dist/cjs/indexes/btree-index.cjs.map +1 -1
  51. package/dist/cjs/indexes/btree-index.d.cts +1 -1
  52. package/dist/cjs/indexes/index-options.d.cts +8 -9
  53. package/dist/cjs/indexes/index-registry.cjs +89 -0
  54. package/dist/cjs/indexes/index-registry.cjs.map +1 -0
  55. package/dist/cjs/indexes/index-registry.d.cts +61 -0
  56. package/dist/cjs/local-only.cjs +5 -0
  57. package/dist/cjs/local-only.cjs.map +1 -1
  58. package/dist/cjs/query/builder/functions.cjs +27 -11
  59. package/dist/cjs/query/builder/functions.cjs.map +1 -1
  60. package/dist/cjs/query/builder/functions.d.cts +25 -3
  61. package/dist/cjs/query/builder/index.cjs +200 -39
  62. package/dist/cjs/query/builder/index.cjs.map +1 -1
  63. package/dist/cjs/query/builder/index.d.cts +4 -3
  64. package/dist/cjs/query/builder/ref-proxy.cjs.map +1 -1
  65. package/dist/cjs/query/builder/ref-proxy.d.cts +14 -3
  66. package/dist/cjs/query/builder/types.d.cts +84 -19
  67. package/dist/cjs/query/compiler/evaluators.cjs +51 -0
  68. package/dist/cjs/query/compiler/evaluators.cjs.map +1 -1
  69. package/dist/cjs/query/compiler/group-by.cjs +100 -28
  70. package/dist/cjs/query/compiler/group-by.cjs.map +1 -1
  71. package/dist/cjs/query/compiler/group-by.d.cts +4 -2
  72. package/dist/cjs/query/compiler/index.cjs +283 -11
  73. package/dist/cjs/query/compiler/index.cjs.map +1 -1
  74. package/dist/cjs/query/compiler/index.d.cts +30 -2
  75. package/dist/cjs/query/compiler/order-by.cjs +29 -10
  76. package/dist/cjs/query/compiler/order-by.cjs.map +1 -1
  77. package/dist/cjs/query/compiler/order-by.d.cts +1 -1
  78. package/dist/cjs/query/compiler/select.cjs +8 -0
  79. package/dist/cjs/query/compiler/select.cjs.map +1 -1
  80. package/dist/cjs/query/index.d.cts +2 -1
  81. package/dist/cjs/query/ir.cjs +18 -1
  82. package/dist/cjs/query/ir.cjs.map +1 -1
  83. package/dist/cjs/query/ir.d.cts +21 -1
  84. package/dist/cjs/query/live/collection-config-builder.cjs +501 -5
  85. package/dist/cjs/query/live/collection-config-builder.cjs.map +1 -1
  86. package/dist/cjs/query/live/collection-config-builder.d.cts +7 -0
  87. package/dist/cjs/query/live/types.d.cts +3 -3
  88. package/dist/cjs/query/live/utils.cjs +43 -3
  89. package/dist/cjs/query/live/utils.cjs.map +1 -1
  90. package/dist/cjs/query/live/utils.d.cts +1 -0
  91. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  92. package/dist/cjs/query/live-query-collection.d.cts +9 -6
  93. package/dist/cjs/query/query-once.cjs.map +1 -1
  94. package/dist/cjs/query/query-once.d.cts +7 -5
  95. package/dist/cjs/query/subset-dedupe.cjs +9 -3
  96. package/dist/cjs/query/subset-dedupe.cjs.map +1 -1
  97. package/dist/cjs/types.d.cts +42 -8
  98. package/dist/cjs/utils/array-utils.cjs +27 -0
  99. package/dist/cjs/utils/array-utils.cjs.map +1 -0
  100. package/dist/cjs/utils/array-utils.d.cts +16 -0
  101. package/dist/cjs/utils/comparison.cjs +11 -0
  102. package/dist/cjs/utils/comparison.cjs.map +1 -1
  103. package/dist/cjs/utils/index-optimization.cjs +4 -0
  104. package/dist/cjs/utils/index-optimization.cjs.map +1 -1
  105. package/dist/cjs/utils.cjs +7 -9
  106. package/dist/cjs/utils.cjs.map +1 -1
  107. package/dist/cjs/utils.d.cts +6 -1
  108. package/dist/cjs/virtual-props.cjs +33 -0
  109. package/dist/cjs/virtual-props.cjs.map +1 -0
  110. package/dist/cjs/virtual-props.d.cts +196 -0
  111. package/dist/esm/collection/change-events.d.ts +3 -2
  112. package/dist/esm/collection/change-events.js.map +1 -1
  113. package/dist/esm/collection/changes.d.ts +10 -1
  114. package/dist/esm/collection/changes.js +13 -4
  115. package/dist/esm/collection/changes.js.map +1 -1
  116. package/dist/esm/collection/cleanup-queue.d.ts +30 -0
  117. package/dist/esm/collection/cleanup-queue.js +89 -0
  118. package/dist/esm/collection/cleanup-queue.js.map +1 -0
  119. package/dist/esm/collection/events.d.ts +39 -1
  120. package/dist/esm/collection/events.js +14 -0
  121. package/dist/esm/collection/events.js.map +1 -1
  122. package/dist/esm/collection/index.d.ts +49 -36
  123. package/dist/esm/collection/index.js +67 -29
  124. package/dist/esm/collection/index.js.map +1 -1
  125. package/dist/esm/collection/indexes.d.ts +27 -17
  126. package/dist/esm/collection/indexes.js +211 -62
  127. package/dist/esm/collection/indexes.js.map +1 -1
  128. package/dist/esm/collection/lifecycle.d.ts +0 -1
  129. package/dist/esm/collection/lifecycle.js +5 -22
  130. package/dist/esm/collection/lifecycle.js.map +1 -1
  131. package/dist/esm/collection/mutations.d.ts +1 -0
  132. package/dist/esm/collection/mutations.js +18 -0
  133. package/dist/esm/collection/mutations.js.map +1 -1
  134. package/dist/esm/collection/state.d.ts +65 -1
  135. package/dist/esm/collection/state.js +381 -53
  136. package/dist/esm/collection/state.js.map +1 -1
  137. package/dist/esm/collection/subscription.d.ts +4 -0
  138. package/dist/esm/collection/subscription.js +6 -0
  139. package/dist/esm/collection/subscription.js.map +1 -1
  140. package/dist/esm/collection/sync.d.ts +2 -0
  141. package/dist/esm/collection/sync.js +108 -1
  142. package/dist/esm/collection/sync.js.map +1 -1
  143. package/dist/esm/collection/transaction-metadata.d.ts +1 -0
  144. package/dist/esm/collection/transaction-metadata.js +5 -0
  145. package/dist/esm/collection/transaction-metadata.js.map +1 -0
  146. package/dist/esm/errors.d.ts +3 -0
  147. package/dist/esm/errors.js +8 -0
  148. package/dist/esm/errors.js.map +1 -1
  149. package/dist/esm/index.d.ts +11 -3
  150. package/dist/esm/index.js +25 -7
  151. package/dist/esm/index.js.map +1 -1
  152. package/dist/esm/indexes/auto-index.js +13 -6
  153. package/dist/esm/indexes/auto-index.js.map +1 -1
  154. package/dist/esm/indexes/base-index.d.ts +2 -6
  155. package/dist/esm/indexes/base-index.js +1 -4
  156. package/dist/esm/indexes/base-index.js.map +1 -1
  157. package/dist/esm/indexes/basic-index.d.ts +102 -0
  158. package/dist/esm/indexes/basic-index.js +361 -0
  159. package/dist/esm/indexes/basic-index.js.map +1 -0
  160. package/dist/esm/indexes/btree-index.d.ts +1 -1
  161. package/dist/esm/indexes/btree-index.js.map +1 -1
  162. package/dist/esm/indexes/index-options.d.ts +8 -9
  163. package/dist/esm/indexes/index-registry.d.ts +61 -0
  164. package/dist/esm/indexes/index-registry.js +89 -0
  165. package/dist/esm/indexes/index-registry.js.map +1 -0
  166. package/dist/esm/local-only.js +5 -0
  167. package/dist/esm/local-only.js.map +1 -1
  168. package/dist/esm/query/builder/functions.d.ts +25 -3
  169. package/dist/esm/query/builder/functions.js +27 -11
  170. package/dist/esm/query/builder/functions.js.map +1 -1
  171. package/dist/esm/query/builder/index.d.ts +4 -3
  172. package/dist/esm/query/builder/index.js +201 -40
  173. package/dist/esm/query/builder/index.js.map +1 -1
  174. package/dist/esm/query/builder/ref-proxy.d.ts +14 -3
  175. package/dist/esm/query/builder/ref-proxy.js.map +1 -1
  176. package/dist/esm/query/builder/types.d.ts +84 -19
  177. package/dist/esm/query/compiler/evaluators.js +51 -0
  178. package/dist/esm/query/compiler/evaluators.js.map +1 -1
  179. package/dist/esm/query/compiler/group-by.d.ts +4 -2
  180. package/dist/esm/query/compiler/group-by.js +101 -29
  181. package/dist/esm/query/compiler/group-by.js.map +1 -1
  182. package/dist/esm/query/compiler/index.d.ts +30 -2
  183. package/dist/esm/query/compiler/index.js +285 -13
  184. package/dist/esm/query/compiler/index.js.map +1 -1
  185. package/dist/esm/query/compiler/order-by.d.ts +1 -1
  186. package/dist/esm/query/compiler/order-by.js +30 -11
  187. package/dist/esm/query/compiler/order-by.js.map +1 -1
  188. package/dist/esm/query/compiler/select.js +8 -0
  189. package/dist/esm/query/compiler/select.js.map +1 -1
  190. package/dist/esm/query/index.d.ts +2 -1
  191. package/dist/esm/query/ir.d.ts +21 -1
  192. package/dist/esm/query/ir.js +18 -1
  193. package/dist/esm/query/ir.js.map +1 -1
  194. package/dist/esm/query/live/collection-config-builder.d.ts +7 -0
  195. package/dist/esm/query/live/collection-config-builder.js +503 -7
  196. package/dist/esm/query/live/collection-config-builder.js.map +1 -1
  197. package/dist/esm/query/live/types.d.ts +3 -3
  198. package/dist/esm/query/live/utils.d.ts +1 -0
  199. package/dist/esm/query/live/utils.js +43 -3
  200. package/dist/esm/query/live/utils.js.map +1 -1
  201. package/dist/esm/query/live-query-collection.d.ts +9 -6
  202. package/dist/esm/query/live-query-collection.js.map +1 -1
  203. package/dist/esm/query/query-once.d.ts +7 -5
  204. package/dist/esm/query/query-once.js.map +1 -1
  205. package/dist/esm/query/subset-dedupe.js +9 -3
  206. package/dist/esm/query/subset-dedupe.js.map +1 -1
  207. package/dist/esm/types.d.ts +42 -8
  208. package/dist/esm/utils/array-utils.d.ts +16 -0
  209. package/dist/esm/utils/array-utils.js +27 -0
  210. package/dist/esm/utils/array-utils.js.map +1 -0
  211. package/dist/esm/utils/comparison.js +11 -0
  212. package/dist/esm/utils/comparison.js.map +1 -1
  213. package/dist/esm/utils/index-optimization.js +4 -0
  214. package/dist/esm/utils/index-optimization.js.map +1 -1
  215. package/dist/esm/utils.d.ts +6 -1
  216. package/dist/esm/utils.js +7 -9
  217. package/dist/esm/utils.js.map +1 -1
  218. package/dist/esm/virtual-props.d.ts +196 -0
  219. package/dist/esm/virtual-props.js +33 -0
  220. package/dist/esm/virtual-props.js.map +1 -0
  221. package/package.json +2 -2
  222. package/skills/db-core/collection-setup/references/electric-adapter.md +1 -1
  223. package/src/collection/change-events.ts +13 -9
  224. package/src/collection/changes.ts +30 -7
  225. package/src/collection/cleanup-queue.ts +105 -0
  226. package/src/collection/events.ts +65 -0
  227. package/src/collection/index.ts +110 -45
  228. package/src/collection/indexes.ts +283 -76
  229. package/src/collection/lifecycle.ts +5 -26
  230. package/src/collection/mutations.ts +21 -0
  231. package/src/collection/state.ts +545 -71
  232. package/src/collection/subscription.ts +7 -0
  233. package/src/collection/sync.ts +137 -0
  234. package/src/collection/transaction-metadata.ts +1 -0
  235. package/src/errors.ts +9 -0
  236. package/src/index.ts +46 -3
  237. package/src/indexes/auto-index.ts +18 -8
  238. package/src/indexes/base-index.ts +2 -10
  239. package/src/indexes/basic-index.ts +507 -0
  240. package/src/indexes/btree-index.ts +1 -1
  241. package/src/indexes/index-options.ts +17 -37
  242. package/src/indexes/index-registry.ts +174 -0
  243. package/src/local-only.ts +7 -0
  244. package/src/query/builder/functions.ts +84 -7
  245. package/src/query/builder/index.ts +329 -9
  246. package/src/query/builder/ref-proxy.ts +22 -4
  247. package/src/query/builder/types.ts +257 -62
  248. package/src/query/compiler/evaluators.ts +57 -0
  249. package/src/query/compiler/group-by.ts +156 -35
  250. package/src/query/compiler/index.ts +445 -15
  251. package/src/query/compiler/order-by.ts +51 -12
  252. package/src/query/compiler/select.ts +9 -0
  253. package/src/query/index.ts +7 -0
  254. package/src/query/ir.ts +23 -2
  255. package/src/query/live/collection-config-builder.ts +809 -9
  256. package/src/query/live/types.ts +10 -4
  257. package/src/query/live/utils.ts +64 -3
  258. package/src/query/live-query-collection.ts +43 -18
  259. package/src/query/query-once.ts +31 -12
  260. package/src/query/subset-dedupe.ts +11 -7
  261. package/src/types.ts +49 -9
  262. package/src/utils/array-utils.ts +49 -0
  263. package/src/utils/comparison.ts +14 -0
  264. package/src/utils/index-optimization.ts +4 -0
  265. package/src/utils.ts +12 -9
  266. package/src/virtual-props.ts +282 -0
  267. package/dist/cjs/indexes/lazy-index.cjs +0 -190
  268. package/dist/cjs/indexes/lazy-index.cjs.map +0 -1
  269. package/dist/cjs/indexes/lazy-index.d.cts +0 -96
  270. package/dist/esm/indexes/lazy-index.d.ts +0 -96
  271. package/dist/esm/indexes/lazy-index.js +0 -190
  272. package/dist/esm/indexes/lazy-index.js.map +0 -1
  273. package/src/indexes/lazy-index.ts +0 -251
@@ -1,5 +1,12 @@
1
1
  import { deepEquals } from '../utils'
2
2
  import { SortedMap } from '../SortedMap'
3
+ import { enrichRowWithVirtualProps } from '../virtual-props.js'
4
+ import { DIRECT_TRANSACTION_METADATA_KEY } from './transaction-metadata.js'
5
+ import type {
6
+ VirtualOrigin,
7
+ VirtualRowProps,
8
+ WithVirtualProps,
9
+ } from '../virtual-props.js'
3
10
  import type { Transaction } from '../transactions'
4
11
  import type { StandardSchemaV1 } from '@standard-schema/spec'
5
12
  import type {
@@ -21,6 +28,8 @@ interface PendingSyncedTransaction<
21
28
  operations: Array<OptimisticChangeMessage<T>>
22
29
  truncate?: boolean
23
30
  deletedKeys: Set<string | number>
31
+ rowMetadataWrites: Map<TKey, PendingMetadataWrite>
32
+ collectionMetadataWrites: Map<string, PendingMetadataWrite>
24
33
  optimisticSnapshot?: {
25
34
  upserts: Map<TKey, T>
26
35
  deletes: Set<TKey>
@@ -33,6 +42,18 @@ interface PendingSyncedTransaction<
33
42
  immediate?: boolean
34
43
  }
35
44
 
45
+ type PendingMetadataWrite = { type: `set`; value: unknown } | { type: `delete` }
46
+
47
+ type InternalChangeMessage<
48
+ T extends object = Record<string, unknown>,
49
+ TKey extends string | number = string | number,
50
+ > = ChangeMessage<T, TKey> & {
51
+ __virtualProps?: {
52
+ value?: VirtualRowProps<TKey>
53
+ previousValue?: VirtualRowProps<TKey>
54
+ }
55
+ }
56
+
36
57
  export class CollectionStateManager<
37
58
  TOutput extends object = Record<string, unknown>,
38
59
  TKey extends string | number = string | number,
@@ -53,10 +74,45 @@ export class CollectionStateManager<
53
74
  > = []
54
75
  public syncedData: SortedMap<TKey, TOutput>
55
76
  public syncedMetadata = new Map<TKey, unknown>()
77
+ public syncedCollectionMetadata = new Map<string, unknown>()
56
78
 
57
79
  // Optimistic state tracking - make public for testing
58
80
  public optimisticUpserts = new Map<TKey, TOutput>()
59
81
  public optimisticDeletes = new Set<TKey>()
82
+ public pendingOptimisticUpserts = new Map<TKey, TOutput>()
83
+ public pendingOptimisticDeletes = new Set<TKey>()
84
+ public pendingOptimisticDirectUpserts = new Set<TKey>()
85
+ public pendingOptimisticDirectDeletes = new Set<TKey>()
86
+
87
+ /**
88
+ * Tracks the origin of confirmed changes for each row.
89
+ * 'local' = change originated from this client
90
+ * 'remote' = change was received via sync
91
+ *
92
+ * This is used for the $origin virtual property.
93
+ * Note: This only tracks *confirmed* changes, not optimistic ones.
94
+ * Optimistic changes are always considered 'local' for $origin.
95
+ */
96
+ public rowOrigins = new Map<TKey, VirtualOrigin>()
97
+
98
+ /**
99
+ * Tracks keys that have pending local changes.
100
+ * Used to determine whether sync-confirmed data should have 'local' or 'remote' origin.
101
+ * When sync confirms data for a key with pending local changes, it keeps 'local' origin.
102
+ */
103
+ public pendingLocalChanges = new Set<TKey>()
104
+ public pendingLocalOrigins = new Set<TKey>()
105
+
106
+ private virtualPropsCache = new WeakMap<
107
+ object,
108
+ {
109
+ synced: boolean
110
+ origin: VirtualOrigin
111
+ key: TKey
112
+ collectionId: string
113
+ enriched: WithVirtualProps<TOutput, TKey>
114
+ }
115
+ >()
60
116
 
61
117
  // Cached size for performance
62
118
  public size = 0
@@ -67,6 +123,7 @@ export class CollectionStateManager<
67
123
  public recentlySyncedKeys = new Set<TKey>()
68
124
  public hasReceivedFirstCommit = false
69
125
  public isCommittingSyncTransactions = false
126
+ public isLocalOnly = false
70
127
 
71
128
  /**
72
129
  * Creates a new CollectionState manager
@@ -96,6 +153,183 @@ export class CollectionStateManager<
96
153
  this._events = deps.events
97
154
  }
98
155
 
156
+ /**
157
+ * Checks if a row has pending optimistic mutations (not yet confirmed by sync).
158
+ * Used to compute the $synced virtual property.
159
+ */
160
+ public isRowSynced(key: TKey): boolean {
161
+ if (this.isLocalOnly) {
162
+ return true
163
+ }
164
+ return !this.optimisticUpserts.has(key) && !this.optimisticDeletes.has(key)
165
+ }
166
+
167
+ /**
168
+ * Gets the origin of the last confirmed change to a row.
169
+ * Returns 'local' if the row has optimistic mutations (optimistic changes are local).
170
+ * Used to compute the $origin virtual property.
171
+ */
172
+ public getRowOrigin(key: TKey): VirtualOrigin {
173
+ if (this.isLocalOnly) {
174
+ return 'local'
175
+ }
176
+ // If there are optimistic changes, they're local
177
+ if (this.optimisticUpserts.has(key) || this.optimisticDeletes.has(key)) {
178
+ return 'local'
179
+ }
180
+ // Otherwise, return the confirmed origin (defaults to 'remote' for synced data)
181
+ return this.rowOrigins.get(key) ?? 'remote'
182
+ }
183
+
184
+ private createVirtualPropsSnapshot(
185
+ key: TKey,
186
+ overrides?: Partial<VirtualRowProps<TKey>>,
187
+ ): VirtualRowProps<TKey> {
188
+ return {
189
+ $synced: overrides?.$synced ?? this.isRowSynced(key),
190
+ $origin: overrides?.$origin ?? this.getRowOrigin(key),
191
+ $key: overrides?.$key ?? key,
192
+ $collectionId: overrides?.$collectionId ?? this.collection.id,
193
+ }
194
+ }
195
+
196
+ private getVirtualPropsSnapshotForState(
197
+ key: TKey,
198
+ options?: {
199
+ rowOrigins?: ReadonlyMap<TKey, VirtualOrigin>
200
+ optimisticUpserts?: Pick<Map<TKey, unknown>, 'has'>
201
+ optimisticDeletes?: Pick<Set<TKey>, 'has'>
202
+ completedOptimisticKeys?: Pick<Map<TKey, unknown>, 'has'>
203
+ },
204
+ ): VirtualRowProps<TKey> {
205
+ if (this.isLocalOnly) {
206
+ return this.createVirtualPropsSnapshot(key, {
207
+ $synced: true,
208
+ $origin: 'local',
209
+ })
210
+ }
211
+
212
+ const optimisticUpserts =
213
+ options?.optimisticUpserts ?? this.optimisticUpserts
214
+ const optimisticDeletes =
215
+ options?.optimisticDeletes ?? this.optimisticDeletes
216
+ const hasOptimisticChange =
217
+ optimisticUpserts.has(key) ||
218
+ optimisticDeletes.has(key) ||
219
+ options?.completedOptimisticKeys?.has(key) === true
220
+
221
+ return this.createVirtualPropsSnapshot(key, {
222
+ $synced: !hasOptimisticChange,
223
+ $origin: hasOptimisticChange
224
+ ? 'local'
225
+ : ((options?.rowOrigins ?? this.rowOrigins).get(key) ?? 'remote'),
226
+ })
227
+ }
228
+
229
+ private enrichWithVirtualPropsSnapshot(
230
+ row: TOutput,
231
+ virtualProps: VirtualRowProps<TKey>,
232
+ ): WithVirtualProps<TOutput, TKey> {
233
+ const existingRow = row as Partial<WithVirtualProps<TOutput, TKey>>
234
+ const synced = existingRow.$synced ?? virtualProps.$synced
235
+ const origin = existingRow.$origin ?? virtualProps.$origin
236
+ const resolvedKey = existingRow.$key ?? virtualProps.$key
237
+ const collectionId = existingRow.$collectionId ?? virtualProps.$collectionId
238
+
239
+ const cached = this.virtualPropsCache.get(row as object)
240
+ if (
241
+ cached &&
242
+ cached.synced === synced &&
243
+ cached.origin === origin &&
244
+ cached.key === resolvedKey &&
245
+ cached.collectionId === collectionId
246
+ ) {
247
+ return cached.enriched
248
+ }
249
+
250
+ const enriched = {
251
+ ...row,
252
+ $synced: synced,
253
+ $origin: origin,
254
+ $key: resolvedKey,
255
+ $collectionId: collectionId,
256
+ } as WithVirtualProps<TOutput, TKey>
257
+
258
+ this.virtualPropsCache.set(row as object, {
259
+ synced,
260
+ origin,
261
+ key: resolvedKey,
262
+ collectionId,
263
+ enriched,
264
+ })
265
+
266
+ return enriched
267
+ }
268
+
269
+ private clearOriginTrackingState(): void {
270
+ this.rowOrigins.clear()
271
+ this.pendingLocalChanges.clear()
272
+ this.pendingLocalOrigins.clear()
273
+ }
274
+
275
+ /**
276
+ * Enriches a row with virtual properties using the "add-if-missing" pattern.
277
+ * If the row already has virtual properties (from an upstream collection),
278
+ * they are preserved. Otherwise, new values are computed.
279
+ */
280
+ public enrichWithVirtualProps(
281
+ row: TOutput,
282
+ key: TKey,
283
+ ): WithVirtualProps<TOutput, TKey> {
284
+ return this.enrichWithVirtualPropsSnapshot(
285
+ row,
286
+ this.createVirtualPropsSnapshot(key),
287
+ )
288
+ }
289
+
290
+ /**
291
+ * Creates a change message with virtual properties.
292
+ * Uses the "add-if-missing" pattern so that pass-through from upstream
293
+ * collections works correctly.
294
+ */
295
+ public enrichChangeMessage(
296
+ change: ChangeMessage<TOutput, TKey>,
297
+ ): ChangeMessage<WithVirtualProps<TOutput, TKey>, TKey> {
298
+ const { __virtualProps } = change as InternalChangeMessage<TOutput, TKey>
299
+ const enrichedValue = __virtualProps?.value
300
+ ? this.enrichWithVirtualPropsSnapshot(change.value, __virtualProps.value)
301
+ : this.enrichWithVirtualProps(change.value, change.key)
302
+ const enrichedPreviousValue = change.previousValue
303
+ ? __virtualProps?.previousValue
304
+ ? this.enrichWithVirtualPropsSnapshot(
305
+ change.previousValue,
306
+ __virtualProps.previousValue,
307
+ )
308
+ : this.enrichWithVirtualProps(change.previousValue, change.key)
309
+ : undefined
310
+
311
+ return {
312
+ key: change.key,
313
+ type: change.type,
314
+ value: enrichedValue,
315
+ previousValue: enrichedPreviousValue,
316
+ metadata: change.metadata,
317
+ } as ChangeMessage<WithVirtualProps<TOutput, TKey>, TKey>
318
+ }
319
+
320
+ /**
321
+ * Get the current value for a key enriched with virtual properties.
322
+ */
323
+ public getWithVirtualProps(
324
+ key: TKey,
325
+ ): WithVirtualProps<TOutput, TKey> | undefined {
326
+ const value = this.get(key)
327
+ if (value === undefined) {
328
+ return undefined
329
+ }
330
+ return this.enrichWithVirtualProps(value, key)
331
+ }
332
+
99
333
  /**
100
334
  * Get the current value for a key (virtual derived state)
101
335
  */
@@ -242,10 +476,108 @@ export class CollectionStateManager<
242
476
 
243
477
  const previousState = new Map(this.optimisticUpserts)
244
478
  const previousDeletes = new Set(this.optimisticDeletes)
479
+ const previousRowOrigins = new Map(this.rowOrigins)
480
+
481
+ // Update pending optimistic state for completed/failed transactions
482
+ for (const transaction of this.transactions.values()) {
483
+ const isDirectTransaction =
484
+ transaction.metadata[DIRECT_TRANSACTION_METADATA_KEY] === true
485
+ if (transaction.state === `completed`) {
486
+ for (const mutation of transaction.mutations) {
487
+ if (!this.isThisCollection(mutation.collection)) {
488
+ continue
489
+ }
490
+ this.pendingLocalOrigins.add(mutation.key)
491
+ if (!mutation.optimistic) {
492
+ continue
493
+ }
494
+ switch (mutation.type) {
495
+ case `insert`:
496
+ case `update`:
497
+ this.pendingOptimisticUpserts.set(
498
+ mutation.key,
499
+ mutation.modified as TOutput,
500
+ )
501
+ this.pendingOptimisticDeletes.delete(mutation.key)
502
+ if (isDirectTransaction) {
503
+ this.pendingOptimisticDirectUpserts.add(mutation.key)
504
+ this.pendingOptimisticDirectDeletes.delete(mutation.key)
505
+ } else {
506
+ this.pendingOptimisticDirectUpserts.delete(mutation.key)
507
+ this.pendingOptimisticDirectDeletes.delete(mutation.key)
508
+ }
509
+ break
510
+ case `delete`:
511
+ this.pendingOptimisticUpserts.delete(mutation.key)
512
+ this.pendingOptimisticDeletes.add(mutation.key)
513
+ if (isDirectTransaction) {
514
+ this.pendingOptimisticDirectUpserts.delete(mutation.key)
515
+ this.pendingOptimisticDirectDeletes.add(mutation.key)
516
+ } else {
517
+ this.pendingOptimisticDirectUpserts.delete(mutation.key)
518
+ this.pendingOptimisticDirectDeletes.delete(mutation.key)
519
+ }
520
+ break
521
+ }
522
+ }
523
+ } else if (transaction.state === `failed`) {
524
+ for (const mutation of transaction.mutations) {
525
+ if (!this.isThisCollection(mutation.collection)) {
526
+ continue
527
+ }
528
+ this.pendingLocalOrigins.delete(mutation.key)
529
+ if (mutation.optimistic) {
530
+ this.pendingOptimisticUpserts.delete(mutation.key)
531
+ this.pendingOptimisticDeletes.delete(mutation.key)
532
+ this.pendingOptimisticDirectUpserts.delete(mutation.key)
533
+ this.pendingOptimisticDirectDeletes.delete(mutation.key)
534
+ }
535
+ }
536
+ }
537
+ }
245
538
 
246
539
  // Clear current optimistic state
247
540
  this.optimisticUpserts.clear()
248
541
  this.optimisticDeletes.clear()
542
+ this.pendingLocalChanges.clear()
543
+
544
+ // Seed optimistic state with pending optimistic mutations only when a sync is pending
545
+ const pendingSyncKeys = new Set<TKey>()
546
+ for (const transaction of this.pendingSyncedTransactions) {
547
+ for (const operation of transaction.operations) {
548
+ pendingSyncKeys.add(operation.key as TKey)
549
+ }
550
+ }
551
+ const staleOptimisticUpserts: Array<TKey> = []
552
+ for (const [key, value] of this.pendingOptimisticUpserts) {
553
+ if (
554
+ pendingSyncKeys.has(key) ||
555
+ this.pendingOptimisticDirectUpserts.has(key)
556
+ ) {
557
+ this.optimisticUpserts.set(key, value)
558
+ } else {
559
+ staleOptimisticUpserts.push(key)
560
+ }
561
+ }
562
+ for (const key of staleOptimisticUpserts) {
563
+ this.pendingOptimisticUpserts.delete(key)
564
+ this.pendingLocalOrigins.delete(key)
565
+ }
566
+ const staleOptimisticDeletes: Array<TKey> = []
567
+ for (const key of this.pendingOptimisticDeletes) {
568
+ if (
569
+ pendingSyncKeys.has(key) ||
570
+ this.pendingOptimisticDirectDeletes.has(key)
571
+ ) {
572
+ this.optimisticDeletes.add(key)
573
+ } else {
574
+ staleOptimisticDeletes.push(key)
575
+ }
576
+ }
577
+ for (const key of staleOptimisticDeletes) {
578
+ this.pendingOptimisticDeletes.delete(key)
579
+ this.pendingLocalOrigins.delete(key)
580
+ }
249
581
 
250
582
  const activeTransactions: Array<Transaction<any>> = []
251
583
 
@@ -258,7 +590,14 @@ export class CollectionStateManager<
258
590
  // Apply active transactions only (completed transactions are handled by sync operations)
259
591
  for (const transaction of activeTransactions) {
260
592
  for (const mutation of transaction.mutations) {
261
- if (this.isThisCollection(mutation.collection) && mutation.optimistic) {
593
+ if (!this.isThisCollection(mutation.collection)) {
594
+ continue
595
+ }
596
+
597
+ // Track that this key has pending local changes for $origin tracking
598
+ this.pendingLocalChanges.add(mutation.key)
599
+
600
+ if (mutation.optimistic) {
262
601
  switch (mutation.type) {
263
602
  case `insert`:
264
603
  case `update`:
@@ -281,8 +620,13 @@ export class CollectionStateManager<
281
620
  this.size = this.calculateSize()
282
621
 
283
622
  // Collect events for changes
284
- const events: Array<ChangeMessage<TOutput, TKey>> = []
285
- this.collectOptimisticChanges(previousState, previousDeletes, events)
623
+ const events: Array<InternalChangeMessage<TOutput, TKey>> = []
624
+ this.collectOptimisticChanges(
625
+ previousState,
626
+ previousDeletes,
627
+ previousRowOrigins,
628
+ events,
629
+ )
286
630
 
287
631
  // Filter out events for recently synced keys to prevent duplicates
288
632
  // BUT: Only filter out events that are actually from sync operations
@@ -305,12 +649,12 @@ export class CollectionStateManager<
305
649
  // that will immediately restore the same data, but only for completed transactions
306
650
  // IMPORTANT: Skip complex filtering for user-triggered actions to prevent UI blocking
307
651
  if (this.pendingSyncedTransactions.length > 0 && !triggeredByUserAction) {
308
- const pendingSyncKeys = new Set<TKey>()
652
+ const pendingSyncKeysForFilter = new Set<TKey>()
309
653
 
310
654
  // Collect keys from pending sync operations
311
655
  for (const transaction of this.pendingSyncedTransactions) {
312
656
  for (const operation of transaction.operations) {
313
- pendingSyncKeys.add(operation.key as TKey)
657
+ pendingSyncKeysForFilter.add(operation.key as TKey)
314
658
  }
315
659
  }
316
660
 
@@ -318,7 +662,10 @@ export class CollectionStateManager<
318
662
  // 1. Have pending sync operations AND
319
663
  // 2. Are from completed transactions (being cleaned up)
320
664
  const filteredEvents = filteredEventsBySyncStatus.filter((event) => {
321
- if (event.type === `delete` && pendingSyncKeys.has(event.key)) {
665
+ if (
666
+ event.type === `delete` &&
667
+ pendingSyncKeysForFilter.has(event.key)
668
+ ) {
322
669
  // Check if this delete is from clearing optimistic state of completed transactions
323
670
  // We can infer this by checking if we have no remaining optimistic mutations for this key
324
671
  const hasActiveOptimisticMutation = activeTransactions.some((tx) =>
@@ -370,7 +717,8 @@ export class CollectionStateManager<
370
717
  private collectOptimisticChanges(
371
718
  previousUpserts: Map<TKey, TOutput>,
372
719
  previousDeletes: Set<TKey>,
373
- events: Array<ChangeMessage<TOutput, TKey>>,
720
+ previousRowOrigins: ReadonlyMap<TKey, VirtualOrigin>,
721
+ events: Array<InternalChangeMessage<TOutput, TKey>>,
374
722
  ): void {
375
723
  const allKeys = new Set([
376
724
  ...previousUpserts.keys(),
@@ -386,11 +734,31 @@ export class CollectionStateManager<
386
734
  previousUpserts,
387
735
  previousDeletes,
388
736
  )
737
+ const previousVirtualProps = this.getVirtualPropsSnapshotForState(key, {
738
+ rowOrigins: previousRowOrigins,
739
+ optimisticUpserts: previousUpserts,
740
+ optimisticDeletes: previousDeletes,
741
+ })
742
+ const nextVirtualProps = this.getVirtualPropsSnapshotForState(key)
389
743
 
390
744
  if (previousValue !== undefined && currentValue === undefined) {
391
- events.push({ type: `delete`, key, value: previousValue })
745
+ events.push({
746
+ type: `delete`,
747
+ key,
748
+ value: previousValue,
749
+ __virtualProps: {
750
+ value: previousVirtualProps,
751
+ },
752
+ })
392
753
  } else if (previousValue === undefined && currentValue !== undefined) {
393
- events.push({ type: `insert`, key, value: currentValue })
754
+ events.push({
755
+ type: `insert`,
756
+ key,
757
+ value: currentValue,
758
+ __virtualProps: {
759
+ value: nextVirtualProps,
760
+ },
761
+ })
394
762
  } else if (
395
763
  previousValue !== undefined &&
396
764
  currentValue !== undefined &&
@@ -401,6 +769,10 @@ export class CollectionStateManager<
401
769
  key,
402
770
  value: currentValue,
403
771
  previousValue,
772
+ __virtualProps: {
773
+ value: nextVirtualProps,
774
+ previousValue: previousVirtualProps,
775
+ },
404
776
  })
405
777
  }
406
778
  }
@@ -485,11 +857,17 @@ export class CollectionStateManager<
485
857
  // Set flag to prevent redundant optimistic state recalculations
486
858
  this.isCommittingSyncTransactions = true
487
859
 
860
+ const previousRowOrigins = new Map(this.rowOrigins)
861
+ const previousOptimisticUpserts = new Map(this.optimisticUpserts)
862
+ const previousOptimisticDeletes = new Set(this.optimisticDeletes)
863
+
488
864
  // Get the optimistic snapshot from the truncate transaction (captured when truncate() was called)
489
865
  const truncateOptimisticSnapshot = hasTruncateSync
490
866
  ? committedSyncedTransactions.find((t) => t.truncate)
491
867
  ?.optimisticSnapshot
492
868
  : null
869
+ let truncatePendingLocalChanges: Set<TKey> | undefined
870
+ let truncatePendingLocalOrigins: Set<TKey> | undefined
493
871
 
494
872
  // First collect all keys that will be affected by sync operations
495
873
  const changedKeys = new Set<TKey>()
@@ -497,6 +875,9 @@ export class CollectionStateManager<
497
875
  for (const operation of transaction.operations) {
498
876
  changedKeys.add(operation.key as TKey)
499
877
  }
878
+ for (const [key] of transaction.rowMetadataWrites) {
879
+ changedKeys.add(key)
880
+ }
500
881
  }
501
882
 
502
883
  // Use pre-captured state if available (from optimistic scenarios),
@@ -515,6 +896,25 @@ export class CollectionStateManager<
515
896
 
516
897
  const events: Array<ChangeMessage<TOutput, TKey>> = []
517
898
  const rowUpdateMode = this.config.sync.rowUpdateMode || `partial`
899
+ const completedOptimisticOps = new Map<
900
+ TKey,
901
+ { type: string; value: TOutput }
902
+ >()
903
+
904
+ for (const transaction of this.transactions.values()) {
905
+ if (transaction.state === `completed`) {
906
+ for (const mutation of transaction.mutations) {
907
+ if (this.isThisCollection(mutation.collection)) {
908
+ if (mutation.optimistic) {
909
+ completedOptimisticOps.set(mutation.key, {
910
+ type: mutation.type,
911
+ value: mutation.modified as TOutput,
912
+ })
913
+ }
914
+ }
915
+ }
916
+ }
917
+ }
518
918
 
519
919
  for (const transaction of committedSyncedTransactions) {
520
920
  // Handle truncate operations first
@@ -540,9 +940,14 @@ export class CollectionStateManager<
540
940
 
541
941
  // 2) Clear the authoritative synced base. Subsequent server ops in this
542
942
  // same commit will rebuild the base atomically.
943
+ // Preserve pending local tracking just long enough for operations in this
944
+ // truncate batch to retain correct local origin semantics.
945
+ truncatePendingLocalChanges = new Set(this.pendingLocalChanges)
946
+ truncatePendingLocalOrigins = new Set(this.pendingLocalOrigins)
543
947
  this.syncedData.clear()
544
948
  this.syncedMetadata.clear()
545
949
  this.syncedKeys.clear()
950
+ this.clearOriginTrackingState()
546
951
 
547
952
  // 3) Clear currentVisibleState for truncated keys to ensure subsequent operations
548
953
  // are compared against the post-truncate state (undefined) rather than pre-truncate state
@@ -562,30 +967,28 @@ export class CollectionStateManager<
562
967
  const key = operation.key as TKey
563
968
  this.syncedKeys.add(key)
564
969
 
565
- // Update metadata
566
- switch (operation.type) {
567
- case `insert`:
568
- this.syncedMetadata.set(key, operation.metadata)
569
- break
570
- case `update`:
571
- this.syncedMetadata.set(
572
- key,
573
- Object.assign(
574
- {},
575
- this.syncedMetadata.get(key),
576
- operation.metadata,
577
- ),
578
- )
579
- break
580
- case `delete`:
581
- this.syncedMetadata.delete(key)
582
- break
583
- }
970
+ // Determine origin: 'local' for local-only collections or pending local changes
971
+ const origin: VirtualOrigin =
972
+ this.isLocalOnly ||
973
+ this.pendingLocalChanges.has(key) ||
974
+ this.pendingLocalOrigins.has(key) ||
975
+ truncatePendingLocalChanges?.has(key) === true ||
976
+ truncatePendingLocalOrigins?.has(key) === true
977
+ ? 'local'
978
+ : 'remote'
584
979
 
585
980
  // Update synced data
586
981
  switch (operation.type) {
587
982
  case `insert`:
588
983
  this.syncedData.set(key, operation.value)
984
+ this.rowOrigins.set(key, origin)
985
+ // Clear pending local changes now that sync has confirmed
986
+ this.pendingLocalChanges.delete(key)
987
+ this.pendingLocalOrigins.delete(key)
988
+ this.pendingOptimisticUpserts.delete(key)
989
+ this.pendingOptimisticDeletes.delete(key)
990
+ this.pendingOptimisticDirectUpserts.delete(key)
991
+ this.pendingOptimisticDirectDeletes.delete(key)
589
992
  break
590
993
  case `update`: {
591
994
  if (rowUpdateMode === `partial`) {
@@ -598,13 +1001,49 @@ export class CollectionStateManager<
598
1001
  } else {
599
1002
  this.syncedData.set(key, operation.value)
600
1003
  }
1004
+ this.rowOrigins.set(key, origin)
1005
+ // Clear pending local changes now that sync has confirmed
1006
+ this.pendingLocalChanges.delete(key)
1007
+ this.pendingLocalOrigins.delete(key)
1008
+ this.pendingOptimisticUpserts.delete(key)
1009
+ this.pendingOptimisticDeletes.delete(key)
1010
+ this.pendingOptimisticDirectUpserts.delete(key)
1011
+ this.pendingOptimisticDirectDeletes.delete(key)
601
1012
  break
602
1013
  }
603
1014
  case `delete`:
604
1015
  this.syncedData.delete(key)
1016
+ this.syncedMetadata.delete(key)
1017
+ // Clean up origin and pending tracking for deleted rows
1018
+ this.rowOrigins.delete(key)
1019
+ this.pendingLocalChanges.delete(key)
1020
+ this.pendingLocalOrigins.delete(key)
1021
+ this.pendingOptimisticUpserts.delete(key)
1022
+ this.pendingOptimisticDeletes.delete(key)
1023
+ this.pendingOptimisticDirectUpserts.delete(key)
1024
+ this.pendingOptimisticDirectDeletes.delete(key)
605
1025
  break
606
1026
  }
607
1027
  }
1028
+
1029
+ for (const [key, metadataWrite] of transaction.rowMetadataWrites) {
1030
+ if (metadataWrite.type === `delete`) {
1031
+ this.syncedMetadata.delete(key)
1032
+ continue
1033
+ }
1034
+ this.syncedMetadata.set(key, metadataWrite.value)
1035
+ }
1036
+
1037
+ for (const [
1038
+ key,
1039
+ metadataWrite,
1040
+ ] of transaction.collectionMetadataWrites) {
1041
+ if (metadataWrite.type === `delete`) {
1042
+ this.syncedCollectionMetadata.delete(key)
1043
+ continue
1044
+ }
1045
+ this.syncedCollectionMetadata.set(key, metadataWrite.value)
1046
+ }
608
1047
  }
609
1048
 
610
1049
  // After applying synced operations, if this commit included a truncate,
@@ -721,30 +1160,30 @@ export class CollectionStateManager<
721
1160
  }
722
1161
  }
723
1162
 
724
- // Check for redundant sync operations that match completed optimistic operations
725
- const completedOptimisticOps = new Map<TKey, any>()
726
-
727
- for (const transaction of this.transactions.values()) {
728
- if (transaction.state === `completed`) {
729
- for (const mutation of transaction.mutations) {
730
- if (
731
- mutation.optimistic &&
732
- this.isThisCollection(mutation.collection) &&
733
- changedKeys.has(mutation.key)
734
- ) {
735
- completedOptimisticOps.set(mutation.key, {
736
- type: mutation.type,
737
- value: mutation.modified,
738
- })
739
- }
740
- }
741
- }
742
- }
743
-
744
1163
  // Now check what actually changed in the final visible state
745
1164
  for (const key of changedKeys) {
746
1165
  const previousVisibleValue = currentVisibleState.get(key)
747
1166
  const newVisibleValue = this.get(key) // This returns the new derived state
1167
+ const previousVirtualProps = this.getVirtualPropsSnapshotForState(key, {
1168
+ rowOrigins: previousRowOrigins,
1169
+ optimisticUpserts: previousOptimisticUpserts,
1170
+ optimisticDeletes: previousOptimisticDeletes,
1171
+ completedOptimisticKeys: completedOptimisticOps,
1172
+ })
1173
+ const nextVirtualProps = this.getVirtualPropsSnapshotForState(key)
1174
+ const virtualChanged =
1175
+ previousVirtualProps.$synced !== nextVirtualProps.$synced ||
1176
+ previousVirtualProps.$origin !== nextVirtualProps.$origin
1177
+ const previousValueWithVirtual =
1178
+ previousVisibleValue !== undefined
1179
+ ? enrichRowWithVirtualProps(
1180
+ previousVisibleValue,
1181
+ key,
1182
+ this.collection.id,
1183
+ () => previousVirtualProps.$synced,
1184
+ () => previousVirtualProps.$origin,
1185
+ )
1186
+ : undefined
748
1187
 
749
1188
  // Check if this sync operation is redundant with a completed optimistic operation
750
1189
  const completedOp = completedOptimisticOps.get(key)
@@ -766,37 +1205,65 @@ export class CollectionStateManager<
766
1205
  }
767
1206
  }
768
1207
 
769
- if (!isRedundantSync) {
770
- if (
771
- previousVisibleValue === undefined &&
772
- newVisibleValue !== undefined
773
- ) {
1208
+ const shouldEmitVirtualUpdate =
1209
+ virtualChanged &&
1210
+ previousVisibleValue !== undefined &&
1211
+ newVisibleValue !== undefined &&
1212
+ deepEquals(previousVisibleValue, newVisibleValue)
1213
+
1214
+ if (isRedundantSync && !shouldEmitVirtualUpdate) {
1215
+ continue
1216
+ }
1217
+
1218
+ if (
1219
+ previousVisibleValue === undefined &&
1220
+ newVisibleValue !== undefined
1221
+ ) {
1222
+ const completedOptimisticOp = completedOptimisticOps.get(key)
1223
+ if (completedOptimisticOp) {
1224
+ const previousValueFromCompleted = completedOptimisticOp.value
1225
+ const previousValueWithVirtualFromCompleted =
1226
+ enrichRowWithVirtualProps(
1227
+ previousValueFromCompleted,
1228
+ key,
1229
+ this.collection.id,
1230
+ () => previousVirtualProps.$synced,
1231
+ () => previousVirtualProps.$origin,
1232
+ )
774
1233
  events.push({
775
- type: `insert`,
1234
+ type: `update`,
776
1235
  key,
777
1236
  value: newVisibleValue,
1237
+ previousValue: previousValueWithVirtualFromCompleted,
778
1238
  })
779
- } else if (
780
- previousVisibleValue !== undefined &&
781
- newVisibleValue === undefined
782
- ) {
783
- events.push({
784
- type: `delete`,
785
- key,
786
- value: previousVisibleValue,
787
- })
788
- } else if (
789
- previousVisibleValue !== undefined &&
790
- newVisibleValue !== undefined &&
791
- !deepEquals(previousVisibleValue, newVisibleValue)
792
- ) {
1239
+ } else {
793
1240
  events.push({
794
- type: `update`,
1241
+ type: `insert`,
795
1242
  key,
796
1243
  value: newVisibleValue,
797
- previousValue: previousVisibleValue,
798
1244
  })
799
1245
  }
1246
+ } else if (
1247
+ previousVisibleValue !== undefined &&
1248
+ newVisibleValue === undefined
1249
+ ) {
1250
+ events.push({
1251
+ type: `delete`,
1252
+ key,
1253
+ value: previousValueWithVirtual ?? previousVisibleValue,
1254
+ })
1255
+ } else if (
1256
+ previousVisibleValue !== undefined &&
1257
+ newVisibleValue !== undefined &&
1258
+ (!deepEquals(previousVisibleValue, newVisibleValue) ||
1259
+ shouldEmitVirtualUpdate)
1260
+ ) {
1261
+ events.push({
1262
+ type: `update`,
1263
+ key,
1264
+ value: newVisibleValue,
1265
+ previousValue: previousValueWithVirtual ?? previousVisibleValue,
1266
+ })
800
1267
  }
801
1268
  }
802
1269
 
@@ -847,7 +1314,7 @@ export class CollectionStateManager<
847
1314
  .catch(() => {
848
1315
  // Transaction failed, but we want to keep failed transactions for reference
849
1316
  // so don't remove it.
850
- // This empty catch block is necessary to prevent unhandled promise rejections.
1317
+ // Rollback already triggers state recomputation via touchCollection().
851
1318
  })
852
1319
  }
853
1320
 
@@ -906,8 +1373,15 @@ export class CollectionStateManager<
906
1373
  public cleanup(): void {
907
1374
  this.syncedData.clear()
908
1375
  this.syncedMetadata.clear()
1376
+ this.syncedCollectionMetadata.clear()
909
1377
  this.optimisticUpserts.clear()
910
1378
  this.optimisticDeletes.clear()
1379
+ this.pendingOptimisticUpserts.clear()
1380
+ this.pendingOptimisticDeletes.clear()
1381
+ this.pendingOptimisticDirectUpserts.clear()
1382
+ this.pendingOptimisticDirectDeletes.clear()
1383
+ this.clearOriginTrackingState()
1384
+ this.isLocalOnly = false
911
1385
  this.size = 0
912
1386
  this.pendingSyncedTransactions = []
913
1387
  this.syncedKeys.clear()