From 505dc205a50e705429f0594cd32d0c6d08016db3 Mon Sep 17 00:00:00 2001 From: streamich Date: Tue, 20 Jun 2023 22:01:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20implement=20crudfs=20.pu?= =?UTF-8?q?t()=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/crud/types.ts | 41 +++++++------- src/fsa-crud/FsaCrud.ts | 73 +++++++++++++++++++++++++ src/fsa-crud/__tests__/FsaCrud.test.ts | 75 ++++++++++++++++++++++++++ src/fsa-crud/util.ts | 7 +++ 4 files changed, 178 insertions(+), 18 deletions(-) create mode 100644 src/fsa-crud/FsaCrud.ts create mode 100644 src/fsa-crud/__tests__/FsaCrud.test.ts create mode 100644 src/fsa-crud/util.ts diff --git a/src/crud/types.ts b/src/crud/types.ts index 2f7730bc7..8b8b60f85 100644 --- a/src/crud/types.ts +++ b/src/crud/types.ts @@ -2,65 +2,65 @@ export interface CrudApi { /** * Creates a new resource, or overwrites an existing one. * - * @param type Type of the resource, collection name. + * @param collection Type of the resource, collection name. * @param id Id of the resource, document name. * @param data Blob content of the resource. * @param options Write behavior options. */ - put: (type: CrudType, id: string, data: Uint8Array, options: CrudPutOptions) => Promise; + put: (collection: CrudType, id: string, data: Uint8Array, options: CrudPutOptions) => Promise; /** * Retrieves the content of a resource. * - * @param type Type of the resource, collection name. + * @param collection Type of the resource, collection name. * @param id Id of the resource, document name. * @returns Blob content of the resource. */ - get: (type: CrudType, id: string) => Promise; + get: (collection: CrudType, id: string) => Promise; /** * Deletes a resource. * - * @param type Type of the resource, collection name. + * @param collection Type of the resource, collection name. * @param id Id of the resource, document name. */ - del: (type: CrudType, id: string) => Promise; + del: (collection: CrudType, id: string) => Promise; /** * Fetches information about a resource. * - * @param type Type of the resource, collection name. + * @param collection Type of the resource, collection name. * @param id Id of the resource, document name, if any. * @returns Information about the resource. */ - info: (type: CrudType, id?: string) => Promise; + info: (collection: CrudType, id?: string) => Promise; /** - * Deletes all resources of a type, and deletes recursively all sub-collections. + * Deletes all resources of a collection, and deletes recursively all sub-collections. * - * @param type Type of the resource, collection name. + * @param collection Type of the resource, collection name. */ - drop: (type: CrudType) => Promise; + drop: (collection: CrudType) => Promise; /** - * Fetches a list of resources of a type, and sub-collections. + * Fetches a list of resources of a collection, and sub-collections. * - * @param type Type of the resource, collection name. - * @returns List of resources of the given type, and sub-collections. + * @param collection Type of the resource, collection name. + * @returns List of resources of the given type, and sub-types. */ - list: (type: CrudType) => Promise; + list: (collection: CrudType) => Promise; /** - * Recursively scans all resources of a type, and sub-collections. Returns + * Recursively scans all resources of a collection, and sub-collections. Returns * a cursor to continue scanning. * - * @param type Type of the resource, collection name. + * @param collection Type of the resource, collection name. * @param cursor Cursor to start scanning from. If empty string, starts from the beginning. * @returns List of resources of the given type, and sub-collections. Also * returns a cursor to continue scanning. If the cursor is empty * string, the scan is complete. */ - scan: (type: CrudType, cursor?: string | '') => Promise; + scan: (collection: CrudType, cursor?: string | '') => Promise; } export type CrudType = string[]; @@ -89,3 +89,8 @@ export interface CrudScanResult { cursor: string | ''; list: CrudTypeEntry[]; } + +export interface CrudScanEntry extends CrudTypeEntry { + /** Collection, which contains this entry. */ + type: CrudType; +} diff --git a/src/fsa-crud/FsaCrud.ts b/src/fsa-crud/FsaCrud.ts new file mode 100644 index 000000000..8bd42b2e6 --- /dev/null +++ b/src/fsa-crud/FsaCrud.ts @@ -0,0 +1,73 @@ +import type * as crud from '../crud/types'; +import type * as fsa from '../fsa/types'; +import {assertName} from '../node-to-fsa/util'; +import {assertType} from './util'; + +export class FsaCrud implements crud.CrudApi { + public constructor (protected readonly root: fsa.IFileSystemDirectoryHandle | Promise) {} + + protected async getDir(type: crud.CrudType): Promise { + let dir = await this.root; + for (const name of type) + dir = await dir.getDirectoryHandle(name, {create: true}); + return dir; + } + + public readonly put = async (type: crud.CrudType, id: string, data: Uint8Array, options?: crud.CrudPutOptions): Promise => { + assertType(type, 'put', 'crudfs'); + assertName(id, 'put', 'crudfs'); + const dir = await this.getDir(type); + let file: fsa.IFileSystemFileHandle | undefined; + switch (options?.throwIf) { + case 'exists': { + try { + file = await dir.getFileHandle(id, {create: false}); + throw new DOMException('Resource already exists', 'ExistsError'); + } catch (e) { + if (e.name !== 'NotFoundError') throw e; + file = await dir.getFileHandle(id, {create: true}); + } + break; + } + case 'missing': { + try { + file = await dir.getFileHandle(id, {create: false}); + } catch (e) { + if (e.name === 'NotFoundError') throw new DOMException('Resource is missing', 'MissingError'); + throw e; + } + break; + } + default: { + file = await dir.getFileHandle(id, {create: true}); + } + } + const writable = await file!.createWritable(); + await writable.write(data); + await writable.close(); + }; + + public readonly get = async (type: crud.CrudType, id: string): Promise => { + throw new Error('Not implemented'); + }; + + public readonly del = async (type: crud.CrudType, id: string): Promise => { + throw new Error('Not implemented'); + }; + + public readonly info = async (type: crud.CrudType, id?: string): Promise => { + throw new Error('Not implemented'); + }; + + public readonly drop = async (type: crud.CrudType): Promise => { + throw new Error('Not implemented'); + }; + + public readonly list = async (type: crud.CrudType): Promise => { + throw new Error('Not implemented'); + }; + + public readonly scan = async (type: crud.CrudType, cursor?: string | ''): Promise => { + throw new Error('Not implemented'); + }; +} diff --git a/src/fsa-crud/__tests__/FsaCrud.test.ts b/src/fsa-crud/__tests__/FsaCrud.test.ts new file mode 100644 index 000000000..681ff1c4e --- /dev/null +++ b/src/fsa-crud/__tests__/FsaCrud.test.ts @@ -0,0 +1,75 @@ +import {of} from 'thingies'; +import {memfs} from '../..'; +import {onlyOnNode20} from '../../__tests__/util'; +import {NodeFileSystemDirectoryHandle} from '../../node-to-fsa'; +import {FsaCrud} from '../FsaCrud'; + +const setup = () => { + const fs = memfs(); + const fsa = new NodeFileSystemDirectoryHandle(fs, '/', {mode: 'readwrite'}); + const crud = new FsaCrud(fsa); + return {fs, fsa, crud, snapshot: () => (fs).__vol.toJSON()}; +}; + +const b = (str: string) => Buffer.from(str).subarray(0); + +onlyOnNode20('FsaCrud', () => { + describe('.put()', () => { + test('throws if the type is not valid', async () => { + const {crud} = setup(); + const [, err] = await of(crud.put(['', 'foo'], 'bar', new Uint8Array())); + expect(err).toBeInstanceOf(TypeError); + }); + + test('throws if id is not valid', async () => { + const {crud} = setup(); + const [, err] = await of(crud.put(['foo'], '', new Uint8Array())); + expect(err).toBeInstanceOf(TypeError); + }); + + test('can store a resource at root', async () => { + const {crud, snapshot} = setup(); + await crud.put([], 'bar', b('abc')); + expect(snapshot()).toStrictEqual({ + '/bar': 'abc', + }); + }); + + test('can store a resource in two levels deep collection', async () => { + const {crud, snapshot} = setup(); + await crud.put(['a', 'b'], 'bar', b('abc')); + expect(snapshot()).toStrictEqual({ + '/a/b/bar': 'abc', + }); + }); + + test('can overwrite existing resource', async () => { + const {crud, snapshot} = setup(); + await crud.put(['a', 'b'], 'bar', b('abc')); + await crud.put(['a', 'b'], 'bar', b('efg')); + expect(snapshot()).toStrictEqual({ + '/a/b/bar': 'efg', + }); + }); + + test('can choose to throw if item already exists', async () => { + const {crud} = setup(); + await crud.put(['a', 'b'], 'bar', b('abc'), {throwIf: 'exists'}); + const [, err] = await of(crud.put(['a', 'b'], 'bar', b('efg'), {throwIf: 'exists'})); + expect(err).toBeInstanceOf(DOMException); + expect((err).name).toBe('ExistsError'); + }); + + test('can choose to throw if item does not exist', async () => { + const {crud, snapshot} = setup(); + const [, err] = await of(crud.put(['a', 'b'], 'bar', b('1'), {throwIf: 'missing'})); + await crud.put(['a', 'b'], 'bar', b('2'), ); + await crud.put(['a', 'b'], 'bar', b('3'), {throwIf: 'missing'}); + expect(err).toBeInstanceOf(DOMException); + expect((err).name).toBe('MissingError'); + expect(snapshot()).toStrictEqual({ + '/a/b/bar': '3', + }); + }); + }); +}); diff --git a/src/fsa-crud/util.ts b/src/fsa-crud/util.ts new file mode 100644 index 000000000..49a26b769 --- /dev/null +++ b/src/fsa-crud/util.ts @@ -0,0 +1,7 @@ +import {CrudType} from "../crud/types"; +import {assertName} from "../node-to-fsa/util"; + +export const assertType = (type: CrudType, method: string, klass: string): void => { + const length = type.length; + for (let i = 0; i < length; i++) assertName(type[i], method, klass); +};