@firtoz/drizzle-indexeddb 0.3.0 → 0.4.0

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.
@@ -1,128 +1,12 @@
1
- // ============================================================================
2
- // Minimal IDB Interface - High-Level Async API
3
- // ============================================================================
4
- // These interfaces define a simple, high-level async API for IndexedDB operations.
5
- // This makes it easy to:
6
- // - Create mock implementations for testing
7
- // - Implement alternative backends (e.g., Chrome extension message-based IDB)
8
- // - Use with any async storage that supports similar operations
9
- // ============================================================================
10
-
11
- /**
12
- * Index information returned by getStoreIndexes
13
- */
14
- export interface IndexInfo {
15
- name: string;
16
- keyPath: string | string[];
17
- }
18
-
19
- /**
20
- * Options for creating an object store
21
- */
22
- export interface CreateStoreOptions {
23
- keyPath?: string;
24
- autoIncrement?: boolean;
25
- }
26
-
27
- /**
28
- * Options for creating an index
29
- */
30
- export interface CreateIndexOptions {
31
- unique?: boolean;
32
- }
33
-
34
- /**
35
- * Key range specification for index queries
36
- */
37
- export interface KeyRangeSpec {
38
- type: "only" | "lowerBound" | "upperBound" | "bound";
39
- value?: unknown;
40
- lower?: unknown;
41
- upper?: unknown;
42
- lowerOpen?: boolean;
43
- upperOpen?: boolean;
44
- }
45
-
46
- /**
47
- * Minimal database interface with high-level async operations.
48
- * This is the interface that custom implementations (mocks, Chrome extension proxies, etc.) need to implement.
49
- *
50
- * All operations are simple async functions - no transactions, requests, or callbacks to deal with.
51
- */
52
- export interface IDBDatabaseLike {
53
- /** Database version number */
54
- readonly version: number;
55
-
56
- // =========================================================================
57
- // Schema Operations (for migrations)
58
- // =========================================================================
59
-
60
- /** Check if a store exists */
61
- hasStore(storeName: string): boolean;
62
-
63
- /** Get list of all store names */
64
- getStoreNames(): string[];
65
-
66
- /** Create an object store (only valid during migrations) */
67
- createStore(storeName: string, options?: CreateStoreOptions): void;
68
-
69
- /** Delete an object store (only valid during migrations) */
70
- deleteStore(storeName: string): void;
71
-
72
- /** Create an index on a store (only valid during migrations) */
73
- createIndex(
74
- storeName: string,
75
- indexName: string,
76
- keyPath: string | string[],
77
- options?: CreateIndexOptions,
78
- ): void;
79
-
80
- /** Delete an index from a store (only valid during migrations) */
81
- deleteIndex(storeName: string, indexName: string): void;
82
-
83
- /** Get all indexes for a store (for index discovery) */
84
- getStoreIndexes(storeName: string): IndexInfo[];
85
-
86
- // =========================================================================
87
- // Data Operations (all async, handle transactions internally)
88
- // =========================================================================
89
-
90
- /** Get all items from a store */
91
- getAll<T = unknown>(storeName: string): Promise<T[]>;
92
-
93
- /** Get items from a store using an index with optional key range */
94
- getAllByIndex<T = unknown>(
95
- storeName: string,
96
- indexName: string,
97
- keyRange?: KeyRangeSpec,
98
- ): Promise<T[]>;
99
-
100
- /** Get a single item by key */
101
- get<T = unknown>(storeName: string, key: IDBValidKey): Promise<T | undefined>;
102
-
103
- /** Add items to a store (batch operation) */
104
- add(storeName: string, items: unknown[]): Promise<void>;
105
-
106
- /** Update items in a store (batch operation, uses put) */
107
- put(storeName: string, items: unknown[]): Promise<void>;
108
-
109
- /** Delete items from a store by keys (batch operation) */
110
- delete(storeName: string, keys: IDBValidKey[]): Promise<void>;
111
-
112
- /** Clear all items from a store */
113
- clear(storeName: string): Promise<void>;
114
-
115
- // =========================================================================
116
- // Lifecycle
117
- // =========================================================================
118
-
119
- /** Close the database connection */
120
- close(): void;
121
- }
122
-
123
- // ============================================================================
124
- // Default Implementation (wraps native IndexedDB)
125
- // ============================================================================
1
+ import type {
2
+ IDBDatabaseLike,
3
+ IDBCreator,
4
+ IDBOpenOptions,
5
+ IndexInfo,
6
+ CreateStoreOptions,
7
+ CreateIndexOptions,
8
+ KeyRangeSpec,
9
+ } from "./idb-types";
126
10
 
127
11
  /**
128
12
  * Creates a KeyRange from a KeyRangeSpec
@@ -321,69 +205,6 @@ class NativeIDBDatabase implements IDBDatabaseLike {
321
205
  }
322
206
  }
323
207
 
324
- // ============================================================================
325
- // IDB Creator / Opener
326
- // ============================================================================
327
-
328
- /**
329
- * Options for opening a database with version upgrade support.
330
- */
331
- export interface IDBOpenOptions {
332
- /** Target version for the database. If higher than current, triggers upgrade. */
333
- version?: number;
334
- /** Called during version upgrade - this is where schema changes (createStore, createIndex) are allowed. */
335
- onUpgrade?: (db: IDBDatabaseLike) => void;
336
- }
337
-
338
- /**
339
- * Function type for creating/opening an IndexedDB-like database.
340
- * Custom implementations can use this to provide proxy/mock/alternative backends.
341
- */
342
- export type IDBCreator = (
343
- name: string,
344
- options?: IDBOpenOptions,
345
- ) => Promise<IDBDatabaseLike>;
346
-
347
- const defaultIDBCreator: IDBCreator = (
348
- name: string,
349
- options?: IDBOpenOptions,
350
- ): Promise<IDBDatabaseLike> => {
351
- return new Promise((resolve, reject) => {
352
- const request = options?.version
353
- ? indexedDB.open(name, options.version)
354
- : indexedDB.open(name);
355
-
356
- request.onerror = () => reject(request.error);
357
-
358
- request.onblocked = () => {
359
- setTimeout(() => {
360
- reject(new Error("Database upgrade blocked - close other tabs"));
361
- }, 3000);
362
- };
363
-
364
- request.onupgradeneeded = (event) => {
365
- if (options?.onUpgrade) {
366
- const db = request.result;
367
- const transaction = (event.target as IDBOpenDBRequest).transaction;
368
- if (!transaction) {
369
- reject(new Error("No transaction during upgrade"));
370
- return;
371
- }
372
- // Create an upgrade-mode database wrapper
373
- const upgradeDb = new UpgradeModeDatabase(db, transaction);
374
- try {
375
- options.onUpgrade(upgradeDb);
376
- } catch (error) {
377
- transaction.abort();
378
- reject(error);
379
- }
380
- }
381
- };
382
-
383
- request.onsuccess = () => resolve(new NativeIDBDatabase(request.result));
384
- });
385
- };
386
-
387
208
  /**
388
209
  * Upgrade-mode database wrapper used during version changes.
389
210
  * Provides IDBDatabaseLike interface with schema modification capabilities.
@@ -490,36 +311,45 @@ class UpgradeModeDatabase implements IDBDatabaseLike {
490
311
  }
491
312
  }
492
313
 
493
- export async function openIndexedDb(
314
+ /**
315
+ * Default IDB creator that uses the native IndexedDB API.
316
+ */
317
+ export const defaultIDBCreator: IDBCreator = (
494
318
  name: string,
495
- dbCreator?: IDBCreator,
496
319
  options?: IDBOpenOptions,
497
- ): Promise<IDBDatabaseLike> {
498
- const dbCreatorToUse = dbCreator ?? defaultIDBCreator;
499
- return dbCreatorToUse(name, options);
500
- }
320
+ ): Promise<IDBDatabaseLike> => {
321
+ return new Promise((resolve, reject) => {
322
+ const request = options?.version
323
+ ? indexedDB.open(name, options.version)
324
+ : indexedDB.open(name);
501
325
 
502
- // ============================================================================
503
- // IDB Deleter
504
- // ============================================================================
326
+ request.onerror = () => reject(request.error);
505
327
 
506
- export type IDBDeleter = (name: string) => Promise<void>;
328
+ request.onblocked = () => {
329
+ setTimeout(() => {
330
+ reject(new Error("Database upgrade blocked - close other tabs"));
331
+ }, 3000);
332
+ };
507
333
 
508
- const defaultIDBDeleter: IDBDeleter = (name: string): Promise<void> => {
509
- return new Promise((resolve, reject) => {
510
- const request = indexedDB.deleteDatabase(name);
511
- request.onerror = () => reject(request.error);
512
- request.onsuccess = () => resolve();
334
+ request.onupgradeneeded = (event) => {
335
+ if (options?.onUpgrade) {
336
+ const db = request.result;
337
+ const transaction = (event.target as IDBOpenDBRequest).transaction;
338
+ if (!transaction) {
339
+ reject(new Error("No transaction during upgrade"));
340
+ return;
341
+ }
342
+ // Create an upgrade-mode database wrapper
343
+ const upgradeDb = new UpgradeModeDatabase(db, transaction);
344
+ try {
345
+ options.onUpgrade(upgradeDb);
346
+ } catch (error) {
347
+ transaction.abort();
348
+ reject(error);
349
+ }
350
+ }
351
+ };
352
+
353
+ request.onsuccess = () => resolve(new NativeIDBDatabase(request.result));
513
354
  });
514
355
  };
515
-
516
- /**
517
- * Deletes the database (useful for testing)
518
- */
519
- export async function deleteIndexedDB(
520
- dbName: string,
521
- dbDeleter?: IDBDeleter,
522
- ): Promise<void> {
523
- const dbDeleterToUse = dbDeleter ?? defaultIDBDeleter;
524
- return dbDeleterToUse(dbName);
525
- }
@@ -0,0 +1,345 @@
1
+ import type {
2
+ IDBDatabaseLike,
3
+ IDBCreator,
4
+ IDBOpenOptions,
5
+ IndexInfo,
6
+ CreateStoreOptions,
7
+ CreateIndexOptions,
8
+ KeyRangeSpec,
9
+ } from "../idb-types";
10
+ import type { IDBProxyClientTransport } from "./idb-proxy-transport";
11
+ import type {
12
+ IDBProxyRequest,
13
+ IDBProxyRequestBody,
14
+ IDBProxyResponse,
15
+ IDBProxySyncMessage,
16
+ } from "./idb-proxy-types";
17
+ import { generateRequestId, generateClientId } from "./idb-proxy-types";
18
+
19
+ /**
20
+ * Handler for sync messages from the server
21
+ */
22
+ export type SyncHandler = (message: IDBProxySyncMessage) => void;
23
+
24
+ /**
25
+ * A proxy implementation of IDBDatabaseLike that sends all operations
26
+ * to a remote server via a transport layer.
27
+ *
28
+ * This is used by content scripts or other clients that don't have
29
+ * direct access to IndexedDB (e.g., in a Chrome extension context).
30
+ *
31
+ * The client manages a "connection" (session) to the server.
32
+ * The server manages the actual database lifecycle.
33
+ *
34
+ * When other clients modify data, the server broadcasts sync messages
35
+ * which this client receives and can handle via onSync().
36
+ */
37
+ export class IDBProxyClient implements IDBDatabaseLike {
38
+ private _version: number = 0;
39
+ private _storeNames: string[] = [];
40
+ private _storeIndexes: Map<string, IndexInfo[]> = new Map();
41
+ private _connected: boolean = false;
42
+ private _clientId: string;
43
+ private _syncHandlers: Set<SyncHandler> = new Set();
44
+
45
+ constructor(
46
+ private dbName: string,
47
+ private transport: IDBProxyClientTransport,
48
+ clientId?: string,
49
+ ) {
50
+ this._clientId = clientId ?? generateClientId();
51
+
52
+ // Listen for sync messages from server
53
+ this.transport.onSync((message) => {
54
+ // Only handle messages for this database
55
+ if (message.dbName === this.dbName) {
56
+ for (const handler of this._syncHandlers) {
57
+ handler(message);
58
+ }
59
+ }
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Get the unique client ID
65
+ */
66
+ get clientId(): string {
67
+ return this._clientId;
68
+ }
69
+
70
+ /**
71
+ * Register a handler for sync messages from other clients.
72
+ * Returns an unsubscribe function.
73
+ */
74
+ onSync(handler: SyncHandler): () => void {
75
+ this._syncHandlers.add(handler);
76
+ return () => {
77
+ this._syncHandlers.delete(handler);
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Connect to the server and fetch database metadata.
83
+ * The server will ensure the database is open and migrated.
84
+ */
85
+ async connect(): Promise<void> {
86
+ if (this._connected) {
87
+ return;
88
+ }
89
+
90
+ // Tell the server we want to connect to this database
91
+ const connectResponse = await this.sendRequest({ type: "connect" });
92
+
93
+ if (connectResponse.type === "error") {
94
+ throw new Error(connectResponse.error || "Failed to connect to database");
95
+ }
96
+
97
+ // Fetch metadata
98
+ const versionResponse = await this.sendRequest({ type: "getVersion" });
99
+ if (versionResponse.type === "success") {
100
+ this._version = versionResponse.data as number;
101
+ }
102
+
103
+ const storeNamesResponse = await this.sendRequest({
104
+ type: "getStoreNames",
105
+ });
106
+ if (storeNamesResponse.type === "success") {
107
+ this._storeNames = storeNamesResponse.data as string[];
108
+ }
109
+
110
+ this._connected = true;
111
+ }
112
+
113
+ /**
114
+ * Disconnect from the server.
115
+ * This is a no-op since clients are cached and reused.
116
+ * The connection stays open for future use.
117
+ */
118
+ disconnect(): void {
119
+ // Intentionally a no-op - clients are cached and reused
120
+ }
121
+
122
+ private async sendRequest(
123
+ request: IDBProxyRequestBody,
124
+ ): Promise<IDBProxyResponse> {
125
+ const fullRequest: IDBProxyRequest = {
126
+ ...request,
127
+ id: generateRequestId(),
128
+ clientId: this._clientId,
129
+ dbName: this.dbName,
130
+ };
131
+ return this.transport.sendRequest(fullRequest);
132
+ }
133
+
134
+ private handleResponse<T>(response: IDBProxyResponse): T {
135
+ if (response.type === "error") {
136
+ throw new Error(response.error || "Unknown server error");
137
+ }
138
+ return response.data as T;
139
+ }
140
+
141
+ get version(): number {
142
+ return this._version;
143
+ }
144
+
145
+ // =========================================================================
146
+ // Schema Operations - Cached locally, read-only for clients
147
+ // =========================================================================
148
+
149
+ hasStore(storeName: string): boolean {
150
+ return this._storeNames.includes(storeName);
151
+ }
152
+
153
+ getStoreNames(): string[] {
154
+ return [...this._storeNames];
155
+ }
156
+
157
+ createStore(_storeName: string, _options?: CreateStoreOptions): void {
158
+ throw new Error(
159
+ "Schema modifications not supported on proxy client. Use server-side migrations.",
160
+ );
161
+ }
162
+
163
+ deleteStore(_storeName: string): void {
164
+ throw new Error(
165
+ "Schema modifications not supported on proxy client. Use server-side migrations.",
166
+ );
167
+ }
168
+
169
+ createIndex(
170
+ _storeName: string,
171
+ _indexName: string,
172
+ _keyPath: string | string[],
173
+ _options?: CreateIndexOptions,
174
+ ): void {
175
+ throw new Error(
176
+ "Schema modifications not supported on proxy client. Use server-side migrations.",
177
+ );
178
+ }
179
+
180
+ deleteIndex(_storeName: string, _indexName: string): void {
181
+ throw new Error(
182
+ "Schema modifications not supported on proxy client. Use server-side migrations.",
183
+ );
184
+ }
185
+
186
+ getStoreIndexes(storeName: string): IndexInfo[] {
187
+ const cached = this._storeIndexes.get(storeName);
188
+ if (cached) {
189
+ return cached;
190
+ }
191
+ return [];
192
+ }
193
+
194
+ /**
195
+ * Async version to fetch indexes from server
196
+ */
197
+ async fetchStoreIndexes(storeName: string): Promise<IndexInfo[]> {
198
+ const response = await this.sendRequest({
199
+ type: "getStoreIndexes",
200
+ storeName,
201
+ });
202
+ const indexes = this.handleResponse<IndexInfo[]>(response);
203
+ this._storeIndexes.set(storeName, indexes);
204
+ return indexes;
205
+ }
206
+
207
+ // =========================================================================
208
+ // Data Operations - All proxied to server
209
+ // =========================================================================
210
+
211
+ async getAll<T = unknown>(storeName: string): Promise<T[]> {
212
+ const response = await this.sendRequest({
213
+ type: "getAll",
214
+ storeName,
215
+ });
216
+ return this.handleResponse<T[]>(response);
217
+ }
218
+
219
+ async getAllByIndex<T = unknown>(
220
+ storeName: string,
221
+ indexName: string,
222
+ keyRange?: KeyRangeSpec,
223
+ ): Promise<T[]> {
224
+ const response = await this.sendRequest({
225
+ type: "getAllByIndex",
226
+ storeName,
227
+ indexName,
228
+ keyRange,
229
+ });
230
+ return this.handleResponse<T[]>(response);
231
+ }
232
+
233
+ async get<T = unknown>(
234
+ storeName: string,
235
+ key: IDBValidKey,
236
+ ): Promise<T | undefined> {
237
+ const response = await this.sendRequest({
238
+ type: "get",
239
+ storeName,
240
+ key,
241
+ });
242
+ return this.handleResponse<T | undefined>(response);
243
+ }
244
+
245
+ async add(storeName: string, items: unknown[]): Promise<void> {
246
+ const response = await this.sendRequest({
247
+ type: "add",
248
+ storeName,
249
+ items,
250
+ });
251
+ this.handleResponse<void>(response);
252
+ }
253
+
254
+ async put(storeName: string, items: unknown[]): Promise<void> {
255
+ const response = await this.sendRequest({
256
+ type: "put",
257
+ storeName,
258
+ items,
259
+ });
260
+ this.handleResponse<void>(response);
261
+ }
262
+
263
+ async delete(storeName: string, keys: IDBValidKey[]): Promise<void> {
264
+ const response = await this.sendRequest({
265
+ type: "delete",
266
+ storeName,
267
+ keys,
268
+ });
269
+ this.handleResponse<void>(response);
270
+ }
271
+
272
+ async clear(storeName: string): Promise<void> {
273
+ const response = await this.sendRequest({
274
+ type: "clear",
275
+ storeName,
276
+ });
277
+ this.handleResponse<void>(response);
278
+ }
279
+
280
+ /**
281
+ * Close is an alias for disconnect.
282
+ * Required by IDBDatabaseLike interface.
283
+ */
284
+ close(): void {
285
+ this.disconnect();
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Creates an IDBCreator that returns proxy clients connected to a remote server.
291
+ * Clients are cached by database name, so multiple calls return the same client.
292
+ *
293
+ * @param transport The transport to use for communication
294
+ * @param onSync Optional handler called when any sync message is received
295
+ *
296
+ * @example
297
+ * const dbCreator = createProxyDbCreator(transport, (msg) => {
298
+ * console.log('Sync:', msg.type, msg.storeName);
299
+ * });
300
+ *
301
+ * <DrizzleIndexedDBProvider dbCreator={dbCreator} ... />
302
+ */
303
+ export function createProxyDbCreator(
304
+ transport: IDBProxyClientTransport,
305
+ onSync?: SyncHandler,
306
+ ): IDBCreator {
307
+ // Cache clients by database name - React may call dbCreator multiple times
308
+ const clientCache = new Map<string, IDBProxyClient>();
309
+ const connectingCache = new Map<string, Promise<IDBProxyClient>>();
310
+
311
+ return async (
312
+ name: string,
313
+ _options?: IDBOpenOptions,
314
+ ): Promise<IDBDatabaseLike> => {
315
+ // Return cached client if already connected
316
+ const cached = clientCache.get(name);
317
+ if (cached) {
318
+ return cached;
319
+ }
320
+
321
+ // If currently connecting, wait for that connection
322
+ const connecting = connectingCache.get(name);
323
+ if (connecting) {
324
+ return connecting;
325
+ }
326
+
327
+ // Create new client and connect
328
+ const connectPromise = (async () => {
329
+ const proxy = new IDBProxyClient(name, transport);
330
+
331
+ // Register sync handler if provided
332
+ if (onSync) {
333
+ proxy.onSync(onSync);
334
+ }
335
+
336
+ await proxy.connect();
337
+ clientCache.set(name, proxy);
338
+ connectingCache.delete(name);
339
+ return proxy;
340
+ })();
341
+
342
+ connectingCache.set(name, connectPromise);
343
+ return connectPromise;
344
+ };
345
+ }