@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,267 @@
1
+ function validateJsonSerializable(value, operation) {
2
+ try {
3
+ JSON.stringify(value);
4
+ } catch (error) {
5
+ throw new Error(
6
+ `Cannot ${operation} item because it cannot be JSON serialized: ${error instanceof Error ? error.message : String(error)}`
7
+ );
8
+ }
9
+ }
10
+ function generateUuid() {
11
+ return crypto.randomUUID();
12
+ }
13
+ function localStorageCollectionOptions(config) {
14
+ if (!config.storageKey) {
15
+ throw new Error(`[LocalStorageCollection] storageKey must be provided.`);
16
+ }
17
+ const storage = config.storage || (typeof window !== `undefined` ? window.localStorage : null);
18
+ if (!storage) {
19
+ throw new Error(
20
+ `[LocalStorageCollection] No storage available. Please provide a storage option or ensure window.localStorage is available.`
21
+ );
22
+ }
23
+ const storageEventApi = config.storageEventApi || (typeof window !== `undefined` ? window : null);
24
+ if (!storageEventApi) {
25
+ throw new Error(
26
+ `[LocalStorageCollection] No storage event API available. Please provide a storageEventApi option or ensure window is available.`
27
+ );
28
+ }
29
+ const lastKnownData = /* @__PURE__ */ new Map();
30
+ const sync = createLocalStorageSync(
31
+ config.storageKey,
32
+ storage,
33
+ storageEventApi,
34
+ config.getKey,
35
+ lastKnownData
36
+ );
37
+ const triggerLocalSync = () => {
38
+ if (sync.manualTrigger) {
39
+ sync.manualTrigger();
40
+ }
41
+ };
42
+ const saveToStorage = (dataMap) => {
43
+ try {
44
+ const objectData = {};
45
+ dataMap.forEach((storedItem, key) => {
46
+ objectData[String(key)] = storedItem;
47
+ });
48
+ const serialized = JSON.stringify(objectData);
49
+ storage.setItem(config.storageKey, serialized);
50
+ } catch (error) {
51
+ console.error(
52
+ `[LocalStorageCollection] Error saving data to storage key "${config.storageKey}":`,
53
+ error
54
+ );
55
+ throw error;
56
+ }
57
+ };
58
+ const clearStorage = () => {
59
+ storage.removeItem(config.storageKey);
60
+ };
61
+ const getStorageSize = () => {
62
+ const data = storage.getItem(config.storageKey);
63
+ return data ? new Blob([data]).size : 0;
64
+ };
65
+ const wrappedOnInsert = async (params) => {
66
+ params.transaction.mutations.forEach((mutation) => {
67
+ validateJsonSerializable(mutation.modified, `insert`);
68
+ });
69
+ let handlerResult = {};
70
+ if (config.onInsert) {
71
+ handlerResult = await config.onInsert(params) ?? {};
72
+ }
73
+ const currentData = loadFromStorage(
74
+ config.storageKey,
75
+ storage
76
+ );
77
+ params.transaction.mutations.forEach((mutation) => {
78
+ const key = config.getKey(mutation.modified);
79
+ const storedItem = {
80
+ versionKey: generateUuid(),
81
+ data: mutation.modified
82
+ };
83
+ currentData.set(key, storedItem);
84
+ });
85
+ saveToStorage(currentData);
86
+ triggerLocalSync();
87
+ return handlerResult;
88
+ };
89
+ const wrappedOnUpdate = async (params) => {
90
+ params.transaction.mutations.forEach((mutation) => {
91
+ validateJsonSerializable(mutation.modified, `update`);
92
+ });
93
+ let handlerResult = {};
94
+ if (config.onUpdate) {
95
+ handlerResult = await config.onUpdate(params) ?? {};
96
+ }
97
+ const currentData = loadFromStorage(
98
+ config.storageKey,
99
+ storage
100
+ );
101
+ params.transaction.mutations.forEach((mutation) => {
102
+ const key = config.getKey(mutation.modified);
103
+ const storedItem = {
104
+ versionKey: generateUuid(),
105
+ data: mutation.modified
106
+ };
107
+ currentData.set(key, storedItem);
108
+ });
109
+ saveToStorage(currentData);
110
+ triggerLocalSync();
111
+ return handlerResult;
112
+ };
113
+ const wrappedOnDelete = async (params) => {
114
+ let handlerResult = {};
115
+ if (config.onDelete) {
116
+ handlerResult = await config.onDelete(params) ?? {};
117
+ }
118
+ const currentData = loadFromStorage(
119
+ config.storageKey,
120
+ storage
121
+ );
122
+ params.transaction.mutations.forEach((mutation) => {
123
+ const key = config.getKey(mutation.original);
124
+ currentData.delete(key);
125
+ });
126
+ saveToStorage(currentData);
127
+ triggerLocalSync();
128
+ return handlerResult;
129
+ };
130
+ const {
131
+ storageKey: _storageKey,
132
+ storage: _storage,
133
+ storageEventApi: _storageEventApi,
134
+ onInsert: _onInsert,
135
+ onUpdate: _onUpdate,
136
+ onDelete: _onDelete,
137
+ id,
138
+ ...restConfig
139
+ } = config;
140
+ const collectionId = id ?? `local-collection:${config.storageKey}`;
141
+ return {
142
+ ...restConfig,
143
+ id: collectionId,
144
+ sync,
145
+ onInsert: wrappedOnInsert,
146
+ onUpdate: wrappedOnUpdate,
147
+ onDelete: wrappedOnDelete,
148
+ utils: {
149
+ clearStorage,
150
+ getStorageSize
151
+ }
152
+ };
153
+ }
154
+ function loadFromStorage(storageKey, storage) {
155
+ try {
156
+ const rawData = storage.getItem(storageKey);
157
+ if (!rawData) {
158
+ return /* @__PURE__ */ new Map();
159
+ }
160
+ const parsed = JSON.parse(rawData);
161
+ const dataMap = /* @__PURE__ */ new Map();
162
+ if (typeof parsed === `object` && parsed !== null && !Array.isArray(parsed)) {
163
+ Object.entries(parsed).forEach(([key, value]) => {
164
+ if (value && typeof value === `object` && `versionKey` in value && `data` in value) {
165
+ const storedItem = value;
166
+ dataMap.set(key, storedItem);
167
+ } else {
168
+ throw new Error(
169
+ `[LocalStorageCollection] Invalid data format in storage key "${storageKey}" for key "${key}".`
170
+ );
171
+ }
172
+ });
173
+ } else {
174
+ throw new Error(
175
+ `[LocalStorageCollection] Invalid data format in storage key "${storageKey}". Expected object format.`
176
+ );
177
+ }
178
+ return dataMap;
179
+ } catch (error) {
180
+ console.warn(
181
+ `[LocalStorageCollection] Error loading data from storage key "${storageKey}":`,
182
+ error
183
+ );
184
+ return /* @__PURE__ */ new Map();
185
+ }
186
+ }
187
+ function createLocalStorageSync(storageKey, storage, storageEventApi, getKey, lastKnownData) {
188
+ let syncParams = null;
189
+ const findChanges = (oldData, newData) => {
190
+ const changes = [];
191
+ oldData.forEach((oldStoredItem, key) => {
192
+ const newStoredItem = newData.get(key);
193
+ if (!newStoredItem) {
194
+ changes.push({ type: `delete`, key, value: oldStoredItem.data });
195
+ } else if (oldStoredItem.versionKey !== newStoredItem.versionKey) {
196
+ changes.push({ type: `update`, key, value: newStoredItem.data });
197
+ }
198
+ });
199
+ newData.forEach((newStoredItem, key) => {
200
+ if (!oldData.has(key)) {
201
+ changes.push({ type: `insert`, key, value: newStoredItem.data });
202
+ }
203
+ });
204
+ return changes;
205
+ };
206
+ const processStorageChanges = () => {
207
+ if (!syncParams) return;
208
+ const { begin, write, commit } = syncParams;
209
+ const newData = loadFromStorage(storageKey, storage);
210
+ const changes = findChanges(lastKnownData, newData);
211
+ if (changes.length > 0) {
212
+ begin();
213
+ changes.forEach(({ type, value }) => {
214
+ if (value) {
215
+ validateJsonSerializable(value, type);
216
+ write({ type, value });
217
+ }
218
+ });
219
+ commit();
220
+ lastKnownData.clear();
221
+ newData.forEach((storedItem, key) => {
222
+ lastKnownData.set(key, storedItem);
223
+ });
224
+ }
225
+ };
226
+ const syncConfig = {
227
+ sync: (params) => {
228
+ const { begin, write, commit } = params;
229
+ syncParams = params;
230
+ const initialData = loadFromStorage(storageKey, storage);
231
+ if (initialData.size > 0) {
232
+ begin();
233
+ initialData.forEach((storedItem) => {
234
+ validateJsonSerializable(storedItem.data, `load`);
235
+ write({ type: `insert`, value: storedItem.data });
236
+ });
237
+ commit();
238
+ }
239
+ lastKnownData.clear();
240
+ initialData.forEach((storedItem, key) => {
241
+ lastKnownData.set(key, storedItem);
242
+ });
243
+ const handleStorageEvent = (event) => {
244
+ if (event.key !== storageKey || event.storageArea !== storage) {
245
+ return;
246
+ }
247
+ processStorageChanges();
248
+ };
249
+ storageEventApi.addEventListener(`storage`, handleStorageEvent);
250
+ },
251
+ /**
252
+ * Get sync metadata - returns storage key information
253
+ * @returns Object containing storage key and storage type metadata
254
+ */
255
+ getSyncMetadata: () => ({
256
+ storageKey,
257
+ storageType: storage === (typeof window !== `undefined` ? window.localStorage : null) ? `localStorage` : `custom`
258
+ }),
259
+ // Manual trigger function for local updates
260
+ manualTrigger: processStorageChanges
261
+ };
262
+ return syncConfig;
263
+ }
264
+ export {
265
+ localStorageCollectionOptions
266
+ };
267
+ //# sourceMappingURL=local-storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-storage.js","sources":["../../src/local-storage.ts"],"sourcesContent":["import type {\n CollectionConfig,\n DeleteMutationFnParams,\n InsertMutationFnParams,\n ResolveType,\n SyncConfig,\n UpdateMutationFnParams,\n UtilsRecord,\n} from \"./types\"\nimport type { StandardSchemaV1 } from \"@standard-schema/spec\"\n\n/**\n * Storage API interface - subset of DOM Storage that we need\n */\nexport type StorageApi = Pick<Storage, `getItem` | `setItem` | `removeItem`>\n\n/**\n * Storage event API - subset of Window for 'storage' events only\n */\nexport type StorageEventApi = {\n addEventListener: (\n type: `storage`,\n listener: (event: StorageEvent) => void\n ) => void\n removeEventListener: (\n type: `storage`,\n listener: (event: StorageEvent) => void\n ) => void\n}\n\n/**\n * Internal storage format that includes version tracking\n */\ninterface StoredItem<T> {\n versionKey: string\n data: T\n}\n\n/**\n * Configuration interface for localStorage collection options\n * @template TExplicit - The explicit type of items in the collection (highest priority)\n * @template TSchema - The schema type for validation and type inference (second priority)\n * @template TFallback - The fallback type if no explicit or schema type is provided\n *\n * @remarks\n * Type resolution follows a priority order:\n * 1. If you provide an explicit type via generic parameter, it will be used\n * 2. If no explicit type is provided but a schema is, the schema's output type will be inferred\n * 3. If neither explicit type nor schema is provided, the fallback type will be used\n *\n * You should provide EITHER an explicit type OR a schema, but not both, as they would conflict.\n */\nexport interface LocalStorageCollectionConfig<\n TExplicit = unknown,\n TSchema extends StandardSchemaV1 = never,\n TFallback extends object = Record<string, unknown>,\n> {\n /**\n * The key to use for storing the collection data in localStorage/sessionStorage\n */\n storageKey: string\n\n /**\n * Storage API to use (defaults to window.localStorage)\n * Can be any object that implements the Storage interface (e.g., sessionStorage)\n */\n storage?: StorageApi\n\n /**\n * Storage event API to use for cross-tab synchronization (defaults to window)\n * Can be any object that implements addEventListener/removeEventListener for storage events\n */\n storageEventApi?: StorageEventApi\n\n /**\n * Collection identifier (defaults to \"local-collection:{storageKey}\" if not provided)\n */\n id?: string\n schema?: TSchema\n getKey: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>[`getKey`]\n sync?: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>[`sync`]\n\n /**\n * Optional asynchronous handler function called before an insert operation\n * @param params Object containing transaction and collection information\n * @returns Promise resolving to any value\n */\n onInsert?: (\n params: InsertMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>\n ) => Promise<any>\n\n /**\n * Optional asynchronous handler function called before an update operation\n * @param params Object containing transaction and collection information\n * @returns Promise resolving to any value\n */\n onUpdate?: (\n params: UpdateMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>\n ) => Promise<any>\n\n /**\n * Optional asynchronous handler function called before a delete operation\n * @param params Object containing transaction and collection information\n * @returns Promise resolving to any value\n */\n onDelete?: (\n params: DeleteMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>\n ) => Promise<any>\n}\n\n/**\n * Type for the clear utility function\n */\nexport type ClearStorageFn = () => void\n\n/**\n * Type for the getStorageSize utility function\n */\nexport type GetStorageSizeFn = () => number\n\n/**\n * LocalStorage collection utilities type\n */\nexport interface LocalStorageCollectionUtils extends UtilsRecord {\n clearStorage: ClearStorageFn\n getStorageSize: GetStorageSizeFn\n}\n\n/**\n * Validates that a value can be JSON serialized\n * @param value - The value to validate for JSON serialization\n * @param operation - The operation type being performed (for error messages)\n * @throws Error if the value cannot be JSON serialized\n */\nfunction validateJsonSerializable(value: any, operation: string): void {\n try {\n JSON.stringify(value)\n } catch (error) {\n throw new Error(\n `Cannot ${operation} item because it cannot be JSON serialized: ${\n error instanceof Error ? error.message : String(error)\n }`\n )\n }\n}\n\n/**\n * Generate a UUID for version tracking\n * @returns A unique identifier string for tracking data versions\n */\nfunction generateUuid(): string {\n return crypto.randomUUID()\n}\n\n/**\n * Creates localStorage collection options for use with a standard Collection\n *\n * This function creates a collection that persists data to localStorage/sessionStorage\n * and synchronizes changes across browser tabs using storage events.\n *\n * @template TExplicit - The explicit type of items in the collection (highest priority)\n * @template TSchema - The schema type for validation and type inference (second priority)\n * @template TFallback - The fallback type if no explicit or schema type is provided\n * @param config - Configuration options for the localStorage collection\n * @returns Collection options with utilities including clearStorage and getStorageSize\n *\n * @example\n * // Basic localStorage collection\n * const collection = createCollection(\n * localStorageCollectionOptions({\n * storageKey: 'todos',\n * getKey: (item) => item.id,\n * })\n * )\n *\n * @example\n * // localStorage collection with custom storage\n * const collection = createCollection(\n * localStorageCollectionOptions({\n * storageKey: 'todos',\n * storage: window.sessionStorage, // Use sessionStorage instead\n * getKey: (item) => item.id,\n * })\n * )\n *\n * @example\n * // localStorage collection with mutation handlers\n * const collection = createCollection(\n * localStorageCollectionOptions({\n * storageKey: 'todos',\n * getKey: (item) => item.id,\n * onInsert: async ({ transaction }) => {\n * console.log('Item inserted:', transaction.mutations[0].modified)\n * },\n * })\n * )\n */\nexport function localStorageCollectionOptions<\n TExplicit = unknown,\n TSchema extends StandardSchemaV1 = never,\n TFallback extends object = Record<string, unknown>,\n>(config: LocalStorageCollectionConfig<TExplicit, TSchema, TFallback>) {\n type ResolvedType = ResolveType<TExplicit, TSchema, TFallback>\n\n // Validate required parameters\n if (!config.storageKey) {\n throw new Error(`[LocalStorageCollection] storageKey must be provided.`)\n }\n\n // Default to window.localStorage if no storage is provided\n const storage =\n config.storage ||\n (typeof window !== `undefined` ? window.localStorage : null)\n\n if (!storage) {\n throw new Error(\n `[LocalStorageCollection] No storage available. Please provide a storage option or ensure window.localStorage is available.`\n )\n }\n\n // Default to window for storage events if not provided\n const storageEventApi =\n config.storageEventApi || (typeof window !== `undefined` ? window : null)\n\n if (!storageEventApi) {\n throw new Error(\n `[LocalStorageCollection] No storage event API available. Please provide a storageEventApi option or ensure window is available.`\n )\n }\n\n // Track the last known state to detect changes\n const lastKnownData = new Map<string | number, StoredItem<ResolvedType>>()\n\n // Create the sync configuration\n const sync = createLocalStorageSync<ResolvedType>(\n config.storageKey,\n storage,\n storageEventApi,\n config.getKey,\n lastKnownData\n )\n\n /**\n * Manual trigger function for local sync updates\n * Forces a check for storage changes and updates the collection if needed\n */\n const triggerLocalSync = () => {\n if (sync.manualTrigger) {\n sync.manualTrigger()\n }\n }\n\n /**\n * Save data to storage\n * @param dataMap - Map of items with version tracking to save to storage\n */\n const saveToStorage = (\n dataMap: Map<string | number, StoredItem<ResolvedType>>\n ): void => {\n try {\n // Convert Map to object format for storage\n const objectData: Record<string, StoredItem<ResolvedType>> = {}\n dataMap.forEach((storedItem, key) => {\n objectData[String(key)] = storedItem\n })\n const serialized = JSON.stringify(objectData)\n storage.setItem(config.storageKey, serialized)\n } catch (error) {\n console.error(\n `[LocalStorageCollection] Error saving data to storage key \"${config.storageKey}\":`,\n error\n )\n throw error\n }\n }\n\n /**\n * Removes all collection data from the configured storage\n */\n const clearStorage: ClearStorageFn = (): void => {\n storage.removeItem(config.storageKey)\n }\n\n /**\n * Get the size of the stored data in bytes (approximate)\n * @returns The approximate size in bytes of the stored collection data\n */\n const getStorageSize: GetStorageSizeFn = (): number => {\n const data = storage.getItem(config.storageKey)\n return data ? new Blob([data]).size : 0\n }\n\n /*\n * Create wrapper handlers for direct persistence operations that perform actual storage operations\n * Wraps the user's onInsert handler to also save changes to localStorage\n */\n const wrappedOnInsert = async (\n params: InsertMutationFnParams<ResolvedType>\n ) => {\n // Validate that all values in the transaction can be JSON serialized\n params.transaction.mutations.forEach((mutation) => {\n validateJsonSerializable(mutation.modified, `insert`)\n })\n\n // Call the user handler BEFORE persisting changes (if provided)\n let handlerResult: any = {}\n if (config.onInsert) {\n handlerResult = (await config.onInsert(params)) ?? {}\n }\n\n // Always persist to storage\n // Load current data from storage\n const currentData = loadFromStorage<ResolvedType>(\n config.storageKey,\n storage\n )\n\n // Add new items with version keys\n params.transaction.mutations.forEach((mutation) => {\n const key = config.getKey(mutation.modified)\n const storedItem: StoredItem<ResolvedType> = {\n versionKey: generateUuid(),\n data: mutation.modified,\n }\n currentData.set(key, storedItem)\n })\n\n // Save to storage\n saveToStorage(currentData)\n\n // Manually trigger local sync since storage events don't fire for current tab\n triggerLocalSync()\n\n return handlerResult\n }\n\n const wrappedOnUpdate = async (\n params: UpdateMutationFnParams<ResolvedType>\n ) => {\n // Validate that all values in the transaction can be JSON serialized\n params.transaction.mutations.forEach((mutation) => {\n validateJsonSerializable(mutation.modified, `update`)\n })\n\n // Call the user handler BEFORE persisting changes (if provided)\n let handlerResult: any = {}\n if (config.onUpdate) {\n handlerResult = (await config.onUpdate(params)) ?? {}\n }\n\n // Always persist to storage\n // Load current data from storage\n const currentData = loadFromStorage<ResolvedType>(\n config.storageKey,\n storage\n )\n\n // Update items with new version keys\n params.transaction.mutations.forEach((mutation) => {\n const key = config.getKey(mutation.modified)\n const storedItem: StoredItem<ResolvedType> = {\n versionKey: generateUuid(),\n data: mutation.modified,\n }\n currentData.set(key, storedItem)\n })\n\n // Save to storage\n saveToStorage(currentData)\n\n // Manually trigger local sync since storage events don't fire for current tab\n triggerLocalSync()\n\n return handlerResult\n }\n\n const wrappedOnDelete = async (\n params: DeleteMutationFnParams<ResolvedType>\n ) => {\n // Call the user handler BEFORE persisting changes (if provided)\n let handlerResult: any = {}\n if (config.onDelete) {\n handlerResult = (await config.onDelete(params)) ?? {}\n }\n\n // Always persist to storage\n // Load current data from storage\n const currentData = loadFromStorage<ResolvedType>(\n config.storageKey,\n storage\n )\n\n // Remove items\n params.transaction.mutations.forEach((mutation) => {\n // For delete operations, mutation.original contains the full object\n const key = config.getKey(mutation.original)\n currentData.delete(key)\n })\n\n // Save to storage\n saveToStorage(currentData)\n\n // Manually trigger local sync since storage events don't fire for current tab\n triggerLocalSync()\n\n return handlerResult\n }\n\n // Extract standard Collection config properties\n const {\n storageKey: _storageKey,\n storage: _storage,\n storageEventApi: _storageEventApi,\n onInsert: _onInsert,\n onUpdate: _onUpdate,\n onDelete: _onDelete,\n id,\n ...restConfig\n } = config\n\n // Default id to a pattern based on storage key if not provided\n const collectionId = id ?? `local-collection:${config.storageKey}`\n\n return {\n ...restConfig,\n id: collectionId,\n sync,\n onInsert: wrappedOnInsert,\n onUpdate: wrappedOnUpdate,\n onDelete: wrappedOnDelete,\n utils: {\n clearStorage,\n getStorageSize,\n },\n }\n}\n\n/**\n * Load data from storage and return as a Map\n * @param storageKey - The key used to store data in the storage API\n * @param storage - The storage API to load from (localStorage, sessionStorage, etc.)\n * @returns Map of stored items with version tracking, or empty Map if loading fails\n */\nfunction loadFromStorage<T extends object>(\n storageKey: string,\n storage: StorageApi\n): Map<string | number, StoredItem<T>> {\n try {\n const rawData = storage.getItem(storageKey)\n if (!rawData) {\n return new Map()\n }\n\n const parsed = JSON.parse(rawData)\n const dataMap = new Map<string | number, StoredItem<T>>()\n\n // Handle object format where keys map to StoredItem values\n if (\n typeof parsed === `object` &&\n parsed !== null &&\n !Array.isArray(parsed)\n ) {\n Object.entries(parsed).forEach(([key, value]) => {\n // Runtime check to ensure the value has the expected StoredItem structure\n if (\n value &&\n typeof value === `object` &&\n `versionKey` in value &&\n `data` in value\n ) {\n const storedItem = value as StoredItem<T>\n dataMap.set(key, storedItem)\n } else {\n throw new Error(\n `[LocalStorageCollection] Invalid data format in storage key \"${storageKey}\" for key \"${key}\".`\n )\n }\n })\n } else {\n throw new Error(\n `[LocalStorageCollection] Invalid data format in storage key \"${storageKey}\". Expected object format.`\n )\n }\n\n return dataMap\n } catch (error) {\n console.warn(\n `[LocalStorageCollection] Error loading data from storage key \"${storageKey}\":`,\n error\n )\n return new Map()\n }\n}\n\n/**\n * Internal function to create localStorage sync configuration\n * Creates a sync configuration that handles localStorage persistence and cross-tab synchronization\n * @param storageKey - The key used for storing data in localStorage\n * @param storage - The storage API to use (localStorage, sessionStorage, etc.)\n * @param storageEventApi - The event API for listening to storage changes\n * @param getKey - Function to extract the key from an item\n * @param lastKnownData - Map tracking the last known state for change detection\n * @returns Sync configuration with manual trigger capability\n */\nfunction createLocalStorageSync<T extends object>(\n storageKey: string,\n storage: StorageApi,\n storageEventApi: StorageEventApi,\n getKey: (item: T) => string | number,\n lastKnownData: Map<string | number, StoredItem<T>>\n): SyncConfig<T> & { manualTrigger?: () => void } {\n let syncParams: Parameters<SyncConfig<T>[`sync`]>[0] | null = null\n\n /**\n * Compare two Maps to find differences using version keys\n * @param oldData - The previous state of stored items\n * @param newData - The current state of stored items\n * @returns Array of changes with type, key, and value information\n */\n const findChanges = (\n oldData: Map<string | number, StoredItem<T>>,\n newData: Map<string | number, StoredItem<T>>\n ): Array<{\n type: `insert` | `update` | `delete`\n key: string | number\n value?: T\n }> => {\n const changes: Array<{\n type: `insert` | `update` | `delete`\n key: string | number\n value?: T\n }> = []\n\n // Check for deletions and updates\n oldData.forEach((oldStoredItem, key) => {\n const newStoredItem = newData.get(key)\n if (!newStoredItem) {\n changes.push({ type: `delete`, key, value: oldStoredItem.data })\n } else if (oldStoredItem.versionKey !== newStoredItem.versionKey) {\n changes.push({ type: `update`, key, value: newStoredItem.data })\n }\n })\n\n // Check for insertions\n newData.forEach((newStoredItem, key) => {\n if (!oldData.has(key)) {\n changes.push({ type: `insert`, key, value: newStoredItem.data })\n }\n })\n\n return changes\n }\n\n /**\n * Process storage changes and update collection\n * Loads new data from storage, compares with last known state, and applies changes\n */\n const processStorageChanges = () => {\n if (!syncParams) return\n\n const { begin, write, commit } = syncParams\n\n // Load the new data\n const newData = loadFromStorage<T>(storageKey, storage)\n\n // Find the specific changes\n const changes = findChanges(lastKnownData, newData)\n\n if (changes.length > 0) {\n begin()\n changes.forEach(({ type, value }) => {\n if (value) {\n validateJsonSerializable(value, type)\n write({ type, value })\n }\n })\n commit()\n\n // Update lastKnownData\n lastKnownData.clear()\n newData.forEach((storedItem, key) => {\n lastKnownData.set(key, storedItem)\n })\n }\n }\n\n const syncConfig: SyncConfig<T> & { manualTrigger?: () => void } = {\n sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {\n const { begin, write, commit } = params\n\n // Store sync params for later use\n syncParams = params\n\n // Initial load\n const initialData = loadFromStorage<T>(storageKey, storage)\n if (initialData.size > 0) {\n begin()\n initialData.forEach((storedItem) => {\n validateJsonSerializable(storedItem.data, `load`)\n write({ type: `insert`, value: storedItem.data })\n })\n commit()\n }\n\n // Update lastKnownData\n lastKnownData.clear()\n initialData.forEach((storedItem, key) => {\n lastKnownData.set(key, storedItem)\n })\n\n // Listen for storage events from other tabs\n const handleStorageEvent = (event: StorageEvent) => {\n // Only respond to changes to our specific key and from our storage\n if (event.key !== storageKey || event.storageArea !== storage) {\n return\n }\n\n processStorageChanges()\n }\n\n // Add storage event listener for cross-tab sync\n storageEventApi.addEventListener(`storage`, handleStorageEvent)\n\n // Note: Cleanup is handled automatically by the collection when it's disposed\n },\n\n /**\n * Get sync metadata - returns storage key information\n * @returns Object containing storage key and storage type metadata\n */\n getSyncMetadata: () => ({\n storageKey,\n storageType:\n storage === (typeof window !== `undefined` ? window.localStorage : null)\n ? `localStorage`\n : `custom`,\n }),\n\n // Manual trigger function for local updates\n manualTrigger: processStorageChanges,\n }\n\n return syncConfig\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tanstack/db",
3
3
  "description": "A reactive client store for building super fast apps on sync",
4
- "version": "0.0.20",
4
+ "version": "0.0.21",
5
5
  "dependencies": {
6
6
  "@electric-sql/d2mini": "^0.1.6",
7
7
  "@standard-schema/spec": "^1.0.0"
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export * from "./errors"
7
7
  export * from "./proxy"
8
8
  export * from "./query/index.js"
9
9
  export * from "./optimistic-action"
10
+ export * from "./local-only"
11
+ export * from "./local-storage"
10
12
 
11
13
  // Re-export some stuff explicitly to ensure the type & value is exported
12
14
  export type { Collection } from "./collection"
@@ -0,0 +1,302 @@
1
+ import type {
2
+ DeleteMutationFnParams,
3
+ InsertMutationFnParams,
4
+ OperationType,
5
+ ResolveType,
6
+ SyncConfig,
7
+ UpdateMutationFnParams,
8
+ UtilsRecord,
9
+ } from "./types"
10
+ import type { StandardSchemaV1 } from "@standard-schema/spec"
11
+
12
+ /**
13
+ * Configuration interface for Local-only collection options
14
+ * @template TExplicit - The explicit type of items in the collection (highest priority)
15
+ * @template TSchema - The schema type for validation and type inference (second priority)
16
+ * @template TFallback - The fallback type if no explicit or schema type is provided
17
+ * @template TKey - The type of the key returned by getKey
18
+ *
19
+ * @remarks
20
+ * Type resolution follows a priority order:
21
+ * 1. If you provide an explicit type via generic parameter, it will be used
22
+ * 2. If no explicit type is provided but a schema is, the schema's output type will be inferred
23
+ * 3. If neither explicit type nor schema is provided, the fallback type will be used
24
+ *
25
+ * You should provide EITHER an explicit type OR a schema, but not both, as they would conflict.
26
+ */
27
+ export interface LocalOnlyCollectionConfig<
28
+ TExplicit = unknown,
29
+ TSchema extends StandardSchemaV1 = never,
30
+ TFallback extends Record<string, unknown> = Record<string, unknown>,
31
+ TKey extends string | number = string | number,
32
+ > {
33
+ /**
34
+ * Standard Collection configuration properties
35
+ */
36
+ id?: string
37
+ schema?: TSchema
38
+ getKey: (item: ResolveType<TExplicit, TSchema, TFallback>) => TKey
39
+
40
+ /**
41
+ * Optional initial data to populate the collection with on creation
42
+ * This data will be applied during the initial sync process
43
+ */
44
+ initialData?: Array<ResolveType<TExplicit, TSchema, TFallback>>
45
+
46
+ /**
47
+ * Optional asynchronous handler function called after an insert operation
48
+ * @param params Object containing transaction and collection information
49
+ * @returns Promise resolving to any value
50
+ */
51
+ onInsert?: (
52
+ params: InsertMutationFnParams<
53
+ ResolveType<TExplicit, TSchema, TFallback>,
54
+ TKey,
55
+ LocalOnlyCollectionUtils
56
+ >
57
+ ) => Promise<any>
58
+
59
+ /**
60
+ * Optional asynchronous handler function called after an update operation
61
+ * @param params Object containing transaction and collection information
62
+ * @returns Promise resolving to any value
63
+ */
64
+ onUpdate?: (
65
+ params: UpdateMutationFnParams<
66
+ ResolveType<TExplicit, TSchema, TFallback>,
67
+ TKey,
68
+ LocalOnlyCollectionUtils
69
+ >
70
+ ) => Promise<any>
71
+
72
+ /**
73
+ * Optional asynchronous handler function called after a delete operation
74
+ * @param params Object containing transaction and collection information
75
+ * @returns Promise resolving to any value
76
+ */
77
+ onDelete?: (
78
+ params: DeleteMutationFnParams<
79
+ ResolveType<TExplicit, TSchema, TFallback>,
80
+ TKey,
81
+ LocalOnlyCollectionUtils
82
+ >
83
+ ) => Promise<any>
84
+ }
85
+
86
+ /**
87
+ * Local-only collection utilities type (currently empty but matches the pattern)
88
+ */
89
+ export interface LocalOnlyCollectionUtils extends UtilsRecord {}
90
+
91
+ /**
92
+ * Creates Local-only collection options for use with a standard Collection
93
+ *
94
+ * This is an in-memory collection that doesn't sync with external sources but uses a loopback sync config
95
+ * that immediately "syncs" all optimistic changes to the collection, making them permanent.
96
+ * Perfect for local-only data that doesn't need persistence or external synchronization.
97
+ *
98
+ * @template TExplicit - The explicit type of items in the collection (highest priority)
99
+ * @template TSchema - The schema type for validation and type inference (second priority)
100
+ * @template TFallback - The fallback type if no explicit or schema type is provided
101
+ * @template TKey - The type of the key returned by getKey
102
+ * @param config - Configuration options for the Local-only collection
103
+ * @returns Collection options with utilities (currently empty but follows the pattern)
104
+ *
105
+ * @example
106
+ * // Basic local-only collection
107
+ * const collection = createCollection(
108
+ * localOnlyCollectionOptions({
109
+ * getKey: (item) => item.id,
110
+ * })
111
+ * )
112
+ *
113
+ * @example
114
+ * // Local-only collection with initial data
115
+ * const collection = createCollection(
116
+ * localOnlyCollectionOptions({
117
+ * getKey: (item) => item.id,
118
+ * initialData: [
119
+ * { id: 1, name: 'Item 1' },
120
+ * { id: 2, name: 'Item 2' },
121
+ * ],
122
+ * })
123
+ * )
124
+ *
125
+ * @example
126
+ * // Local-only collection with mutation handlers
127
+ * const collection = createCollection(
128
+ * localOnlyCollectionOptions({
129
+ * getKey: (item) => item.id,
130
+ * onInsert: async ({ transaction }) => {
131
+ * console.log('Item inserted:', transaction.mutations[0].modified)
132
+ * // Custom logic after insert
133
+ * },
134
+ * })
135
+ * )
136
+ */
137
+ export function localOnlyCollectionOptions<
138
+ TExplicit = unknown,
139
+ TSchema extends StandardSchemaV1 = never,
140
+ TFallback extends Record<string, unknown> = Record<string, unknown>,
141
+ TKey extends string | number = string | number,
142
+ >(config: LocalOnlyCollectionConfig<TExplicit, TSchema, TFallback, TKey>) {
143
+ type ResolvedType = ResolveType<TExplicit, TSchema, TFallback>
144
+
145
+ const { initialData, onInsert, onUpdate, onDelete, ...restConfig } = config
146
+
147
+ // Create the sync configuration with transaction confirmation capability
148
+ const syncResult = createLocalOnlySync<ResolvedType, TKey>(initialData)
149
+
150
+ /**
151
+ * Create wrapper handlers that call user handlers first, then confirm transactions
152
+ * Wraps the user's onInsert handler to also confirm the transaction immediately
153
+ */
154
+ const wrappedOnInsert = async (
155
+ params: InsertMutationFnParams<ResolvedType, TKey, LocalOnlyCollectionUtils>
156
+ ) => {
157
+ // Call user handler first if provided
158
+ let handlerResult
159
+ if (onInsert) {
160
+ handlerResult = (await onInsert(params)) ?? {}
161
+ }
162
+
163
+ // Then synchronously confirm the transaction by looping through mutations
164
+ syncResult.confirmOperationsSync(params.transaction.mutations)
165
+
166
+ return handlerResult
167
+ }
168
+
169
+ /**
170
+ * Wrapper for onUpdate handler that also confirms the transaction immediately
171
+ */
172
+ const wrappedOnUpdate = async (
173
+ params: UpdateMutationFnParams<ResolvedType, TKey, LocalOnlyCollectionUtils>
174
+ ) => {
175
+ // Call user handler first if provided
176
+ let handlerResult
177
+ if (onUpdate) {
178
+ handlerResult = (await onUpdate(params)) ?? {}
179
+ }
180
+
181
+ // Then synchronously confirm the transaction by looping through mutations
182
+ syncResult.confirmOperationsSync(params.transaction.mutations)
183
+
184
+ return handlerResult
185
+ }
186
+
187
+ /**
188
+ * Wrapper for onDelete handler that also confirms the transaction immediately
189
+ */
190
+ const wrappedOnDelete = async (
191
+ params: DeleteMutationFnParams<ResolvedType, TKey, LocalOnlyCollectionUtils>
192
+ ) => {
193
+ // Call user handler first if provided
194
+ let handlerResult
195
+ if (onDelete) {
196
+ handlerResult = (await onDelete(params)) ?? {}
197
+ }
198
+
199
+ // Then synchronously confirm the transaction by looping through mutations
200
+ syncResult.confirmOperationsSync(params.transaction.mutations)
201
+
202
+ return handlerResult
203
+ }
204
+
205
+ return {
206
+ ...restConfig,
207
+ sync: syncResult.sync,
208
+ onInsert: wrappedOnInsert,
209
+ onUpdate: wrappedOnUpdate,
210
+ onDelete: wrappedOnDelete,
211
+ utils: {} as LocalOnlyCollectionUtils,
212
+ startSync: true,
213
+ gcTime: 0,
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Internal function to create Local-only sync configuration with transaction confirmation
219
+ *
220
+ * This captures the sync functions and provides synchronous confirmation of operations.
221
+ * It creates a loopback sync that immediately confirms all optimistic operations,
222
+ * making them permanent in the collection.
223
+ *
224
+ * @param initialData - Optional array of initial items to populate the collection
225
+ * @returns Object with sync configuration and confirmOperationsSync function
226
+ */
227
+ function createLocalOnlySync<T extends object, TKey extends string | number>(
228
+ initialData?: Array<T>
229
+ ) {
230
+ // Capture sync functions for transaction confirmation
231
+ let syncBegin: (() => void) | null = null
232
+ let syncWrite: ((message: { type: OperationType; value: T }) => void) | null =
233
+ null
234
+ let syncCommit: (() => void) | null = null
235
+
236
+ const sync: SyncConfig<T, TKey> = {
237
+ /**
238
+ * Sync function that captures sync parameters and applies initial data
239
+ * @param params - Sync parameters containing begin, write, and commit functions
240
+ * @returns Unsubscribe function (empty since no ongoing sync is needed)
241
+ */
242
+ sync: (params) => {
243
+ const { begin, write, commit } = params
244
+
245
+ // Capture sync functions for later use by confirmOperationsSync
246
+ syncBegin = begin
247
+ syncWrite = write
248
+ syncCommit = commit
249
+
250
+ // Apply initial data if provided
251
+ if (initialData && initialData.length > 0) {
252
+ begin()
253
+ initialData.forEach((item) => {
254
+ write({
255
+ type: `insert`,
256
+ value: item,
257
+ })
258
+ })
259
+ commit()
260
+ }
261
+
262
+ // Return empty unsubscribe function - no ongoing sync needed
263
+ return () => {}
264
+ },
265
+ /**
266
+ * Get sync metadata - returns empty object for local-only collections
267
+ * @returns Empty metadata object
268
+ */
269
+ getSyncMetadata: () => ({}),
270
+ }
271
+
272
+ /**
273
+ * Synchronously confirms optimistic operations by immediately writing through sync
274
+ *
275
+ * This loops through transaction mutations and applies them to move from optimistic to synced state.
276
+ * It's called after user handlers to make optimistic changes permanent.
277
+ *
278
+ * @param mutations - Array of mutation objects from the transaction
279
+ */
280
+ const confirmOperationsSync = (mutations: Array<any>) => {
281
+ if (!syncBegin || !syncWrite || !syncCommit) {
282
+ return // Sync not initialized yet, which is fine
283
+ }
284
+
285
+ // Immediately write back through sync interface
286
+ syncBegin()
287
+ mutations.forEach((mutation) => {
288
+ if (syncWrite) {
289
+ syncWrite({
290
+ type: mutation.type,
291
+ value: mutation.modified,
292
+ })
293
+ }
294
+ })
295
+ syncCommit()
296
+ }
297
+
298
+ return {
299
+ sync,
300
+ confirmOperationsSync,
301
+ }
302
+ }