@tanstack/db 0.0.5 → 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 (43) hide show
  1. package/dist/cjs/collection.cjs +71 -21
  2. package/dist/cjs/collection.cjs.map +1 -1
  3. package/dist/cjs/collection.d.cts +8 -7
  4. package/dist/cjs/query/evaluators.cjs +15 -0
  5. package/dist/cjs/query/evaluators.cjs.map +1 -1
  6. package/dist/cjs/query/evaluators.d.cts +5 -1
  7. package/dist/cjs/query/pipeline-compiler.cjs +2 -2
  8. package/dist/cjs/query/pipeline-compiler.cjs.map +1 -1
  9. package/dist/cjs/query/query-builder.cjs +22 -17
  10. package/dist/cjs/query/query-builder.cjs.map +1 -1
  11. package/dist/cjs/query/query-builder.d.cts +12 -2
  12. package/dist/cjs/query/schema.d.cts +11 -5
  13. package/dist/cjs/query/select.cjs +12 -0
  14. package/dist/cjs/query/select.cjs.map +1 -1
  15. package/dist/cjs/transactions.cjs +3 -1
  16. package/dist/cjs/transactions.cjs.map +1 -1
  17. package/dist/cjs/types.d.cts +25 -0
  18. package/dist/esm/collection.d.ts +8 -7
  19. package/dist/esm/collection.js +72 -22
  20. package/dist/esm/collection.js.map +1 -1
  21. package/dist/esm/query/evaluators.d.ts +5 -1
  22. package/dist/esm/query/evaluators.js +16 -1
  23. package/dist/esm/query/evaluators.js.map +1 -1
  24. package/dist/esm/query/pipeline-compiler.js +3 -3
  25. package/dist/esm/query/pipeline-compiler.js.map +1 -1
  26. package/dist/esm/query/query-builder.d.ts +12 -2
  27. package/dist/esm/query/query-builder.js +22 -17
  28. package/dist/esm/query/query-builder.js.map +1 -1
  29. package/dist/esm/query/schema.d.ts +11 -5
  30. package/dist/esm/query/select.js +12 -0
  31. package/dist/esm/query/select.js.map +1 -1
  32. package/dist/esm/transactions.js +3 -1
  33. package/dist/esm/transactions.js.map +1 -1
  34. package/dist/esm/types.d.ts +25 -0
  35. package/package.json +1 -1
  36. package/src/collection.ts +117 -30
  37. package/src/query/evaluators.ts +27 -0
  38. package/src/query/pipeline-compiler.ts +3 -3
  39. package/src/query/query-builder.ts +40 -23
  40. package/src/query/schema.ts +17 -5
  41. package/src/query/select.ts +24 -1
  42. package/src/transactions.ts +8 -1
  43. package/src/types.ts +28 -0
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
@@ -169,7 +169,7 @@ export class SchemaValidationError extends Error {
169
169
  }
170
170
 
171
171
  export class Collection<T extends object = Record<string, unknown>> {
172
- public transactions: Store<SortedMap<string, Transaction>>
172
+ public transactions: Store<SortedMap<string, TransactionType>>
173
173
  public optimisticOperations: Derived<Array<OptimisticChangeMessage<T>>>
174
174
  public derivedState: Derived<Map<string, T>>
175
175
  public derivedArray: Derived<Array<T>>
@@ -218,7 +218,7 @@ export class Collection<T extends object = Record<string, unknown>> {
218
218
  }
219
219
 
220
220
  this.transactions = new Store(
221
- new SortedMap<string, Transaction>(
221
+ new SortedMap<string, TransactionType>(
222
222
  (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
223
223
  )
224
224
  )
@@ -604,7 +604,7 @@ export class Collection<T extends object = Record<string, unknown>> {
604
604
  * Inserts one or more items into the collection
605
605
  * @param items - Single item or array of items to insert
606
606
  * @param config - Optional configuration including metadata and custom keys
607
- * @returns A Transaction object representing the insert operation(s)
607
+ * @returns A TransactionType object representing the insert operation(s)
608
608
  * @throws {SchemaValidationError} If the data fails schema validation
609
609
  * @example
610
610
  * // Insert a single item
@@ -620,9 +620,13 @@ export class Collection<T extends object = Record<string, unknown>> {
620
620
  * insert({ text: "Buy groceries" }, { key: "grocery-task" })
621
621
  */
622
622
  insert = (data: T | Array<T>, config?: InsertConfig) => {
623
- const transaction = getActiveTransaction()
624
- if (typeof transaction === `undefined`) {
625
- 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
+ )
626
630
  }
627
631
 
628
632
  const items = Array.isArray(data) ? data : [data]
@@ -662,14 +666,37 @@ export class Collection<T extends object = Record<string, unknown>> {
662
666
  mutations.push(mutation)
663
667
  })
664
668
 
665
- transaction.applyMutations(mutations)
669
+ // If an ambient transaction exists, use it
670
+ if (ambientTransaction) {
671
+ ambientTransaction.applyMutations(mutations)
666
672
 
667
- this.transactions.setState((sortedMap) => {
668
- sortedMap.set(transaction.id, transaction)
669
- return sortedMap
670
- })
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()
671
691
 
672
- 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
+ }
673
700
  }
674
701
 
675
702
  /**
@@ -715,13 +742,13 @@ export class Collection<T extends object = Record<string, unknown>> {
715
742
  id: unknown,
716
743
  configOrCallback: ((draft: TItem) => void) | OperationConfig,
717
744
  maybeCallback?: (draft: TItem) => void
718
- ): Transaction
745
+ ): TransactionType
719
746
 
720
747
  update<TItem extends object = T>(
721
748
  ids: Array<unknown>,
722
749
  configOrCallback: ((draft: Array<TItem>) => void) | OperationConfig,
723
750
  maybeCallback?: (draft: Array<TItem>) => void
724
- ): Transaction
751
+ ): TransactionType
725
752
 
726
753
  update<TItem extends object = T>(
727
754
  ids: unknown | Array<unknown>,
@@ -732,9 +759,13 @@ export class Collection<T extends object = Record<string, unknown>> {
732
759
  throw new Error(`The first argument to update is missing`)
733
760
  }
734
761
 
735
- const transaction = getActiveTransaction()
736
- if (typeof transaction === `undefined`) {
737
- 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
+ )
738
769
  }
739
770
 
740
771
  const isArray = Array.isArray(ids)
@@ -828,21 +859,46 @@ export class Collection<T extends object = Record<string, unknown>> {
828
859
  throw new Error(`No changes were made to any of the objects`)
829
860
  }
830
861
 
831
- 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
832
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()
887
+
888
+ // Add the transaction to the collection's transactions store
833
889
  this.transactions.setState((sortedMap) => {
834
- sortedMap.set(transaction.id, transaction)
890
+ sortedMap.set(directOpTransaction.id, directOpTransaction)
835
891
  return sortedMap
836
892
  })
837
893
 
838
- return transaction
894
+ return directOpTransaction
839
895
  }
840
896
 
841
897
  /**
842
898
  * Deletes one or more items from the collection
843
899
  * @param ids - Single ID or array of IDs to delete
844
900
  * @param config - Optional configuration including metadata
845
- * @returns A Transaction object representing the delete operation(s)
901
+ * @returns A TransactionType object representing the delete operation(s)
846
902
  * @example
847
903
  * // Delete a single item
848
904
  * delete("todo-1")
@@ -853,10 +909,17 @@ export class Collection<T extends object = Record<string, unknown>> {
853
909
  * // Delete with metadata
854
910
  * delete("todo-1", { metadata: { reason: "completed" } })
855
911
  */
856
- delete = (ids: Array<string> | string, config?: OperationConfig) => {
857
- const transaction = getActiveTransaction()
858
- if (typeof transaction === `undefined`) {
859
- throw `no transaction found when calling collection.delete`
912
+ delete = (
913
+ ids: Array<string> | string,
914
+ config?: OperationConfig
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
+ )
860
923
  }
861
924
 
862
925
  const idsArray = (Array.isArray(ids) ? ids : [ids]).map((id) =>
@@ -885,14 +948,38 @@ export class Collection<T extends object = Record<string, unknown>> {
885
948
  mutations.push(mutation)
886
949
  }
887
950
 
888
- transaction.applyMutations(mutations)
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
+ },
970
+ })
971
+
972
+ // Apply mutations to the new transaction
973
+ directOpTransaction.applyMutations(mutations)
974
+ directOpTransaction.commit()
889
975
 
976
+ // Add the transaction to the collection's transactions store
890
977
  this.transactions.setState((sortedMap) => {
891
- sortedMap.set(transaction.id, transaction)
978
+ sortedMap.set(directOpTransaction.id, directOpTransaction)
892
979
  return sortedMap
893
980
  })
894
981
 
895
- return transaction
982
+ return directOpTransaction
896
983
  }
897
984
 
898
985
  /**
@@ -6,9 +6,36 @@ import type {
6
6
  ConditionOperand,
7
7
  LogicalOperator,
8
8
  SimpleCondition,
9
+ Where,
10
+ WhereCallback,
9
11
  } from "./schema.js"
10
12
  import type { NamespacedRow } from "../types.js"
11
13
 
14
+ /**
15
+ * Evaluates a Where clause (which is always an array of conditions and/or callbacks) against a nested row structure
16
+ */
17
+ export function evaluateWhereOnNamespacedRow(
18
+ namespacedRow: NamespacedRow,
19
+ where: Where,
20
+ mainTableAlias?: string,
21
+ joinedTableAlias?: string
22
+ ): boolean {
23
+ // Where is always an array of conditions and/or callbacks
24
+ // Evaluate all items and combine with AND logic
25
+ return where.every((item) => {
26
+ if (typeof item === `function`) {
27
+ return (item as WhereCallback)(namespacedRow)
28
+ } else {
29
+ return evaluateConditionOnNamespacedRow(
30
+ namespacedRow,
31
+ item as Condition,
32
+ mainTableAlias,
33
+ joinedTableAlias
34
+ )
35
+ }
36
+ })
37
+ }
38
+
12
39
  /**
13
40
  * Evaluates a condition against a nested row structure
14
41
  */
@@ -1,5 +1,5 @@
1
1
  import { filter, map } from "@electric-sql/d2ts"
2
- import { evaluateConditionOnNamespacedRow } from "./evaluators.js"
2
+ import { evaluateWhereOnNamespacedRow } from "./evaluators.js"
3
3
  import { processJoinClause } from "./joins.js"
4
4
  import { processGroupBy } from "./group-by.js"
5
5
  import { processOrderBy } from "./order-by.js"
@@ -95,7 +95,7 @@ export function compileQueryPipeline<T extends IStreamBuilder<unknown>>(
95
95
  if (query.where) {
96
96
  pipeline = pipeline.pipe(
97
97
  filter(([_key, row]) => {
98
- const result = evaluateConditionOnNamespacedRow(
98
+ const result = evaluateWhereOnNamespacedRow(
99
99
  row,
100
100
  query.where!,
101
101
  mainTableAlias
@@ -117,7 +117,7 @@ export function compileQueryPipeline<T extends IStreamBuilder<unknown>>(
117
117
  filter(([_key, row]) => {
118
118
  // For HAVING, we're working with the flattened row that contains both
119
119
  // the group by keys and the aggregate results directly
120
- const result = evaluateConditionOnNamespacedRow(
120
+ const result = evaluateWhereOnNamespacedRow(
121
121
  row,
122
122
  query.having!,
123
123
  mainTableAlias
@@ -10,6 +10,7 @@ import type {
10
10
  OrderBy,
11
11
  Query,
12
12
  Select,
13
+ WhereCallback,
13
14
  WithQuery,
14
15
  } from "./schema.js"
15
16
  import type {
@@ -197,8 +198,9 @@ export class BaseQueryBuilder<TContext extends Context<Schema>> {
197
198
  /**
198
199
  * Specify what columns to select.
199
200
  * Overwrites any previous select clause.
201
+ * Also supports callback functions that receive the row context and return selected data.
200
202
  *
201
- * @param selects The columns to select
203
+ * @param selects The columns to select (can include callbacks)
202
204
  * @returns A new QueryBuilder with the select clause set
203
205
  */
204
206
  select<TSelects extends Array<Select<TContext>>>(
@@ -296,17 +298,23 @@ export class BaseQueryBuilder<TContext extends Context<Schema>> {
296
298
  */
297
299
  where(condition: Condition<TContext>): QueryBuilder<TContext>
298
300
 
301
+ /**
302
+ * Add a where clause with a callback function.
303
+ */
304
+ where(callback: WhereCallback<TContext>): QueryBuilder<TContext>
305
+
299
306
  /**
300
307
  * Add a where clause to filter the results.
301
308
  * Can be called multiple times to add AND conditions.
309
+ * Also supports callback functions that receive the row context.
302
310
  *
303
- * @param leftOrCondition The left operand or complete condition
311
+ * @param leftOrConditionOrCallback The left operand, complete condition, or callback function
304
312
  * @param operator Optional comparison operator
305
313
  * @param right Optional right operand
306
314
  * @returns A new QueryBuilder with the where clause added
307
315
  */
308
316
  where(
309
- leftOrCondition: any,
317
+ leftOrConditionOrCallback: any,
310
318
  operator?: any,
311
319
  right?: any
312
320
  ): QueryBuilder<TContext> {
@@ -317,22 +325,23 @@ export class BaseQueryBuilder<TContext extends Context<Schema>> {
317
325
 
318
326
  let condition: any
319
327
 
320
- // Determine if this is a complete condition or individual parts
321
- if (operator !== undefined && right !== undefined) {
328
+ // Determine if this is a callback, complete condition, or individual parts
329
+ if (typeof leftOrConditionOrCallback === `function`) {
330
+ // It's a callback function
331
+ condition = leftOrConditionOrCallback
332
+ } else if (operator !== undefined && right !== undefined) {
322
333
  // Create a condition from parts
323
- condition = [leftOrCondition, operator, right]
334
+ condition = [leftOrConditionOrCallback, operator, right]
324
335
  } else {
325
336
  // Use the provided condition directly
326
- condition = leftOrCondition
337
+ condition = leftOrConditionOrCallback
327
338
  }
328
339
 
340
+ // Where is always an array, so initialize or append
329
341
  if (!newBuilder.query.where) {
330
- newBuilder.query.where = condition
342
+ newBuilder.query.where = [condition]
331
343
  } else {
332
- // Create a composite condition with AND
333
- // Use any to bypass type checking issues
334
- const andArray: any = [newBuilder.query.where, `and`, condition]
335
- newBuilder.query.where = andArray
344
+ newBuilder.query.where = [...newBuilder.query.where, condition]
336
345
  }
337
346
 
338
347
  return newBuilder as unknown as QueryBuilder<TContext>
@@ -354,17 +363,24 @@ export class BaseQueryBuilder<TContext extends Context<Schema>> {
354
363
  */
355
364
  having(condition: Condition<TContext>): QueryBuilder<TContext>
356
365
 
366
+ /**
367
+ * Add a having clause with a callback function.
368
+ * For filtering results after they have been grouped.
369
+ */
370
+ having(callback: WhereCallback<TContext>): QueryBuilder<TContext>
371
+
357
372
  /**
358
373
  * Add a having clause to filter the grouped results.
359
374
  * Can be called multiple times to add AND conditions.
375
+ * Also supports callback functions that receive the row context.
360
376
  *
361
- * @param leftOrCondition The left operand or complete condition
377
+ * @param leftOrConditionOrCallback The left operand, complete condition, or callback function
362
378
  * @param operator Optional comparison operator
363
379
  * @param right Optional right operand
364
380
  * @returns A new QueryBuilder with the having clause added
365
381
  */
366
382
  having(
367
- leftOrCondition: any,
383
+ leftOrConditionOrCallback: any,
368
384
  operator?: any,
369
385
  right?: any
370
386
  ): QueryBuilder<TContext> {
@@ -374,22 +390,23 @@ export class BaseQueryBuilder<TContext extends Context<Schema>> {
374
390
 
375
391
  let condition: any
376
392
 
377
- // Determine if this is a complete condition or individual parts
378
- if (operator !== undefined && right !== undefined) {
393
+ // Determine if this is a callback, complete condition, or individual parts
394
+ if (typeof leftOrConditionOrCallback === `function`) {
395
+ // It's a callback function
396
+ condition = leftOrConditionOrCallback
397
+ } else if (operator !== undefined && right !== undefined) {
379
398
  // Create a condition from parts
380
- condition = [leftOrCondition, operator, right]
399
+ condition = [leftOrConditionOrCallback, operator, right]
381
400
  } else {
382
401
  // Use the provided condition directly
383
- condition = leftOrCondition
402
+ condition = leftOrConditionOrCallback
384
403
  }
385
404
 
405
+ // Having is always an array, so initialize or append
386
406
  if (!newBuilder.query.having) {
387
- newBuilder.query.having = condition
407
+ newBuilder.query.having = [condition]
388
408
  } else {
389
- // Create a composite condition with AND
390
- // Use any to bypass type checking issues
391
- const andArray: any = [newBuilder.query.having, `and`, condition]
392
- newBuilder.query.having = andArray
409
+ newBuilder.query.having = [...newBuilder.query.having, condition]
393
410
  }
394
411
 
395
412
  return newBuilder as QueryBuilder<TContext>
@@ -191,6 +191,11 @@ export type Select<TContext extends Context = Context> =
191
191
  | AggregateFunctionCall<TContext>
192
192
  }
193
193
  | WildcardReferenceString<TContext>
194
+ | SelectCallback<TContext>
195
+
196
+ export type SelectCallback<TContext extends Context = Context> = (
197
+ context: TContext extends { schema: infer S } ? S : any
198
+ ) => any
194
199
 
195
200
  export type As<TContext extends Context = Context> = string
196
201
 
@@ -199,14 +204,21 @@ export type From<TContext extends Context = Context> = InputReference<{
199
204
  schema: TContext[`baseSchema`]
200
205
  }>
201
206
 
202
- export type Where<TContext extends Context = Context> = Condition<TContext>
207
+ export type WhereCallback<TContext extends Context = Context> = (
208
+ context: TContext extends { schema: infer S } ? S : any
209
+ ) => boolean
210
+
211
+ export type Where<TContext extends Context = Context> = Array<
212
+ Condition<TContext> | WhereCallback<TContext>
213
+ >
214
+
215
+ // Having is the same implementation as a where clause, its just run after the group by
216
+ export type Having<TContext extends Context = Context> = Where<TContext>
203
217
 
204
218
  export type GroupBy<TContext extends Context = Context> =
205
219
  | PropertyReference<TContext>
206
220
  | Array<PropertyReference<TContext>>
207
221
 
208
- export type Having<TContext extends Context = Context> = Condition<TContext>
209
-
210
222
  export type Limit<TContext extends Context = Context> = number
211
223
 
212
224
  export type Offset<TContext extends Context = Context> = number
@@ -220,9 +232,9 @@ export interface BaseQuery<TContext extends Context = Context> {
220
232
  as?: As<TContext>
221
233
  from: From<TContext>
222
234
  join?: Array<JoinClause<TContext>>
223
- where?: Condition<TContext>
235
+ where?: Where<TContext>
224
236
  groupBy?: GroupBy<TContext>
225
- having?: Condition<TContext>
237
+ having?: Having<TContext>
226
238
  orderBy?: OrderBy<TContext>
227
239
  limit?: Limit<TContext>
228
240
  offset?: Offset<TContext>
@@ -3,7 +3,7 @@ import {
3
3
  evaluateOperandOnNamespacedRow,
4
4
  extractValueFromNamespacedRow,
5
5
  } from "./extractors"
6
- import type { ConditionOperand, Query } from "./schema"
6
+ import type { ConditionOperand, Query, SelectCallback } from "./schema"
7
7
  import type { KeyedStream, NamespacedAndKeyedStream } from "../types"
8
8
 
9
9
  export function processSelect(
@@ -31,6 +31,29 @@ export function processSelect(
31
31
  }
32
32
 
33
33
  for (const item of query.select) {
34
+ // Handle callback functions
35
+ if (typeof item === `function`) {
36
+ const callback = item as SelectCallback
37
+ const callbackResult = callback(namespacedRow)
38
+
39
+ // If the callback returns an object, merge its properties into the result
40
+ if (
41
+ callbackResult &&
42
+ typeof callbackResult === `object` &&
43
+ !Array.isArray(callbackResult)
44
+ ) {
45
+ Object.assign(result, callbackResult)
46
+ } else {
47
+ // If the callback returns a primitive value, we can't merge it
48
+ // This would need a specific key, but since we don't have one, we'll skip it
49
+ // In practice, select callbacks should return objects with keys
50
+ console.warn(
51
+ `SelectCallback returned a non-object value. SelectCallbacks should return objects with key-value pairs.`
52
+ )
53
+ }
54
+ continue
55
+ }
56
+
34
57
  if (typeof item === `string`) {
35
58
  // Handle wildcard select - all columns from all tables
36
59
  if ((item as string) === `@*`) {
@@ -4,6 +4,7 @@ import type {
4
4
  PendingMutation,
5
5
  TransactionConfig,
6
6
  TransactionState,
7
+ TransactionWithMutations,
7
8
  } from "./types"
8
9
 
9
10
  function generateUUID() {
@@ -181,11 +182,17 @@ export class Transaction {
181
182
 
182
183
  if (this.mutations.length === 0) {
183
184
  this.setState(`completed`)
185
+
186
+ return this
184
187
  }
185
188
 
186
189
  // Run mutationFn
187
190
  try {
188
- await this.mutationFn({ transaction: this })
191
+ // At this point we know there's at least one mutation
192
+ // Use type assertion to tell TypeScript about this guarantee
193
+ const transactionWithMutations =
194
+ this as unknown as TransactionWithMutations
195
+ await this.mutationFn({ transaction: transactionWithMutations })
189
196
 
190
197
  this.setState(`completed`)
191
198
  this.touchCollection()
package/src/types.ts CHANGED
@@ -32,6 +32,16 @@ export type MutationFnParams = {
32
32
 
33
33
  export type MutationFn = (params: MutationFnParams) => Promise<any>
34
34
 
35
+ /**
36
+ * Utility type for a Transaction with at least one mutation
37
+ * This is used internally by the Transaction.commit method
38
+ */
39
+ export type TransactionWithMutations<
40
+ T extends object = Record<string, unknown>,
41
+ > = Transaction & {
42
+ mutations: [PendingMutation<T>, ...Array<PendingMutation<T>>]
43
+ }
44
+
35
45
  export interface TransactionConfig {
36
46
  /** Unique identifier for the transaction */
37
47
  id?: string
@@ -130,6 +140,24 @@ export interface CollectionConfig<T extends object = Record<string, unknown>> {
130
140
  * getId: (item) => item.uuid
131
141
  */
132
142
  getId: (item: T) => any
143
+ /**
144
+ * Optional asynchronous handler function called before an insert operation
145
+ * @param params Object containing transaction and mutation information
146
+ * @returns Promise resolving to any value
147
+ */
148
+ onInsert?: MutationFn
149
+ /**
150
+ * Optional asynchronous handler function called before an update operation
151
+ * @param params Object containing transaction and mutation information
152
+ * @returns Promise resolving to any value
153
+ */
154
+ onUpdate?: MutationFn
155
+ /**
156
+ * Optional asynchronous handler function called before a delete operation
157
+ * @param params Object containing transaction and mutation information
158
+ * @returns Promise resolving to any value
159
+ */
160
+ onDelete?: MutationFn
133
161
  }
134
162
 
135
163
  export type ChangesPayload<T extends object = Record<string, unknown>> = Array<