@tanstack/db 0.0.1

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 (154) hide show
  1. package/README.md +37 -0
  2. package/dist/cjs/SortedMap.cjs +140 -0
  3. package/dist/cjs/SortedMap.cjs.map +1 -0
  4. package/dist/cjs/SortedMap.d.cts +91 -0
  5. package/dist/cjs/collection.cjs +597 -0
  6. package/dist/cjs/collection.cjs.map +1 -0
  7. package/dist/cjs/collection.d.cts +176 -0
  8. package/dist/cjs/deferred.cjs +25 -0
  9. package/dist/cjs/deferred.cjs.map +1 -0
  10. package/dist/cjs/deferred.d.cts +20 -0
  11. package/dist/cjs/errors.cjs +10 -0
  12. package/dist/cjs/errors.cjs.map +1 -0
  13. package/dist/cjs/errors.d.cts +3 -0
  14. package/dist/cjs/index.cjs +33 -0
  15. package/dist/cjs/index.cjs.map +1 -0
  16. package/dist/cjs/index.d.cts +9 -0
  17. package/dist/cjs/proxy.cjs +654 -0
  18. package/dist/cjs/proxy.cjs.map +1 -0
  19. package/dist/cjs/proxy.d.cts +59 -0
  20. package/dist/cjs/query/compiled-query.cjs +162 -0
  21. package/dist/cjs/query/compiled-query.cjs.map +1 -0
  22. package/dist/cjs/query/compiled-query.d.cts +22 -0
  23. package/dist/cjs/query/evaluators.cjs +146 -0
  24. package/dist/cjs/query/evaluators.cjs.map +1 -0
  25. package/dist/cjs/query/evaluators.d.cts +9 -0
  26. package/dist/cjs/query/extractors.cjs +122 -0
  27. package/dist/cjs/query/extractors.cjs.map +1 -0
  28. package/dist/cjs/query/extractors.d.cts +22 -0
  29. package/dist/cjs/query/functions.cjs +152 -0
  30. package/dist/cjs/query/functions.cjs.map +1 -0
  31. package/dist/cjs/query/functions.d.cts +21 -0
  32. package/dist/cjs/query/group-by.cjs +91 -0
  33. package/dist/cjs/query/group-by.cjs.map +1 -0
  34. package/dist/cjs/query/group-by.d.cts +40 -0
  35. package/dist/cjs/query/index.d.cts +5 -0
  36. package/dist/cjs/query/joins.cjs +155 -0
  37. package/dist/cjs/query/joins.cjs.map +1 -0
  38. package/dist/cjs/query/joins.d.cts +14 -0
  39. package/dist/cjs/query/key-by.cjs +43 -0
  40. package/dist/cjs/query/key-by.cjs.map +1 -0
  41. package/dist/cjs/query/key-by.d.cts +3 -0
  42. package/dist/cjs/query/order-by.cjs +229 -0
  43. package/dist/cjs/query/order-by.cjs.map +1 -0
  44. package/dist/cjs/query/order-by.d.cts +3 -0
  45. package/dist/cjs/query/pipeline-compiler.cjs +94 -0
  46. package/dist/cjs/query/pipeline-compiler.cjs.map +1 -0
  47. package/dist/cjs/query/pipeline-compiler.d.cts +9 -0
  48. package/dist/cjs/query/query-builder.cjs +314 -0
  49. package/dist/cjs/query/query-builder.cjs.map +1 -0
  50. package/dist/cjs/query/query-builder.d.cts +219 -0
  51. package/dist/cjs/query/schema.d.cts +98 -0
  52. package/dist/cjs/query/select.cjs +107 -0
  53. package/dist/cjs/query/select.cjs.map +1 -0
  54. package/dist/cjs/query/select.d.cts +3 -0
  55. package/dist/cjs/query/types.d.cts +188 -0
  56. package/dist/cjs/query/utils.cjs +154 -0
  57. package/dist/cjs/query/utils.cjs.map +1 -0
  58. package/dist/cjs/query/utils.d.cts +37 -0
  59. package/dist/cjs/transactions.cjs +137 -0
  60. package/dist/cjs/transactions.cjs.map +1 -0
  61. package/dist/cjs/transactions.d.cts +27 -0
  62. package/dist/cjs/types.d.cts +94 -0
  63. package/dist/cjs/utils.cjs +17 -0
  64. package/dist/cjs/utils.cjs.map +1 -0
  65. package/dist/cjs/utils.d.cts +3 -0
  66. package/dist/esm/SortedMap.d.ts +91 -0
  67. package/dist/esm/SortedMap.js +140 -0
  68. package/dist/esm/SortedMap.js.map +1 -0
  69. package/dist/esm/collection.d.ts +176 -0
  70. package/dist/esm/collection.js +597 -0
  71. package/dist/esm/collection.js.map +1 -0
  72. package/dist/esm/deferred.d.ts +20 -0
  73. package/dist/esm/deferred.js +25 -0
  74. package/dist/esm/deferred.js.map +1 -0
  75. package/dist/esm/errors.d.ts +3 -0
  76. package/dist/esm/errors.js +10 -0
  77. package/dist/esm/errors.js.map +1 -0
  78. package/dist/esm/index.d.ts +9 -0
  79. package/dist/esm/index.js +33 -0
  80. package/dist/esm/index.js.map +1 -0
  81. package/dist/esm/proxy.d.ts +59 -0
  82. package/dist/esm/proxy.js +654 -0
  83. package/dist/esm/proxy.js.map +1 -0
  84. package/dist/esm/query/compiled-query.d.ts +22 -0
  85. package/dist/esm/query/compiled-query.js +162 -0
  86. package/dist/esm/query/compiled-query.js.map +1 -0
  87. package/dist/esm/query/evaluators.d.ts +9 -0
  88. package/dist/esm/query/evaluators.js +146 -0
  89. package/dist/esm/query/evaluators.js.map +1 -0
  90. package/dist/esm/query/extractors.d.ts +22 -0
  91. package/dist/esm/query/extractors.js +122 -0
  92. package/dist/esm/query/extractors.js.map +1 -0
  93. package/dist/esm/query/functions.d.ts +21 -0
  94. package/dist/esm/query/functions.js +152 -0
  95. package/dist/esm/query/functions.js.map +1 -0
  96. package/dist/esm/query/group-by.d.ts +40 -0
  97. package/dist/esm/query/group-by.js +91 -0
  98. package/dist/esm/query/group-by.js.map +1 -0
  99. package/dist/esm/query/index.d.ts +5 -0
  100. package/dist/esm/query/joins.d.ts +14 -0
  101. package/dist/esm/query/joins.js +155 -0
  102. package/dist/esm/query/joins.js.map +1 -0
  103. package/dist/esm/query/key-by.d.ts +3 -0
  104. package/dist/esm/query/key-by.js +43 -0
  105. package/dist/esm/query/key-by.js.map +1 -0
  106. package/dist/esm/query/order-by.d.ts +3 -0
  107. package/dist/esm/query/order-by.js +229 -0
  108. package/dist/esm/query/order-by.js.map +1 -0
  109. package/dist/esm/query/pipeline-compiler.d.ts +9 -0
  110. package/dist/esm/query/pipeline-compiler.js +94 -0
  111. package/dist/esm/query/pipeline-compiler.js.map +1 -0
  112. package/dist/esm/query/query-builder.d.ts +219 -0
  113. package/dist/esm/query/query-builder.js +314 -0
  114. package/dist/esm/query/query-builder.js.map +1 -0
  115. package/dist/esm/query/schema.d.ts +98 -0
  116. package/dist/esm/query/select.d.ts +3 -0
  117. package/dist/esm/query/select.js +107 -0
  118. package/dist/esm/query/select.js.map +1 -0
  119. package/dist/esm/query/types.d.ts +188 -0
  120. package/dist/esm/query/utils.d.ts +37 -0
  121. package/dist/esm/query/utils.js +154 -0
  122. package/dist/esm/query/utils.js.map +1 -0
  123. package/dist/esm/transactions.d.ts +27 -0
  124. package/dist/esm/transactions.js +137 -0
  125. package/dist/esm/transactions.js.map +1 -0
  126. package/dist/esm/types.d.ts +94 -0
  127. package/dist/esm/utils.d.ts +3 -0
  128. package/dist/esm/utils.js +17 -0
  129. package/dist/esm/utils.js.map +1 -0
  130. package/package.json +57 -0
  131. package/src/SortedMap.ts +163 -0
  132. package/src/collection.ts +919 -0
  133. package/src/deferred.ts +47 -0
  134. package/src/errors.ts +6 -0
  135. package/src/index.ts +12 -0
  136. package/src/proxy.ts +1104 -0
  137. package/src/query/compiled-query.ts +193 -0
  138. package/src/query/evaluators.ts +222 -0
  139. package/src/query/extractors.ts +211 -0
  140. package/src/query/functions.ts +297 -0
  141. package/src/query/group-by.ts +137 -0
  142. package/src/query/index.ts +5 -0
  143. package/src/query/joins.ts +247 -0
  144. package/src/query/key-by.ts +61 -0
  145. package/src/query/order-by.ts +312 -0
  146. package/src/query/pipeline-compiler.ts +152 -0
  147. package/src/query/query-builder.ts +898 -0
  148. package/src/query/schema.ts +255 -0
  149. package/src/query/select.ts +173 -0
  150. package/src/query/types.ts +417 -0
  151. package/src/query/utils.ts +245 -0
  152. package/src/transactions.ts +198 -0
  153. package/src/types.ts +125 -0
  154. package/src/utils.ts +15 -0
@@ -0,0 +1,919 @@
1
+ import { Derived, Store, batch } from "@tanstack/store"
2
+ import { withArrayChangeTracking, withChangeTracking } from "./proxy"
3
+ import { getActiveTransaction } from "./transactions"
4
+ import { SortedMap } from "./SortedMap"
5
+ import type {
6
+ ChangeMessage,
7
+ CollectionConfig,
8
+ InsertConfig,
9
+ OperationConfig,
10
+ OptimisticChangeMessage,
11
+ PendingMutation,
12
+ StandardSchema,
13
+ Transaction,
14
+ } from "./types"
15
+
16
+ // Store collections in memory using Tanstack store
17
+ export const collectionsStore = new Store(new Map<string, Collection<any>>())
18
+
19
+ // Map to track loading collections
20
+
21
+ const loadingCollections = new Map<
22
+ string,
23
+ Promise<Collection<Record<string, unknown>>>
24
+ >()
25
+
26
+ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
27
+ committed: boolean
28
+ operations: Array<OptimisticChangeMessage<T>>
29
+ }
30
+
31
+ /**
32
+ * Preloads a collection with the given configuration
33
+ * Returns a promise that resolves once the sync tool has done its first commit (initial sync is finished)
34
+ * If the collection has already loaded, it resolves immediately
35
+ *
36
+ * This function is useful in route loaders or similar pre-rendering scenarios where you want
37
+ * to ensure data is available before a route transition completes. It uses the same shared collection
38
+ * instance that will be used by useCollection, ensuring data consistency.
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * // In a route loader
43
+ * async function loader({ params }) {
44
+ * await preloadCollection({
45
+ * id: `users-${params.userId}`,
46
+ * sync: { ... },
47
+ * });
48
+ *
49
+ * return null;
50
+ * }
51
+ * ```
52
+ *
53
+ * @template T - The type of items in the collection
54
+ * @param config - Configuration for the collection, including id and sync
55
+ * @returns Promise that resolves when the initial sync is finished
56
+ */
57
+ export function preloadCollection<T extends object = Record<string, unknown>>(
58
+ config: CollectionConfig<T>
59
+ ): Promise<Collection<T>> {
60
+ // If the collection is already fully loaded, return a resolved promise
61
+ if (
62
+ collectionsStore.state.has(config.id) &&
63
+ !loadingCollections.has(config.id)
64
+ ) {
65
+ return Promise.resolve(
66
+ collectionsStore.state.get(config.id)! as Collection<T>
67
+ )
68
+ }
69
+
70
+ // If the collection is in the process of loading, return its promise
71
+ if (loadingCollections.has(config.id)) {
72
+ return loadingCollections.get(config.id)! as Promise<Collection<T>>
73
+ }
74
+
75
+ // Create a new collection instance if it doesn't exist
76
+ if (!collectionsStore.state.has(config.id)) {
77
+ collectionsStore.setState((prev) => {
78
+ const next = new Map(prev)
79
+ next.set(
80
+ config.id,
81
+ new Collection<T>({
82
+ id: config.id,
83
+ sync: config.sync,
84
+ schema: config.schema,
85
+ })
86
+ )
87
+ return next
88
+ })
89
+ }
90
+
91
+ const collection = collectionsStore.state.get(config.id)! as Collection<T>
92
+
93
+ // Create a promise that will resolve after the first commit
94
+ let resolveFirstCommit: () => void
95
+ const firstCommitPromise = new Promise<Collection<T>>((resolve) => {
96
+ resolveFirstCommit = () => {
97
+ resolve(collection)
98
+ }
99
+ })
100
+
101
+ // Register a one-time listener for the first commit
102
+ collection.onFirstCommit(() => {
103
+ if (loadingCollections.has(config.id)) {
104
+ loadingCollections.delete(config.id)
105
+ resolveFirstCommit()
106
+ }
107
+ })
108
+
109
+ // Store the loading promise
110
+ loadingCollections.set(
111
+ config.id,
112
+ firstCommitPromise as Promise<Collection<Record<string, unknown>>>
113
+ )
114
+
115
+ return firstCommitPromise
116
+ }
117
+
118
+ /**
119
+ * Custom error class for schema validation errors
120
+ */
121
+ export class SchemaValidationError extends Error {
122
+ type: `insert` | `update`
123
+ issues: ReadonlyArray<{
124
+ message: string
125
+ path?: ReadonlyArray<string | number | symbol>
126
+ }>
127
+
128
+ constructor(
129
+ type: `insert` | `update`,
130
+ issues: ReadonlyArray<{
131
+ message: string
132
+ path?: ReadonlyArray<string | number | symbol>
133
+ }>,
134
+ message?: string
135
+ ) {
136
+ const defaultMessage = `${type === `insert` ? `Insert` : `Update`} validation failed: ${issues
137
+ .map((issue) => issue.message)
138
+ .join(`, `)}`
139
+
140
+ super(message || defaultMessage)
141
+ this.name = `SchemaValidationError`
142
+ this.type = type
143
+ this.issues = issues
144
+ }
145
+ }
146
+
147
+ export class Collection<T extends object = Record<string, unknown>> {
148
+ public transactions: Store<SortedMap<string, Transaction>>
149
+ public optimisticOperations: Derived<Array<OptimisticChangeMessage<T>>>
150
+ public derivedState: Derived<Map<string, T>>
151
+ public derivedArray: Derived<Array<T>>
152
+ public derivedChanges: Derived<Array<ChangeMessage<T>>>
153
+ public syncedData = new Store<Map<string, T>>(new Map())
154
+ public syncedMetadata = new Store(new Map<string, unknown>())
155
+ private pendingSyncedTransactions: Array<PendingSyncedTransaction<T>> = []
156
+ private syncedKeys = new Set<string>()
157
+ public config: CollectionConfig<T>
158
+ private hasReceivedFirstCommit = false
159
+
160
+ // WeakMap to associate objects with their keys
161
+ public objectKeyMap = new WeakMap<object, string>()
162
+
163
+ // Array to store one-time commit listeners
164
+ private onFirstCommitCallbacks: Array<() => void> = []
165
+
166
+ /**
167
+ * Register a callback to be executed on the next commit
168
+ * Useful for preloading collections
169
+ * @param callback Function to call after the next commit
170
+ */
171
+ public onFirstCommit(callback: () => void): void {
172
+ this.onFirstCommitCallbacks.push(callback)
173
+ }
174
+
175
+ public id = crypto.randomUUID()
176
+
177
+ /**
178
+ * Creates a new Collection instance
179
+ *
180
+ * @param config - Configuration object for the collection
181
+ * @throws Error if sync config is missing
182
+ */
183
+ constructor(config?: CollectionConfig<T>) {
184
+ if (!config?.sync) {
185
+ throw new Error(`Collection requires a sync config`)
186
+ }
187
+
188
+ this.transactions = new Store(
189
+ new SortedMap<string, Transaction>(
190
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
191
+ )
192
+ )
193
+
194
+ // Copies of live mutations are stored here and removed once the transaction completes.
195
+ this.optimisticOperations = new Derived({
196
+ fn: ({ currDepVals: [transactions] }) => {
197
+ const result = Array.from(transactions.values())
198
+ .map((transaction) => {
199
+ const isActive = ![`completed`, `failed`].includes(
200
+ transaction.state
201
+ )
202
+ return transaction.mutations.map((mutation) => {
203
+ const message: OptimisticChangeMessage<T> = {
204
+ type: mutation.type,
205
+ key: mutation.key,
206
+ value: mutation.modified as T,
207
+ isActive,
208
+ }
209
+ if (
210
+ mutation.metadata !== undefined &&
211
+ mutation.metadata !== null
212
+ ) {
213
+ message.metadata = mutation.metadata as Record<string, unknown>
214
+ }
215
+ return message
216
+ })
217
+ })
218
+ .flat()
219
+
220
+ return result
221
+ },
222
+ deps: [this.transactions],
223
+ })
224
+ this.optimisticOperations.mount()
225
+
226
+ // Combine together synced data & optimistic operations.
227
+ this.derivedState = new Derived({
228
+ fn: ({ currDepVals: [syncedData, operations] }) => {
229
+ const combined = new Map<string, T>(syncedData)
230
+ const optimisticKeys = new Set<string>()
231
+
232
+ // Apply the optimistic operations on top of the synced state.
233
+ for (const operation of operations) {
234
+ optimisticKeys.add(operation.key)
235
+ if (operation.isActive) {
236
+ switch (operation.type) {
237
+ case `insert`:
238
+ combined.set(operation.key, operation.value)
239
+ break
240
+ case `update`:
241
+ combined.set(operation.key, operation.value)
242
+ break
243
+ case `delete`:
244
+ combined.delete(operation.key)
245
+ break
246
+ }
247
+ }
248
+ }
249
+
250
+ // Update object => key mappings
251
+ optimisticKeys.forEach((key) => {
252
+ if (combined.has(key)) {
253
+ this.objectKeyMap.set(combined.get(key)!, key)
254
+ }
255
+ })
256
+
257
+ return combined
258
+ },
259
+ deps: [this.syncedData, this.optimisticOperations],
260
+ })
261
+
262
+ // Create a derived array from the map to avoid recalculating it
263
+ this.derivedArray = new Derived({
264
+ fn: ({ currDepVals: [stateMap] }) => {
265
+ // Collections returned by a query that has an orderBy are annotated
266
+ // with the _orderByIndex field.
267
+ // This is used to sort the array when it's derived.
268
+ const array: Array<T & { _orderByIndex?: number }> = Array.from(
269
+ stateMap.values()
270
+ )
271
+ if (array[0] && `_orderByIndex` in array[0]) {
272
+ ;(array as Array<T & { _orderByIndex: number }>).sort((a, b) => {
273
+ if (a._orderByIndex === b._orderByIndex) {
274
+ return 0
275
+ }
276
+ return a._orderByIndex < b._orderByIndex ? -1 : 1
277
+ })
278
+ }
279
+ return array
280
+ },
281
+ deps: [this.derivedState],
282
+ })
283
+ this.derivedArray.mount()
284
+
285
+ this.derivedChanges = new Derived({
286
+ fn: ({
287
+ currDepVals: [derivedState, optimisticOperations],
288
+ prevDepVals,
289
+ }) => {
290
+ const prevDerivedState = prevDepVals?.[0] ?? new Map<string, T>()
291
+ const changedKeys = new Set(this.syncedKeys)
292
+ optimisticOperations
293
+ .flat()
294
+ .filter((op) => op.isActive)
295
+ .forEach((op) => changedKeys.add(op.key))
296
+
297
+ if (changedKeys.size === 0) {
298
+ return []
299
+ }
300
+
301
+ const changes: Array<ChangeMessage<T>> = []
302
+ for (const key of changedKeys) {
303
+ if (prevDerivedState.has(key) && !derivedState.has(key)) {
304
+ changes.push({
305
+ type: `delete`,
306
+ key,
307
+ value: prevDerivedState.get(key)!,
308
+ })
309
+ } else if (!prevDerivedState.has(key) && derivedState.has(key)) {
310
+ changes.push({ type: `insert`, key, value: derivedState.get(key)! })
311
+ } else if (prevDerivedState.has(key) && derivedState.has(key)) {
312
+ changes.push({
313
+ type: `update`,
314
+ key,
315
+ value: derivedState.get(key)!,
316
+ previousValue: prevDerivedState.get(key),
317
+ })
318
+ }
319
+ }
320
+
321
+ this.syncedKeys.clear()
322
+
323
+ return changes
324
+ },
325
+ deps: [this.derivedState, this.optimisticOperations],
326
+ })
327
+ this.derivedChanges.mount()
328
+
329
+ this.config = config
330
+
331
+ this.derivedState.mount()
332
+
333
+ // Start the sync process
334
+ config.sync.sync({
335
+ collection: this,
336
+ begin: () => {
337
+ this.pendingSyncedTransactions.push({
338
+ committed: false,
339
+ operations: [],
340
+ })
341
+ },
342
+ write: (message: ChangeMessage<T>) => {
343
+ const pendingTransaction =
344
+ this.pendingSyncedTransactions[
345
+ this.pendingSyncedTransactions.length - 1
346
+ ]
347
+ if (!pendingTransaction) {
348
+ throw new Error(`No pending sync transaction to write to`)
349
+ }
350
+ if (pendingTransaction.committed) {
351
+ throw new Error(
352
+ `The pending sync transaction is already committed, you can't still write to it.`
353
+ )
354
+ }
355
+ pendingTransaction.operations.push(message)
356
+ },
357
+ commit: () => {
358
+ const pendingTransaction =
359
+ this.pendingSyncedTransactions[
360
+ this.pendingSyncedTransactions.length - 1
361
+ ]
362
+ if (!pendingTransaction) {
363
+ throw new Error(`No pending sync transaction to commit`)
364
+ }
365
+ if (pendingTransaction.committed) {
366
+ throw new Error(
367
+ `The pending sync transaction is already committed, you can't commit it again.`
368
+ )
369
+ }
370
+
371
+ pendingTransaction.committed = true
372
+
373
+ this.commitPendingTransactions()
374
+ },
375
+ })
376
+ }
377
+
378
+ /**
379
+ * Attempts to commit pending synced transactions if there are no active transactions
380
+ * This method processes operations from pending transactions and applies them to the synced data
381
+ */
382
+ commitPendingTransactions = () => {
383
+ if (
384
+ !Array.from(this.transactions.state.values()).some(
385
+ ({ state }) => state === `persisting`
386
+ )
387
+ ) {
388
+ const keys = new Set<string>()
389
+ batch(() => {
390
+ for (const transaction of this.pendingSyncedTransactions) {
391
+ for (const operation of transaction.operations) {
392
+ keys.add(operation.key)
393
+ this.syncedKeys.add(operation.key)
394
+ this.syncedMetadata.setState((prevData) => {
395
+ switch (operation.type) {
396
+ case `insert`:
397
+ prevData.set(operation.key, operation.metadata)
398
+ break
399
+ case `update`:
400
+ prevData.set(operation.key, {
401
+ ...prevData.get(operation.key)!,
402
+ ...operation.metadata,
403
+ })
404
+ break
405
+ case `delete`:
406
+ prevData.delete(operation.key)
407
+ break
408
+ }
409
+ return prevData
410
+ })
411
+ this.syncedData.setState((prevData) => {
412
+ switch (operation.type) {
413
+ case `insert`:
414
+ prevData.set(operation.key, operation.value)
415
+ break
416
+ case `update`:
417
+ prevData.set(operation.key, {
418
+ ...prevData.get(operation.key)!,
419
+ ...operation.value,
420
+ })
421
+ break
422
+ case `delete`:
423
+ prevData.delete(operation.key)
424
+ break
425
+ }
426
+ return prevData
427
+ })
428
+ }
429
+ }
430
+ })
431
+
432
+ keys.forEach((key) => {
433
+ const curValue = this.state.get(key)
434
+ if (curValue) {
435
+ this.objectKeyMap.set(curValue, key)
436
+ }
437
+ })
438
+
439
+ this.pendingSyncedTransactions = []
440
+
441
+ // Call any registered one-time commit listeners
442
+ if (!this.hasReceivedFirstCommit) {
443
+ this.hasReceivedFirstCommit = true
444
+ const callbacks = [...this.onFirstCommitCallbacks]
445
+ this.onFirstCommitCallbacks = []
446
+ callbacks.forEach((callback) => callback())
447
+ }
448
+ }
449
+ }
450
+
451
+ private ensureStandardSchema(schema: unknown): StandardSchema<T> {
452
+ // If the schema already implements the standard-schema interface, return it
453
+ if (schema && typeof schema === `object` && `~standard` in schema) {
454
+ return schema as StandardSchema<T>
455
+ }
456
+
457
+ throw new Error(
458
+ `Schema must either implement the standard-schema interface or be a Zod schema`
459
+ )
460
+ }
461
+
462
+ private validateData(
463
+ data: unknown,
464
+ type: `insert` | `update`,
465
+ key?: string
466
+ ): T | never {
467
+ if (!this.config.schema) return data as T
468
+
469
+ const standardSchema = this.ensureStandardSchema(this.config.schema)
470
+
471
+ // For updates, we need to merge with the existing data before validation
472
+ if (type === `update` && key) {
473
+ // Get the existing data for this key
474
+ const existingData = this.state.get(key)
475
+
476
+ if (
477
+ existingData &&
478
+ data &&
479
+ typeof data === `object` &&
480
+ typeof existingData === `object`
481
+ ) {
482
+ // Merge the update with the existing data
483
+ const mergedData = { ...existingData, ...data }
484
+
485
+ // Validate the merged data
486
+ const result = standardSchema[`~standard`].validate(mergedData)
487
+
488
+ // Ensure validation is synchronous
489
+ if (result instanceof Promise) {
490
+ throw new TypeError(`Schema validation must be synchronous`)
491
+ }
492
+
493
+ // If validation fails, throw a SchemaValidationError with the issues
494
+ if (`issues` in result && result.issues) {
495
+ const typedIssues = result.issues.map((issue) => ({
496
+ message: issue.message,
497
+ path: issue.path?.map((p) => String(p)),
498
+ }))
499
+ throw new SchemaValidationError(type, typedIssues)
500
+ }
501
+
502
+ // Return the original update data, not the merged data
503
+ // We only used the merged data for validation
504
+ return data as T
505
+ }
506
+ }
507
+
508
+ // For inserts or updates without existing data, validate the data directly
509
+ const result = standardSchema[`~standard`].validate(data)
510
+
511
+ // Ensure validation is synchronous
512
+ if (result instanceof Promise) {
513
+ throw new TypeError(`Schema validation must be synchronous`)
514
+ }
515
+
516
+ // If validation fails, throw a SchemaValidationError with the issues
517
+ if (`issues` in result && result.issues) {
518
+ const typedIssues = result.issues.map((issue) => ({
519
+ message: issue.message,
520
+ path: issue.path?.map((p) => String(p)),
521
+ }))
522
+ throw new SchemaValidationError(type, typedIssues)
523
+ }
524
+
525
+ return result.value as T
526
+ }
527
+
528
+ private generateKey(data: unknown): string {
529
+ const str = JSON.stringify(data)
530
+ let h = 0
531
+
532
+ for (let i = 0; i < str.length; i++) {
533
+ h = (Math.imul(31, h) + str.charCodeAt(i)) | 0
534
+ }
535
+
536
+ return `${this.id}/${Math.abs(h).toString(36)}`
537
+ }
538
+
539
+ /**
540
+ * Inserts one or more items into the collection
541
+ * @param items - Single item or array of items to insert
542
+ * @param config - Optional configuration including metadata and custom keys
543
+ * @returns A Transaction object representing the insert operation(s)
544
+ * @throws {SchemaValidationError} If the data fails schema validation
545
+ * @example
546
+ * // Insert a single item
547
+ * insert({ text: "Buy groceries", completed: false })
548
+ *
549
+ * // Insert multiple items
550
+ * insert([
551
+ * { text: "Buy groceries", completed: false },
552
+ * { text: "Walk dog", completed: false }
553
+ * ])
554
+ *
555
+ * // Insert with custom key
556
+ * insert({ text: "Buy groceries" }, { key: "grocery-task" })
557
+ */
558
+ insert = (data: T | Array<T>, config?: InsertConfig) => {
559
+ const transaction = getActiveTransaction()
560
+ if (typeof transaction === `undefined`) {
561
+ throw `no transaction found when calling collection.insert`
562
+ }
563
+
564
+ const items = Array.isArray(data) ? data : [data]
565
+ const mutations: Array<PendingMutation<T>> = []
566
+
567
+ // Handle keys - convert to array if string, or generate if not provided
568
+ let keys: Array<string>
569
+ if (config?.key) {
570
+ const configKeys = Array.isArray(config.key) ? config.key : [config.key]
571
+ // If keys are provided, ensure we have the right number or allow sparse array
572
+ if (Array.isArray(config.key) && configKeys.length > items.length) {
573
+ throw new Error(`More keys provided than items to insert`)
574
+ }
575
+ keys = items.map((_, i) => configKeys[i] ?? this.generateKey(items[i]))
576
+ } else {
577
+ // No keys provided, generate for all items
578
+ keys = items.map((item) => this.generateKey(item))
579
+ }
580
+
581
+ // Create mutations for each item
582
+ items.forEach((item, index) => {
583
+ // Validate the data against the schema if one exists
584
+ const validatedData = this.validateData(item, `insert`)
585
+ const key = keys[index]!
586
+
587
+ const mutation: PendingMutation<T> = {
588
+ mutationId: crypto.randomUUID(),
589
+ original: {},
590
+ modified: validatedData as Record<string, unknown>,
591
+ changes: validatedData as Record<string, unknown>,
592
+ key,
593
+ metadata: config?.metadata as unknown,
594
+ syncMetadata: this.config.sync.getSyncMetadata?.() || {},
595
+ type: `insert`,
596
+ createdAt: new Date(),
597
+ updatedAt: new Date(),
598
+ collection: this,
599
+ }
600
+
601
+ mutations.push(mutation)
602
+ })
603
+
604
+ transaction.applyMutations(mutations)
605
+
606
+ this.transactions.setState((sortedMap) => {
607
+ sortedMap.set(transaction.id, transaction)
608
+ return sortedMap
609
+ })
610
+
611
+ return transaction
612
+ }
613
+
614
+ /**
615
+ * Updates one or more items in the collection using a callback function
616
+ * @param items - Single item/key or array of items/keys to update
617
+ * @param configOrCallback - Either update configuration or update callback
618
+ * @param maybeCallback - Update callback if config was provided
619
+ * @returns A Transaction object representing the update operation(s)
620
+ * @throws {SchemaValidationError} If the updated data fails schema validation
621
+ * @example
622
+ * // Update a single item
623
+ * update(todo, (draft) => { draft.completed = true })
624
+ *
625
+ * // Update multiple items
626
+ * update([todo1, todo2], (drafts) => {
627
+ * drafts.forEach(draft => { draft.completed = true })
628
+ * })
629
+ *
630
+ * // Update with metadata
631
+ * update(todo, { metadata: { reason: "user update" } }, (draft) => { draft.text = "Updated text" })
632
+ */
633
+
634
+ update<TItem extends object = T>(
635
+ item: TItem,
636
+ configOrCallback: ((draft: TItem) => void) | OperationConfig,
637
+ maybeCallback?: (draft: TItem) => void
638
+ ): Transaction
639
+
640
+ update<TItem extends object = T>(
641
+ items: Array<TItem>,
642
+ configOrCallback: ((draft: Array<TItem>) => void) | OperationConfig,
643
+ maybeCallback?: (draft: Array<TItem>) => void
644
+ ): Transaction
645
+
646
+ update<TItem extends object = T>(
647
+ items: TItem | Array<TItem>,
648
+ configOrCallback: ((draft: TItem | Array<TItem>) => void) | OperationConfig,
649
+ maybeCallback?: (draft: TItem | Array<TItem>) => void
650
+ ) {
651
+ if (typeof items === `undefined`) {
652
+ throw new Error(`The first argument to update is missing`)
653
+ }
654
+
655
+ const transaction = getActiveTransaction()
656
+ if (typeof transaction === `undefined`) {
657
+ throw `no transaction found when calling collection.update`
658
+ }
659
+
660
+ const isArray = Array.isArray(items)
661
+ const itemsArray = Array.isArray(items) ? items : [items]
662
+ const callback =
663
+ typeof configOrCallback === `function` ? configOrCallback : maybeCallback!
664
+ const config =
665
+ typeof configOrCallback === `function` ? {} : configOrCallback
666
+
667
+ const keys = itemsArray.map((item) => {
668
+ if (typeof item === `object` && (item as unknown) !== null) {
669
+ const key = this.objectKeyMap.get(item)
670
+ if (key === undefined) {
671
+ throw new Error(`Object not found in collection`)
672
+ }
673
+ return key
674
+ }
675
+ throw new Error(`Invalid item type for update - must be an object`)
676
+ })
677
+
678
+ // Get the current objects or empty objects if they don't exist
679
+ const currentObjects = keys.map((key) => ({
680
+ ...(this.state.get(key) || {}),
681
+ })) as Array<TItem>
682
+
683
+ let changesArray
684
+ if (isArray) {
685
+ // Use the proxy to track changes for all objects
686
+ changesArray = withArrayChangeTracking(
687
+ currentObjects,
688
+ callback as (draft: Array<TItem>) => void
689
+ )
690
+ } else {
691
+ const result = withChangeTracking(
692
+ currentObjects[0] as TItem,
693
+ callback as (draft: TItem) => void
694
+ )
695
+ changesArray = [result]
696
+ }
697
+
698
+ // Create mutations for each object that has changes
699
+ const mutations: Array<PendingMutation<T>> = keys
700
+ .map((key, index) => {
701
+ const changes = changesArray[index]
702
+
703
+ // Skip items with no changes
704
+ if (!changes || Object.keys(changes).length === 0) {
705
+ return null
706
+ }
707
+
708
+ // Validate the changes for this item
709
+ const validatedData = this.validateData(changes, `update`, key)
710
+
711
+ return {
712
+ mutationId: crypto.randomUUID(),
713
+ original: (this.state.get(key) || {}) as Record<string, unknown>,
714
+ modified: {
715
+ ...(this.state.get(key) || {}),
716
+ ...validatedData,
717
+ } as Record<string, unknown>,
718
+ changes: validatedData as Record<string, unknown>,
719
+ key,
720
+ metadata: config.metadata as unknown,
721
+ syncMetadata: (this.syncedMetadata.state.get(key) || {}) as Record<
722
+ string,
723
+ unknown
724
+ >,
725
+ type: `update`,
726
+ createdAt: new Date(),
727
+ updatedAt: new Date(),
728
+ collection: this,
729
+ }
730
+ })
731
+ .filter(Boolean) as Array<PendingMutation<T>>
732
+
733
+ // If no changes were made, return early
734
+ if (mutations.length === 0) {
735
+ throw new Error(`No changes were made to any of the objects`)
736
+ }
737
+
738
+ transaction.applyMutations(mutations)
739
+
740
+ this.transactions.setState((sortedMap) => {
741
+ sortedMap.set(transaction.id, transaction)
742
+ return sortedMap
743
+ })
744
+
745
+ return transaction
746
+ }
747
+
748
+ /**
749
+ * Deletes one or more items from the collection
750
+ * @param items - Single item/key or array of items/keys to delete
751
+ * @param config - Optional configuration including metadata
752
+ * @returns A Transaction object representing the delete operation(s)
753
+ * @example
754
+ * // Delete a single item
755
+ * delete(todo)
756
+ *
757
+ * // Delete multiple items
758
+ * delete([todo1, todo2])
759
+ *
760
+ * // Delete with metadata
761
+ * delete(todo, { metadata: { reason: "completed" } })
762
+ */
763
+ delete = (
764
+ items: Array<T | string> | T | string,
765
+ config?: OperationConfig
766
+ ) => {
767
+ const transaction = getActiveTransaction()
768
+ if (typeof transaction === `undefined`) {
769
+ throw `no transaction found when calling collection.delete`
770
+ }
771
+
772
+ const itemsArray = Array.isArray(items) ? items : [items]
773
+ const mutations: Array<PendingMutation<T>> = []
774
+
775
+ for (const item of itemsArray) {
776
+ let key: string
777
+ if (typeof item === `object` && (item as unknown) !== null) {
778
+ const objectKey = this.objectKeyMap.get(item)
779
+ if (objectKey === undefined) {
780
+ throw new Error(
781
+ `Object not found in collection: ${JSON.stringify(item)}`
782
+ )
783
+ }
784
+ key = objectKey
785
+ } else if (typeof item === `string`) {
786
+ key = item
787
+ } else {
788
+ throw new Error(
789
+ `Invalid item type for delete - must be an object or string key`
790
+ )
791
+ }
792
+
793
+ const mutation: PendingMutation<T> = {
794
+ mutationId: crypto.randomUUID(),
795
+ original: (this.state.get(key) || {}) as Record<string, unknown>,
796
+ modified: { _deleted: true },
797
+ changes: { _deleted: true },
798
+ key,
799
+ metadata: config?.metadata as unknown,
800
+ syncMetadata: (this.syncedMetadata.state.get(key) || {}) as Record<
801
+ string,
802
+ unknown
803
+ >,
804
+ type: `delete`,
805
+ createdAt: new Date(),
806
+ updatedAt: new Date(),
807
+ collection: this,
808
+ }
809
+
810
+ mutations.push(mutation)
811
+ }
812
+
813
+ // Delete object => key mapping.
814
+ mutations.forEach((mutation) => {
815
+ const curValue = this.state.get(mutation.key)
816
+ if (curValue) {
817
+ this.objectKeyMap.delete(curValue)
818
+ }
819
+ })
820
+
821
+ transaction.applyMutations(mutations)
822
+
823
+ this.transactions.setState((sortedMap) => {
824
+ sortedMap.set(transaction.id, transaction)
825
+ return sortedMap
826
+ })
827
+
828
+ return transaction
829
+ }
830
+
831
+ /**
832
+ * Gets the current state of the collection as a Map
833
+ *
834
+ * @returns A Map containing all items in the collection, with keys as identifiers
835
+ */
836
+ get state() {
837
+ return this.derivedState.state
838
+ }
839
+
840
+ /**
841
+ * Gets the current state of the collection as a Map, but only resolves when data is available
842
+ * Waits for the first sync commit to complete before resolving
843
+ *
844
+ * @returns Promise that resolves to a Map containing all items in the collection
845
+ */
846
+ stateWhenReady(): Promise<Map<string, T>> {
847
+ // If we already have data or there are no loading collections, resolve immediately
848
+ if (this.state.size > 0 || this.hasReceivedFirstCommit === true) {
849
+ return Promise.resolve(this.state)
850
+ }
851
+
852
+ // Otherwise, wait for the first commit
853
+ return new Promise<Map<string, T>>((resolve) => {
854
+ this.onFirstCommit(() => {
855
+ resolve(this.state)
856
+ })
857
+ })
858
+ }
859
+
860
+ /**
861
+ * Gets the current state of the collection as an Array
862
+ *
863
+ * @returns An Array containing all items in the collection
864
+ */
865
+ get toArray() {
866
+ return this.derivedArray.state
867
+ }
868
+
869
+ /**
870
+ * Gets the current state of the collection as an Array, but only resolves when data is available
871
+ * Waits for the first sync commit to complete before resolving
872
+ *
873
+ * @returns Promise that resolves to an Array containing all items in the collection
874
+ */
875
+ toArrayWhenReady(): Promise<Array<T>> {
876
+ // If we already have data or there are no loading collections, resolve immediately
877
+ if (this.toArray.length > 0 || this.hasReceivedFirstCommit === true) {
878
+ return Promise.resolve(this.toArray)
879
+ }
880
+
881
+ // Otherwise, wait for the first commit
882
+ return new Promise<Array<T>>((resolve) => {
883
+ this.onFirstCommit(() => {
884
+ resolve(this.toArray)
885
+ })
886
+ })
887
+ }
888
+
889
+ /**
890
+ * Returns the current state of the collection as an array of changes
891
+ * @returns An array of changes
892
+ */
893
+ public currentStateAsChanges(): Array<ChangeMessage<T>> {
894
+ return [...this.state.entries()].map(([key, value]) => ({
895
+ type: `insert`,
896
+ key,
897
+ value,
898
+ }))
899
+ }
900
+
901
+ /**
902
+ * Subscribe to changes in the collection
903
+ * @param callback - A function that will be called with the changes in the collection
904
+ * @returns A function that can be called to unsubscribe from the changes
905
+ */
906
+ public subscribeChanges(
907
+ callback: (changes: Array<ChangeMessage<T>>) => void
908
+ ): () => void {
909
+ // First send the current state as changes
910
+ callback(this.currentStateAsChanges())
911
+
912
+ // Then subscribe to changes, this returns an unsubscribe function
913
+ return this.derivedChanges.subscribe((changes) => {
914
+ if (changes.currentVal.length > 0) {
915
+ callback(changes.currentVal)
916
+ }
917
+ })
918
+ }
919
+ }