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"
}