diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fc70ff..2169b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] + +### Changed + +- [store] remove dep on `@cross/env` + +### Added + +- [store-node-fs] Node.js compatible file storage + ## [0.3.0] ### Changed diff --git a/deno.json b/deno.json index ddd5d71..eb67002 100644 --- a/deno.json +++ b/deno.json @@ -3,7 +3,7 @@ "kv" ], "tasks": { - "test": "deno test --allow-env --allow-read --allow-write --allow-net", + "test": "deno test --allow-env --allow-read --allow-write --allow-net --allow-sys", "reload": "deno cache --reload **/*.ts jsr:@check/deps", "check": "deno check **/*.ts", "lint": "deno lint && deno doc --lint **/*.ts", @@ -15,9 +15,10 @@ "noUncheckedIndexedAccess": true, "verbatimModuleSyntax": true }, - "workspaces": [ + "workspace": [ "./store-common", "./store-deno-fs", + "./store-node-fs", "./store-deno-kv", "./store-deno-kv-fs", "./store-web-storage", @@ -25,16 +26,16 @@ "./store" ], "imports": { - "@cross/env": "jsr:@cross/env@^1.0.2", - "@jollytoad/store": "jsr:@jollytoad/store@^0.3.0", - "@jollytoad/store-common": "jsr:@jollytoad/store-common@^0.3.0", - "@jollytoad/store-deno-fs": "jsr:@jollytoad/store-deno-fs@^0.3.0", - "@jollytoad/store-deno-kv": "jsr:@jollytoad/store-deno-kv@^0.3.0", - "@jollytoad/store-deno-kv-fs": "jsr:@jollytoad/store-deno-kv-fs@^0.3.0", - "@jollytoad/store-web-storage": "jsr:@jollytoad/store-web-storage@^0.3.0", - "@jollytoad/store-no-op": "jsr:@jollytoad/store-no-op@^0.3.0", - "@std/assert": "jsr:@std/assert@^1.0.0-rc.3", - "@std/fs": "jsr:@std/fs@^1.0.0-rc.3", - "@std/path": "jsr:@std/path@^1.0.0-rc.3" + "@jollytoad/store": "jsr:@jollytoad/store@^0.4.0", + "@jollytoad/store-common": "jsr:@jollytoad/store-common@^0.4.0", + "@jollytoad/store-deno-fs": "jsr:@jollytoad/store-deno-fs@^0.4.0", + "@jollytoad/store-deno-kv": "jsr:@jollytoad/store-deno-kv@^0.4.0", + "@jollytoad/store-deno-kv-fs": "jsr:@jollytoad/store-deno-kv-fs@^0.4.0", + "@jollytoad/store-no-op": "jsr:@jollytoad/store-no-op@^0.4.0", + "@jollytoad/store-web-storage": "jsr:@jollytoad/store-web-storage@^0.4.0", + "@std/assert": "jsr:@std/assert@^1.0.0", + "@std/fs": "jsr:@std/fs@^1.0.0-rc.4", + "@std/path": "jsr:@std/path@^1.0.0-rc.4", + "@types/node": "npm:@types/node@^20.14.10" } } diff --git a/deno.lock b/deno.lock index 15250c7..45d9e74 100644 --- a/deno.lock +++ b/deno.lock @@ -2,79 +2,65 @@ "version": "3", "packages": { "specifiers": { - "jsr:@cross/deepmerge@^1.0.0": "jsr:@cross/deepmerge@1.0.0", - "jsr:@cross/env@^1.0.2": "jsr:@cross/env@1.0.2", - "jsr:@cross/runtime@^1.0.0": "jsr:@cross/runtime@1.0.0", - "jsr:@std/assert@^1.0.0-rc.3": "jsr:@std/assert@1.0.0-rc.3", - "jsr:@std/fs@^1.0.0-rc.3": "jsr:@std/fs@1.0.0-rc.3", + "jsr:@std/assert@^1.0.0": "jsr:@std/assert@1.0.0", + "jsr:@std/fs@^1.0.0-rc.4": "jsr:@std/fs@1.0.0-rc.4", "jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.1", - "jsr:@std/path@1.0.0-rc.3": "jsr:@std/path@1.0.0-rc.3", - "jsr:@std/path@^1.0.0-rc.3": "jsr:@std/path@1.0.0-rc.3", - "npm:@types/node": "npm:@types/node@18.16.19" + "jsr:@std/path@1.0.0-rc.4": "jsr:@std/path@1.0.0-rc.4", + "jsr:@std/path@^1.0.0-rc.4": "jsr:@std/path@1.0.0-rc.4", + "npm:@types/node": "npm:@types/node@18.16.19", + "npm:@types/node@^20.14.10": "npm:@types/node@20.14.10" }, "jsr": { - "@cross/deepmerge@1.0.0": { - "integrity": "1e1318a74e31ba1959b9aa0acae8bd417b806f74ffd25ac07c90e12f83ad6b1d" - }, - "@cross/env@1.0.2": { - "integrity": "28501ad1043c218a5b00fe5db27ec62c01ab16371bbe1b9d738496f0a7c5eeb8", - "dependencies": [ - "jsr:@cross/deepmerge@^1.0.0", - "jsr:@cross/runtime@^1.0.0" - ] - }, - "@cross/runtime@1.0.0": { - "integrity": "dddecdf99182df13d50279d1e473f715e83d41961c5c22edd7bb0c4c3cf8a76a" - }, - "@std/assert@1.0.0-rc.3": { - "integrity": "27fb4b1da846ea3f7f0504f9b2acf7c83a763387a88c497e94a62ca168c9a24e", + "@std/assert@1.0.0": { + "integrity": "0e4f6d873f7f35e2a1e6194ceee39686c996b9e5d134948e644d35d4c4df2008", "dependencies": [ "jsr:@std/internal@^1.0.1" ] }, - "@std/fs@1.0.0-rc.3": { - "integrity": "50a0a4366e7e2bd444ed0831757e3d9dd39b1d24612764bbb9ac7725169903f4", + "@std/fs@1.0.0-rc.4": { + "integrity": "7f97a43fcb807dff8580569be938274b42217a1cf2b56c7f6b0436985ab79adc", "dependencies": [ - "jsr:@std/path@1.0.0-rc.3" + "jsr:@std/path@1.0.0-rc.4" ] }, "@std/internal@1.0.1": { "integrity": "6f8c7544d06a11dd256c8d6ba54b11ed870aac6c5aeafff499892662c57673e6" }, - "@std/path@1.0.0-rc.3": { - "integrity": "672b8f88b3b58b6e932052ae412f40fc063940fcdb228d12f80353bc6aff38c1" + "@std/path@1.0.0-rc.4": { + "integrity": "2be56452bb0b42ebe4798c5db0ee74efec71b3a7d14b3e406041b538cf9316c9" } }, "npm": { "@types/node@18.16.19": { "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", "dependencies": {} + }, + "@types/node@20.14.10": { + "integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==", + "dependencies": { + "undici-types": "undici-types@5.26.5" + } + }, + "undici-types@5.26.5": { + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dependencies": {} } } }, "remote": {}, "workspace": { "dependencies": [ - "jsr:@cross/env@^1.0.2", - "jsr:@jollytoad/store-common@^0.3.0", - "jsr:@jollytoad/store-deno-fs@^0.3.0", - "jsr:@jollytoad/store-deno-kv-fs@^0.3.0", - "jsr:@jollytoad/store-deno-kv@^0.3.0", - "jsr:@jollytoad/store-no-op@^0.3.0", - "jsr:@jollytoad/store-web-storage@^0.3.0", - "jsr:@jollytoad/store@^0.3.0", - "jsr:@std/assert@^1.0.0-rc.3", - "jsr:@std/fs@^1.0.0-rc.3", - "jsr:@std/path@^1.0.0-rc.3" - ], - "members": { - "@jollytoad/store": {}, - "@jollytoad/store-common": {}, - "@jollytoad/store-deno-fs": {}, - "@jollytoad/store-deno-kv": {}, - "@jollytoad/store-deno-kv-fs": {}, - "@jollytoad/store-no-op": {}, - "@jollytoad/store-web-storage": {} - } + "jsr:@jollytoad/store-common@^0.4.0", + "jsr:@jollytoad/store-deno-fs@^0.4.0", + "jsr:@jollytoad/store-deno-kv-fs@^0.4.0", + "jsr:@jollytoad/store-deno-kv@^0.4.0", + "jsr:@jollytoad/store-no-op@^0.4.0", + "jsr:@jollytoad/store-web-storage@^0.4.0", + "jsr:@jollytoad/store@^0.4.0", + "jsr:@std/assert@^1.0.0", + "jsr:@std/fs@^1.0.0-rc.4", + "jsr:@std/path@^1.0.0-rc.4", + "npm:@types/node@^20.14.10" + ] } } diff --git a/store-common/deno.json b/store-common/deno.json index 3be4136..271f76a 100644 --- a/store-common/deno.json +++ b/store-common/deno.json @@ -1,6 +1,6 @@ { "name": "@jollytoad/store-common", - "version": "0.3.0", + "version": "0.4.0", "exports": { "./key-utils": "./key-utils.ts", "./test-storage-module": "./test-storage-module.ts", diff --git a/store-deno-fs/deno.json b/store-deno-fs/deno.json index 545df22..5efc942 100644 --- a/store-deno-fs/deno.json +++ b/store-deno-fs/deno.json @@ -1,6 +1,6 @@ { "name": "@jollytoad/store-deno-fs", - "version": "0.3.0", + "version": "0.4.0", "exports": { ".": "./mod.ts" } diff --git a/store-deno-kv-fs/deno.json b/store-deno-kv-fs/deno.json index 563f7bd..1b0b71e 100644 --- a/store-deno-kv-fs/deno.json +++ b/store-deno-kv-fs/deno.json @@ -1,6 +1,6 @@ { "name": "@jollytoad/store-deno-kv-fs", - "version": "0.3.0", + "version": "0.4.0", "exports": { ".": "./mod.ts" } diff --git a/store-deno-kv/deno.json b/store-deno-kv/deno.json index 45cbc14..bea9e15 100644 --- a/store-deno-kv/deno.json +++ b/store-deno-kv/deno.json @@ -1,6 +1,6 @@ { "name": "@jollytoad/store-deno-kv", - "version": "0.3.0", + "version": "0.4.0", "exports": { ".": "./mod.ts", "./get-deno-kv": "./get-deno-kv.ts", diff --git a/store-no-op/deno.json b/store-no-op/deno.json index 22cab6e..a882298 100644 --- a/store-no-op/deno.json +++ b/store-no-op/deno.json @@ -1,6 +1,6 @@ { "name": "@jollytoad/store-no-op", - "version": "0.3.0", + "version": "0.4.0", "exports": { ".": "./mod.ts" } diff --git a/store-node-fs/README.md b/store-node-fs/README.md new file mode 100644 index 0000000..76079ed --- /dev/null +++ b/store-node-fs/README.md @@ -0,0 +1,32 @@ +# Node.js Filesystem Storage Module + +See [@jollytoad/store](https://jsr.io/@jollytoad/store) for the bigger picture. + +This package provides an implementation of the storage module interface. + +This stores values in individual files under a directory hierarchy via +[Node fs](https://nodejs.org/docs/latest/api/fs.html) calls. By default this is +under a `.store` dir under the current working dir. This can be overridden via +the environment var `STORE_FS_ROOT`. + +Each level of the key becomes a directory up to the last segment which becomes a +JSON file. + +eg: `["one", "two", "three"]` -> `.store/one/two/three.json` + +Import mapping: `"$store": "jsr:@jollytoad/store-node-fs"` + +**Example** + +```ts +import * as store from "jsr:@jollytoad/store-node-fs"; +import { assertEquals } from "jsr:@std/assert"; + +await store.setItem(["foo", "hello"], "world"); + +assertEquals(await store.hasItem(["foo", "hello"]), true); +assertEquals(await store.getItem(["foo", "hello"]), "world"); + +await store.clearItems(["foo"]); +assertEquals(await store.hasItem(["foo", "hello"]), false); +``` diff --git a/store-node-fs/deno.json b/store-node-fs/deno.json new file mode 100644 index 0000000..6c7a675 --- /dev/null +++ b/store-node-fs/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@jollytoad/store-node-fs", + "version": "0.4.0", + "exports": { + ".": "./mod.ts" + } +} diff --git a/store-node-fs/mod.ts b/store-node-fs/mod.ts new file mode 100644 index 0000000..56e6501 --- /dev/null +++ b/store-node-fs/mod.ts @@ -0,0 +1,201 @@ +/// + +import type { StorageKey, StorageModule } from "@jollytoad/store-common/types"; +import { fromStrKey, toStrKey } from "@jollytoad/store-common/key-utils"; + +import * as fs from "node:fs/promises"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import * as process from "node:process"; + +export type { StorageKey, StorageModule }; + +({ + isWritable, + hasItem, + getItem, + setItem, + removeItem, + listItems, + clearItems, + close, + url, +}) satisfies StorageModule; + +/** + * Returns the `import.meta.url` of the module. + */ +export function url(): Promise { + return Promise.resolve(import.meta.url); +} + +/** + * Check for filesystem write permission at directory for the given key. + */ +export async function isWritable(key: StorageKey = []): Promise { + let path = dirpath(key); + const rootParent = dirname(dirpath()); + do { + if (await exists(path)) { + return await canWrite(path); + } + if (path === rootParent) { + break; + } + path = dirname(path); + } while (path); + return false; +} + +async function canWrite(path: string): Promise { + try { + await fs.access(path, fs.constants.W_OK); + return true; + } catch { + return false; + } +} + +async function exists(path: string): Promise { + try { + return !!(await fs.stat(path)); + } catch { + return false; + } +} + +/** + * Determine whether a value is set for the given key. + */ +export async function hasItem(key: StorageKey): Promise { + try { + return (await fs.stat(filepath(key))).isFile(); + } catch { + return false; + } +} + +/** + * Get a value for the given key. + */ +export async function getItem(key: StorageKey): Promise { + try { + return JSON.parse(await fs.readFile(filepath(key), { encoding: "utf-8" })); + } catch (e) { + if (e.code === "ENOENT") { + return undefined; + } else { + throw e; + } + } +} + +/** + * Set a value for the given key. + */ +export async function setItem(key: StorageKey, value: T): Promise { + const path = filepath(key); + await fs.mkdir(dirname(path), { recursive: true }); + await fs.writeFile(path, JSON.stringify(value), { encoding: "utf-8" }); +} + +/** + * Remove the value with the given key. + */ +export async function removeItem(key: StorageKey): Promise { + let path = filepath(key); + try { + await fs.rm(path); + + const root = dirpath(); + path = dirname(path); + while (path !== root) { + try { + await fs.rmdir(path); + } catch { + break; + } + path = resolve(path, ".."); + } + } catch (e) { + if (e.code !== "ENOENT") { + throw e; + } + } +} + +/** + * List all items beneath the given key prefix. + * At present ordering is not guaranteed and reverse support is optional. + */ +export async function* listItems( + keyPrefix: StorageKey = [], + _reverse = false, +): AsyncIterable<[StorageKey, T]> { + const root = dirpath(); + const path = dirpath(keyPrefix); + + try { + for await (const entry of walk(path)) { + if (entry.name.endsWith(".json")) { + const key = relative(root, entry.path.slice(0, -5)).split(sep); + const item = await getItem(key); + if (item) { + yield [fromStrKey(key), item]; + } + } + } + } catch (e) { + if (e.code === "ENOENT") { + return; + } else { + throw e; + } + } +} + +async function* walk( + root: string, +): AsyncIterable<{ name: string; path: string }> { + for await (const entry of await fs.opendir(root)) { + const path = join(root, entry.name); + + if (entry.isDirectory()) { + yield* walk(path); + } else if (entry.isFile()) { + yield { path, name: entry.name }; + } + } +} + +/** + * Delete item and sub items recursively and clean up. + */ +export async function clearItems(keyPrefix: StorageKey): Promise { + await removeItem(keyPrefix); + try { + await fs.rmdir(dirpath(keyPrefix), { recursive: true }); + } catch (e) { + if (e.code === "ENOENT") { + return; + } else { + throw e; + } + } +} + +/** + * Close all associated resources. + * This isn't generally required in most situations, it's main use is within test cases. + */ +export function close(): Promise { + return Promise.resolve(); +} + +function filepath(key: StorageKey) { + return dirpath(key) + ".json"; +} + +function dirpath(key: StorageKey = []) { + const root = process.env.STORE_FS_ROOT ?? ".store"; + return resolve(root, ...toStrKey(key)); +} diff --git a/store-node-fs/store.test.ts b/store-node-fs/store.test.ts new file mode 100644 index 0000000..73a4e08 --- /dev/null +++ b/store-node-fs/store.test.ts @@ -0,0 +1,55 @@ +import { assert } from "@std/assert"; +import { + open, + testClearItems, + testGetItem, + testHasItem, + testIsWriteable, + testListItems, + testRemoveItem, + testSetItem, + testUrl, +} from "../store-common/test-storage-module.ts"; +import * as store from "./mod.ts"; +import type { StorageModule } from "../store-common/types.ts"; +import { exists } from "@std/fs/exists"; + +Deno.test("store-node-fs", async (t) => { + try { + await open(t, store); + await testUrl(t, store, "store-node-fs"); + await testIsWriteable(t, store); + await testSetItem(t, store); + await testHasItem(t, store); + await testGetItem(t, store); + await testListItems(t, store); + await testRemoveItem(t, store); + await testClearItems(t, store); + await testDirectoryPurge(t, store); + // Ordering is not currently supported on FS + // await testOrdering(t, store); + } finally { + await store.close(); + } +}); + +async function testDirectoryPurge( + t: Deno.TestContext, + { setItem, removeItem }: StorageModule, +) { + await t.step("empty folders are deleted from fs", async () => { + await setItem(["store", "deeply", "nested", "item"], true); + + assert( + await exists(".store/store/deeply/nested"), + "Expected .store/store/deeply/nested folder to exist", + ); + + await removeItem(["store", "deeply", "nested", "item"]); + + assert( + !await exists(".store/store"), + "Expected .store/store folder to no longer exist", + ); + }); +} diff --git a/store-web-storage/deno.json b/store-web-storage/deno.json index 067d47e..ec32431 100644 --- a/store-web-storage/deno.json +++ b/store-web-storage/deno.json @@ -1,6 +1,6 @@ { "name": "@jollytoad/store-web-storage", - "version": "0.3.0", + "version": "0.4.0", "exports": { ".": "./mod.ts" } diff --git a/store/_from_env.ts b/store/_from_env.ts index 0ad7557..b318746 100644 --- a/store/_from_env.ts +++ b/store/_from_env.ts @@ -1,4 +1,3 @@ -import { getEnv } from "@cross/env"; import type { StorageModule } from "@jollytoad/store-common/types"; /** @@ -16,3 +15,12 @@ export function fromEnv(): Promise { ); } } + +type NodeGlobal = typeof globalThis & { + process: { env: Record }; +}; + +function getEnv(name: string): string | undefined { + return globalThis.Deno?.env?.get(name) ?? + (globalThis as NodeGlobal).process?.env?.[name]; +} diff --git a/store/deno.json b/store/deno.json index b9e8a3c..f9a087e 100644 --- a/store/deno.json +++ b/store/deno.json @@ -1,6 +1,6 @@ { "name": "@jollytoad/store", - "version": "0.3.0", + "version": "0.4.0", "exports": { ".": "./mod.ts" }