diff --git a/.changeset/storage-file-backend.md b/.changeset/storage-file-backend.md new file mode 100644 index 0000000..e35bffd --- /dev/null +++ b/.changeset/storage-file-backend.md @@ -0,0 +1,5 @@ +--- +"@polkadot-apps/storage": minor +--- + +Add Node.js file-based backend to `createKvStore`. When running in Node without `localStorage`, data is now persisted as JSON files under `~/.polkadot-apps/` (override with the new `storageDir` option) instead of being silently dropped. Filenames are percent-encoded so distinct keys never collide on disk. Edge runtimes without `localStorage` or `node:fs` continue to use a silent no-op backend. diff --git a/examples/terminal-login/index.ts b/examples/terminal-login/index.ts index 188e633..9afece1 100644 --- a/examples/terminal-login/index.ts +++ b/examples/terminal-login/index.ts @@ -49,7 +49,7 @@ async function main(): Promise { console.log(` Endpoint: ${endpoint}`); console.log(); - const adapter = createTerminalAdapter({ + const adapter = await createTerminalAdapter({ appId: "terminal-login-example", metadataUrl: DEFAULT_METADATA_URL, endpoints: [endpoint], diff --git a/packages/storage/README.md b/packages/storage/README.md index c81f529..74f448a 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -30,9 +30,10 @@ const settings = await store.getJSON<{ fontSize: number; lang: string }>("settin 1. **Explicit host storage** -- if `options.hostLocalStorage` is provided, all operations route through it. 2. **Auto-detect container** -- if running inside a container host (e.g. a native app shell), the host's `localStorage` bridge is used automatically. -3. **Browser localStorage** -- fallback for standard browser environments. +3. **Browser localStorage** -- for standard browser environments. +4. **Node.js filesystem** -- when `localStorage` is unavailable but `node:fs` is, data is persisted as JSON files under `~/.polkadot-apps/` (override with `storageDir`). Each key becomes a single file; the filename is percent-encoded so distinct keys never collide on disk. -In SSR or Node environments where `localStorage` is unavailable, read operations return `null` and write operations are silent no-ops. +In edge runtimes where neither `localStorage` nor `node:fs` are available (e.g. Cloudflare Workers, Deno without `--allow-read`), reads return `null` and writes are dropped silently. ```typescript // Force host storage @@ -128,6 +129,8 @@ interface KvStoreOptions { prefix?: string; /** Override auto-detection. Routes all operations through this host storage. */ hostLocalStorage?: HostLocalStorage; + /** Directory for the Node.js file backend. Default: ~/.polkadot-apps. Ignored outside the file backend. */ + storageDir?: string; } interface HostLocalStorage { diff --git a/packages/storage/package.json b/packages/storage/package.json index 17b047a..afb58d9 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -27,6 +27,7 @@ "dexie": "catalog:" }, "devDependencies": { + "@types/node": "catalog:", "typescript": "catalog:" } } diff --git a/packages/storage/src/kv-store.ts b/packages/storage/src/kv-store.ts index d2a5467..f795d8b 100644 --- a/packages/storage/src/kv-store.ts +++ b/packages/storage/src/kv-store.ts @@ -10,6 +10,116 @@ function prefixer(prefix?: string): (key: string) => string { return prefix ? (key) => `${prefix}:${key}` : (key) => key; } +/** + * Map a key to a filesystem-safe filename. + * + * Uses percent-encoding for disallowed bytes so the mapping is injective: + * two distinct keys never produce the same filename. The `%` character is + * itself encoded so the escape sequence cannot appear in raw input. + */ +function sanitizeFileName(key: string): string { + return key.replace(/[^a-zA-Z0-9_.-]|%/g, (c) => { + const hex = c.charCodeAt(0).toString(16).padStart(2, "0"); + return `%${hex}`; + }); +} + +async function tryCreateFileBackend( + applyPrefix: (key: string) => string, + storageDir?: string, +): Promise { + try { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const os = await import("node:os"); + + const dir = storageDir ?? path.join(os.homedir(), ".polkadot-apps"); + let dirCreated = false; + + async function ensureDir(): Promise { + if (dirCreated) return; + await fs.mkdir(dir, { recursive: true }); + dirCreated = true; + } + + function fp(key: string): string { + return path.join(dir, `${sanitizeFileName(applyPrefix(key))}.json`); + } + + log.debug("Using file-based storage", { dir }); + + async function get(key: string): Promise { + try { + return await fs.readFile(fp(key), "utf-8"); + } catch { + return null; + } + } + + async function set(key: string, value: string): Promise { + try { + await ensureDir(); + await fs.writeFile(fp(key), value, "utf-8"); + } catch (e) { + log.warn("File write failed", { key, error: e }); + } + } + + return { + get, + set, + + async remove(key) { + try { + await fs.unlink(fp(key)); + } catch { + // File didn't exist — fine + } + }, + + async getJSON(key: string): Promise { + const raw = await get(key); + if (raw === null) return null; + try { + return JSON.parse(raw) as T; + } catch (e) { + log.warn("JSON parse failed for key", { key, error: e }); + return null; + } + }, + + async setJSON(key, value) { + await set(key, JSON.stringify(value)); + }, + }; + } catch { + // node:fs not available (browser environment) + return null; + } +} + +/** + * No-op backend for environments with no persistent storage. + * + * Reads return `null`, writes and removes are dropped silently. Used as a + * last-resort fallback in edge runtimes where neither `localStorage` nor + * `node:fs` are available (e.g. Cloudflare Workers, Deno without `--allow-fs`). + */ +function createNoopBackend(): KvStore { + log.debug("No persistent storage backend available — using no-op"); + return { + async get() { + return null; + }, + async set() {}, + async remove() {}, + async getJSON() { + return null; + }, + async setJSON() {}, + }; +} + function createLocalStorageBackend(applyPrefix: (key: string) => string): KvStore { const available = typeof globalThis.localStorage !== "undefined"; @@ -134,7 +244,17 @@ export async function createKvStore(options?: KvStoreOptions): Promise ); } - return createLocalStorageBackend(applyPrefix); + // Browser localStorage + if (typeof globalThis.localStorage !== "undefined") { + return createLocalStorageBackend(applyPrefix); + } + + // Node.js file-based fallback + const fileBackend = await tryCreateFileBackend(applyPrefix, options?.storageDir); + if (fileBackend) return fileBackend; + + // Edge runtimes without localStorage or node:fs + return createNoopBackend(); } if (import.meta.vitest) { @@ -440,4 +560,160 @@ if (import.meta.vitest) { } }); }); + + const nodeFs = await import("node:fs/promises"); + const nodeOs = await import("node:os"); + const nodePath = await import("node:path"); + + describe("file backend", () => { + const { mkdtemp, rm, readFile } = nodeFs; + const { tmpdir } = nodeOs; + const { join } = nodePath; + + let testDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), "kv-file-test-")); + }); + + afterEach(async () => { + try { + await rm(testDir, { recursive: true }); + } catch { + /* ignore */ + } + }); + + async function createFileStore(prefix?: string): Promise { + const backend = await tryCreateFileBackend(prefixer(prefix), testDir); + expect(backend).not.toBeNull(); + return backend!; + } + + test("get/set round-trip", async () => { + const kv = await createFileStore(); + await kv.set("key", "value"); + expect(await kv.get("key")).toBe("value"); + }); + + test("get returns null for missing key", async () => { + const kv = await createFileStore(); + expect(await kv.get("missing")).toBeNull(); + }); + + test("remove deletes key", async () => { + const kv = await createFileStore(); + await kv.set("key", "value"); + await kv.remove("key"); + expect(await kv.get("key")).toBeNull(); + }); + + test("remove is safe for missing key", async () => { + const kv = await createFileStore(); + await expect(kv.remove("nonexistent")).resolves.toBeUndefined(); + }); + + test("getJSON/setJSON round-trip", async () => { + const kv = await createFileStore(); + await kv.setJSON("obj", { a: 1, b: "two", nested: { ok: true } }); + expect(await kv.getJSON("obj")).toEqual({ a: 1, b: "two", nested: { ok: true } }); + }); + + test("getJSON returns null for missing key", async () => { + const kv = await createFileStore(); + expect(await kv.getJSON("nope")).toBeNull(); + }); + + test("getJSON returns null on corrupted JSON", async () => { + const kv = await createFileStore(); + await kv.set("bad", "not-json{{{"); + expect(await kv.getJSON("bad")).toBeNull(); + }); + + test("set overwrites existing value", async () => { + const kv = await createFileStore(); + await kv.set("key", "first"); + await kv.set("key", "second"); + expect(await kv.get("key")).toBe("second"); + }); + + test("prefix namespaces keys on disk", async () => { + const kv = await createFileStore("myapp"); + await kv.set("theme", "dark"); + // ":" is percent-encoded as %3a in the filename + const content = await readFile(join(testDir, "myapp%3atheme.json"), "utf-8"); + expect(content).toBe("dark"); + }); + + test("distinct keys never share a filename (sanitizer is injective)", async () => { + // Under a naive "replace unsafe chars with _" sanitizer, these + // three prefix variants would all map to the same file. + const kvColon = await createFileStore("my:app"); + const kvUnderscore = await createFileStore("my_app"); + const kvSpace = await createFileStore("my app"); + await kvColon.set("k", "colon"); + await kvUnderscore.set("k", "underscore"); + await kvSpace.set("k", "space"); + expect(await kvColon.get("k")).toBe("colon"); + expect(await kvUnderscore.get("k")).toBe("underscore"); + expect(await kvSpace.get("k")).toBe("space"); + }); + + test("raw '%' in keys does not collide with the escape sequence", async () => { + // Percent-encoding the escape char itself guarantees "%3a" as + // literal input is distinguishable from ":" as input. + const kv = await createFileStore(); + await kv.set(":", "colon"); + await kv.set("%3a", "escaped"); + expect(await kv.get(":")).toBe("colon"); + expect(await kv.get("%3a")).toBe("escaped"); + }); + + test("different prefixes are isolated", async () => { + const kvA = await createFileStore("app-a"); + const kvB = await createFileStore("app-b"); + await kvA.set("key", "from-a"); + await kvB.set("key", "from-b"); + expect(await kvA.get("key")).toBe("from-a"); + expect(await kvB.get("key")).toBe("from-b"); + }); + + test("sanitizes special characters in keys", async () => { + const kv = await createFileStore(); + await kv.set("key/with:special chars!", "value"); + expect(await kv.get("key/with:special chars!")).toBe("value"); + }); + + test("createKvStore uses file backend when no localStorage", async () => { + // In this Node.js test environment, localStorage is not defined, + // so createKvStore should fall back to the file backend. + const kv = await createKvStore({ prefix: "file-test", storageDir: testDir }); + await kv.set("x", "1"); + expect(await kv.get("x")).toBe("1"); + }); + + test("createKvStore prefers localStorage over file backend when both exist", async () => { + const { store, cleanup } = shimLocalStorage(); + try { + const kv = await createKvStore({ prefix: "hybrid", storageDir: testDir }); + await kv.set("x", "1"); + // Went to localStorage, not the filesystem + expect(store["hybrid:x"]).toBe("1"); + await expect(readFile(join(testDir, "hybrid%3ax.json"), "utf-8")).rejects.toThrow(); + } finally { + cleanup(); + } + }); + }); + + describe("noop backend", () => { + test("get/getJSON return null, set/remove are dropped", async () => { + const kv = createNoopBackend(); + await expect(kv.set("k", "v")).resolves.toBeUndefined(); + expect(await kv.get("k")).toBeNull(); + await expect(kv.setJSON("obj", { a: 1 })).resolves.toBeUndefined(); + expect(await kv.getJSON("obj")).toBeNull(); + await expect(kv.remove("k")).resolves.toBeUndefined(); + }); + }); } diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index b11f280..83c49e8 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -13,4 +13,10 @@ export interface KvStoreOptions { prefix?: string; /** Override auto-detection. When provided, routes all ops through this host storage. */ hostLocalStorage?: HostLocalStorage; + /** + * Directory for file-based storage in Node.js environments. + * Default: `~/.polkadot-apps/`. + * Ignored in browser environments where localStorage is available. + */ + storageDir?: string; } diff --git a/packages/terminal/README.md b/packages/terminal/README.md index c336187..92eb36c 100644 --- a/packages/terminal/README.md +++ b/packages/terminal/README.md @@ -35,7 +35,7 @@ Or in your `package.json` scripts: import { createTerminalAdapter, renderQrCode, waitForSessions } from "@polkadot-apps/terminal"; // 1. Create the adapter -const adapter = createTerminalAdapter({ +const adapter = await createTerminalAdapter({ appId: "my-terminal-app", metadataUrl: "https://example.com/metadata.json", }); @@ -76,7 +76,7 @@ if (sessions.length > 0) { ## API -### `createTerminalAdapter(options): PappAdapter` +### `createTerminalAdapter(options): Promise` Creates a terminal adapter backed by the host-papp SDK. @@ -86,18 +86,17 @@ Creates a terminal adapter backed by the host-papp SDK. - `endpoints?` -- statement store WebSocket endpoints (defaults to Paseo) - `hostMetadata?` -- optional host environment info -**Returns** a `PappAdapter` with: +**Returns** a `TerminalAdapter` (extends `PappAdapter`) with: - `sso` -- auth component (`.authenticate()`, `.abortAuthentication()`, status subscriptions) - `sessions` -- session manager (signing, disconnect) +- `destroy()` -- disconnect WebSocket and release resources + +Storage is handled automatically via `@polkadot-apps/storage` (file-based in Node.js, localStorage in browsers). ### `renderQrCode(data, options?): Promise` Render a string as a QR code using Unicode half-block characters for terminal display. -### `createNodeStorageAdapter(appId): StorageAdapter` - -File-based storage adapter for Node.js. Data persists in `~/.polkadot-apps/`. - ## Signing After login and attestation, the paired wallet can sign messages via the statement store. diff --git a/packages/terminal/package.json b/packages/terminal/package.json index 8b6ddef..49e2c74 100644 --- a/packages/terminal/package.json +++ b/packages/terminal/package.json @@ -30,6 +30,7 @@ "@novasamatech/host-papp": "0.6.17", "@novasamatech/statement-store": "0.6.17", "@novasamatech/storage-adapter": "0.6.17", + "@polkadot-apps/storage": "workspace:*", "@polkadot-api/ws-provider": "^0.7.5", "neverthrow": "^8.2.0", "polkadot-api": "^1.23.3", diff --git a/packages/terminal/src/adapter.ts b/packages/terminal/src/adapter.ts index f181038..647cd77 100644 --- a/packages/terminal/src/adapter.ts +++ b/packages/terminal/src/adapter.ts @@ -15,7 +15,7 @@ import { import { createLazyClient, createPapiStatementStoreAdapter } from "@novasamatech/statement-store"; import { getWsProvider } from "@polkadot-api/ws-provider/node"; -import { createNodeStorageAdapter } from "./node-storage.js"; +import { createStorageAdapter } from "./node-storage.js"; /** Options for creating a terminal adapter. */ export interface TerminalAdapterOptions { @@ -44,13 +44,27 @@ export type TerminalAdapter = PappAdapter & { destroy(): void; }; -export function createTerminalAdapter(options: TerminalAdapterOptions): TerminalAdapter { +export async function createTerminalAdapter( + options: TerminalAdapterOptions, +): Promise { const endpoints = options.endpoints ?? SS_PASEO_STABLE_STAGE_ENDPOINTS; - const storage = createNodeStorageAdapter(options.appId); - const lazyClient = createLazyClient( - getWsProvider({ endpoints, heartbeatTimeout: Number.POSITIVE_INFINITY }), - ); + const storage = await createStorageAdapter(options.appId); + + // The `@polkadot-api/ws-provider@0.7.5` used by `polkadot-api@1.23.3` returns + // a `JsonRpcProvider` typed against `@polkadot-api/json-rpc-provider@0.0.4` + // (string messages), whereas `@novasamatech/statement-store@0.6.17`'s + // `createLazyClient` expects the same interface from `json-rpc-provider@0.2.0` + // (typed `JsonRpcMessage` objects). The two declarations are structurally + // incompatible in TypeScript but behaviorally identical at runtime — both + // serialize to JSON strings over the WebSocket. The cast bridges the type + // split until the upstream packages agree on a single json-rpc-provider + // version. + const wsProvider = getWsProvider({ + endpoints, + heartbeatTimeout: Number.POSITIVE_INFINITY, + }) as unknown as Parameters[0]; + const lazyClient = createLazyClient(wsProvider); const statementStore = createPapiStatementStoreAdapter(lazyClient); const adapter = createPappAdapter({ diff --git a/packages/terminal/src/index.ts b/packages/terminal/src/index.ts index 7851275..77275e7 100644 --- a/packages/terminal/src/index.ts +++ b/packages/terminal/src/index.ts @@ -16,9 +16,6 @@ export { waitForSessions } from "./sessions.js"; export { renderQrCode } from "./qr-encode.js"; export type { QrRenderOptions } from "./qr-encode.js"; -// TODO: replace node-storage with @polkadot-apps/storage file backend -// once it supports Node.js filesystem persistence. - // Re-export SDK types consumers will need export type { PappAdapter, diff --git a/packages/terminal/src/node-storage.ts b/packages/terminal/src/node-storage.ts index 65a6d47..23b43b8 100644 --- a/packages/terminal/src/node-storage.ts +++ b/packages/terminal/src/node-storage.ts @@ -1,47 +1,30 @@ /** - * File-based StorageAdapter for Node.js environments. + * Bridge from @polkadot-apps/storage KvStore to the novasama StorageAdapter interface. * - * Implements the @novasamatech/storage-adapter interface using JSON files - * in ~/.polkadot-apps/. Node.js doesn't have localStorage, so this - * provides persistent storage for the SDK's session and secret data. + * Uses the file-based backend added to @polkadot-apps/storage, which persists + * data to ~/.polkadot-apps/ in Node.js environments. */ import type { StorageAdapter } from "@novasamatech/storage-adapter"; +import { createKvStore } from "@polkadot-apps/storage"; import { fromPromise } from "neverthrow"; -import { join } from "node:path"; -import { readFile, writeFile, mkdir, unlink } from "node:fs/promises"; -import { homedir } from "node:os"; - -const DEFAULT_STORAGE_DIR = join(homedir(), ".polkadot-apps"); - -function sanitizeKey(appId: string, key: string): string { - return `${appId}_${key}`.replace(/[^a-zA-Z0-9_.-]/g, "_"); -} function toError(e: unknown): Error { return e instanceof Error ? e : new Error(String(e)); } /** - * Create a file-based StorageAdapter for use with the host-papp SDK in Node.js. + * Create a StorageAdapter backed by @polkadot-apps/storage. * - * Data is stored as individual JSON files in the given directory - * (defaults to `~/.polkadot-apps/`). + * In Node.js this uses the file-based backend (~/.polkadot-apps/). + * In browsers it falls back to localStorage. */ -export function createNodeStorageAdapter(appId: string, storageDir?: string): StorageAdapter { - const dir = storageDir ?? DEFAULT_STORAGE_DIR; - let dirCreated = false; +export async function createStorageAdapter( + appId: string, + storageDir?: string, +): Promise { + const store = await createKvStore({ prefix: appId, storageDir }); const subscribers = new Map unknown>>(); - function fp(key: string): string { - return join(dir, `${sanitizeKey(appId, key)}.json`); - } - - async function ensureDir(): Promise { - if (dirCreated) return; - await mkdir(dir, { recursive: true }); - dirCreated = true; - } - function notifySubscribers(key: string, value: string | null) { const subs = subscribers.get(key); if (subs) { @@ -57,30 +40,23 @@ export function createNodeStorageAdapter(appId: string, storageDir?: string): St return { read(key: string) { - return fromPromise( - readFile(fp(key), "utf-8").catch(() => null), - toError, - ); + return fromPromise(store.get(key), toError); }, write(key: string, value: string) { return fromPromise( - ensureDir() - .then(() => writeFile(fp(key), value, "utf-8")) - .then(() => { - notifySubscribers(key, value); - }), + store.set(key, value).then(() => { + notifySubscribers(key, value); + }), toError, ).map(() => undefined as void); }, clear(key: string) { return fromPromise( - unlink(fp(key)) - .catch(() => {}) - .then(() => { - notifySubscribers(key, null); - }), + store.remove(key).then(() => { + notifySubscribers(key, null); + }), toError, ).map(() => undefined as void); }, @@ -98,130 +74,76 @@ export function createNodeStorageAdapter(appId: string, storageDir?: string): St } if (import.meta.vitest) { - const { describe, test, expect, beforeEach, afterAll } = import.meta.vitest; + const { describe, test, expect, beforeEach, afterEach } = import.meta.vitest; const { mkdtemp, rm } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); - let testDir: string; + describe("createStorageAdapter", () => { + let testDir: string; - beforeEach(async () => { - testDir = await mkdtemp(join(tmpdir(), "terminal-storage-test-")); - }); - - afterAll(async () => { - // Clean up any remaining test dirs - try { - await rm(testDir, { recursive: true }); - } catch { - /* ignore */ - } - }); + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), "terminal-storage-test-")); + }); - describe("createNodeStorageAdapter", () => { - test("read returns null for missing key", async () => { - const store = createNodeStorageAdapter("test", testDir); - const result = await store.read("nonexistent"); - expect(result.isOk()).toBe(true); - expect(result._unsafeUnwrap()).toBeNull(); + afterEach(async () => { + try { + await rm(testDir, { recursive: true }); + } catch { + /* ignore */ + } }); test("write and read round-trip", async () => { - const store = createNodeStorageAdapter("test", testDir); - await store.write("key1", "hello"); - const result = await store.read("key1"); - expect(result._unsafeUnwrap()).toBe("hello"); + const adapter = await createStorageAdapter("test", testDir); + const writeResult = await adapter.write("key1", "hello"); + expect(writeResult.isOk()).toBe(true); + const readResult = await adapter.read("key1"); + expect(readResult.isOk()).toBe(true); + expect(readResult._unsafeUnwrap()).toBe("hello"); }); - test("write overwrites existing value", async () => { - const store = createNodeStorageAdapter("test", testDir); - await store.write("key1", "first"); - await store.write("key1", "second"); - const result = await store.read("key1"); - expect(result._unsafeUnwrap()).toBe("second"); + test("read returns null for missing key", async () => { + const adapter = await createStorageAdapter("test", testDir); + const result = await adapter.read("nonexistent"); + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toBeNull(); }); test("clear removes key", async () => { - const store = createNodeStorageAdapter("test", testDir); - await store.write("key1", "value"); - await store.clear("key1"); - const result = await store.read("key1"); + const adapter = await createStorageAdapter("test", testDir); + await adapter.write("key1", "value"); + await adapter.clear("key1"); + const result = await adapter.read("key1"); expect(result._unsafeUnwrap()).toBeNull(); }); - test("clear is safe for missing key", async () => { - const store = createNodeStorageAdapter("test", testDir); - const result = await store.clear("nonexistent"); - expect(result.isOk()).toBe(true); - }); - - test("different appIds are isolated", async () => { - const storeA = createNodeStorageAdapter("app-a", testDir); - const storeB = createNodeStorageAdapter("app-b", testDir); - await storeA.write("key", "from-a"); - await storeB.write("key", "from-b"); - expect((await storeA.read("key"))._unsafeUnwrap()).toBe("from-a"); - expect((await storeB.read("key"))._unsafeUnwrap()).toBe("from-b"); - }); - test("subscribe notifies on write", async () => { - const store = createNodeStorageAdapter("test", testDir); + const adapter = await createStorageAdapter("test", testDir); const values: (string | null)[] = []; - store.subscribe("key1", (v) => values.push(v)); - - await store.write("key1", "hello"); + adapter.subscribe("key1", (v: string | null) => values.push(v)); + await adapter.write("key1", "hello"); expect(values).toEqual(["hello"]); }); test("subscribe notifies on clear", async () => { - const store = createNodeStorageAdapter("test", testDir); + const adapter = await createStorageAdapter("test", testDir); const values: (string | null)[] = []; - await store.write("key1", "hello"); - store.subscribe("key1", (v) => values.push(v)); - - await store.clear("key1"); + await adapter.write("key1", "hello"); + adapter.subscribe("key1", (v: string | null) => values.push(v)); + await adapter.clear("key1"); expect(values).toEqual([null]); }); test("unsubscribe stops notifications", async () => { - const store = createNodeStorageAdapter("test", testDir); + const adapter = await createStorageAdapter("test", testDir); const values: (string | null)[] = []; - const unsub = store.subscribe("key1", (v) => values.push(v)); - - await store.write("key1", "first"); + const unsub = adapter.subscribe("key1", (v: string | null) => values.push(v)); + await adapter.write("key1", "first"); unsub(); - await store.write("key1", "second"); - + await adapter.write("key1", "second"); expect(values).toEqual(["first"]); }); - - test("subscriber errors do not break other subscribers", async () => { - const store = createNodeStorageAdapter("test", testDir); - const values: string[] = []; - store.subscribe("key1", () => { - throw new Error("boom"); - }); - store.subscribe("key1", (v) => { - if (v) values.push(v); - }); - - await store.write("key1", "hello"); - expect(values).toEqual(["hello"]); - }); - - test("sanitizes special characters in keys", async () => { - const store = createNodeStorageAdapter("test", testDir); - await store.write("key/with:special chars!", "value"); - const result = await store.read("key/with:special chars!"); - expect(result._unsafeUnwrap()).toBe("value"); - }); - - test("handles JSON values", async () => { - const store = createNodeStorageAdapter("test", testDir); - const obj = { name: "test", count: 42, nested: { ok: true } }; - await store.write("json", JSON.stringify(obj)); - const raw = (await store.read("json"))._unsafeUnwrap(); - expect(JSON.parse(raw!)).toEqual(obj); - }); }); describe("toError", () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e1cc93..84c55a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -498,6 +498,9 @@ importers: specifier: 'catalog:' version: 4.4.2 devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.12.2 typescript: specifier: 'catalog:' version: 5.9.3 @@ -516,6 +519,9 @@ importers: '@polkadot-api/ws-provider': specifier: ^0.7.5 version: 0.7.5 + '@polkadot-apps/storage': + specifier: workspace:* + version: link:../storage neverthrow: specifier: ^8.2.0 version: 8.2.0