@tanstack/db 0.0.20 → 0.0.21

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.
@@ -0,0 +1,644 @@
1
+ import type {
2
+ CollectionConfig,
3
+ DeleteMutationFnParams,
4
+ InsertMutationFnParams,
5
+ ResolveType,
6
+ SyncConfig,
7
+ UpdateMutationFnParams,
8
+ UtilsRecord,
9
+ } from "./types"
10
+ import type { StandardSchemaV1 } from "@standard-schema/spec"
11
+
12
+ /**
13
+ * Storage API interface - subset of DOM Storage that we need
14
+ */
15
+ export type StorageApi = Pick<Storage, `getItem` | `setItem` | `removeItem`>
16
+
17
+ /**
18
+ * Storage event API - subset of Window for 'storage' events only
19
+ */
20
+ export type StorageEventApi = {
21
+ addEventListener: (
22
+ type: `storage`,
23
+ listener: (event: StorageEvent) => void
24
+ ) => void
25
+ removeEventListener: (
26
+ type: `storage`,
27
+ listener: (event: StorageEvent) => void
28
+ ) => void
29
+ }
30
+
31
+ /**
32
+ * Internal storage format that includes version tracking
33
+ */
34
+ interface StoredItem<T> {
35
+ versionKey: string
36
+ data: T
37
+ }
38
+
39
+ /**
40
+ * Configuration interface for localStorage collection options
41
+ * @template TExplicit - The explicit type of items in the collection (highest priority)
42
+ * @template TSchema - The schema type for validation and type inference (second priority)
43
+ * @template TFallback - The fallback type if no explicit or schema type is provided
44
+ *
45
+ * @remarks
46
+ * Type resolution follows a priority order:
47
+ * 1. If you provide an explicit type via generic parameter, it will be used
48
+ * 2. If no explicit type is provided but a schema is, the schema's output type will be inferred
49
+ * 3. If neither explicit type nor schema is provided, the fallback type will be used
50
+ *
51
+ * You should provide EITHER an explicit type OR a schema, but not both, as they would conflict.
52
+ */
53
+ export interface LocalStorageCollectionConfig<
54
+ TExplicit = unknown,
55
+ TSchema extends StandardSchemaV1 = never,
56
+ TFallback extends object = Record<string, unknown>,
57
+ > {
58
+ /**
59
+ * The key to use for storing the collection data in localStorage/sessionStorage
60
+ */
61
+ storageKey: string
62
+
63
+ /**
64
+ * Storage API to use (defaults to window.localStorage)
65
+ * Can be any object that implements the Storage interface (e.g., sessionStorage)
66
+ */
67
+ storage?: StorageApi
68
+
69
+ /**
70
+ * Storage event API to use for cross-tab synchronization (defaults to window)
71
+ * Can be any object that implements addEventListener/removeEventListener for storage events
72
+ */
73
+ storageEventApi?: StorageEventApi
74
+
75
+ /**
76
+ * Collection identifier (defaults to "local-collection:{storageKey}" if not provided)
77
+ */
78
+ id?: string
79
+ schema?: TSchema
80
+ getKey: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>[`getKey`]
81
+ sync?: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>[`sync`]
82
+
83
+ /**
84
+ * Optional asynchronous handler function called before an insert operation
85
+ * @param params Object containing transaction and collection information
86
+ * @returns Promise resolving to any value
87
+ */
88
+ onInsert?: (
89
+ params: InsertMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
90
+ ) => Promise<any>
91
+
92
+ /**
93
+ * Optional asynchronous handler function called before an update operation
94
+ * @param params Object containing transaction and collection information
95
+ * @returns Promise resolving to any value
96
+ */
97
+ onUpdate?: (
98
+ params: UpdateMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
99
+ ) => Promise<any>
100
+
101
+ /**
102
+ * Optional asynchronous handler function called before a delete operation
103
+ * @param params Object containing transaction and collection information
104
+ * @returns Promise resolving to any value
105
+ */
106
+ onDelete?: (
107
+ params: DeleteMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
108
+ ) => Promise<any>
109
+ }
110
+
111
+ /**
112
+ * Type for the clear utility function
113
+ */
114
+ export type ClearStorageFn = () => void
115
+
116
+ /**
117
+ * Type for the getStorageSize utility function
118
+ */
119
+ export type GetStorageSizeFn = () => number
120
+
121
+ /**
122
+ * LocalStorage collection utilities type
123
+ */
124
+ export interface LocalStorageCollectionUtils extends UtilsRecord {
125
+ clearStorage: ClearStorageFn
126
+ getStorageSize: GetStorageSizeFn
127
+ }
128
+
129
+ /**
130
+ * Validates that a value can be JSON serialized
131
+ * @param value - The value to validate for JSON serialization
132
+ * @param operation - The operation type being performed (for error messages)
133
+ * @throws Error if the value cannot be JSON serialized
134
+ */
135
+ function validateJsonSerializable(value: any, operation: string): void {
136
+ try {
137
+ JSON.stringify(value)
138
+ } catch (error) {
139
+ throw new Error(
140
+ `Cannot ${operation} item because it cannot be JSON serialized: ${
141
+ error instanceof Error ? error.message : String(error)
142
+ }`
143
+ )
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Generate a UUID for version tracking
149
+ * @returns A unique identifier string for tracking data versions
150
+ */
151
+ function generateUuid(): string {
152
+ return crypto.randomUUID()
153
+ }
154
+
155
+ /**
156
+ * Creates localStorage collection options for use with a standard Collection
157
+ *
158
+ * This function creates a collection that persists data to localStorage/sessionStorage
159
+ * and synchronizes changes across browser tabs using storage events.
160
+ *
161
+ * @template TExplicit - The explicit type of items in the collection (highest priority)
162
+ * @template TSchema - The schema type for validation and type inference (second priority)
163
+ * @template TFallback - The fallback type if no explicit or schema type is provided
164
+ * @param config - Configuration options for the localStorage collection
165
+ * @returns Collection options with utilities including clearStorage and getStorageSize
166
+ *
167
+ * @example
168
+ * // Basic localStorage collection
169
+ * const collection = createCollection(
170
+ * localStorageCollectionOptions({
171
+ * storageKey: 'todos',
172
+ * getKey: (item) => item.id,
173
+ * })
174
+ * )
175
+ *
176
+ * @example
177
+ * // localStorage collection with custom storage
178
+ * const collection = createCollection(
179
+ * localStorageCollectionOptions({
180
+ * storageKey: 'todos',
181
+ * storage: window.sessionStorage, // Use sessionStorage instead
182
+ * getKey: (item) => item.id,
183
+ * })
184
+ * )
185
+ *
186
+ * @example
187
+ * // localStorage collection with mutation handlers
188
+ * const collection = createCollection(
189
+ * localStorageCollectionOptions({
190
+ * storageKey: 'todos',
191
+ * getKey: (item) => item.id,
192
+ * onInsert: async ({ transaction }) => {
193
+ * console.log('Item inserted:', transaction.mutations[0].modified)
194
+ * },
195
+ * })
196
+ * )
197
+ */
198
+ export function localStorageCollectionOptions<
199
+ TExplicit = unknown,
200
+ TSchema extends StandardSchemaV1 = never,
201
+ TFallback extends object = Record<string, unknown>,
202
+ >(config: LocalStorageCollectionConfig<TExplicit, TSchema, TFallback>) {
203
+ type ResolvedType = ResolveType<TExplicit, TSchema, TFallback>
204
+
205
+ // Validate required parameters
206
+ if (!config.storageKey) {
207
+ throw new Error(`[LocalStorageCollection] storageKey must be provided.`)
208
+ }
209
+
210
+ // Default to window.localStorage if no storage is provided
211
+ const storage =
212
+ config.storage ||
213
+ (typeof window !== `undefined` ? window.localStorage : null)
214
+
215
+ if (!storage) {
216
+ throw new Error(
217
+ `[LocalStorageCollection] No storage available. Please provide a storage option or ensure window.localStorage is available.`
218
+ )
219
+ }
220
+
221
+ // Default to window for storage events if not provided
222
+ const storageEventApi =
223
+ config.storageEventApi || (typeof window !== `undefined` ? window : null)
224
+
225
+ if (!storageEventApi) {
226
+ throw new Error(
227
+ `[LocalStorageCollection] No storage event API available. Please provide a storageEventApi option or ensure window is available.`
228
+ )
229
+ }
230
+
231
+ // Track the last known state to detect changes
232
+ const lastKnownData = new Map<string | number, StoredItem<ResolvedType>>()
233
+
234
+ // Create the sync configuration
235
+ const sync = createLocalStorageSync<ResolvedType>(
236
+ config.storageKey,
237
+ storage,
238
+ storageEventApi,
239
+ config.getKey,
240
+ lastKnownData
241
+ )
242
+
243
+ /**
244
+ * Manual trigger function for local sync updates
245
+ * Forces a check for storage changes and updates the collection if needed
246
+ */
247
+ const triggerLocalSync = () => {
248
+ if (sync.manualTrigger) {
249
+ sync.manualTrigger()
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Save data to storage
255
+ * @param dataMap - Map of items with version tracking to save to storage
256
+ */
257
+ const saveToStorage = (
258
+ dataMap: Map<string | number, StoredItem<ResolvedType>>
259
+ ): void => {
260
+ try {
261
+ // Convert Map to object format for storage
262
+ const objectData: Record<string, StoredItem<ResolvedType>> = {}
263
+ dataMap.forEach((storedItem, key) => {
264
+ objectData[String(key)] = storedItem
265
+ })
266
+ const serialized = JSON.stringify(objectData)
267
+ storage.setItem(config.storageKey, serialized)
268
+ } catch (error) {
269
+ console.error(
270
+ `[LocalStorageCollection] Error saving data to storage key "${config.storageKey}":`,
271
+ error
272
+ )
273
+ throw error
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Removes all collection data from the configured storage
279
+ */
280
+ const clearStorage: ClearStorageFn = (): void => {
281
+ storage.removeItem(config.storageKey)
282
+ }
283
+
284
+ /**
285
+ * Get the size of the stored data in bytes (approximate)
286
+ * @returns The approximate size in bytes of the stored collection data
287
+ */
288
+ const getStorageSize: GetStorageSizeFn = (): number => {
289
+ const data = storage.getItem(config.storageKey)
290
+ return data ? new Blob([data]).size : 0
291
+ }
292
+
293
+ /*
294
+ * Create wrapper handlers for direct persistence operations that perform actual storage operations
295
+ * Wraps the user's onInsert handler to also save changes to localStorage
296
+ */
297
+ const wrappedOnInsert = async (
298
+ params: InsertMutationFnParams<ResolvedType>
299
+ ) => {
300
+ // Validate that all values in the transaction can be JSON serialized
301
+ params.transaction.mutations.forEach((mutation) => {
302
+ validateJsonSerializable(mutation.modified, `insert`)
303
+ })
304
+
305
+ // Call the user handler BEFORE persisting changes (if provided)
306
+ let handlerResult: any = {}
307
+ if (config.onInsert) {
308
+ handlerResult = (await config.onInsert(params)) ?? {}
309
+ }
310
+
311
+ // Always persist to storage
312
+ // Load current data from storage
313
+ const currentData = loadFromStorage<ResolvedType>(
314
+ config.storageKey,
315
+ storage
316
+ )
317
+
318
+ // Add new items with version keys
319
+ params.transaction.mutations.forEach((mutation) => {
320
+ const key = config.getKey(mutation.modified)
321
+ const storedItem: StoredItem<ResolvedType> = {
322
+ versionKey: generateUuid(),
323
+ data: mutation.modified,
324
+ }
325
+ currentData.set(key, storedItem)
326
+ })
327
+
328
+ // Save to storage
329
+ saveToStorage(currentData)
330
+
331
+ // Manually trigger local sync since storage events don't fire for current tab
332
+ triggerLocalSync()
333
+
334
+ return handlerResult
335
+ }
336
+
337
+ const wrappedOnUpdate = async (
338
+ params: UpdateMutationFnParams<ResolvedType>
339
+ ) => {
340
+ // Validate that all values in the transaction can be JSON serialized
341
+ params.transaction.mutations.forEach((mutation) => {
342
+ validateJsonSerializable(mutation.modified, `update`)
343
+ })
344
+
345
+ // Call the user handler BEFORE persisting changes (if provided)
346
+ let handlerResult: any = {}
347
+ if (config.onUpdate) {
348
+ handlerResult = (await config.onUpdate(params)) ?? {}
349
+ }
350
+
351
+ // Always persist to storage
352
+ // Load current data from storage
353
+ const currentData = loadFromStorage<ResolvedType>(
354
+ config.storageKey,
355
+ storage
356
+ )
357
+
358
+ // Update items with new version keys
359
+ params.transaction.mutations.forEach((mutation) => {
360
+ const key = config.getKey(mutation.modified)
361
+ const storedItem: StoredItem<ResolvedType> = {
362
+ versionKey: generateUuid(),
363
+ data: mutation.modified,
364
+ }
365
+ currentData.set(key, storedItem)
366
+ })
367
+
368
+ // Save to storage
369
+ saveToStorage(currentData)
370
+
371
+ // Manually trigger local sync since storage events don't fire for current tab
372
+ triggerLocalSync()
373
+
374
+ return handlerResult
375
+ }
376
+
377
+ const wrappedOnDelete = async (
378
+ params: DeleteMutationFnParams<ResolvedType>
379
+ ) => {
380
+ // Call the user handler BEFORE persisting changes (if provided)
381
+ let handlerResult: any = {}
382
+ if (config.onDelete) {
383
+ handlerResult = (await config.onDelete(params)) ?? {}
384
+ }
385
+
386
+ // Always persist to storage
387
+ // Load current data from storage
388
+ const currentData = loadFromStorage<ResolvedType>(
389
+ config.storageKey,
390
+ storage
391
+ )
392
+
393
+ // Remove items
394
+ params.transaction.mutations.forEach((mutation) => {
395
+ // For delete operations, mutation.original contains the full object
396
+ const key = config.getKey(mutation.original)
397
+ currentData.delete(key)
398
+ })
399
+
400
+ // Save to storage
401
+ saveToStorage(currentData)
402
+
403
+ // Manually trigger local sync since storage events don't fire for current tab
404
+ triggerLocalSync()
405
+
406
+ return handlerResult
407
+ }
408
+
409
+ // Extract standard Collection config properties
410
+ const {
411
+ storageKey: _storageKey,
412
+ storage: _storage,
413
+ storageEventApi: _storageEventApi,
414
+ onInsert: _onInsert,
415
+ onUpdate: _onUpdate,
416
+ onDelete: _onDelete,
417
+ id,
418
+ ...restConfig
419
+ } = config
420
+
421
+ // Default id to a pattern based on storage key if not provided
422
+ const collectionId = id ?? `local-collection:${config.storageKey}`
423
+
424
+ return {
425
+ ...restConfig,
426
+ id: collectionId,
427
+ sync,
428
+ onInsert: wrappedOnInsert,
429
+ onUpdate: wrappedOnUpdate,
430
+ onDelete: wrappedOnDelete,
431
+ utils: {
432
+ clearStorage,
433
+ getStorageSize,
434
+ },
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Load data from storage and return as a Map
440
+ * @param storageKey - The key used to store data in the storage API
441
+ * @param storage - The storage API to load from (localStorage, sessionStorage, etc.)
442
+ * @returns Map of stored items with version tracking, or empty Map if loading fails
443
+ */
444
+ function loadFromStorage<T extends object>(
445
+ storageKey: string,
446
+ storage: StorageApi
447
+ ): Map<string | number, StoredItem<T>> {
448
+ try {
449
+ const rawData = storage.getItem(storageKey)
450
+ if (!rawData) {
451
+ return new Map()
452
+ }
453
+
454
+ const parsed = JSON.parse(rawData)
455
+ const dataMap = new Map<string | number, StoredItem<T>>()
456
+
457
+ // Handle object format where keys map to StoredItem values
458
+ if (
459
+ typeof parsed === `object` &&
460
+ parsed !== null &&
461
+ !Array.isArray(parsed)
462
+ ) {
463
+ Object.entries(parsed).forEach(([key, value]) => {
464
+ // Runtime check to ensure the value has the expected StoredItem structure
465
+ if (
466
+ value &&
467
+ typeof value === `object` &&
468
+ `versionKey` in value &&
469
+ `data` in value
470
+ ) {
471
+ const storedItem = value as StoredItem<T>
472
+ dataMap.set(key, storedItem)
473
+ } else {
474
+ throw new Error(
475
+ `[LocalStorageCollection] Invalid data format in storage key "${storageKey}" for key "${key}".`
476
+ )
477
+ }
478
+ })
479
+ } else {
480
+ throw new Error(
481
+ `[LocalStorageCollection] Invalid data format in storage key "${storageKey}". Expected object format.`
482
+ )
483
+ }
484
+
485
+ return dataMap
486
+ } catch (error) {
487
+ console.warn(
488
+ `[LocalStorageCollection] Error loading data from storage key "${storageKey}":`,
489
+ error
490
+ )
491
+ return new Map()
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Internal function to create localStorage sync configuration
497
+ * Creates a sync configuration that handles localStorage persistence and cross-tab synchronization
498
+ * @param storageKey - The key used for storing data in localStorage
499
+ * @param storage - The storage API to use (localStorage, sessionStorage, etc.)
500
+ * @param storageEventApi - The event API for listening to storage changes
501
+ * @param getKey - Function to extract the key from an item
502
+ * @param lastKnownData - Map tracking the last known state for change detection
503
+ * @returns Sync configuration with manual trigger capability
504
+ */
505
+ function createLocalStorageSync<T extends object>(
506
+ storageKey: string,
507
+ storage: StorageApi,
508
+ storageEventApi: StorageEventApi,
509
+ getKey: (item: T) => string | number,
510
+ lastKnownData: Map<string | number, StoredItem<T>>
511
+ ): SyncConfig<T> & { manualTrigger?: () => void } {
512
+ let syncParams: Parameters<SyncConfig<T>[`sync`]>[0] | null = null
513
+
514
+ /**
515
+ * Compare two Maps to find differences using version keys
516
+ * @param oldData - The previous state of stored items
517
+ * @param newData - The current state of stored items
518
+ * @returns Array of changes with type, key, and value information
519
+ */
520
+ const findChanges = (
521
+ oldData: Map<string | number, StoredItem<T>>,
522
+ newData: Map<string | number, StoredItem<T>>
523
+ ): Array<{
524
+ type: `insert` | `update` | `delete`
525
+ key: string | number
526
+ value?: T
527
+ }> => {
528
+ const changes: Array<{
529
+ type: `insert` | `update` | `delete`
530
+ key: string | number
531
+ value?: T
532
+ }> = []
533
+
534
+ // Check for deletions and updates
535
+ oldData.forEach((oldStoredItem, key) => {
536
+ const newStoredItem = newData.get(key)
537
+ if (!newStoredItem) {
538
+ changes.push({ type: `delete`, key, value: oldStoredItem.data })
539
+ } else if (oldStoredItem.versionKey !== newStoredItem.versionKey) {
540
+ changes.push({ type: `update`, key, value: newStoredItem.data })
541
+ }
542
+ })
543
+
544
+ // Check for insertions
545
+ newData.forEach((newStoredItem, key) => {
546
+ if (!oldData.has(key)) {
547
+ changes.push({ type: `insert`, key, value: newStoredItem.data })
548
+ }
549
+ })
550
+
551
+ return changes
552
+ }
553
+
554
+ /**
555
+ * Process storage changes and update collection
556
+ * Loads new data from storage, compares with last known state, and applies changes
557
+ */
558
+ const processStorageChanges = () => {
559
+ if (!syncParams) return
560
+
561
+ const { begin, write, commit } = syncParams
562
+
563
+ // Load the new data
564
+ const newData = loadFromStorage<T>(storageKey, storage)
565
+
566
+ // Find the specific changes
567
+ const changes = findChanges(lastKnownData, newData)
568
+
569
+ if (changes.length > 0) {
570
+ begin()
571
+ changes.forEach(({ type, value }) => {
572
+ if (value) {
573
+ validateJsonSerializable(value, type)
574
+ write({ type, value })
575
+ }
576
+ })
577
+ commit()
578
+
579
+ // Update lastKnownData
580
+ lastKnownData.clear()
581
+ newData.forEach((storedItem, key) => {
582
+ lastKnownData.set(key, storedItem)
583
+ })
584
+ }
585
+ }
586
+
587
+ const syncConfig: SyncConfig<T> & { manualTrigger?: () => void } = {
588
+ sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
589
+ const { begin, write, commit } = params
590
+
591
+ // Store sync params for later use
592
+ syncParams = params
593
+
594
+ // Initial load
595
+ const initialData = loadFromStorage<T>(storageKey, storage)
596
+ if (initialData.size > 0) {
597
+ begin()
598
+ initialData.forEach((storedItem) => {
599
+ validateJsonSerializable(storedItem.data, `load`)
600
+ write({ type: `insert`, value: storedItem.data })
601
+ })
602
+ commit()
603
+ }
604
+
605
+ // Update lastKnownData
606
+ lastKnownData.clear()
607
+ initialData.forEach((storedItem, key) => {
608
+ lastKnownData.set(key, storedItem)
609
+ })
610
+
611
+ // Listen for storage events from other tabs
612
+ const handleStorageEvent = (event: StorageEvent) => {
613
+ // Only respond to changes to our specific key and from our storage
614
+ if (event.key !== storageKey || event.storageArea !== storage) {
615
+ return
616
+ }
617
+
618
+ processStorageChanges()
619
+ }
620
+
621
+ // Add storage event listener for cross-tab sync
622
+ storageEventApi.addEventListener(`storage`, handleStorageEvent)
623
+
624
+ // Note: Cleanup is handled automatically by the collection when it's disposed
625
+ },
626
+
627
+ /**
628
+ * Get sync metadata - returns storage key information
629
+ * @returns Object containing storage key and storage type metadata
630
+ */
631
+ getSyncMetadata: () => ({
632
+ storageKey,
633
+ storageType:
634
+ storage === (typeof window !== `undefined` ? window.localStorage : null)
635
+ ? `localStorage`
636
+ : `custom`,
637
+ }),
638
+
639
+ // Manual trigger function for local updates
640
+ manualTrigger: processStorageChanges,
641
+ }
642
+
643
+ return syncConfig
644
+ }