@tanstack/db 0.0.11 → 0.0.13

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 (47) hide show
  1. package/dist/cjs/SortedMap.cjs +38 -11
  2. package/dist/cjs/SortedMap.cjs.map +1 -1
  3. package/dist/cjs/SortedMap.d.cts +10 -0
  4. package/dist/cjs/collection.cjs +476 -144
  5. package/dist/cjs/collection.cjs.map +1 -1
  6. package/dist/cjs/collection.d.cts +107 -32
  7. package/dist/cjs/index.cjs +2 -1
  8. package/dist/cjs/index.cjs.map +1 -1
  9. package/dist/cjs/index.d.cts +1 -0
  10. package/dist/cjs/optimistic-action.cjs +21 -0
  11. package/dist/cjs/optimistic-action.cjs.map +1 -0
  12. package/dist/cjs/optimistic-action.d.cts +39 -0
  13. package/dist/cjs/query/compiled-query.cjs +38 -16
  14. package/dist/cjs/query/compiled-query.cjs.map +1 -1
  15. package/dist/cjs/query/query-builder.cjs +2 -2
  16. package/dist/cjs/query/query-builder.cjs.map +1 -1
  17. package/dist/cjs/transactions.cjs +3 -1
  18. package/dist/cjs/transactions.cjs.map +1 -1
  19. package/dist/cjs/types.d.cts +83 -10
  20. package/dist/esm/SortedMap.d.ts +10 -0
  21. package/dist/esm/SortedMap.js +38 -11
  22. package/dist/esm/SortedMap.js.map +1 -1
  23. package/dist/esm/collection.d.ts +107 -32
  24. package/dist/esm/collection.js +477 -145
  25. package/dist/esm/collection.js.map +1 -1
  26. package/dist/esm/index.d.ts +1 -0
  27. package/dist/esm/index.js +3 -2
  28. package/dist/esm/index.js.map +1 -1
  29. package/dist/esm/optimistic-action.d.ts +39 -0
  30. package/dist/esm/optimistic-action.js +21 -0
  31. package/dist/esm/optimistic-action.js.map +1 -0
  32. package/dist/esm/query/compiled-query.js +38 -16
  33. package/dist/esm/query/compiled-query.js.map +1 -1
  34. package/dist/esm/query/query-builder.js +2 -2
  35. package/dist/esm/query/query-builder.js.map +1 -1
  36. package/dist/esm/transactions.js +3 -1
  37. package/dist/esm/transactions.js.map +1 -1
  38. package/dist/esm/types.d.ts +83 -10
  39. package/package.json +1 -1
  40. package/src/SortedMap.ts +46 -13
  41. package/src/collection.ts +689 -239
  42. package/src/index.ts +1 -0
  43. package/src/optimistic-action.ts +65 -0
  44. package/src/query/compiled-query.ts +79 -21
  45. package/src/query/query-builder.ts +2 -2
  46. package/src/transactions.ts +6 -1
  47. package/src/types.ts +124 -8
@@ -3,7 +3,6 @@ import { withArrayChangeTracking, withChangeTracking } from "./proxy.js";
3
3
  import { getActiveTransaction, Transaction } from "./transactions.js";
4
4
  import { SortedMap } from "./SortedMap.js";
5
5
  const collectionsStore = /* @__PURE__ */ new Map();
6
- const loadingCollectionResolvers = /* @__PURE__ */ new Map();
7
6
  function createCollection(options) {
8
7
  const collection = new CollectionImpl(options);
9
8
  if (options.utils) {
@@ -13,53 +12,10 @@ function createCollection(options) {
13
12
  }
14
13
  return collection;
15
14
  }
16
- function preloadCollection(config) {
17
- if (!config.id) {
18
- throw new Error(`The id property is required for preloadCollection`);
19
- }
20
- if (collectionsStore.has(config.id) && !loadingCollectionResolvers.has(config.id)) {
21
- return Promise.resolve(
22
- collectionsStore.get(config.id)
23
- );
24
- }
25
- if (loadingCollectionResolvers.has(config.id)) {
26
- return loadingCollectionResolvers.get(config.id).promise;
27
- }
28
- if (!collectionsStore.has(config.id)) {
29
- collectionsStore.set(
30
- config.id,
31
- createCollection({
32
- id: config.id,
33
- getKey: config.getKey,
34
- sync: config.sync,
35
- schema: config.schema
36
- })
37
- );
38
- }
39
- const collection = collectionsStore.get(config.id);
40
- let resolveFirstCommit;
41
- const firstCommitPromise = new Promise((resolve) => {
42
- resolveFirstCommit = resolve;
43
- });
44
- loadingCollectionResolvers.set(config.id, {
45
- promise: firstCommitPromise,
46
- resolve: resolveFirstCommit
47
- });
48
- collection.onFirstCommit(() => {
49
- if (!config.id) {
50
- throw new Error(`The id property is required for preloadCollection`);
51
- }
52
- if (loadingCollectionResolvers.has(config.id)) {
53
- const resolver = loadingCollectionResolvers.get(config.id);
54
- loadingCollectionResolvers.delete(config.id);
55
- resolver.resolve(collection);
56
- }
57
- });
58
- return firstCommitPromise;
59
- }
60
15
  class SchemaValidationError extends Error {
61
16
  constructor(type, issues, message) {
62
- const defaultMessage = `${type === `insert` ? `Insert` : `Update`} validation failed: ${issues.map((issue) => issue.message).join(`, `)}`;
17
+ const defaultMessage = `${type === `insert` ? `Insert` : `Update`} validation failed: ${issues.map((issue) => `
18
+ - ${issue.message} - path: ${issue.path}`).join(``)}`;
63
19
  super(message || defaultMessage);
64
20
  this.name = `SchemaValidationError`;
65
21
  this.type = type;
@@ -74,7 +30,7 @@ class CollectionImpl {
74
30
  * @throws Error if sync config is missing
75
31
  */
76
32
  constructor(config) {
77
- this.syncedData = /* @__PURE__ */ new Map();
33
+ this.pendingSyncedTransactions = [];
78
34
  this.syncedMetadata = /* @__PURE__ */ new Map();
79
35
  this.derivedUpserts = /* @__PURE__ */ new Map();
80
36
  this.derivedDeletes = /* @__PURE__ */ new Set();
@@ -82,21 +38,48 @@ class CollectionImpl {
82
38
  this.changeListeners = /* @__PURE__ */ new Set();
83
39
  this.changeKeyListeners = /* @__PURE__ */ new Map();
84
40
  this.utils = {};
85
- this.pendingSyncedTransactions = [];
86
41
  this.syncedKeys = /* @__PURE__ */ new Set();
42
+ this.preSyncVisibleState = /* @__PURE__ */ new Map();
43
+ this.recentlySyncedKeys = /* @__PURE__ */ new Set();
87
44
  this.hasReceivedFirstCommit = false;
45
+ this.isCommittingSyncTransactions = false;
88
46
  this.onFirstCommitCallbacks = [];
47
+ this._status = `idle`;
48
+ this.activeSubscribersCount = 0;
49
+ this.gcTimeoutId = null;
50
+ this.preloadPromise = null;
51
+ this.syncCleanupFn = null;
89
52
  this.id = ``;
90
53
  this.commitPendingTransactions = () => {
91
- if (!Array.from(this.transactions.values()).some(
92
- ({ state }) => state === `persisting`
93
- )) {
54
+ let hasPersistingTransaction = false;
55
+ for (const transaction of this.transactions.values()) {
56
+ if (transaction.state === `persisting`) {
57
+ hasPersistingTransaction = true;
58
+ break;
59
+ }
60
+ }
61
+ if (!hasPersistingTransaction) {
62
+ this.isCommittingSyncTransactions = true;
94
63
  const changedKeys = /* @__PURE__ */ new Set();
64
+ for (const transaction of this.pendingSyncedTransactions) {
65
+ for (const operation of transaction.operations) {
66
+ changedKeys.add(operation.key);
67
+ }
68
+ }
69
+ let currentVisibleState = this.preSyncVisibleState;
70
+ if (currentVisibleState.size === 0) {
71
+ currentVisibleState = /* @__PURE__ */ new Map();
72
+ for (const key of changedKeys) {
73
+ const currentValue = this.get(key);
74
+ if (currentValue !== void 0) {
75
+ currentVisibleState.set(key, currentValue);
76
+ }
77
+ }
78
+ }
95
79
  const events = [];
96
80
  for (const transaction of this.pendingSyncedTransactions) {
97
81
  for (const operation of transaction.operations) {
98
82
  const key = operation.key;
99
- changedKeys.add(key);
100
83
  this.syncedKeys.add(key);
101
84
  switch (operation.type) {
102
85
  case `insert`:
@@ -116,17 +99,9 @@ class CollectionImpl {
116
99
  this.syncedMetadata.delete(key);
117
100
  break;
118
101
  }
119
- const previousValue = this.syncedData.get(key);
120
102
  switch (operation.type) {
121
103
  case `insert`:
122
104
  this.syncedData.set(key, operation.value);
123
- if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) {
124
- events.push({
125
- type: `insert`,
126
- key,
127
- value: operation.value
128
- });
129
- }
130
105
  break;
131
106
  case `update`: {
132
107
  const updatedValue = Object.assign(
@@ -135,34 +110,84 @@ class CollectionImpl {
135
110
  operation.value
136
111
  );
137
112
  this.syncedData.set(key, updatedValue);
138
- if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) {
139
- events.push({
140
- type: `update`,
141
- key,
142
- value: updatedValue,
143
- previousValue
144
- });
145
- }
146
113
  break;
147
114
  }
148
115
  case `delete`:
149
116
  this.syncedData.delete(key);
150
- if (!this.derivedDeletes.has(key) && !this.derivedUpserts.has(key)) {
151
- if (previousValue) {
152
- events.push({
153
- type: `delete`,
154
- key,
155
- value: previousValue
156
- });
157
- }
158
- }
159
117
  break;
160
118
  }
161
119
  }
162
120
  }
121
+ this.derivedUpserts.clear();
122
+ this.derivedDeletes.clear();
123
+ this.isCommittingSyncTransactions = false;
124
+ for (const transaction of this.transactions.values()) {
125
+ if (![`completed`, `failed`].includes(transaction.state)) {
126
+ for (const mutation of transaction.mutations) {
127
+ if (mutation.collection === this) {
128
+ switch (mutation.type) {
129
+ case `insert`:
130
+ case `update`:
131
+ this.derivedUpserts.set(mutation.key, mutation.modified);
132
+ this.derivedDeletes.delete(mutation.key);
133
+ break;
134
+ case `delete`:
135
+ this.derivedUpserts.delete(mutation.key);
136
+ this.derivedDeletes.add(mutation.key);
137
+ break;
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ const completedOptimisticOps = /* @__PURE__ */ new Map();
144
+ for (const transaction of this.transactions.values()) {
145
+ if (transaction.state === `completed`) {
146
+ for (const mutation of transaction.mutations) {
147
+ if (mutation.collection === this && changedKeys.has(mutation.key)) {
148
+ completedOptimisticOps.set(mutation.key, {
149
+ type: mutation.type,
150
+ value: mutation.modified
151
+ });
152
+ }
153
+ }
154
+ }
155
+ }
156
+ for (const key of changedKeys) {
157
+ const previousVisibleValue = currentVisibleState.get(key);
158
+ const newVisibleValue = this.get(key);
159
+ const completedOp = completedOptimisticOps.get(key);
160
+ const isRedundantSync = completedOp && newVisibleValue !== void 0 && this.deepEqual(completedOp.value, newVisibleValue);
161
+ if (!isRedundantSync) {
162
+ if (previousVisibleValue === void 0 && newVisibleValue !== void 0) {
163
+ events.push({
164
+ type: `insert`,
165
+ key,
166
+ value: newVisibleValue
167
+ });
168
+ } else if (previousVisibleValue !== void 0 && newVisibleValue === void 0) {
169
+ events.push({
170
+ type: `delete`,
171
+ key,
172
+ value: previousVisibleValue
173
+ });
174
+ } else if (previousVisibleValue !== void 0 && newVisibleValue !== void 0 && !this.deepEqual(previousVisibleValue, newVisibleValue)) {
175
+ events.push({
176
+ type: `update`,
177
+ key,
178
+ value: newVisibleValue,
179
+ previousValue: previousVisibleValue
180
+ });
181
+ }
182
+ }
183
+ }
163
184
  this._size = this.calculateSize();
164
185
  this.emitEvents(events);
165
186
  this.pendingSyncedTransactions = [];
187
+ this.preSyncVisibleState.clear();
188
+ Promise.resolve().then(() => {
189
+ this.recentlySyncedKeys.clear();
190
+ });
166
191
  if (!this.hasReceivedFirstCommit) {
167
192
  this.hasReceivedFirstCommit = true;
168
193
  const callbacks = [...this.onFirstCommitCallbacks];
@@ -172,6 +197,7 @@ class CollectionImpl {
172
197
  }
173
198
  };
174
199
  this.insert = (data, config2) => {
200
+ this.validateCollectionUsable(`insert`);
175
201
  const ambientTransaction = getActiveTransaction();
176
202
  if (!ambientTransaction && !this.config.onInsert) {
177
203
  throw new Error(
@@ -223,6 +249,7 @@ class CollectionImpl {
223
249
  }
224
250
  };
225
251
  this.delete = (keys, config2) => {
252
+ this.validateCollectionUsable(`delete`);
226
253
  const ambientTransaction = getActiveTransaction();
227
254
  if (!ambientTransaction && !this.config.onDelete) {
228
255
  throw new Error(
@@ -235,12 +262,17 @@ class CollectionImpl {
235
262
  const keysArray = Array.isArray(keys) ? keys : [keys];
236
263
  const mutations = [];
237
264
  for (const key of keysArray) {
265
+ if (!this.has(key)) {
266
+ throw new Error(
267
+ `Collection.delete was called with key '${key}' but there is no item in the collection with this key`
268
+ );
269
+ }
238
270
  const globalKey = this.generateGlobalKey(key, this.get(key));
239
271
  const mutation = {
240
272
  mutationId: crypto.randomUUID(),
241
- original: this.get(key) || {},
273
+ original: this.get(key),
242
274
  modified: this.get(key),
243
- changes: this.get(key) || {},
275
+ changes: this.get(key),
244
276
  globalKey,
245
277
  key,
246
278
  metadata: config2 == null ? void 0 : config2.metadata,
@@ -285,87 +317,305 @@ class CollectionImpl {
285
317
  (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
286
318
  );
287
319
  this.config = config;
288
- config.sync.sync({
289
- collection: this,
290
- begin: () => {
291
- this.pendingSyncedTransactions.push({
292
- committed: false,
293
- operations: []
294
- });
295
- },
296
- write: (messageWithoutKey) => {
297
- const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1];
298
- if (!pendingTransaction) {
299
- throw new Error(`No pending sync transaction to write to`);
300
- }
301
- if (pendingTransaction.committed) {
302
- throw new Error(
303
- `The pending sync transaction is already committed, you can't still write to it.`
304
- );
305
- }
306
- const key = this.getKeyFromItem(messageWithoutKey.value);
307
- if (messageWithoutKey.type === `insert`) {
308
- if (this.syncedData.has(key) && !pendingTransaction.operations.some(
309
- (op) => op.key === key && op.type === `delete`
310
- )) {
320
+ collectionsStore.set(this.id, this);
321
+ if (this.config.compare) {
322
+ this.syncedData = new SortedMap(this.config.compare);
323
+ } else {
324
+ this.syncedData = /* @__PURE__ */ new Map();
325
+ }
326
+ if (config.startSync === true) {
327
+ this.startSync();
328
+ }
329
+ }
330
+ /**
331
+ * Register a callback to be executed on the next commit
332
+ * Useful for preloading collections
333
+ * @param callback Function to call after the next commit
334
+ */
335
+ onFirstCommit(callback) {
336
+ this.onFirstCommitCallbacks.push(callback);
337
+ }
338
+ /**
339
+ * Gets the current status of the collection
340
+ */
341
+ get status() {
342
+ return this._status;
343
+ }
344
+ /**
345
+ * Validates that the collection is in a usable state for data operations
346
+ * @private
347
+ */
348
+ validateCollectionUsable(operation) {
349
+ switch (this._status) {
350
+ case `error`:
351
+ throw new Error(
352
+ `Cannot perform ${operation} on collection "${this.id}" - collection is in error state. Try calling cleanup() and restarting the collection.`
353
+ );
354
+ case `cleaned-up`:
355
+ throw new Error(
356
+ `Cannot perform ${operation} on collection "${this.id}" - collection has been cleaned up. The collection will automatically restart on next access.`
357
+ );
358
+ }
359
+ }
360
+ /**
361
+ * Validates state transitions to prevent invalid status changes
362
+ * @private
363
+ */
364
+ validateStatusTransition(from, to) {
365
+ if (from === to) {
366
+ return;
367
+ }
368
+ const validTransitions = {
369
+ idle: [`loading`, `error`, `cleaned-up`],
370
+ loading: [`ready`, `error`, `cleaned-up`],
371
+ ready: [`cleaned-up`, `error`],
372
+ error: [`cleaned-up`, `idle`],
373
+ "cleaned-up": [`loading`, `error`]
374
+ };
375
+ if (!validTransitions[from].includes(to)) {
376
+ throw new Error(
377
+ `Invalid collection status transition from "${from}" to "${to}" for collection "${this.id}"`
378
+ );
379
+ }
380
+ }
381
+ /**
382
+ * Safely update the collection status with validation
383
+ * @private
384
+ */
385
+ setStatus(newStatus) {
386
+ this.validateStatusTransition(this._status, newStatus);
387
+ this._status = newStatus;
388
+ }
389
+ /**
390
+ * Start sync immediately - internal method for compiled queries
391
+ * This bypasses lazy loading for special cases like live query results
392
+ */
393
+ startSyncImmediate() {
394
+ this.startSync();
395
+ }
396
+ /**
397
+ * Start the sync process for this collection
398
+ * This is called when the collection is first accessed or preloaded
399
+ */
400
+ startSync() {
401
+ if (this._status !== `idle` && this._status !== `cleaned-up`) {
402
+ return;
403
+ }
404
+ this.setStatus(`loading`);
405
+ try {
406
+ const cleanupFn = this.config.sync.sync({
407
+ collection: this,
408
+ begin: () => {
409
+ this.pendingSyncedTransactions.push({
410
+ committed: false,
411
+ operations: []
412
+ });
413
+ },
414
+ write: (messageWithoutKey) => {
415
+ const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1];
416
+ if (!pendingTransaction) {
417
+ throw new Error(`No pending sync transaction to write to`);
418
+ }
419
+ if (pendingTransaction.committed) {
420
+ throw new Error(
421
+ `The pending sync transaction is already committed, you can't still write to it.`
422
+ );
423
+ }
424
+ const key = this.getKeyFromItem(messageWithoutKey.value);
425
+ if (messageWithoutKey.type === `insert`) {
426
+ if (this.syncedData.has(key) && !pendingTransaction.operations.some(
427
+ (op) => op.key === key && op.type === `delete`
428
+ )) {
429
+ throw new Error(
430
+ `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
431
+ );
432
+ }
433
+ }
434
+ const message = {
435
+ ...messageWithoutKey,
436
+ key
437
+ };
438
+ pendingTransaction.operations.push(message);
439
+ },
440
+ commit: () => {
441
+ const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1];
442
+ if (!pendingTransaction) {
443
+ throw new Error(`No pending sync transaction to commit`);
444
+ }
445
+ if (pendingTransaction.committed) {
311
446
  throw new Error(
312
- `Cannot insert document with key "${key}" from sync because it already exists in the collection "${this.id}"`
447
+ `The pending sync transaction is already committed, you can't commit it again.`
313
448
  );
314
449
  }
450
+ pendingTransaction.committed = true;
451
+ this.commitPendingTransactions();
452
+ if (this._status === `loading`) {
453
+ this.setStatus(`ready`);
454
+ }
315
455
  }
316
- const message = {
317
- ...messageWithoutKey,
318
- key
319
- };
320
- pendingTransaction.operations.push(message);
321
- },
322
- commit: () => {
323
- const pendingTransaction = this.pendingSyncedTransactions[this.pendingSyncedTransactions.length - 1];
324
- if (!pendingTransaction) {
325
- throw new Error(`No pending sync transaction to commit`);
456
+ });
457
+ this.syncCleanupFn = typeof cleanupFn === `function` ? cleanupFn : null;
458
+ } catch (error) {
459
+ this.setStatus(`error`);
460
+ throw error;
461
+ }
462
+ }
463
+ /**
464
+ * Preload the collection data by starting sync if not already started
465
+ * Multiple concurrent calls will share the same promise
466
+ */
467
+ preload() {
468
+ if (this.preloadPromise) {
469
+ return this.preloadPromise;
470
+ }
471
+ this.preloadPromise = new Promise((resolve, reject) => {
472
+ if (this._status === `ready`) {
473
+ resolve();
474
+ return;
475
+ }
476
+ if (this._status === `error`) {
477
+ reject(new Error(`Collection is in error state`));
478
+ return;
479
+ }
480
+ this.onFirstCommit(() => {
481
+ resolve();
482
+ });
483
+ if (this._status === `idle` || this._status === `cleaned-up`) {
484
+ try {
485
+ this.startSync();
486
+ } catch (error) {
487
+ reject(error);
488
+ return;
326
489
  }
327
- if (pendingTransaction.committed) {
490
+ }
491
+ });
492
+ return this.preloadPromise;
493
+ }
494
+ /**
495
+ * Clean up the collection by stopping sync and clearing data
496
+ * This can be called manually or automatically by garbage collection
497
+ */
498
+ async cleanup() {
499
+ if (this.gcTimeoutId) {
500
+ clearTimeout(this.gcTimeoutId);
501
+ this.gcTimeoutId = null;
502
+ }
503
+ try {
504
+ if (this.syncCleanupFn) {
505
+ this.syncCleanupFn();
506
+ this.syncCleanupFn = null;
507
+ }
508
+ } catch (error) {
509
+ queueMicrotask(() => {
510
+ if (error instanceof Error) {
511
+ const wrappedError = new Error(
512
+ `Collection "${this.id}" sync cleanup function threw an error: ${error.message}`
513
+ );
514
+ wrappedError.cause = error;
515
+ wrappedError.stack = error.stack;
516
+ throw wrappedError;
517
+ } else {
328
518
  throw new Error(
329
- `The pending sync transaction is already committed, you can't commit it again.`
519
+ `Collection "${this.id}" sync cleanup function threw an error: ${String(error)}`
330
520
  );
331
521
  }
332
- pendingTransaction.committed = true;
333
- this.commitPendingTransactions();
522
+ });
523
+ }
524
+ this.syncedData.clear();
525
+ this.syncedMetadata.clear();
526
+ this.derivedUpserts.clear();
527
+ this.derivedDeletes.clear();
528
+ this._size = 0;
529
+ this.pendingSyncedTransactions = [];
530
+ this.syncedKeys.clear();
531
+ this.hasReceivedFirstCommit = false;
532
+ this.onFirstCommitCallbacks = [];
533
+ this.preloadPromise = null;
534
+ this.setStatus(`cleaned-up`);
535
+ return Promise.resolve();
536
+ }
537
+ /**
538
+ * Start the garbage collection timer
539
+ * Called when the collection becomes inactive (no subscribers)
540
+ */
541
+ startGCTimer() {
542
+ if (this.gcTimeoutId) {
543
+ clearTimeout(this.gcTimeoutId);
544
+ }
545
+ const gcTime = this.config.gcTime ?? 3e5;
546
+ this.gcTimeoutId = setTimeout(() => {
547
+ if (this.activeSubscribersCount === 0) {
548
+ this.cleanup();
334
549
  }
335
- });
550
+ }, gcTime);
336
551
  }
337
552
  /**
338
- * Register a callback to be executed on the next commit
339
- * Useful for preloading collections
340
- * @param callback Function to call after the next commit
553
+ * Cancel the garbage collection timer
554
+ * Called when the collection becomes active again
341
555
  */
342
- onFirstCommit(callback) {
343
- this.onFirstCommitCallbacks.push(callback);
556
+ cancelGCTimer() {
557
+ if (this.gcTimeoutId) {
558
+ clearTimeout(this.gcTimeoutId);
559
+ this.gcTimeoutId = null;
560
+ }
561
+ }
562
+ /**
563
+ * Increment the active subscribers count and start sync if needed
564
+ */
565
+ addSubscriber() {
566
+ this.activeSubscribersCount++;
567
+ this.cancelGCTimer();
568
+ if (this._status === `cleaned-up` || this._status === `idle`) {
569
+ this.startSync();
570
+ }
571
+ }
572
+ /**
573
+ * Decrement the active subscribers count and start GC timer if needed
574
+ */
575
+ removeSubscriber() {
576
+ this.activeSubscribersCount--;
577
+ if (this.activeSubscribersCount === 0) {
578
+ this.activeSubscribersCount = 0;
579
+ this.startGCTimer();
580
+ } else if (this.activeSubscribersCount < 0) {
581
+ throw new Error(
582
+ `Active subscribers count is negative - this should never happen`
583
+ );
584
+ }
344
585
  }
345
586
  /**
346
587
  * Recompute optimistic state from active transactions
347
588
  */
348
589
  recomputeOptimisticState() {
590
+ if (this.isCommittingSyncTransactions) {
591
+ return;
592
+ }
349
593
  const previousState = new Map(this.derivedUpserts);
350
594
  const previousDeletes = new Set(this.derivedDeletes);
351
595
  this.derivedUpserts.clear();
352
596
  this.derivedDeletes.clear();
353
- const activeTransactions = Array.from(this.transactions.values());
597
+ const activeTransactions = [];
598
+ const completedTransactions = [];
599
+ for (const transaction of this.transactions.values()) {
600
+ if (transaction.state === `completed`) {
601
+ completedTransactions.push(transaction);
602
+ } else if (![`completed`, `failed`].includes(transaction.state)) {
603
+ activeTransactions.push(transaction);
604
+ }
605
+ }
354
606
  for (const transaction of activeTransactions) {
355
- if (![`completed`, `failed`].includes(transaction.state)) {
356
- for (const mutation of transaction.mutations) {
357
- if (mutation.collection === this) {
358
- switch (mutation.type) {
359
- case `insert`:
360
- case `update`:
361
- this.derivedUpserts.set(mutation.key, mutation.modified);
362
- this.derivedDeletes.delete(mutation.key);
363
- break;
364
- case `delete`:
365
- this.derivedUpserts.delete(mutation.key);
366
- this.derivedDeletes.add(mutation.key);
367
- break;
368
- }
607
+ for (const mutation of transaction.mutations) {
608
+ if (mutation.collection === this) {
609
+ switch (mutation.type) {
610
+ case `insert`:
611
+ case `update`:
612
+ this.derivedUpserts.set(mutation.key, mutation.modified);
613
+ this.derivedDeletes.delete(mutation.key);
614
+ break;
615
+ case `delete`:
616
+ this.derivedUpserts.delete(mutation.key);
617
+ this.derivedDeletes.add(mutation.key);
618
+ break;
369
619
  }
370
620
  }
371
621
  }
@@ -373,7 +623,41 @@ class CollectionImpl {
373
623
  this._size = this.calculateSize();
374
624
  const events = [];
375
625
  this.collectOptimisticChanges(previousState, previousDeletes, events);
376
- this.emitEvents(events);
626
+ const filteredEventsBySyncStatus = events.filter(
627
+ (event) => !this.recentlySyncedKeys.has(event.key)
628
+ );
629
+ if (this.pendingSyncedTransactions.length > 0) {
630
+ const pendingSyncKeys = /* @__PURE__ */ new Set();
631
+ const completedTransactionMutations = /* @__PURE__ */ new Set();
632
+ for (const transaction of this.pendingSyncedTransactions) {
633
+ for (const operation of transaction.operations) {
634
+ pendingSyncKeys.add(operation.key);
635
+ }
636
+ }
637
+ for (const tx of completedTransactions) {
638
+ for (const mutation of tx.mutations) {
639
+ if (mutation.collection === this) {
640
+ completedTransactionMutations.add(mutation.mutationId);
641
+ }
642
+ }
643
+ }
644
+ const filteredEvents = filteredEventsBySyncStatus.filter((event) => {
645
+ if (event.type === `delete` && pendingSyncKeys.has(event.key)) {
646
+ const hasActiveOptimisticMutation = activeTransactions.some(
647
+ (tx) => tx.mutations.some(
648
+ (m) => m.collection === this && m.key === event.key
649
+ )
650
+ );
651
+ if (!hasActiveOptimisticMutation) {
652
+ return false;
653
+ }
654
+ }
655
+ return true;
656
+ });
657
+ this.emitEvents(filteredEvents);
658
+ } else {
659
+ this.emitEvents(filteredEventsBySyncStatus);
660
+ }
377
661
  }
378
662
  /**
379
663
  * Calculate the current size based on synced data and optimistic changes
@@ -510,7 +794,8 @@ class CollectionImpl {
510
794
  for (const key of this.keys()) {
511
795
  const value = this.get(key);
512
796
  if (value !== void 0) {
513
- yield value;
797
+ const { _orderByIndex, ...copy } = value;
798
+ yield copy;
514
799
  }
515
800
  }
516
801
  }
@@ -521,7 +806,8 @@ class CollectionImpl {
521
806
  for (const key of this.keys()) {
522
807
  const value = this.get(key);
523
808
  if (value !== void 0) {
524
- yield [key, value];
809
+ const { _orderByIndex, ...copy } = value;
810
+ yield [key, copy];
525
811
  }
526
812
  }
527
813
  }
@@ -544,6 +830,24 @@ class CollectionImpl {
544
830
  }
545
831
  return `KEY::${this.id}/${key}`;
546
832
  }
833
+ deepEqual(a, b) {
834
+ if (a === b) return true;
835
+ if (a == null || b == null) return false;
836
+ if (typeof a !== typeof b) return false;
837
+ if (typeof a === `object`) {
838
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
839
+ const keysA = Object.keys(a);
840
+ const keysB = Object.keys(b);
841
+ if (keysA.length !== keysB.length) return false;
842
+ const keysBSet = new Set(keysB);
843
+ for (const key of keysA) {
844
+ if (!keysBSet.has(key)) return false;
845
+ if (!this.deepEqual(a[key], b[key])) return false;
846
+ }
847
+ return true;
848
+ }
849
+ return false;
850
+ }
547
851
  validateData(data, type, key) {
548
852
  if (!this.config.schema) return data;
549
853
  const standardSchema = this.ensureStandardSchema(this.config.schema);
@@ -588,6 +892,7 @@ class CollectionImpl {
588
892
  if (typeof keys === `undefined`) {
589
893
  throw new Error(`The first argument to update is missing`);
590
894
  }
895
+ this.validateCollectionUsable(`update`);
591
896
  const ambientTransaction = getActiveTransaction();
592
897
  if (!ambientTransaction && !this.config.onUpdate) {
593
898
  throw new Error(
@@ -762,18 +1067,21 @@ class CollectionImpl {
762
1067
  * @returns A function that can be called to unsubscribe from the changes
763
1068
  */
764
1069
  subscribeChanges(callback, { includeInitialState = false } = {}) {
1070
+ this.addSubscriber();
765
1071
  if (includeInitialState) {
766
1072
  callback(this.currentStateAsChanges());
767
1073
  }
768
1074
  this.changeListeners.add(callback);
769
1075
  return () => {
770
1076
  this.changeListeners.delete(callback);
1077
+ this.removeSubscriber();
771
1078
  };
772
1079
  }
773
1080
  /**
774
1081
  * Subscribe to changes for a specific key
775
1082
  */
776
1083
  subscribeChangesKey(key, listener, { includeInitialState = false } = {}) {
1084
+ this.addSubscriber();
777
1085
  if (!this.changeKeyListeners.has(key)) {
778
1086
  this.changeKeyListeners.set(key, /* @__PURE__ */ new Set());
779
1087
  }
@@ -795,13 +1103,38 @@ class CollectionImpl {
795
1103
  this.changeKeyListeners.delete(key);
796
1104
  }
797
1105
  }
1106
+ this.removeSubscriber();
798
1107
  };
799
1108
  }
1109
+ /**
1110
+ * Capture visible state for keys that will be affected by pending sync operations
1111
+ * This must be called BEFORE onTransactionStateChange clears optimistic state
1112
+ */
1113
+ capturePreSyncVisibleState() {
1114
+ if (this.pendingSyncedTransactions.length === 0) return;
1115
+ this.preSyncVisibleState.clear();
1116
+ const syncedKeys = /* @__PURE__ */ new Set();
1117
+ for (const transaction of this.pendingSyncedTransactions) {
1118
+ for (const operation of transaction.operations) {
1119
+ syncedKeys.add(operation.key);
1120
+ }
1121
+ }
1122
+ for (const key of syncedKeys) {
1123
+ this.recentlySyncedKeys.add(key);
1124
+ }
1125
+ for (const key of syncedKeys) {
1126
+ const currentValue = this.get(key);
1127
+ if (currentValue !== void 0) {
1128
+ this.preSyncVisibleState.set(key, currentValue);
1129
+ }
1130
+ }
1131
+ }
800
1132
  /**
801
1133
  * Trigger a recomputation when transactions change
802
1134
  * This method should be called by the Transaction class when state changes
803
1135
  */
804
1136
  onTransactionStateChange() {
1137
+ this.capturePreSyncVisibleState();
805
1138
  this.recomputeOptimisticState();
806
1139
  }
807
1140
  /**
@@ -813,7 +1146,7 @@ class CollectionImpl {
813
1146
  asStoreMap() {
814
1147
  if (!this._storeMap) {
815
1148
  this._storeMap = new Store(new Map(this.entries()));
816
- this.subscribeChanges(() => {
1149
+ this.changeListeners.add(() => {
817
1150
  this._storeMap.setState(() => new Map(this.entries()));
818
1151
  });
819
1152
  }
@@ -828,7 +1161,7 @@ class CollectionImpl {
828
1161
  asStoreArray() {
829
1162
  if (!this._storeArray) {
830
1163
  this._storeArray = new Store(this.toArray);
831
- this.subscribeChanges(() => {
1164
+ this.changeListeners.add(() => {
832
1165
  this._storeArray.setState(() => this.toArray);
833
1166
  });
834
1167
  }
@@ -839,7 +1172,6 @@ export {
839
1172
  CollectionImpl,
840
1173
  SchemaValidationError,
841
1174
  collectionsStore,
842
- createCollection,
843
- preloadCollection
1175
+ createCollection
844
1176
  };
845
1177
  //# sourceMappingURL=collection.js.map