remult 3.3.18 → 3.3.19-next.2
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/esm/index.js +7 -6
- package/esm/remult-d1.js +1 -0
- package/esm/remult-knex/index.js +112 -2
- package/esm/remult-mongo.js +17 -8
- package/esm/remult-sqlite-core.js +1 -0
- package/esm/src/data-interfaces.js +4 -4
- package/esm/src/data-providers/array-entity-data-provider.js +14 -2
- package/esm/src/data-providers/indexed-db-data-provider.js +811 -0
- package/esm/src/data-providers/json-data-provider.js +2 -2
- package/esm/src/data-providers/rest-data-provider.js +11 -12
- package/esm/src/data-providers/sql-database.js +110 -43
- package/esm/src/remult3/RepositoryImplementation.js +90 -60
- package/index.d.ts +31 -17
- package/index.js +5 -2
- package/package.json +1 -1
- package/remult-d1.d.ts +1 -0
- package/remult-d1.js +1 -0
- package/remult-knex/index.d.ts +2 -2
- package/remult-knex/index.js +329 -44
- package/remult-mongo.js +80 -36
- package/remult-sqlite-core.d.ts +1 -0
- package/remult-sqlite-core.js +1 -0
- package/src/column-interfaces.d.ts +4 -0
- package/src/data-interfaces.d.ts +2 -3
- package/src/data-interfaces.js +4 -4
- package/src/data-providers/array-entity-data-provider.d.ts +2 -2
- package/src/data-providers/array-entity-data-provider.js +92 -14
- package/src/data-providers/indexed-db-data-provider.d.ts +39 -0
- package/src/data-providers/indexed-db-data-provider.js +1387 -0
- package/src/data-providers/json-data-provider.js +2 -2
- package/src/data-providers/rest-data-provider.d.ts +2 -3
- package/src/data-providers/rest-data-provider.js +18 -17
- package/src/data-providers/sql-database.js +254 -105
- package/src/remult3/RepositoryImplementation.d.ts +1 -1
- package/src/remult3/RepositoryImplementation.js +384 -270
- package/src/remult3/remult3.d.ts +2 -2
- package/src/sql-command.d.ts +2 -0
- package/src/standard-schema/index.d.ts +1 -1
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
import { dbNamesOf, isDbReadonly, } from '../filter/filter-consumer-bridge-to-sql-request.js';
|
|
2
|
+
import { isAutoIncrement } from '../remult3/RepositoryImplementation.js';
|
|
3
|
+
import { ArrayEntityDataProvider } from './array-entity-data-provider.js';
|
|
4
|
+
export class IndexedDbIndexBuilder {
|
|
5
|
+
//@internal
|
|
6
|
+
entries = [];
|
|
7
|
+
ensureIndexes(entity, indexes) {
|
|
8
|
+
this.entries.push({ entity, indexes: [...indexes] });
|
|
9
|
+
return this;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const IDB_KEYS_STORE = '__remult_keys';
|
|
13
|
+
const IDB_DEK_ID = 'dek';
|
|
14
|
+
const IDB_ENC = '_enc';
|
|
15
|
+
const IDB_IV = '_iv';
|
|
16
|
+
export class IndexedDbDataProvider {
|
|
17
|
+
dbName;
|
|
18
|
+
//@internal
|
|
19
|
+
declaredIndexes = [];
|
|
20
|
+
//@internal
|
|
21
|
+
encrypt;
|
|
22
|
+
//@internal
|
|
23
|
+
customGetKey;
|
|
24
|
+
//@internal
|
|
25
|
+
dek;
|
|
26
|
+
constructor(dbName = 'remult', options) {
|
|
27
|
+
this.dbName = dbName;
|
|
28
|
+
this.encrypt = options?.encrypt === true;
|
|
29
|
+
this.customGetKey = options?.getEncryptionKey;
|
|
30
|
+
if (this.encrypt && !(typeof crypto !== 'undefined' && crypto.subtle)) {
|
|
31
|
+
throw new Error('Web Crypto API is required when IndexedDB encryption is enabled');
|
|
32
|
+
}
|
|
33
|
+
if (options?.indexes) {
|
|
34
|
+
const builder = new IndexedDbIndexBuilder();
|
|
35
|
+
options.indexes(builder);
|
|
36
|
+
this.declaredIndexes = builder.entries;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//@internal
|
|
40
|
+
db;
|
|
41
|
+
//@internal
|
|
42
|
+
lastFetch;
|
|
43
|
+
//@internal
|
|
44
|
+
tail = Promise.resolve();
|
|
45
|
+
getEntityDataProvider(entity) {
|
|
46
|
+
return new IndexedDbEntityDataProvider(this, entity);
|
|
47
|
+
}
|
|
48
|
+
async transaction(action) {
|
|
49
|
+
await action(this);
|
|
50
|
+
}
|
|
51
|
+
async ensureSchema(entities) {
|
|
52
|
+
await this.enqueue(() => this.ensureStores(entities));
|
|
53
|
+
}
|
|
54
|
+
close() {
|
|
55
|
+
this.db?.close();
|
|
56
|
+
this.db = undefined;
|
|
57
|
+
}
|
|
58
|
+
//@internal
|
|
59
|
+
enqueue(fn) {
|
|
60
|
+
const run = this.tail.then(fn, fn);
|
|
61
|
+
this.tail = run.then(() => { }, () => { });
|
|
62
|
+
return run;
|
|
63
|
+
}
|
|
64
|
+
//@internal
|
|
65
|
+
async runWithRows(entity, where, what) {
|
|
66
|
+
return this.enqueue(async () => {
|
|
67
|
+
await this.ensureStores([entity]);
|
|
68
|
+
const rows = await this.loadRows(entity, where);
|
|
69
|
+
return what(new ArrayEntityDataProvider(entity, () => rows));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
//@internal
|
|
73
|
+
async fetchRows(entity, where) {
|
|
74
|
+
return this.enqueue(async () => {
|
|
75
|
+
await this.ensureStores([entity]);
|
|
76
|
+
return this.loadRows(entity, where);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
//@internal
|
|
80
|
+
async loadRows(entity, where) {
|
|
81
|
+
const storeName = storeNameOf(entity);
|
|
82
|
+
const prefetch = await idbPrefetchFromFilter(entity, where, this.storeIndexes(storeName));
|
|
83
|
+
this.lastFetch = prefetch;
|
|
84
|
+
const rows = prefetch.type === 'all'
|
|
85
|
+
? await this.getAll(storeName)
|
|
86
|
+
: await this.fetchOps(storeName, entity, prefetch.type === 'or' ? prefetch.parts : [prefetch]);
|
|
87
|
+
if (!this.encrypt)
|
|
88
|
+
return rows;
|
|
89
|
+
const dek = await this.loadDek();
|
|
90
|
+
const names = await dbNamesOf(entity, (x) => x);
|
|
91
|
+
return Promise.all(rows.map((row) => decryptJson(storeName, row, entity, names, dek)));
|
|
92
|
+
}
|
|
93
|
+
//@internal
|
|
94
|
+
storeIndexes(storeName) {
|
|
95
|
+
const db = this.db;
|
|
96
|
+
if (!db?.objectStoreNames.contains(storeName))
|
|
97
|
+
return [];
|
|
98
|
+
const store = db.transaction(storeName).objectStore(storeName);
|
|
99
|
+
const names = store.indexNames;
|
|
100
|
+
const result = [];
|
|
101
|
+
for (let i = 0; i < names.length; i++) {
|
|
102
|
+
const name = names[i];
|
|
103
|
+
result.push({
|
|
104
|
+
name,
|
|
105
|
+
keyPath: store.index(name).keyPath,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
//@internal
|
|
111
|
+
indexesFor(entity) {
|
|
112
|
+
return this.declaredIndexes
|
|
113
|
+
.filter((e) => e.entity === entity.entityType)
|
|
114
|
+
.flatMap((e) => e.indexes);
|
|
115
|
+
}
|
|
116
|
+
//@internal
|
|
117
|
+
async ensureStores(entities) {
|
|
118
|
+
const wanted = await Promise.all(entities.map(async (entity) => {
|
|
119
|
+
const keyPath = await keyPathOf(entity);
|
|
120
|
+
return {
|
|
121
|
+
name: storeNameOf(entity),
|
|
122
|
+
keyPath,
|
|
123
|
+
autoIncrement: isAutoIncrement(entity.idMetadata.field),
|
|
124
|
+
indexes: await resolveIndexDefs(entity, this.indexesFor(entity), keyPath),
|
|
125
|
+
};
|
|
126
|
+
}));
|
|
127
|
+
const db = await this.open();
|
|
128
|
+
const needsKeyStore = this.usesKeyStore() && !db.objectStoreNames.contains(IDB_KEYS_STORE);
|
|
129
|
+
const needsUpgrade = needsKeyStore ||
|
|
130
|
+
wanted.some((w) => {
|
|
131
|
+
if (!db.objectStoreNames.contains(w.name))
|
|
132
|
+
return true;
|
|
133
|
+
const existing = this.storeIndexes(w.name).map((i) => i.name);
|
|
134
|
+
return w.indexes.some((idx) => !existing.includes(idx.name));
|
|
135
|
+
});
|
|
136
|
+
if (!needsUpgrade)
|
|
137
|
+
return;
|
|
138
|
+
const nextVersion = db.version + 1;
|
|
139
|
+
this.forget(db);
|
|
140
|
+
db.close();
|
|
141
|
+
await this.open(nextVersion, (upgradeDb, tx) => {
|
|
142
|
+
if (this.usesKeyStore() &&
|
|
143
|
+
!upgradeDb.objectStoreNames.contains(IDB_KEYS_STORE)) {
|
|
144
|
+
upgradeDb.createObjectStore(IDB_KEYS_STORE, { keyPath: 'id' });
|
|
145
|
+
}
|
|
146
|
+
for (const { name, keyPath, autoIncrement, indexes } of wanted) {
|
|
147
|
+
const store = upgradeDb.objectStoreNames.contains(name)
|
|
148
|
+
? tx.objectStore(name)
|
|
149
|
+
: upgradeDb.createObjectStore(name, { keyPath, autoIncrement });
|
|
150
|
+
for (const idx of indexes) {
|
|
151
|
+
if (!store.indexNames.contains(idx.name)) {
|
|
152
|
+
store.createIndex(idx.name, idx.keyPath);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
//@internal
|
|
159
|
+
usesKeyStore() {
|
|
160
|
+
return this.encrypt && !this.customGetKey;
|
|
161
|
+
}
|
|
162
|
+
//@internal
|
|
163
|
+
async loadDek() {
|
|
164
|
+
if (this.dek)
|
|
165
|
+
return this.dek;
|
|
166
|
+
if (this.customGetKey) {
|
|
167
|
+
const key = await this.customGetKey();
|
|
168
|
+
this.dek = key;
|
|
169
|
+
return key;
|
|
170
|
+
}
|
|
171
|
+
const existing = await this.withStore(IDB_KEYS_STORE, 'readonly', (s) => idbReq(s.get(IDB_DEK_ID)));
|
|
172
|
+
if (existing?.key) {
|
|
173
|
+
this.dek = existing.key;
|
|
174
|
+
return existing.key;
|
|
175
|
+
}
|
|
176
|
+
const generated = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
177
|
+
const key = await this.withStore(IDB_KEYS_STORE, 'readwrite', async (s) => {
|
|
178
|
+
const rec = await idbReq(s.get(IDB_DEK_ID));
|
|
179
|
+
if (rec?.key)
|
|
180
|
+
return rec.key;
|
|
181
|
+
try {
|
|
182
|
+
await idbReq(s.add({ id: IDB_DEK_ID, key: generated }));
|
|
183
|
+
return generated;
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
if (e?.name === 'ConstraintError') {
|
|
187
|
+
const again = await idbReq(s.get(IDB_DEK_ID));
|
|
188
|
+
if (!again?.key)
|
|
189
|
+
throw new Error('Failed to load IndexedDB encryption key');
|
|
190
|
+
return again.key;
|
|
191
|
+
}
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
this.dek = key;
|
|
196
|
+
return key;
|
|
197
|
+
}
|
|
198
|
+
//@internal
|
|
199
|
+
async plaintextDbNames(entity) {
|
|
200
|
+
const names = await dbNamesOf(entity, (x) => x);
|
|
201
|
+
const set = new Set();
|
|
202
|
+
for (const f of entity.idMetadata.fields) {
|
|
203
|
+
set.add(names.$dbNameOf(f));
|
|
204
|
+
}
|
|
205
|
+
for (const def of this.indexesFor(entity)) {
|
|
206
|
+
const keys = Array.isArray(def) ? [...def] : [def];
|
|
207
|
+
for (const key of keys) {
|
|
208
|
+
const field = [...entity.fields].find((f) => f.key === key);
|
|
209
|
+
if (field)
|
|
210
|
+
set.add(names.$dbNameOf(field));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return set;
|
|
214
|
+
}
|
|
215
|
+
//@internal
|
|
216
|
+
forget(db) {
|
|
217
|
+
if (this.db === db)
|
|
218
|
+
this.db = undefined;
|
|
219
|
+
}
|
|
220
|
+
//@internal
|
|
221
|
+
async open(version, upgrade) {
|
|
222
|
+
if (this.db && version == null)
|
|
223
|
+
return this.db;
|
|
224
|
+
this.db = await new Promise((resolve, reject) => {
|
|
225
|
+
const request = version == null
|
|
226
|
+
? indexedDB.open(this.dbName)
|
|
227
|
+
: indexedDB.open(this.dbName, version);
|
|
228
|
+
request.onerror = () => reject(request.error);
|
|
229
|
+
request.onupgradeneeded = () => upgrade?.(request.result, request.transaction);
|
|
230
|
+
request.onsuccess = () => {
|
|
231
|
+
const db = request.result;
|
|
232
|
+
const forget = () => this.forget(db);
|
|
233
|
+
db.onclose = forget;
|
|
234
|
+
db.onversionchange = () => {
|
|
235
|
+
db.close();
|
|
236
|
+
forget();
|
|
237
|
+
};
|
|
238
|
+
resolve(db);
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
return this.db;
|
|
242
|
+
}
|
|
243
|
+
//@internal
|
|
244
|
+
getAll(storeName, range) {
|
|
245
|
+
return this.withStore(storeName, 'readonly', (store) => idbReq(range ? store.getAll(range) : store.getAll()));
|
|
246
|
+
}
|
|
247
|
+
//@internal
|
|
248
|
+
fetchOps(storeName, entity, ops) {
|
|
249
|
+
return this.withStore(storeName, 'readonly', async (store) => {
|
|
250
|
+
const idKey = entity.idMetadata.fields[0].key;
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
const rows = [];
|
|
253
|
+
for (const op of ops) {
|
|
254
|
+
const source = op.index ? store.index(op.index) : store;
|
|
255
|
+
const chunk = op.type === 'keys'
|
|
256
|
+
? op.index
|
|
257
|
+
? (await Promise.all(op.keys.map((key) => idbReq(source.getAll(key))))).flat()
|
|
258
|
+
: await Promise.all(op.keys.map((key) => idbReq(store.get(key))))
|
|
259
|
+
: await idbReq(source.getAll(op.range));
|
|
260
|
+
for (const row of chunk) {
|
|
261
|
+
if (row == null)
|
|
262
|
+
continue;
|
|
263
|
+
const k = JSON.stringify(row[idKey]);
|
|
264
|
+
if (seen.has(k))
|
|
265
|
+
continue;
|
|
266
|
+
seen.add(k);
|
|
267
|
+
rows.push(row);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return rows;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
//@internal
|
|
274
|
+
withStore(storeName, mode, op) {
|
|
275
|
+
return new Promise((resolve, reject) => {
|
|
276
|
+
const tx = this.db.transaction(storeName, mode);
|
|
277
|
+
let result;
|
|
278
|
+
let opErr;
|
|
279
|
+
tx.oncomplete = () => {
|
|
280
|
+
if (opErr)
|
|
281
|
+
reject(opErr);
|
|
282
|
+
else
|
|
283
|
+
resolve(result);
|
|
284
|
+
};
|
|
285
|
+
tx.onabort = () => reject(opErr ?? tx.error);
|
|
286
|
+
tx.onerror = () => reject(opErr ?? tx.error);
|
|
287
|
+
Promise.resolve(op(tx.objectStore(storeName))).then((r) => {
|
|
288
|
+
result = r;
|
|
289
|
+
}, (e) => {
|
|
290
|
+
opErr = e;
|
|
291
|
+
try {
|
|
292
|
+
tx.abort();
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
/* already aborted */
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
class IndexedDbEntityDataProvider {
|
|
302
|
+
provider;
|
|
303
|
+
entity;
|
|
304
|
+
constructor(provider, entity) {
|
|
305
|
+
this.provider = provider;
|
|
306
|
+
this.entity = entity;
|
|
307
|
+
}
|
|
308
|
+
find(options) {
|
|
309
|
+
return this.provider.runWithRows(this.entity, options?.where, (dp) => dp.find(options));
|
|
310
|
+
}
|
|
311
|
+
count(where) {
|
|
312
|
+
return this.provider.runWithRows(this.entity, where, (dp) => dp.count(where));
|
|
313
|
+
}
|
|
314
|
+
groupBy(options) {
|
|
315
|
+
return this.provider.runWithRows(this.entity, options?.where, (dp) => dp.groupBy(options));
|
|
316
|
+
}
|
|
317
|
+
insert(data) {
|
|
318
|
+
return this.provider.enqueue(async () => {
|
|
319
|
+
await this.provider.ensureStores([this.entity]);
|
|
320
|
+
const helper = new ArrayEntityDataProvider(this.entity, () => []);
|
|
321
|
+
const names = await helper.init();
|
|
322
|
+
const auto = isAutoIncrement(this.entity.idMetadata.field);
|
|
323
|
+
const idName = names.$dbNameOf(this.entity.idMetadata.field);
|
|
324
|
+
const rows = data.map((row) => {
|
|
325
|
+
const json = helper.translateToJson(row, names);
|
|
326
|
+
helper.verifyThatRowHasAllNotNullColumns(json, names);
|
|
327
|
+
if (auto)
|
|
328
|
+
delete json[idName];
|
|
329
|
+
return json;
|
|
330
|
+
});
|
|
331
|
+
const storeName = storeNameOf(this.entity);
|
|
332
|
+
const dek = this.provider.encrypt
|
|
333
|
+
? await this.provider.loadDek()
|
|
334
|
+
: undefined;
|
|
335
|
+
const keep = dek
|
|
336
|
+
? await this.provider.plaintextDbNames(this.entity)
|
|
337
|
+
: undefined;
|
|
338
|
+
if (dek && auto) {
|
|
339
|
+
const keys = await this.provider.withStore(storeName, 'readwrite', async (store) => {
|
|
340
|
+
const allocated = [];
|
|
341
|
+
for (let i = 0; i < rows.length; i++) {
|
|
342
|
+
allocated.push(await idbReq(store.add({})));
|
|
343
|
+
}
|
|
344
|
+
return allocated;
|
|
345
|
+
});
|
|
346
|
+
for (let i = 0; i < rows.length; i++) {
|
|
347
|
+
rows[i][idName] = keys[i];
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const toWrite = dek
|
|
351
|
+
? await Promise.all(rows.map((json) => encryptJson(storeName, json, keep, keyFromRow(this.entity, names, json), dek)))
|
|
352
|
+
: rows;
|
|
353
|
+
await this.provider.withStore(storeName, 'readwrite', async (store) => {
|
|
354
|
+
for (let i = 0; i < toWrite.length; i++) {
|
|
355
|
+
try {
|
|
356
|
+
if (dek && auto) {
|
|
357
|
+
await idbReq(store.put(toWrite[i]));
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
const key = await idbReq(store.add(toWrite[i]));
|
|
361
|
+
if (auto)
|
|
362
|
+
rows[i][idName] = rows[i][idName] ?? key;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch (e) {
|
|
366
|
+
if (e?.name === 'ConstraintError')
|
|
367
|
+
throw Error('id already exists');
|
|
368
|
+
throw e;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
return rows.map((json) => helper.translateFromJson(json, names));
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
update(id, data) {
|
|
376
|
+
return this.provider.enqueue(async () => {
|
|
377
|
+
await this.provider.ensureStores([this.entity]);
|
|
378
|
+
const helper = new ArrayEntityDataProvider(this.entity, () => []);
|
|
379
|
+
const names = await helper.init();
|
|
380
|
+
const key = toIdbKey(this.entity, id);
|
|
381
|
+
const storeName = storeNameOf(this.entity);
|
|
382
|
+
const existingRaw = await this.provider.withStore(storeName, 'readonly', (store) => idbReq(store.get(key)));
|
|
383
|
+
if (existingRaw == null)
|
|
384
|
+
throw new Error(`Couldn't find row with id "${id}" in entity "${this.entity.key}" to update`);
|
|
385
|
+
const dek = this.provider.encrypt
|
|
386
|
+
? await this.provider.loadDek()
|
|
387
|
+
: undefined;
|
|
388
|
+
const existing = dek
|
|
389
|
+
? await decryptJson(storeName, existingRaw, this.entity, names, dek)
|
|
390
|
+
: existingRaw;
|
|
391
|
+
const json = { ...existing };
|
|
392
|
+
const keys = Object.keys(data);
|
|
393
|
+
for (const f of this.entity.fields) {
|
|
394
|
+
if (!isDbReadonly(f, names) && keys.includes(f.key)) {
|
|
395
|
+
json[names.$dbNameOf(f)] = f.valueConverter.toJson(data[f.key]);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
helper.verifyThatRowHasAllNotNullColumns(json, names);
|
|
399
|
+
const newKey = keyFromRow(this.entity, names, json);
|
|
400
|
+
const toWrite = dek
|
|
401
|
+
? await encryptJson(storeName, json, await this.provider.plaintextDbNames(this.entity), newKey, dek)
|
|
402
|
+
: json;
|
|
403
|
+
await this.provider.withStore(storeName, 'readwrite', async (store) => {
|
|
404
|
+
if (!idbKeysEqual(key, newKey)) {
|
|
405
|
+
const conflict = await idbReq(store.get(newKey));
|
|
406
|
+
if (conflict != null)
|
|
407
|
+
throw Error('id already exists');
|
|
408
|
+
await idbReq(store.delete(key));
|
|
409
|
+
}
|
|
410
|
+
await idbReq(store.put(toWrite));
|
|
411
|
+
});
|
|
412
|
+
return helper.translateFromJson(json, names);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
delete(ids) {
|
|
416
|
+
if (ids.length === 0)
|
|
417
|
+
return Promise.resolve();
|
|
418
|
+
return this.provider.enqueue(async () => {
|
|
419
|
+
await this.provider.ensureStores([this.entity]);
|
|
420
|
+
return this.provider.withStore(storeNameOf(this.entity), 'readwrite', async (store) => {
|
|
421
|
+
for (const id of ids) {
|
|
422
|
+
const key = toIdbKey(this.entity, id);
|
|
423
|
+
const existing = await idbReq(store.get(key));
|
|
424
|
+
if (existing == null)
|
|
425
|
+
throw new Error(`Couldn't find row with id "${id}" in entity "${this.entity.key}" to delete`);
|
|
426
|
+
await idbReq(store.delete(key));
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function storeNameOf(entity) {
|
|
433
|
+
return entity.dbName;
|
|
434
|
+
}
|
|
435
|
+
//@internal
|
|
436
|
+
export async function idbPrefetchFromFilter(entity, where, indexes = []) {
|
|
437
|
+
if (!where)
|
|
438
|
+
return { type: 'all' };
|
|
439
|
+
const names = await dbNamesOf(entity, (x) => x);
|
|
440
|
+
if (entity.idMetadata.fields.length === 1) {
|
|
441
|
+
const c = new IdbPrefetchCollector(entity.idMetadata.fields[0]);
|
|
442
|
+
where.__applyToConsumer(c);
|
|
443
|
+
const r = c.result();
|
|
444
|
+
if (r.type !== 'all')
|
|
445
|
+
return r;
|
|
446
|
+
}
|
|
447
|
+
const ordered = [...indexes].sort((a, b) => pathLen(b.keyPath) - pathLen(a.keyPath));
|
|
448
|
+
for (const idx of ordered) {
|
|
449
|
+
const r = prefetchForIndex(entity, names, where, idx);
|
|
450
|
+
if (r.type !== 'all')
|
|
451
|
+
return withIndex(r, idx.name);
|
|
452
|
+
}
|
|
453
|
+
return { type: 'all' };
|
|
454
|
+
}
|
|
455
|
+
function prefetchForIndex(entity, names, where, idx) {
|
|
456
|
+
const path = Array.isArray(idx.keyPath) ? idx.keyPath : [idx.keyPath];
|
|
457
|
+
if (path.length === 1) {
|
|
458
|
+
const field = fieldByDbName(entity, names, path[0]);
|
|
459
|
+
if (!field)
|
|
460
|
+
return { type: 'all' };
|
|
461
|
+
const c = new IdbPrefetchCollector(field);
|
|
462
|
+
where.__applyToConsumer(c);
|
|
463
|
+
return c.result();
|
|
464
|
+
}
|
|
465
|
+
const parts = path.map((dbName) => {
|
|
466
|
+
const field = fieldByDbName(entity, names, dbName);
|
|
467
|
+
if (!field)
|
|
468
|
+
return { type: 'all' };
|
|
469
|
+
const c = new IdbPrefetchCollector(field);
|
|
470
|
+
where.__applyToConsumer(c);
|
|
471
|
+
return c.result();
|
|
472
|
+
});
|
|
473
|
+
if (parts.every((p) => p.type === 'keys')) {
|
|
474
|
+
return {
|
|
475
|
+
type: 'keys',
|
|
476
|
+
keys: cartesian(parts.map((p) => p.keys)),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const last = parts[parts.length - 1];
|
|
480
|
+
const prefix = parts.slice(0, -1);
|
|
481
|
+
if (last.type === 'range' &&
|
|
482
|
+
prefix.every((p) => p.type === 'keys' && p.keys.length === 1)) {
|
|
483
|
+
const pre = prefix.map((p) => p.keys[0]);
|
|
484
|
+
const r = last.range;
|
|
485
|
+
try {
|
|
486
|
+
if (r.lower !== undefined && r.upper !== undefined)
|
|
487
|
+
return {
|
|
488
|
+
type: 'range',
|
|
489
|
+
range: IDBKeyRange.bound([...pre, r.lower], [...pre, r.upper], r.lowerOpen, r.upperOpen),
|
|
490
|
+
};
|
|
491
|
+
if (r.lower !== undefined)
|
|
492
|
+
return {
|
|
493
|
+
type: 'range',
|
|
494
|
+
range: IDBKeyRange.lowerBound([...pre, r.lower], r.lowerOpen),
|
|
495
|
+
};
|
|
496
|
+
if (r.upper !== undefined)
|
|
497
|
+
return {
|
|
498
|
+
type: 'range',
|
|
499
|
+
range: IDBKeyRange.upperBound([...pre, r.upper], r.upperOpen),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return { type: 'keys', keys: [] };
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return { type: 'all' };
|
|
507
|
+
}
|
|
508
|
+
function withIndex(prefetch, index) {
|
|
509
|
+
if (prefetch.type === 'all')
|
|
510
|
+
return prefetch;
|
|
511
|
+
if (prefetch.type === 'or')
|
|
512
|
+
return { type: 'or', parts: prefetch.parts.map((p) => ({ ...p, index })) };
|
|
513
|
+
return { ...prefetch, index };
|
|
514
|
+
}
|
|
515
|
+
function pathLen(keyPath) {
|
|
516
|
+
return Array.isArray(keyPath) ? keyPath.length : 1;
|
|
517
|
+
}
|
|
518
|
+
function fieldByDbName(entity, names, dbName) {
|
|
519
|
+
for (const f of entity.fields) {
|
|
520
|
+
if (names.$dbNameOf(f) === dbName)
|
|
521
|
+
return f;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function cartesian(lists) {
|
|
525
|
+
let acc = [[]];
|
|
526
|
+
for (const list of lists) {
|
|
527
|
+
acc = acc.flatMap((prefix) => list.map((v) => [...prefix, v]));
|
|
528
|
+
}
|
|
529
|
+
return acc;
|
|
530
|
+
}
|
|
531
|
+
class IdbPrefetchCollector {
|
|
532
|
+
idField;
|
|
533
|
+
keys;
|
|
534
|
+
lower;
|
|
535
|
+
upper;
|
|
536
|
+
orOps;
|
|
537
|
+
cannotNarrow = false;
|
|
538
|
+
constructor(idField) {
|
|
539
|
+
this.idField = idField;
|
|
540
|
+
}
|
|
541
|
+
result() {
|
|
542
|
+
if (this.cannotNarrow)
|
|
543
|
+
return { type: 'all' };
|
|
544
|
+
const andOp = this.andOp();
|
|
545
|
+
if (this.keys && andOp)
|
|
546
|
+
return andOp;
|
|
547
|
+
if (this.orOps)
|
|
548
|
+
return mergeOps(this.orOps);
|
|
549
|
+
return andOp ?? { type: 'all' };
|
|
550
|
+
}
|
|
551
|
+
hasIdConstraint() {
|
|
552
|
+
return this.keys != null || this.hasBounds || this.orOps != null;
|
|
553
|
+
}
|
|
554
|
+
get hasBounds() {
|
|
555
|
+
return this.lower != null || this.upper != null;
|
|
556
|
+
}
|
|
557
|
+
andOp() {
|
|
558
|
+
if (this.keys) {
|
|
559
|
+
if (!this.hasBounds)
|
|
560
|
+
return { type: 'keys', keys: this.keys };
|
|
561
|
+
try {
|
|
562
|
+
const range = this.toKeyRange();
|
|
563
|
+
return {
|
|
564
|
+
type: 'keys',
|
|
565
|
+
keys: this.keys.filter((k) => range.includes(k)),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return { type: 'keys', keys: [] };
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (!this.hasBounds)
|
|
573
|
+
return undefined;
|
|
574
|
+
try {
|
|
575
|
+
return { type: 'range', range: this.toKeyRange() };
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return { type: 'keys', keys: [] };
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
toKeyRange() {
|
|
582
|
+
if (this.lower && this.upper)
|
|
583
|
+
return IDBKeyRange.bound(this.lower.value, this.upper.value, this.lower.open, this.upper.open);
|
|
584
|
+
if (this.lower)
|
|
585
|
+
return IDBKeyRange.lowerBound(this.lower.value, this.lower.open);
|
|
586
|
+
return IDBKeyRange.upperBound(this.upper.value, this.upper.open);
|
|
587
|
+
}
|
|
588
|
+
isId(col) {
|
|
589
|
+
return col.key === this.idField.key;
|
|
590
|
+
}
|
|
591
|
+
toKey(col, val) {
|
|
592
|
+
return col.valueConverter.toJson(val);
|
|
593
|
+
}
|
|
594
|
+
intersectKeys(vals) {
|
|
595
|
+
if (!this.keys)
|
|
596
|
+
this.keys = [...vals];
|
|
597
|
+
else
|
|
598
|
+
this.keys = this.keys.filter((k) => vals.some((v) => idbKeysEqual(k, v)));
|
|
599
|
+
}
|
|
600
|
+
addLower(value, open) {
|
|
601
|
+
if (!this.lower)
|
|
602
|
+
this.lower = { value, open };
|
|
603
|
+
else if (value > this.lower.value)
|
|
604
|
+
this.lower = { value, open };
|
|
605
|
+
else if (value === this.lower.value)
|
|
606
|
+
this.lower = { value, open: this.lower.open || open };
|
|
607
|
+
}
|
|
608
|
+
addUpper(value, open) {
|
|
609
|
+
if (!this.upper)
|
|
610
|
+
this.upper = { value, open };
|
|
611
|
+
else if (value < this.upper.value)
|
|
612
|
+
this.upper = { value, open };
|
|
613
|
+
else if (value === this.upper.value)
|
|
614
|
+
this.upper = { value, open: this.upper.open || open };
|
|
615
|
+
}
|
|
616
|
+
isEqualTo(col, val) {
|
|
617
|
+
if (this.isId(col))
|
|
618
|
+
this.intersectKeys([this.toKey(col, val)]);
|
|
619
|
+
}
|
|
620
|
+
isIn(col, val) {
|
|
621
|
+
if (this.isId(col))
|
|
622
|
+
this.intersectKeys(val.map((v) => this.toKey(col, v)));
|
|
623
|
+
}
|
|
624
|
+
isGreaterThan(col, val) {
|
|
625
|
+
if (this.isId(col))
|
|
626
|
+
this.addLower(this.toKey(col, val), true);
|
|
627
|
+
}
|
|
628
|
+
isGreaterOrEqualTo(col, val) {
|
|
629
|
+
if (this.isId(col))
|
|
630
|
+
this.addLower(this.toKey(col, val), false);
|
|
631
|
+
}
|
|
632
|
+
isLessThan(col, val) {
|
|
633
|
+
if (this.isId(col))
|
|
634
|
+
this.addUpper(this.toKey(col, val), true);
|
|
635
|
+
}
|
|
636
|
+
isLessOrEqualTo(col, val) {
|
|
637
|
+
if (this.isId(col))
|
|
638
|
+
this.addUpper(this.toKey(col, val), false);
|
|
639
|
+
}
|
|
640
|
+
or(orElements) {
|
|
641
|
+
const results = orElements.map((el) => {
|
|
642
|
+
const c = new IdbPrefetchCollector(this.idField);
|
|
643
|
+
el.__applyToConsumer(c);
|
|
644
|
+
return c.result();
|
|
645
|
+
});
|
|
646
|
+
if (results.some((r) => r.type === 'all'))
|
|
647
|
+
return;
|
|
648
|
+
const ops = results.flatMap((r) => r.type === 'or' ? r.parts : r.type === 'all' ? [] : [r]);
|
|
649
|
+
this.orOps = this.orOps ? [...this.orOps, ...ops] : ops;
|
|
650
|
+
}
|
|
651
|
+
not(filter) {
|
|
652
|
+
const c = new IdbPrefetchCollector(this.idField);
|
|
653
|
+
filter.__applyToConsumer(c);
|
|
654
|
+
if (c.cannotNarrow || c.hasIdConstraint())
|
|
655
|
+
this.cannotNarrow = true;
|
|
656
|
+
}
|
|
657
|
+
isNull(col) {
|
|
658
|
+
if (this.isId(col))
|
|
659
|
+
this.cannotNarrow = true;
|
|
660
|
+
}
|
|
661
|
+
isNotNull(col) {
|
|
662
|
+
if (this.isId(col))
|
|
663
|
+
this.cannotNarrow = true;
|
|
664
|
+
}
|
|
665
|
+
isDifferentFrom(_col, _val) { }
|
|
666
|
+
containsCaseInsensitive(_col, _val) { }
|
|
667
|
+
notContainsCaseInsensitive(_col, _val) { }
|
|
668
|
+
startsWithCaseInsensitive(_col, _val) { }
|
|
669
|
+
endsWithCaseInsensitive(_col, _val) { }
|
|
670
|
+
custom(_key, _customItem) {
|
|
671
|
+
this.cannotNarrow = true;
|
|
672
|
+
}
|
|
673
|
+
databaseCustom(_databaseCustom) {
|
|
674
|
+
this.cannotNarrow = true;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
function mergeOps(ops) {
|
|
678
|
+
const keyGroups = new Map();
|
|
679
|
+
const ranges = [];
|
|
680
|
+
for (const op of ops) {
|
|
681
|
+
if (op.type === 'keys') {
|
|
682
|
+
const k = op.index ?? '';
|
|
683
|
+
keyGroups.set(k, [...(keyGroups.get(k) ?? []), ...op.keys]);
|
|
684
|
+
}
|
|
685
|
+
else
|
|
686
|
+
ranges.push(op);
|
|
687
|
+
}
|
|
688
|
+
const parts = [
|
|
689
|
+
...[...keyGroups.entries()].map(([index, keys]) => ({
|
|
690
|
+
type: 'keys',
|
|
691
|
+
keys: keys.filter((k, i) => keys.findIndex((x) => idbKeysEqual(x, k)) === i),
|
|
692
|
+
...(index ? { index } : {}),
|
|
693
|
+
})),
|
|
694
|
+
...ranges,
|
|
695
|
+
];
|
|
696
|
+
if (parts.length === 1)
|
|
697
|
+
return parts[0];
|
|
698
|
+
return { type: 'or', parts };
|
|
699
|
+
}
|
|
700
|
+
async function resolveIndexDefs(entity, defs, storeKeyPath) {
|
|
701
|
+
if (defs.length === 0)
|
|
702
|
+
return [];
|
|
703
|
+
const names = await dbNamesOf(entity, (x) => x);
|
|
704
|
+
const result = [];
|
|
705
|
+
const seen = new Set();
|
|
706
|
+
const pk = JSON.stringify(storeKeyPath);
|
|
707
|
+
for (const def of defs) {
|
|
708
|
+
const keys = Array.isArray(def) ? [...def] : [def];
|
|
709
|
+
const keyPath = keys.map((key) => {
|
|
710
|
+
const field = [...entity.fields].find((f) => f.key === key);
|
|
711
|
+
if (!field)
|
|
712
|
+
throw new Error(`IndexedDB index field "${key}" not found on entity "${entity.key}"`);
|
|
713
|
+
return names.$dbNameOf(field);
|
|
714
|
+
});
|
|
715
|
+
const path = keyPath.length === 1 ? keyPath[0] : keyPath;
|
|
716
|
+
if (JSON.stringify(path) === pk)
|
|
717
|
+
continue;
|
|
718
|
+
const name = keyPath.join('_');
|
|
719
|
+
if (seen.has(name))
|
|
720
|
+
continue;
|
|
721
|
+
seen.add(name);
|
|
722
|
+
result.push({ name, keyPath: path });
|
|
723
|
+
}
|
|
724
|
+
return result;
|
|
725
|
+
}
|
|
726
|
+
async function keyPathOf(entity) {
|
|
727
|
+
const names = await dbNamesOf(entity, (x) => x);
|
|
728
|
+
const fields = entity.idMetadata.fields;
|
|
729
|
+
if (fields.length === 1)
|
|
730
|
+
return names.$dbNameOf(fields[0]);
|
|
731
|
+
return fields.map((f) => names.$dbNameOf(f));
|
|
732
|
+
}
|
|
733
|
+
function toIdbKey(entity, id) {
|
|
734
|
+
const fields = entity.idMetadata.fields;
|
|
735
|
+
if (fields.length === 1)
|
|
736
|
+
return fields[0].valueConverter.toJson(id);
|
|
737
|
+
const values = typeof id === 'object' && id !== null
|
|
738
|
+
? id
|
|
739
|
+
: Object.fromEntries(String(id)
|
|
740
|
+
.split(',')
|
|
741
|
+
.map((part, i) => [
|
|
742
|
+
fields[i].key,
|
|
743
|
+
fields[i].valueConverter.fromJson(part),
|
|
744
|
+
]));
|
|
745
|
+
return fields.map((f) => f.valueConverter.toJson(values[f.key]));
|
|
746
|
+
}
|
|
747
|
+
function keyFromRow(entity, names, row) {
|
|
748
|
+
const fields = entity.idMetadata.fields;
|
|
749
|
+
if (fields.length === 1)
|
|
750
|
+
return row[names.$dbNameOf(fields[0])];
|
|
751
|
+
return fields.map((f) => row[names.$dbNameOf(f)]);
|
|
752
|
+
}
|
|
753
|
+
function idbKeysEqual(a, b) {
|
|
754
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
755
|
+
}
|
|
756
|
+
function idbReq(request) {
|
|
757
|
+
return new Promise((resolve, reject) => {
|
|
758
|
+
request.onsuccess = () => resolve(request.result);
|
|
759
|
+
request.onerror = () => reject(request.error);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
function encAad(storeName, id) {
|
|
763
|
+
return new TextEncoder().encode(storeName + '\0' + JSON.stringify(id));
|
|
764
|
+
}
|
|
765
|
+
function asBufferSource(value, label) {
|
|
766
|
+
if (value instanceof Uint8Array || value instanceof ArrayBuffer)
|
|
767
|
+
return value;
|
|
768
|
+
throw new Error(`Failed to decrypt IndexedDB row: missing ${label}`);
|
|
769
|
+
}
|
|
770
|
+
async function encryptJson(storeName, json, plaintext, idbKey, dek) {
|
|
771
|
+
const stored = {};
|
|
772
|
+
const payload = {};
|
|
773
|
+
for (const k of Object.keys(json)) {
|
|
774
|
+
if (k === IDB_ENC || k === IDB_IV)
|
|
775
|
+
continue;
|
|
776
|
+
if (plaintext.has(k))
|
|
777
|
+
stored[k] = json[k];
|
|
778
|
+
else
|
|
779
|
+
payload[k] = json[k];
|
|
780
|
+
}
|
|
781
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
782
|
+
stored[IDB_ENC] = await crypto.subtle.encrypt({
|
|
783
|
+
name: 'AES-GCM',
|
|
784
|
+
iv,
|
|
785
|
+
additionalData: encAad(storeName, idbKey),
|
|
786
|
+
}, dek, new TextEncoder().encode(JSON.stringify(payload)));
|
|
787
|
+
stored[IDB_IV] = iv;
|
|
788
|
+
return stored;
|
|
789
|
+
}
|
|
790
|
+
async function decryptJson(storeName, row, entity, names, dek) {
|
|
791
|
+
if (row == null || row[IDB_ENC] == null)
|
|
792
|
+
return row;
|
|
793
|
+
const idbKey = keyFromRow(entity, names, row);
|
|
794
|
+
try {
|
|
795
|
+
const plain = await crypto.subtle.decrypt({
|
|
796
|
+
name: 'AES-GCM',
|
|
797
|
+
iv: asBufferSource(row[IDB_IV], IDB_IV),
|
|
798
|
+
additionalData: encAad(storeName, idbKey),
|
|
799
|
+
}, dek, asBufferSource(row[IDB_ENC], IDB_ENC));
|
|
800
|
+
const payload = JSON.parse(new TextDecoder().decode(plain));
|
|
801
|
+
const result = { ...row, ...payload };
|
|
802
|
+
delete result[IDB_ENC];
|
|
803
|
+
delete result[IDB_IV];
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
806
|
+
catch (e) {
|
|
807
|
+
if (e?.message?.startsWith('Failed to decrypt IndexedDB row'))
|
|
808
|
+
throw e;
|
|
809
|
+
throw new Error(`Failed to decrypt IndexedDB row in "${storeName}"`);
|
|
810
|
+
}
|
|
811
|
+
}
|