@tanstack/db 0.0.4 → 0.0.6

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 (102) hide show
  1. package/dist/cjs/collection.cjs +182 -113
  2. package/dist/cjs/collection.cjs.map +1 -1
  3. package/dist/cjs/collection.d.cts +43 -15
  4. package/dist/cjs/index.cjs +1 -0
  5. package/dist/cjs/index.cjs.map +1 -1
  6. package/dist/cjs/proxy.cjs +87 -248
  7. package/dist/cjs/proxy.cjs.map +1 -1
  8. package/dist/cjs/proxy.d.cts +5 -5
  9. package/dist/cjs/query/compiled-query.cjs +23 -14
  10. package/dist/cjs/query/compiled-query.cjs.map +1 -1
  11. package/dist/cjs/query/compiled-query.d.cts +3 -1
  12. package/dist/cjs/query/evaluators.cjs +35 -20
  13. package/dist/cjs/query/evaluators.cjs.map +1 -1
  14. package/dist/cjs/query/evaluators.d.cts +8 -3
  15. package/dist/cjs/query/extractors.cjs +20 -20
  16. package/dist/cjs/query/extractors.cjs.map +1 -1
  17. package/dist/cjs/query/extractors.d.cts +3 -3
  18. package/dist/cjs/query/group-by.cjs +12 -15
  19. package/dist/cjs/query/group-by.cjs.map +1 -1
  20. package/dist/cjs/query/group-by.d.cts +7 -7
  21. package/dist/cjs/query/joins.cjs +41 -55
  22. package/dist/cjs/query/joins.cjs.map +1 -1
  23. package/dist/cjs/query/joins.d.cts +3 -3
  24. package/dist/cjs/query/order-by.cjs +37 -84
  25. package/dist/cjs/query/order-by.cjs.map +1 -1
  26. package/dist/cjs/query/order-by.d.cts +2 -2
  27. package/dist/cjs/query/pipeline-compiler.cjs +13 -18
  28. package/dist/cjs/query/pipeline-compiler.cjs.map +1 -1
  29. package/dist/cjs/query/pipeline-compiler.d.cts +2 -1
  30. package/dist/cjs/query/query-builder.cjs +22 -29
  31. package/dist/cjs/query/query-builder.cjs.map +1 -1
  32. package/dist/cjs/query/query-builder.d.cts +16 -10
  33. package/dist/cjs/query/schema.d.cts +12 -11
  34. package/dist/cjs/query/select.cjs +47 -24
  35. package/dist/cjs/query/select.cjs.map +1 -1
  36. package/dist/cjs/query/select.d.cts +2 -2
  37. package/dist/cjs/query/types.d.cts +1 -0
  38. package/dist/cjs/transactions.cjs +20 -9
  39. package/dist/cjs/transactions.cjs.map +1 -1
  40. package/dist/cjs/types.d.cts +66 -7
  41. package/dist/esm/collection.d.ts +43 -15
  42. package/dist/esm/collection.js +183 -114
  43. package/dist/esm/collection.js.map +1 -1
  44. package/dist/esm/index.js +2 -1
  45. package/dist/esm/proxy.d.ts +5 -5
  46. package/dist/esm/proxy.js +87 -248
  47. package/dist/esm/proxy.js.map +1 -1
  48. package/dist/esm/query/compiled-query.d.ts +3 -1
  49. package/dist/esm/query/compiled-query.js +23 -14
  50. package/dist/esm/query/compiled-query.js.map +1 -1
  51. package/dist/esm/query/evaluators.d.ts +8 -3
  52. package/dist/esm/query/evaluators.js +36 -21
  53. package/dist/esm/query/evaluators.js.map +1 -1
  54. package/dist/esm/query/extractors.d.ts +3 -3
  55. package/dist/esm/query/extractors.js +20 -20
  56. package/dist/esm/query/extractors.js.map +1 -1
  57. package/dist/esm/query/group-by.d.ts +7 -7
  58. package/dist/esm/query/group-by.js +14 -17
  59. package/dist/esm/query/group-by.js.map +1 -1
  60. package/dist/esm/query/joins.d.ts +3 -3
  61. package/dist/esm/query/joins.js +42 -56
  62. package/dist/esm/query/joins.js.map +1 -1
  63. package/dist/esm/query/order-by.d.ts +2 -2
  64. package/dist/esm/query/order-by.js +39 -86
  65. package/dist/esm/query/order-by.js.map +1 -1
  66. package/dist/esm/query/pipeline-compiler.d.ts +2 -1
  67. package/dist/esm/query/pipeline-compiler.js +14 -19
  68. package/dist/esm/query/pipeline-compiler.js.map +1 -1
  69. package/dist/esm/query/query-builder.d.ts +16 -10
  70. package/dist/esm/query/query-builder.js +22 -29
  71. package/dist/esm/query/query-builder.js.map +1 -1
  72. package/dist/esm/query/schema.d.ts +12 -11
  73. package/dist/esm/query/select.d.ts +2 -2
  74. package/dist/esm/query/select.js +48 -25
  75. package/dist/esm/query/select.js.map +1 -1
  76. package/dist/esm/query/types.d.ts +1 -0
  77. package/dist/esm/transactions.js +20 -9
  78. package/dist/esm/transactions.js.map +1 -1
  79. package/dist/esm/types.d.ts +66 -7
  80. package/package.json +2 -2
  81. package/src/collection.ts +286 -146
  82. package/src/proxy.ts +141 -358
  83. package/src/query/compiled-query.ts +30 -15
  84. package/src/query/evaluators.ts +49 -21
  85. package/src/query/extractors.ts +24 -21
  86. package/src/query/group-by.ts +24 -22
  87. package/src/query/joins.ts +88 -75
  88. package/src/query/order-by.ts +56 -106
  89. package/src/query/pipeline-compiler.ts +34 -37
  90. package/src/query/query-builder.ts +49 -46
  91. package/src/query/schema.ts +18 -15
  92. package/src/query/select.ts +68 -33
  93. package/src/query/types.ts +1 -0
  94. package/src/transactions.ts +30 -14
  95. package/src/types.ts +76 -7
  96. package/dist/cjs/query/key-by.cjs +0 -43
  97. package/dist/cjs/query/key-by.cjs.map +0 -1
  98. package/dist/cjs/query/key-by.d.cts +0 -3
  99. package/dist/esm/query/key-by.d.ts +0 -3
  100. package/dist/esm/query/key-by.js +0 -43
  101. package/dist/esm/query/key-by.js.map +0 -1
  102. package/src/query/key-by.ts +0 -61
package/src/collection.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Derived, Store, batch } from "@tanstack/store"
2
2
  import { withArrayChangeTracking, withChangeTracking } from "./proxy"
3
- import { getActiveTransaction } from "./transactions"
3
+ import { Transaction, getActiveTransaction } from "./transactions"
4
4
  import { SortedMap } from "./SortedMap"
5
5
  import type {
6
6
  ChangeMessage,
@@ -10,7 +10,7 @@ import type {
10
10
  OptimisticChangeMessage,
11
11
  PendingMutation,
12
12
  StandardSchema,
13
- Transaction,
13
+ Transaction as TransactionType,
14
14
  } from "./types"
15
15
 
16
16
  // Store collections in memory using Tanstack store
@@ -28,6 +28,19 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
28
28
  operations: Array<OptimisticChangeMessage<T>>
29
29
  }
30
30
 
31
+ /**
32
+ * Creates a new Collection instance with the given configuration
33
+ *
34
+ * @template T - The type of items in the collection
35
+ * @param config - Configuration for the collection, including id and sync
36
+ * @returns A new Collection instance
37
+ */
38
+ export function createCollection<T extends object = Record<string, unknown>>(
39
+ config: CollectionConfig<T>
40
+ ): Collection<T> {
41
+ return new Collection<T>(config)
42
+ }
43
+
31
44
  /**
32
45
  * Preloads a collection with the given configuration
33
46
  * Returns a promise that resolves once the sync tool has done its first commit (initial sync is finished)
@@ -57,6 +70,10 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
57
70
  export function preloadCollection<T extends object = Record<string, unknown>>(
58
71
  config: CollectionConfig<T>
59
72
  ): Promise<Collection<T>> {
73
+ if (!config.id) {
74
+ throw new Error(`The id property is required for preloadCollection`)
75
+ }
76
+
60
77
  // If the collection is already fully loaded, return a resolved promise
61
78
  if (
62
79
  collectionsStore.state.has(config.id) &&
@@ -76,10 +93,14 @@ export function preloadCollection<T extends object = Record<string, unknown>>(
76
93
  if (!collectionsStore.state.has(config.id)) {
77
94
  collectionsStore.setState((prev) => {
78
95
  const next = new Map(prev)
96
+ if (!config.id) {
97
+ throw new Error(`The id property is required for preloadCollection`)
98
+ }
79
99
  next.set(
80
100
  config.id,
81
101
  new Collection<T>({
82
102
  id: config.id,
103
+ getId: config.getId,
83
104
  sync: config.sync,
84
105
  schema: config.schema,
85
106
  })
@@ -100,6 +121,9 @@ export function preloadCollection<T extends object = Record<string, unknown>>(
100
121
 
101
122
  // Register a one-time listener for the first commit
102
123
  collection.onFirstCommit(() => {
124
+ if (!config.id) {
125
+ throw new Error(`The id property is required for preloadCollection`)
126
+ }
103
127
  if (loadingCollections.has(config.id)) {
104
128
  loadingCollections.delete(config.id)
105
129
  resolveFirstCommit()
@@ -145,7 +169,7 @@ export class SchemaValidationError extends Error {
145
169
  }
146
170
 
147
171
  export class Collection<T extends object = Record<string, unknown>> {
148
- public transactions: Store<SortedMap<string, Transaction>>
172
+ public transactions: Store<SortedMap<string, TransactionType>>
149
173
  public optimisticOperations: Derived<Array<OptimisticChangeMessage<T>>>
150
174
  public derivedState: Derived<Map<string, T>>
151
175
  public derivedArray: Derived<Array<T>>
@@ -157,9 +181,6 @@ export class Collection<T extends object = Record<string, unknown>> {
157
181
  public config: CollectionConfig<T>
158
182
  private hasReceivedFirstCommit = false
159
183
 
160
- // WeakMap to associate objects with their keys
161
- public objectKeyMap = new WeakMap<object, string>()
162
-
163
184
  // Array to store one-time commit listeners
164
185
  private onFirstCommitCallbacks: Array<() => void> = []
165
186
 
@@ -172,7 +193,7 @@ export class Collection<T extends object = Record<string, unknown>> {
172
193
  this.onFirstCommitCallbacks.push(callback)
173
194
  }
174
195
 
175
- public id = crypto.randomUUID()
196
+ public id = ``
176
197
 
177
198
  /**
178
199
  * Creates a new Collection instance
@@ -180,13 +201,24 @@ export class Collection<T extends object = Record<string, unknown>> {
180
201
  * @param config - Configuration object for the collection
181
202
  * @throws Error if sync config is missing
182
203
  */
183
- constructor(config?: CollectionConfig<T>) {
184
- if (!config?.sync) {
204
+ constructor(config: CollectionConfig<T>) {
205
+ // eslint-disable-next-line
206
+ if (!config) {
207
+ throw new Error(`Collection requires a config`)
208
+ }
209
+ if (config.id) {
210
+ this.id = config.id
211
+ } else {
212
+ this.id = crypto.randomUUID()
213
+ }
214
+
215
+ // eslint-disable-next-line
216
+ if (!config.sync) {
185
217
  throw new Error(`Collection requires a sync config`)
186
218
  }
187
219
 
188
220
  this.transactions = new Store(
189
- new SortedMap<string, Transaction>(
221
+ new SortedMap<string, TransactionType>(
190
222
  (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
191
223
  )
192
224
  )
@@ -232,11 +264,9 @@ export class Collection<T extends object = Record<string, unknown>> {
232
264
  this.derivedState = new Derived({
233
265
  fn: ({ currDepVals: [syncedData, operations] }) => {
234
266
  const combined = new Map<string, T>(syncedData)
235
- const optimisticKeys = new Set<string>()
236
267
 
237
268
  // Apply the optimistic operations on top of the synced state.
238
269
  for (const operation of operations) {
239
- optimisticKeys.add(operation.key)
240
270
  if (operation.isActive) {
241
271
  switch (operation.type) {
242
272
  case `insert`:
@@ -252,13 +282,6 @@ export class Collection<T extends object = Record<string, unknown>> {
252
282
  }
253
283
  }
254
284
 
255
- // Update object => key mappings
256
- optimisticKeys.forEach((key) => {
257
- if (combined.has(key)) {
258
- this.objectKeyMap.set(combined.get(key)!, key)
259
- }
260
- })
261
-
262
285
  return combined
263
286
  },
264
287
  deps: [this.syncedData, this.optimisticOperations],
@@ -353,7 +376,7 @@ export class Collection<T extends object = Record<string, unknown>> {
353
376
  operations: [],
354
377
  })
355
378
  },
356
- write: (message: ChangeMessage<T>) => {
379
+ write: (messageWithoutKey: Omit<ChangeMessage<T>, `key`>) => {
357
380
  const pendingTransaction =
358
381
  this.pendingSyncedTransactions[
359
382
  this.pendingSyncedTransactions.length - 1
@@ -366,6 +389,30 @@ export class Collection<T extends object = Record<string, unknown>> {
366
389
  `The pending sync transaction is already committed, you can't still write to it.`
367
390
  )
368
391
  }
392
+ const key = this.generateObjectKey(
393
+ this.config.getId(messageWithoutKey.value),
394
+ messageWithoutKey.value
395
+ )
396
+
397
+ // Check if an item with this ID already exists when inserting
398
+ if (messageWithoutKey.type === `insert`) {
399
+ if (
400
+ this.syncedData.state.has(key) &&
401
+ !pendingTransaction.operations.some(
402
+ (op) => op.key === key && op.type === `delete`
403
+ )
404
+ ) {
405
+ const id = this.config.getId(messageWithoutKey.value)
406
+ throw new Error(
407
+ `Cannot insert document with ID "${id}" from sync because it already exists in the collection "${this.id}"`
408
+ )
409
+ }
410
+ }
411
+
412
+ const message: ChangeMessage<T> = {
413
+ ...messageWithoutKey,
414
+ key,
415
+ }
369
416
  pendingTransaction.operations.push(message)
370
417
  },
371
418
  commit: () => {
@@ -399,11 +446,9 @@ export class Collection<T extends object = Record<string, unknown>> {
399
446
  ({ state }) => state === `persisting`
400
447
  )
401
448
  ) {
402
- const keys = new Set<string>()
403
449
  batch(() => {
404
450
  for (const transaction of this.pendingSyncedTransactions) {
405
451
  for (const operation of transaction.operations) {
406
- keys.add(operation.key)
407
452
  this.syncedKeys.add(operation.key)
408
453
  this.syncedMetadata.setState((prevData) => {
409
454
  switch (operation.type) {
@@ -443,13 +488,6 @@ export class Collection<T extends object = Record<string, unknown>> {
443
488
  }
444
489
  })
445
490
 
446
- keys.forEach((key) => {
447
- const curValue = this.state.get(key)
448
- if (curValue) {
449
- this.objectKeyMap.set(curValue, key)
450
- }
451
- })
452
-
453
491
  this.pendingSyncedTransactions = []
454
492
 
455
493
  // Call any registered one-time commit listeners
@@ -473,6 +511,29 @@ export class Collection<T extends object = Record<string, unknown>> {
473
511
  )
474
512
  }
475
513
 
514
+ private getKeyFromId(id: unknown): string {
515
+ if (typeof id === `undefined`) {
516
+ throw new Error(`id is undefined`)
517
+ }
518
+ if (typeof id === `string` && id.startsWith(`KEY::`)) {
519
+ return id
520
+ } else {
521
+ // if it's not a string, then it's some other
522
+ // primitive type and needs turned into a key.
523
+ return this.generateObjectKey(id, null)
524
+ }
525
+ }
526
+
527
+ public generateObjectKey(id: any, item: any): string {
528
+ if (typeof id === `undefined`) {
529
+ throw new Error(
530
+ `An object was created without a defined id: ${JSON.stringify(item)}`
531
+ )
532
+ }
533
+
534
+ return `KEY::${this.id}/${id}`
535
+ }
536
+
476
537
  private validateData(
477
538
  data: unknown,
478
539
  type: `insert` | `update`,
@@ -539,22 +600,11 @@ export class Collection<T extends object = Record<string, unknown>> {
539
600
  return result.value as T
540
601
  }
541
602
 
542
- private generateKey(data: unknown): string {
543
- const str = JSON.stringify(data)
544
- let h = 0
545
-
546
- for (let i = 0; i < str.length; i++) {
547
- h = (Math.imul(31, h) + str.charCodeAt(i)) | 0
548
- }
549
-
550
- return `${this.id}/${Math.abs(h).toString(36)}`
551
- }
552
-
553
603
  /**
554
604
  * Inserts one or more items into the collection
555
605
  * @param items - Single item or array of items to insert
556
606
  * @param config - Optional configuration including metadata and custom keys
557
- * @returns A Transaction object representing the insert operation(s)
607
+ * @returns A TransactionType object representing the insert operation(s)
558
608
  * @throws {SchemaValidationError} If the data fails schema validation
559
609
  * @example
560
610
  * // Insert a single item
@@ -570,27 +620,22 @@ export class Collection<T extends object = Record<string, unknown>> {
570
620
  * insert({ text: "Buy groceries" }, { key: "grocery-task" })
571
621
  */
572
622
  insert = (data: T | Array<T>, config?: InsertConfig) => {
573
- const transaction = getActiveTransaction()
574
- if (typeof transaction === `undefined`) {
575
- throw `no transaction found when calling collection.insert`
623
+ const ambientTransaction = getActiveTransaction()
624
+
625
+ // If no ambient transaction exists, check for an onInsert handler early
626
+ if (!ambientTransaction && !this.config.onInsert) {
627
+ throw new Error(
628
+ `Collection.insert called directly (not within an explicit transaction) but no 'onInsert' handler is configured.`
629
+ )
576
630
  }
577
631
 
578
632
  const items = Array.isArray(data) ? data : [data]
579
633
  const mutations: Array<PendingMutation<T>> = []
580
634
 
581
635
  // Handle keys - convert to array if string, or generate if not provided
582
- let keys: Array<string>
583
- if (config?.key) {
584
- const configKeys = Array.isArray(config.key) ? config.key : [config.key]
585
- // If keys are provided, ensure we have the right number or allow sparse array
586
- if (Array.isArray(config.key) && configKeys.length > items.length) {
587
- throw new Error(`More keys provided than items to insert`)
588
- }
589
- keys = items.map((_, i) => configKeys[i] ?? this.generateKey(items[i]))
590
- } else {
591
- // No keys provided, generate for all items
592
- keys = items.map((item) => this.generateKey(item))
593
- }
636
+ const keys: Array<unknown> = items.map((item) =>
637
+ this.generateObjectKey(this.config.getId(item), item)
638
+ )
594
639
 
595
640
  // Create mutations for each item
596
641
  items.forEach((item, index) => {
@@ -598,6 +643,12 @@ export class Collection<T extends object = Record<string, unknown>> {
598
643
  const validatedData = this.validateData(item, `insert`)
599
644
  const key = keys[index]!
600
645
 
646
+ // Check if an item with this ID already exists in the collection
647
+ const id = this.config.getId(item)
648
+ if (this.state.has(this.getKeyFromId(id))) {
649
+ throw `Cannot insert document with ID "${id}" because it already exists in the collection`
650
+ }
651
+
601
652
  const mutation: PendingMutation<T> = {
602
653
  mutationId: crypto.randomUUID(),
603
654
  original: {},
@@ -615,14 +666,37 @@ export class Collection<T extends object = Record<string, unknown>> {
615
666
  mutations.push(mutation)
616
667
  })
617
668
 
618
- transaction.applyMutations(mutations)
669
+ // If an ambient transaction exists, use it
670
+ if (ambientTransaction) {
671
+ ambientTransaction.applyMutations(mutations)
619
672
 
620
- this.transactions.setState((sortedMap) => {
621
- sortedMap.set(transaction.id, transaction)
622
- return sortedMap
623
- })
673
+ this.transactions.setState((sortedMap) => {
674
+ sortedMap.set(ambientTransaction.id, ambientTransaction)
675
+ return sortedMap
676
+ })
677
+
678
+ return ambientTransaction
679
+ } else {
680
+ // Create a new transaction with a mutation function that calls the onInsert handler
681
+ const directOpTransaction = new Transaction({
682
+ mutationFn: async (params) => {
683
+ // Call the onInsert handler with the transaction
684
+ return this.config.onInsert!(params)
685
+ },
686
+ })
687
+
688
+ // Apply mutations to the new transaction
689
+ directOpTransaction.applyMutations(mutations)
690
+ directOpTransaction.commit()
624
691
 
625
- return transaction
692
+ // Add the transaction to the collection's transactions store
693
+ this.transactions.setState((sortedMap) => {
694
+ sortedMap.set(directOpTransaction.id, directOpTransaction)
695
+ return sortedMap
696
+ })
697
+
698
+ return directOpTransaction
699
+ }
626
700
  }
627
701
 
628
702
  /**
@@ -645,54 +719,75 @@ export class Collection<T extends object = Record<string, unknown>> {
645
719
  * update(todo, { metadata: { reason: "user update" } }, (draft) => { draft.text = "Updated text" })
646
720
  */
647
721
 
722
+ /**
723
+ * Updates one or more items in the collection using a callback function
724
+ * @param ids - Single ID or array of IDs to update
725
+ * @param configOrCallback - Either update configuration or update callback
726
+ * @param maybeCallback - Update callback if config was provided
727
+ * @returns A Transaction object representing the update operation(s)
728
+ * @throws {SchemaValidationError} If the updated data fails schema validation
729
+ * @example
730
+ * // Update a single item
731
+ * update("todo-1", (draft) => { draft.completed = true })
732
+ *
733
+ * // Update multiple items
734
+ * update(["todo-1", "todo-2"], (drafts) => {
735
+ * drafts.forEach(draft => { draft.completed = true })
736
+ * })
737
+ *
738
+ * // Update with metadata
739
+ * update("todo-1", { metadata: { reason: "user update" } }, (draft) => { draft.text = "Updated text" })
740
+ */
648
741
  update<TItem extends object = T>(
649
- item: TItem,
742
+ id: unknown,
650
743
  configOrCallback: ((draft: TItem) => void) | OperationConfig,
651
744
  maybeCallback?: (draft: TItem) => void
652
- ): Transaction
745
+ ): TransactionType
653
746
 
654
747
  update<TItem extends object = T>(
655
- items: Array<TItem>,
748
+ ids: Array<unknown>,
656
749
  configOrCallback: ((draft: Array<TItem>) => void) | OperationConfig,
657
750
  maybeCallback?: (draft: Array<TItem>) => void
658
- ): Transaction
751
+ ): TransactionType
659
752
 
660
753
  update<TItem extends object = T>(
661
- items: TItem | Array<TItem>,
754
+ ids: unknown | Array<unknown>,
662
755
  configOrCallback: ((draft: TItem | Array<TItem>) => void) | OperationConfig,
663
756
  maybeCallback?: (draft: TItem | Array<TItem>) => void
664
757
  ) {
665
- if (typeof items === `undefined`) {
758
+ if (typeof ids === `undefined`) {
666
759
  throw new Error(`The first argument to update is missing`)
667
760
  }
668
761
 
669
- const transaction = getActiveTransaction()
670
- if (typeof transaction === `undefined`) {
671
- throw `no transaction found when calling collection.update`
762
+ const ambientTransaction = getActiveTransaction()
763
+
764
+ // If no ambient transaction exists, check for an onUpdate handler early
765
+ if (!ambientTransaction && !this.config.onUpdate) {
766
+ throw new Error(
767
+ `Collection.update called directly (not within an explicit transaction) but no 'onUpdate' handler is configured.`
768
+ )
672
769
  }
673
770
 
674
- const isArray = Array.isArray(items)
675
- const itemsArray = Array.isArray(items) ? items : [items]
771
+ const isArray = Array.isArray(ids)
772
+ const idsArray = (Array.isArray(ids) ? ids : [ids]).map((id) =>
773
+ this.getKeyFromId(id)
774
+ )
676
775
  const callback =
677
776
  typeof configOrCallback === `function` ? configOrCallback : maybeCallback!
678
777
  const config =
679
778
  typeof configOrCallback === `function` ? {} : configOrCallback
680
779
 
681
- const keys = itemsArray.map((item) => {
682
- if (typeof item === `object` && (item as unknown) !== null) {
683
- const key = this.objectKeyMap.get(item)
684
- if (key === undefined) {
685
- throw new Error(`Object not found in collection`)
686
- }
687
- return key
780
+ // Get the current objects or empty objects if they don't exist
781
+ const currentObjects = idsArray.map((id) => {
782
+ const item = this.state.get(id)
783
+ if (!item) {
784
+ throw new Error(
785
+ `The id "${id}" was passed to update but an object for this ID was not found in the collection`
786
+ )
688
787
  }
689
- throw new Error(`Invalid item type for update - must be an object`)
690
- })
691
788
 
692
- // Get the current objects or empty objects if they don't exist
693
- const currentObjects = keys.map((key) => ({
694
- ...(this.state.get(key) || {}),
695
- })) as Array<TItem>
789
+ return item
790
+ }) as unknown as Array<TItem>
696
791
 
697
792
  let changesArray
698
793
  if (isArray) {
@@ -710,29 +805,44 @@ export class Collection<T extends object = Record<string, unknown>> {
710
805
  }
711
806
 
712
807
  // Create mutations for each object that has changes
713
- const mutations: Array<PendingMutation<T>> = keys
714
- .map((key, index) => {
715
- const changes = changesArray[index]
808
+ const mutations: Array<PendingMutation<T>> = idsArray
809
+ .map((id, index) => {
810
+ const itemChanges = changesArray[index] // User-provided changes for this specific item
716
811
 
717
812
  // Skip items with no changes
718
- if (!changes || Object.keys(changes).length === 0) {
813
+ if (!itemChanges || Object.keys(itemChanges).length === 0) {
719
814
  return null
720
815
  }
721
816
 
722
- // Validate the changes for this item
723
- const validatedData = this.validateData(changes, `update`, key)
817
+ const originalItem = currentObjects[index] as unknown as T
818
+ // Validate the user-provided changes for this item
819
+ const validatedUpdatePayload = this.validateData(
820
+ itemChanges,
821
+ `update`,
822
+ id
823
+ )
824
+
825
+ // Construct the full modified item by applying the validated update payload to the original item
826
+ const modifiedItem = { ...originalItem, ...validatedUpdatePayload }
827
+
828
+ // Check if the ID of the item is being changed
829
+ const originalItemId = this.config.getId(originalItem)
830
+ const modifiedItemId = this.config.getId(modifiedItem)
831
+
832
+ if (originalItemId !== modifiedItemId) {
833
+ throw new Error(
834
+ `Updating the ID of an item is not allowed. Original ID: "${originalItemId}", Attempted new ID: "${modifiedItemId}". Please delete the old item and create a new one if an ID change is necessary.`
835
+ )
836
+ }
724
837
 
725
838
  return {
726
839
  mutationId: crypto.randomUUID(),
727
- original: (this.state.get(key) || {}) as Record<string, unknown>,
728
- modified: {
729
- ...(this.state.get(key) || {}),
730
- ...validatedData,
731
- } as Record<string, unknown>,
732
- changes: validatedData as Record<string, unknown>,
733
- key,
840
+ original: originalItem as Record<string, unknown>,
841
+ modified: modifiedItem as Record<string, unknown>,
842
+ changes: validatedUpdatePayload as Record<string, unknown>,
843
+ key: id,
734
844
  metadata: config.metadata as unknown,
735
- syncMetadata: (this.syncedMetadata.state.get(key) || {}) as Record<
845
+ syncMetadata: (this.syncedMetadata.state.get(id) || {}) as Record<
736
846
  string,
737
847
  unknown
738
848
  >,
@@ -749,69 +859,83 @@ export class Collection<T extends object = Record<string, unknown>> {
749
859
  throw new Error(`No changes were made to any of the objects`)
750
860
  }
751
861
 
752
- transaction.applyMutations(mutations)
862
+ // If an ambient transaction exists, use it
863
+ if (ambientTransaction) {
864
+ ambientTransaction.applyMutations(mutations)
865
+
866
+ this.transactions.setState((sortedMap) => {
867
+ sortedMap.set(ambientTransaction.id, ambientTransaction)
868
+ return sortedMap
869
+ })
870
+
871
+ return ambientTransaction
872
+ }
873
+
874
+ // No need to check for onUpdate handler here as we've already checked at the beginning
875
+
876
+ // Create a new transaction with a mutation function that calls the onUpdate handler
877
+ const directOpTransaction = new Transaction({
878
+ mutationFn: async (transaction) => {
879
+ // Call the onUpdate handler with the transaction
880
+ return this.config.onUpdate!(transaction)
881
+ },
882
+ })
883
+
884
+ // Apply mutations to the new transaction
885
+ directOpTransaction.applyMutations(mutations)
886
+ directOpTransaction.commit()
753
887
 
888
+ // Add the transaction to the collection's transactions store
754
889
  this.transactions.setState((sortedMap) => {
755
- sortedMap.set(transaction.id, transaction)
890
+ sortedMap.set(directOpTransaction.id, directOpTransaction)
756
891
  return sortedMap
757
892
  })
758
893
 
759
- return transaction
894
+ return directOpTransaction
760
895
  }
761
896
 
762
897
  /**
763
898
  * Deletes one or more items from the collection
764
- * @param items - Single item/key or array of items/keys to delete
899
+ * @param ids - Single ID or array of IDs to delete
765
900
  * @param config - Optional configuration including metadata
766
- * @returns A Transaction object representing the delete operation(s)
901
+ * @returns A TransactionType object representing the delete operation(s)
767
902
  * @example
768
903
  * // Delete a single item
769
- * delete(todo)
904
+ * delete("todo-1")
770
905
  *
771
906
  * // Delete multiple items
772
- * delete([todo1, todo2])
907
+ * delete(["todo-1", "todo-2"])
773
908
  *
774
909
  * // Delete with metadata
775
- * delete(todo, { metadata: { reason: "completed" } })
910
+ * delete("todo-1", { metadata: { reason: "completed" } })
776
911
  */
777
912
  delete = (
778
- items: Array<T | string> | T | string,
913
+ ids: Array<string> | string,
779
914
  config?: OperationConfig
780
- ) => {
781
- const transaction = getActiveTransaction()
782
- if (typeof transaction === `undefined`) {
783
- throw `no transaction found when calling collection.delete`
915
+ ): TransactionType => {
916
+ const ambientTransaction = getActiveTransaction()
917
+
918
+ // If no ambient transaction exists, check for an onDelete handler early
919
+ if (!ambientTransaction && !this.config.onDelete) {
920
+ throw new Error(
921
+ `Collection.delete called directly (not within an explicit transaction) but no 'onDelete' handler is configured.`
922
+ )
784
923
  }
785
924
 
786
- const itemsArray = Array.isArray(items) ? items : [items]
925
+ const idsArray = (Array.isArray(ids) ? ids : [ids]).map((id) =>
926
+ this.getKeyFromId(id)
927
+ )
787
928
  const mutations: Array<PendingMutation<T>> = []
788
929
 
789
- for (const item of itemsArray) {
790
- let key: string
791
- if (typeof item === `object` && (item as unknown) !== null) {
792
- const objectKey = this.objectKeyMap.get(item)
793
- if (objectKey === undefined) {
794
- throw new Error(
795
- `Object not found in collection: ${JSON.stringify(item)}`
796
- )
797
- }
798
- key = objectKey
799
- } else if (typeof item === `string`) {
800
- key = item
801
- } else {
802
- throw new Error(
803
- `Invalid item type for delete - must be an object or string key`
804
- )
805
- }
806
-
930
+ for (const id of idsArray) {
807
931
  const mutation: PendingMutation<T> = {
808
932
  mutationId: crypto.randomUUID(),
809
- original: (this.state.get(key) || {}) as Record<string, unknown>,
810
- modified: { _deleted: true },
811
- changes: { _deleted: true },
812
- key,
933
+ original: (this.state.get(id) || {}) as Record<string, unknown>,
934
+ modified: (this.state.get(id) || {}) as Record<string, unknown>,
935
+ changes: (this.state.get(id) || {}) as Record<string, unknown>,
936
+ key: id,
813
937
  metadata: config?.metadata as unknown,
814
- syncMetadata: (this.syncedMetadata.state.get(key) || {}) as Record<
938
+ syncMetadata: (this.syncedMetadata.state.get(id) || {}) as Record<
815
939
  string,
816
940
  unknown
817
941
  >,
@@ -824,22 +948,38 @@ export class Collection<T extends object = Record<string, unknown>> {
824
948
  mutations.push(mutation)
825
949
  }
826
950
 
827
- // Delete object => key mapping.
828
- mutations.forEach((mutation) => {
829
- const curValue = this.state.get(mutation.key)
830
- if (curValue) {
831
- this.objectKeyMap.delete(curValue)
832
- }
951
+ // If an ambient transaction exists, use it
952
+ if (ambientTransaction) {
953
+ ambientTransaction.applyMutations(mutations)
954
+
955
+ this.transactions.setState((sortedMap) => {
956
+ sortedMap.set(ambientTransaction.id, ambientTransaction)
957
+ return sortedMap
958
+ })
959
+
960
+ return ambientTransaction
961
+ }
962
+
963
+ // Create a new transaction with a mutation function that calls the onDelete handler
964
+ const directOpTransaction = new Transaction({
965
+ autoCommit: true,
966
+ mutationFn: async (transaction) => {
967
+ // Call the onDelete handler with the transaction
968
+ return this.config.onDelete!(transaction)
969
+ },
833
970
  })
834
971
 
835
- transaction.applyMutations(mutations)
972
+ // Apply mutations to the new transaction
973
+ directOpTransaction.applyMutations(mutations)
974
+ directOpTransaction.commit()
836
975
 
976
+ // Add the transaction to the collection's transactions store
837
977
  this.transactions.setState((sortedMap) => {
838
- sortedMap.set(transaction.id, transaction)
978
+ sortedMap.set(directOpTransaction.id, directOpTransaction)
839
979
  return sortedMap
840
980
  })
841
981
 
842
- return transaction
982
+ return directOpTransaction
843
983
  }
844
984
 
845
985
  /**