@firtoz/drizzle-indexeddb 0.4.0 → 0.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @firtoz/drizzle-indexeddb
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`904019f`](https://github.com/firtoz/fullstack-toolkit/commit/904019f4d04bc02521206fbe0feaeecb67e38f87) Thanks [@firtoz](https://github.com/firtoz)! - Fix tsx exporting
8
+
3
9
  ## 0.4.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firtoz/drizzle-indexeddb",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "IndexedDB migrations powered by Drizzle ORM",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -24,10 +24,16 @@
24
24
  "types": "./src/*.ts",
25
25
  "import": "./src/*.ts",
26
26
  "require": "./src/*.ts"
27
+ },
28
+ "./*.tsx": {
29
+ "types": "./src/*.tsx",
30
+ "import": "./src/*.tsx",
31
+ "require": "./src/*.tsx"
27
32
  }
28
33
  },
29
34
  "files": [
30
35
  "src/**/*.ts",
36
+ "src/**/*.tsx",
31
37
  "!src/**/*.test.ts",
32
38
  "README.md",
33
39
  "CHANGELOG.md"
@@ -65,13 +71,13 @@
65
71
  },
66
72
  "dependencies": {
67
73
  "@firtoz/drizzle-utils": "^0.3.0",
68
- "@tanstack/db": "^0.5.10",
69
- "drizzle-orm": "^0.44.7",
74
+ "@tanstack/db": "^0.5.11",
75
+ "drizzle-orm": "^0.45.0",
70
76
  "drizzle-valibot": "^0.4.2",
71
77
  "valibot": "^1.2.0"
72
78
  },
73
79
  "peerDependencies": {
74
- "react": "^19.2.0"
80
+ "react": "^19.2.1"
75
81
  },
76
82
  "devDependencies": {
77
83
  "@types/react": "^19.2.7"
@@ -0,0 +1,374 @@
1
+ import type { PropsWithChildren } from "react";
2
+ import {
3
+ createContext,
4
+ useMemo,
5
+ useCallback,
6
+ useEffect,
7
+ useState,
8
+ useRef,
9
+ } from "react";
10
+ import {
11
+ createCollection,
12
+ type InferSchemaInput,
13
+ type UtilsRecord,
14
+ type Collection,
15
+ type InferSchemaOutput,
16
+ type SyncMode,
17
+ } from "@tanstack/db";
18
+ import { getTableName, type Table } from "drizzle-orm";
19
+ import {
20
+ indexedDBCollectionOptions,
21
+ type IndexedDBCollectionConfig,
22
+ } from "@firtoz/drizzle-indexeddb";
23
+ import type {
24
+ IdOf,
25
+ InsertSchema,
26
+ SelectSchema,
27
+ GetTableFromSchema,
28
+ InferCollectionFromTable,
29
+ ExternalSyncHandler,
30
+ } from "@firtoz/drizzle-utils";
31
+ import {
32
+ type Migration,
33
+ migrateIndexedDBWithFunctions,
34
+ } from "../function-migrator";
35
+ import type { IDBCreator, IDBDatabaseLike } from "../idb-types";
36
+ import { openIndexedDb } from "../idb-operations";
37
+ import type { IDBProxySyncMessage } from "../proxy/idb-proxy-types";
38
+
39
+ interface CollectionCacheEntry {
40
+ // biome-ignore lint/suspicious/noExplicitAny: Cache needs to store collections of various types
41
+ collection: Collection<any, string>;
42
+ refCount: number;
43
+ // biome-ignore lint/suspicious/noExplicitAny: External sync needs to accept any item type
44
+ pushExternalSync: ExternalSyncHandler<any>;
45
+ }
46
+
47
+ // Type for migration functions (generated by Drizzle)
48
+
49
+ type IndexedDbCollection<
50
+ TSchema extends Record<string, unknown>,
51
+ TTableName extends keyof TSchema & string,
52
+ > = Collection<
53
+ InferSchemaOutput<SelectSchema<GetTableFromSchema<TSchema, TTableName>>>,
54
+ IdOf<GetTableFromSchema<TSchema, TTableName>>,
55
+ UtilsRecord,
56
+ SelectSchema<GetTableFromSchema<TSchema, TTableName>>,
57
+ InferSchemaInput<InsertSchema<GetTableFromSchema<TSchema, TTableName>>>
58
+ >;
59
+
60
+ export type DrizzleIndexedDBContextValue<
61
+ TSchema extends Record<string, unknown>,
62
+ > = {
63
+ indexedDB: IDBDatabaseLike | null;
64
+ getCollection: <TTableName extends keyof TSchema & string>(
65
+ tableName: TTableName,
66
+ ) => IndexedDbCollection<TSchema, TTableName>;
67
+ incrementRefCount: (tableName: string) => void;
68
+ decrementRefCount: (tableName: string) => void;
69
+ /**
70
+ * Handle a sync message from a proxy server.
71
+ * Routes the message to the appropriate collection's external sync handler.
72
+ */
73
+ handleProxySync: (message: IDBProxySyncMessage) => void;
74
+ };
75
+
76
+ export const DrizzleIndexedDBContext =
77
+ // biome-ignore lint/suspicious/noExplicitAny: Context needs to accept any schema type
78
+ createContext<DrizzleIndexedDBContextValue<any> | null>(null);
79
+
80
+ type DrizzleIndexedDBProviderProps<TSchema extends Record<string, unknown>> =
81
+ PropsWithChildren<{
82
+ dbName: string;
83
+ schema: TSchema;
84
+ migrations?: Migration[];
85
+ migrateFunction?: (
86
+ dbName: string,
87
+ migrations: Migration[],
88
+ debug?: boolean,
89
+ dbCreator?: IDBCreator,
90
+ ) => Promise<IDBDatabaseLike>;
91
+ debug?: boolean;
92
+ syncMode?: SyncMode;
93
+ /**
94
+ * Optional custom database creator for testing/mocking.
95
+ * Use createInstrumentedDbCreator() to track IndexedDB operations.
96
+ */
97
+ dbCreator?: IDBCreator;
98
+ /**
99
+ * Called when the sync handler is ready.
100
+ * Use this to connect proxy sync messages to the provider.
101
+ *
102
+ * @example
103
+ * const proxyClient = ...;
104
+ * <DrizzleIndexedDBProvider
105
+ * onSyncReady={(handleSync) => proxyClient.onSync(handleSync)}
106
+ * ...
107
+ * />
108
+ */
109
+ onSyncReady?: (handleSync: (message: IDBProxySyncMessage) => void) => void;
110
+ }>;
111
+
112
+ export function DrizzleIndexedDBProvider<
113
+ TSchema extends Record<string, unknown>,
114
+ >({
115
+ children,
116
+ dbName,
117
+ schema,
118
+ migrations = [],
119
+ migrateFunction = migrateIndexedDBWithFunctions,
120
+ debug = false,
121
+ syncMode = "eager",
122
+ dbCreator,
123
+ onSyncReady,
124
+ }: DrizzleIndexedDBProviderProps<TSchema>) {
125
+ const [indexedDB, setIndexedDB] = useState<IDBDatabaseLike | null>(null);
126
+ const indexedDBRef = useRef<IDBDatabaseLike | null>(null);
127
+ const [readyPromise] = useState(() => {
128
+ let resolveReady: () => void;
129
+ const promise = new Promise<void>((resolve) => {
130
+ resolveReady = resolve;
131
+ });
132
+ // biome-ignore lint/style/noNonNullAssertion: resolveReady is guaranteed to be set
133
+ return { promise, resolve: resolveReady! };
134
+ });
135
+
136
+ useEffect(() => {
137
+ const initDB = async () => {
138
+ try {
139
+ let db: IDBDatabaseLike;
140
+
141
+ if (migrations.length === 0) {
142
+ // Open database directly without migration logic
143
+ db = await openIndexedDb(dbName, dbCreator);
144
+ } else {
145
+ db = await migrateFunction(dbName, migrations, debug, dbCreator);
146
+ }
147
+
148
+ indexedDBRef.current = db;
149
+ setIndexedDB(db);
150
+ readyPromise.resolve();
151
+ } catch (error) {
152
+ console.error(
153
+ `[DrizzleIndexedDBProvider] Failed to initialize database ${dbName}:`,
154
+ error,
155
+ );
156
+ throw error;
157
+ }
158
+ };
159
+
160
+ initDB();
161
+
162
+ // Cleanup on unmount
163
+ return () => {
164
+ if (indexedDB) {
165
+ indexedDB.close();
166
+ }
167
+ };
168
+ }, [dbName, migrations, migrateFunction, debug, readyPromise]);
169
+
170
+ // Collection cache with ref counting
171
+ const collections = useMemo<Map<string, CollectionCacheEntry>>(
172
+ () => new Map(),
173
+ [],
174
+ );
175
+
176
+ const getCollection = useCallback<
177
+ DrizzleIndexedDBContextValue<TSchema>["getCollection"]
178
+ >(
179
+ <TTableName extends keyof TSchema & string>(tableName: TTableName) => {
180
+ const cacheKey = tableName;
181
+
182
+ // Check if collection already exists in cache
183
+ if (!collections.has(cacheKey)) {
184
+ // Get the table definition from schema
185
+ const table = schema[tableName] as Table;
186
+
187
+ if (!table) {
188
+ throw new Error(
189
+ `Table "${tableName}" not found in schema. Available tables: ${Object.keys(schema).join(", ")}`,
190
+ );
191
+ }
192
+
193
+ // Extract the actual store/table name from the table definition
194
+ const actualTableName = getTableName(table);
195
+
196
+ // Create collection options
197
+ const collectionConfig = indexedDBCollectionOptions({
198
+ indexedDBRef,
199
+ dbName,
200
+ table,
201
+ storeName: actualTableName,
202
+ readyPromise: readyPromise.promise,
203
+ debug,
204
+ syncMode,
205
+ } as IndexedDBCollectionConfig<Table>);
206
+
207
+ // Create new collection and cache it with ref count 0
208
+ // The collection will wait for readyPromise before accessing the database
209
+ const collection = createCollection(collectionConfig);
210
+
211
+ collections.set(cacheKey, {
212
+ collection,
213
+ refCount: 0,
214
+ pushExternalSync: collectionConfig.utils.pushExternalSync,
215
+ });
216
+ }
217
+
218
+ // biome-ignore lint/style/noNonNullAssertion: We just ensured the collection exists
219
+ return collections.get(cacheKey)!
220
+ .collection as unknown as IndexedDbCollection<TSchema, TTableName>;
221
+ },
222
+ [
223
+ indexedDBRef,
224
+ collections,
225
+ schema,
226
+ readyPromise.promise,
227
+ debug,
228
+ dbName,
229
+ syncMode,
230
+ ],
231
+ );
232
+
233
+ const incrementRefCount: DrizzleIndexedDBContextValue<TSchema>["incrementRefCount"] =
234
+ useCallback(
235
+ (tableName: string) => {
236
+ const entry = collections.get(tableName);
237
+ if (entry) {
238
+ entry.refCount++;
239
+ }
240
+ },
241
+ [collections],
242
+ );
243
+
244
+ const decrementRefCount: DrizzleIndexedDBContextValue<TSchema>["decrementRefCount"] =
245
+ useCallback(
246
+ (tableName: string) => {
247
+ const entry = collections.get(tableName);
248
+ if (entry) {
249
+ entry.refCount--;
250
+
251
+ // If ref count reaches 0, remove from cache
252
+ if (entry.refCount <= 0) {
253
+ collections.delete(tableName);
254
+ }
255
+ }
256
+ },
257
+ [collections],
258
+ );
259
+
260
+ // Handle proxy sync messages by routing to the appropriate collection
261
+ const handleProxySync: DrizzleIndexedDBContextValue<TSchema>["handleProxySync"] =
262
+ useCallback(
263
+ (message: IDBProxySyncMessage) => {
264
+ // Find the collection for this store by checking the schema
265
+ for (const [tableName, table] of Object.entries(schema)) {
266
+ const actualStoreName = getTableName(table as Table);
267
+ if (actualStoreName === message.storeName) {
268
+ const entry = collections.get(tableName);
269
+ if (entry?.pushExternalSync) {
270
+ // Route sync message to collection
271
+ switch (message.type) {
272
+ case "sync:add":
273
+ entry.pushExternalSync({
274
+ type: "insert",
275
+ items: message.items,
276
+ });
277
+ break;
278
+ case "sync:put":
279
+ entry.pushExternalSync({
280
+ type: "update",
281
+ items: message.items,
282
+ });
283
+ break;
284
+ case "sync:delete":
285
+ // For delete, construct items with id
286
+ entry.pushExternalSync({
287
+ type: "delete",
288
+ items: message.keys.map((key) => ({ id: key })),
289
+ });
290
+ break;
291
+ case "sync:clear":
292
+ entry.pushExternalSync({
293
+ type: "truncate",
294
+ });
295
+ break;
296
+ }
297
+ }
298
+ return;
299
+ }
300
+ }
301
+
302
+ if (debug) {
303
+ console.warn(
304
+ `[DrizzleIndexedDBProvider] No collection found for store: ${message.storeName}`,
305
+ );
306
+ }
307
+ },
308
+ [collections, schema, debug],
309
+ );
310
+
311
+ // Call onSyncReady when handleProxySync is available
312
+ useEffect(() => {
313
+ if (onSyncReady) {
314
+ onSyncReady(handleProxySync);
315
+ }
316
+ }, [onSyncReady, handleProxySync]);
317
+
318
+ const contextValue: DrizzleIndexedDBContextValue<TSchema> = useMemo(
319
+ () => ({
320
+ indexedDB,
321
+ getCollection,
322
+ incrementRefCount,
323
+ decrementRefCount,
324
+ handleProxySync,
325
+ }),
326
+ [
327
+ indexedDB,
328
+ getCollection,
329
+ incrementRefCount,
330
+ decrementRefCount,
331
+ handleProxySync,
332
+ ],
333
+ );
334
+
335
+ return (
336
+ <DrizzleIndexedDBContext.Provider value={contextValue}>
337
+ {children}
338
+ </DrizzleIndexedDBContext.Provider>
339
+ );
340
+ }
341
+
342
+ // Hook that components use to get a collection with automatic ref counting
343
+ export function useIndexedDBCollection<
344
+ TSchema extends Record<string, unknown>,
345
+ TTableName extends keyof TSchema & string,
346
+ >(
347
+ context: DrizzleIndexedDBContextValue<TSchema>,
348
+ tableName: TTableName,
349
+ ): InferCollectionFromTable<GetTableFromSchema<TSchema, TTableName>> {
350
+ const { collection, unsubscribe } = useMemo(() => {
351
+ // Get the collection and increment ref count
352
+ const col = context.getCollection(tableName);
353
+ context.incrementRefCount(tableName);
354
+
355
+ // Return collection and unsubscribe function
356
+ return {
357
+ collection: col,
358
+ unsubscribe: () => {
359
+ context.decrementRefCount(tableName);
360
+ },
361
+ };
362
+ }, [context, tableName]);
363
+
364
+ // Cleanup on unmount
365
+ useEffect(() => {
366
+ return () => {
367
+ unsubscribe();
368
+ };
369
+ }, [unsubscribe]);
370
+
371
+ return collection as unknown as InferCollectionFromTable<
372
+ GetTableFromSchema<TSchema, TTableName>
373
+ >;
374
+ }