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 -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);