@firtoz/drizzle-indexeddb 1.0.0 → 2.0.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.
- package/CHANGELOG.md +13 -0
- package/package.json +7 -6
- package/src/collections/drizzle-indexeddb-collection.ts +310 -0
- package/src/context/DrizzleIndexedDBProvider.tsx +4 -4
- package/src/idb-types.ts +2 -12
- package/src/index.ts +4 -5
- package/src/instrumented-idb-database.ts +1 -1
- package/src/native-idb-database.ts +1 -1
- package/src/standalone-collection.ts +5 -5
- package/src/collections/indexeddb-collection.ts +0 -590
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @firtoz/drizzle-indexeddb
|
|
2
2
|
|
|
3
|
+
## 2.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- [`5c667ec`](https://github.com/firtoz/fullstack-toolkit/commit/5c667ecfce1ed4f22ccf9686ad37f00e7a4ecee3) Thanks [@firtoz](https://github.com/firtoz)! - BREAKING: Renamed Drizzle-specific exports with `Drizzle` prefix for clarity. `indexedDBCollectionOptions` → `drizzleIndexedDBCollectionOptions`, `IndexedDBCollectionConfig` → `DrizzleIndexedDBCollectionConfig`, `IndexedDBSyncItem` → `DrizzleIndexedDBSyncItem`. Removed `KeyRangeSpec` re-export (import from `@firtoz/idb-collections` instead). `tryExtractIndexedQuery` moved to `@firtoz/idb-collections`.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [[`5c667ec`](https://github.com/firtoz/fullstack-toolkit/commit/5c667ecfce1ed4f22ccf9686ad37f00e7a4ecee3), [`5c667ec`](https://github.com/firtoz/fullstack-toolkit/commit/5c667ecfce1ed4f22ccf9686ad37f00e7a4ecee3), [`5c667ec`](https://github.com/firtoz/fullstack-toolkit/commit/5c667ecfce1ed4f22ccf9686ad37f00e7a4ecee3), [`5c667ec`](https://github.com/firtoz/fullstack-toolkit/commit/5c667ecfce1ed4f22ccf9686ad37f00e7a4ecee3)]:
|
|
12
|
+
- @firtoz/idb-collections@0.2.0
|
|
13
|
+
- @firtoz/db-helpers@2.0.0
|
|
14
|
+
- @firtoz/drizzle-utils@1.0.1
|
|
15
|
+
|
|
3
16
|
## 1.0.0
|
|
4
17
|
|
|
5
18
|
### Major Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/drizzle-indexeddb",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "IndexedDB migrations powered by Drizzle ORM",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -68,16 +68,16 @@
|
|
|
68
68
|
"access": "public"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
|
-
"@firtoz/drizzle-utils": ">=1.0.
|
|
72
|
-
"@tanstack/db": ">=0.5.
|
|
71
|
+
"@firtoz/drizzle-utils": ">=1.0.1",
|
|
72
|
+
"@tanstack/db": ">=0.5.33",
|
|
73
73
|
"drizzle-orm": ">=0.45.1",
|
|
74
74
|
"drizzle-valibot": ">=0.4.0",
|
|
75
75
|
"react": ">=19.2.4",
|
|
76
76
|
"valibot": ">=1.0.0"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
|
-
"@firtoz/drizzle-utils": "^1.0.
|
|
80
|
-
"@tanstack/db": "^0.5.
|
|
79
|
+
"@firtoz/drizzle-utils": "^1.0.1",
|
|
80
|
+
"@tanstack/db": "^0.5.33",
|
|
81
81
|
"@types/react": "^19.2.14",
|
|
82
82
|
"drizzle-orm": "^0.45.1",
|
|
83
83
|
"drizzle-valibot": "^0.4.2",
|
|
@@ -85,7 +85,8 @@
|
|
|
85
85
|
"valibot": "^1.2.0"
|
|
86
86
|
},
|
|
87
87
|
"dependencies": {
|
|
88
|
-
"@firtoz/db-helpers": "^
|
|
88
|
+
"@firtoz/db-helpers": "^2.0.0",
|
|
89
|
+
"@firtoz/idb-collections": "^0.2.0",
|
|
89
90
|
"@firtoz/maybe-error": "^1.5.2",
|
|
90
91
|
"citty": "^0.2.1"
|
|
91
92
|
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import type { InferSchemaOutput, SyncMode } from "@tanstack/db";
|
|
2
|
+
import type { IR } from "@tanstack/db";
|
|
3
|
+
import { parseOrderByExpression } from "@tanstack/db";
|
|
4
|
+
import type { Table } from "drizzle-orm";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type IdOf,
|
|
8
|
+
type SelectSchema,
|
|
9
|
+
type BaseSyncConfig,
|
|
10
|
+
type SyncBackend,
|
|
11
|
+
createSyncFunction,
|
|
12
|
+
createInsertSchemaWithDefaults,
|
|
13
|
+
createGetKeyFunction,
|
|
14
|
+
createCollectionConfig,
|
|
15
|
+
} from "@firtoz/drizzle-utils";
|
|
16
|
+
import { evaluateExpression } from "@firtoz/db-helpers";
|
|
17
|
+
import { tryExtractIndexedQuery } from "@firtoz/idb-collections";
|
|
18
|
+
|
|
19
|
+
import type { IDBDatabaseLike } from "../idb-types";
|
|
20
|
+
|
|
21
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional
|
|
22
|
+
type AnyId = IdOf<any>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Type for items stored in IndexedDB (must have required sync fields)
|
|
26
|
+
*/
|
|
27
|
+
export type DrizzleIndexedDBSyncItem = {
|
|
28
|
+
id: AnyId;
|
|
29
|
+
createdAt: Date;
|
|
30
|
+
updatedAt: Date;
|
|
31
|
+
deletedAt: Date | null;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface DrizzleIndexedDBCollectionConfig<TTable extends Table> {
|
|
36
|
+
/**
|
|
37
|
+
* Ref to the IndexedDB database instance
|
|
38
|
+
*/
|
|
39
|
+
indexedDBRef: React.RefObject<IDBDatabaseLike | null>;
|
|
40
|
+
/**
|
|
41
|
+
* The Drizzle table definition (used for schema and type inference only)
|
|
42
|
+
*/
|
|
43
|
+
table: TTable;
|
|
44
|
+
/**
|
|
45
|
+
* The name of the IndexedDB object store (should match the table name)
|
|
46
|
+
*/
|
|
47
|
+
storeName: string;
|
|
48
|
+
/**
|
|
49
|
+
* Promise that resolves when the database is ready
|
|
50
|
+
*/
|
|
51
|
+
readyPromise: Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Sync mode: 'eager' (immediate) or 'on-demand'
|
|
54
|
+
*/
|
|
55
|
+
syncMode?: SyncMode;
|
|
56
|
+
/**
|
|
57
|
+
* Enable debug logging for index discovery and query optimization
|
|
58
|
+
*/
|
|
59
|
+
debug?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Auto-discovers indexes from the IndexedDB store.
|
|
64
|
+
* Returns a map of field names to index names for single-column indexes.
|
|
65
|
+
*/
|
|
66
|
+
function discoverIndexes(
|
|
67
|
+
db: IDBDatabaseLike,
|
|
68
|
+
storeName: string,
|
|
69
|
+
): Record<string, string> {
|
|
70
|
+
const indexes = db.getStoreIndexes(storeName);
|
|
71
|
+
const indexMap: Record<string, string> = {};
|
|
72
|
+
|
|
73
|
+
for (const index of indexes) {
|
|
74
|
+
if (typeof index.keyPath === "string") {
|
|
75
|
+
indexMap[index.keyPath] = index.name;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return indexMap;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Creates a TanStack DB collection config for IndexedDB backed by Drizzle ORM.
|
|
84
|
+
*/
|
|
85
|
+
export function drizzleIndexedDBCollectionOptions<const TTable extends Table>(
|
|
86
|
+
config: DrizzleIndexedDBCollectionConfig<TTable>,
|
|
87
|
+
) {
|
|
88
|
+
let discoveredIndexes: Record<string, string> = {};
|
|
89
|
+
let indexesDiscovered = false;
|
|
90
|
+
|
|
91
|
+
const table = config.table;
|
|
92
|
+
|
|
93
|
+
const discoverIndexesOnce = async () => {
|
|
94
|
+
await config.readyPromise;
|
|
95
|
+
|
|
96
|
+
const db = config.indexedDBRef.current;
|
|
97
|
+
if (!db) {
|
|
98
|
+
throw new Error("Database not ready");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!indexesDiscovered) {
|
|
102
|
+
discoveredIndexes = discoverIndexes(db, config.storeName);
|
|
103
|
+
|
|
104
|
+
indexesDiscovered = true;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const backend: SyncBackend<TTable> = {
|
|
109
|
+
initialLoad: async () => {
|
|
110
|
+
const db = config.indexedDBRef.current;
|
|
111
|
+
if (!db) {
|
|
112
|
+
throw new Error("Database not ready");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await discoverIndexesOnce();
|
|
116
|
+
|
|
117
|
+
const items = await db.getAll<DrizzleIndexedDBSyncItem>(config.storeName);
|
|
118
|
+
|
|
119
|
+
return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
|
|
120
|
+
},
|
|
121
|
+
loadSubset: async (options) => {
|
|
122
|
+
const db = config.indexedDBRef.current;
|
|
123
|
+
if (!db) {
|
|
124
|
+
throw new Error("Database not ready");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!indexesDiscovered) {
|
|
128
|
+
discoveredIndexes = discoverIndexes(db, config.storeName);
|
|
129
|
+
indexesDiscovered = true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let items: DrizzleIndexedDBSyncItem[];
|
|
133
|
+
|
|
134
|
+
let combinedWhere = options.where;
|
|
135
|
+
if (options.cursor?.whereFrom) {
|
|
136
|
+
if (combinedWhere) {
|
|
137
|
+
combinedWhere = {
|
|
138
|
+
type: "func",
|
|
139
|
+
name: "and",
|
|
140
|
+
args: [combinedWhere, options.cursor.whereFrom],
|
|
141
|
+
} as IR.Func;
|
|
142
|
+
} else {
|
|
143
|
+
combinedWhere = options.cursor.whereFrom;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const indexedQuery = combinedWhere
|
|
148
|
+
? tryExtractIndexedQuery(combinedWhere, discoveredIndexes, config.debug)
|
|
149
|
+
: null;
|
|
150
|
+
|
|
151
|
+
if (indexedQuery) {
|
|
152
|
+
items = await db.getAllByIndex<DrizzleIndexedDBSyncItem>(
|
|
153
|
+
config.storeName,
|
|
154
|
+
indexedQuery.indexName,
|
|
155
|
+
indexedQuery.keyRange,
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
items = await db.getAll<DrizzleIndexedDBSyncItem>(config.storeName);
|
|
159
|
+
|
|
160
|
+
if (combinedWhere) {
|
|
161
|
+
const whereExpression = combinedWhere;
|
|
162
|
+
items = items.filter((item) =>
|
|
163
|
+
evaluateExpression(
|
|
164
|
+
whereExpression,
|
|
165
|
+
item as Record<string, unknown>,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (options.orderBy) {
|
|
172
|
+
const sorts = parseOrderByExpression(options.orderBy);
|
|
173
|
+
items.sort((a, b) => {
|
|
174
|
+
for (const sort of sorts) {
|
|
175
|
+
// biome-ignore lint/suspicious/noExplicitAny: Need any for dynamic field access
|
|
176
|
+
let aValue: any = a;
|
|
177
|
+
// biome-ignore lint/suspicious/noExplicitAny: Need any for dynamic field access
|
|
178
|
+
let bValue: any = b;
|
|
179
|
+
for (const fieldName of sort.field) {
|
|
180
|
+
aValue = aValue?.[fieldName];
|
|
181
|
+
bValue = bValue?.[fieldName];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (aValue < bValue) {
|
|
185
|
+
return sort.direction === "asc" ? -1 : 1;
|
|
186
|
+
}
|
|
187
|
+
if (aValue > bValue) {
|
|
188
|
+
return sort.direction === "asc" ? 1 : -1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return 0;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (options.offset !== undefined && options.offset > 0) {
|
|
196
|
+
items = items.slice(options.offset);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (options.limit !== undefined) {
|
|
200
|
+
items = items.slice(0, options.limit);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
handleInsert: async (itemsToInsert) => {
|
|
207
|
+
const db = config.indexedDBRef.current;
|
|
208
|
+
if (!db) {
|
|
209
|
+
throw new Error("Database not ready");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await db.add(config.storeName, itemsToInsert);
|
|
213
|
+
|
|
214
|
+
return itemsToInsert;
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
handleUpdate: async (mutations) => {
|
|
218
|
+
const db = config.indexedDBRef.current;
|
|
219
|
+
|
|
220
|
+
if (!db) {
|
|
221
|
+
throw new Error("Database not ready");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
|
|
225
|
+
const itemsToUpdate: DrizzleIndexedDBSyncItem[] = [];
|
|
226
|
+
|
|
227
|
+
for (const mutation of mutations) {
|
|
228
|
+
const existing = await db.get<DrizzleIndexedDBSyncItem>(
|
|
229
|
+
config.storeName,
|
|
230
|
+
mutation.key,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
if (existing) {
|
|
234
|
+
const updateTime = new Date();
|
|
235
|
+
const updatedItem = {
|
|
236
|
+
...existing,
|
|
237
|
+
...mutation.changes,
|
|
238
|
+
updatedAt: updateTime,
|
|
239
|
+
} as DrizzleIndexedDBSyncItem;
|
|
240
|
+
|
|
241
|
+
itemsToUpdate.push(updatedItem);
|
|
242
|
+
results.push(
|
|
243
|
+
updatedItem as unknown as InferSchemaOutput<SelectSchema<TTable>>,
|
|
244
|
+
);
|
|
245
|
+
} else {
|
|
246
|
+
results.push(mutation.original);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (itemsToUpdate.length > 0) {
|
|
251
|
+
await db.put(config.storeName, itemsToUpdate);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return results;
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
handleDelete: async (mutations) => {
|
|
258
|
+
const db = config.indexedDBRef.current;
|
|
259
|
+
|
|
260
|
+
if (!db) {
|
|
261
|
+
throw new Error("Database not ready");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const keysToDelete: IDBValidKey[] = mutations.map((m) => m.key);
|
|
265
|
+
|
|
266
|
+
await db.delete(config.storeName, keysToDelete);
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
handleTruncate: async () => {
|
|
270
|
+
const db = config.indexedDBRef.current;
|
|
271
|
+
|
|
272
|
+
if (!db) {
|
|
273
|
+
throw new Error("Database not ready");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await db.clear(config.storeName);
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const wrappedBackend: SyncBackend<TTable> = {
|
|
281
|
+
...backend,
|
|
282
|
+
initialLoad: async () => {
|
|
283
|
+
if (config.syncMode === "eager" || !config.syncMode) {
|
|
284
|
+
return await backend.initialLoad();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await discoverIndexesOnce();
|
|
288
|
+
|
|
289
|
+
return [];
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const baseSyncConfig: BaseSyncConfig<TTable> = {
|
|
294
|
+
table,
|
|
295
|
+
readyPromise: config.readyPromise,
|
|
296
|
+
syncMode: config.syncMode,
|
|
297
|
+
debug: config.debug,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const syncResult = createSyncFunction(baseSyncConfig, wrappedBackend);
|
|
301
|
+
|
|
302
|
+
const schema = createInsertSchemaWithDefaults(table);
|
|
303
|
+
|
|
304
|
+
return createCollectionConfig({
|
|
305
|
+
schema,
|
|
306
|
+
getKey: createGetKeyFunction<TTable>(),
|
|
307
|
+
syncResult,
|
|
308
|
+
syncMode: config.syncMode,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
} from "@tanstack/db";
|
|
17
17
|
import { getTableName, type Table } from "drizzle-orm";
|
|
18
18
|
import {
|
|
19
|
-
|
|
20
|
-
type
|
|
19
|
+
drizzleIndexedDBCollectionOptions,
|
|
20
|
+
type DrizzleIndexedDBCollectionConfig,
|
|
21
21
|
} from "@firtoz/drizzle-indexeddb";
|
|
22
22
|
import type { CollectionUtils } from "@firtoz/db-helpers";
|
|
23
23
|
import type {
|
|
@@ -173,14 +173,14 @@ export function DrizzleIndexedDBProvider<
|
|
|
173
173
|
const actualTableName = getTableName(table);
|
|
174
174
|
|
|
175
175
|
// Create collection options
|
|
176
|
-
const collectionConfig =
|
|
176
|
+
const collectionConfig = drizzleIndexedDBCollectionOptions({
|
|
177
177
|
indexedDBRef,
|
|
178
178
|
table,
|
|
179
179
|
storeName: actualTableName,
|
|
180
180
|
readyPromise: readyPromise.promise,
|
|
181
181
|
debug,
|
|
182
182
|
syncMode,
|
|
183
|
-
} as
|
|
183
|
+
} as DrizzleIndexedDBCollectionConfig<Table>);
|
|
184
184
|
|
|
185
185
|
// Create new collection and cache it with ref count 0
|
|
186
186
|
// The collection will wait for readyPromise before accessing the database
|
package/src/idb-types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { KeyRangeSpec } from "@firtoz/idb-collections";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Index information returned by getStoreIndexes
|
|
3
5
|
*/
|
|
@@ -21,18 +23,6 @@ export interface CreateIndexOptions {
|
|
|
21
23
|
unique?: boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
/**
|
|
25
|
-
* Key range specification for index queries
|
|
26
|
-
*/
|
|
27
|
-
export interface KeyRangeSpec {
|
|
28
|
-
type: "only" | "lowerBound" | "upperBound" | "bound";
|
|
29
|
-
value?: unknown;
|
|
30
|
-
lower?: unknown;
|
|
31
|
-
upper?: unknown;
|
|
32
|
-
lowerOpen?: boolean;
|
|
33
|
-
upperOpen?: boolean;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
26
|
/**
|
|
37
27
|
* Minimal database interface with high-level async operations.
|
|
38
28
|
* This is the interface that custom implementations (mocks, Chrome extension proxies, etc.) need to implement.
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,6 @@ export type {
|
|
|
17
17
|
IndexInfo,
|
|
18
18
|
CreateStoreOptions,
|
|
19
19
|
CreateIndexOptions,
|
|
20
|
-
KeyRangeSpec,
|
|
21
20
|
} from "./idb-types";
|
|
22
21
|
|
|
23
22
|
// IDB Interceptor (for testing/debugging)
|
|
@@ -34,10 +33,10 @@ export { createInstrumentedDbCreator } from "./instrumented-idb-database";
|
|
|
34
33
|
|
|
35
34
|
// Collection
|
|
36
35
|
export {
|
|
37
|
-
|
|
38
|
-
type
|
|
39
|
-
type
|
|
40
|
-
} from "./collections/indexeddb-collection";
|
|
36
|
+
drizzleIndexedDBCollectionOptions,
|
|
37
|
+
type DrizzleIndexedDBCollectionConfig,
|
|
38
|
+
type DrizzleIndexedDBSyncItem,
|
|
39
|
+
} from "./collections/drizzle-indexeddb-collection";
|
|
41
40
|
|
|
42
41
|
// Standalone Collection (for use outside React)
|
|
43
42
|
export {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { KeyRangeSpec } from "@firtoz/idb-collections";
|
|
1
2
|
import type {
|
|
2
3
|
IDBDatabaseLike,
|
|
3
4
|
IDBCreator,
|
|
@@ -5,7 +6,6 @@ import type {
|
|
|
5
6
|
IndexInfo,
|
|
6
7
|
CreateStoreOptions,
|
|
7
8
|
CreateIndexOptions,
|
|
8
|
-
KeyRangeSpec,
|
|
9
9
|
} from "./idb-types";
|
|
10
10
|
import type { IDBInterceptor } from "./idb-interceptor";
|
|
11
11
|
import { defaultIDBCreator } from "./native-idb-database";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { exhaustiveGuard } from "@firtoz/maybe-error";
|
|
2
|
+
import type { KeyRangeSpec } from "@firtoz/idb-collections";
|
|
2
3
|
import type {
|
|
3
4
|
IDBDatabaseLike,
|
|
4
5
|
IDBCreator,
|
|
@@ -6,7 +7,6 @@ import type {
|
|
|
6
7
|
IndexInfo,
|
|
7
8
|
CreateStoreOptions,
|
|
8
9
|
CreateIndexOptions,
|
|
9
|
-
KeyRangeSpec,
|
|
10
10
|
} from "./idb-types";
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -11,9 +11,9 @@ import type { Table } from "drizzle-orm";
|
|
|
11
11
|
import type { CollectionUtils } from "@firtoz/db-helpers";
|
|
12
12
|
import type { IdOf, InsertSchema, SelectSchema } from "@firtoz/drizzle-utils";
|
|
13
13
|
import {
|
|
14
|
-
|
|
15
|
-
type
|
|
16
|
-
} from "./collections/indexeddb-collection";
|
|
14
|
+
drizzleIndexedDBCollectionOptions,
|
|
15
|
+
type DrizzleIndexedDBCollectionConfig,
|
|
16
|
+
} from "./collections/drizzle-indexeddb-collection";
|
|
17
17
|
import {
|
|
18
18
|
migrateIndexedDBWithFunctions,
|
|
19
19
|
type Migration,
|
|
@@ -262,14 +262,14 @@ export function createStandaloneCollection<TTable extends Table>(
|
|
|
262
262
|
initDB();
|
|
263
263
|
|
|
264
264
|
// Create collection config
|
|
265
|
-
const collectionConfig =
|
|
265
|
+
const collectionConfig = drizzleIndexedDBCollectionOptions({
|
|
266
266
|
indexedDBRef,
|
|
267
267
|
table,
|
|
268
268
|
storeName,
|
|
269
269
|
readyPromise,
|
|
270
270
|
debug,
|
|
271
271
|
syncMode,
|
|
272
|
-
} as
|
|
272
|
+
} as DrizzleIndexedDBCollectionConfig<TTable>);
|
|
273
273
|
|
|
274
274
|
// Create the collection
|
|
275
275
|
const collection = createCollection(
|
|
@@ -1,590 +0,0 @@
|
|
|
1
|
-
import type { InferSchemaOutput, SyncMode } from "@tanstack/db";
|
|
2
|
-
import type { IR } from "@tanstack/db";
|
|
3
|
-
import { extractSimpleComparisons, parseOrderByExpression } from "@tanstack/db";
|
|
4
|
-
import type { Table } from "drizzle-orm";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
type IdOf,
|
|
8
|
-
type SelectSchema,
|
|
9
|
-
type BaseSyncConfig,
|
|
10
|
-
type SyncBackend,
|
|
11
|
-
createSyncFunction,
|
|
12
|
-
createInsertSchemaWithDefaults,
|
|
13
|
-
createGetKeyFunction,
|
|
14
|
-
createCollectionConfig,
|
|
15
|
-
} from "@firtoz/drizzle-utils";
|
|
16
|
-
|
|
17
|
-
import type { IDBDatabaseLike, KeyRangeSpec } from "../idb-types";
|
|
18
|
-
|
|
19
|
-
// biome-ignore lint/suspicious/noExplicitAny: intentional
|
|
20
|
-
type AnyId = IdOf<any>;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Type for items stored in IndexedDB (must have required sync fields)
|
|
24
|
-
*/
|
|
25
|
-
export type IndexedDBSyncItem = {
|
|
26
|
-
id: AnyId;
|
|
27
|
-
createdAt: Date;
|
|
28
|
-
updatedAt: Date;
|
|
29
|
-
deletedAt: Date | null;
|
|
30
|
-
[key: string]: unknown;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export interface IndexedDBCollectionConfig<TTable extends Table> {
|
|
34
|
-
/**
|
|
35
|
-
* Ref to the IndexedDB database instance
|
|
36
|
-
*/
|
|
37
|
-
indexedDBRef: React.RefObject<IDBDatabaseLike | null>;
|
|
38
|
-
/**
|
|
39
|
-
* The Drizzle table definition (used for schema and type inference only)
|
|
40
|
-
*/
|
|
41
|
-
table: TTable;
|
|
42
|
-
/**
|
|
43
|
-
* The name of the IndexedDB object store (should match the table name)
|
|
44
|
-
*/
|
|
45
|
-
storeName: string;
|
|
46
|
-
/**
|
|
47
|
-
* Promise that resolves when the database is ready
|
|
48
|
-
*/
|
|
49
|
-
readyPromise: Promise<void>;
|
|
50
|
-
/**
|
|
51
|
-
* Sync mode: 'eager' (immediate) or 'lazy' (on-demand)
|
|
52
|
-
*/
|
|
53
|
-
syncMode?: SyncMode;
|
|
54
|
-
/**
|
|
55
|
-
* Enable debug logging for index discovery and query optimization
|
|
56
|
-
*/
|
|
57
|
-
debug?: boolean;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Evaluates a TanStack DB IR expression against an IndexedDB item
|
|
62
|
-
* @internal Exported for testing
|
|
63
|
-
*/
|
|
64
|
-
export function evaluateExpression(
|
|
65
|
-
expression: IR.BasicExpression,
|
|
66
|
-
item: Record<string, unknown>,
|
|
67
|
-
): boolean {
|
|
68
|
-
switch (expression.type) {
|
|
69
|
-
case "ref": {
|
|
70
|
-
const propRef = expression;
|
|
71
|
-
const columnName = propRef.path[propRef.path.length - 1];
|
|
72
|
-
return item[columnName as string] !== undefined;
|
|
73
|
-
}
|
|
74
|
-
case "val": {
|
|
75
|
-
const value = expression;
|
|
76
|
-
return !!value.value;
|
|
77
|
-
}
|
|
78
|
-
case "func": {
|
|
79
|
-
const func = expression;
|
|
80
|
-
|
|
81
|
-
switch (func.name) {
|
|
82
|
-
case "eq": {
|
|
83
|
-
const left = getExpressionValue(func.args[0], item);
|
|
84
|
-
const right = getExpressionValue(func.args[1], item);
|
|
85
|
-
return left === right;
|
|
86
|
-
}
|
|
87
|
-
case "ne": {
|
|
88
|
-
const left = getExpressionValue(func.args[0], item);
|
|
89
|
-
const right = getExpressionValue(func.args[1], item);
|
|
90
|
-
return left !== right;
|
|
91
|
-
}
|
|
92
|
-
case "gt": {
|
|
93
|
-
const left = getExpressionValue(func.args[0], item);
|
|
94
|
-
const right = getExpressionValue(func.args[1], item);
|
|
95
|
-
return left > right;
|
|
96
|
-
}
|
|
97
|
-
case "gte": {
|
|
98
|
-
const left = getExpressionValue(func.args[0], item);
|
|
99
|
-
const right = getExpressionValue(func.args[1], item);
|
|
100
|
-
return left >= right;
|
|
101
|
-
}
|
|
102
|
-
case "lt": {
|
|
103
|
-
const left = getExpressionValue(func.args[0], item);
|
|
104
|
-
const right = getExpressionValue(func.args[1], item);
|
|
105
|
-
return left < right;
|
|
106
|
-
}
|
|
107
|
-
case "lte": {
|
|
108
|
-
const left = getExpressionValue(func.args[0], item);
|
|
109
|
-
const right = getExpressionValue(func.args[1], item);
|
|
110
|
-
return left <= right;
|
|
111
|
-
}
|
|
112
|
-
case "and": {
|
|
113
|
-
return func.args.every((arg) => evaluateExpression(arg, item));
|
|
114
|
-
}
|
|
115
|
-
case "or": {
|
|
116
|
-
return func.args.some((arg) => evaluateExpression(arg, item));
|
|
117
|
-
}
|
|
118
|
-
case "not": {
|
|
119
|
-
return !evaluateExpression(func.args[0], item);
|
|
120
|
-
}
|
|
121
|
-
case "isNull": {
|
|
122
|
-
const value = getExpressionValue(func.args[0], item);
|
|
123
|
-
return value === null || value === undefined;
|
|
124
|
-
}
|
|
125
|
-
case "isNotNull": {
|
|
126
|
-
const value = getExpressionValue(func.args[0], item);
|
|
127
|
-
return value !== null && value !== undefined;
|
|
128
|
-
}
|
|
129
|
-
case "like": {
|
|
130
|
-
const left = String(getExpressionValue(func.args[0], item));
|
|
131
|
-
const right = String(getExpressionValue(func.args[1], item));
|
|
132
|
-
// Convert SQL LIKE pattern to regex (case-sensitive)
|
|
133
|
-
const pattern = right.replace(/%/g, ".*").replace(/_/g, ".");
|
|
134
|
-
return new RegExp(`^${pattern}$`).test(left);
|
|
135
|
-
}
|
|
136
|
-
case "ilike": {
|
|
137
|
-
const left = String(getExpressionValue(func.args[0], item));
|
|
138
|
-
const right = String(getExpressionValue(func.args[1], item));
|
|
139
|
-
// Convert SQL ILIKE pattern to regex (case-insensitive)
|
|
140
|
-
const pattern = right.replace(/%/g, ".*").replace(/_/g, ".");
|
|
141
|
-
return new RegExp(`^${pattern}$`, "i").test(left);
|
|
142
|
-
}
|
|
143
|
-
case "in": {
|
|
144
|
-
const left = getExpressionValue(func.args[0], item);
|
|
145
|
-
const right = getExpressionValue(func.args[1], item);
|
|
146
|
-
// Check if left value is in the right array
|
|
147
|
-
return Array.isArray(right) && right.includes(left);
|
|
148
|
-
}
|
|
149
|
-
case "isUndefined": {
|
|
150
|
-
const value = getExpressionValue(func.args[0], item);
|
|
151
|
-
return value === null || value === undefined;
|
|
152
|
-
}
|
|
153
|
-
default:
|
|
154
|
-
throw new Error(`Unsupported function: ${func.name}`);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
default: {
|
|
158
|
-
const _ex: never = expression;
|
|
159
|
-
void _ex;
|
|
160
|
-
throw new Error(
|
|
161
|
-
`Unsupported expression type: ${(expression as { type: string }).type}`,
|
|
162
|
-
);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Gets the value from an IR expression
|
|
169
|
-
* @internal Exported for testing
|
|
170
|
-
*/
|
|
171
|
-
export function getExpressionValue(
|
|
172
|
-
expression: IR.BasicExpression,
|
|
173
|
-
item: Record<string, unknown>,
|
|
174
|
-
// biome-ignore lint/suspicious/noExplicitAny: We need any here for dynamic values
|
|
175
|
-
): any {
|
|
176
|
-
switch (expression.type) {
|
|
177
|
-
case "ref": {
|
|
178
|
-
const propRef = expression;
|
|
179
|
-
const columnName = propRef.path[propRef.path.length - 1];
|
|
180
|
-
return item[columnName as string];
|
|
181
|
-
}
|
|
182
|
-
case "val": {
|
|
183
|
-
const value = expression;
|
|
184
|
-
return value.value;
|
|
185
|
-
}
|
|
186
|
-
case "func":
|
|
187
|
-
throw new Error("Cannot get value from func expression");
|
|
188
|
-
default: {
|
|
189
|
-
const _ex: never = expression;
|
|
190
|
-
void _ex;
|
|
191
|
-
throw new Error(
|
|
192
|
-
`Cannot get value from expression type: ${(expression as { type: string }).type}`,
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Attempts to extract a simple indexed query from an IR expression
|
|
200
|
-
* Returns the field name and key range if the query can be optimized
|
|
201
|
-
*
|
|
202
|
-
* NOTE: IndexedDB indexes are much more limited than SQL WHERE clauses:
|
|
203
|
-
* - Only supports simple comparisons on a SINGLE indexed field
|
|
204
|
-
* - Supported operators: eq, gt, gte, lt, lte
|
|
205
|
-
* - Complex queries (AND, OR, NOT, multiple fields) fall back to in-memory filtering
|
|
206
|
-
*
|
|
207
|
-
* Indexes are auto-discovered from your Drizzle schema:
|
|
208
|
-
* - Define indexes using index().on() in your schema
|
|
209
|
-
* - Run migrations to create them in IndexedDB
|
|
210
|
-
* - This collection automatically detects and uses them
|
|
211
|
-
* @internal Exported for testing
|
|
212
|
-
*/
|
|
213
|
-
export function tryExtractIndexedQuery(
|
|
214
|
-
expression: IR.BasicExpression,
|
|
215
|
-
indexes?: Record<string, string>,
|
|
216
|
-
debug?: boolean,
|
|
217
|
-
): { fieldName: string; indexName: string; keyRange: KeyRangeSpec } | null {
|
|
218
|
-
if (!indexes) {
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
// Use TanStack DB helper to extract simple comparisons
|
|
224
|
-
const comparisons = extractSimpleComparisons(expression);
|
|
225
|
-
|
|
226
|
-
// We can only use an index for a single field
|
|
227
|
-
if (comparisons.length !== 1) {
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const comparison = comparisons[0];
|
|
232
|
-
const fieldName = comparison.field.join(".");
|
|
233
|
-
const indexName = indexes[fieldName];
|
|
234
|
-
|
|
235
|
-
if (!indexName) {
|
|
236
|
-
return null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Convert operator to key range spec
|
|
240
|
-
let keyRange: KeyRangeSpec | null = null;
|
|
241
|
-
|
|
242
|
-
switch (comparison.operator) {
|
|
243
|
-
case "eq":
|
|
244
|
-
keyRange = { type: "only", value: comparison.value };
|
|
245
|
-
break;
|
|
246
|
-
case "gt":
|
|
247
|
-
keyRange = {
|
|
248
|
-
type: "lowerBound",
|
|
249
|
-
lower: comparison.value,
|
|
250
|
-
lowerOpen: true,
|
|
251
|
-
};
|
|
252
|
-
break;
|
|
253
|
-
case "gte":
|
|
254
|
-
keyRange = {
|
|
255
|
-
type: "lowerBound",
|
|
256
|
-
lower: comparison.value,
|
|
257
|
-
lowerOpen: false,
|
|
258
|
-
};
|
|
259
|
-
break;
|
|
260
|
-
case "lt":
|
|
261
|
-
keyRange = {
|
|
262
|
-
type: "upperBound",
|
|
263
|
-
upper: comparison.value,
|
|
264
|
-
upperOpen: true,
|
|
265
|
-
};
|
|
266
|
-
break;
|
|
267
|
-
case "lte":
|
|
268
|
-
keyRange = {
|
|
269
|
-
type: "upperBound",
|
|
270
|
-
upper: comparison.value,
|
|
271
|
-
upperOpen: false,
|
|
272
|
-
};
|
|
273
|
-
break;
|
|
274
|
-
default:
|
|
275
|
-
if (debug) {
|
|
276
|
-
console.warn(
|
|
277
|
-
`Skipping indexed query extraction for unsupported operator: ${comparison.operator}`,
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (!keyRange) {
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
return { fieldName, indexName, keyRange };
|
|
288
|
-
} catch (error) {
|
|
289
|
-
console.error("Error extracting indexed query", error, expression);
|
|
290
|
-
// If extractSimpleComparisons fails, it's a complex query
|
|
291
|
-
|
|
292
|
-
return null;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Note: Low-level transaction helpers have been replaced by high-level IDBDatabaseLike methods
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Auto-discovers indexes from the IndexedDB store
|
|
300
|
-
* Returns a map of field names to index names for single-column indexes
|
|
301
|
-
*
|
|
302
|
-
* NOTE: Indexes are created automatically by Drizzle migrations based on your schema:
|
|
303
|
-
*
|
|
304
|
-
* @example
|
|
305
|
-
* // In your schema.ts:
|
|
306
|
-
* export const todoTable = syncableTable(
|
|
307
|
-
* "todo",
|
|
308
|
-
* { title: text("title"), userId: text("userId") },
|
|
309
|
-
* (t) => [
|
|
310
|
-
* index("todo_user_id_index").on(t.userId),
|
|
311
|
-
* index("todo_created_at_index").on(t.createdAt),
|
|
312
|
-
* ]
|
|
313
|
-
* );
|
|
314
|
-
*
|
|
315
|
-
* // The migrator will automatically create these indexes in IndexedDB
|
|
316
|
-
* // This collection will auto-detect and use them for optimized queries
|
|
317
|
-
*/
|
|
318
|
-
function discoverIndexes(
|
|
319
|
-
db: IDBDatabaseLike,
|
|
320
|
-
storeName: string,
|
|
321
|
-
): Record<string, string> {
|
|
322
|
-
const indexes = db.getStoreIndexes(storeName);
|
|
323
|
-
const indexMap: Record<string, string> = {};
|
|
324
|
-
|
|
325
|
-
for (const index of indexes) {
|
|
326
|
-
// Only map single-column indexes (string keyPath)
|
|
327
|
-
// Compound indexes (array keyPath) are more complex and not currently optimized
|
|
328
|
-
if (typeof index.keyPath === "string") {
|
|
329
|
-
indexMap[index.keyPath] = index.name;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
return indexMap;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Creates a TanStack DB collection config for IndexedDB
|
|
338
|
-
*/
|
|
339
|
-
export function indexedDBCollectionOptions<const TTable extends Table>(
|
|
340
|
-
config: IndexedDBCollectionConfig<TTable>,
|
|
341
|
-
) {
|
|
342
|
-
// Defer index discovery until the database is ready
|
|
343
|
-
let discoveredIndexes: Record<string, string> = {};
|
|
344
|
-
let indexesDiscovered = false;
|
|
345
|
-
|
|
346
|
-
const table = config.table;
|
|
347
|
-
|
|
348
|
-
// Discover indexes once when the database is ready
|
|
349
|
-
const discoverIndexesOnce = async () => {
|
|
350
|
-
await config.readyPromise;
|
|
351
|
-
|
|
352
|
-
const db = config.indexedDBRef.current;
|
|
353
|
-
if (!db) {
|
|
354
|
-
throw new Error("Database not ready");
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
if (!indexesDiscovered) {
|
|
358
|
-
discoveredIndexes = discoverIndexes(db, config.storeName);
|
|
359
|
-
|
|
360
|
-
indexesDiscovered = true;
|
|
361
|
-
}
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
// Create backend-specific implementation
|
|
365
|
-
const backend: SyncBackend<TTable> = {
|
|
366
|
-
initialLoad: async () => {
|
|
367
|
-
const db = config.indexedDBRef.current;
|
|
368
|
-
if (!db) {
|
|
369
|
-
throw new Error("Database not ready");
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
await discoverIndexesOnce();
|
|
373
|
-
|
|
374
|
-
const items = await db.getAll<IndexedDBSyncItem>(config.storeName);
|
|
375
|
-
|
|
376
|
-
return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
|
|
377
|
-
},
|
|
378
|
-
loadSubset: async (options) => {
|
|
379
|
-
const db = config.indexedDBRef.current;
|
|
380
|
-
if (!db) {
|
|
381
|
-
throw new Error("Database not ready");
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Ensure indexes are discovered before we try to use them
|
|
385
|
-
if (!indexesDiscovered) {
|
|
386
|
-
discoveredIndexes = discoverIndexes(db, config.storeName);
|
|
387
|
-
indexesDiscovered = true;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
let items: IndexedDBSyncItem[];
|
|
391
|
-
|
|
392
|
-
// Combine where with cursor expressions if present
|
|
393
|
-
// The cursor.whereFrom gives us rows after the cursor position
|
|
394
|
-
let combinedWhere = options.where;
|
|
395
|
-
if (options.cursor?.whereFrom) {
|
|
396
|
-
if (combinedWhere) {
|
|
397
|
-
// Combine main where with cursor expression using AND
|
|
398
|
-
combinedWhere = {
|
|
399
|
-
type: "func",
|
|
400
|
-
name: "and",
|
|
401
|
-
args: [combinedWhere, options.cursor.whereFrom],
|
|
402
|
-
} as IR.Func;
|
|
403
|
-
} else {
|
|
404
|
-
combinedWhere = options.cursor.whereFrom;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// Try to use an index for efficient querying
|
|
409
|
-
const indexedQuery = combinedWhere
|
|
410
|
-
? tryExtractIndexedQuery(combinedWhere, discoveredIndexes, config.debug)
|
|
411
|
-
: null;
|
|
412
|
-
|
|
413
|
-
if (indexedQuery) {
|
|
414
|
-
// Use indexed query for better performance
|
|
415
|
-
// Index returns exact results for single-field queries, no additional filtering needed
|
|
416
|
-
items = await db.getAllByIndex<IndexedDBSyncItem>(
|
|
417
|
-
config.storeName,
|
|
418
|
-
indexedQuery.indexName,
|
|
419
|
-
indexedQuery.keyRange,
|
|
420
|
-
);
|
|
421
|
-
} else {
|
|
422
|
-
// Fall back to getting all items
|
|
423
|
-
items = await db.getAll<IndexedDBSyncItem>(config.storeName);
|
|
424
|
-
|
|
425
|
-
// Apply combined where filter in memory
|
|
426
|
-
if (combinedWhere) {
|
|
427
|
-
const whereExpression = combinedWhere;
|
|
428
|
-
items = items.filter((item) =>
|
|
429
|
-
evaluateExpression(
|
|
430
|
-
whereExpression,
|
|
431
|
-
item as Record<string, unknown>,
|
|
432
|
-
),
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Apply orderBy
|
|
438
|
-
if (options.orderBy) {
|
|
439
|
-
const sorts = parseOrderByExpression(options.orderBy);
|
|
440
|
-
items.sort((a, b) => {
|
|
441
|
-
for (const sort of sorts) {
|
|
442
|
-
// Access nested field (though typically will be single level)
|
|
443
|
-
// biome-ignore lint/suspicious/noExplicitAny: Need any for dynamic field access
|
|
444
|
-
let aValue: any = a;
|
|
445
|
-
// biome-ignore lint/suspicious/noExplicitAny: Need any for dynamic field access
|
|
446
|
-
let bValue: any = b;
|
|
447
|
-
for (const fieldName of sort.field) {
|
|
448
|
-
aValue = aValue?.[fieldName];
|
|
449
|
-
bValue = bValue?.[fieldName];
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
if (aValue < bValue) {
|
|
453
|
-
return sort.direction === "asc" ? -1 : 1;
|
|
454
|
-
}
|
|
455
|
-
if (aValue > bValue) {
|
|
456
|
-
return sort.direction === "asc" ? 1 : -1;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
return 0;
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Apply offset (skip first N items for pagination)
|
|
464
|
-
if (options.offset !== undefined && options.offset > 0) {
|
|
465
|
-
items = items.slice(options.offset);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Apply limit
|
|
469
|
-
if (options.limit !== undefined) {
|
|
470
|
-
items = items.slice(0, options.limit);
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
return items as unknown as InferSchemaOutput<SelectSchema<TTable>>[];
|
|
474
|
-
},
|
|
475
|
-
|
|
476
|
-
handleInsert: async (itemsToInsert) => {
|
|
477
|
-
const db = config.indexedDBRef.current;
|
|
478
|
-
if (!db) {
|
|
479
|
-
throw new Error("Database not ready");
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Add all items in a single batch operation
|
|
483
|
-
await db.add(config.storeName, itemsToInsert);
|
|
484
|
-
|
|
485
|
-
return itemsToInsert;
|
|
486
|
-
},
|
|
487
|
-
|
|
488
|
-
handleUpdate: async (mutations) => {
|
|
489
|
-
const db = config.indexedDBRef.current;
|
|
490
|
-
|
|
491
|
-
if (!db) {
|
|
492
|
-
throw new Error("Database not ready");
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
const results: Array<InferSchemaOutput<SelectSchema<TTable>>> = [];
|
|
496
|
-
const itemsToUpdate: IndexedDBSyncItem[] = [];
|
|
497
|
-
|
|
498
|
-
for (const mutation of mutations) {
|
|
499
|
-
const existing = await db.get<IndexedDBSyncItem>(
|
|
500
|
-
config.storeName,
|
|
501
|
-
mutation.key,
|
|
502
|
-
);
|
|
503
|
-
|
|
504
|
-
if (existing) {
|
|
505
|
-
const updateTime = new Date();
|
|
506
|
-
const updatedItem = {
|
|
507
|
-
...existing,
|
|
508
|
-
...mutation.changes,
|
|
509
|
-
updatedAt: updateTime,
|
|
510
|
-
} as IndexedDBSyncItem;
|
|
511
|
-
|
|
512
|
-
itemsToUpdate.push(updatedItem);
|
|
513
|
-
results.push(
|
|
514
|
-
updatedItem as unknown as InferSchemaOutput<SelectSchema<TTable>>,
|
|
515
|
-
);
|
|
516
|
-
} else {
|
|
517
|
-
// If item doesn't exist, push original to maintain order
|
|
518
|
-
results.push(mutation.original);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Update all items in a single batch operation
|
|
523
|
-
if (itemsToUpdate.length > 0) {
|
|
524
|
-
await db.put(config.storeName, itemsToUpdate);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
return results;
|
|
528
|
-
},
|
|
529
|
-
|
|
530
|
-
handleDelete: async (mutations) => {
|
|
531
|
-
const db = config.indexedDBRef.current;
|
|
532
|
-
|
|
533
|
-
if (!db) {
|
|
534
|
-
throw new Error("Database not ready");
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
const keysToDelete: IDBValidKey[] = mutations.map((m) => m.key);
|
|
538
|
-
|
|
539
|
-
// Delete all items in a single batch operation
|
|
540
|
-
await db.delete(config.storeName, keysToDelete);
|
|
541
|
-
},
|
|
542
|
-
|
|
543
|
-
handleTruncate: async () => {
|
|
544
|
-
const db = config.indexedDBRef.current;
|
|
545
|
-
|
|
546
|
-
if (!db) {
|
|
547
|
-
throw new Error("Database not ready");
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// Clear all items from the store
|
|
551
|
-
await db.clear(config.storeName);
|
|
552
|
-
},
|
|
553
|
-
};
|
|
554
|
-
|
|
555
|
-
// For non-eager sync modes, still discover indexes before marking ready
|
|
556
|
-
const wrappedBackend: SyncBackend<TTable> = {
|
|
557
|
-
...backend,
|
|
558
|
-
initialLoad: async () => {
|
|
559
|
-
if (config.syncMode === "eager" || !config.syncMode) {
|
|
560
|
-
return await backend.initialLoad();
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// For non-eager sync modes, still discover indexes but don't load data
|
|
564
|
-
await discoverIndexesOnce();
|
|
565
|
-
|
|
566
|
-
return [];
|
|
567
|
-
},
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
// Create sync function using shared utilities
|
|
571
|
-
const baseSyncConfig: BaseSyncConfig<TTable> = {
|
|
572
|
-
table,
|
|
573
|
-
readyPromise: config.readyPromise,
|
|
574
|
-
syncMode: config.syncMode,
|
|
575
|
-
debug: config.debug,
|
|
576
|
-
};
|
|
577
|
-
|
|
578
|
-
const syncResult = createSyncFunction(baseSyncConfig, wrappedBackend);
|
|
579
|
-
|
|
580
|
-
// Create insert schema with all defaults (IndexedDB needs them upfront)
|
|
581
|
-
const schema = createInsertSchemaWithDefaults(table);
|
|
582
|
-
|
|
583
|
-
// Create collection config using shared utilities
|
|
584
|
-
return createCollectionConfig({
|
|
585
|
-
schema,
|
|
586
|
-
getKey: createGetKeyFunction<TTable>(),
|
|
587
|
-
syncResult,
|
|
588
|
-
syncMode: config.syncMode,
|
|
589
|
-
});
|
|
590
|
-
}
|