@tanstack/db 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/cjs/collection.cjs +23 -4
  2. package/dist/cjs/collection.cjs.map +1 -1
  3. package/dist/cjs/collection.d.cts +35 -41
  4. package/dist/cjs/local-only.cjs.map +1 -1
  5. package/dist/cjs/local-only.d.cts +17 -43
  6. package/dist/cjs/local-storage.cjs +3 -12
  7. package/dist/cjs/local-storage.cjs.map +1 -1
  8. package/dist/cjs/local-storage.d.cts +16 -39
  9. package/dist/cjs/query/builder/types.d.cts +3 -10
  10. package/dist/cjs/query/live-query-collection.cjs.map +1 -1
  11. package/dist/cjs/transactions.cjs +76 -5
  12. package/dist/cjs/transactions.cjs.map +1 -1
  13. package/dist/cjs/transactions.d.cts +17 -0
  14. package/dist/cjs/types.d.cts +10 -31
  15. package/dist/esm/collection.d.ts +35 -41
  16. package/dist/esm/collection.js +23 -4
  17. package/dist/esm/collection.js.map +1 -1
  18. package/dist/esm/local-only.d.ts +17 -43
  19. package/dist/esm/local-only.js.map +1 -1
  20. package/dist/esm/local-storage.d.ts +16 -39
  21. package/dist/esm/local-storage.js +3 -12
  22. package/dist/esm/local-storage.js.map +1 -1
  23. package/dist/esm/query/builder/types.d.ts +3 -10
  24. package/dist/esm/query/live-query-collection.js.map +1 -1
  25. package/dist/esm/transactions.d.ts +17 -0
  26. package/dist/esm/transactions.js +76 -5
  27. package/dist/esm/transactions.js.map +1 -1
  28. package/dist/esm/types.d.ts +10 -31
  29. package/package.json +2 -2
  30. package/src/collection.ts +148 -196
  31. package/src/local-only.ts +57 -77
  32. package/src/local-storage.ts +53 -85
  33. package/src/query/builder/types.ts +3 -12
  34. package/src/query/live-query-collection.ts +1 -1
  35. package/src/transactions.ts +121 -6
  36. package/src/types.ts +25 -55
package/src/local-only.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import type {
2
+ BaseCollectionConfig,
2
3
  CollectionConfig,
3
4
  DeleteMutationFnParams,
5
+ InferSchemaOutput,
4
6
  InsertMutationFnParams,
5
7
  OperationType,
6
- ResolveType,
7
8
  SyncConfig,
8
9
  UpdateMutationFnParams,
9
10
  UtilsRecord,
@@ -12,76 +13,23 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"
12
13
 
13
14
  /**
14
15
  * Configuration interface for Local-only collection options
15
- * @template TExplicit - The explicit type of items in the collection (highest priority)
16
- * @template TSchema - The schema type for validation and type inference (second priority)
17
- * @template TFallback - The fallback type if no explicit or schema type is provided
18
- * @template TKey - The type of the key returned by getKey
19
- *
20
- * @remarks
21
- * Type resolution follows a priority order:
22
- * 1. If you provide an explicit type via generic parameter, it will be used
23
- * 2. If no explicit type is provided but a schema is, the schema's output type will be inferred
24
- * 3. If neither explicit type nor schema is provided, the fallback type will be used
25
- *
26
- * You should provide EITHER an explicit type OR a schema, but not both, as they would conflict.
16
+ * @template T - The type of items in the collection
17
+ * @template TSchema - The schema type for validation
18
+ * @template TKey - The type of the key returned by `getKey`
27
19
  */
28
20
  export interface LocalOnlyCollectionConfig<
29
- TExplicit = unknown,
21
+ T extends object = object,
30
22
  TSchema extends StandardSchemaV1 = never,
31
- TFallback extends Record<string, unknown> = Record<string, unknown>,
32
23
  TKey extends string | number = string | number,
33
- > {
34
- /**
35
- * Standard Collection configuration properties
36
- */
37
- id?: string
38
- schema?: TSchema
39
- getKey: (item: ResolveType<TExplicit, TSchema, TFallback>) => TKey
40
-
24
+ > extends Omit<
25
+ BaseCollectionConfig<T, TKey, TSchema, LocalOnlyCollectionUtils>,
26
+ `gcTime` | `startSync`
27
+ > {
41
28
  /**
42
29
  * Optional initial data to populate the collection with on creation
43
30
  * This data will be applied during the initial sync process
44
31
  */
45
- initialData?: Array<ResolveType<TExplicit, TSchema, TFallback>>
46
-
47
- /**
48
- * Optional asynchronous handler function called after an insert operation
49
- * @param params Object containing transaction and collection information
50
- * @returns Promise resolving to any value
51
- */
52
- onInsert?: (
53
- params: InsertMutationFnParams<
54
- ResolveType<TExplicit, TSchema, TFallback>,
55
- TKey,
56
- LocalOnlyCollectionUtils
57
- >
58
- ) => Promise<any>
59
-
60
- /**
61
- * Optional asynchronous handler function called after an update operation
62
- * @param params Object containing transaction and collection information
63
- * @returns Promise resolving to any value
64
- */
65
- onUpdate?: (
66
- params: UpdateMutationFnParams<
67
- ResolveType<TExplicit, TSchema, TFallback>,
68
- TKey,
69
- LocalOnlyCollectionUtils
70
- >
71
- ) => Promise<any>
72
-
73
- /**
74
- * Optional asynchronous handler function called after a delete operation
75
- * @param params Object containing transaction and collection information
76
- * @returns Promise resolving to any value
77
- */
78
- onDelete?: (
79
- params: DeleteMutationFnParams<
80
- ResolveType<TExplicit, TSchema, TFallback>,
81
- TKey,
82
- LocalOnlyCollectionUtils
83
- >
84
- ) => Promise<any>
32
+ initialData?: Array<T>
85
33
  }
86
34
 
87
35
  /**
@@ -96,9 +44,7 @@ export interface LocalOnlyCollectionUtils extends UtilsRecord {}
96
44
  * that immediately "syncs" all optimistic changes to the collection, making them permanent.
97
45
  * Perfect for local-only data that doesn't need persistence or external synchronization.
98
46
  *
99
- * @template TExplicit - The explicit type of items in the collection (highest priority)
100
- * @template TSchema - The schema type for validation and type inference (second priority)
101
- * @template TFallback - The fallback type if no explicit or schema type is provided
47
+ * @template T - The schema type if a schema is provided, otherwise the type of items in the collection
102
48
  * @template TKey - The type of the key returned by getKey
103
49
  * @param config - Configuration options for the Local-only collection
104
50
  * @returns Collection options with utilities (currently empty but follows the pattern)
@@ -135,29 +81,55 @@ export interface LocalOnlyCollectionUtils extends UtilsRecord {}
135
81
  * })
136
82
  * )
137
83
  */
84
+
85
+ // Overload for when schema is provided
138
86
  export function localOnlyCollectionOptions<
139
- TExplicit = unknown,
140
- TSchema extends StandardSchemaV1 = never,
141
- TFallback extends Record<string, unknown> = Record<string, unknown>,
87
+ T extends StandardSchemaV1,
142
88
  TKey extends string | number = string | number,
143
89
  >(
144
- config: LocalOnlyCollectionConfig<TExplicit, TSchema, TFallback, TKey>
145
- ): CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>, TKey> & {
90
+ config: LocalOnlyCollectionConfig<InferSchemaOutput<T>, T, TKey> & {
91
+ schema: T
92
+ }
93
+ ): CollectionConfig<InferSchemaOutput<T>, TKey, T> & {
146
94
  utils: LocalOnlyCollectionUtils
147
- } {
148
- type ResolvedType = ResolveType<TExplicit, TSchema, TFallback>
95
+ schema: T
96
+ }
97
+
98
+ // Overload for when no schema is provided
99
+ // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config
100
+ export function localOnlyCollectionOptions<
101
+ T extends object,
102
+ TKey extends string | number = string | number,
103
+ >(
104
+ config: LocalOnlyCollectionConfig<T, never, TKey> & {
105
+ schema?: never // prohibit schema
106
+ }
107
+ ): CollectionConfig<T, TKey> & {
108
+ utils: LocalOnlyCollectionUtils
109
+ schema?: never // no schema in the result
110
+ }
149
111
 
112
+ export function localOnlyCollectionOptions(
113
+ config: LocalOnlyCollectionConfig<any, any, string | number>
114
+ ): CollectionConfig<any, string | number, any> & {
115
+ utils: LocalOnlyCollectionUtils
116
+ schema?: StandardSchemaV1
117
+ } {
150
118
  const { initialData, onInsert, onUpdate, onDelete, ...restConfig } = config
151
119
 
152
120
  // Create the sync configuration with transaction confirmation capability
153
- const syncResult = createLocalOnlySync<ResolvedType, TKey>(initialData)
121
+ const syncResult = createLocalOnlySync(initialData)
154
122
 
155
123
  /**
156
124
  * Create wrapper handlers that call user handlers first, then confirm transactions
157
125
  * Wraps the user's onInsert handler to also confirm the transaction immediately
158
126
  */
159
127
  const wrappedOnInsert = async (
160
- params: InsertMutationFnParams<ResolvedType, TKey, LocalOnlyCollectionUtils>
128
+ params: InsertMutationFnParams<
129
+ any,
130
+ string | number,
131
+ LocalOnlyCollectionUtils
132
+ >
161
133
  ) => {
162
134
  // Call user handler first if provided
163
135
  let handlerResult
@@ -175,7 +147,11 @@ export function localOnlyCollectionOptions<
175
147
  * Wrapper for onUpdate handler that also confirms the transaction immediately
176
148
  */
177
149
  const wrappedOnUpdate = async (
178
- params: UpdateMutationFnParams<ResolvedType, TKey, LocalOnlyCollectionUtils>
150
+ params: UpdateMutationFnParams<
151
+ any,
152
+ string | number,
153
+ LocalOnlyCollectionUtils
154
+ >
179
155
  ) => {
180
156
  // Call user handler first if provided
181
157
  let handlerResult
@@ -193,7 +169,11 @@ export function localOnlyCollectionOptions<
193
169
  * Wrapper for onDelete handler that also confirms the transaction immediately
194
170
  */
195
171
  const wrappedOnDelete = async (
196
- params: DeleteMutationFnParams<ResolvedType, TKey, LocalOnlyCollectionUtils>
172
+ params: DeleteMutationFnParams<
173
+ any,
174
+ string | number,
175
+ LocalOnlyCollectionUtils
176
+ >
197
177
  ) => {
198
178
  // Call user handler first if provided
199
179
  let handlerResult
@@ -7,10 +7,11 @@ import {
7
7
  StorageKeyRequiredError,
8
8
  } from "./errors"
9
9
  import type {
10
+ BaseCollectionConfig,
10
11
  CollectionConfig,
11
12
  DeleteMutationFnParams,
13
+ InferSchemaOutput,
12
14
  InsertMutationFnParams,
13
- ResolveType,
14
15
  SyncConfig,
15
16
  UpdateMutationFnParams,
16
17
  UtilsRecord,
@@ -46,23 +47,15 @@ interface StoredItem<T> {
46
47
 
47
48
  /**
48
49
  * Configuration interface for localStorage collection options
49
- * @template TExplicit - The explicit type of items in the collection (highest priority)
50
- * @template TSchema - The schema type for validation and type inference (second priority)
51
- * @template TFallback - The fallback type if no explicit or schema type is provided
52
- *
53
- * @remarks
54
- * Type resolution follows a priority order:
55
- * 1. If you provide an explicit type via generic parameter, it will be used
56
- * 2. If no explicit type is provided but a schema is, the schema's output type will be inferred
57
- * 3. If neither explicit type nor schema is provided, the fallback type will be used
58
- *
59
- * You should provide EITHER an explicit type OR a schema, but not both, as they would conflict.
50
+ * @template T - The type of items in the collection
51
+ * @template TSchema - The schema type for validation
52
+ * @template TKey - The type of the key returned by `getKey`
60
53
  */
61
54
  export interface LocalStorageCollectionConfig<
62
- TExplicit = unknown,
55
+ T extends object = object,
63
56
  TSchema extends StandardSchemaV1 = never,
64
- TFallback extends object = Record<string, unknown>,
65
- > {
57
+ TKey extends string | number = string | number,
58
+ > extends BaseCollectionConfig<T, TKey, TSchema> {
66
59
  /**
67
60
  * The key to use for storing the collection data in localStorage/sessionStorage
68
61
  */
@@ -79,41 +72,6 @@ export interface LocalStorageCollectionConfig<
79
72
  * Can be any object that implements addEventListener/removeEventListener for storage events
80
73
  */
81
74
  storageEventApi?: StorageEventApi
82
-
83
- /**
84
- * Collection identifier (defaults to "local-collection:{storageKey}" if not provided)
85
- */
86
- id?: string
87
- schema?: TSchema
88
- getKey: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>[`getKey`]
89
- sync?: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>[`sync`]
90
-
91
- /**
92
- * Optional asynchronous handler function called before an insert operation
93
- * @param params Object containing transaction and collection information
94
- * @returns Promise resolving to any value
95
- */
96
- onInsert?: (
97
- params: InsertMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
98
- ) => Promise<any>
99
-
100
- /**
101
- * Optional asynchronous handler function called before an update operation
102
- * @param params Object containing transaction and collection information
103
- * @returns Promise resolving to any value
104
- */
105
- onUpdate?: (
106
- params: UpdateMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
107
- ) => Promise<any>
108
-
109
- /**
110
- * Optional asynchronous handler function called before a delete operation
111
- * @param params Object containing transaction and collection information
112
- * @returns Promise resolving to any value
113
- */
114
- onDelete?: (
115
- params: DeleteMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
116
- ) => Promise<any>
117
75
  }
118
76
 
119
77
  /**
@@ -202,18 +160,43 @@ function generateUuid(): string {
202
160
  * })
203
161
  * )
204
162
  */
163
+
164
+ // Overload for when schema is provided
205
165
  export function localStorageCollectionOptions<
206
- TExplicit = unknown,
207
- TSchema extends StandardSchemaV1 = never,
208
- TFallback extends object = Record<string, unknown>,
166
+ T extends StandardSchemaV1,
167
+ TKey extends string | number = string | number,
209
168
  >(
210
- config: LocalStorageCollectionConfig<TExplicit, TSchema, TFallback>
211
- ): Omit<CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>, `id`> & {
169
+ config: LocalStorageCollectionConfig<InferSchemaOutput<T>, T, TKey> & {
170
+ schema: T
171
+ }
172
+ ): CollectionConfig<InferSchemaOutput<T>, TKey, T> & {
212
173
  id: string
213
174
  utils: LocalStorageCollectionUtils
214
- } {
215
- type ResolvedType = ResolveType<TExplicit, TSchema, TFallback>
175
+ schema: T
176
+ }
216
177
 
178
+ // Overload for when no schema is provided
179
+ // the type T needs to be passed explicitly unless it can be inferred from the getKey function in the config
180
+ export function localStorageCollectionOptions<
181
+ T extends object,
182
+ TKey extends string | number = string | number,
183
+ >(
184
+ config: LocalStorageCollectionConfig<T, never, TKey> & {
185
+ schema?: never // prohibit schema
186
+ }
187
+ ): CollectionConfig<T, TKey> & {
188
+ id: string
189
+ utils: LocalStorageCollectionUtils
190
+ schema?: never // no schema in the result
191
+ }
192
+
193
+ export function localStorageCollectionOptions(
194
+ config: LocalStorageCollectionConfig<any, any, string | number>
195
+ ): Omit<CollectionConfig<any, string | number, any>, `id`> & {
196
+ id: string
197
+ utils: LocalStorageCollectionUtils
198
+ schema?: StandardSchemaV1
199
+ } {
217
200
  // Validate required parameters
218
201
  if (!config.storageKey) {
219
202
  throw new StorageKeyRequiredError()
@@ -237,10 +220,10 @@ export function localStorageCollectionOptions<
237
220
  }
238
221
 
239
222
  // Track the last known state to detect changes
240
- const lastKnownData = new Map<string | number, StoredItem<ResolvedType>>()
223
+ const lastKnownData = new Map<string | number, StoredItem<any>>()
241
224
 
242
225
  // Create the sync configuration
243
- const sync = createLocalStorageSync<ResolvedType>(
226
+ const sync = createLocalStorageSync<any>(
244
227
  config.storageKey,
245
228
  storage,
246
229
  storageEventApi,
@@ -263,11 +246,11 @@ export function localStorageCollectionOptions<
263
246
  * @param dataMap - Map of items with version tracking to save to storage
264
247
  */
265
248
  const saveToStorage = (
266
- dataMap: Map<string | number, StoredItem<ResolvedType>>
249
+ dataMap: Map<string | number, StoredItem<any>>
267
250
  ): void => {
268
251
  try {
269
252
  // Convert Map to object format for storage
270
- const objectData: Record<string, StoredItem<ResolvedType>> = {}
253
+ const objectData: Record<string, StoredItem<any>> = {}
271
254
  dataMap.forEach((storedItem, key) => {
272
255
  objectData[String(key)] = storedItem
273
256
  })
@@ -302,9 +285,7 @@ export function localStorageCollectionOptions<
302
285
  * Create wrapper handlers for direct persistence operations that perform actual storage operations
303
286
  * Wraps the user's onInsert handler to also save changes to localStorage
304
287
  */
305
- const wrappedOnInsert = async (
306
- params: InsertMutationFnParams<ResolvedType>
307
- ) => {
288
+ const wrappedOnInsert = async (params: InsertMutationFnParams<any>) => {
308
289
  // Validate that all values in the transaction can be JSON serialized
309
290
  params.transaction.mutations.forEach((mutation) => {
310
291
  validateJsonSerializable(mutation.modified, `insert`)
@@ -318,15 +299,12 @@ export function localStorageCollectionOptions<
318
299
 
319
300
  // Always persist to storage
320
301
  // Load current data from storage
321
- const currentData = loadFromStorage<ResolvedType>(
322
- config.storageKey,
323
- storage
324
- )
302
+ const currentData = loadFromStorage<any>(config.storageKey, storage)
325
303
 
326
304
  // Add new items with version keys
327
305
  params.transaction.mutations.forEach((mutation) => {
328
306
  const key = config.getKey(mutation.modified)
329
- const storedItem: StoredItem<ResolvedType> = {
307
+ const storedItem: StoredItem<any> = {
330
308
  versionKey: generateUuid(),
331
309
  data: mutation.modified,
332
310
  }
@@ -342,9 +320,7 @@ export function localStorageCollectionOptions<
342
320
  return handlerResult
343
321
  }
344
322
 
345
- const wrappedOnUpdate = async (
346
- params: UpdateMutationFnParams<ResolvedType>
347
- ) => {
323
+ const wrappedOnUpdate = async (params: UpdateMutationFnParams<any>) => {
348
324
  // Validate that all values in the transaction can be JSON serialized
349
325
  params.transaction.mutations.forEach((mutation) => {
350
326
  validateJsonSerializable(mutation.modified, `update`)
@@ -358,15 +334,12 @@ export function localStorageCollectionOptions<
358
334
 
359
335
  // Always persist to storage
360
336
  // Load current data from storage
361
- const currentData = loadFromStorage<ResolvedType>(
362
- config.storageKey,
363
- storage
364
- )
337
+ const currentData = loadFromStorage<any>(config.storageKey, storage)
365
338
 
366
339
  // Update items with new version keys
367
340
  params.transaction.mutations.forEach((mutation) => {
368
341
  const key = config.getKey(mutation.modified)
369
- const storedItem: StoredItem<ResolvedType> = {
342
+ const storedItem: StoredItem<any> = {
370
343
  versionKey: generateUuid(),
371
344
  data: mutation.modified,
372
345
  }
@@ -382,9 +355,7 @@ export function localStorageCollectionOptions<
382
355
  return handlerResult
383
356
  }
384
357
 
385
- const wrappedOnDelete = async (
386
- params: DeleteMutationFnParams<ResolvedType>
387
- ) => {
358
+ const wrappedOnDelete = async (params: DeleteMutationFnParams<any>) => {
388
359
  // Call the user handler BEFORE persisting changes (if provided)
389
360
  let handlerResult: any = {}
390
361
  if (config.onDelete) {
@@ -393,15 +364,12 @@ export function localStorageCollectionOptions<
393
364
 
394
365
  // Always persist to storage
395
366
  // Load current data from storage
396
- const currentData = loadFromStorage<ResolvedType>(
397
- config.storageKey,
398
- storage
399
- )
367
+ const currentData = loadFromStorage<any>(config.storageKey, storage)
400
368
 
401
369
  // Remove items
402
370
  params.transaction.mutations.forEach((mutation) => {
403
371
  // For delete operations, mutation.original contains the full object
404
- const key = config.getKey(mutation.original as ResolvedType)
372
+ const key = config.getKey(mutation.original)
405
373
  currentData.delete(key)
406
374
  })
407
375
 
@@ -8,7 +8,6 @@ import type {
8
8
  Value,
9
9
  } from "../ir.js"
10
10
  import type { QueryBuilder } from "./index.js"
11
- import type { ResolveType } from "../../types.js"
12
11
 
13
12
  /**
14
13
  * Context - The central state container for query builder operations
@@ -77,19 +76,11 @@ export type Source = {
77
76
  /**
78
77
  * InferCollectionType - Extracts the TypeScript type from a CollectionImpl
79
78
  *
80
- * This helper ensures we get the same type that would be used when creating
81
- * the collection itself. It uses the internal `ResolveType` logic to maintain
82
- * consistency between collection creation and query type inference.
83
- *
84
- * The complex generic parameters extract:
85
- * - U: The base document type
86
- * - TSchema: The schema definition
87
- * - The resolved type combines these with any transforms
79
+ * This helper ensures we get the same type that was used when creating the collection itself.
80
+ * This can be an explicit type passed by the user or the schema output type.
88
81
  */
89
82
  export type InferCollectionType<T> =
90
- T extends CollectionImpl<infer U, any, any, infer TSchema, any>
91
- ? ResolveType<U, TSchema, U>
92
- : never
83
+ T extends CollectionImpl<infer TOutput, any, any, any, any> ? TOutput : never
93
84
 
94
85
  /**
95
86
  * SchemaFromSource - Converts a Source definition into a ContextSchema
@@ -130,7 +130,7 @@ export function createLiveQueryCollection<
130
130
 
131
131
  /**
132
132
  * Bridge function that handles the type compatibility between query2's TResult
133
- * and core collection's ResolveType without exposing ugly type assertions to users
133
+ * and core collection's output type without exposing ugly type assertions to users
134
134
  */
135
135
  function bridgeToCreateCollection<
136
136
  TResult extends object,
@@ -19,6 +19,86 @@ let transactionStack: Array<Transaction<any>> = []
19
19
 
20
20
  let sequenceNumber = 0
21
21
 
22
+ /**
23
+ * Merges two pending mutations for the same item within a transaction
24
+ *
25
+ * Merge behavior truth table:
26
+ * - (insert, update) → insert (merge changes, keep empty original)
27
+ * - (insert, delete) → null (cancel both mutations)
28
+ * - (update, delete) → delete (delete dominates)
29
+ * - (update, update) → update (replace with latest, union changes)
30
+ * - (delete, delete) → delete (replace with latest)
31
+ * - (insert, insert) → insert (replace with latest)
32
+ *
33
+ * Note: (delete, update) and (delete, insert) should never occur as the collection
34
+ * layer prevents operations on deleted items within the same transaction.
35
+ *
36
+ * @param existing - The existing mutation in the transaction
37
+ * @param incoming - The new mutation being applied
38
+ * @returns The merged mutation, or null if both should be removed
39
+ */
40
+ function mergePendingMutations<T extends object>(
41
+ existing: PendingMutation<T>,
42
+ incoming: PendingMutation<T>
43
+ ): PendingMutation<T> | null {
44
+ // Truth table implementation
45
+ switch (`${existing.type}-${incoming.type}` as const) {
46
+ case `insert-update`: {
47
+ // Update after insert: keep as insert but merge changes
48
+ // For insert-update, the key should remain the same since collections don't allow key changes
49
+ return {
50
+ ...existing,
51
+ type: `insert` as const,
52
+ original: {},
53
+ modified: incoming.modified,
54
+ changes: { ...existing.changes, ...incoming.changes },
55
+ // Keep existing keys (key changes not allowed in updates)
56
+ key: existing.key,
57
+ globalKey: existing.globalKey,
58
+ // Merge metadata (last-write-wins)
59
+ metadata: incoming.metadata ?? existing.metadata,
60
+ syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },
61
+ // Update tracking info
62
+ mutationId: incoming.mutationId,
63
+ updatedAt: incoming.updatedAt,
64
+ }
65
+ }
66
+
67
+ case `insert-delete`:
68
+ // Delete after insert: cancel both mutations
69
+ return null
70
+
71
+ case `update-delete`:
72
+ // Delete after update: delete dominates
73
+ return incoming
74
+
75
+ case `update-update`: {
76
+ // Update after update: replace with latest, union changes
77
+ return {
78
+ ...incoming,
79
+ // Keep original from first update
80
+ original: existing.original,
81
+ // Union the changes from both updates
82
+ changes: { ...existing.changes, ...incoming.changes },
83
+ // Merge metadata
84
+ metadata: incoming.metadata ?? existing.metadata,
85
+ syncMetadata: { ...existing.syncMetadata, ...incoming.syncMetadata },
86
+ }
87
+ }
88
+
89
+ case `delete-delete`:
90
+ case `insert-insert`:
91
+ // Same type: replace with latest
92
+ return incoming
93
+
94
+ default: {
95
+ // Exhaustiveness check
96
+ const _exhaustive: never = `${existing.type}-${incoming.type}` as never
97
+ throw new Error(`Unhandled mutation combination: ${_exhaustive}`)
98
+ }
99
+ }
100
+ }
101
+
22
102
  /**
23
103
  * Creates a new transaction for grouping multiple collection operations
24
104
  * @param config - Transaction configuration with mutation function
@@ -203,12 +283,32 @@ class Transaction<T extends object = Record<string, unknown>> {
203
283
  }
204
284
 
205
285
  if (this.autoCommit) {
206
- this.commit()
286
+ this.commit().catch(() => {
287
+ // Errors from autoCommit are handled via isPersisted.promise
288
+ // This catch prevents unhandled promise rejections
289
+ })
207
290
  }
208
291
 
209
292
  return this
210
293
  }
211
294
 
295
+ /**
296
+ * Apply new mutations to this transaction, intelligently merging with existing mutations
297
+ *
298
+ * When mutations operate on the same item (same globalKey), they are merged according to
299
+ * the following rules:
300
+ *
301
+ * - **insert + update** → insert (merge changes, keep empty original)
302
+ * - **insert + delete** → removed (mutations cancel each other out)
303
+ * - **update + delete** → delete (delete dominates)
304
+ * - **update + update** → update (union changes, keep first original)
305
+ * - **same type** → replace with latest
306
+ *
307
+ * This merging reduces over-the-wire churn and keeps the optimistic local view
308
+ * aligned with user intent.
309
+ *
310
+ * @param mutations - Array of new mutations to apply
311
+ */
212
312
  applyMutations(mutations: Array<PendingMutation<any>>): void {
213
313
  for (const newMutation of mutations) {
214
314
  const existingIndex = this.mutations.findIndex(
@@ -216,8 +316,16 @@ class Transaction<T extends object = Record<string, unknown>> {
216
316
  )
217
317
 
218
318
  if (existingIndex >= 0) {
219
- // Replace existing mutation
220
- this.mutations[existingIndex] = newMutation
319
+ const existingMutation = this.mutations[existingIndex]!
320
+ const mergeResult = mergePendingMutations(existingMutation, newMutation)
321
+
322
+ if (mergeResult === null) {
323
+ // Remove the mutation (e.g., delete after insert cancels both)
324
+ this.mutations.splice(existingIndex, 1)
325
+ } else {
326
+ // Replace with merged mutation
327
+ this.mutations[existingIndex] = mergeResult
328
+ }
221
329
  } else {
222
330
  // Insert new mutation
223
331
  this.mutations.push(newMutation)
@@ -374,14 +482,21 @@ class Transaction<T extends object = Record<string, unknown>> {
374
482
 
375
483
  this.isPersisted.resolve(this)
376
484
  } catch (error) {
485
+ // Preserve the original error for rethrowing
486
+ const originalError =
487
+ error instanceof Error ? error : new Error(String(error))
488
+
377
489
  // Update transaction with error information
378
490
  this.error = {
379
- message: error instanceof Error ? error.message : String(error),
380
- error: error instanceof Error ? error : new Error(String(error)),
491
+ message: originalError.message,
492
+ error: originalError,
381
493
  }
382
494
 
383
495
  // rollback the transaction
384
- return this.rollback()
496
+ this.rollback()
497
+
498
+ // Re-throw the original error to preserve identity and stack
499
+ throw originalError
385
500
  }
386
501
 
387
502
  return this