mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
fileStorage: implement file locks
This breaks down reading and writing into low-level and high level sagas. Opening now takes a lock on the file so that it can only have one writer or have multiple readers. The low-level read and write sagas use the file descriptor to determine if the file is still open before actually performing the action. Since we are using the web locks api, these locks should work across browser tabs/windows. The high-level read/write sagas perform the low-level open, read/write, close operations in a single call. The high-level delete and rename sagas are also updated to take locks on the files while performing the respective operations and will fail if the file is already being used somewhere else.
This commit is contained in:
File diff suppressed because one or more lines are too long
+4
-1
@@ -29,6 +29,7 @@
|
||||
"@types/redux-logger": "^3.0.9",
|
||||
"@types/semver": "^7.3.9",
|
||||
"@types/web-bluetooth": "^0.0.13",
|
||||
"@types/web-locks-api": "^0.0.2",
|
||||
"@types/wicg-file-system-access": "^2020.9.5",
|
||||
"@types/zen-push": "^0.1.1",
|
||||
"babel-plugin-macros": "^3.0.1",
|
||||
@@ -44,6 +45,7 @@
|
||||
"monaco-editor-webpack-plugin": "^6.0.0",
|
||||
"monaco-themes": "^0.4.0",
|
||||
"mq-polyfill": "1.1.8",
|
||||
"navigator.locks": "0.8.1",
|
||||
"node-sass": "^6.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^16.13.1",
|
||||
@@ -108,6 +110,7 @@
|
||||
"packageManager": "yarn@3.2.0",
|
||||
"resolutions": {
|
||||
"mq-polyfill@1.1.8": "patch:mq-polyfill@npm:1.1.8#.yarn/patches/mq-polyfill-npm-1.1.8-62fe162439.patch",
|
||||
"react-error-overlay": "6.0.9"
|
||||
"react-error-overlay": "6.0.9",
|
||||
"navigator.locks@0.8.1": "patch:navigator.locks@npm:0.8.1#.yarn/patches/navigator.locks-npm-0.8.1-e8530a2d4f.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import MonacoEditor, {
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useTernaryDarkMode } from 'usehooks-ts';
|
||||
import { IDisposable } from 'xterm';
|
||||
import { UUID, fileStorageWriteFile } from '../fileStorage/actions';
|
||||
import { fileStorageWriteFile } from '../fileStorage/actions';
|
||||
import { compile } from '../mpy/actions';
|
||||
import { useSettingIsShowDocsEnabled } from '../settings/hooks';
|
||||
import { isMacOS } from '../utils/os';
|
||||
@@ -268,7 +268,7 @@ const Editor: React.VFC = () => {
|
||||
|
||||
const handleChange = useCallback<ChangeHandler>(
|
||||
// REVISIT: need to ensure we have exclusive access to file
|
||||
(v) => dispatch(fileStorageWriteFile('main.py' as UUID, v)),
|
||||
(v) => dispatch(fileStorageWriteFile('main.py', v)),
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
|
||||
+8
-25
@@ -14,16 +14,12 @@ import {
|
||||
takeEvery,
|
||||
} from 'typed-redux-saga/macro';
|
||||
import {
|
||||
fileStorageDidFailToOpenFile,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidInitialize,
|
||||
fileStorageDidOpenFile,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageOpenFile,
|
||||
fileStorageReadFile,
|
||||
} from '../fileStorage/actions';
|
||||
import { RootState } from '../reducers';
|
||||
import { defined } from '../utils';
|
||||
import {
|
||||
editorDidCreate,
|
||||
editorGetValueRequest,
|
||||
@@ -73,31 +69,18 @@ function* handleDidCreateEditor(editor: monaco.editor.ICodeEditor): Generator {
|
||||
// then we can load the most recently used file
|
||||
// REVISIT: should this be here or elsewhere?
|
||||
|
||||
yield* put(fileStorageOpenFile('main.py'));
|
||||
yield* put(fileStorageReadFile('main.py'));
|
||||
|
||||
const didOpen = yield* race({
|
||||
succeeded: take(fileStorageDidOpenFile.when((a) => a.path === 'main.py')),
|
||||
failed: take(fileStorageDidFailToOpenFile.when((a) => a.path === 'main.py')),
|
||||
const { didRead } = yield* race({
|
||||
didRead: take(fileStorageDidReadFile.when((a) => a.path === 'main.py')),
|
||||
didFailToRead: take(
|
||||
fileStorageDidFailToReadFile.when((a) => a.path === 'main.py'),
|
||||
),
|
||||
});
|
||||
|
||||
// TODO: what to do in case of failure?
|
||||
if (didOpen.succeeded) {
|
||||
defined(didOpen.succeeded);
|
||||
|
||||
const { id } = didOpen.succeeded;
|
||||
|
||||
yield* put(fileStorageReadFile(id));
|
||||
|
||||
const didRead = yield* race({
|
||||
succeeded: take(fileStorageDidReadFile.when((a) => a.id === id)),
|
||||
failed: take(fileStorageDidFailToReadFile.when((a) => a.id === id)),
|
||||
});
|
||||
|
||||
// TODO: what to do in case of failure?
|
||||
if (didRead.succeeded) {
|
||||
const { contents } = didRead.succeeded;
|
||||
editor.setValue(contents);
|
||||
}
|
||||
if (didRead) {
|
||||
editor.setValue(didRead.contents);
|
||||
}
|
||||
|
||||
yield* takeEvery(editorGetValueRequest, handleEditorGetValueRequest, editor);
|
||||
|
||||
+19
-47
@@ -4,16 +4,13 @@
|
||||
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 {
|
||||
fileStorageDidFailToOpenFile,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToRenameFile,
|
||||
fileStorageDidOpenFile,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageOpenFile,
|
||||
fileStorageReadFile,
|
||||
fileStorageRenameFile,
|
||||
fileStorageWriteFile,
|
||||
@@ -56,15 +53,11 @@ describe('handleExplorerImportFiles', () => {
|
||||
|
||||
saga.put(explorerImportFiles());
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageOpenFile(testFileName));
|
||||
|
||||
saga.put(fileStorageDidOpenFile(testFileName, uuid(0)));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageWriteFile(uuid(0), testFileContents),
|
||||
fileStorageWriteFile(testFileName, testFileContents),
|
||||
);
|
||||
|
||||
saga.put(fileStorageDidWriteFile(uuid(0)));
|
||||
saga.put(fileStorageDidWriteFile(testFileName));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidImportFiles());
|
||||
|
||||
@@ -93,10 +86,6 @@ describe('handleExplorerCreateNewFile', () => {
|
||||
|
||||
saga.put(explorerCreateNewFile('test', pythonFileExtension, Hub.Technic));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageOpenFile('test.py'));
|
||||
|
||||
saga.put(fileStorageDidOpenFile('test.py', uuid(0)));
|
||||
|
||||
await expect(saga.take()).resolves.toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"contents": "from pybricks.hubs import TechnicHub
|
||||
@@ -108,12 +97,12 @@ describe('handleExplorerCreateNewFile', () => {
|
||||
hub = TechnicHub()
|
||||
|
||||
",
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"path": "test.py",
|
||||
"type": "fileStorage.action.writeFile",
|
||||
}
|
||||
`);
|
||||
|
||||
saga.put(fileStorageDidWriteFile(uuid(0)));
|
||||
saga.put(fileStorageDidWriteFile('test.py'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidCreateNewFile());
|
||||
|
||||
@@ -170,7 +159,6 @@ describe('handleExplorerRenameFile', () => {
|
||||
describe('handleExplorerExportFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
const testFile = 'test.file';
|
||||
const testFileId = uuid(0);
|
||||
const testFileContents = '# test file contents';
|
||||
const testError = new Error('test error');
|
||||
|
||||
@@ -179,11 +167,11 @@ describe('handleExplorerExportFile', () => {
|
||||
|
||||
saga.put(explorerExportFile(testFile));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageOpenFile(testFile));
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageReadFile(testFile));
|
||||
});
|
||||
|
||||
it('should fail if file does not exist', async () => {
|
||||
saga.put(fileStorageDidFailToOpenFile(testFile, testError));
|
||||
saga.put(fileStorageDidFailToReadFile(testFile, testError));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerDidFailToExportFile(testFile, testError),
|
||||
@@ -191,42 +179,26 @@ describe('handleExplorerExportFile', () => {
|
||||
});
|
||||
|
||||
describe('should read file', () => {
|
||||
beforeEach(async () => {
|
||||
saga.put(fileStorageDidOpenFile(testFile, testFileId));
|
||||
it('should export file', async () => {
|
||||
// NB: resolved value doesn't matter since it is not used
|
||||
jest.spyOn(browserFsAccess, 'fileSave').mockResolvedValue(null);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageReadFile(testFileId));
|
||||
saga.put(fileStorageDidReadFile(testFile, testFileContents));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(explorerDidExportFile(testFile));
|
||||
|
||||
expect(browserFsAccess.fileSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should catch read error', async () => {
|
||||
saga.put(fileStorageDidFailToReadFile(testFileId, testError));
|
||||
it('should catch error', async () => {
|
||||
jest.spyOn(browserFsAccess, 'fileSave').mockRejectedValue(testError);
|
||||
|
||||
saga.put(fileStorageDidReadFile(testFile, testFileContents));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerDidFailToExportFile(testFile, testError),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should read file', () => {
|
||||
it('should export file', async () => {
|
||||
jest.spyOn(browserFsAccess, 'fileSave').mockResolvedValue(null);
|
||||
|
||||
saga.put(fileStorageDidReadFile(testFileId, testFileContents));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerDidExportFile('test.file'),
|
||||
);
|
||||
expect(browserFsAccess.fileSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should catch error', async () => {
|
||||
jest.spyOn(browserFsAccess, 'fileSave').mockRejectedValue(testError);
|
||||
|
||||
saga.put(fileStorageDidReadFile(testFileId, testFileContents));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
explorerDidFailToExportFile('test.file', testError),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
+12
-60
@@ -13,15 +13,12 @@ import {
|
||||
} from 'typed-redux-saga/macro';
|
||||
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
|
||||
import {
|
||||
fileStorageDidFailToOpenFile,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToRenameFile,
|
||||
fileStorageDidFailToWriteFile,
|
||||
fileStorageDidOpenFile,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageOpenFile,
|
||||
fileStorageReadFile,
|
||||
fileStorageRenameFile,
|
||||
fileStorageWriteFile,
|
||||
@@ -94,29 +91,14 @@ function* handleExplorerImportFiles(): Generator {
|
||||
|
||||
const fileName = `${baseName}${pythonFileExtension}`;
|
||||
|
||||
yield* put(fileStorageOpenFile(fileName));
|
||||
|
||||
const { didOpen, didFailToOpen } = yield* race({
|
||||
didOpen: take(fileStorageDidOpenFile.when((a) => a.path === fileName)),
|
||||
didFailToOpen: take(
|
||||
fileStorageDidFailToOpenFile.when((a) => a.path === fileName),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToOpen) {
|
||||
throw didFailToOpen.error;
|
||||
}
|
||||
|
||||
defined(didOpen);
|
||||
|
||||
yield* put(fileStorageWriteFile(didOpen.id, text));
|
||||
yield* put(fileStorageWriteFile(fileName, text));
|
||||
|
||||
const { didFailToWrite } = yield* race({
|
||||
didWrite: take(
|
||||
fileStorageDidWriteFile.when((a) => a.id === didOpen.id),
|
||||
fileStorageDidWriteFile.when((a) => a.path === fileName),
|
||||
),
|
||||
didFailToWrite: take(
|
||||
fileStorageDidFailToWriteFile.when((a) => a.id === didOpen.id),
|
||||
fileStorageDidFailToWriteFile.when((a) => a.path === fileName),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -137,32 +119,17 @@ function* handleExplorerCreateNewFile(
|
||||
try {
|
||||
const fileName = `${action.fileName}${action.fileExtension}`;
|
||||
|
||||
yield* put(fileStorageOpenFile(fileName));
|
||||
|
||||
const { didOpen, didFailToOpen } = yield* race({
|
||||
didOpen: take(fileStorageDidOpenFile.when((a) => a.path === fileName)),
|
||||
didFailToOpen: take(
|
||||
fileStorageDidFailToOpenFile.when((a) => a.path === fileName),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToOpen) {
|
||||
throw didFailToOpen.error;
|
||||
}
|
||||
|
||||
defined(didOpen);
|
||||
|
||||
yield* put(
|
||||
fileStorageWriteFile(
|
||||
didOpen.id,
|
||||
fileName,
|
||||
getPybricksMicroPythonFileTemplate(action.hub) || '',
|
||||
),
|
||||
);
|
||||
|
||||
const { didFailToWrite } = yield* race({
|
||||
didWrite: take(fileStorageDidWriteFile.when((a) => a.id === didOpen.id)),
|
||||
didWrite: take(fileStorageDidWriteFile.when((a) => a.path === fileName)),
|
||||
didFailToWrite: take(
|
||||
fileStorageDidFailToWriteFile.when((a) => a.id === didOpen.id),
|
||||
fileStorageDidFailToWriteFile.when((a) => a.path === fileName),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -217,29 +184,14 @@ function* handleExplorerExportFile(
|
||||
action: ReturnType<typeof explorerExportFile>,
|
||||
): Generator {
|
||||
try {
|
||||
yield* put(fileStorageOpenFile(action.fileName));
|
||||
|
||||
const { didOpen, didFailToOpen } = yield* race({
|
||||
didOpen: take(
|
||||
fileStorageDidOpenFile.when((a) => a.path === action.fileName),
|
||||
),
|
||||
didFailToOpen: take(
|
||||
fileStorageDidFailToOpenFile.when((a) => a.path === action.fileName),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToOpen) {
|
||||
throw didFailToOpen.error;
|
||||
}
|
||||
|
||||
defined(didOpen);
|
||||
|
||||
yield* put(fileStorageReadFile(didOpen.id));
|
||||
yield* put(fileStorageReadFile(action.fileName));
|
||||
|
||||
const { didRead, didFailToRead } = yield* race({
|
||||
didRead: take(fileStorageDidReadFile.when((a) => a.id === didOpen.id)),
|
||||
didRead: take(
|
||||
fileStorageDidReadFile.when((a) => a.path === action.fileName),
|
||||
),
|
||||
didFailToRead: take(
|
||||
fileStorageDidFailToReadFile.when((a) => a.id === didOpen.id),
|
||||
fileStorageDidFailToReadFile.when((a) => a.path === action.fileName),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -254,7 +206,7 @@ function* handleExplorerExportFile(
|
||||
yield* call(() =>
|
||||
fileSave(blob, {
|
||||
id: 'pybricksCodeFileStorageExport',
|
||||
fileName: didOpen.path,
|
||||
fileName: action.fileName,
|
||||
extensions: [pythonFileExtension],
|
||||
mimeTypes: [pythonFileMimeType],
|
||||
// TODO: translate description
|
||||
|
||||
+157
-63
@@ -3,6 +3,12 @@
|
||||
|
||||
import { createAction } from '../actions';
|
||||
|
||||
/** File open modes. */
|
||||
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 };
|
||||
|
||||
@@ -74,140 +80,228 @@ export const fileStorageDidRemoveItem = createAction((file: FileMetadata) => ({
|
||||
/**
|
||||
* Action that requests to open a file in storage.
|
||||
* @param path The file path.
|
||||
* @param mode 'r' to open for reading or 'w' to open for writing.
|
||||
*/
|
||||
export const fileStorageOpenFile = createAction((path: string) => ({
|
||||
export const fileStorageOpen = createAction((path: string, mode: FileOpenMode) => ({
|
||||
type: 'fileStorage.action.Open',
|
||||
path,
|
||||
mode,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that indicates that {@link fileStorageOpenFile} succeeded.
|
||||
* Action that indicates that {@link fileStorageOpen} succeeded.
|
||||
* @param path The file path.
|
||||
* @param id The file handle UUID.
|
||||
* @param fd The file descriptor.
|
||||
*/
|
||||
export const fileStorageDidOpenFile = createAction((path: string, id: UUID) => ({
|
||||
export const fileStorageDidOpen = createAction((path: string, fd: FD) => ({
|
||||
type: 'fileStorage.action.DidOpen',
|
||||
path,
|
||||
id,
|
||||
fd,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that indicates that {@link fileStorageOpenFile} failed.
|
||||
* Action that indicates that {@link fileStorageOpen} failed.
|
||||
* @param path The file path.
|
||||
* @param error The error.
|
||||
*/
|
||||
export const fileStorageDidFailToOpenFile = createAction(
|
||||
(path: string, error: Error) => ({
|
||||
type: 'fileStorage.action.DidFailToOpen',
|
||||
path,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
/**
|
||||
* Requests to read a file from storage.
|
||||
* @param id The file handle UUID.
|
||||
*/
|
||||
export const fileStorageReadFile = createAction((id: UUID) => ({
|
||||
type: 'fileStorage.action.readFile',
|
||||
id,
|
||||
export const fileStorageDidFailToOpen = createAction((path: string, error: Error) => ({
|
||||
type: 'fileStorage.action.DidFailToOpen',
|
||||
path,
|
||||
error,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Response to read file request indicating success.
|
||||
* @param id The file handle UUID.
|
||||
* Closes a file that was opened with {@link fileStorageOpen}.
|
||||
* @param fd The file descriptor received by {@link fileStorageDidOpen}.
|
||||
*/
|
||||
export const fileStorageClose = createAction((fd: FD) => ({
|
||||
type: 'fileStorage.action.close',
|
||||
fd,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link fileStorageClose} completed.
|
||||
* @param fd The file descriptor that was passed to {@link fileStorageClose}.
|
||||
*/
|
||||
export const fileStorageDidClose = createAction((fd: FD) => ({
|
||||
type: 'fileStorage.action.didClose',
|
||||
fd,
|
||||
}));
|
||||
|
||||
// NB: Unlike most "did" actions, closing a file does not fail so there is no
|
||||
// `fileStorageDidFailToClose` action.
|
||||
|
||||
/**
|
||||
* Requests to read a file from storage.
|
||||
* @param fd An open file descriptor.
|
||||
*/
|
||||
export const fileStorageRead = createAction((fd: FD) => ({
|
||||
type: 'fileStorage.action.read',
|
||||
fd,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link fileStorageRead} succeeded.
|
||||
* @param fd The file descriptor passed to {@link fileStorageRead}
|
||||
* @param contents The contents of the file.
|
||||
*/
|
||||
export const fileStorageDidReadFile = createAction((id: UUID, contents: string) => ({
|
||||
type: 'fileStorage.action.didReadFile',
|
||||
id,
|
||||
export const fileStorageDidRead = createAction((fd: FD, contents: string) => ({
|
||||
type: 'fileStorage.action.didRead',
|
||||
fd,
|
||||
contents,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Response to read file request indicating failure.
|
||||
* @param id The file handle UUID.
|
||||
* Indicates that {@link fileStorageRead} failed.
|
||||
* @param fd The file descriptor passed to {@link fileStorageRead}
|
||||
* @param error The error.
|
||||
*/
|
||||
export const fileStorageDidFailToReadFile = createAction((id: UUID, error: Error) => ({
|
||||
type: 'fileStorage.action.didFailToReadFile',
|
||||
id,
|
||||
export const fileStorageDidFailToRead = createAction((fd: FD, error: Error) => ({
|
||||
type: 'fileStorage.action.didFailToRead',
|
||||
fd,
|
||||
error,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Requests to write a file to storage.
|
||||
* @param id The file handle UUID.
|
||||
* @param fd A file descriptor that is open for writing.
|
||||
* @param contents The contents of the file.
|
||||
*/
|
||||
export const fileStorageWriteFile = createAction((id: UUID, contents: string) => ({
|
||||
type: 'fileStorage.action.writeFile',
|
||||
id,
|
||||
export const fileStorageWrite = createAction((fd: FD, contents: string) => ({
|
||||
type: 'fileStorage.action.write',
|
||||
fd,
|
||||
contents,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Response to write file request indicating success.
|
||||
* @param id The file handle UUID.
|
||||
* Indicates that {@link fileStorageWrite} succeeded.
|
||||
* @param fd The file descriptor passed to {@link fileStorageWrite}
|
||||
*/
|
||||
export const fileStorageDidWriteFile = createAction((id: UUID) => ({
|
||||
type: 'fileStorage.action.didWriteFile',
|
||||
id,
|
||||
export const fileStorageDidWrite = createAction((fd: FD) => ({
|
||||
type: 'fileStorage.action.didWrite',
|
||||
fd,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Response to write file request indicating failure.
|
||||
* @param id The file handle UUID.
|
||||
* Indicates that {@link fileStorageWrite} failed.
|
||||
* @param fd The file descriptor passed to {@link fileStorageWrite}
|
||||
* @param error The error.
|
||||
*/
|
||||
export const fileStorageDidFailToWriteFile = createAction((id: UUID, error: Error) => ({
|
||||
type: 'fileStorage.action.didFailToWriteFile',
|
||||
id,
|
||||
export const fileStorageDidFailToWrite = createAction((fd: FD, error: Error) => ({
|
||||
type: 'fileStorage.action.didFailToWrite',
|
||||
fd,
|
||||
error,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Request to delete a file from storage.
|
||||
* @param id The file handle UUID.
|
||||
* Performs file open, read, close.
|
||||
* @param path: The file path.
|
||||
*/
|
||||
export const fileStorageDeleteFile = createAction((fileName: string) => ({
|
||||
export const fileStorageReadFile = createAction((path: string) => ({
|
||||
type: 'fileStorage.action.readFile',
|
||||
path,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link fileStorageReadFile} succeeded.
|
||||
* @param path: The file path.
|
||||
* @param contents: The contents read from the file.
|
||||
*/
|
||||
export const fileStorageDidReadFile = createAction(
|
||||
(path: string, contents: string) => ({
|
||||
type: 'fileStorage.action.didReadFile',
|
||||
path,
|
||||
contents,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Indicates that {@link fileStorageReadFile} failed.
|
||||
* @param path: The file path.
|
||||
* @param error The error.
|
||||
*/
|
||||
export const fileStorageDidFailToReadFile = createAction(
|
||||
(path: string, error: Error) => ({
|
||||
type: 'fileStorage.action.didFailToReadFile',
|
||||
path,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Performs file open, write, close.
|
||||
* @param path: The file path.
|
||||
* @param contents: The contents read from the file.
|
||||
*/
|
||||
export const fileStorageWriteFile = createAction((path: string, contents: string) => ({
|
||||
type: 'fileStorage.action.writeFile',
|
||||
path,
|
||||
contents,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link fileStorageWriteFile} succeeded.
|
||||
* @param path: The file path.
|
||||
*/
|
||||
export const fileStorageDidWriteFile = createAction((path: string) => ({
|
||||
type: 'fileStorage.action.didWriteFile',
|
||||
path,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link fileStorageWriteFile} failed.
|
||||
* @param path: The file path.
|
||||
* @param error The error.
|
||||
*/
|
||||
export const fileStorageDidFailToWriteFile = createAction(
|
||||
(path: string, error: Error) => ({
|
||||
type: 'fileStorage.action.didFailToWriteFile',
|
||||
path,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Request to delete a file from storage.
|
||||
* @param path: The file path.
|
||||
*/
|
||||
export const fileStorageDeleteFile = createAction((path: string) => ({
|
||||
type: 'fileStorage.action.deleteFile',
|
||||
fileName,
|
||||
path,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link fileStorageDeleteFile} succeeded.
|
||||
* @param fileName The file handle UUID.
|
||||
* @param path: The file path.
|
||||
*/
|
||||
export const fileStorageDidDeleteFile = createAction((fileName: string) => ({
|
||||
export const fileStorageDidDeleteFile = createAction((path: string) => ({
|
||||
type: 'fileStorage.action.didDeleteFile',
|
||||
fileName,
|
||||
path,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that {@link fileStorageDeleteFile} failed.
|
||||
* @param fileName The file handle UUID.
|
||||
* @param path: The file path.
|
||||
* @param error The error.
|
||||
*/
|
||||
export const fileStorageDidFailToDeleteFile = createAction(
|
||||
(fileName: string, error: Error) => ({
|
||||
(path: string, error: Error) => ({
|
||||
type: 'fileStorage.action.didFailToDeleteFile',
|
||||
fileName,
|
||||
path,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Requests for a file to be renamed.
|
||||
* @param fileName The file handle UUID.
|
||||
* @param newName The new name for the file.
|
||||
* @param path: The file path.
|
||||
* @param newPath The new path for the file.
|
||||
*/
|
||||
export const fileStorageRenameFile = createAction(
|
||||
(fileName: string, newName: string) => ({
|
||||
type: 'fileStorage.action.renameFile',
|
||||
fileName,
|
||||
newName,
|
||||
}),
|
||||
);
|
||||
export const fileStorageRenameFile = createAction((path: string, newPath: string) => ({
|
||||
type: 'fileStorage.action.renameFile',
|
||||
path,
|
||||
newPath,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Indicates that fileStorageRenameFile(oldName, newName) succeeded.
|
||||
|
||||
+619
-134
@@ -8,30 +8,49 @@ import 'dexie-observable';
|
||||
import { AsyncSaga, uuid } from '../../test';
|
||||
import { createCountFunc } from '../utils/iter';
|
||||
import {
|
||||
FD,
|
||||
FileMetadata,
|
||||
FileOpenMode,
|
||||
fileStorageArchiveAllFiles,
|
||||
fileStorageClose,
|
||||
fileStorageDeleteFile,
|
||||
fileStorageDidAddItem,
|
||||
fileStorageDidArchiveAllFiles,
|
||||
fileStorageDidChangeItem,
|
||||
fileStorageDidClose,
|
||||
fileStorageDidDeleteFile,
|
||||
fileStorageDidFailToArchiveAllFiles,
|
||||
fileStorageDidFailToDeleteFile,
|
||||
fileStorageDidFailToInitialize,
|
||||
fileStorageDidFailToOpen,
|
||||
fileStorageDidFailToRead,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToRenameFile,
|
||||
fileStorageDidFailToWrite,
|
||||
fileStorageDidFailToWriteFile,
|
||||
fileStorageDidInitialize,
|
||||
fileStorageDidOpenFile,
|
||||
fileStorageDidOpen,
|
||||
fileStorageDidRead,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRemoveItem,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidWrite,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageOpenFile,
|
||||
fileStorageOpen,
|
||||
fileStorageRead,
|
||||
fileStorageReadFile,
|
||||
fileStorageRenameFile,
|
||||
fileStorageWrite,
|
||||
fileStorageWriteFile,
|
||||
} from './actions';
|
||||
import fileStorage from './sagas';
|
||||
|
||||
jest.mock('browser-fs-access');
|
||||
|
||||
/** SHA256 hash of '' */
|
||||
const emptyFileSha256 =
|
||||
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
|
||||
beforeEach(() => {
|
||||
// deterministic UUID generator for repeatable tests
|
||||
const nextId = createCountFunc();
|
||||
@@ -39,7 +58,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase('pybricks.fileStorage');
|
||||
@@ -68,175 +87,639 @@ async function setUpTestFile(saga: AsyncSaga): Promise<[FileMetadata, string]> {
|
||||
sha256: testFileContentsSha256,
|
||||
};
|
||||
|
||||
const emptyFileSha256 =
|
||||
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
const emptyFile: FileMetadata = { ...testFile, sha256: emptyFileSha256 };
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
saga.put(fileStorageOpen(testFilePath, 'w'));
|
||||
|
||||
saga.put(fileStorageOpenFile(testFilePath));
|
||||
const didOpen = await saga.take();
|
||||
|
||||
if (!fileStorageDidOpen.matches(didOpen)) {
|
||||
fail(didOpen);
|
||||
}
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpenFile(testFilePath, testFileId),
|
||||
);
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidAddItem(emptyFile));
|
||||
|
||||
saga.put(fileStorageWriteFile(testFileId, testFileContents));
|
||||
saga.put(fileStorageWrite(didOpen.fd, testFileContents));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidWriteFile(testFileId));
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidWrite(didOpen.fd));
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidChangeItem(emptyFile, testFile),
|
||||
);
|
||||
|
||||
saga.put(fileStorageClose(didOpen.fd));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidClose(didOpen.fd));
|
||||
|
||||
return [testFile, testFileContents];
|
||||
}
|
||||
|
||||
it('should migrate old program from local storage during initialization', async () => {
|
||||
const oldProgramKey = 'program';
|
||||
const oldProgramContents = '# test program';
|
||||
const oldProgramContentsSha256 =
|
||||
'31c21eb39c9276341d9364f6d4bcac46a4aa3768bc2626f8aa742c46e3e0fdd6';
|
||||
describe('initialize', () => {
|
||||
it('should migrate old program from local storage during initialization', async () => {
|
||||
const oldProgramKey = 'program';
|
||||
const oldProgramContents = '# test program';
|
||||
const oldProgramContentsSha256 =
|
||||
'31c21eb39c9276341d9364f6d4bcac46a4aa3768bc2626f8aa742c46e3e0fdd6';
|
||||
|
||||
// add item to localStorage to simulate an existing program
|
||||
localStorage.setItem(oldProgramKey, oldProgramContents);
|
||||
expect(localStorage.getItem(oldProgramKey)).toBe(oldProgramContents);
|
||||
|
||||
const saga = new AsyncSaga(fileStorage);
|
||||
|
||||
// initialization should remove the localStorage entry and add add it to
|
||||
// new storage backend
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidInitialize([
|
||||
{ uuid: uuid(0), path: 'main.py', sha256: oldProgramContentsSha256 },
|
||||
]),
|
||||
);
|
||||
expect(localStorage.getItem(oldProgramKey)).toBeNull();
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
it('should read and write files', async () => {
|
||||
const saga = new AsyncSaga(fileStorage);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
|
||||
const testFilePath = 'test.file';
|
||||
const testFileId = uuid(0);
|
||||
const testFileContents = 'test file contents';
|
||||
const testFileContentsSha256 =
|
||||
'c4fa968a745586faaa030054f51fb1cafd5e9ae25fa6b137ac6477715fdc81b1';
|
||||
|
||||
const testFile: FileMetadata = {
|
||||
uuid: testFileId,
|
||||
path: testFilePath,
|
||||
sha256: testFileContentsSha256,
|
||||
};
|
||||
|
||||
const emptyFileSha256 =
|
||||
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
||||
const emptyFile: FileMetadata = { ...testFile, sha256: emptyFileSha256 };
|
||||
|
||||
saga.put(fileStorageOpenFile(testFilePath));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpenFile(testFilePath, testFileId),
|
||||
);
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidAddItem(emptyFile));
|
||||
|
||||
// test writing a file
|
||||
saga.put(fileStorageWriteFile(testFileId, testFileContents));
|
||||
|
||||
// writing file triggers response
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidWriteFile(testFileId));
|
||||
|
||||
// and as a side-effect, triggers item change as well
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidChangeItem(emptyFile, testFile),
|
||||
);
|
||||
|
||||
// test reading the same file back
|
||||
saga.put(fileStorageReadFile(testFileId));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidReadFile(testFileId, testFileContents),
|
||||
);
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
it('should dispatch fail action if file does not exist', async () => {
|
||||
const saga = new AsyncSaga(fileStorage);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
|
||||
const testFileId = uuid(0);
|
||||
|
||||
saga.put(fileStorageReadFile(testFileId));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToReadFile(testFileId, new Error('file does not exist')),
|
||||
);
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
it('should delete files', async () => {
|
||||
const saga = new AsyncSaga(fileStorage);
|
||||
|
||||
const [testFile] = await setUpTestFile(saga);
|
||||
|
||||
saga.put(fileStorageDeleteFile(testFile.path));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidDeleteFile(testFile.path));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidRemoveItem(testFile));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
describe('rename', () => {
|
||||
it('should rename files', async () => {
|
||||
const newName = 'new.file';
|
||||
// add item to localStorage to simulate an existing program
|
||||
localStorage.setItem(oldProgramKey, oldProgramContents);
|
||||
expect(localStorage.getItem(oldProgramKey)).toBe(oldProgramContents);
|
||||
|
||||
const saga = new AsyncSaga(fileStorage);
|
||||
|
||||
const [testFile] = await setUpTestFile(saga);
|
||||
|
||||
saga.put(fileStorageRenameFile(testFile.path, newName));
|
||||
|
||||
const newMetadata: FileMetadata = { ...testFile, path: newName };
|
||||
|
||||
// initialization should remove the localStorage entry and add add it to
|
||||
// new storage backend
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidRenameFile(testFile.path),
|
||||
fileStorageDidInitialize([
|
||||
{ uuid: uuid(0), path: 'main.py', sha256: oldProgramContentsSha256 },
|
||||
]),
|
||||
);
|
||||
expect(localStorage.getItem(oldProgramKey)).toBeNull();
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
it('should catch error', async () => {
|
||||
const testError = new Error('test error');
|
||||
|
||||
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw testError;
|
||||
});
|
||||
|
||||
const saga = new AsyncSaga(fileStorage);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidChangeItem(testFile, newMetadata),
|
||||
fileStorageDidFailToInitialize(testError),
|
||||
);
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
describe('archive', () => {
|
||||
it('should archive file', async () => {
|
||||
const saga = new AsyncSaga(fileStorage);
|
||||
|
||||
describe('open', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
});
|
||||
|
||||
it('should fail to open for reading if file does not exist', async () => {
|
||||
saga.put(fileStorageOpen('test.file', 'r'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToOpen(
|
||||
'test.file',
|
||||
new Error("file 'test.file' does not exist"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should open file for writing if file does not exist', () => {
|
||||
beforeEach(async () => {
|
||||
saga.put(fileStorageOpen('test.file', 'w'));
|
||||
|
||||
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', () => {
|
||||
it.each<FileOpenMode>(['r', 'w'])('mode: %o', async (mode) => {
|
||||
saga.put(fileStorageOpen('test.file', mode));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToOpen(
|
||||
'test.file',
|
||||
new Error("file 'test.file' is already in use"),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
saga.put(fileStorageClose(0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidClose(0 as FD));
|
||||
});
|
||||
});
|
||||
|
||||
describe('should open file if file exists', () => {
|
||||
beforeEach(async () => {
|
||||
await setUpTestFile(saga);
|
||||
});
|
||||
|
||||
it.each<FileOpenMode>(['r', 'w'])('mode: %o', async (mode) => {
|
||||
saga.put(fileStorageOpen('test.file', mode));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow multiple readers', async () => {
|
||||
await setUpTestFile(saga);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', 'r'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', 'r'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 2 as FD),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail to open for writing if already open for reading', async () => {
|
||||
await setUpTestFile(saga);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', 'r'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', 'w'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToOpen(
|
||||
'test.file',
|
||||
new Error("file 'test.file' is already in use"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow calling close multiple times', async () => {
|
||||
saga.put(fileStorageOpen('test.file', 'w'));
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
|
||||
saga.put(fileStorageClose(0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidClose(0 as FD));
|
||||
|
||||
saga.put(fileStorageClose(0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidClose(0 as FD));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
});
|
||||
|
||||
describe('should read an open file', () => {
|
||||
it.each<FileOpenMode>(['r', 'w'])('mode: %o', async (mode) => {
|
||||
const [, contents] = await setUpTestFile(saga);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', mode));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageRead(1 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidRead(1 as FD, contents),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should fail to read a closed file', () => {
|
||||
it.each<FileOpenMode>(['r', 'w'])('mode: %o', async (mode) => {
|
||||
await setUpTestFile(saga);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', mode));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageClose(1 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidClose(1 as FD));
|
||||
|
||||
saga.put(fileStorageRead(1 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToRead(
|
||||
1 as FD,
|
||||
new Error('file descriptor 1 is not open'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('write', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
});
|
||||
|
||||
it('should write a file open for writing', async () => {
|
||||
await setUpTestFile(saga);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', 'w'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageWrite(1 as FD, 'new contents'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidWrite(1 as FD));
|
||||
});
|
||||
|
||||
it('should fail to write a file open for reading', async () => {
|
||||
await setUpTestFile(saga);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', 'r'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageWrite(1 as FD, 'new contents'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToWrite(
|
||||
1 as FD,
|
||||
new Error('file descriptor 1 is not open for writing'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should fail to write a closed file', () => {
|
||||
it.each<FileOpenMode>(['r', 'w'])('mode: %o', async (mode) => {
|
||||
await setUpTestFile(saga);
|
||||
|
||||
saga.put(fileStorageOpen('test.file', mode));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
);
|
||||
|
||||
saga.put(fileStorageClose(1 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidClose(1 as FD));
|
||||
|
||||
saga.put(fileStorageWrite(1 as FD, 'new contents'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToWrite(
|
||||
1 as FD,
|
||||
new Error('file descriptor 1 is not open'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
|
||||
saga.put(fileStorageReadFile('test.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageOpen('test.file', 'r'));
|
||||
});
|
||||
|
||||
it('should forward open error', async () => {
|
||||
const error = new Error('open test file failed');
|
||||
saga.put(fileStorageDidFailToOpen('test.file', error));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToReadFile('test.file', error),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should open file', () => {
|
||||
beforeEach(async () => {
|
||||
saga.put(fileStorageDidOpen('test.file', 0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageRead(0 as FD));
|
||||
});
|
||||
|
||||
it('should forward read error', async () => {
|
||||
const error = new Error('test fail to read');
|
||||
saga.put(fileStorageDidFailToRead(0 as FD, error));
|
||||
|
||||
// file should be closed before we get fileStorageDidFailToReadFile
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageClose(0 as FD));
|
||||
|
||||
saga.put(fileStorageDidClose(0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToReadFile('test.file', error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return file contents', async () => {
|
||||
const contents = 'test read contents';
|
||||
saga.put(fileStorageDidRead(0 as FD, contents));
|
||||
|
||||
// file should be closed before we get fileStorageDidReadFile
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageClose(0 as FD));
|
||||
|
||||
saga.put(fileStorageDidClose(0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidReadFile('test.file', contents),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
const contents = 'test write file contents';
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
|
||||
saga.put(fileStorageWriteFile('test.file', contents));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageOpen('test.file', 'w'));
|
||||
});
|
||||
|
||||
it('should forward open error', async () => {
|
||||
const error = new Error('open test file failed');
|
||||
saga.put(fileStorageDidFailToOpen('test.file', error));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToWriteFile('test.file', error),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should open file', () => {
|
||||
beforeEach(async () => {
|
||||
saga.put(fileStorageDidOpen('test.file', 0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageWrite(0 as FD, contents),
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward write error', async () => {
|
||||
const error = new Error('test fail to write');
|
||||
saga.put(fileStorageDidFailToWrite(0 as FD, error));
|
||||
|
||||
// file should be closed before we get fileStorageDidFailToWriteFile
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageClose(0 as FD));
|
||||
|
||||
saga.put(fileStorageDidClose(0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToWriteFile('test.file', error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return', async () => {
|
||||
saga.put(fileStorageDidWrite(0 as FD));
|
||||
|
||||
// file should be closed before we get fileStorageDidWriteFile
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageClose(0 as FD));
|
||||
|
||||
saga.put(fileStorageDidClose(0 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidWriteFile('test.file'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
let testFile: FileMetadata;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
[testFile] = await setUpTestFile(saga);
|
||||
});
|
||||
|
||||
it('should fail if file does not exist', async () => {
|
||||
saga.put(fileStorageDeleteFile('other.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToDeleteFile(
|
||||
'other.file',
|
||||
new Error("file 'other.file' does not exist"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if file is open', async () => {
|
||||
saga.put(fileStorageOpen('test.file', 'r'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
);
|
||||
saga.put(fileStorageDeleteFile('test.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToDeleteFile(
|
||||
'test.file',
|
||||
new Error("file 'test.file' is in use"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove file', async () => {
|
||||
saga.put(fileStorageDeleteFile('test.file'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidDeleteFile('test.file'),
|
||||
);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidRemoveItem(testFile));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameFile', () => {
|
||||
let saga: AsyncSaga;
|
||||
let testFile: FileMetadata;
|
||||
const newPath = 'new.file';
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
[testFile] = await setUpTestFile(saga);
|
||||
});
|
||||
|
||||
it('should fail if file does not exist', async () => {
|
||||
saga.put(fileStorageRenameFile('other.file', newPath));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToRenameFile(
|
||||
'other.file',
|
||||
new Error("file 'other.file' does not exist"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if file is open', async () => {
|
||||
saga.put(fileStorageOpen('test.file', 'r'));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidOpen('test.file', 1 as FD),
|
||||
);
|
||||
saga.put(fileStorageRenameFile('test.file', newPath));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToRenameFile(
|
||||
'test.file',
|
||||
new Error("file 'test.file' is in use"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if new path already exists', async () => {
|
||||
// there are two paths here that result in the same error
|
||||
// the first is if the file is open, e.g. in another tab
|
||||
|
||||
saga.put(fileStorageOpen(newPath, 'w'));
|
||||
|
||||
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));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToRenameFile(
|
||||
'test.file',
|
||||
new Error("file 'new.file' already exists"),
|
||||
),
|
||||
);
|
||||
|
||||
// the second is if the file is not open but still exists in storage
|
||||
|
||||
saga.put(fileStorageClose(1 as FD));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidClose(1 as FD));
|
||||
|
||||
saga.put(fileStorageRenameFile('test.file', newPath));
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToRenameFile(
|
||||
'test.file',
|
||||
new Error("file 'new.file' already exists"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive', () => {
|
||||
let saga: AsyncSaga;
|
||||
|
||||
beforeEach(async () => {
|
||||
saga = new AsyncSaga(fileStorage);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
|
||||
await setUpTestFile(saga);
|
||||
});
|
||||
|
||||
it('should archive file', async () => {
|
||||
jest.spyOn(browserFsAccess, 'fileSave');
|
||||
|
||||
saga.put(fileStorageArchiveAllFiles());
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(fileStorageDidArchiveAllFiles());
|
||||
expect(browserFsAccess.fileSave).toHaveBeenCalled();
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
it('should catch error', async () => {
|
||||
const saga = new AsyncSaga(fileStorage);
|
||||
|
||||
await setUpTestFile(saga);
|
||||
|
||||
const testError = new Error('test error');
|
||||
jest.spyOn(browserFsAccess, 'fileSave').mockRejectedValue(testError);
|
||||
|
||||
@@ -245,7 +728,9 @@ describe('archive', () => {
|
||||
await expect(saga.take()).resolves.toEqual(
|
||||
fileStorageDidFailToArchiveAllFiles(testError),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await saga.end();
|
||||
});
|
||||
});
|
||||
|
||||
+375
-87
@@ -12,34 +12,45 @@ import {
|
||||
import 'dexie-observable';
|
||||
import JSZip from 'jszip';
|
||||
import { eventChannel } from 'redux-saga';
|
||||
import { call, fork, put, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import { ensureError, timestamp } from '../utils';
|
||||
import { call, fork, put, race, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import { defined, ensureError, timestamp } from '../utils';
|
||||
import { sha256Digest } from '../utils/crypto';
|
||||
import { createCountFunc } from '../utils/iter';
|
||||
import {
|
||||
FD,
|
||||
FileMetadata,
|
||||
FileOpenMode,
|
||||
UUID,
|
||||
fileStorageArchiveAllFiles,
|
||||
fileStorageClose,
|
||||
fileStorageDeleteFile,
|
||||
fileStorageDidAddItem,
|
||||
fileStorageDidArchiveAllFiles,
|
||||
fileStorageDidChangeItem,
|
||||
fileStorageDidClose,
|
||||
fileStorageDidDeleteFile,
|
||||
fileStorageDidFailToArchiveAllFiles,
|
||||
fileStorageDidFailToDeleteFile,
|
||||
fileStorageDidFailToInitialize,
|
||||
fileStorageDidFailToOpenFile,
|
||||
fileStorageDidFailToOpen,
|
||||
fileStorageDidFailToRead,
|
||||
fileStorageDidFailToReadFile,
|
||||
fileStorageDidFailToRenameFile,
|
||||
fileStorageDidFailToWrite,
|
||||
fileStorageDidFailToWriteFile,
|
||||
fileStorageDidInitialize,
|
||||
fileStorageDidOpenFile,
|
||||
fileStorageDidOpen,
|
||||
fileStorageDidRead,
|
||||
fileStorageDidReadFile,
|
||||
fileStorageDidRemoveItem,
|
||||
fileStorageDidRenameFile,
|
||||
fileStorageDidWrite,
|
||||
fileStorageDidWriteFile,
|
||||
fileStorageOpenFile,
|
||||
fileStorageOpen,
|
||||
fileStorageRead,
|
||||
fileStorageReadFile,
|
||||
fileStorageRenameFile,
|
||||
fileStorageWrite,
|
||||
fileStorageWriteFile,
|
||||
} from './actions';
|
||||
|
||||
@@ -104,6 +115,9 @@ type FileContents = {
|
||||
contents: string;
|
||||
};
|
||||
|
||||
/** 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.
|
||||
@@ -121,6 +135,13 @@ class FileStorageDb extends Dexie {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a namespaced lock name for the given path.
|
||||
*/
|
||||
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.
|
||||
@@ -146,61 +167,161 @@ function* handleFileStorageDidChange(changes: IDatabaseChange[]): Generator {
|
||||
/**
|
||||
* Handles requests to open a file.
|
||||
* @param db The database instance.
|
||||
* @param nextFd Function to get the next file descriptor.
|
||||
* @param openFds Map of open file descriptors.
|
||||
* @param action The requested action.
|
||||
*/
|
||||
function* handleOpenFile(
|
||||
function* handleOpen(
|
||||
db: FileStorageDb,
|
||||
action: ReturnType<typeof fileStorageOpenFile>,
|
||||
nextFd: () => FD,
|
||||
openFds: OpenFdMap,
|
||||
action: ReturnType<typeof fileStorageOpen>,
|
||||
): Generator {
|
||||
try {
|
||||
// NB: can't await non-db functions inside of transaction, so we have
|
||||
// to do this before even if it is not used
|
||||
const contents = '';
|
||||
const sha256 = yield* call(() => sha256Digest(contents));
|
||||
const fd = nextFd();
|
||||
let lockWaiter: Promise<void>;
|
||||
|
||||
const uuid = yield* call(() =>
|
||||
db.transaction('rw', db.metadata, db._contents, async () => {
|
||||
const metadata = await db.metadata
|
||||
.where('path')
|
||||
.equals(action.path)
|
||||
.first();
|
||||
const close = yield* call(
|
||||
() =>
|
||||
new Promise<(() => void) | void>((resolve, reject) => {
|
||||
lockWaiter = navigator.locks
|
||||
.request(
|
||||
lockNameForPath(action.path),
|
||||
{
|
||||
ifAvailable: true,
|
||||
mode: action.mode === 'w' ? 'exclusive' : 'shared',
|
||||
},
|
||||
(lock) => {
|
||||
if (lock === null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// if the file exists, return the existing uuid
|
||||
if (metadata) {
|
||||
return metadata.uuid;
|
||||
}
|
||||
|
||||
// otherwise create a new empty file
|
||||
const key = await db.metadata.add((<Omit<FileMetadata, 'uuid'>>{
|
||||
path: action.path,
|
||||
sha256,
|
||||
}) as FileMetadata);
|
||||
|
||||
await db._contents.put({ path: action.path, contents });
|
||||
|
||||
return key;
|
||||
}),
|
||||
// capture a promise new resolve function that will be used
|
||||
// to release the lock later
|
||||
return new Promise<void>((resolve2) =>
|
||||
resolve(resolve2),
|
||||
);
|
||||
},
|
||||
)
|
||||
.catch(reject);
|
||||
}),
|
||||
);
|
||||
yield* put(fileStorageDidOpenFile(action.path, uuid));
|
||||
|
||||
if (!close) {
|
||||
throw new Error(`file '${action.path}' is already in use`);
|
||||
}
|
||||
|
||||
let isCloseExplicitlyRequested = false;
|
||||
|
||||
try {
|
||||
// NB: can't await non-db functions inside of transaction, so we have
|
||||
// to do this before even if it is not used
|
||||
const contents = '';
|
||||
const sha256 = yield* call(() => sha256Digest(contents));
|
||||
|
||||
const uuid = yield* call(() =>
|
||||
db.transaction('rw', db.metadata, db._contents, async () => {
|
||||
const metadata = await db.metadata
|
||||
.where('path')
|
||||
.equals(action.path)
|
||||
.first();
|
||||
|
||||
// if the file exists, return the existing uuid
|
||||
if (metadata) {
|
||||
return metadata.uuid;
|
||||
}
|
||||
|
||||
// if reading, do not create a new file
|
||||
if (action.mode === 'r') {
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise create a new empty file for writing
|
||||
const key = await db.metadata.add((<Omit<FileMetadata, 'uuid'>>{
|
||||
path: action.path,
|
||||
sha256,
|
||||
}) as FileMetadata);
|
||||
|
||||
await db._contents.put({ path: action.path, contents });
|
||||
|
||||
return key;
|
||||
}),
|
||||
);
|
||||
|
||||
// uuid will be undefined if we attempted to open for reading
|
||||
// and the file does not exist
|
||||
if (!uuid) {
|
||||
throw new Error(`file '${action.path}' does not exist`);
|
||||
}
|
||||
|
||||
openFds.set(fd, { mode: action.mode, uuid });
|
||||
|
||||
yield* put(fileStorageDidOpen(action.path, fd));
|
||||
|
||||
yield* take(fileStorageClose.when((a) => a.fd === fd));
|
||||
|
||||
isCloseExplicitlyRequested = true;
|
||||
} finally {
|
||||
openFds.delete(fd);
|
||||
close();
|
||||
|
||||
// this ensures that the lock is released before we send the action
|
||||
yield* call(() => lockWaiter);
|
||||
|
||||
// Post fileStorageDidClose only if fileStorageClose was received.
|
||||
// If the task is canceled or fails, we don't want this extra action.
|
||||
if (isCloseExplicitlyRequested) {
|
||||
yield* put(fileStorageDidClose(fd));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
yield* put(fileStorageDidFailToOpenFile(action.path, ensureError(err)));
|
||||
yield* put(fileStorageDidFailToOpen(action.path, ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles requests to close a file.
|
||||
* @param openFds The map of open file descriptors.
|
||||
* @param action The action that triggered this handler.
|
||||
*/
|
||||
function* handleClose(
|
||||
openFds: OpenFdMap,
|
||||
action: ReturnType<typeof fileStorageClose>,
|
||||
): Generator {
|
||||
if (openFds.has(action.fd)) {
|
||||
// handleOpenFile handles closing open files so there is nothing to do here.
|
||||
return;
|
||||
}
|
||||
|
||||
// Close was called more than once, which is allowed, so respond to avoid
|
||||
// blocking while waiting for response.
|
||||
yield* put(fileStorageDidClose(action.fd));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles requests to read a file.
|
||||
* @param db The database instance.
|
||||
* @param openFds Map of open file descriptors.
|
||||
* @param action The requested action.
|
||||
*/
|
||||
function* handleReadFile(
|
||||
function* handleRead(
|
||||
db: FileStorageDb,
|
||||
action: ReturnType<typeof fileStorageReadFile>,
|
||||
openFds: OpenFdMap,
|
||||
action: ReturnType<typeof fileStorageRead>,
|
||||
): Generator {
|
||||
try {
|
||||
const fdInfo = openFds.get(action.fd);
|
||||
|
||||
if (!fdInfo) {
|
||||
throw new Error(`file descriptor ${action.fd} is not open`);
|
||||
}
|
||||
|
||||
const file = yield* call(() =>
|
||||
db.transaction('r', db.metadata, db._contents, async () => {
|
||||
const metadata = await db.metadata.get(action.id);
|
||||
const metadata = await db.metadata.get(fdInfo.uuid);
|
||||
|
||||
// istanbul ignore if: file locks should prevent this from happening
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -209,34 +330,48 @@ function* handleReadFile(
|
||||
}),
|
||||
);
|
||||
|
||||
// istanbul ignore if: file locks should prevent this from happening
|
||||
if (!file) {
|
||||
throw new Error('file does not exist');
|
||||
}
|
||||
|
||||
yield* put(fileStorageDidReadFile(action.id, file.contents));
|
||||
yield* put(fileStorageDidRead(action.fd, file.contents));
|
||||
} catch (err) {
|
||||
yield* put(fileStorageDidFailToReadFile(action.id, ensureError(err)));
|
||||
yield* put(fileStorageDidFailToRead(action.fd, ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the file contents to storage.
|
||||
* @param db The database instance.
|
||||
* @param openFds Map of open file descriptors.
|
||||
* @param action The action that triggered this saga.
|
||||
*/
|
||||
function* handleWriteFile(
|
||||
function* handleWrite(
|
||||
db: FileStorageDb,
|
||||
action: ReturnType<typeof fileStorageWriteFile>,
|
||||
openFds: OpenFdMap,
|
||||
action: ReturnType<typeof fileStorageWrite>,
|
||||
) {
|
||||
try {
|
||||
const fdInfo = openFds.get(action.fd);
|
||||
|
||||
if (!fdInfo) {
|
||||
throw new Error(`file descriptor ${action.fd} is not open`);
|
||||
}
|
||||
|
||||
if (fdInfo.mode !== 'w') {
|
||||
throw new Error(`file descriptor ${action.fd} is not open for writing`);
|
||||
}
|
||||
|
||||
const sha256 = yield* call(() => sha256Digest(action.contents));
|
||||
|
||||
yield* call(() =>
|
||||
db.transaction('rw', db.metadata, db._contents, async () => {
|
||||
const metadata = await db.metadata.get(action.id);
|
||||
const metadata = await db.metadata.get(fdInfo.uuid);
|
||||
|
||||
// istanbul ignore if: file locks should prevent this from happening
|
||||
if (!metadata) {
|
||||
throw new Error(`file handle '${action.id}' does not exist`);
|
||||
throw new Error(`file handle '${fdInfo.uuid}' does not exist`);
|
||||
}
|
||||
|
||||
await db.metadata.put({ ...metadata, sha256 });
|
||||
@@ -246,9 +381,105 @@ function* handleWriteFile(
|
||||
});
|
||||
}),
|
||||
);
|
||||
yield* put(fileStorageDidWriteFile(action.id));
|
||||
yield* put(fileStorageDidWrite(action.fd));
|
||||
} catch (err) {
|
||||
yield* put(fileStorageDidFailToWriteFile(action.id, ensureError(err)));
|
||||
yield* put(fileStorageDidFailToWrite(action.fd, ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle open, read, close action.
|
||||
* @param action The action that triggered this saga.
|
||||
*/
|
||||
function* handleReadFile(action: ReturnType<typeof fileStorageReadFile>): Generator {
|
||||
try {
|
||||
yield* put(fileStorageOpen(action.path, 'r'));
|
||||
|
||||
const { didOpen, didFailToOpen } = yield* race({
|
||||
didOpen: take(fileStorageDidOpen.when((a) => a.path === action.path)),
|
||||
didFailToOpen: take(
|
||||
fileStorageDidFailToOpen.when((a) => a.path === action.path),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToOpen) {
|
||||
throw didFailToOpen.error;
|
||||
}
|
||||
|
||||
defined(didOpen);
|
||||
|
||||
let contents: string;
|
||||
|
||||
try {
|
||||
yield* put(fileStorageRead(didOpen.fd));
|
||||
|
||||
const { didRead, didFailToRead } = yield* race({
|
||||
didRead: take(fileStorageDidRead.when((a) => a.fd === didOpen.fd)),
|
||||
didFailToRead: take(
|
||||
fileStorageDidFailToRead.when((a) => a.fd === didOpen.fd),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToRead) {
|
||||
throw didFailToRead.error;
|
||||
}
|
||||
|
||||
defined(didRead);
|
||||
|
||||
contents = didRead.contents;
|
||||
} finally {
|
||||
yield* put(fileStorageClose(didOpen.fd));
|
||||
yield* take(fileStorageDidClose.when((a) => a.fd === didOpen.fd));
|
||||
}
|
||||
|
||||
yield* put(fileStorageDidReadFile(action.path, contents));
|
||||
} catch (err) {
|
||||
yield* put(fileStorageDidFailToReadFile(action.path, ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle open, write, close action.
|
||||
* @param action The action that triggered this saga.
|
||||
*/
|
||||
function* handleWriteFile(action: ReturnType<typeof fileStorageWriteFile>): Generator {
|
||||
try {
|
||||
yield* put(fileStorageOpen(action.path, 'w'));
|
||||
|
||||
const { didOpen, didFailToOpen } = yield* race({
|
||||
didOpen: take(fileStorageDidOpen.when((a) => a.path === action.path)),
|
||||
didFailToOpen: take(
|
||||
fileStorageDidFailToOpen.when((a) => a.path === action.path),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToOpen) {
|
||||
throw didFailToOpen.error;
|
||||
}
|
||||
|
||||
defined(didOpen);
|
||||
|
||||
try {
|
||||
yield* put(fileStorageWrite(didOpen.fd, action.contents));
|
||||
|
||||
const { didFailToWrite } = yield* race({
|
||||
didWrite: take(fileStorageDidWrite.when((a) => a.fd === didOpen.fd)),
|
||||
didFailToWrite: take(
|
||||
fileStorageDidFailToWrite.when((a) => a.fd === didOpen.fd),
|
||||
),
|
||||
});
|
||||
|
||||
if (didFailToWrite) {
|
||||
throw didFailToWrite.error;
|
||||
}
|
||||
} finally {
|
||||
yield* put(fileStorageClose(didOpen.fd));
|
||||
yield* take(fileStorageDidClose.when((a) => a.fd === didOpen.fd));
|
||||
}
|
||||
|
||||
yield* put(fileStorageDidWriteFile(action.path));
|
||||
} catch (err) {
|
||||
yield* put(fileStorageDidFailToWriteFile(action.path, ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,23 +494,34 @@ function* handleDeleteFile(
|
||||
) {
|
||||
try {
|
||||
yield* call(() =>
|
||||
db.transaction('rw', db.metadata, db._contents, async () => {
|
||||
const metadata = await db.metadata
|
||||
.where('path')
|
||||
.equals(action.fileName)
|
||||
.first();
|
||||
navigator.locks.request(
|
||||
lockNameForPath(action.path),
|
||||
{ ifAvailable: true },
|
||||
async (lock) => {
|
||||
if (lock === null) {
|
||||
throw new Error(`file '${action.path}' is in use`);
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
throw new Error(`file '${action.fileName}' does not exist`);
|
||||
}
|
||||
await db.transaction('rw', db.metadata, db._contents, async () => {
|
||||
const metadata = await db.metadata
|
||||
.where('path')
|
||||
.equals(action.path)
|
||||
.first();
|
||||
|
||||
await db.metadata.delete(metadata.uuid);
|
||||
await db._contents.delete(metadata.path);
|
||||
}),
|
||||
if (!metadata) {
|
||||
throw new Error(`file '${action.path}' does not exist`);
|
||||
}
|
||||
|
||||
await db.metadata.delete(metadata.uuid);
|
||||
await db._contents.delete(metadata.path);
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
yield* put(fileStorageDidDeleteFile(action.fileName));
|
||||
|
||||
yield* put(fileStorageDidDeleteFile(action.path));
|
||||
} catch (err) {
|
||||
yield* put(fileStorageDidFailToDeleteFile(action.fileName, ensureError(err)));
|
||||
yield* put(fileStorageDidFailToDeleteFile(action.path, ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,42 +536,82 @@ function* handleRenameFile(
|
||||
) {
|
||||
try {
|
||||
yield* call(() =>
|
||||
db.transaction('rw', db.metadata, db._contents, async () => {
|
||||
const metadata = await db.metadata
|
||||
.where('path')
|
||||
.equals(action.fileName)
|
||||
.first();
|
||||
navigator.locks.request(
|
||||
lockNameForPath(action.path),
|
||||
{ ifAvailable: true },
|
||||
async (lock) => {
|
||||
if (lock === null) {
|
||||
throw new Error(`file '${action.path}' is in use`);
|
||||
}
|
||||
|
||||
if (!metadata) {
|
||||
throw new Error(`file '${action.fileName}' does not exist`);
|
||||
}
|
||||
await navigator.locks.request(
|
||||
lockNameForPath(action.newPath),
|
||||
{ ifAvailable: true },
|
||||
async (lock2) => {
|
||||
if (lock2 === null) {
|
||||
throw new Error(
|
||||
`file '${action.newPath}' already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const oldName = metadata.path;
|
||||
await db.transaction(
|
||||
'rw',
|
||||
db.metadata,
|
||||
db._contents,
|
||||
async () => {
|
||||
const metadata = await db.metadata
|
||||
.where('path')
|
||||
.equals(action.path)
|
||||
.first();
|
||||
|
||||
const oldFile = await db._contents.get(oldName);
|
||||
if (!metadata) {
|
||||
throw new Error(
|
||||
`file '${action.path}' does not exist`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!oldFile) {
|
||||
throw new Error(`file '${oldName}' does not exist in storage`);
|
||||
}
|
||||
const oldName = metadata.path;
|
||||
|
||||
const newFile = await db._contents.get(action.newName);
|
||||
const oldFile = await db._contents.get(oldName);
|
||||
|
||||
if (newFile) {
|
||||
throw new Error(
|
||||
`cannot rename: file '${action.newName}' already exists`,
|
||||
// istanbul ignore if: file locks should prevent this from happening
|
||||
if (!oldFile) {
|
||||
throw new Error(
|
||||
`file '${oldName}' does not exist in storage`,
|
||||
);
|
||||
}
|
||||
|
||||
const newFile = await db._contents.get(
|
||||
action.newPath,
|
||||
);
|
||||
|
||||
if (newFile) {
|
||||
throw new Error(
|
||||
`file '${action.newPath}' already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
await db._contents.delete(oldName);
|
||||
await db._contents.add({
|
||||
...oldFile,
|
||||
path: action.newPath,
|
||||
});
|
||||
|
||||
await db.metadata.put({
|
||||
...metadata,
|
||||
path: action.newPath,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await db._contents.delete(oldName);
|
||||
await db._contents.add({ ...oldFile, path: action.newName });
|
||||
|
||||
await db.metadata.put({ ...metadata, path: action.newName });
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
yield* put(fileStorageDidRenameFile(action.fileName));
|
||||
yield* put(fileStorageDidRenameFile(action.path));
|
||||
} catch (err) {
|
||||
yield* put(fileStorageDidFailToRenameFile(action.fileName, ensureError(err)));
|
||||
yield* put(fileStorageDidFailToRenameFile(action.path, ensureError(err)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,10 +689,16 @@ function* initialize(): Generator {
|
||||
|
||||
// subscribe to events
|
||||
|
||||
const nextFd = createCountFunc() as () => FD;
|
||||
const openFds: OpenFdMap = new Map();
|
||||
|
||||
yield* takeEvery(changesChan, handleFileStorageDidChange);
|
||||
yield* takeEvery(fileStorageOpenFile, handleOpenFile, db);
|
||||
yield* takeEvery(fileStorageReadFile, handleReadFile, db);
|
||||
yield* takeEvery(fileStorageWriteFile, handleWriteFile, db);
|
||||
yield* takeEvery(fileStorageOpen, handleOpen, db, nextFd, openFds);
|
||||
yield* takeEvery(fileStorageClose, handleClose, openFds);
|
||||
yield* takeEvery(fileStorageRead, handleRead, db, openFds);
|
||||
yield* takeEvery(fileStorageWrite, handleWrite, db, openFds);
|
||||
yield* takeEvery(fileStorageReadFile, handleReadFile);
|
||||
yield* takeEvery(fileStorageWriteFile, handleWriteFile);
|
||||
yield* takeEvery(fileStorageDeleteFile, handleDeleteFile, db);
|
||||
yield* takeEvery(fileStorageRenameFile, handleRenameFile, db);
|
||||
yield* takeEvery(fileStorageArchiveAllFiles, handleArchiveAllFiles, db);
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import 'navigator.locks';
|
||||
import crypto from 'crypto';
|
||||
import { inspect } from 'util';
|
||||
import {
|
||||
KeyCodes,
|
||||
Modifiers,
|
||||
@@ -76,3 +78,15 @@ document.addEventListener('keyup', addWhichToKeyboardEvent);
|
||||
Object.defineProperty(global.self, 'crypto', {
|
||||
value: crypto.webcrypto,
|
||||
});
|
||||
|
||||
// https://github.com/facebook/jest/issues/11698
|
||||
function fail(reason: unknown): never {
|
||||
if (typeof reason === 'string') {
|
||||
throw new Error(reason);
|
||||
}
|
||||
throw new Error(inspect(reason));
|
||||
}
|
||||
|
||||
if (global.fail === undefined) {
|
||||
global.fail = fail;
|
||||
}
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ export class AsyncSaga {
|
||||
// if there are no dispatches queued, then queue the taker to be
|
||||
// completed later
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(reject, 500, new Error('timed out'));
|
||||
const timeout = setTimeout(reject, 1000, new Error('timed out'));
|
||||
|
||||
this.takers.push({
|
||||
put: (a: AnyAction): void => {
|
||||
|
||||
@@ -2123,6 +2123,7 @@ __metadata:
|
||||
"@types/redux-logger": ^3.0.9
|
||||
"@types/semver": ^7.3.9
|
||||
"@types/web-bluetooth": ^0.0.13
|
||||
"@types/web-locks-api": ^0.0.2
|
||||
"@types/wicg-file-system-access": ^2020.9.5
|
||||
"@types/zen-push": ^0.1.1
|
||||
"@typescript-eslint/eslint-plugin": ^4.33.0
|
||||
@@ -2149,6 +2150,7 @@ __metadata:
|
||||
monaco-editor-webpack-plugin: ^6.0.0
|
||||
monaco-themes: ^0.4.0
|
||||
mq-polyfill: 1.1.8
|
||||
navigator.locks: 0.8.1
|
||||
node-sass: ^6.0.1
|
||||
prettier: ^2.5.1
|
||||
prop-types: ^15.8.1
|
||||
@@ -2995,6 +2997,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/web-locks-api@npm:^0.0.2":
|
||||
version: 0.0.2
|
||||
resolution: "@types/web-locks-api@npm:0.0.2"
|
||||
checksum: 36d8e6378f4b48143fb8ac829ebd0f071782b0a2f7db98babddfbe05a82d8332a123c88599100aceebbbb2cdf512e67ed3f2c188f8b70f38000f9563f366f298
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/webpack-sources@npm:*":
|
||||
version: 3.2.0
|
||||
resolution: "@types/webpack-sources@npm:3.2.0"
|
||||
@@ -11400,6 +11409,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"navigator.locks@npm:0.8.1":
|
||||
version: 0.8.1
|
||||
resolution: "navigator.locks@npm:0.8.1"
|
||||
checksum: 15438f3500839f91fb9fb516e35436263847182a9bf593581a5aa0f50f744b0b62957d5cf54556f733692318dd9f774ca99a40e7babe7fef2b436b8ef82fb9ca
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"navigator.locks@patch:navigator.locks@npm:0.8.1#.yarn/patches/navigator.locks-npm-0.8.1-e8530a2d4f.patch::locator=%40pybricks%2Fpybricks-code%40workspace%3A.":
|
||||
version: 0.8.1
|
||||
resolution: "navigator.locks@patch:navigator.locks@npm%3A0.8.1#.yarn/patches/navigator.locks-npm-0.8.1-e8530a2d4f.patch::version=0.8.1&hash=cb7184&locator=%40pybricks%2Fpybricks-code%40workspace%3A."
|
||||
checksum: a4ce9fa068d07874234f11849dccc4bc265ce9baa468aea29a3734bc672be893cd6e9d1c91320b898fbe011d0c3ce8d55fb7af8ed4295f9d67feda65697f2bbe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"negotiator@npm:0.6.3, negotiator@npm:^0.6.3":
|
||||
version: 0.6.3
|
||||
resolution: "negotiator@npm:0.6.3"
|
||||
|
||||
Reference in New Issue
Block a user