From 75c3efc5badaefaf103e3390d035be58958a7c5f Mon Sep 17 00:00:00 2001 From: David Lechner Date: Mon, 28 Mar 2022 17:04:45 -0500 Subject: [PATCH] fileStorage: replace localforage with dexie This gives us more fine-grained control over database transaction and cross-tab notifications and will facilitate upgrading the database with new metadata in the future. --- package.json | 5 +- src/fileStorage/sagas.test.ts | 90 +++++---- src/fileStorage/sagas.ts | 335 +++++++++++++++++++++++++--------- yarn.lock | 122 +++++++++---- 4 files changed, 377 insertions(+), 175 deletions(-) diff --git a/package.json b/package.json index a87f5fd1..63247969 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,11 @@ "browser-fs-access": "^0.25.0", "canvas": "^2.9.0", "copy-webpack-plugin": "^6.4.1", + "dexie": "^3.2.1", + "dexie-observable": "^3.0.0-beta.11", + "fake-indexeddb": "^3.1.7", "jszip": "^3.7.1", "license-webpack-plugin": "^3.0.0", - "localforage": "^1.10.0", - "localforage-observable": "^2.1.1", "monaco-editor": "^0.30.1", "monaco-editor-webpack-plugin": "^6.0.0", "monaco-themes": "^0.4.0", diff --git a/src/fileStorage/sagas.test.ts b/src/fileStorage/sagas.test.ts index c989cd2a..64d6f615 100644 --- a/src/fileStorage/sagas.test.ts +++ b/src/fileStorage/sagas.test.ts @@ -2,6 +2,7 @@ // Copyright (c) 2022 The Pybricks Authors import * as browserFsAccess from 'browser-fs-access'; +import 'fake-indexeddb/auto'; import { AsyncSaga } from '../../test'; import { fileStorageArchiveAllFiles, @@ -27,10 +28,15 @@ import fileStorage from './sagas'; jest.mock('browser-fs-access'); -beforeEach(() => { +afterEach(async () => { jest.clearAllMocks(); - // localForge uses localStorage as backend in test environment, so we need - // to start with a clean slate in each test + + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase('pybricks.fileStorage'); + request.addEventListener('success', resolve); + request.addEventListener('error', reject); + }); + localStorage.clear(); }); @@ -43,16 +49,13 @@ async function setUpTestFile(saga: AsyncSaga): Promise<[string, string]> { const testFileName = 'test.file'; const testFileContents = 'test file contents'; - const action0 = await saga.take(); - expect(action0).toEqual(fileStorageDidInitialize([])); + await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); saga.put(fileStorageWriteFile(testFileName, testFileContents)); - const action1 = await saga.take(); - expect(action1).toEqual(fileStorageDidWriteFile(testFileName)); + await expect(saga.take()).resolves.toEqual(fileStorageDidWriteFile(testFileName)); - const action2 = await saga.take(); - expect(action2).toEqual(fileStorageDidChangeItem(testFileName)); + await expect(saga.take()).resolves.toEqual(fileStorageDidChangeItem(testFileName)); return [testFileName, testFileContents]; } @@ -69,8 +72,7 @@ it('should migrate old program from local storage during initialization', async // initialization should remove the localStorage entry and add add it to // new storage backend - const action = await saga.take(); - expect(action).toEqual(fileStorageDidInitialize(['main.py'])); + await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize(['main.py'])); expect(localStorage.getItem(oldProgramKey)).toBeNull(); await saga.end(); @@ -79,9 +81,7 @@ it('should migrate old program from local storage during initialization', async it('should read and write files', async () => { const saga = new AsyncSaga(fileStorage); - let action = await saga.take(); - - expect(action).toEqual(fileStorageDidInitialize([])); + await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); const testFileName = 'test.file'; const testFileContents = 'test file contents'; @@ -90,18 +90,17 @@ it('should read and write files', async () => { saga.put(fileStorageWriteFile(testFileName, testFileContents)); // writing file triggers response - action = await saga.take(); - expect(action).toEqual(fileStorageDidWriteFile(testFileName)); + await expect(saga.take()).resolves.toEqual(fileStorageDidWriteFile(testFileName)); // and as a side-effect, triggers item change as well - action = await saga.take(); - expect(action).toEqual(fileStorageDidChangeItem(testFileName)); + await expect(saga.take()).resolves.toEqual(fileStorageDidChangeItem(testFileName)); // test reading the same file back saga.put(fileStorageReadFile(testFileName)); - action = await saga.take(); - expect(action).toEqual(fileStorageDidReadFile(testFileName, testFileContents)); + await expect(saga.take()).resolves.toEqual( + fileStorageDidReadFile(testFileName, testFileContents), + ); await saga.end(); }); @@ -109,15 +108,15 @@ it('should read and write files', async () => { it('should dispatch fail action if file does not exist', async () => { const saga = new AsyncSaga(fileStorage); - let action = await saga.take(); - expect(action).toEqual(fileStorageDidInitialize([])); + await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); const testFileName = 'test.file'; saga.put(fileStorageReadFile(testFileName)); - action = await saga.take(); - expect(fileStorageDidFailToReadFile.matches(action)).toBeTruthy(); + await expect(saga.take()).resolves.toEqual( + fileStorageDidFailToReadFile('test.file', new Error('file does not exist')), + ); await saga.end(); }); @@ -129,11 +128,9 @@ it('should delete files', async () => { saga.put(fileStorageDeleteFile(testFileName)); - const action = await saga.take(); - expect(action).toEqual(fileStorageDidDeleteFile(testFileName)); + await expect(saga.take()).resolves.toEqual(fileStorageDidDeleteFile(testFileName)); - const action2 = await saga.take(); - expect(action2).toEqual(fileStorageDidRemoveItem(testFileName)); + await expect(saga.take()).resolves.toEqual(fileStorageDidRemoveItem(testFileName)); await saga.end(); }); @@ -148,14 +145,15 @@ describe('rename', () => { saga.put(fileStorageRenameFile(testFileName, newName)); - const action = await saga.take(); - expect(action).toEqual(fileStorageDidChangeItem(newName)); + await expect(saga.take()).resolves.toEqual( + fileStorageDidRenameFile(testFileName, newName), + ); - const action2 = await saga.take(); - expect(action2).toEqual(fileStorageDidRemoveItem(testFileName)); + await expect(saga.take()).resolves.toEqual(fileStorageDidChangeItem(newName)); - const action3 = await saga.take(); - expect(action3).toEqual(fileStorageDidRenameFile(testFileName, newName)); + await expect(saga.take()).resolves.toEqual( + fileStorageDidRemoveItem(testFileName), + ); await saga.end(); }); @@ -167,13 +165,11 @@ describe('export', () => { const saga = new AsyncSaga(fileStorage); - const action0 = await saga.take(); - expect(action0).toEqual(fileStorageDidInitialize([])); + await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([])); saga.put(fileStorageExportFile(testFileName)); - const action = await saga.take(); - expect(action).toEqual( + await expect(saga.take()).resolves.toEqual( fileStorageDidFailToExportFile( testFileName, new Error('file does not exist'), @@ -192,8 +188,9 @@ describe('export', () => { saga.put(fileStorageExportFile(testFileName)); - const action = await saga.take(); - expect(action).toEqual(fileStorageDidExportFile(testFileName)); + await expect(saga.take()).resolves.toEqual( + fileStorageDidExportFile(testFileName), + ); expect(browserFsAccess.fileSave).toHaveBeenCalled(); await saga.end(); @@ -209,8 +206,9 @@ describe('export', () => { saga.put(fileStorageExportFile(testFileName)); - const action = await saga.take(); - expect(action).toEqual(fileStorageDidFailToExportFile(testFileName, testError)); + await expect(saga.take()).resolves.toEqual( + fileStorageDidFailToExportFile(testFileName, testError), + ); await saga.end(); }); @@ -226,8 +224,7 @@ describe('archive', () => { saga.put(fileStorageArchiveAllFiles()); - const action = await saga.take(); - expect(action).toEqual(fileStorageDidArchiveAllFiles()); + await expect(saga.take()).resolves.toEqual(fileStorageDidArchiveAllFiles()); expect(browserFsAccess.fileSave).toHaveBeenCalled(); await saga.end(); @@ -243,8 +240,9 @@ describe('archive', () => { saga.put(fileStorageArchiveAllFiles()); - const action = await saga.take(); - expect(action).toEqual(fileStorageDidFailToArchiveAllFiles(testError)); + await expect(saga.take()).resolves.toEqual( + fileStorageDidFailToArchiveAllFiles(testError), + ); await saga.end(); }); diff --git a/src/fileStorage/sagas.ts b/src/fileStorage/sagas.ts index 5b643d39..377d85c9 100644 --- a/src/fileStorage/sagas.ts +++ b/src/fileStorage/sagas.ts @@ -2,12 +2,17 @@ // Copyright (c) 2022 The Pybricks Authors import { fileSave } from 'browser-fs-access'; +import Dexie, { Table } from 'dexie'; +import { + ICreateChange, + IDatabaseChange, + IDeleteChange, + IUpdateChange, +} from 'dexie-observable/api'; +import 'dexie-observable'; import JSZip from 'jszip'; -import localForage from 'localforage'; -import { extendPrototype } from 'localforage-observable'; import { eventChannel } from 'redux-saga'; -import { call, fork, put, takeEvery } from 'typed-redux-saga/macro'; -import Observable from 'zen-observable'; +import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro'; import { pythonFileExtension, pythonFileMimeType } from '../pybricksMicropython/lib'; import { ensureError, timestamp } from '../utils'; import { @@ -35,42 +40,145 @@ import { fileStorageWriteFile, } from './actions'; +// HACK: we have to redefine DatabaseChangeType since it is a const enum +// https://ncjamieson.com/dont-export-const-enums +const enum DatabaseChangeType { + Create = 1, + Update = 2, + Delete = 3, +} + +/** Type discriminator for {@link ICreateChange} */ +function isCreateChange(change: IDatabaseChange): change is ICreateChange { + return change.type === Number(DatabaseChangeType.Create); +} + +/** Type discriminator for {@link IUpdateChange} */ +function isUpdateChange(change: IDatabaseChange): change is IUpdateChange { + return change.type === Number(DatabaseChangeType.Update); +} + +/** Type discriminator for {@link IDeleteChange} */ +function isDeleteChange(change: IDatabaseChange): change is IDeleteChange { + return change.type === Number(DatabaseChangeType.Delete); +} + +/** Type discriminator for {@link ICreateChange} of {@link FileMetadata} */ +function isFileMetadataCreateChange( + change: ICreateChange, +): change is Omit & { key: string; obj: FileMetadata } { + return change.table === 'metadata'; +} + +/** Type discriminator for {@link IUpdateChange} of {@link FileMetadata} */ +function isFileMetadataUpdateChange(change: IUpdateChange): change is Omit< + IUpdateChange, + 'key' | 'obj' | 'oldObj' +> & { + key: string; + obj: FileMetadata; + oldObj: FileMetadata; +} { + return change.table === 'metadata'; +} + +/** Type discriminator for {@link IDeleteChange} of {@link FileMetadata} */ +function isFileMetaDataDeleteChange(change: IDeleteChange): change is Omit< + IDeleteChange, + 'key' | 'oldObj' +> & { + key: string; + oldObj: FileMetadata; +} { + return change.table === 'metadata'; +} + +/** Database metadata table data type. */ +type FileMetadata = { + /** A globally unique identifier that serves a a file handle. */ + uuid?: string; + /** The path of the file in storage. */ + path: string; +}; + +/** Database contents table data type. */ +type FileContents = { + /** The path of the file in storage. */ + path: string; + /** The contents of the file. */ + contents: string; +}; + +class FileStorageDb extends Dexie { + metadata!: Table; + // NB: This table starts with an underscore to hide it from Dexie observable. + // In the future we may change this to use File Access API or some other + // storage, so we don't want to rely on the file contents being included + // with the metadata. + _contents!: Table; + + constructor() { + super('pybricks.fileStorage'); + this.version(1).stores({ + metadata: '$$uuid, &path', + _contents: 'path, contents', + }); + } +} + /** * Converts localForage change events to redux actions. - * @param change The storage change event. + * @param changes The list of changes from the 'changed' event. */ -function* handleFileStorageDidChange(change: LocalForageObservableChange): Generator { - switch (change.methodName) { - case 'setItem': - if (change.success) { - yield* put(fileStorageDidChangeItem(change.key)); +function* handleFileStorageDidChange(changes: IDatabaseChange[]): Generator { + for (const change of changes) { + if (isCreateChange(change)) { + if (isFileMetadataCreateChange(change)) { + yield* put(fileStorageDidChangeItem(change.obj.path)); } - break; - case 'removeItem': - if (change.success) { - yield* put(fileStorageDidRemoveItem(change.key)); + } else if (isUpdateChange(change)) { + if (isFileMetadataUpdateChange(change)) { + if (change.oldObj.path !== change.obj.path) { + // TODO: need to introduce a DidCreate action + yield* put(fileStorageDidChangeItem(change.obj.path)); + yield* put(fileStorageDidRemoveItem(change.oldObj.path)); + } } - break; + } else if (isDeleteChange(change)) { + if (isFileMetaDataDeleteChange(change)) { + yield* put(fileStorageDidRemoveItem(change.oldObj.path)); + } + } } } /** * Handles requests to read a file. - * @param files The storage instance. + * @param db The database instance. * @param action The requested action. */ function* handleReadFile( - files: LocalForage, + db: FileStorageDb, action: ReturnType, ): Generator { try { - const value = yield* call(() => files.getItem(action.fileName)); + const file = yield* call(() => + db.transaction('r', db.metadata, db._contents, async () => { + const metadata = await db.metadata.get(action.fileName); - if (value === null) { + if (!metadata) { + return undefined; + } + + return db._contents.get(metadata.path); + }), + ); + + if (!file) { throw new Error('file does not exist'); } - yield* put(fileStorageDidReadFile(action.fileName, value)); + yield* put(fileStorageDidReadFile(action.fileName, file.contents)); } catch (err) { yield* put(fileStorageDidFailToReadFile(action.fileName, ensureError(err))); } @@ -78,15 +186,26 @@ function* handleReadFile( /** * Saves the file contents to storage. - * @param files The localForage instance. + * @param db The database instance. * @param action The action that triggered this saga. */ function* handleWriteFile( - files: LocalForage, + db: FileStorageDb, action: ReturnType, ) { try { - yield* call(() => files.setItem(action.fileName, action.fileContents)); + yield* call(() => + db.transaction('rw', db.metadata, db._contents, async () => { + await db.metadata.put({ + uuid: action.fileName, + path: action.fileName, + }); + await db._contents.put({ + path: action.fileName, + contents: action.fileContents, + }); + }), + ); yield* put(fileStorageDidWriteFile(action.fileName)); } catch (err) { yield* put(fileStorageDidFailToWriteFile(action.fileName, ensureError(err))); @@ -94,12 +213,22 @@ function* handleWriteFile( } function* handleExportFile( - files: LocalForage, + db: FileStorageDb, action: ReturnType, ): Generator { - const data = yield* call(() => files.getItem(action.fileName)); + const file = yield* call(() => + db.transaction('r', db.metadata, db._contents, async () => { + const metadata = await db.metadata.get(action.fileName); - if (data === null) { + if (!metadata) { + return undefined; + } + + return db._contents.get(metadata.path); + }), + ); + + if (!file) { yield* put( fileStorageDidFailToExportFile( action.fileName, @@ -109,7 +238,7 @@ function* handleExportFile( return; } - const blob = new Blob([data], { type: `${pythonFileMimeType}` }); + const blob = new Blob([file.contents], { type: `${pythonFileMimeType}` }); try { yield* call(() => @@ -131,15 +260,28 @@ function* handleExportFile( /** * Deletes a file from storage. - * @param files The localForage instance. + * @param db The database instance. * @param action The action that triggered this saga. */ function* handleDeleteFile( - files: LocalForage, + db: FileStorageDb, action: ReturnType, ) { try { - yield* call(() => files.removeItem(action.fileName)); + yield* call(() => + db.transaction('rw', db.metadata, db._contents, async () => { + const metadata = await db.metadata.get(action.fileName); + + if (!metadata) { + throw new Error( + `cannot rename: file '${action.fileName}' does not exist in db`, + ); + } + + await db.metadata.delete(action.fileName); + await db._contents.delete(metadata.path); + }), + ); yield* put(fileStorageDidDeleteFile(action.fileName)); } catch (err) { yield* put(fileStorageDidFailToDeleteFile(action.fileName, ensureError(err))); @@ -148,23 +290,46 @@ function* handleDeleteFile( /** * Renames a file in storage. - * @param files The localForage instance. + * @param db The database instance. * @param action The action that triggered this saga. */ function* handleRenameFile( - files: LocalForage, + db: FileStorageDb, action: ReturnType, ) { try { - yield* call(async () => { - // There is no move/rename API, so we have to make a copy with the - // new name and delete the old one. - // FIXME: This should be an atomic operation, e.g. if removing the - // old file fails, the new file should be removed. - const contents = await files.getItem(action.oldName); - await files.setItem(action.newName, contents); - await files.removeItem(action.oldName); - }); + yield* call(() => + db.transaction('rw', db.metadata, db._contents, async () => { + const metadata = await db.metadata.get(action.oldName); + + if (!metadata) { + throw new Error( + `cannot rename: file '${action.oldName}' does not exist in db`, + ); + } + + const oldFile = await db._contents.get(metadata.path); + + if (!oldFile) { + throw new Error( + `cannot rename: file '${action.oldName}' does not exist in storage`, + ); + } + + const newFile = await db._contents.get(action.newName); + + if (newFile) { + throw new Error( + `cannot rename: file '${action.newName}' already exists`, + ); + } + + await db._contents.delete(action.oldName); + await db._contents.add({ ...oldFile, path: action.newName }); + + await db.metadata.put({ ...metadata, path: action.newName }); + }), + ); yield* put(fileStorageDidRenameFile(action.oldName, action.newName)); } catch (err) { @@ -178,15 +343,11 @@ function* handleRenameFile( } } -function* handleArchiveAllFiles(files: LocalForage): Generator { +function* handleArchiveAllFiles(db: FileStorageDb): Generator { try { const zip = new JSZip(); - yield* call(() => - files.iterate((value, key) => { - zip.file(key, value); - }), - ); + yield* call(() => db._contents.each((f) => zip.file(f.path, f.contents))); const zipData = yield* call(() => zip.generateAsync({ type: 'blob' })); @@ -213,64 +374,62 @@ function* handleArchiveAllFiles(files: LocalForage): Generator { * Initializes the storage backend. */ function* initialize(): Generator { + const defer = new Array<(...args: unknown[]) => unknown>(); + try { - // set up storage - - const files = extendPrototype( - localForage.createInstance({ name: 'fileStorage' }), - ); - - files.newObservable.factory = (subscribe) => - // @ts-expect-error localforage-observable Subscription is missing - // closed property compared to zen-observable Subscription. - new Observable(subscribe); - - yield* call(() => files.ready()); + const db = new FileStorageDb(); // migrate from old storage - // Previous versions of pybricks code used local storage to save a single program. - const oldProgram = localStorage.getItem('program'); + // NB: this is a one-shot event, so we don't need to unsubscribe + db.on('ready', async () => { + // Previous versions of pybricks code used local storage to save a single program. + const oldProgram = localStorage.getItem('program'); - if (oldProgram !== null) { - yield* call(() => files.setItem('main.py', oldProgram)); - localStorage.removeItem('program'); - } + if (oldProgram !== null) { + await db.transaction('rw', db.metadata, db._contents, async () => { + await db.metadata.add({ uuid: 'main.py', path: 'main.py' }); + await db._contents.add({ path: 'main.py', contents: oldProgram }); + }); + localStorage.removeItem('program'); + } + }); + + yield* call(() => db.open()); + defer.push(() => db.close()); // wire storage observable to redux-sagas - files.configObservables({ - crossTabNotification: true, - crossTabChangeDetection: true, + const changesChan = eventChannel((emit) => { + db.on('changes').subscribe(emit); + return () => db.on('changes').unsubscribe(emit); }); - const localForageChannel = eventChannel((emit) => { - const filesObservable = files.newObservable({ - crossTabNotification: true, - }); - - const subscription = filesObservable.subscribe({ - next: (value) => emit(value), - }); - - return () => subscription.unsubscribe(); - }); + defer.push(() => changesChan.close()); // subscribe to events - yield* takeEvery(localForageChannel, handleFileStorageDidChange); - yield* takeEvery(fileStorageReadFile, handleReadFile, files); - yield* takeEvery(fileStorageWriteFile, handleWriteFile, files); - yield* takeEvery(fileStorageDeleteFile, handleDeleteFile, files); - yield* takeEvery(fileStorageRenameFile, handleRenameFile, files); - yield* takeEvery(fileStorageExportFile, handleExportFile, files); - yield* takeEvery(fileStorageArchiveAllFiles, handleArchiveAllFiles, files); + yield* takeEvery(changesChan, handleFileStorageDidChange); + yield* takeEvery(fileStorageReadFile, handleReadFile, db); + yield* takeEvery(fileStorageWriteFile, handleWriteFile, db); + yield* takeEvery(fileStorageDeleteFile, handleDeleteFile, db); + yield* takeEvery(fileStorageRenameFile, handleRenameFile, db); + yield* takeEvery(fileStorageExportFile, handleExportFile, db); + yield* takeEvery(fileStorageArchiveAllFiles, handleArchiveAllFiles, db); - const fileNames = yield* call(() => files.keys()); + const files = yield* call(() => db.metadata.toArray()); - yield* put(fileStorageDidInitialize(fileNames)); + yield* put(fileStorageDidInitialize(files.map((f) => f.path))); + + // this blocks "forever" until canceled so that the finally + // clause will run cleanup code at the appropriate time + yield* take('__never__'); } catch (err) { yield* put(fileStorageDidFailToInitialize(ensureError(err))); + } finally { + for (const callback of defer.reverse()) { + yield* call(callback); + } } } diff --git a/yarn.lock b/yarn.lock index cad480e9..ea6efd96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2131,6 +2131,8 @@ __metadata: browser-fs-access: ^0.25.0 canvas: ^2.9.0 copy-webpack-plugin: ^6.4.1 + dexie: ^3.2.1 + dexie-observable: ^3.0.0-beta.11 eslint: ^7.31.0 eslint-config-prettier: ^7.2.0 eslint-config-typed-fp: ^1.6.0 @@ -2139,11 +2141,10 @@ __metadata: eslint-plugin-prettier: ^4.0.0 eslint-plugin-react: ^7.29.4 eslint-plugin-total-functions: ^4.10.1 + fake-indexeddb: ^3.1.7 jest-mock-extended: ^2.0.4 jszip: ^3.7.1 license-webpack-plugin: ^3.0.0 - localforage: ^1.10.0 - localforage-observable: ^2.1.1 monaco-editor: ^0.30.1 monaco-editor-webpack-plugin: ^6.0.0 monaco-themes: ^0.4.0 @@ -4328,6 +4329,13 @@ __metadata: languageName: node linkType: hard +"base64-arraybuffer-es6@npm:^0.7.0": + version: 0.7.0 + resolution: "base64-arraybuffer-es6@npm:0.7.0" + checksum: 6d2fd114df49201b476cea5d470504e5d4e8c4cd42544152b312c9bdcb824313086fe83f1ffc34262e9e276b82d46aefc6e63bb85553f016932061137b355cdf + languageName: node + linkType: hard + "base64-js@npm:^1.0.2": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -5526,7 +5534,7 @@ __metadata: languageName: node linkType: hard -"core-js@npm:^3.6.5": +"core-js@npm:^3.4, core-js@npm:^3.6.5": version: 3.21.1 resolution: "core-js@npm:3.21.1" checksum: d68eddd831340ad5b24ac29c72fda022a43b17f194c4278b6b875a843283d316502cb4abd07f28631d6ebc4387f66aa06e2b1b3c8fd7e08096a751b5c63f6889 @@ -6285,6 +6293,22 @@ __metadata: languageName: node linkType: hard +"dexie-observable@npm:^3.0.0-beta.11": + version: 3.0.0-beta.11 + resolution: "dexie-observable@npm:3.0.0-beta.11" + peerDependencies: + dexie: ^3.0.2 + checksum: af154708ca5a47d3c35a78fdf5b1a0935d1f8a394dbaaf09576344289188f02855f77776428bf68deb1d2489b3f69d9ca8690f76d06b50826ca471fefd5610a1 + languageName: node + linkType: hard + +"dexie@npm:^3.2.1": + version: 3.2.1 + resolution: "dexie@npm:3.2.1" + checksum: ba2608005de06b129460cd6c3bfc2ca22a8bcccc0d319b621775b93c82e932f2292b59843dffbecc32549aca789af1497b253b7082cf3da6d3c34eca99887283 + languageName: node + linkType: hard + "diff-sequences@npm:^26.6.2": version: 26.6.2 resolution: "diff-sequences@npm:26.6.2" @@ -6445,6 +6469,15 @@ __metadata: languageName: node linkType: hard +"domexception@npm:^1.0.1": + version: 1.0.1 + resolution: "domexception@npm:1.0.1" + dependencies: + webidl-conversions: ^4.0.2 + checksum: f564a9c0915dcb83ceefea49df14aaed106b1468fbe505119e8bcb0b77e242534f3aba861978537c0fc9dc6f35b176d0ffc77b3e342820fb27a8f215e7ae4d52 + languageName: node + linkType: hard + "domexception@npm:^2.0.1": version: 2.0.1 resolution: "domexception@npm:2.0.1" @@ -7497,6 +7530,15 @@ __metadata: languageName: node linkType: hard +"fake-indexeddb@npm:^3.1.7": + version: 3.1.7 + resolution: "fake-indexeddb@npm:3.1.7" + dependencies: + realistic-structured-clone: ^2.0.1 + checksum: bd1663c9e27858de3ce6217721eeb0e802a3910bf521a509caaff509ca8c490efda6842d19de86488c43cf5cf5f55a7fdadcefc5e7d591a0976e606ead5000fd + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -10483,15 +10525,6 @@ __metadata: languageName: node linkType: hard -"lie@npm:3.1.1": - version: 3.1.1 - resolution: "lie@npm:3.1.1" - dependencies: - immediate: ~3.0.5 - checksum: 6da9f2121d2dbd15f1eca44c0c7e211e66a99c7b326ec8312645f3648935bc3a658cf0e9fa7b5f10144d9e2641500b4f55bd32754607c3de945b5f443e50ddd1 - languageName: node - linkType: hard - "lie@npm:~3.3.0": version: 3.3.0 resolution: "lie@npm:3.3.0" @@ -10559,25 +10592,6 @@ __metadata: languageName: node linkType: hard -"localforage-observable@npm:^2.1.1": - version: 2.1.1 - resolution: "localforage-observable@npm:2.1.1" - dependencies: - localforage: ^1.5.0 - zen-observable: ^0.2.1 - checksum: 1b36b2624de7cfde540176b52b1f73113df3ae34b9e020a3452854c4dbd6aca0f69a9aab449bf8a0dbc95a2ce098e269ebfe2e1cd29e1e92b64d20c6ec35dd24 - languageName: node - linkType: hard - -"localforage@npm:^1.10.0, localforage@npm:^1.5.0": - version: 1.10.0 - resolution: "localforage@npm:1.10.0" - dependencies: - lie: 3.1.1 - checksum: f2978b434dafff9bcb0d9498de57d97eba165402419939c944412e179cab1854782830b5ec196212560b22712d1dd03918939f59cf1d4fc1d756fca7950086cf - languageName: node - linkType: hard - "locate-path@npm:^2.0.0": version: 2.0.0 resolution: "locate-path@npm:2.0.0" @@ -13987,6 +14001,18 @@ __metadata: languageName: node linkType: hard +"realistic-structured-clone@npm:^2.0.1": + version: 2.0.4 + resolution: "realistic-structured-clone@npm:2.0.4" + dependencies: + core-js: ^3.4 + domexception: ^1.0.1 + typeson: ^6.1.0 + typeson-registry: ^1.0.0-alpha.20 + checksum: 0174efd3a3046bd084b8f459b2ce718750a1d90099a9e502fd6ec88483d92b4e784143cdbb7e7d1b7daef2d2d2ced6eaebf0f54f0f4a143eef9b03e1c4f13982 + languageName: node + linkType: hard + "recursive-readdir@npm:2.2.2": version: 2.2.2 resolution: "recursive-readdir@npm:2.2.2" @@ -16394,6 +16420,24 @@ __metadata: languageName: node linkType: hard +"typeson-registry@npm:^1.0.0-alpha.20": + version: 1.0.0-alpha.39 + resolution: "typeson-registry@npm:1.0.0-alpha.39" + dependencies: + base64-arraybuffer-es6: ^0.7.0 + typeson: ^6.0.0 + whatwg-url: ^8.4.0 + checksum: c6b629697acf4652aecfff7be760356d764600afc9beca253278bbfc44fae0fe635b7619201b83e497cdc30645cbce7614d12a04b5726d9b8b505f73e6a3fc2a + languageName: node + linkType: hard + +"typeson@npm:^6.0.0, typeson@npm:^6.1.0": + version: 6.1.0 + resolution: "typeson@npm:6.1.0" + checksum: 00a77b03ac8f704acb103307bad9295fe47d6b304c386297f078ec3be63875c0b81e022a4815edb9dc2c7da0a72a431345411d35c755a8510af4a420e9e46cdc + languageName: node + linkType: hard + "unbox-primitive@npm:^1.0.1": version: 1.0.1 resolution: "unbox-primitive@npm:1.0.1" @@ -16853,6 +16897,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^4.0.2": + version: 4.0.2 + resolution: "webidl-conversions@npm:4.0.2" + checksum: c93d8dfe908a0140a4ae9c0ebc87a33805b416a33ee638a605b551523eec94a9632165e54632f6d57a39c5f948c4bab10e0e066525e9a4b87a79f0d04fbca374 + languageName: node + linkType: hard + "webidl-conversions@npm:^5.0.0": version: 5.0.0 resolution: "webidl-conversions@npm:5.0.0" @@ -17062,7 +17113,7 @@ __metadata: languageName: node linkType: hard -"whatwg-url@npm:^8.0.0, whatwg-url@npm:^8.5.0": +"whatwg-url@npm:^8.0.0, whatwg-url@npm:^8.4.0, whatwg-url@npm:^8.5.0": version: 8.7.0 resolution: "whatwg-url@npm:8.7.0" dependencies: @@ -17544,13 +17595,6 @@ __metadata: languageName: node linkType: hard -"zen-observable@npm:^0.2.1": - version: 0.2.1 - resolution: "zen-observable@npm:0.2.1" - checksum: 40d3391c5a8de5a863980a732f956e4ac9aca7baaeff9825b4f94e4cc0ab1e1f7b609eff49dd01259bbe34345d4330168deaa265347b5a1553d7d37ff4be69ae - languageName: node - linkType: hard - "zen-observable@npm:^0.7.0": version: 0.7.1 resolution: "zen-observable@npm:0.7.1"