@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
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./errors"
7
7
  export * from "./utils"
8
8
  export * from "./proxy"
9
9
  export * from "./query/index.js"
10
+ export * from "./optimistic-action"
10
11
 
11
12
  // Re-export some stuff explicitly to ensure the type & value is exported
12
13
  export type { Collection } from "./collection"
@@ -0,0 +1,65 @@
1
+ import { createTransaction } from "./transactions"
2
+ import type { CreateOptimisticActionsOptions, Transaction } from "./types"
3
+
4
+ /**
5
+ * Creates an optimistic action function that applies local optimistic updates immediately
6
+ * before executing the actual mutation on the server.
7
+ *
8
+ * This pattern allows for responsive UI updates while the actual mutation is in progress.
9
+ * The optimistic update is applied via the `onMutate` callback, and the server mutation
10
+ * is executed via the `mutationFn`.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const addTodo = createOptimisticAction<string>({
15
+ * onMutate: (text) => {
16
+ * // Instantly applies local optimistic state
17
+ * todoCollection.insert({
18
+ * id: uuid(),
19
+ * text,
20
+ * completed: false
21
+ * })
22
+ * },
23
+ * mutationFn: async (text, params) => {
24
+ * // Persist the todo to your backend
25
+ * const response = await fetch('/api/todos', {
26
+ * method: 'POST',
27
+ * body: JSON.stringify({ text, completed: false }),
28
+ * })
29
+ * return response.json()
30
+ * }
31
+ * })
32
+ *
33
+ * // Usage
34
+ * const transaction = addTodo('New Todo Item')
35
+ * ```
36
+ *
37
+ * @template TVariables - The type of variables that will be passed to the action function
38
+ * @param options - Configuration options for the optimistic action
39
+ * @returns A function that accepts variables of type TVariables and returns a Transaction
40
+ */
41
+ export function createOptimisticAction<TVariables = unknown>(
42
+ options: CreateOptimisticActionsOptions<TVariables>
43
+ ) {
44
+ const { mutationFn, onMutate, ...config } = options
45
+
46
+ return (variables: TVariables): Transaction => {
47
+ // Create transaction with the original config
48
+ const transaction = createTransaction({
49
+ ...config,
50
+ // Wire the mutationFn to use the provided variables
51
+ mutationFn: async (params) => {
52
+ return await mutationFn(variables, params)
53
+ },
54
+ })
55
+
56
+ // Execute the transaction. The mutationFn is called once mutate()
57
+ // is finished.
58
+ transaction.mutate(() => {
59
+ // Call onMutate with variables to apply optimistic updates
60
+ onMutate(variables)
61
+ })
62
+
63
+ return transaction
64
+ }
65
+ }
@@ -1,8 +1,9 @@
1
1
  import { D2, MultiSet, output } from "@electric-sql/d2mini"
2
2
  import { createCollection } from "../collection.js"
3
3
  import { compileQueryPipeline } from "./pipeline-compiler.js"
4
+ import type { StandardSchemaV1 } from "@standard-schema/spec"
4
5
  import type { Collection } from "../collection.js"
5
- import type { ChangeMessage, SyncConfig } from "../types.js"
6
+ import type { ChangeMessage, ResolveType, SyncConfig } from "../types.js"
6
7
  import type {
7
8
  IStreamBuilder,
8
9
  MultiSetArray,
@@ -42,7 +43,13 @@ export class CompiledQuery<TResults extends object = Record<string, unknown>> {
42
43
  Object.entries(collections).map(([key]) => [key, graph.newInput<any>()])
43
44
  )
44
45
 
45
- const sync: SyncConfig<TResults>[`sync`] = ({ begin, write, commit }) => {
46
+ // Use TResults directly to ensure type compatibility
47
+ const sync: SyncConfig<TResults>[`sync`] = ({
48
+ begin,
49
+ write,
50
+ commit,
51
+ collection,
52
+ }) => {
46
53
  compileQueryPipeline<IStreamBuilder<[unknown, TResults]>>(
47
54
  query,
48
55
  inputs
@@ -69,21 +76,35 @@ export class CompiledQuery<TResults extends object = Record<string, unknown>> {
69
76
  .forEach((changes, rawKey) => {
70
77
  const { deletes, inserts, value } = changes
71
78
  const valueWithKey = { ...value, _key: rawKey }
72
- if (inserts && !deletes) {
79
+
80
+ // Simple singular insert.
81
+ if (inserts && deletes === 0) {
73
82
  write({
74
83
  value: valueWithKey,
75
84
  type: `insert`,
76
85
  })
77
- } else if (inserts >= deletes) {
86
+ } else if (
87
+ // Insert & update(s) (updates are a delete & insert)
88
+ inserts > deletes ||
89
+ // Just update(s) but the item is already in the collection (so
90
+ // was inserted previously).
91
+ (inserts === deletes &&
92
+ collection.has(valueWithKey._key as string | number))
93
+ ) {
78
94
  write({
79
95
  value: valueWithKey,
80
96
  type: `update`,
81
97
  })
98
+ // Only delete is left as an option
82
99
  } else if (deletes > 0) {
83
100
  write({
84
101
  value: valueWithKey,
85
102
  type: `delete`,
86
103
  })
104
+ } else {
105
+ throw new Error(
106
+ `This should never happen ${JSON.stringify(changes)}`
107
+ )
87
108
  }
88
109
  })
89
110
  commit()
@@ -94,15 +115,57 @@ export class CompiledQuery<TResults extends object = Record<string, unknown>> {
94
115
 
95
116
  this.graph = graph
96
117
  this.inputs = inputs
118
+
119
+ const compare = query.orderBy
120
+ ? (
121
+ val1: ResolveType<
122
+ TResults,
123
+ StandardSchemaV1,
124
+ Record<string, unknown>
125
+ >,
126
+ val2: ResolveType<TResults, StandardSchemaV1, Record<string, unknown>>
127
+ ): number => {
128
+ // The query builder always adds an _orderByIndex property if the results are ordered
129
+ const x = val1 as TResults & { _orderByIndex: number }
130
+ const y = val2 as TResults & { _orderByIndex: number }
131
+ if (x._orderByIndex < y._orderByIndex) {
132
+ return -1
133
+ } else if (x._orderByIndex > y._orderByIndex) {
134
+ return 1
135
+ } else {
136
+ return 0
137
+ }
138
+ }
139
+ : undefined
140
+
97
141
  this.resultCollection = createCollection<TResults>({
98
- id: crypto.randomUUID(), // TODO: remove when we don't require any more
99
142
  getKey: (val: unknown) => {
100
143
  return (val as any)._key
101
144
  },
145
+ gcTime: 0,
146
+ startSync: true,
147
+ compare,
102
148
  sync: {
103
- sync,
149
+ sync: sync as unknown as (params: {
150
+ collection: Collection<
151
+ ResolveType<TResults, never, Record<string, unknown>>,
152
+ string | number,
153
+ {}
154
+ >
155
+ begin: () => void
156
+ write: (
157
+ message: Omit<
158
+ ChangeMessage<
159
+ ResolveType<TResults, never, Record<string, unknown>>,
160
+ string | number
161
+ >,
162
+ `key`
163
+ >
164
+ ) => void
165
+ commit: () => void
166
+ }) => void,
104
167
  },
105
- })
168
+ }) as unknown as Collection<TResults, string | number, {}>
106
169
  }
107
170
 
108
171
  get results() {
@@ -142,26 +205,21 @@ export class CompiledQuery<TResults extends object = Record<string, unknown>> {
142
205
  throw new Error(`Query is stopped`)
143
206
  }
144
207
 
145
- // Send initial state
146
- Object.entries(this.inputCollections).forEach(([key, collection]) => {
147
- this.sendChangesToInput(
148
- key,
149
- collection.currentStateAsChanges(),
150
- collection.config.getKey
151
- )
152
- })
153
- this.runGraph()
154
-
155
208
  // Subscribe to changes
156
209
  Object.entries(this.inputCollections).forEach(([key, collection]) => {
157
- const unsubscribe = collection.subscribeChanges((changes) => {
158
- this.sendChangesToInput(key, changes, collection.config.getKey)
159
- this.runGraph()
160
- })
210
+ const unsubscribe = collection.subscribeChanges(
211
+ (changes) => {
212
+ this.sendChangesToInput(key, changes, collection.config.getKey)
213
+ this.runGraph()
214
+ },
215
+ { includeInitialState: true }
216
+ )
161
217
 
162
218
  this.unsubscribeCallbacks.push(unsubscribe)
163
219
  })
164
220
 
221
+ this.runGraph()
222
+
165
223
  this.state = `running`
166
224
  return () => {
167
225
  this.stop()
@@ -268,7 +268,7 @@ export class BaseQueryBuilder<TContext extends Context<Schema>> {
268
268
  // Ensure we have an orderByIndex in the select if we have an orderBy
269
269
  // This is required if select is called after orderBy
270
270
  if (this._query.orderBy) {
271
- validatedSelects.push({ _orderByIndex: { ORDER_INDEX: `numeric` } })
271
+ validatedSelects.push({ _orderByIndex: { ORDER_INDEX: `fractional` } })
272
272
  }
273
273
 
274
274
  const newBuilder = new BaseQueryBuilder<TContext>(
@@ -735,7 +735,7 @@ export class BaseQueryBuilder<TContext extends Context<Schema>> {
735
735
  // This is required if select is called before orderBy
736
736
  newBuilder.query.select = [
737
737
  ...(newBuilder.query.select ?? []),
738
- { _orderByIndex: { ORDER_INDEX: `numeric` } },
738
+ { _orderByIndex: { ORDER_INDEX: `fractional` } },
739
739
  ]
740
740
 
741
741
  return newBuilder as QueryBuilder<TContext>
@@ -156,7 +156,12 @@ export class Transaction<T extends object = Record<string, unknown>> {
156
156
  for (const mutation of this.mutations) {
157
157
  if (!hasCalled.has(mutation.collection.id)) {
158
158
  mutation.collection.onTransactionStateChange()
159
- mutation.collection.commitPendingTransactions()
159
+
160
+ // Only call commitPendingTransactions if there are pending sync transactions
161
+ if (mutation.collection.pendingSyncedTransactions.length > 0) {
162
+ mutation.collection.commitPendingTransactions()
163
+ }
164
+
160
165
  hasCalled.add(mutation.collection.id)
161
166
  }
162
167
  }
package/src/types.ts CHANGED
@@ -3,6 +3,39 @@ import type { Collection } from "./collection"
3
3
  import type { StandardSchemaV1 } from "@standard-schema/spec"
4
4
  import type { Transaction } from "./transactions"
5
5
 
6
+ /**
7
+ * Helper type to extract the output type from a standard schema
8
+ *
9
+ * @internal This is used by the type resolution system
10
+ */
11
+ export type InferSchemaOutput<T> = T extends StandardSchemaV1
12
+ ? StandardSchemaV1.InferOutput<T> extends object
13
+ ? StandardSchemaV1.InferOutput<T>
14
+ : Record<string, unknown>
15
+ : Record<string, unknown>
16
+
17
+ /**
18
+ * Helper type to determine the final type based on priority:
19
+ * 1. Explicit generic TExplicit (if not 'unknown')
20
+ * 2. Schema output type (if schema provided)
21
+ * 3. Fallback type TFallback
22
+ *
23
+ * @remarks
24
+ * This type is used internally to resolve the collection item type based on the provided generics and schema.
25
+ * Users should not need to use this type directly, but understanding the priority order helps when defining collections.
26
+ */
27
+ export type ResolveType<
28
+ TExplicit,
29
+ TSchema extends StandardSchemaV1 = never,
30
+ TFallback extends object = Record<string, unknown>,
31
+ > = unknown extends TExplicit
32
+ ? [TSchema] extends [never]
33
+ ? TFallback
34
+ : InferSchemaOutput<TSchema>
35
+ : TExplicit extends object
36
+ ? TExplicit
37
+ : Record<string, unknown>
38
+
6
39
  export type TransactionState = `pending` | `persisting` | `completed` | `failed`
7
40
 
8
41
  /**
@@ -19,11 +52,18 @@ export type UtilsRecord = Record<string, Fn>
19
52
  * Represents a pending mutation within a transaction
20
53
  * Contains information about the original and modified data, as well as metadata
21
54
  */
22
- export interface PendingMutation<T extends object = Record<string, unknown>> {
55
+ export interface PendingMutation<
56
+ T extends object = Record<string, unknown>,
57
+ TOperation extends OperationType = OperationType,
58
+ > {
23
59
  mutationId: string
24
- original: Partial<T>
60
+ original: TOperation extends `insert` ? {} : T
25
61
  modified: T
26
- changes: Partial<T>
62
+ changes: TOperation extends `insert`
63
+ ? T
64
+ : TOperation extends `delete`
65
+ ? T
66
+ : Partial<T>
27
67
  globalKey: string
28
68
  key: any
29
69
  type: OperationType
@@ -56,8 +96,9 @@ export type NonEmptyArray<T> = [T, ...Array<T>]
56
96
  */
57
97
  export type TransactionWithMutations<
58
98
  T extends object = Record<string, unknown>,
99
+ TOperation extends OperationType = OperationType,
59
100
  > = Transaction<T> & {
60
- mutations: NonEmptyArray<PendingMutation<T>>
101
+ mutations: NonEmptyArray<PendingMutation<T, TOperation>>
61
102
  }
62
103
 
63
104
  export interface TransactionConfig<T extends object = Record<string, unknown>> {
@@ -70,6 +111,17 @@ export interface TransactionConfig<T extends object = Record<string, unknown>> {
70
111
  metadata?: Record<string, unknown>
71
112
  }
72
113
 
114
+ /**
115
+ * Options for the createOptimisticAction helper
116
+ */
117
+ export interface CreateOptimisticActionsOptions<TVars = unknown>
118
+ extends Omit<TransactionConfig, `mutationFn`> {
119
+ /** Function to apply optimistic updates locally before the mutation completes */
120
+ onMutate: (vars: TVars) => void
121
+ /** Function to execute the mutation on the server */
122
+ mutationFn: (vars: TVars, params: MutationFnParams) => Promise<any>
123
+ }
124
+
73
125
  export type { Transaction }
74
126
 
75
127
  type Value<TExtensions = never> =
@@ -148,15 +200,58 @@ export interface InsertConfig {
148
200
  metadata?: Record<string, unknown>
149
201
  }
150
202
 
203
+ export type UpdateMutationFnParams<T extends object = Record<string, unknown>> =
204
+ {
205
+ transaction: TransactionWithMutations<T, `update`>
206
+ }
207
+
208
+ export type InsertMutationFnParams<T extends object = Record<string, unknown>> =
209
+ {
210
+ transaction: TransactionWithMutations<T, `insert`>
211
+ }
212
+
213
+ export type DeleteMutationFnParams<T extends object = Record<string, unknown>> =
214
+ {
215
+ transaction: TransactionWithMutations<T, `delete`>
216
+ }
217
+
218
+ export type InsertMutationFn<T extends object = Record<string, unknown>> = (
219
+ params: InsertMutationFnParams<T>
220
+ ) => Promise<any>
221
+
222
+ export type UpdateMutationFn<T extends object = Record<string, unknown>> = (
223
+ params: UpdateMutationFnParams<T>
224
+ ) => Promise<any>
225
+
226
+ export type DeleteMutationFn<T extends object = Record<string, unknown>> = (
227
+ params: DeleteMutationFnParams<T>
228
+ ) => Promise<any>
229
+
230
+ /**
231
+ * Collection status values for lifecycle management
232
+ */
233
+ export type CollectionStatus =
234
+ /** Collection is created but sync hasn't started yet (when startSync config is false) */
235
+ | `idle`
236
+ /** Sync has started but hasn't received the first commit yet */
237
+ | `loading`
238
+ /** Collection has received at least one commit and is ready for use */
239
+ | `ready`
240
+ /** An error occurred during sync initialization */
241
+ | `error`
242
+ /** Collection has been cleaned up and resources freed */
243
+ | `cleaned-up`
244
+
151
245
  export interface CollectionConfig<
152
246
  T extends object = Record<string, unknown>,
153
247
  TKey extends string | number = string | number,
248
+ TSchema extends StandardSchemaV1 = StandardSchemaV1,
154
249
  > {
155
250
  // If an id isn't passed in, a UUID will be
156
251
  // generated for it.
157
252
  id?: string
158
253
  sync: SyncConfig<T, TKey>
159
- schema?: StandardSchema<T>
254
+ schema?: TSchema
160
255
  /**
161
256
  * Function to extract the ID from an object
162
257
  * This is required for update/delete operations which now only accept IDs
@@ -167,24 +262,45 @@ export interface CollectionConfig<
167
262
  * getKey: (item) => item.uuid
168
263
  */
169
264
  getKey: (item: T) => TKey
265
+ /**
266
+ * Time in milliseconds after which the collection will be garbage collected
267
+ * when it has no active subscribers. Defaults to 5 minutes (300000ms).
268
+ */
269
+ gcTime?: number
270
+ /**
271
+ * Whether to start syncing immediately when the collection is created.
272
+ * Defaults to false for lazy loading. Set to true to immediately sync.
273
+ */
274
+ startSync?: boolean
275
+ /**
276
+ * Optional function to compare two items.
277
+ * This is used to order the items in the collection.
278
+ * @param x The first item to compare
279
+ * @param y The second item to compare
280
+ * @returns A number indicating the order of the items
281
+ * @example
282
+ * // For a collection with a 'createdAt' field
283
+ * compare: (x, y) => x.createdAt.getTime() - y.createdAt.getTime()
284
+ */
285
+ compare?: (x: T, y: T) => number
170
286
  /**
171
287
  * Optional asynchronous handler function called before an insert operation
172
288
  * @param params Object containing transaction and mutation information
173
289
  * @returns Promise resolving to any value
174
290
  */
175
- onInsert?: MutationFn<T>
291
+ onInsert?: InsertMutationFn<T>
176
292
  /**
177
293
  * Optional asynchronous handler function called before an update operation
178
294
  * @param params Object containing transaction and mutation information
179
295
  * @returns Promise resolving to any value
180
296
  */
181
- onUpdate?: MutationFn<T>
297
+ onUpdate?: UpdateMutationFn<T>
182
298
  /**
183
299
  * Optional asynchronous handler function called before a delete operation
184
300
  * @param params Object containing transaction and mutation information
185
301
  * @returns Promise resolving to any value
186
302
  */
187
- onDelete?: MutationFn<T>
303
+ onDelete?: DeleteMutationFn<T>
188
304
  }
189
305
 
190
306
  export type ChangesPayload<T extends object = Record<string, unknown>> = Array<