diff --git a/apps/example/src/fs-adapter.ts b/apps/example/src/fs-adapter.ts index ea932f8..7f0fef5 100644 --- a/apps/example/src/fs-adapter.ts +++ b/apps/example/src/fs-adapter.ts @@ -1,4 +1,3 @@ -import { S3Client } from "@aws-sdk/client-s3"; import { createClient } from "clyve"; import { FileSystemAdapter } from "clyve/adapters"; import "dotenv/config"; @@ -15,20 +14,12 @@ type MySchema = { }; }; -export const s3Client = new S3Client({ - endpoint: process.env.S3_ENDPOINT!, - region: process.env.S3_REGION!, - credentials: { - accessKeyId: process.env.S3_ACCESS_KEY!, - secretAccessKey: process.env.S3_SECRET_KEY!, - }, -}); - const adapter = new FileSystemAdapter("./data"); const db = createClient(adapter); -await db.users.deleteAll(); - -const hej = await db.users.all(); +await db.users.upsert({ + id: "1", + name: "Wall-e", +}); -console.log(hej); +await db.users.deleteAll(); diff --git a/apps/example/src/s3-adapter.ts b/apps/example/src/s3-adapter.ts index 48b1a40..08fb6e1 100644 --- a/apps/example/src/s3-adapter.ts +++ b/apps/example/src/s3-adapter.ts @@ -32,8 +32,4 @@ await db.users.upsert({ name: "Wall-e", }); -const exists = await db.users.exists("1"); - -console.log(exists); - await db.users.deleteAll(); diff --git a/packages/clyve/README.md b/packages/clyve/README.md index 5365002..0bef7e1 100644 --- a/packages/clyve/README.md +++ b/packages/clyve/README.md @@ -155,3 +155,11 @@ Delete all entries in a collection: ```typescript await db.users.deleteAll(); ``` + +Edit an entry, a shortcut for performing a sequential `.get()` and `.update()` operation. +```typescript +await db.users.edit("1", (user) => { + user.name = "Wall-e 2"; + return user; +}); +``` \ No newline at end of file diff --git a/packages/clyve/package.json b/packages/clyve/package.json index 582177f..46ad90e 100644 --- a/packages/clyve/package.json +++ b/packages/clyve/package.json @@ -1,7 +1,7 @@ { "name": "clyve", "type": "module", - "version": "1.0.1", + "version": "1.1.1", "description": "A lightweight TypeScript client that uses AWS S3 or the file system as a schema-driven, JSON-based database.", "homepage": "https://github.com/feelixe/clyve", "repository": { diff --git a/packages/clyve/src/adapters/file-system.ts b/packages/clyve/src/adapters/file-system.ts index 4729db7..d0e8030 100644 --- a/packages/clyve/src/adapters/file-system.ts +++ b/packages/clyve/src/adapters/file-system.ts @@ -1,5 +1,5 @@ import { Model } from "../model.js"; -import { DuplicateKeyError, KeyDoesNotExistError } from "../errors.js"; +import { KeyDoesNotExistError } from "../errors.js"; import { Adapter } from "./types.js"; import { promises as fs } from "node:fs"; import path from "node:path"; @@ -17,15 +17,25 @@ export class FileSystemAdapter implements Adapter { await fs.writeFile(filePath, data); } + private isEnoentError(error: unknown): error is Error { + return error instanceof Error && "code" in error && error.code === "ENOENT"; + } + async getByKey(key: string) { const filePath = path.resolve(this.basePath, key); - const string = await fs.readFile(filePath, "utf-8"); - - return JSON.parse(string); - } + let content; + try { + content = await fs.readFile(filePath, "utf-8"); + } catch (error) { + if (this.isEnoentError(error)) { + throw new KeyDoesNotExistError(`Key ${key} does not exist`, { + cause: error, + }); + } + throw error; + } - async getById(collection: string, id: string) { - return await this.getByKey(`${collection}/${id}.json`); + return JSON.parse(content); } async exists(collection: string, id: string) { @@ -38,33 +48,20 @@ export class FileSystemAdapter implements Adapter { } } - private isEnoentError(error: unknown): error is Error { - return error instanceof Error && "code" in error && error.code === "ENOENT"; - } - - private async listEntries(collection: string) { + async keys(collection: string) { + const directoryPath = path.resolve(this.basePath, collection); + let files; try { - const directoryPath = path.resolve(this.basePath, collection); - const files = await fs.readdir(directoryPath, { withFileTypes: true }); - return files.filter((file) => !file.isDirectory()); + files = await fs.readdir(directoryPath, { withFileTypes: true }); } catch (error) { if (this.isEnoentError(error)) { return []; } throw error; } - } - - async count(collection: string) { - const files = await this.listEntries(collection); - return files.length; - } - - async all(collection: string) { - const files = await this.listEntries(collection); - return await Promise.all( - files.map((file) => this.getByKey(`${collection}/${file.name}`)) - ); + return files + .filter((file) => !file.isDirectory()) + .map((file) => path.posix.join(collection, file.name)); } async upsert(collection: string, data: Model) { @@ -76,71 +73,8 @@ export class FileSystemAdapter implements Adapter { return data; } - async create(collection: string, data: Model) { - const doesKeyAlreadyExist = await this.exists(collection, data.id); - if (doesKeyAlreadyExist) { - throw new DuplicateKeyError( - `Key ${collection}/${data.id}.json already exists` - ); - } - - return await this.upsert(collection, data); - } - - async update(collection: string, data: Model) { - const doesKeyAlreadyExist = await this.exists(collection, data.id); - if (!doesKeyAlreadyExist) { - throw new KeyDoesNotExistError( - `Key ${collection}/${data.id}.json does not exist` - ); - } - - return await this.upsert(collection, data); - } - - async createMany(collection: string, data: Array) { - const entriesExist = await Promise.all( - data.map((data) => this.exists(collection, data.id)) - ); - - const someExist = entriesExist.some((exists) => exists); - if (someExist) { - throw new DuplicateKeyError( - "Cannot create items because some key already exist" - ); - } - - return await Promise.all(data.map((data) => this.upsert(collection, data))); - } - async deleteObject(collection: string, id: string) { - const filePath = path.resolve(this.basePath, `${collection}/${id}.json`); + const filePath = path.resolve(this.basePath, collection, `${id}.json`); await fs.unlink(filePath); } - - async deleteMany(collection: string, ids: Array) { - const entriesExist = await Promise.all( - ids.map((id) => this.exists(collection, id)) - ); - - const allExist = entriesExist.every((exists) => exists); - - if (!allExist) { - throw new KeyDoesNotExistError( - "Cannot delete items because some key does not exist" - ); - } - - await Promise.all(ids.map((id) => this.deleteObject(collection, id))); - } - - async deleteAll(collection: string) { - const files = await this.listEntries(collection); - - await Promise.all( - files.map((file) => - this.deleteObject(collection, `${collection}/${file.name}`) - ) - ); - } } diff --git a/packages/clyve/src/adapters/index.ts b/packages/clyve/src/adapters/index.ts index 435e13e..24b3fb3 100644 --- a/packages/clyve/src/adapters/index.ts +++ b/packages/clyve/src/adapters/index.ts @@ -1,2 +1,3 @@ export * from "./s3.js"; export * from "./file-system.js"; +export * from "./types.js"; diff --git a/packages/clyve/src/adapters/s3.ts b/packages/clyve/src/adapters/s3.ts index b5fda0f..23eec63 100644 --- a/packages/clyve/src/adapters/s3.ts +++ b/packages/clyve/src/adapters/s3.ts @@ -3,16 +3,13 @@ import { GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, + NoSuchKey, NotFound, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; import { Adapter } from "./types.js"; -import { - DuplicateKeyError, - KeyDoesNotExistError, - NoBodyError, -} from "../errors.js"; +import { KeyDoesNotExistError, NoBodyError } from "../errors.js"; import { Model } from "../model.js"; export type S3AdapterConstructorParams = [s3Client: S3Client, bucket: string]; @@ -28,12 +25,22 @@ export class S3Adapter implements Adapter { } async getByKey(key: string) { - const response = await this.client.send( - new GetObjectCommand({ - Bucket: this.bucket, - Key: key, - }) - ); + let response; + try { + response = await this.client.send( + new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }) + ); + } catch (error) { + if (error instanceof NoSuchKey) { + throw new KeyDoesNotExistError(`Key ${key} does not exist`, { + cause: error, + }); + } + throw error; + } if (!response.Body) { throw new NoBodyError("S3 response did not have a body"); @@ -64,7 +71,7 @@ export class S3Adapter implements Adapter { } } - private async listEntries(collection: string) { + async keys(collection: string) { const response = await this.client.send( new ListObjectsV2Command({ Bucket: this.bucket, @@ -72,17 +79,9 @@ export class S3Adapter implements Adapter { }) ); const files = response.Contents ?? []; - return files; - } - - async count(collection: string) { - const files = await this.listEntries(collection); - return files.length; - } - - async all(collection: string) { - const files = await this.listEntries(collection); - return await Promise.all(files.map((file) => this.getByKey(file.Key!))); + return files + .filter((file) => file.Key !== undefined) + .map((file) => file.Key!); } async upsert(collection: string, data: Model) { @@ -97,44 +96,6 @@ export class S3Adapter implements Adapter { return data; } - async create(collection: string, data: Model) { - const doesKeyAlreadyExist = await this.exists(collection, data.id); - if (doesKeyAlreadyExist) { - throw new DuplicateKeyError( - `Key ${collection}/${data.id}.json already exists` - ); - } - - return await this.upsert(collection, data); - } - - async update(collection: string, data: Model) { - const doesKeyAlreadyExist = await this.exists(collection, data.id); - if (!doesKeyAlreadyExist) { - throw new KeyDoesNotExistError( - `Key ${collection}/${data.id}.json does not exist` - ); - } - - return await this.upsert(collection, data); - } - - async createMany(collection: string, data: Array) { - const entriesExist = await Promise.all( - data.map((data) => this.exists(collection, data.id)) - ); - - const someExist = entriesExist.some((exists) => exists); - - if (someExist) { - throw new DuplicateKeyError( - "Cannot create items because some key already exist" - ); - } - - return await Promise.all(data.map((data) => this.upsert(collection, data))); - } - async deleteObject(collection: string, id: string) { await this.client.send( new DeleteObjectCommand({ @@ -143,27 +104,4 @@ export class S3Adapter implements Adapter { }) ); } - - async deleteMany(collection: string, ids: Array) { - const entriesExist = await Promise.all( - ids.map((id) => this.exists(collection, id)) - ); - - const allExist = entriesExist.every((exists) => exists); - - if (!allExist) { - throw new KeyDoesNotExistError( - "Cannot delete items because some key does not exist" - ); - } - - await Promise.all(ids.map((id) => this.deleteObject(collection, id))); - } - - async deleteAll(collection: string) { - const files = await this.listEntries(collection); - await Promise.all( - files.map((file) => this.deleteObject(collection, file.Key!)) - ); - } } diff --git a/packages/clyve/src/adapters/types.ts b/packages/clyve/src/adapters/types.ts index 107c8ba..8698937 100644 --- a/packages/clyve/src/adapters/types.ts +++ b/packages/clyve/src/adapters/types.ts @@ -2,15 +2,8 @@ import { Model } from "model.js"; export interface Adapter { getByKey: (key: string) => Promise; - getById: (collection: string, id: string) => Promise; exists: (collection: string, id: string) => Promise; - count: (collection: string) => Promise; - all: (collection: string) => Promise; + keys: (collection: string) => Promise; upsert: (collection: string, data: Model) => Promise; - create: (collection: string, data: Model) => Promise; - update: (collection: string, data: Model) => Promise; - createMany: (collection: string, data: Model[]) => Promise; deleteObject: (collection: string, id: string) => Promise; - deleteMany: (collection: string, ids: string[]) => Promise; - deleteAll: (collection: string) => Promise; } diff --git a/packages/clyve/src/index.ts b/packages/clyve/src/index.ts index 58212ca..e2340cc 100644 --- a/packages/clyve/src/index.ts +++ b/packages/clyve/src/index.ts @@ -1,10 +1,11 @@ import { CollectionObjectReadOnlyError } from "./errors.js"; import { Adapter } from "./adapters/types.js"; import { Model } from "./model.js"; +import { Operations } from "./operations.js"; type Schema = Record; -type ClyveClient = { +export type ClyveClient = { [K in keyof T]: { get: (id: string) => Promise; all: () => Promise>; @@ -17,30 +18,37 @@ type ClyveClient = { exists: (id: string) => Promise; update: (data: T[K]) => Promise; upsert: (data: T[K]) => Promise; + edit: ( + id: string, + fn: (entity: T[K]) => T[K] | Promise + ) => Promise; }; }; -export function createClient( - adapter: Adapter -): ClyveClient { +export function createClient(adapter: Adapter) { + const operations = new Operations(adapter); + return new Proxy( {}, { get(_, key) { + const collection = key.toString(); return { - get: (id: string) => adapter.getById(key.toString(), id), - all: () => adapter.all(key.toString()), - create: (data: Model) => adapter.create(key.toString(), data), - delete: (id: string) => adapter.deleteObject(key.toString(), id), - deleteMany: (id: Array) => - adapter.deleteMany(key.toString(), id), - deleteAll: () => adapter.deleteAll(key.toString()), + get: (id: string) => operations.get(collection, id), + all: () => operations.all(collection), + create: (data: Model) => operations.create(collection, data), createMany: (data: Array) => - adapter.createMany(key.toString(), data), - count: () => adapter.count(key.toString()), - exists: (id: string) => adapter.exists(key.toString(), id), - update: (data: Model) => adapter.update(key.toString(), data), - upsert: (data: Model) => adapter.upsert(key.toString(), data), + operations.createMany(collection, data), + delete: (id: string) => operations.deleteObject(collection, id), + deleteMany: (id: Array) => + operations.deleteMany(collection, id), + deleteAll: () => operations.deleteAll(collection), + count: () => operations.count(collection), + exists: (id: string) => operations.exists(collection, id), + update: (data: Model) => operations.update(collection, data), + upsert: (data: Model) => operations.upsert(collection, data), + edit: (id: string, fn: (entity: Model) => Model | Promise) => + operations.edit(collection, id, fn), }; }, set() { diff --git a/packages/clyve/src/operations.ts b/packages/clyve/src/operations.ts new file mode 100644 index 0000000..ee5c44b --- /dev/null +++ b/packages/clyve/src/operations.ts @@ -0,0 +1,121 @@ +import { Adapter } from "./adapters/types.js"; +import { DuplicateKeyError, KeyDoesNotExistError } from "./errors.js"; +import { Model } from "./model.js"; + +export class Operations { + private adapter: Adapter; + + constructor(adapter: Adapter) { + this.adapter = adapter; + } + + async getById(collection: string, id: string) { + return await this.adapter.getByKey(`${collection}/${id}.json`); + } + + async get(collection: string, id: string) { + return await this.getById(collection, id); + } + + async all(collection: string) { + const keys = await this.adapter.keys(collection); + return await Promise.all(keys.map((key) => this.adapter.getByKey(key))); + } + + async create(collection: string, data: Model) { + const doesKeyAlreadyExist = await this.adapter.exists(collection, data.id); + if (doesKeyAlreadyExist) { + throw new DuplicateKeyError( + `Key ${collection}/${data.id}.json already exists` + ); + } + + return await this.adapter.upsert(collection, data); + } + + async createMany(collection: string, data: Array) { + const entriesExist = await Promise.all( + data.map((data) => this.adapter.exists(collection, data.id)) + ); + const someExist = entriesExist.some((exists) => exists); + if (someExist) { + throw new DuplicateKeyError( + "Cannot create items because some key already exist" + ); + } + + return await Promise.all( + data.map((data) => this.adapter.upsert(collection, data)) + ); + } + + async deleteObject(collection: string, id: string) { + await this.adapter.deleteObject(collection, id); + } + + async deleteMany(collection: string, ids: Array) { + const entriesExist = await Promise.all( + ids.map((id) => this.adapter.exists(collection, id)) + ); + + const allExist = entriesExist.every((exists) => exists); + + if (!allExist) { + throw new KeyDoesNotExistError( + "Cannot delete items because some key does not exist" + ); + } + + await Promise.all( + ids.map((id) => this.adapter.deleteObject(collection, id)) + ); + } + + async allIds(collection: string) { + const keys = await this.adapter.keys(collection); + const fileNames = keys.map((key) => key.split("/")[1]); + const ids = fileNames.map((fileName) => fileName.split(".")[0]); + return ids; + } + + async deleteAll(collection: string) { + const ids = await this.allIds(collection); + await Promise.all( + ids.map((id) => this.adapter.deleteObject(collection, id)) + ); + } + + async count(collection: string) { + const keys = await this.adapter.keys(collection); + return keys.length; + } + + async exists(collection: string, id: string) { + return await this.adapter.exists(collection, id); + } + + async update(collection: string, data: Model) { + const doesKeyAlreadyExist = await this.exists(collection, data.id); + if (!doesKeyAlreadyExist) { + throw new KeyDoesNotExistError( + `Key ${collection}/${data.id}.json does not exist` + ); + } + + return await this.upsert(collection, data); + } + + async upsert(collection: string, data: Model) { + return await this.adapter.upsert(collection, data); + } + + async edit( + collection: string, + id: string, + fn: (entity: Model) => Model | Promise + ) { + const data = await this.getById(collection, id); + const modified = await fn(data); + return await this.update(collection, modified); + } +}