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