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