fileStorage: use dexie-react-hooks

This lets the dexie-react-hooks library do more of the work for us
instead of having to make redux-sagas wrappers for everything.
This commit is contained in:
David Lechner
2022-05-18 17:06:08 -05:00
parent 4580869e6f
commit a005992a21
22 changed files with 183 additions and 464 deletions
+1
View File
@@ -124,6 +124,7 @@ const licenseTextOverrides = {
'@shopify/react-hooks': shopifyLicense,
'@shopify/react-i18n': shopifyLicense,
'dexie-observable': dexieLicense,
'dexie-react-hooks': dexieLicense,
'bn.js': fedorIndutnyLicense(2015),
brorand: fedorIndutnyLicense(2014),
'des.js': fedorIndutnyLicense(2015),
+1
View File
@@ -50,6 +50,7 @@
"css-minimizer-webpack-plugin": "^3.2.0",
"dexie": "^3.2.1",
"dexie-observable": "^4.0.0-beta.13",
"dexie-react-hooks": "^1.1.1",
"dotenv": "^10.0.0",
"dotenv-expand": "^5.1.0",
"eslint": "^8.3.0",
+24 -31
View File
@@ -5,6 +5,8 @@ import { cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { testRender, uuid } from '../../test';
import { FileMetadata } from '../fileStorage';
import { useFileStorageMetadata } from '../fileStorage/hooks';
import Explorer from './Explorer';
import {
explorerActivateFile,
@@ -15,7 +17,6 @@ import {
explorerExportFile,
explorerImportFiles,
} from './actions';
import { ExplorerFileInfo } from './reducers';
afterEach(async () => {
jest.restoreAllMocks();
@@ -23,16 +24,16 @@ afterEach(async () => {
localStorage.clear();
});
const testFile: ExplorerFileInfo = {
id: uuid(0),
name: 'test.file',
const testFile: FileMetadata = {
uuid: uuid(0),
path: 'test.file',
sha256: '',
};
describe('archive button', () => {
it('should dispatch action when clicked', () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const button = explorer.getByTitle('Backup all files');
expect(button).toBeEnabled();
@@ -66,9 +67,8 @@ describe('new file button', () => {
describe('tree item', () => {
it('should dispatch action when clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
@@ -78,9 +78,8 @@ describe('tree item', () => {
});
it('should dispatch action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
@@ -92,9 +91,8 @@ describe('tree item', () => {
describe('duplicate', () => {
it('should dispatch action when button is clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
// NB: this button is intentionally not accessible (by role) since
// there is a keyboard shortcut.
@@ -109,9 +107,8 @@ describe('tree item', () => {
});
it('should dispatch action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
@@ -124,9 +121,8 @@ describe('tree item', () => {
describe('export', () => {
it('should dispatch export action when button is clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
// NB: this button is intentionally not accessible (by role) since
// there is a keyboard shortcut.
@@ -141,9 +137,8 @@ describe('tree item', () => {
});
it('should dispatch export action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
@@ -156,9 +151,8 @@ describe('tree item', () => {
describe('delete', () => {
it('should dispatch delete action when button is clicked', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
// NB: this button is intentionally not accessible (by role) since
// there is a keyboard shortcut.
@@ -173,9 +167,8 @@ describe('tree item', () => {
});
it('should dispatch delete action when key is pressed', async () => {
const [explorer, dispatch] = testRender(<Explorer />, {
explorer: { files: [testFile] },
});
jest.mocked(useFileStorageMetadata).mockReturnValue([testFile]);
const [explorer, dispatch] = testRender(<Explorer />);
const treeItem = explorer.getByRole('treeitem', { name: 'test.file' });
+5 -8
View File
@@ -27,7 +27,7 @@ import {
import { useDispatch } from 'react-redux';
import { Toolbar } from '../components/toolbar/Toolbar';
import { useToolbarItemFocus } from '../components/toolbar/aria';
import { useSelector } from '../reducers';
import { useFileStorageMetadata } from '../fileStorage/hooks';
import { isMacOS } from '../utils/os';
import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer';
import {
@@ -308,7 +308,7 @@ type FileTreeProps = {
const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
const [focusedItem, setFocusedItem] = useState<TreeItemIndex>();
const files = useSelector((s) => s.explorer.files);
const files = useFileStorageMetadata() ?? [];
const liveDescriptors = useLiveDescriptors(i18n);
const rootItemIndex = 'root';
@@ -317,12 +317,12 @@ const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
() =>
files.reduce(
(obj, file) => {
const index = file.id;
const index = file.uuid;
obj[index] = {
index,
data: {
fileName: file.name,
fileName: file.path,
icon: 'document',
secondaryLabel: (
<TreeItemContext.Consumer>
@@ -344,10 +344,7 @@ const FileTree: React.VoidFunctionComponent<FileTreeProps> = ({ i18n }) => {
index: rootItemIndex,
data: { fileName: '/' },
hasChildren: true,
children: [...files]
// REVISIT: consider using Intl.Collator() for i18n.locale
.sort((a, b) => a.name.localeCompare(b.name))
.map((n) => n.id),
children: [...files].map((f) => f.uuid),
},
} as Record<TreeItemIndex, FileTreeItem>,
),
@@ -5,6 +5,7 @@ import { Button, Classes, Dialog } from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useFileStorageMetadata } from '../../fileStorage/hooks';
import {
FileNameValidationResult,
validateFileName,
@@ -24,11 +25,11 @@ const DuplicateFileDialog: React.VFC = () => {
const [baseName, extension] = oldName.split(/(\.\w+)$/);
const [newName, setNewName] = useState(baseName);
const files = useSelector((s) => s.explorer.files);
const files = useFileStorageMetadata() ?? [];
const result = validateFileName(
newName,
extension,
files.map((f) => f.name),
files.map((f) => f.path),
);
const inputRef = useRef<HTMLInputElement>(null);
+3 -2
View File
@@ -12,6 +12,7 @@ import {
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useFileStorageMetadata } from '../../fileStorage/hooks';
import {
FileNameValidationResult,
pythonFileExtension,
@@ -33,11 +34,11 @@ const NewFileWizard: React.VoidFunctionComponent = () => {
const isOpen = useSelector((s) => s.explorer.newFileWizard.isOpen);
const [fileName, setFileName] = useState('');
const files = useSelector((s) => s.explorer.files);
const files = useFileStorageMetadata() ?? [];
const fileNameValidation = validateFileName(
fileName,
pythonFileExtension,
files.map((f) => f.name),
files.map((f) => f.path),
);
const [hubType, setHubType] = useState(defaultHub);
-92
View File
@@ -1,92 +0,0 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { uuid } from '../../test';
import {
FileMetadata,
fileStorageDidAddItem,
fileStorageDidChangeItem,
fileStorageDidInitialize,
fileStorageDidRemoveItem,
} from '../fileStorage/actions';
import reducers, { ExplorerFileInfo } from './reducers';
type State = ReturnType<typeof reducers>;
describe('files', () => {
const testFile: ExplorerFileInfo = {
id: uuid(0),
name: 'test.file',
};
const testFileMetadata: FileMetadata = {
uuid: uuid(0),
path: 'test.file',
sha256: '',
};
const modifiedFile: ExplorerFileInfo = {
...testFile,
name: 'modified.file',
};
const modifiedFileMetadata: FileMetadata = {
...testFileMetadata,
path: 'modified.file',
};
beforeAll(() => {
// check validity of test data before starting tests
expect(testFile).not.toEqual(modifiedFile);
expect(testFileMetadata).not.toEqual(modifiedFileMetadata);
});
it('should get a list when file storage is initialized', () => {
expect(
reducers(
{ files: [] as readonly ExplorerFileInfo[] } as State,
fileStorageDidInitialize([testFileMetadata]),
).files,
).toEqual([testFile]);
});
it('should modify the list when a file is added to storage', () => {
expect(
reducers(
{ files: [] as readonly ExplorerFileInfo[] } as State,
fileStorageDidAddItem(testFileMetadata),
).files,
).toEqual([testFile]);
});
it('should modify the list when an item is renamed in storage', () => {
expect(
reducers(
{ files: [testFile] as readonly ExplorerFileInfo[] } as State,
fileStorageDidChangeItem(testFileMetadata, modifiedFileMetadata),
).files,
).toEqual([modifiedFile]);
});
it('should not modify the list if a change is made other than renaming', () => {
const originalList: readonly ExplorerFileInfo[] = [testFile];
expect(
reducers(
{ files: originalList } as State,
fileStorageDidChangeItem(testFileMetadata, {
...testFileMetadata,
sha256: 'changed',
}),
).files,
).toBe(originalList);
});
it('should modify the list when a file is removed from storage', () => {
expect(
reducers(
{ files: [testFile] as readonly ExplorerFileInfo[] } as State,
fileStorageDidRemoveItem(testFileMetadata),
).files,
).not.toContain(testFile);
});
});
+1 -48
View File
@@ -1,61 +1,14 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Reducer, combineReducers } from 'redux';
import {
FileMetadata,
fileStorageDidAddItem,
fileStorageDidChangeItem,
fileStorageDidInitialize,
fileStorageDidRemoveItem,
} from '../fileStorage/actions';
import { combineReducers } from 'redux';
import deleteFileAlert from './deleteFileAlert/reducers';
import duplicateFileDialog from './duplicateFileDialog/reducers';
import newFileWizard from './newFileWizard/reducers';
import renameFileDialog from './renameFileDialog/reducers';
export type ExplorerFileInfo = Readonly<{
/** A unique identifier for this file (not the path, which can change). */
id: string;
/** The file name (including extension - without directory). */
name: string;
}>;
function metadataToInfo(file: FileMetadata): ExplorerFileInfo {
return { id: file.uuid, name: file.path };
}
const files: Reducer<readonly ExplorerFileInfo[]> = (state = [], action) => {
if (fileStorageDidInitialize.matches(action)) {
return action.files.map(metadataToInfo);
}
if (fileStorageDidAddItem.matches(action)) {
return [...state, metadataToInfo(action.file)];
}
if (fileStorageDidChangeItem.matches(action)) {
// We only care about UUID and the path. UUID can't change so if the
// path didn't change, there is nothing to do.
if (action.oldFile.path === action.file.path) {
return state;
}
return state.map((f) =>
f.id === action.file.uuid ? metadataToInfo(action.file) : f,
);
}
if (fileStorageDidRemoveItem.matches(action)) {
return state.filter((value) => value.id !== action.file.uuid);
}
return state;
};
export default combineReducers({
files,
duplicateFileDialog,
deleteFileAlert,
newFileWizard,
@@ -5,6 +5,7 @@ import { Button, Classes, Dialog } from '@blueprintjs/core';
import { useI18n } from '@shopify/react-i18n';
import React, { useCallback, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useFileStorageMetadata } from '../../fileStorage/hooks';
import {
FileNameValidationResult,
validateFileName,
@@ -24,11 +25,11 @@ const RenameFileDialog: React.VFC = () => {
const [baseName, extension] = oldName.split(/(\.\w+)$/);
const [newName, setNewName] = useState(baseName);
const files = useSelector((s) => s.explorer.files);
const files = useFileStorageMetadata() ?? [];
const result = validateFileName(
newName,
extension,
files.map((f) => f.name),
files.map((f) => f.path),
);
const inputRef = useRef<HTMLInputElement>(null);
+1 -18
View File
@@ -4,7 +4,7 @@
import * as browserFsAccess from 'browser-fs-access';
import { FileWithHandle } from 'browser-fs-access';
import { mock } from 'jest-mock-extended';
import { AsyncSaga, uuid } from '../../test';
import { AsyncSaga } from '../../test';
import {
editorActivateFile,
editorCloseFile,
@@ -23,7 +23,6 @@ import {
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToReadFile,
fileStorageDidReadFile,
fileStorageDidRemoveItem,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageReadFile,
@@ -399,22 +398,6 @@ describe('handleExplorerDeleteFile', () => {
);
});
it('should fail with AbortError if file was removed before user accept/cancel', async () => {
saga.put(
fileStorageDidRemoveItem({ path: testFile, uuid: uuid(0), sha256: '' }),
);
// should programmatically cancel the dialog
await expect(saga.take()).resolves.toEqual(deleteFileAlertDidCancel());
await expect(saga.take()).resolves.toEqual(
explorerDidFailToDeleteFile(
testFile,
new DOMException('file was removed', 'AbortError'),
),
);
});
describe('accepted', () => {
beforeEach(async () => {
saga.put(deleteFileAlertDidAccept());
+3 -20
View File
@@ -3,7 +3,7 @@
import { fileOpen, fileSave } from 'browser-fs-access';
import JSZip from 'jszip';
import { call, put, race, select, take, takeEvery } from 'typed-redux-saga/macro';
import { call, put, race, take, takeEvery } from 'typed-redux-saga/macro';
import {
editorActivateFile,
editorCloseFile,
@@ -24,7 +24,6 @@ import {
fileStorageDidFailToReadFile,
fileStorageDidFailToWriteFile,
fileStorageDidReadFile,
fileStorageDidRemoveItem,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageReadFile,
@@ -37,7 +36,6 @@ import {
pythonFileMimeType,
validateFileName,
} from '../pybricksMicropython/lib';
import { RootState } from '../reducers';
import { defined, ensureError, timestamp } from '../utils';
import {
explorerActivateFile,
@@ -143,13 +141,8 @@ function* handleExplorerImportFiles(): Generator {
const text = yield* call(() => file.text());
const [baseName] = file.name.split(pythonFileExtensionRegex);
const existingFiles = yield* select((s: RootState) => s.explorer.files);
const result = validateFileName(
baseName,
pythonFileExtension,
existingFiles.map((f) => f.name),
);
const result = validateFileName(baseName, pythonFileExtension, []);
if (result != FileNameValidationResult.IsOk) {
// TODO: validate file name and allow user to rename or skip
@@ -347,25 +340,15 @@ function* handleExplorerDeleteFile(action: ReturnType<typeof explorerDeleteFile>
try {
yield* put(deleteFileAlertShow(action.fileName));
const { didCancel, didRemove } = yield* race({
const { didCancel } = yield* race({
didAccept: take(deleteFileAlertDidAccept),
didCancel: take(deleteFileAlertDidCancel),
didRemove: take(
fileStorageDidRemoveItem.when((a) => a.file.path === action.fileName),
),
});
if (didCancel) {
throw new DOMException('user canceled', 'AbortError');
}
// automatically cancel the dialog, if the file was removed, e.g. it was
// deleted in a different window
if (didRemove) {
yield* put(deleteFileAlertDidCancel());
throw new DOMException('file was removed', 'AbortError');
}
// at this point we know the user accepted
// have to close editor before deleting, otherwise we get "in use" error
+1 -48
View File
@@ -2,6 +2,7 @@
// Copyright (c) 2022 The Pybricks Authors
import { createAction } from '../actions';
import { FileMetadata } from '.';
/** File open modes. */
export type FileOpenMode = 'r' | 'w';
@@ -9,23 +10,6 @@ export type FileOpenMode = 'r' | 'w';
/** Type to avoid mixing up file descriptor with number. */
export type FD = number & { _fdBrand: undefined };
/** Type to avoid mixing UUID with regular string. */
export type UUID = string & { _uuidBrand: undefined };
/**
* Database metadata table data type.
*
* IMPORTANT: if this type is changed, we need to modify the database schema to match
*/
export type FileMetadata = Readonly<{
/** A globally unique identifier that serves a a file handle. */
uuid: UUID;
/** The path of the file in storage. */
path: string;
/** The SHA256 hash of the file contents. */
sha256: string;
}>;
/**
* Action that indicates that the storage backend is ready to use.
* @param files List of all files currently in storage.
@@ -46,37 +30,6 @@ export const fileStorageDidFailToInitialize = createAction((error: Error) => ({
error,
}));
/**
* Action that indicates that an item in the storage was created by us or in another tab.
* @param file The file metadata.
*/
export const fileStorageDidAddItem = createAction((file: FileMetadata) => ({
type: 'fileStorage.action.didAddItem',
file,
}));
/**
* Action that indicates that an item in the storage was changed by us or in another tab.
* @param file The old file metadata.
* @param file The file metadata.
*/
export const fileStorageDidChangeItem = createAction(
(oldFile: FileMetadata, file: FileMetadata) => ({
type: 'fileStorage.action.didChangeItem',
oldFile,
file,
}),
);
/**
* Action that indicates that an item in the storage was removed by us or in another tab.
* @param file The file metadata.
*/
export const fileStorageDidRemoveItem = createAction((file: FileMetadata) => ({
type: 'fileStorage.action.didRemoveItem',
file,
}));
/**
* Action that requests to open a file in storage.
* @param path The file path.
+9
View File
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { createContext } from 'react';
import { FileStorageDb } from '.';
export const db = new FileStorageDb('pybricks.fileStorage');
export const FileStorageContext = createContext(db);
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { useLiveQuery } from 'dexie-react-hooks';
import { useContext } from 'react';
import { FileStorageContext } from './context';
import { FileMetadata, UUID } from '.';
/**
* Gets all file metadata for all files currently in storage.
*
* The returned array is sorted by the path.
*/
export function useFileStorageMetadata(): FileMetadata[] | undefined {
const db = useContext(FileStorageContext);
return useLiveQuery(() => db.metadata.orderBy('path').toArray());
}
/**
* Gets the file path for a file UUID.
*
* If the file is renamed, the returned value will be automatically updated.
*/
export function useFileStoragePath(uuid: UUID): string | undefined {
const db = useContext(FileStorageContext);
return useLiveQuery(() => db.metadata.get(uuid, (x) => x?.path));
}
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import 'dexie-observable';
import Dexie, { Table } from 'dexie';
/** Type to avoid mixing UUID with regular string. */
export type UUID = string & { _uuidBrand: undefined };
/**
* Database metadata table data type.
*
* IMPORTANT: if this type is changed, we need to modify the database schema to match
*/
export type FileMetadata = Readonly<{
/** A globally unique identifier that serves a a file handle. */
uuid: UUID;
/** The path of the file in storage. */
path: string;
/** The SHA256 hash of the file contents. */
sha256: string;
}>;
/**
* Database contents table data type.
*
* IMPORTANT: if this type is changed, we need to modify the database schema to match
*/
type FileContents = {
/** The path of the file in storage. */
path: string;
/** The contents of the file. */
contents: string;
};
export class FileStorageDb extends Dexie {
metadata!: Table<FileMetadata, UUID>;
// 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<FileContents, string>;
constructor(databaseName: string) {
super(databaseName);
this.version(1).stores({
metadata: '$$uuid, &path, sha256',
_contents: 'path, contents',
});
}
}
+19 -66
View File
@@ -8,13 +8,10 @@ import { AsyncSaga, uuid } from '../../test';
import { createCountFunc } from '../utils/iter';
import {
FD,
FileMetadata,
FileOpenMode,
fileStorageClose,
fileStorageCopyFile,
fileStorageDeleteFile,
fileStorageDidAddItem,
fileStorageDidChangeItem,
fileStorageDidClose,
fileStorageDidCopyFile,
fileStorageDidDeleteFile,
@@ -33,7 +30,6 @@ import {
fileStorageDidOpen,
fileStorageDidRead,
fileStorageDidReadFile,
fileStorageDidRemoveItem,
fileStorageDidRenameFile,
fileStorageDidWrite,
fileStorageDidWriteFile,
@@ -46,10 +42,7 @@ import {
fileStorageWriteFile,
} from './actions';
import fileStorage from './sagas';
/** SHA256 hash of '' */
const emptyFileSha256 =
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
import { FileMetadata, FileStorageDb } from '.';
beforeEach(() => {
// deterministic UUID generator for repeatable tests
@@ -61,7 +54,7 @@ afterEach(async () => {
jest.restoreAllMocks();
await new Promise((resolve, reject) => {
const request = indexedDB.deleteDatabase('pybricks.fileStorage');
const request = indexedDB.deleteDatabase('test');
request.addEventListener('success', resolve);
request.addEventListener('error', reject);
});
@@ -87,8 +80,6 @@ async function setUpTestFile(saga: AsyncSaga): Promise<[FileMetadata, string]> {
sha256: testFileContentsSha256,
};
const emptyFile: FileMetadata = { ...testFile, sha256: emptyFileSha256 };
saga.put(fileStorageOpen(testFilePath, 'w', true));
const didOpen = await saga.take();
@@ -97,14 +88,9 @@ async function setUpTestFile(saga: AsyncSaga): Promise<[FileMetadata, string]> {
fail(didOpen);
}
await expect(saga.take()).resolves.toEqual(fileStorageDidAddItem(emptyFile));
saga.put(fileStorageWrite(didOpen.fd, testFileContents));
await expect(saga.take()).resolves.toEqual(fileStorageDidWrite(didOpen.fd));
await expect(saga.take()).resolves.toEqual(
fileStorageDidChangeItem(emptyFile, testFile),
);
saga.put(fileStorageClose(didOpen.fd));
@@ -124,7 +110,9 @@ describe('initialize', () => {
localStorage.setItem(oldProgramKey, oldProgramContents);
expect(localStorage.getItem(oldProgramKey)).toBe(oldProgramContents);
const saga = new AsyncSaga(fileStorage);
const saga = new AsyncSaga(fileStorage, {
fileStorage: new FileStorageDb('test'),
});
// initialization should remove the localStorage entry and add add it to
// new storage backend
@@ -145,7 +133,9 @@ describe('initialize', () => {
throw testError;
});
const saga = new AsyncSaga(fileStorage);
const saga = new AsyncSaga(fileStorage, {
fileStorage: new FileStorageDb('test'),
});
await expect(saga.take()).resolves.toEqual(
fileStorageDidFailToInitialize(testError),
@@ -159,7 +149,7 @@ describe('open', () => {
let saga: AsyncSaga;
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
});
@@ -182,14 +172,6 @@ describe('open', () => {
await expect(saga.take()).resolves.toEqual(
fileStorageDidOpen('test.file', 0 as FD),
);
await expect(saga.take()).resolves.toEqual(
fileStorageDidAddItem({
uuid: uuid(0),
path: 'test.file',
sha256: emptyFileSha256,
}),
);
});
describe('should fail to open if file is already open for writing', () => {
@@ -268,14 +250,6 @@ describe('open', () => {
fileStorageDidOpen('test.file', 0 as FD),
);
await expect(saga.take()).resolves.toEqual(
fileStorageDidAddItem({
uuid: uuid(0),
path: 'test.file',
sha256: emptyFileSha256,
}),
);
saga.put(fileStorageClose(0 as FD));
await expect(saga.take()).resolves.toEqual(fileStorageDidClose(0 as FD));
@@ -294,7 +268,7 @@ describe('read', () => {
let saga: AsyncSaga;
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
});
@@ -350,7 +324,7 @@ describe('write', () => {
let saga: AsyncSaga;
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
});
@@ -421,7 +395,7 @@ describe('readFile', () => {
let saga: AsyncSaga;
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
saga.put(fileStorageReadFile('test.file'));
@@ -488,7 +462,7 @@ describe('writeFile', () => {
const contents = 'test write file contents';
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
saga.put(fileStorageWriteFile('test.file', contents));
@@ -553,13 +527,12 @@ describe('writeFile', () => {
describe('copyFile', () => {
let saga: AsyncSaga;
let testFile: FileMetadata;
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
[testFile] = await setUpTestFile(saga);
await setUpTestFile(saga);
});
it('should fail if file does not exist', async () => {
@@ -612,10 +585,6 @@ describe('copyFile', () => {
saga.put(fileStorageCopyFile('test.file', 'new.file'));
await expect(saga.take()).resolves.toEqual(fileStorageDidCopyFile('test.file'));
await expect(saga.take()).resolves.toEqual(
fileStorageDidAddItem({ ...testFile, uuid: uuid(1), path: 'new.file' }),
);
});
afterEach(async () => {
@@ -625,13 +594,12 @@ describe('copyFile', () => {
describe('deleteFile', () => {
let saga: AsyncSaga;
let testFile: FileMetadata;
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
[testFile] = await setUpTestFile(saga);
await setUpTestFile(saga);
});
it('should fail if file does not exist', async () => {
@@ -667,8 +635,6 @@ describe('deleteFile', () => {
await expect(saga.take()).resolves.toEqual(
fileStorageDidDeleteFile('test.file'),
);
await expect(saga.take()).resolves.toEqual(fileStorageDidRemoveItem(testFile));
});
afterEach(async () => {
@@ -682,7 +648,7 @@ describe('renameFile', () => {
const newPath = 'new.file';
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
[testFile] = await setUpTestFile(saga);
@@ -724,13 +690,6 @@ describe('renameFile', () => {
await expect(saga.take()).resolves.toEqual(
fileStorageDidOpen(newPath, 1 as FD),
);
await expect(saga.take()).resolves.toEqual(
fileStorageDidAddItem({
uuid: uuid(1),
path: newPath,
sha256: emptyFileSha256,
}),
);
saga.put(fileStorageRenameFile('test.file', newPath));
@@ -760,15 +719,9 @@ describe('renameFile', () => {
it('should change file', async () => {
saga.put(fileStorageRenameFile(testFile.path, newPath));
const newMetadata: FileMetadata = { ...testFile, path: newPath };
await expect(saga.take()).resolves.toEqual(
fileStorageDidRenameFile(testFile.path),
);
await expect(saga.take()).resolves.toEqual(
fileStorageDidChangeItem(testFile, newMetadata),
);
});
afterEach(async () => {
@@ -782,7 +735,7 @@ describe('dump all files', () => {
let testFileContents: string;
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
saga = new AsyncSaga(fileStorage, { fileStorage: new FileStorageDb('test') });
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
[testFile, testFileContents] = await setUpTestFile(saga);
+11 -124
View File
@@ -1,29 +1,24 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import Dexie, { Table } from 'dexie';
import {
ICreateChange,
IDatabaseChange,
IDeleteChange,
IUpdateChange,
} from 'dexie-observable/api';
import 'dexie-observable';
import { eventChannel } from 'redux-saga';
import { call, fork, put, race, take, takeEvery } from 'typed-redux-saga/macro';
call,
fork,
getContext,
put,
race,
take,
takeEvery,
} from 'typed-redux-saga/macro';
import { defined, ensureError } from '../utils';
import { sha256Digest } from '../utils/crypto';
import { createCountFunc } from '../utils/iter';
import {
FD,
FileMetadata,
FileOpenMode,
UUID,
fileStorageClose,
fileStorageCopyFile,
fileStorageDeleteFile,
fileStorageDidAddItem,
fileStorageDidChangeItem,
fileStorageDidClose,
fileStorageDidCopyFile,
fileStorageDidDeleteFile,
@@ -42,7 +37,6 @@ import {
fileStorageDidOpen,
fileStorageDidRead,
fileStorageDidReadFile,
fileStorageDidRemoveItem,
fileStorageDidRenameFile,
fileStorageDidWrite,
fileStorageDidWriteFile,
@@ -54,88 +48,13 @@ import {
fileStorageWrite,
fileStorageWriteFile,
} from './actions';
import { FileMetadata, FileStorageDb, UUID } from '.';
// 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<ICreateChange, 'key' | 'obj'> & { 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 contents table data type. */
type FileContents = {
/** The path of the file in storage. */
path: string;
/** The contents of the file. */
contents: string;
};
export type FileStorageSageContext = { fileStorage: FileStorageDb };
/** Map for keeping track of open file descriptors. */
type OpenFdMap = Map<FD, { mode: FileOpenMode; uuid: UUID }>;
class FileStorageDb extends Dexie {
metadata!: Table<FileMetadata, UUID>;
// 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<FileContents, string>;
constructor() {
super('pybricks.fileStorage');
this.version(1).stores({
metadata: '$$uuid, &path, sha256',
_contents: 'path, contents',
});
}
}
/**
* Creates a namespaced lock name for the given path.
*/
@@ -143,28 +62,6 @@ function lockNameForPath(path: string): string {
return `pybricks.fileStorage:${path}`;
}
/**
* Converts localForage change events to redux actions.
* @param changes The list of changes from the 'changed' event.
*/
function* handleFileStorageDidChange(changes: IDatabaseChange[]): Generator {
for (const change of changes) {
if (isCreateChange(change)) {
if (isFileMetadataCreateChange(change)) {
yield* put(fileStorageDidAddItem(change.obj));
}
} else if (isUpdateChange(change)) {
if (isFileMetadataUpdateChange(change)) {
yield* put(fileStorageDidChangeItem(change.oldObj, change.obj));
}
} else if (isDeleteChange(change)) {
if (isFileMetaDataDeleteChange(change)) {
yield* put(fileStorageDidRemoveItem(change.oldObj));
}
}
}
}
/**
* Handles requests to open a file.
* @param db The database instance.
@@ -706,7 +603,7 @@ function* initialize(): Generator {
const defer = new Array<(...args: unknown[]) => unknown>();
try {
const db = new FileStorageDb();
const db = yield* getContext<FileStorageDb>('fileStorage');
// migrate from old storage
@@ -735,21 +632,11 @@ function* initialize(): Generator {
yield* call(() => db.open());
defer.push(() => db.close());
// wire storage observable to redux-sagas
const changesChan = eventChannel<IDatabaseChange[]>((emit) => {
db.on('changes').subscribe(emit);
return () => db.on('changes').unsubscribe(emit);
});
defer.push(() => changesChan.close());
// subscribe to events
const nextFd = createCountFunc() as () => FD;
const openFds: OpenFdMap = new Map();
yield* takeEvery(changesChan, handleFileStorageDidChange);
yield* takeEvery(fileStorageOpen, handleOpen, db, nextFd, openFds);
yield* takeEvery(fileStorageClose, handleClose, openFds);
yield* takeEvery(fileStorageRead, handleRead, db, openFds);
+2
View File
@@ -12,6 +12,7 @@ import createSagaMiddleware from 'redux-saga';
import './index.scss';
import App from './app/App';
import { appVersion } from './app/constants';
import { db } from './fileStorage/context';
import { i18nManager } from './i18n';
import * as I18nToaster from './notifications/I18nToaster';
import { rootReducer } from './reducers';
@@ -28,6 +29,7 @@ const sagaMiddleware = createSagaMiddleware<RootSagaContext>({
nextMessageId: createCountFunc(),
notification: { toaster },
terminal: defaultTerminalContext,
fileStorage: db,
},
});
+3 -2
View File
@@ -9,7 +9,7 @@ import ble from './ble/sagas';
import editor from './editor/sagas';
import errorLog from './error-log/sagas';
import explorer from './explorer/sagas';
import fileStorage from './fileStorage/sagas';
import fileStorage, { FileStorageSageContext } from './fileStorage/sagas';
import flashFirmware from './firmware/sagas';
import hub from './hub/sagas';
import lwp3BootloaderProtocol from './lwp3-bootloader/sagas';
@@ -44,5 +44,6 @@ export default function* (): Generator {
*/
export type RootSagaContext = {
nextMessageId: () => number;
} & NotificationSagaContext &
} & FileStorageSageContext &
NotificationSagaContext &
TerminalSagaContext;
+2
View File
@@ -16,6 +16,8 @@ import {
// @ts-expect-error no typings
import matchMediaPolyfill from 'mq-polyfill';
jest.mock('./fileStorage/hooks');
//https://stackoverflow.com/a/66515427/1976323
matchMediaPolyfill(window);
+1 -1
View File
@@ -8,7 +8,7 @@ import React, { ReactElement } from 'react';
import { Provider } from 'react-redux';
import { AnyAction, DeepPartial, PreloadedState, createStore } from 'redux';
import { END, MulticastChannel, Saga, Task, runSaga, stdChannel } from 'redux-saga';
import { UUID } from '../src/fileStorage/actions';
import { UUID } from '../src/fileStorage';
import { RootState, rootReducer } from '../src/reducers';
import { RootSagaContext } from '../src/sagas';
+12
View File
@@ -2590,6 +2590,7 @@ __metadata:
css-minimizer-webpack-plugin: ^3.2.0
dexie: ^3.2.1
dexie-observable: ^4.0.0-beta.13
dexie-react-hooks: ^1.1.1
dotenv: ^10.0.0
dotenv-expand: ^5.1.0
eslint: ^7.31.0
@@ -7353,6 +7354,17 @@ __metadata:
languageName: node
linkType: hard
"dexie-react-hooks@npm:^1.1.1":
version: 1.1.1
resolution: "dexie-react-hooks@npm:1.1.1"
peerDependencies:
"@types/react": ">=16"
dexie: ">=3.1.0-alpha.1 <5.0.0"
react: ">=16"
checksum: 09e1b1d4ebc248d103eb13c8f77733f4c140478350d2b8f3989af2fedb45e0d4ae7a07df9281ade162a3264ff621db35899b47d6edf5bf532ba22d571f6f1496
languageName: node
linkType: hard
"dexie@npm:^3.2.1":
version: 3.2.1
resolution: "dexie@npm:3.2.1"