Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
# @suaveplan/storage-browser-localstorage - Browser localStorage AbstractStorage adapter for @suaveplan/storage: namespaced base64 JSON envelopes, TTL expiry on read, AbortSignal everywhere, strict key sanitisation, DOMException → typed LocalStorageError mapping, full OTel surface.
Browser-tier `AbstractStorage` adapter backed by the synchronous `Storage` API (`globalThis.localStorage`). Provides namespaced, origin-scoped key-value storage with full `AbstractStorage` conformance, AbortSignal threading, Zod-validated options, structured `LocalStorageError` boundary, lazy TTL eviction, an injectable `StorageLike` adapter for testing, and a complete OpenTelemetry surface.
<!-- begin:auto-docs -->
## Documentation tree
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [in-memory-storage](./src/in-memory-storage/in-memory-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [in-memory-storage](./src/in-memory-storage/in-memory-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - local-storage.ops — `localStorage` is a flat string…
- [local-storage.errors](./src/local-storage/local-storage.errors.md) — Maps every error thrown by the underlying `Storage` adapter to the typed `LocalStorageError` surface. Browsers raise in…
- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - local-storage.ops — `localStorage` is a flat string…
- [local-storage.ops](./src/local-storage/local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
- [local-storage.partial](./src/local-storage/local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
- [local-storage.slot](./src/local-storage/local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
- [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Single source of truth for every error code the `@suaveplan/storage-browser-localstorage` package can throw. The regist…
<!-- end:auto-docs -->
- [envelope](./src/envelope/envelope.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory-storage](./src/in-memory-storage/in-memory-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [in-memory-storage](./src/in-memory-storage/in-memory-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [key-sanitizer](./src/key-sanitizer/key-sanitizer.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage.errors](./src/local-storage/local-storage.errors.md) — Maps every error thrown by the underlying `Storage` adapter to the typed `LocalStorageError` surface. Browsers raise in…
- [local-storage](./src/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage.ops](./src/local-storage/local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
- [local-storage.partial](./src/local-storage/local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
- [local-storage.slot](./src/local-storage/local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
- [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage-adapter](./src/local-storage-adapter/local-storage-adapter.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Single source of truth for every error code the `@suaveplan/storage-browser-localstorage` package can throw. The regist…
## Install
```bash
bun add @suaveplan/storage-browser-localstorage
```
## Quick start
```ts
import {
createLocalStorageStorage,
isLocalStorageAvailable,
} from "@suaveplan/storage-browser-localstorage";
if (!isLocalStorageAvailable()) {
throw new Error("This browser blocks localStorage (private mode?)");
}
const storage = createLocalStorageStorage({ namespace: "myapp" });
await storage.set("user/42", new TextEncoder().encode("hello"));
const back = await storage.get("user/42");
```
## Factory integration
```ts
import { registerLocalStorageProvider } from "@suaveplan/storage-browser-localstorage/registry";
import { createStorage } from "@suaveplan/storage/factory";
registerLocalStorageProvider();
const storage = createStorage({
type: "localstorage",
config: { namespace: "myapp" },
});
```
The package does NOT self-register at module-evaluation time — call `registerLocalStorageProvider()` explicitly so `sideEffects: false` bundlers can tree-shake the factory path when not used.
## Capabilities
| Capability | Value | Notes |
|---|---|---|
| `partialReads` | `false` | `getPartial` is implemented via the `AbstractStorage` reference helper (load + slice). |
| `partialWrites` | `false` | `setPartial` is implemented via the `AbstractStorage` reference helper (load + splice + write). |
| `nativeStreams` | `false` | `createReadStream` / `createWriteStream` use the `AbstractStorage` default buffered implementations. |
| `atomicWrites` | `true` | A single `setItem` is atomic at the DOM API level. |
| `contentAddressable` | `false` | Keys are caller-supplied strings. |
## Modules
- [`src/error-codes.ts`](./src/error-codes.md) — `LOCAL_STORAGE_CODES` registry + `LocalStorageError`.
- [`src/key-sanitizer/key-sanitizer.ts`](./src/key-sanitizer/key-sanitizer.md) — strict key boundary.
- [`src/envelope/envelope.ts`](./src/envelope/envelope.md) — base64 + JSON wire format.
- [`src/local-storage-adapter/local-storage-adapter.ts`](./src/local-storage-adapter/local-storage-adapter.md) — `defaultLocalStorage()` + `isLocalStorageAvailable()`.
- [`src/local-storage/local-storage.ts`](./src/local-storage/local-storage.md) — the `LocalStorageStorage` class.
- [`src/local-storage/local-storage.errors.ts`](./src/local-storage/local-storage.errors.md) — DOMException mapping.
- [`src/local-storage/local-storage.ops.ts`](./src/local-storage/local-storage.ops.md) — namespacing + pagination helpers.
- [`src/local-storage/local-storage.partial.ts`](./src/local-storage/local-storage.partial.md) — argument validation.
- [`src/local-storage/local-storage.slot.ts`](./src/local-storage/local-storage.slot.md) — option resolution + envelope IO.
- [`src/otel/otel.ts`](./src/otel/otel.md) — OpenTelemetry instrument set.
- [`src/registry/registry.ts`](./src/registry/registry.md) — `registerLocalStorageProvider()`.
- [`src/in-memory-storage/in-memory-storage.ts`](./src/in-memory-storage/in-memory-storage.md) — real-implementation test double, exposed under `./in-memory-storage` for downstream test packages.
- [`src/types/storage-like.types.ts`](./src/types/storage-like.types.md) — structural DOM Storage surface.
- [`src/types/local-storage-storage.types.ts`](./src/types/local-storage-storage.types.md) — option boundary.
## License
MIT.

View File

@@ -0,0 +1,113 @@
{
"name": "@suaveplan/storage-browser-localstorage",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Browser localStorage AbstractStorage adapter for @suaveplan/storage: namespaced base64 JSON envelopes, TTL expiry on read, AbortSignal everywhere, strict key sanitisation, DOMException → typed LocalStorageError mapping, full OTel surface.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"browser": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./local-storage": {
"import": {
"types": "./dist/local-storage/index.d.ts",
"default": "./dist/local-storage/local-storage.js"
}
},
"./key-sanitizer": {
"import": {
"types": "./dist/key-sanitizer/index.d.ts",
"default": "./dist/key-sanitizer/key-sanitizer.js"
}
},
"./envelope": {
"import": {
"types": "./dist/envelope/index.d.ts",
"default": "./dist/envelope/envelope.js"
}
},
"./local-storage-adapter": {
"import": {
"types": "./dist/local-storage-adapter/index.d.ts",
"default": "./dist/local-storage-adapter/local-storage-adapter.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/index.d.ts",
"default": "./dist/otel/otel.js"
}
},
"./registry": {
"import": {
"types": "./dist/registry/index.d.ts",
"default": "./dist/registry/registry.js"
}
},
"./in-memory-storage": {
"import": {
"types": "./dist/in-memory-storage/index.d.ts",
"default": "./dist/in-memory-storage/in-memory-storage.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,12 @@
/** Maximum length of a caller-supplied key. localStorage entries are
* comparatively expensive so this cap is tighter than OPFS (1024). */
export const MAX_KEY_LENGTH = 256;
/** Default namespace prefix applied to every wire key. */
export const DEFAULT_NAMESPACE = "suaveplan:storage";
/** Current envelope schema version. Bumped on incompatible wire changes. */
export const ENVELOPE_VERSION = 1;
/** Sentinel key written by `isLocalStorageAvailable()` to probe write access. */
export const SENTINEL_KEY = "__suaveplan_localstorage_probe__";

View File

@@ -0,0 +1,84 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## `envelope` — base64 JSON wire format
### Purpose
The `localStorage` API only stores string values, but `AbstractStorage` deals in `Uint8Array`. The envelope module encodes binary payloads as base64 inside a JSON wrapper that also carries the full `StorageMetadata` record (size, timestamps, optional caller `metadata` bag) and an optional `expiresAt` TTL deadline. Centralising the wire format prevents drift between writers and readers, keeps the serialised representation forward-compatible via the `v` field, and lets corruption be detected as a typed boundary error rather than swallowed silently as `null` (which is how the upstream implementation behaved).
### Features
- Versioned envelope schema (`v: 1` today) — version mismatches raise `LOCAL_STORAGE.CORRUPT_PAYLOAD` instead of being silently re-interpreted.
- Standard base64 via `btoa` / `atob` for portability across browsers and Node.
- Metadata-preserving — `size`, `createdAt`, `modifiedAt`, `expiresAt?`, and the caller's `metadata?` bag round-trip byte-exact.
- Optional `expiresAt` millisecond Unix timestamp surfaces TTL on the wire so any reader (current or future-versioned) can prune expired rows.
- Pure exports for callers that want byte ↔ base64 conversion without the envelope wrapper.
### Usage: Basic Example
```ts
import {
decodeEnvelope,
encodeEnvelope,
} from "@suaveplan/storage-browser-localstorage/envelope";
const raw = encodeEnvelope(
new Uint8Array([1, 2, 3]),
{ size: 3, createdAt: 0, modifiedAt: 0 },
undefined
);
const { value, metadata } = decodeEnvelope(raw, "k");
```
### Usage: Advanced Example
```ts
import {
base64ToBytes,
bytesToBase64,
decodeEnvelope,
encodeEnvelope,
LOCAL_STORAGE_CODES,
LocalStorageError,
} from "@suaveplan/storage-browser-localstorage";
const bytes = new Uint8Array([0, 255, 128, 0, 1]);
const b64 = bytesToBase64(bytes);
const wire = encodeEnvelope(
bytes,
{
size: bytes.byteLength,
createdAt: Date.now(),
modifiedAt: Date.now(),
metadata: { mimeType: "application/octet-stream", tag: "demo" },
},
Date.now() + 60_000
);
try {
const parsed = decodeEnvelope(wire, "demo/k");
console.log(parsed.metadata.metadata);
} catch (err) {
if (
err instanceof LocalStorageError &&
err.code === LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
) {
// Telemetry hook: another tool wrote the slot.
}
}
```
### API Reference
- `interface Envelope` — `{ v, payload, meta, expiresAt? }` shape.
- `bytesToBase64(bytes: Uint8Array): string` — char-by-char `btoa` avoiding the `fromCharCode(...largeArray)` stack-overflow trap.
- `base64ToBytes(encoded: string): Uint8Array` — `atob` + charcode unpack. Throws on malformed input.
- `encodeEnvelope(bytes, metadata, expiresAt): string` — JSON-stringifies a fully-populated envelope.
- `decodeEnvelope(raw, key): { value, metadata, expiresAt? }` — parses and validates; throws `LocalStorageError(LOCAL_STORAGE.CORRUPT_PAYLOAD)` with the offending `key` on any structural defect.
### Implementation Notes
The base64 round-trip uses `btoa`/`atob` rather than `Buffer.from(...)` because the package's `browser` field targets the DOM realm — Node also exposes both globals from v18+ so the same code path runs in both environments without conditional import. Char-by-char encoding (`String.fromCharCode(bytes[i])`) is intentional: passing a large `Uint8Array` to `String.fromCharCode(...bytes)` blows the call stack on payloads over ~64 KiB in many engines. The decoder explicitly types every short-circuit failure (`v` mismatch, missing `payload`, non-object `meta`, malformed base64) as `CORRUPT_PAYLOAD` so callers downstream can distinguish "this slot was overwritten by another tool" from "infrastructure broke."

View File

@@ -0,0 +1,161 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
import {
base64ToBytes,
bytesToBase64,
decodeEnvelope,
encodeEnvelope,
} from "./envelope.js";
describe("envelope", () => {
it("round-trips arbitrary bytes through encodeEnvelope/decodeEnvelope", {
timeout: 5000,
}, () => {
const samples = [
new Uint8Array([]),
new Uint8Array([0]),
new Uint8Array([0, 1, 2, 3, 4, 5]),
new Uint8Array([255, 254, 253, 252]),
new Uint8Array([128, 64, 32, 16, 8, 4, 2, 1, 0]),
];
for (const bytes of samples) {
const meta = {
size: bytes.byteLength,
createdAt: 100,
modifiedAt: 200,
};
const wire = encodeEnvelope(bytes, meta, undefined);
const decoded = decodeEnvelope(wire, "k");
expect(decoded.value).toEqual(bytes);
expect(decoded.metadata).toEqual(meta);
expect(decoded.expiresAt).toBeUndefined();
}
});
it("preserves expiresAt when supplied", { timeout: 5000 }, () => {
const wire = encodeEnvelope(
new Uint8Array([1]),
{ size: 1, createdAt: 0, modifiedAt: 0 },
1700000000000
);
expect(decodeEnvelope(wire, "k").expiresAt).toBe(1700000000000);
});
it("bytesToBase64 + base64ToBytes round-trip every byte value", {
timeout: 5000,
}, () => {
const bytes = new Uint8Array(256);
for (let i = 0; i < 256; i++) bytes[i] = i;
const back = base64ToBytes(bytesToBase64(bytes));
expect(back).toEqual(bytes);
});
it("decodeEnvelope raises CORRUPT_PAYLOAD on invalid JSON", {
timeout: 5000,
}, () => {
try {
decodeEnvelope("not-json{", "k");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(LocalStorageError);
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
);
}
});
it("decodeEnvelope raises CORRUPT_PAYLOAD when parsed value is not an object", {
timeout: 5000,
}, () => {
for (const sample of ["null", '"hello"', "42", "true", "[]"]) {
try {
decodeEnvelope(sample, "k");
expect.fail(`expected throw for ${sample}`);
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
);
}
}
});
it("decodeEnvelope raises CORRUPT_PAYLOAD on version mismatch", {
timeout: 5000,
}, () => {
const wire = JSON.stringify({
v: 99,
payload: "AA==",
meta: { size: 1, createdAt: 0, modifiedAt: 0 },
});
try {
decodeEnvelope(wire, "k");
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
);
}
});
it("decodeEnvelope raises CORRUPT_PAYLOAD when payload is missing or non-string", {
timeout: 5000,
}, () => {
const noPayload = JSON.stringify({
v: 1,
meta: { size: 0, createdAt: 0, modifiedAt: 0 },
});
const numericPayload = JSON.stringify({
v: 1,
payload: 42,
meta: { size: 0, createdAt: 0, modifiedAt: 0 },
});
for (const sample of [noPayload, numericPayload]) {
try {
decodeEnvelope(sample, "k");
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
);
}
}
});
it("decodeEnvelope raises CORRUPT_PAYLOAD when meta is missing or non-object", {
timeout: 5000,
}, () => {
const samples = [
JSON.stringify({ v: 1, payload: "AA==" }),
JSON.stringify({ v: 1, payload: "AA==", meta: null }),
JSON.stringify({ v: 1, payload: "AA==", meta: "bad" }),
];
for (const wire of samples) {
try {
decodeEnvelope(wire, "k");
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
);
}
}
});
it("decodeEnvelope raises CORRUPT_PAYLOAD on malformed base64 payload", {
timeout: 5000,
}, () => {
const wire = JSON.stringify({
v: 1,
payload: "@@not-base64!!",
meta: { size: 0, createdAt: 0, modifiedAt: 0 },
});
try {
decodeEnvelope(wire, "k");
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
);
}
});
});

View File

@@ -0,0 +1,133 @@
import type { StorageMetadata } from "@suaveplan/storage/types";
import { ENVELOPE_VERSION } from "../constants/index.js";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
export interface Envelope {
/** Envelope schema version. Currently always `1`. */
v: number;
/** Base64-encoded value bytes. */
payload: string;
/** Storage metadata for this entry. */
meta: StorageMetadata;
/** Optional millisecond Unix timestamp after which the entry is expired. */
expiresAt?: number;
}
/**
* Encode a `Uint8Array` to standard base64 using `btoa`. Done char-by-char to
* avoid the `String.fromCharCode(...largeArray)` stack-overflow trap.
*/
export function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i] as number);
}
return btoa(binary);
}
/**
* Decode a base64 string to `Uint8Array`. Throws when the string is not
* valid base64 (caller wraps to `CORRUPT_PAYLOAD`).
*/
export function base64ToBytes(encoded: string): Uint8Array {
const binary = atob(encoded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i);
}
return out;
}
/**
* Serialise an envelope to its wire form (a JSON string suitable for
* `localStorage.setItem`). Round-trips byte-exactly through
* `decodeEnvelope`.
*/
export function encodeEnvelope(
bytes: Uint8Array,
metadata: StorageMetadata,
expiresAt: number | undefined
): string {
const envelope: Envelope = {
v: ENVELOPE_VERSION,
payload: bytesToBase64(bytes),
meta: metadata,
...(expiresAt !== undefined ? { expiresAt } : {}),
};
return JSON.stringify(envelope);
}
/**
* Parse a stored envelope from its wire form. Raises
* `LocalStorageError(LOCAL_STORAGE.CORRUPT_PAYLOAD)` when the string is not
* valid JSON, when the parsed shape is not an object, when the version is
* unsupported, when `payload` is missing or malformed base64, or when
* `meta` is missing/non-object.
*/
export function decodeEnvelope(
raw: string,
key: string
): { value: Uint8Array; metadata: StorageMetadata; expiresAt?: number } {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (cause) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
"envelope JSON is malformed",
{ key, cause: cause as Error }
);
}
if (parsed === null || typeof parsed !== "object") {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
"envelope is not an object",
{ key }
);
}
const env = parsed as Partial<Envelope>;
if (env.v !== ENVELOPE_VERSION) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
`unsupported envelope version: ${String(env.v)}`,
{ key }
);
}
if (typeof env.payload !== "string") {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
"envelope payload is missing or non-string",
{ key }
);
}
if (
env.meta === undefined ||
env.meta === null ||
typeof env.meta !== "object"
) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
"envelope meta is missing or non-object",
{ key }
);
}
let value: Uint8Array;
try {
value = base64ToBytes(env.payload);
} catch (cause) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD,
"envelope payload is not valid base64",
{ key, cause: cause as Error }
);
}
const result: {
value: Uint8Array;
metadata: StorageMetadata;
expiresAt?: number;
} = { value, metadata: env.meta as StorageMetadata };
if (typeof env.expiresAt === "number") {
result.expiresAt = env.expiresAt;
}
return result;
}

View File

@@ -0,0 +1,7 @@
export type { Envelope } from "./envelope.js";
export {
base64ToBytes,
bytesToBase64,
decodeEnvelope,
encodeEnvelope,
} from "./envelope.js";

View File

@@ -0,0 +1,68 @@
## `error-codes` — LocalStorage error registry
### Purpose
Single source of truth for every error code the `@suaveplan/storage-browser-localstorage` package can throw. The registry uses `defineCodes("LOCAL_STORAGE", [...])` from `@suaveplan/error` so each code is namespaced (`LOCAL_STORAGE.QUOTA_EXCEEDED`, `LOCAL_STORAGE.CORRUPT_PAYLOAD`, etc.), frozen at module load, and surfaced through a typed `LocalStorageErrorCode` union. Centralising the registry keeps the boundary vocabulary tiny, prevents drift between the implementation and the spec, and lets callers pattern-match on a stable string code rather than parsing message text.
### Features
- Ten frozen codes covering every observable failure mode: `NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`, `CORRUPT_PAYLOAD`.
- `LocalStorageError extends SuaveplanError<LocalStorageErrorCode>` with an optional `key` discriminant for log correlation.
- `cause` chain preserved from upstream `DOMException` instances (no message-text parsing required downstream).
- TypeScript-narrowed `LocalStorageErrorCode` union for exhaustive switches.
- `CORRUPT_PAYLOAD` distinguishes "another tool overwrote the slot with garbage" from `IO_FAILED` / `NOT_FOUND`, allowing telemetry to separate genuine corruption from infrastructure failure.
### Usage: Basic Example
```ts
import {
LOCAL_STORAGE_CODES,
LocalStorageError,
} from "@suaveplan/storage-browser-localstorage";
throw new LocalStorageError(LOCAL_STORAGE_CODES.NOT_FOUND, "missing entry", {
key: "user/42",
});
```
### Usage: Advanced Example
```ts
import {
LOCAL_STORAGE_CODES,
LocalStorageError,
type LocalStorageErrorCode,
} from "@suaveplan/storage-browser-localstorage";
function handle(err: unknown): "retry" | "fail" | "ignore" {
if (!(err instanceof LocalStorageError)) return "fail";
const code: LocalStorageErrorCode = err.code;
switch (code) {
case LOCAL_STORAGE_CODES.NOT_FOUND:
return "ignore";
case LOCAL_STORAGE_CODES.QUOTA_EXCEEDED:
case LOCAL_STORAGE_CODES.IO_FAILED:
return "retry";
case LOCAL_STORAGE_CODES.PERMISSION_DENIED:
case LOCAL_STORAGE_CODES.UNAVAILABLE:
return "fail";
case LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD:
return "fail";
case LOCAL_STORAGE_CODES.INVALID_KEY:
case LOCAL_STORAGE_CODES.KEY_TOO_LONG:
case LOCAL_STORAGE_CODES.INVALID_OPTIONS:
case LOCAL_STORAGE_CODES.ABORTED:
return "fail";
}
}
```
### API Reference
- `LOCAL_STORAGE_CODES` — frozen `Record<string, string>` mapping each short name to its dotted form (`"LOCAL_STORAGE.NOT_FOUND"`).
- `LocalStorageErrorCode` — `(typeof LOCAL_STORAGE_CODES)[keyof typeof LOCAL_STORAGE_CODES]` union.
- `class LocalStorageError extends SuaveplanError<LocalStorageErrorCode>` — adds optional `key?: string`. Constructor signature: `new LocalStorageError(code, message, options?)` where `options` may carry `cause` and `key`.
### Implementation Notes
The registry is constructed via `defineCodes` so the strings are validated at module load (preventing duplicate slot collisions across the project's packages). The `key` discriminant is intentionally not part of the registry — it is per-throw metadata; some failure modes (`UNAVAILABLE`, `INVALID_OPTIONS`) have no key to attach.

View File

@@ -0,0 +1,45 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const LOCAL_STORAGE_CODES = defineCodes("LOCAL_STORAGE", [
"NOT_FOUND",
"PERMISSION_DENIED",
"QUOTA_EXCEEDED",
"UNAVAILABLE",
"INVALID_KEY",
"KEY_TOO_LONG",
"INVALID_OPTIONS",
"ABORTED",
"IO_FAILED",
"CORRUPT_PAYLOAD",
] as const);
export type LocalStorageErrorCode =
(typeof LOCAL_STORAGE_CODES)[keyof typeof LOCAL_STORAGE_CODES];
export interface LocalStorageErrorOptions extends SuaveplanErrorOptions {
key?: string;
}
/**
* Structured error for localStorage adapter operations.
*
* Carries an optional `key` discriminant so callers can correlate failures
* with the offending key without parsing message text.
*/
export class LocalStorageError extends SuaveplanError<LocalStorageErrorCode> {
public readonly key: string | undefined;
public constructor(
code: LocalStorageErrorCode,
message: string,
options?: LocalStorageErrorOptions
) {
super(code, message, options);
this.key = options?.key;
this.name = "LocalStorageError";
}
}

View File

@@ -0,0 +1,64 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## `in-memory-storage` — real-implementation StorageLike fixture
### Purpose
`InMemoryStorage` is a real, dependency-free implementation of the DOM `Storage` interface (narrowed to `StorageLike`) used by the package's own test suite and exported under `./in-memory-storage` so downstream test packages can run against the same fixture without re-implementing the (subtle) DOM semantics. the test suite bans `vi.mock` / `vi.stubGlobal` — every test must run against a real implementation; this module is that real implementation. It also supports optional quota enforcement (to drive the `QUOTA_EXCEEDED` path) and `SecurityError` injection (to drive the `PERMISSION_DENIED` path) without resorting to monkey-patching the global.
### Features
- `length`, `key(i)`, `getItem`, `setItem`, `removeItem`, `clear` with DOM-compatible semantics.
- Insertion-order iteration matching the WHATWG `Storage` contract.
- Optional `quotaBytes` cap — when set, `setItem` throws `QuotaExceededDomException` (`name === "QuotaExceededError"`, `code === 22`) once the projected byte sum would exceed the cap.
- Optional `securityError` flag — every `setItem` throws `SecurityErrorDomException` (`name === "SecurityError"`, `code === 18`), mimicking Safari private mode.
- Side-effect free constructor; safe to instantiate multiple times concurrently.
### Usage: Basic Example
```ts
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
const storage = createInMemoryLocalStorage();
storage.setItem("k", "v");
console.log(storage.getItem("k")); // "v"
```
### Usage: Advanced Example
```ts
import {
createInMemoryLocalStorage,
QuotaExceededDomException,
SecurityErrorDomException,
} from "@suaveplan/storage-browser-localstorage/in-memory-storage";
const cramped = createInMemoryLocalStorage({ quotaBytes: 16 });
try {
cramped.setItem("longish-key", "x".repeat(50));
} catch (err) {
console.log(err instanceof QuotaExceededDomException); // true
}
const locked = createInMemoryLocalStorage({ securityError: true });
try {
locked.setItem("k", "v");
} catch (err) {
console.log(err instanceof SecurityErrorDomException); // true
}
```
### API Reference
- `createInMemoryLocalStorage(options?): InMemoryStorage` — preferred entry point.
- `class InMemoryStorage implements StorageLike` — Map-backed with insertion-order semantics.
- `class QuotaExceededDomException extends Error` — `name = "QuotaExceededError"`, `code = 22`.
- `class SecurityErrorDomException extends Error` — `name = "SecurityError"`, `code = 18`.
- `interface InMemoryStorageOptions` — `{ quotaBytes?, securityError? }`.
### Implementation Notes
The fixture uses a `Map<string, string>` for storage so `length`, `key(i)`, and iteration order all match DOM `Storage` insertion-order semantics. The quota probe is intentionally pessimistic: it recomputes the full byte sum on each `setItem` rather than maintaining a running counter, so the test is robust against `delete`-then-`set` interleaving without complex bookkeeping. `code === 22` (`QuotaExceededError`) is the modern WebKit/Chromium value; Firefox uses `code === 1014` for the same condition — the production error mapper checks both. The `key(i)` method's final `return null` after the loop is unreachable because the earlier `index >= size` short-circuit covers the out-of-range case, and the loop completes by returning the matching key — it is marked with a `c8 ignore` comment because Istanbul can't statically prove the unreachability.

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createInMemoryLocalStorage,
InMemoryStorage,
QuotaExceededDomException,
SecurityErrorDomException,
} from "./in-memory-storage.js";
describe("InMemoryStorage", () => {
it("setItem and getItem round-trip", { timeout: 5000 }, () => {
const storage = createInMemoryLocalStorage();
storage.setItem("a", "1");
expect(storage.getItem("a")).toBe("1");
expect(storage.getItem("missing")).toBeNull();
});
it("length reflects the number of stored slots", { timeout: 5000 }, () => {
const storage = createInMemoryLocalStorage();
expect(storage.length).toBe(0);
storage.setItem("a", "1");
storage.setItem("b", "2");
expect(storage.length).toBe(2);
});
it("key(i) returns null for out-of-range indices", {
timeout: 5000,
}, () => {
const storage = createInMemoryLocalStorage();
storage.setItem("a", "1");
expect(storage.key(0)).toBe("a");
expect(storage.key(1)).toBeNull();
expect(storage.key(-1)).toBeNull();
expect(storage.key(1.5)).toBeNull();
});
it("removeItem deletes a slot", { timeout: 5000 }, () => {
const storage = createInMemoryLocalStorage();
storage.setItem("a", "1");
storage.removeItem("a");
expect(storage.getItem("a")).toBeNull();
expect(storage.length).toBe(0);
});
it("clear empties every slot", { timeout: 5000 }, () => {
const storage = createInMemoryLocalStorage();
storage.setItem("a", "1");
storage.setItem("b", "2");
storage.clear();
expect(storage.length).toBe(0);
expect(storage.getItem("a")).toBeNull();
});
it("preserves insertion order for key(i)", { timeout: 5000 }, () => {
const storage = createInMemoryLocalStorage();
storage.setItem("a", "1");
storage.setItem("b", "2");
storage.setItem("c", "3");
expect([storage.key(0), storage.key(1), storage.key(2)]).toEqual([
"a",
"b",
"c",
]);
});
it("setItem overwriting an existing key does not change length", {
timeout: 5000,
}, () => {
const storage = createInMemoryLocalStorage();
storage.setItem("a", "1");
storage.setItem("a", "2");
expect(storage.length).toBe(1);
expect(storage.getItem("a")).toBe("2");
});
it("throws QuotaExceededDomException when projected size exceeds quota", {
timeout: 5000,
}, () => {
const storage = createInMemoryLocalStorage({ quotaBytes: 8 });
storage.setItem("a", "12"); // 1 + 2 = 3 bytes — OK.
try {
storage.setItem("b", "longish-value");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(QuotaExceededDomException);
expect((err as QuotaExceededDomException).code).toBe(22);
}
});
it("quota check excludes the slot being overwritten", {
timeout: 5000,
}, () => {
const storage = createInMemoryLocalStorage({ quotaBytes: 5 });
storage.setItem("a", "1234"); // 5 bytes — at quota.
// Overwrite with the same byte length — should NOT count both copies.
storage.setItem("a", "5678");
expect(storage.getItem("a")).toBe("5678");
});
it("securityError flag makes every setItem throw", {
timeout: 5000,
}, () => {
const storage = createInMemoryLocalStorage({ securityError: true });
try {
storage.setItem("a", "1");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(SecurityErrorDomException);
expect((err as SecurityErrorDomException).code).toBe(18);
}
});
it("InMemoryStorage class constructs with default options", {
timeout: 5000,
}, () => {
const storage = new InMemoryStorage();
expect(storage.length).toBe(0);
});
});

View File

@@ -0,0 +1,103 @@
import type { StorageLike } from "../types/storage-like.types.js";
export interface InMemoryStorageOptions {
/** Optional quota in bytes (sum of `key.length + value.length`). When the
* sum would exceed the quota, `setItem` throws a `QuotaExceededError`
* `DOMException`-shaped object. */
quotaBytes?: number;
/** When `true`, every `setItem` throws a `SecurityError` `DOMException`-
* shaped object — mimics Safari private mode. */
securityError?: boolean;
}
/**
* DOMException-shaped sentinel error. `localStorage` quota failures use this
* shape; the production error mapper inspects `name` and `code`.
*/
export class QuotaExceededDomException extends Error {
public override readonly name = "QuotaExceededError";
public readonly code = 22;
}
export class SecurityErrorDomException extends Error {
public override readonly name = "SecurityError";
public readonly code = 18;
}
/**
* In-memory `StorageLike` implementation that preserves the DOM `Storage`
* insertion-order semantics, supports optional quota enforcement, and can
* be configured to simulate Safari private-mode `SecurityError` rejections.
*
* Every method is a real implementation — no `vi.fn` mocks. The fixture is
* exported under `./in-memory-storage` so downstream test packages can use
* the same contract without re-implementing it.
*/
export class InMemoryStorage implements StorageLike {
readonly #map = new Map<string, string>();
readonly #quotaBytes: number | undefined;
readonly #securityError: boolean;
public constructor(options: InMemoryStorageOptions = {}) {
this.#quotaBytes = options.quotaBytes;
this.#securityError = options.securityError === true;
}
public get length(): number {
return this.#map.size;
}
public key(index: number): string | null {
if (!Number.isInteger(index) || index < 0) return null;
if (index >= this.#map.size) return null;
// `index < size` is invariant from the guard above. Snapshot the keys
// once and index directly so the function has a single exit point.
return [...this.#map.keys()][index] as string;
}
public getItem(key: string): string | null {
return this.#map.has(key) ? (this.#map.get(key) as string) : null;
}
public setItem(key: string, value: string): void {
if (this.#securityError) {
throw new SecurityErrorDomException(
"localStorage is disabled in this realm"
);
}
if (this.#quotaBytes !== undefined) {
// Compute the prospective sum: existing entries minus the slot
// being overwritten, plus the new entry.
let bytes = key.length + value.length;
for (const [k, v] of this.#map.entries()) {
if (k === key) continue;
bytes += k.length + v.length;
}
if (bytes > this.#quotaBytes) {
throw new QuotaExceededDomException(
`localStorage quota exceeded (need ${bytes}, cap ${this.#quotaBytes})`
);
}
}
this.#map.set(key, value);
}
public removeItem(key: string): void {
this.#map.delete(key);
}
public clear(): void {
this.#map.clear();
}
}
/**
* Factory creating a new `InMemoryStorage` with optional quota + security
* fault injection. Re-exported under `./in-memory-storage` so downstream
* test packages can share the same contract.
*/
export function createInMemoryLocalStorage(
options?: InMemoryStorageOptions
): InMemoryStorage {
return new InMemoryStorage(options);
}

View File

@@ -0,0 +1,7 @@
export type { InMemoryStorageOptions } from "./in-memory-storage.js";
export {
createInMemoryLocalStorage,
InMemoryStorage,
QuotaExceededDomException,
SecurityErrorDomException,
} from "./in-memory-storage.js";

View File

@@ -0,0 +1,72 @@
// Side-effect import: ensures `defineCodes` registers the LOCAL_STORAGE.*
// registry even when subpath consumers never reach this file.
import "./error-codes.js";
export {
base64ToBytes,
bytesToBase64,
decodeEnvelope,
type Envelope,
encodeEnvelope,
} from "./envelope/envelope.js";
export {
LOCAL_STORAGE_CODES,
LocalStorageError,
type LocalStorageErrorCode,
type LocalStorageErrorOptions,
} from "./error-codes.js";
export {
createInMemoryLocalStorage,
InMemoryStorage,
type InMemoryStorageOptions,
QuotaExceededDomException,
SecurityErrorDomException,
} from "./in-memory-storage/in-memory-storage.js";
export { sanitizeKey } from "./key-sanitizer/key-sanitizer.js";
export {
assertNotAborted,
isQuotaExceeded,
mapLocalStorageError,
} from "./local-storage/local-storage.errors.js";
export {
createLocalStorageStorage,
LocalStorageStorage,
} from "./local-storage/local-storage.js";
export {
buildWireKey,
collectOwnedKeys,
isOwnedWireKey,
paginateKeys,
stripNamespace,
} from "./local-storage/local-storage.ops.js";
export { assertNonNegInt } from "./local-storage/local-storage.partial.js";
export {
buildMetadata,
type DecodedEntry,
peekMetadata,
type ResolvedConfig,
readEnvelope,
requireEntry,
resolveOptions,
} from "./local-storage/local-storage.slot.js";
export {
defaultLocalStorage,
isLocalStorageAvailable,
} from "./local-storage-adapter/local-storage-adapter.js";
export {
getInstruments,
type RecordOpInput,
recordOp,
resetInstruments,
type StorageBrowserLocalStorageInstruments,
type StorageBrowserLocalStorageOp,
type StorageBrowserLocalStorageOpOutcome,
setStorageBrowserLocalStorageMeterProvider,
} from "./otel/otel.js";
export { registerLocalStorageProvider } from "./registry/registry.js";
export type {
LocalStorageAdapter,
LocalStorageStorageOptions,
StorageLike,
} from "./types/index.js";
export { LocalStorageStorageOptionsSchema } from "./types/index.js";

View File

@@ -0,0 +1 @@
export { sanitizeKey } from "./key-sanitizer.js";

View File

@@ -0,0 +1,64 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## `key-sanitizer` — strict caller-key boundary
### Purpose
`sanitizeKey(key)` is the single validation funnel through which every caller-supplied key passes before any `localStorage` API call is issued. It enforces the storage-browser-localstorage boundary contract: keys must be non-empty strings of bounded length, must not contain control characters or path-traversal segments, must not be absolute, and must use POSIX separators internally. Centralising this guard prevents adapter callers from accidentally exfiltrating data outside the configured namespace or constructing wire keys that collide with sibling tools sharing the origin's `localStorage`.
### Features
- Rejects empty strings, non-string input, keys over 256 chars, control chars (0x00-0x1F, 0x7F), absolute POSIX paths, and `..` traversal segments.
- Normalises Windows `\` separators to POSIX `/` so cross-platform callers see consistent layout.
- Throws typed `LocalStorageError(LOCAL_STORAGE.INVALID_KEY | LOCAL_STORAGE.KEY_TOO_LONG)` with the offending key captured for log correlation.
- Pure / synchronous / dependency-free — safe to call in hot paths.
### Usage: Basic Example
```ts
import { sanitizeKey } from "@suaveplan/storage-browser-localstorage/key-sanitizer";
const safe = sanitizeKey("orders/2026/05/12.json");
// → "orders/2026/05/12.json"
```
### Usage: Advanced Example
```ts
import {
LOCAL_STORAGE_CODES,
LocalStorageError,
sanitizeKey,
} from "@suaveplan/storage-browser-localstorage";
function safeKeyOrNull(input: unknown): string | null {
if (typeof input !== "string") return null;
try {
return sanitizeKey(input);
} catch (err) {
if (
err instanceof LocalStorageError &&
(err.code === LOCAL_STORAGE_CODES.INVALID_KEY ||
err.code === LOCAL_STORAGE_CODES.KEY_TOO_LONG)
) {
return null;
}
throw err;
}
}
```
### API Reference
`sanitizeKey(key: string): string`
- **Throws** `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` for empty / non-string / control-char / absolute / traversal-segment input.
- **Throws** `LocalStorageError(LOCAL_STORAGE.KEY_TOO_LONG)` for keys with `length > 256`.
- **Returns** the normalised POSIX-separator key on success.
### Implementation Notes
The control-char scan is a single linear pass over `charCodeAt(i)` — it costs less than a regex compile for typical key sizes. The traversal check splits on `/` after backslash normalisation to ensure forms like `a\..\b` are rejected, not just `a/../b`. The 256-char cap is tighter than the OPFS adapter's 1024: `localStorage` enumeration cost is O(n) over all slot keys, and tighter caps reduce worst-case `list()` scan time when collisions are deliberately crafted by a noisy tab.

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
import { sanitizeKey } from "./key-sanitizer.js";
describe("sanitizeKey", () => {
it("returns valid POSIX-separator keys unchanged", {
timeout: 5000,
}, () => {
expect(sanitizeKey("a")).toBe("a");
expect(sanitizeKey("orders/2026/05/12.json")).toBe(
"orders/2026/05/12.json"
);
});
it("normalises Windows backslash separators to POSIX", {
timeout: 5000,
}, () => {
expect(sanitizeKey("orders\\2026\\05.json")).toBe(
"orders/2026/05.json"
);
});
it("accepts unicode and mixed-script keys", { timeout: 5000 }, () => {
expect(sanitizeKey("héllo/wörld")).toBe("héllo/wörld");
expect(sanitizeKey("日本語")).toBe("日本語");
});
it("rejects empty keys with INVALID_KEY", { timeout: 5000 }, () => {
try {
sanitizeKey("");
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(LocalStorageError);
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_KEY
);
}
});
it("rejects non-string input with INVALID_KEY", { timeout: 5000 }, () => {
try {
sanitizeKey(123 as unknown as string);
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_KEY
);
}
});
it("rejects keys longer than 256 chars with KEY_TOO_LONG", {
timeout: 5000,
}, () => {
try {
sanitizeKey("a".repeat(257));
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.KEY_TOO_LONG
);
}
});
it("rejects keys containing control characters", { timeout: 5000 }, () => {
for (const sample of ["ab\x00cd", "ab\x7fcd", "ab\x1fcd"]) {
try {
sanitizeKey(sample);
expect.fail(`expected throw for ${JSON.stringify(sample)}`);
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_KEY
);
}
}
});
it("rejects absolute POSIX paths with INVALID_KEY", {
timeout: 5000,
}, () => {
try {
sanitizeKey("/etc/passwd");
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_KEY
);
}
});
it("rejects path traversal segments", { timeout: 5000 }, () => {
for (const sample of ["../../etc/passwd", "a/../b", "a\\..\\b"]) {
try {
sanitizeKey(sample);
expect.fail(`expected throw for ${sample}`);
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_KEY
);
}
}
});
});

View File

@@ -0,0 +1,75 @@
import { MAX_KEY_LENGTH } from "../constants/index.js";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
// Returns true when any code unit is a C0 control char (0x00-0x1F) or DEL (0x7F).
function containsControlChar(value: string): boolean {
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i);
if (code <= 0x1f || code === 0x7f) return true;
}
return false;
}
/**
* Validate and normalise a caller-supplied storage key.
*
* Returns the normalised POSIX-separator form on success; throws
* `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` or
* `LocalStorageError(LOCAL_STORAGE.KEY_TOO_LONG)` on rejection. Rejection
* rules:
* - non-string input
* - empty string
* - length over `MAX_KEY_LENGTH` (256) chars
* - contains a `..` path segment (parent-directory traversal)
* - starts with `/` (POSIX absolute)
* - contains a null byte (`\x00`) or other control char
*
* Normalisation: Windows `\` separators are replaced with POSIX `/`.
*/
export function sanitizeKey(key: string): string {
if (typeof key !== "string") {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.INVALID_KEY,
"key must be a string"
);
}
if (key.length === 0) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.INVALID_KEY,
"key must not be empty"
);
}
if (key.length > MAX_KEY_LENGTH) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.KEY_TOO_LONG,
`key length ${key.length} exceeds maximum ${MAX_KEY_LENGTH}`,
{ key: `${key.slice(0, 64)}…` }
);
}
if (containsControlChar(key)) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.INVALID_KEY,
"key contains a control character",
{ key }
);
}
if (key.startsWith("/")) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.INVALID_KEY,
"absolute paths are not allowed",
{ key }
);
}
const normalised = key.replace(/\\/g, "/");
const segments = normalised.split("/");
for (const segment of segments) {
if (segment === "..") {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.INVALID_KEY,
"path traversal segment (..) is not allowed",
{ key }
);
}
}
return normalised;
}

View File

@@ -0,0 +1,4 @@
export {
defaultLocalStorage,
isLocalStorageAvailable,
} from "./local-storage-adapter.js";

View File

@@ -0,0 +1,66 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## `local-storage-adapter` — global resolver + availability probe
### Purpose
`defaultLocalStorage()` returns a `LocalStorageAdapter` that lazily resolves `globalThis.localStorage` on every public-method call. Lazy resolution is critical because the package is published as universal/browser-tier and `globalThis.localStorage` is absent in Node, Workers without a storage shim, and ServiceWorker realms — eagerly reading the global at module load would either throw a `TypeError` on missing-property access or capture an undefined snapshot and freeze it for the lifetime of the import. `isLocalStorageAvailable()` is the companion feature detector that also accounts for Safari private mode, where `localStorage` exists on the global but every `setItem` throws `SecurityError` — the only reliable detection is a sentinel write/remove probe.
### Features
- Lazy `read()` — `globalThis.localStorage` is looked up at first method call, never at import.
- Typed `LOCAL_STORAGE.UNAVAILABLE` error when the global is missing.
- `isLocalStorageAvailable(storage?)` sentinel probe — writes + removes a probe key (`__suaveplan_localstorage_probe__`) to detect Safari private mode.
- The probe restores the storage to its prior state on success (no side effects observable to other consumers).
### Usage: Basic Example
```ts
import {
defaultLocalStorage,
isLocalStorageAvailable,
} from "@suaveplan/storage-browser-localstorage/local-storage-adapter";
if (isLocalStorageAvailable()) {
const adapter = defaultLocalStorage();
adapter.read().setItem("hello", "world");
}
```
### Usage: Advanced Example
```ts
import {
createInMemoryLocalStorage,
defaultLocalStorage,
isLocalStorageAvailable,
LocalStorageError,
LOCAL_STORAGE_CODES,
} from "@suaveplan/storage-browser-localstorage";
// Force-test the unavailable path under Node:
try {
defaultLocalStorage().read();
} catch (err) {
console.log(
err instanceof LocalStorageError &&
err.code === LOCAL_STORAGE_CODES.UNAVAILABLE
);
}
// Probe a custom StorageLike — useful when wrapping platform shims:
const fixture = createInMemoryLocalStorage({ securityError: true });
console.log(isLocalStorageAvailable(fixture)); // false
```
### API Reference
- `defaultLocalStorage(): LocalStorageAdapter` — returns `{ read() }`; each `read()` throws `LocalStorageError(LOCAL_STORAGE.UNAVAILABLE)` if `globalThis.localStorage` is missing.
- `isLocalStorageAvailable(storage?: StorageLike): boolean` — returns `true` only when storage is present AND sentinel write succeeds; defaults to `globalThis.localStorage` when no argument is provided.
### Implementation Notes
The sentinel key is namespaced with a leading + trailing double-underscore (`__suaveplan_localstorage_probe__`) so it cannot collide with the package's own namespaced keys (`suaveplan:storage:...`) or any reasonable application key. The probe deliberately swallows ALL exceptions — Safari private mode throws `SecurityError`, but quota-exceeded realms throw `QuotaExceededError`, and some test-runner shims throw `TypeError` for "not implemented." All three should report "unavailable" because the adapter cannot meaningfully use the storage. The lazy resolver pattern matches the OPFS adapter sibling — both packages defer global lookups until first method call so universal consumers can `import` them safely.

View File

@@ -0,0 +1,100 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
import { createInMemoryLocalStorage } from "../in-memory-storage/in-memory-storage.js";
import {
defaultLocalStorage,
isLocalStorageAvailable,
} from "./local-storage-adapter.js";
interface GlobalWithStorage {
localStorage?: unknown;
}
describe("defaultLocalStorage", () => {
let priorGlobal: { had: boolean; value: unknown };
beforeEach(() => {
const g = globalThis as unknown as GlobalWithStorage;
priorGlobal = { had: "localStorage" in g, value: g.localStorage };
delete (g as Record<string, unknown>).localStorage;
});
afterEach(() => {
const g = globalThis as unknown as Record<string, unknown>;
if (priorGlobal.had) {
g.localStorage = priorGlobal.value;
} else {
delete g.localStorage;
}
});
it("throws LOCAL_STORAGE.UNAVAILABLE when globalThis.localStorage is missing", {
timeout: 5000,
}, () => {
try {
defaultLocalStorage().read();
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(LocalStorageError);
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.UNAVAILABLE
);
}
});
it("returns the global localStorage when present", {
timeout: 5000,
}, () => {
const fixture = createInMemoryLocalStorage();
(globalThis as unknown as Record<string, unknown>).localStorage =
fixture;
const adapter = defaultLocalStorage();
const resolved = adapter.read();
resolved.setItem("k", "1");
expect(fixture.getItem("k")).toBe("1");
});
});
describe("isLocalStorageAvailable", () => {
it("returns true for an in-memory fixture", { timeout: 5000 }, () => {
const fixture = createInMemoryLocalStorage();
expect(isLocalStorageAvailable(fixture)).toBe(true);
});
it("returns false when sentinel write throws (Safari private mode)", {
timeout: 5000,
}, () => {
const locked = createInMemoryLocalStorage({ securityError: true });
expect(isLocalStorageAvailable(locked)).toBe(false);
});
it("returns false when the global is missing and no override is provided", {
timeout: 5000,
}, () => {
const priorHad = "localStorage" in globalThis;
const priorValue = (globalThis as unknown as Record<string, unknown>)
.localStorage;
delete (globalThis as unknown as Record<string, unknown>).localStorage;
try {
expect(isLocalStorageAvailable()).toBe(false);
} finally {
if (priorHad) {
(
globalThis as unknown as Record<string, unknown>
).localStorage = priorValue;
}
}
});
it("removes its sentinel key on success", { timeout: 5000 }, () => {
const fixture = createInMemoryLocalStorage();
fixture.setItem("pre-existing", "untouched");
isLocalStorageAvailable(fixture);
expect(fixture.length).toBe(1);
expect(fixture.getItem("pre-existing")).toBe("untouched");
});
});

View File

@@ -0,0 +1,60 @@
import { SENTINEL_KEY } from "../constants/index.js";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
import type {
LocalStorageAdapter,
StorageLike,
} from "../types/storage-like.types.js";
interface GlobalWithStorage {
localStorage?: StorageLike;
}
function readGlobalStorage(): StorageLike | undefined {
return (globalThis as unknown as GlobalWithStorage).localStorage;
}
/**
* Default `LocalStorageAdapter` wrapping `globalThis.localStorage`.
*
* Each `read()` call throws `LocalStorageError(LOCAL_STORAGE.UNAVAILABLE)`
* when the global is missing — Node, Workers without a storage shim, or
* realms where the global was deleted. The lookup is deferred to call time
* so the package can be imported in any environment without throwing at
* module evaluation.
*/
export function defaultLocalStorage(): LocalStorageAdapter {
return {
read(): StorageLike {
const ls = readGlobalStorage();
if (ls === undefined) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.UNAVAILABLE,
"globalThis.localStorage is not available in this environment"
);
}
return ls;
},
};
}
/**
* Feature detector — returns `true` only when both the `localStorage`
* global is present AND a sentinel `setItem`/`removeItem` probe completes
* without throwing. Safari private mode exposes `localStorage` but throws
* on every `setItem` with a `SecurityError`; this probe surfaces that case
* synchronously.
*
* Pure, synchronous, side-effect free on success (the probe removes its
* own sentinel before returning).
*/
export function isLocalStorageAvailable(storage?: StorageLike): boolean {
const ls = storage ?? readGlobalStorage();
if (ls === undefined) return false;
try {
ls.setItem(SENTINEL_KEY, "1");
ls.removeItem(SENTINEL_KEY);
return true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,26 @@
export {
assertNotAborted,
isQuotaExceeded,
mapLocalStorageError,
} from "./local-storage.errors.js";
export {
createLocalStorageStorage,
LocalStorageStorage,
} from "./local-storage.js";
export {
buildWireKey,
collectOwnedKeys,
isOwnedWireKey,
paginateKeys,
stripNamespace,
} from "./local-storage.ops.js";
export { assertNonNegInt } from "./local-storage.partial.js";
export {
buildMetadata,
type DecodedEntry,
peekMetadata,
type ResolvedConfig,
readEnvelope,
requireEntry,
resolveOptions,
} from "./local-storage.slot.js";

View File

@@ -0,0 +1,151 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
import {
assertNotAborted,
isQuotaExceeded,
mapLocalStorageError,
} from "./local-storage.errors.js";
describe("isQuotaExceeded", () => {
it("returns true when name is QuotaExceededError", {
timeout: 5000,
}, () => {
expect(
isQuotaExceeded(
Object.assign(new Error("full"), { name: "QuotaExceededError" })
)
).toBe(true);
});
it("returns true for legacy WebKit code 22", { timeout: 5000 }, () => {
expect(
isQuotaExceeded(Object.assign(new Error("full"), { code: 22 }))
).toBe(true);
});
it("returns true for Firefox code 1014", { timeout: 5000 }, () => {
expect(
isQuotaExceeded(Object.assign(new Error("full"), { code: 1014 }))
).toBe(true);
});
it("returns false for unrelated errors", { timeout: 5000 }, () => {
expect(isQuotaExceeded(new Error("nope"))).toBe(false);
expect(isQuotaExceeded(null)).toBe(false);
expect(isQuotaExceeded("string")).toBe(false);
expect(isQuotaExceeded({ name: 42 })).toBe(false);
expect(isQuotaExceeded({ code: "not-a-number" })).toBe(false);
});
});
describe("mapLocalStorageError", () => {
it("passes through a pre-existing LocalStorageError", {
timeout: 5000,
}, () => {
const pre = new LocalStorageError(
LOCAL_STORAGE_CODES.UNAVAILABLE,
"no storage"
);
expect(mapLocalStorageError(pre, "k")).toBe(pre);
});
it("maps QuotaExceededError without a key", { timeout: 5000 }, () => {
const err = mapLocalStorageError(
Object.assign(new Error("full"), { name: "QuotaExceededError" }),
undefined
);
expect(err.code).toBe(LOCAL_STORAGE_CODES.QUOTA_EXCEEDED);
expect(err.key).toBeUndefined();
expect(err.message).not.toContain("writing");
});
it("maps QuotaExceededError with a key", { timeout: 5000 }, () => {
const err = mapLocalStorageError(
Object.assign(new Error("full"), { code: 1014 }),
"user/42"
);
expect(err.code).toBe(LOCAL_STORAGE_CODES.QUOTA_EXCEEDED);
expect(err.key).toBe("user/42");
expect(err.message).toContain("user/42");
});
it("maps NotAllowedError to PERMISSION_DENIED", { timeout: 5000 }, () => {
const err = mapLocalStorageError(
Object.assign(new Error("blocked"), { name: "NotAllowedError" }),
"k"
);
expect(err.code).toBe(LOCAL_STORAGE_CODES.PERMISSION_DENIED);
expect(err.key).toBe("k");
});
it("maps NotAllowedError without a key", { timeout: 5000 }, () => {
const err = mapLocalStorageError(
Object.assign(new Error("blocked"), { name: "NotAllowedError" }),
undefined
);
expect(err.code).toBe(LOCAL_STORAGE_CODES.PERMISSION_DENIED);
expect(err.key).toBeUndefined();
});
it("maps non-Error throwables to IO_FAILED with a synthetic message", {
timeout: 5000,
}, () => {
const err = mapLocalStorageError("not-an-error", "k");
expect(err.code).toBe(LOCAL_STORAGE_CODES.IO_FAILED);
expect(err.cause).toBeUndefined();
expect(err.message).toContain("localStorage operation failed");
});
it("maps non-Error throwables to IO_FAILED without a key", {
timeout: 5000,
}, () => {
const err = mapLocalStorageError("not-an-error", undefined);
expect(err.code).toBe(LOCAL_STORAGE_CODES.IO_FAILED);
expect(err.key).toBeUndefined();
});
});
describe("assertNotAborted", () => {
it("returns nothing when signal is undefined", { timeout: 5000 }, () => {
expect(() => assertNotAborted(undefined, "k")).not.toThrow();
});
it("returns nothing when signal is not aborted", {
timeout: 5000,
}, () => {
const ac = new AbortController();
expect(() => assertNotAborted(ac.signal, "k")).not.toThrow();
});
it("throws ABORTED when signal is aborted (with key)", {
timeout: 5000,
}, () => {
const ac = new AbortController();
ac.abort();
try {
assertNotAborted(ac.signal, "k");
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.ABORTED
);
expect((err as LocalStorageError).key).toBe("k");
}
});
it("throws ABORTED when signal is aborted (no key)", {
timeout: 5000,
}, () => {
const ac = new AbortController();
ac.abort();
try {
assertNotAborted(ac.signal, undefined);
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.ABORTED
);
expect((err as LocalStorageError).key).toBeUndefined();
}
});
});

View File

@@ -0,0 +1,66 @@
## `local-storage.errors` — DOMException mapping
### Purpose
Maps every error thrown by the underlying `Storage` adapter to the typed `LocalStorageError` surface. Browsers raise inconsistent error shapes for the same logical failure — Safari WebKit uses `QuotaExceededError` (name) + `code === 22`, Firefox uses `code === 1014`, and Safari private mode raises `SecurityError` for what is conceptually "the storage is locked." Centralising the translation here lets `LocalStorageStorage` raise a single error class with stable codes, while preserving the underlying `cause` for telemetry.
### Features
- `isQuotaExceeded(err)` — `true` for any of the three browser variants.
- `mapLocalStorageError(err, key?)` — translates to `LocalStorageError` with code routing: quota → `QUOTA_EXCEEDED`, security/not-allowed → `PERMISSION_DENIED`, anything else → `IO_FAILED`.
- `assertNotAborted(signal, key?)` — pre-aborted signal guard raising `LOCAL_STORAGE.ABORTED`.
- Pre-existing `LocalStorageError` instances pass through untouched (so `defaultLocalStorage()`'s `UNAVAILABLE` survives the wrap).
- Optional `key` discriminant carried into the resulting error for log correlation.
### Usage: Basic Example
```ts
import { mapLocalStorageError } from "@suaveplan/storage-browser-localstorage/local-storage";
try {
throw Object.assign(new Error("full"), { name: "QuotaExceededError" });
} catch (err) {
throw mapLocalStorageError(err, "user/42");
}
```
### Usage: Advanced Example
```ts
import {
assertNotAborted,
isQuotaExceeded,
LOCAL_STORAGE_CODES,
LocalStorageError,
mapLocalStorageError,
} from "@suaveplan/storage-browser-localstorage";
function safeWrite(
storage: { setItem(k: string, v: string): void },
key: string,
value: string,
signal?: AbortSignal
): void {
assertNotAborted(signal, key);
try {
storage.setItem(key, value);
} catch (err) {
const mapped = mapLocalStorageError(err, key);
if (mapped.code === LOCAL_STORAGE_CODES.QUOTA_EXCEEDED) {
// Caller may now LRU-evict and retry.
}
throw mapped;
}
}
console.log(isQuotaExceeded(Object.assign(new Error(), { code: 1014 })));
```
### API Reference
- `isQuotaExceeded(err: unknown): boolean` — checks `name === "QuotaExceededError"` AND legacy `code === 22` AND Firefox `code === 1014`.
- `mapLocalStorageError(err: unknown, key: string | undefined): LocalStorageError` — pass-through for `LocalStorageError`; quota → `QUOTA_EXCEEDED`; security/not-allowed → `PERMISSION_DENIED`; otherwise → `IO_FAILED` preserving `cause`.
- `assertNotAborted(signal: AbortSignal | undefined, key: string | undefined): void` — throws `LOCAL_STORAGE.ABORTED` when `signal.aborted === true`.
### Implementation Notes
The dual `code === 22 || code === 1014` check is critical — Firefox-only consumers reported "quota errors map to generic IO failures" before this branch existed in the upstream library; the migration captures both at the boundary. The `name === "NotAllowedError"` branch covers OPFS-flavored permission-denied errors that some test harnesses produce; mapping them to `PERMISSION_DENIED` keeps the storage adapters consistent with their OPFS sibling. The unknown-error branch uses the `err.message` (when `err instanceof Error`) inside the wrapped message so logs surface both the package code AND the original exception text without forcing dashboards to unwrap `cause`.

View File

@@ -0,0 +1,251 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOCAL_STORAGE_CODES, type LocalStorageError } from "../error-codes.js";
import { createInMemoryLocalStorage } from "../in-memory-storage/in-memory-storage.js";
import {
createLocalStorageStorage,
LocalStorageStorage,
} from "./local-storage.js";
function withProxy<K extends string>(
fixture: ReturnType<typeof createInMemoryLocalStorage>,
method: K,
fn: (...args: unknown[]) => unknown
): typeof fixture {
return new Proxy(fixture, {
get(target, prop): unknown {
if (prop === method) return fn;
// Read against `target` (not the proxy receiver) so private-field
// access in getters / methods is anchored on the real instance.
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
}) as typeof fixture;
}
describe("LocalStorageStorage — error mapping", () => {
it("maps quota errors to LOCAL_STORAGE.QUOTA_EXCEEDED", {
timeout: 5000,
}, async () => {
const fixture = createInMemoryLocalStorage({ quotaBytes: 8 });
const storage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => fixture },
});
try {
await storage.set("k", new Uint8Array(1024));
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.QUOTA_EXCEEDED
);
}
});
it("maps SecurityError to LOCAL_STORAGE.PERMISSION_DENIED", {
timeout: 5000,
}, async () => {
const fixture = createInMemoryLocalStorage({ securityError: true });
const storage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => fixture },
});
try {
await storage.set("k", new Uint8Array([1]));
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.PERMISSION_DENIED
);
}
});
it("maps unknown setItem errors to LOCAL_STORAGE.IO_FAILED preserving cause", {
timeout: 5000,
}, async () => {
const fixture = createInMemoryLocalStorage();
const proxy = withProxy(fixture, "setItem", () => {
throw new Error("disk blew up");
});
const storage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => proxy },
});
try {
await storage.set("k", new Uint8Array([1]));
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.IO_FAILED
);
expect((err as LocalStorageError).cause).toBeInstanceOf(Error);
}
});
it("get rethrows non-CORRUPT_PAYLOAD errors from getItem", {
timeout: 5000,
}, async () => {
const fixture = createInMemoryLocalStorage();
const proxy = withProxy(fixture, "getItem", () => {
throw new Error("io broke");
});
const storage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => proxy },
});
try {
await storage.get("k");
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.IO_FAILED
);
}
});
it("delete maps removeItem errors to LOCAL_STORAGE.IO_FAILED", {
timeout: 5000,
}, async () => {
const fixture = createInMemoryLocalStorage();
fixture.setItem(
"t:k",
JSON.stringify({
v: 1,
payload: "AA==",
meta: { size: 1, createdAt: 0, modifiedAt: 0 },
})
);
const proxy = withProxy(fixture, "removeItem", () => {
throw new Error("locked");
});
const storage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => proxy },
});
try {
await storage.delete("k");
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.IO_FAILED
);
}
});
it("clear maps removeItem errors to LOCAL_STORAGE.IO_FAILED", {
timeout: 5000,
}, async () => {
const fixture = createInMemoryLocalStorage();
fixture.setItem("t:k", "irrelevant");
const proxy = withProxy(fixture, "removeItem", () => {
throw new Error("locked");
});
const storage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => proxy },
});
try {
await storage.clear();
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.IO_FAILED
);
}
});
it("set swallows peekMetadata getItem failures and writes new entry", {
timeout: 5000,
}, async () => {
const fixture = createInMemoryLocalStorage();
let firstCall = true;
const proxy = new Proxy(fixture, {
get(target, prop): unknown {
if (prop === "getItem") {
if (firstCall) {
firstCall = false;
return () => {
throw new Error("transient");
};
}
// Subsequent reads delegate to the real getItem so the
// write-back round-trip can complete.
const real = Reflect.get(target, prop, target);
return (real as (k: string) => string | null).bind(target);
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
const storage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => proxy },
});
// peekMetadata swallows the first getItem error → set proceeds with
// createdAt === now.
await storage.set("k", new Uint8Array([1]));
expect(await storage.get("k")).toEqual(new Uint8Array([1]));
});
it("lazy expiry removeItem failure is swallowed (best-effort)", {
timeout: 5000,
}, async () => {
const fixture = createInMemoryLocalStorage();
let callCount = 0;
const proxy = withProxy(fixture, "removeItem", () => {
callCount++;
throw new Error("locked");
});
const clock = { now: 1000 };
const storage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => proxy },
clock: () => clock.now,
});
fixture.setItem(
"t:k",
JSON.stringify({
v: 1,
payload: "AA==",
meta: { size: 1, createdAt: 0, modifiedAt: 0 },
expiresAt: 500,
})
);
expect(await storage.get("k")).toBeUndefined();
expect(callCount).toBeGreaterThanOrEqual(1);
});
});
describe("LocalStorageStorage — options", () => {
it("rejects malformed options with LOCAL_STORAGE.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
try {
new LocalStorageStorage({ namespace: 123 as never });
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_OPTIONS
);
}
});
it("rejects empty namespace with LOCAL_STORAGE.INVALID_OPTIONS", {
timeout: 5000,
}, () => {
try {
new LocalStorageStorage({ namespace: "" });
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_OPTIONS
);
}
});
it("constructs with default options", { timeout: 5000 }, () => {
const storage = new LocalStorageStorage();
expect(storage).toBeInstanceOf(LocalStorageStorage);
const factory = createLocalStorageStorage();
expect(factory).toBeInstanceOf(LocalStorageStorage);
});
});

View File

@@ -0,0 +1,84 @@
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
interface DomLike {
name?: unknown;
code?: unknown;
message?: unknown;
}
function nameOf(err: unknown): string | undefined {
if (err === null || typeof err !== "object") return undefined;
const n = (err as DomLike).name;
return typeof n === "string" ? n : undefined;
}
function codeOf(err: unknown): number | undefined {
if (err === null || typeof err !== "object") return undefined;
const c = (err as DomLike).code;
return typeof c === "number" ? c : undefined;
}
/**
* True when `err` looks like a `QuotaExceededError` DOMException. Covers all
* three identifiers in use across browsers: WHATWG `name`, legacy WebKit
* `code === 22`, and Firefox-flavoured `code === 1014`.
*/
export function isQuotaExceeded(err: unknown): boolean {
if (nameOf(err) === "QuotaExceededError") return true;
const code = codeOf(err);
return code === 22 || code === 1014;
}
/**
* Map any error thrown by an underlying `localStorage` call to the typed
* `LocalStorageError` surface. Pre-existing `LocalStorageError` instances
* pass through unmodified so a caller-thrown `LOCAL_STORAGE.UNAVAILABLE`
* from `defaultLocalStorage()` reaches the boundary untouched.
*/
export function mapLocalStorageError(
err: unknown,
key: string | undefined
): LocalStorageError {
if (err instanceof LocalStorageError) return err;
const name = nameOf(err);
const cause = err instanceof Error ? err : undefined;
if (isQuotaExceeded(err)) {
return new LocalStorageError(
LOCAL_STORAGE_CODES.QUOTA_EXCEEDED,
`localStorage quota exceeded${key !== undefined ? ` writing ${key}` : ""}`,
key !== undefined ? { key, cause } : { cause }
);
}
if (name === "SecurityError" || name === "NotAllowedError") {
return new LocalStorageError(
LOCAL_STORAGE_CODES.PERMISSION_DENIED,
`localStorage access denied${key !== undefined ? ` for ${key}` : ""}`,
key !== undefined ? { key, cause } : { cause }
);
}
const message =
err instanceof Error ? err.message : "localStorage operation failed";
return new LocalStorageError(
LOCAL_STORAGE_CODES.IO_FAILED,
`localStorage IO failure${key !== undefined ? ` on ${key}` : ""}: ${message}`,
key !== undefined ? { key, cause } : { cause }
);
}
/**
* Throw `LocalStorageError(LOCAL_STORAGE.ABORTED)` when `signal` is already
* aborted. Called at the top of every public async method before any
* `localStorage` call is issued.
*/
export function assertNotAborted(
signal: AbortSignal | undefined,
key: string | undefined
): void {
if (signal?.aborted) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.ABORTED,
"Operation aborted",
key !== undefined ? { key } : {}
);
}
}

View File

@@ -0,0 +1,88 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - local-storage.ops — `localStorage` is a flat string…
- [local-storage.ops](./local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
- [local-storage.partial](./local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
- [local-storage.slot](./local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
- [local-storage.ops](./local-storage.ops.md) — `localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` par…
- [local-storage.partial](./local-storage.partial.md) — `localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorag…
- [local-storage.slot](./local-storage.slot.md) — The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside t…
## `local-storage` — LocalStorageStorage adapter
### Purpose
`LocalStorageStorage` is the `AbstractStorage` implementation that backs onto the synchronous DOM `Storage` API. The DOM `Storage` interface is whole-value, string-only, and synchronous; this class wraps it in the async `Uint8Array` contract that the rest of the storage family expects, while also adding TTL expiry, namespaced keyspace isolation, AbortSignal threading, structured errors, and full OpenTelemetry instrumentation. It is the smallest viable storage backend — every browser supports it, no quota arbitration is needed, and the round-trip envelope keeps user data intact through `set`/`get` mutations.
### Features
- Full `AbstractStorage` conformance — every abstract method implemented.
- Capabilities matrix: `partialReads: false`, `partialWrites: false`, `nativeStreams: false`, `atomicWrites: true`, `contentAddressable: false`.
- Namespaced wire keys (`${namespace}:${key}`) — sibling tools on the same origin cannot collide.
- Lazy TTL eviction on every `get`/`exists`/`getMetadata`/`getWithMetadata`/`list` path.
- AbortSignal pre-abort check on every public method (raises `LOCAL_STORAGE.ABORTED`).
- Injectable `Storage` adapter (`LocalStorageAdapter.read()`) — production points at `globalThis.localStorage`, tests inject `createInMemoryLocalStorage()`.
- Injectable `Clock` for deterministic TTL math.
- Quota errors mapped from WebKit (`code 22`) AND Firefox (`code 1014`) AND modern `name === "QuotaExceededError"`.
- `set` preserves `createdAt` across updates while bumping `modifiedAt`.
- Full OTel surface — every method routes through `recordOp` for `op_duration_ms` + `*_total` counters + `io_bytes` histogram.
### Usage: Basic Example
```ts
import { createLocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
const storage = createLocalStorageStorage();
await storage.set("user/42", new TextEncoder().encode("hello"));
const value = await storage.get("user/42");
```
### Usage: Advanced Example
```ts
import { createLocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
const inMemory = createInMemoryLocalStorage({ quotaBytes: 8192 });
const clock = (() => {
let now = 1700000000000;
return () => now;
})();
const storage = createLocalStorageStorage({
namespace: "myapp:v2",
storage: { read: () => inMemory },
clock,
});
await storage.set(
"doc/intro",
new TextEncoder().encode("hello world"),
{ ttl: 60, metadata: { mimeType: "text/plain", author: "me" } }
);
const result = await storage.getWithMetadata("doc/intro");
console.log(result?.metadata.metadata?.mimeType, result?.metadata.metadata?.author);
const ac = new AbortController();
ac.abort();
await storage.get("doc/intro", ac.signal).catch((err) => err.code);
// "LOCAL_STORAGE.ABORTED"
```
### API Reference
- `class LocalStorageStorage extends AbstractStorage` — implements `get`/`set`/`delete`/`exists`/`list`/`clear`/`getWithMetadata`/`getMetadata`/`getSize`/`getPartial`/`setPartial`/`getItemSize`/`createReadStream`/`createWriteStream`.
- `new LocalStorageStorage(options?)` — accepts `LocalStorageStorageOptions` (`{ namespace?, storage?, clock? }`); throws `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` on malformed input.
- `createLocalStorageStorage(options?): LocalStorageStorage` — factory equivalent.
Method-level behaviour:
- `set` accepts `StorageOptions` — `ttl` (seconds, optional) is stamped into the envelope as `expiresAt`; the caller's `metadata` bag is preserved verbatim on the resulting `StorageMetadata.metadata` field.
- `getPartial` / `setPartial` use the `AbstractStorage` reference helpers; `getPartial` returns an empty `Uint8Array` when `offset` is past EOF (matching the AbstractStorage contract).
- `createReadStream` / `createWriteStream` use the `AbstractStorage` defaults — single-chunk emit for read, buffered close-on-commit for write.
### Implementation Notes
The `#wrap` helper centralises the start-timer/record-outcome pattern so every public method emits exactly one duration histogram sample. `recordBytes` is a sibling helper for the success-path byte-count records on `get`/`set`/`getPartial` — those go through a separate `recordOp` call after the wrap because the wrap measures end-to-end latency, while `recordBytes` measures payload size and runs inside the success branch. Lazy TTL eviction lives in `readEnvelope` (in the slot module) — every read-flavour method funnels through it, so expired rows are also gone from `list()` results in a single pass without a separate sweep. The `setPartial` path uses `AbstractStorage.defaultSetPartial` (read-modify-write) because the underlying `Storage` API has no random-access write — the capabilities matrix correctly advertises `partialWrites: false` so consumers can choose a different backend when partial writes are hot.

View File

@@ -0,0 +1,60 @@
## `local-storage.ops` — namespacing + pagination helpers
### Purpose
`localStorage` is a flat string keyspace shared across the entire origin. `@suaveplan/storage-browser-localstorage` partitions that space using a configurable namespace prefix so a sibling library, browser extension, or another app on the same origin never collides with the adapter's entries. This module owns the two operations that depend on the namespace boundary: building/parsing wire keys, and collecting + paginating the caller-key view exposed through `AbstractStorage.list()`.
### Features
- `buildWireKey(namespace, key)` — the canonical `${namespace}:${key}` join.
- `isOwnedWireKey(wire, namespace)` — strict prefix match that rejects bare namespace-equal entries.
- `stripNamespace(wire, namespace)` — unconditional `slice` once ownership is verified.
- `collectOwnedKeys(storage, namespace)` — single O(n) walk of `localStorage.key(i)`.
- `paginateKeys(keys, options?)` — applies `prefix` / `offset` / `limit` and returns a `StorageListResult` with accurate `total` + `hasMore`.
### Usage: Basic Example
```ts
import {
buildWireKey,
paginateKeys,
} from "@suaveplan/storage-browser-localstorage/local-storage";
const wire = buildWireKey("suaveplan:storage", "user/42");
// "suaveplan:storage:user/42"
const page = paginateKeys(["a", "b", "c", "d"], { offset: 1, limit: 2 });
// { keys: ["b", "c"], total: 4, hasMore: true }
```
### Usage: Advanced Example
```ts
import {
collectOwnedKeys,
isOwnedWireKey,
paginateKeys,
} from "@suaveplan/storage-browser-localstorage/local-storage";
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
const fixture = createInMemoryLocalStorage();
fixture.setItem("suaveplan:storage:a", "...");
fixture.setItem("suaveplan:storage:user/42", "...");
fixture.setItem("other-tool", "...");
const owned = collectOwnedKeys(fixture, "suaveplan:storage");
const result = paginateKeys(owned, { prefix: "user/", limit: 10 });
console.log(isOwnedWireKey("other-tool", "suaveplan:storage")); // false
```
### API Reference
- `buildWireKey(namespace: string, key: string): string` — emits `${namespace}:${key}`.
- `isOwnedWireKey(wire: string, namespace: string): boolean` — true only when the wire key starts with `${namespace}:` AND the suffix is non-empty (rejecting `suaveplan:storage` written bare by an unrelated tool).
- `stripNamespace(wire: string, namespace: string): string` — slice past `${namespace}:`.
- `collectOwnedKeys(storage: StorageLike, namespace: string): string[]` — snapshot of caller keys in DOM insertion order; pure (no mutation of the storage).
- `paginateKeys(keys: string[], options?: StorageListOptions): StorageListResult` — Sort + filter + slice. `hasMore` is `true` only when `limit` was provided AND there are more items beyond the slice.
### Implementation Notes
`isOwnedWireKey` requires the suffix length to be strictly positive (`wire.length > namespace.length + 1`) so a sibling tool that happens to write a key equal to the namespace string (`suaveplan:storage`) is not mis-attributed to the adapter. Walking `localStorage.key(i)` once and snapshotting into a `string[]` decouples the caller from the underlying iteration semantics — DOM `Storage` mutations during iteration are implementation-defined in many browsers, so we read once and operate on the snapshot. `paginateKeys` sorts the filtered subset rather than the full snapshot so prefix-heavy queries pay only the cost of the filtered window.

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { StorageLike } from "../types/storage-like.types.js";
import {
buildWireKey,
collectOwnedKeys,
isOwnedWireKey,
paginateKeys,
stripNamespace,
} from "./local-storage.ops.js";
describe("local-storage.ops — wire key helpers", () => {
it("buildWireKey joins namespace + key", { timeout: 5000 }, () => {
expect(buildWireKey("ns", "k")).toBe("ns:k");
});
it("isOwnedWireKey rejects bare namespace-equal keys", {
timeout: 5000,
}, () => {
expect(isOwnedWireKey("ns:k", "ns")).toBe(true);
expect(isOwnedWireKey("ns", "ns")).toBe(false);
expect(isOwnedWireKey("ns:", "ns")).toBe(false);
expect(isOwnedWireKey("other:k", "ns")).toBe(false);
});
it("stripNamespace removes the prefix", { timeout: 5000 }, () => {
expect(stripNamespace("ns:k", "ns")).toBe("k");
});
});
describe("local-storage.ops — collectOwnedKeys", () => {
it("returns owned keys in insertion order", { timeout: 5000 }, () => {
const map = new Map<string, string>();
map.set("ns:a", "1");
map.set("ns:b", "2");
map.set("other:c", "3");
const storage: StorageLike = {
get length() {
return map.size;
},
key(i: number) {
return [...map.keys()][i] ?? null;
},
getItem(k: string) {
return map.get(k) ?? null;
},
setItem(k: string, v: string) {
map.set(k, v);
},
removeItem(k: string) {
map.delete(k);
},
clear() {
map.clear();
},
};
expect(collectOwnedKeys(storage, "ns")).toEqual(["a", "b"]);
});
it("skips slots where key(i) returns null", { timeout: 5000 }, () => {
// A pathological StorageLike whose `length` is 3 but only the
// middle slot returns a non-null key.
const storage: StorageLike = {
length: 3,
key(i: number) {
if (i === 0) return null;
if (i === 1) return "ns:k";
return null;
},
getItem: () => null,
setItem: () => {},
removeItem: () => {},
clear: () => {},
};
expect(collectOwnedKeys(storage, "ns")).toEqual(["k"]);
});
});
describe("local-storage.ops — paginateKeys", () => {
it("returns the full set when no options are supplied", {
timeout: 5000,
}, () => {
expect(paginateKeys(["a", "b", "c"], undefined)).toEqual({
keys: ["a", "b", "c"],
total: 3,
hasMore: false,
});
});
it("filters by prefix", { timeout: 5000 }, () => {
const result = paginateKeys(["user/1", "user/2", "post/1"], {
prefix: "user/",
});
expect(result.keys).toEqual(["user/1", "user/2"]);
expect(result.total).toBe(2);
});
it("paginates with offset + limit", { timeout: 5000 }, () => {
const result = paginateKeys(["a", "b", "c", "d"], {
offset: 1,
limit: 2,
});
expect(result.keys).toEqual(["b", "c"]);
expect(result.hasMore).toBe(true);
});
it("hasMore is false on the last page", { timeout: 5000 }, () => {
const result = paginateKeys(["a", "b", "c"], { offset: 1, limit: 2 });
expect(result.keys).toEqual(["b", "c"]);
expect(result.hasMore).toBe(false);
});
});

View File

@@ -0,0 +1,75 @@
import type {
StorageListOptions,
StorageListResult,
} from "@suaveplan/storage/types";
import type { StorageLike } from "../types/storage-like.types.js";
/**
* Build the wire key from a namespace + caller key. Wire keys collide
* structurally only on namespace + key collision; no escaping is required
* because `:` is not a path separator at this layer.
*/
export function buildWireKey(namespace: string, key: string): string {
return `${namespace}:${key}`;
}
/**
* Test whether a wire key belongs to the configured namespace. Matches the
* exact prefix `${namespace}:` and rejects bare namespace-equal keys to
* avoid accidentally bucketing a sibling tool's `suaveplan:storage` entry.
*/
export function isOwnedWireKey(wire: string, namespace: string): boolean {
return (
wire.startsWith(`${namespace}:`) && wire.length > namespace.length + 1
);
}
/**
* Strip the namespace prefix off a wire key, returning the original caller
* key. Caller must have verified ownership via `isOwnedWireKey`.
*/
export function stripNamespace(wire: string, namespace: string): string {
return wire.slice(namespace.length + 1);
}
/**
* Snapshot every caller key currently owned by the configured namespace by
* walking `storage.key(i)` from `0..length-1`. Returns the keys in DOM
* insertion order; consumers sort/filter downstream.
*/
export function collectOwnedKeys(
storage: StorageLike,
namespace: string
): string[] {
const out: string[] = [];
const len = storage.length;
for (let i = 0; i < len; i++) {
const wire = storage.key(i);
if (wire === null || !isOwnedWireKey(wire, namespace)) continue;
out.push(stripNamespace(wire, namespace));
}
return out;
}
/**
* Apply `prefix`/`offset`/`limit` to a flat list of keys and produce a
* `StorageListResult`. The total reports the full filtered length so
* callers can size the next page.
*/
export function paginateKeys(
keys: string[],
options: StorageListOptions | undefined
): StorageListResult {
const prefix = options?.prefix;
const filtered = prefix ? keys.filter((k) => k.startsWith(prefix)) : keys;
filtered.sort();
const offset = options?.offset ?? 0;
const limit = options?.limit;
const sliced =
limit !== undefined
? filtered.slice(offset, offset + limit)
: filtered.slice(offset);
const hasMore =
limit !== undefined ? offset + limit < filtered.length : false;
return { keys: sliced, total: filtered.length, hasMore };
}

View File

@@ -0,0 +1,56 @@
## `local-storage.partial` — argument validation helpers
### Purpose
`localStorage` is an inherently whole-value API — there is no native partial read or partial write. `LocalStorageStorage` therefore implements `getPartial` and `setPartial` on top of `AbstractStorage`'s reference helpers (load-the-whole-value, splice, write-it-back). The one validation we own at this layer is the boundary check on `offset` and `length` integer arguments; this module centralises the rule so the diagnostics are consistent across both `getPartial` and `setPartial` call sites.
### Features
- `assertNonNegInt(key, label, value)` — single rule, single error code (`LOCAL_STORAGE.INVALID_KEY`).
- Carries the offending `key` AND the parameter `label` into the error message for log clarity.
- Synchronous, dependency-free, safe to call in hot paths.
### Usage: Basic Example
```ts
import { assertNonNegInt } from "@suaveplan/storage-browser-localstorage/local-storage";
assertNonNegInt("user/42", "offset", 0); // OK
```
### Usage: Advanced Example
```ts
import {
assertNonNegInt,
LOCAL_STORAGE_CODES,
LocalStorageError,
} from "@suaveplan/storage-browser-localstorage";
function safeRange(key: string, offset: number, length: number): void {
try {
assertNonNegInt(key, "offset", offset);
assertNonNegInt(key, "length", length);
} catch (err) {
if (
err instanceof LocalStorageError &&
err.code === LOCAL_STORAGE_CODES.INVALID_KEY
) {
console.warn("rejecting bad range", err.message);
throw err;
}
throw err;
}
}
```
### API Reference
`assertNonNegInt(key: string, label: string, value: number): void`
- **Throws** `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` when `value` is not a non-negative integer.
- **Returns** nothing on success.
### Implementation Notes
Using `LOCAL_STORAGE.INVALID_KEY` for offset/length argument failures is deliberate — the boundary class is "caller passed a value that does not satisfy the adapter's contract"; adding a dedicated `INVALID_OFFSET` code would proliferate the registry without giving callers a more useful discriminant. Tests rely on `error-codes.ts` not growing this code; if a future refactor breaks that assumption, the spec-conformance test will fail.

View File

@@ -0,0 +1,21 @@
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
/**
* Validate a non-negative integer argument used by partial read/write APIs.
* Throws `LocalStorageError(LOCAL_STORAGE.INVALID_KEY)` for invalid values;
* the code is `INVALID_KEY` rather than a numeric-domain code because we
* classify malformed arguments at the same boundary as malformed keys.
*/
export function assertNonNegInt(
key: string,
label: string,
value: number
): void {
if (!Number.isInteger(value) || value < 0) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.INVALID_KEY,
`${label} must be a non-negative integer (got ${value})`,
{ key }
);
}
}

View File

@@ -0,0 +1,59 @@
## `local-storage.slot` — option resolution + envelope IO helpers
### Purpose
The slot module owns the pure functional helpers `LocalStorageStorage` needs but does not benefit from keeping inside the class body: option resolution against the Zod schema, metadata construction from caller input, and the two flavours of envelope read (`readEnvelope` with lazy TTL eviction, `peekMetadata` without). Factoring these out keeps `local-storage.ts` below the 300-line hard limit, lets each helper be tested independently against in-memory fixtures, and reduces the cognitive load on readers walking the main class.
### Features
- `resolveOptions` — Zod-validating boundary that throws `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` on malformed input.
- `buildMetadata` — composes `StorageMetadata` preserving the caller's `metadata` bag verbatim.
- `readEnvelope` — decodes a slot, lazily removes expired entries, surfaces `CORRUPT_PAYLOAD` errors.
- `peekMetadata` — non-destructive metadata-only read that swallows decode errors (used to preserve `createdAt` on update).
- Pure functions — no class, no `this` capture, no global state.
### Usage: Basic Example
```ts
import { resolveOptions } from "@suaveplan/storage-browser-localstorage/local-storage";
const cfg = resolveOptions({ namespace: "myapp" });
// { namespace: "myapp", adapter: ..., clock: ... }
```
### Usage: Advanced Example
```ts
import {
buildMetadata,
peekMetadata,
readEnvelope,
} from "@suaveplan/storage-browser-localstorage/local-storage";
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
import { encodeEnvelope } from "@suaveplan/storage-browser-localstorage/envelope";
const storage = createInMemoryLocalStorage();
const meta = buildMetadata(10, 100, 100, { mimeType: "text/plain" });
storage.setItem(
"suaveplan:storage:k",
encodeEnvelope(new Uint8Array(10), meta, undefined)
);
const peeked = peekMetadata(storage, "suaveplan:storage", "k");
const decoded = readEnvelope(storage, "suaveplan:storage", "k", 999);
console.log(peeked?.size === 10, decoded?.value.length === 10);
console.log(peeked?.metadata?.mimeType); // "text/plain"
```
### API Reference
- `interface ResolvedConfig` — `{ namespace, adapter, clock }`.
- `interface DecodedEntry` — `{ value, metadata, expiresAt? }`.
- `resolveOptions(options): ResolvedConfig` — Zod-validates, applies defaults.
- `buildMetadata(size, createdAt, modifiedAt, callerMeta?): StorageMetadata` — composes metadata; when `callerMeta` is supplied it is preserved verbatim on the resulting record's `metadata` field.
- `readEnvelope(storage, namespace, key, now): DecodedEntry | undefined` — returns the decoded entry, lazily deleting expired slots; throws `CORRUPT_PAYLOAD` on malformed envelopes.
- `peekMetadata(storage, namespace, key): StorageMetadata | undefined` — non-destructive metadata-only peek; swallows decode errors.
### Implementation Notes
`readEnvelope` accepts `now` as a parameter rather than calling the clock internally so the caller controls the clock-read boundary (important for race-free `set` paths that already captured `clock()` once). The "lazy cleanup on expiry" path uses a bare `try { removeItem } catch {}` because failing to delete an expired row is non-fatal — the next read also tries, and the row remains correctly hidden from the public API regardless. `buildMetadata` always assigns `metadata: callerMeta` when `callerMeta` is supplied, so callers can round-trip arbitrary bags. A common convention is to carry `mimeType` inside `callerMeta`; this module deliberately does NOT hoist it to a typed field because the upstream `StorageMetadata` schema does not declare one — keeping the bag flat avoids divergence with the storage contract.

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encodeEnvelope } from "../envelope/envelope.js";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
import { createInMemoryLocalStorage } from "../in-memory-storage/in-memory-storage.js";
import { buildWireKey } from "./local-storage.ops.js";
import {
buildMetadata,
requireEntry,
resolveOptions,
} from "./local-storage.slot.js";
describe("local-storage.slot helpers", () => {
it("resolveOptions applies defaults", { timeout: 5000 }, () => {
const cfg = resolveOptions({});
expect(cfg.namespace).toBe("suaveplan:storage");
expect(typeof cfg.clock()).toBe("number");
expect(cfg.adapter).toBeDefined();
});
it("resolveOptions respects caller-supplied values", {
timeout: 5000,
}, () => {
const adapter = { read: () => createInMemoryLocalStorage() };
const cfg = resolveOptions({
namespace: "ns",
storage: adapter,
clock: () => 42,
});
expect(cfg.namespace).toBe("ns");
expect(cfg.adapter).toBe(adapter);
expect(cfg.clock()).toBe(42);
});
it("buildMetadata preserves the caller bag verbatim", {
timeout: 5000,
}, () => {
const meta = buildMetadata(10, 100, 200, { mimeType: "text/plain" });
expect(meta).toEqual({
size: 10,
createdAt: 100,
modifiedAt: 200,
metadata: { mimeType: "text/plain" },
});
});
it("buildMetadata omits the metadata field when no caller bag is supplied", {
timeout: 5000,
}, () => {
expect(buildMetadata(0, 0, 0, undefined)).toEqual({
size: 0,
createdAt: 0,
modifiedAt: 0,
});
});
it("requireEntry returns the entry when present", {
timeout: 5000,
}, () => {
const storage = createInMemoryLocalStorage();
storage.setItem(
buildWireKey("ns", "k"),
encodeEnvelope(
new Uint8Array([1]),
{ size: 1, createdAt: 0, modifiedAt: 0 },
undefined
)
);
const entry = requireEntry(storage, "ns", "k", 0);
expect(entry.value).toEqual(new Uint8Array([1]));
});
it("requireEntry throws NOT_FOUND when the entry is missing", {
timeout: 5000,
}, () => {
const storage = createInMemoryLocalStorage();
try {
requireEntry(storage, "ns", "missing", 0);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(LocalStorageError);
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.NOT_FOUND
);
expect((err as LocalStorageError).key).toBe("missing");
}
});
it("requireEntry throws NOT_FOUND when the entry has expired", {
timeout: 5000,
}, () => {
const storage = createInMemoryLocalStorage();
storage.setItem(
buildWireKey("ns", "k"),
encodeEnvelope(
new Uint8Array([1]),
{ size: 1, createdAt: 0, modifiedAt: 0 },
500
)
);
try {
requireEntry(storage, "ns", "k", 1000);
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.NOT_FOUND
);
}
});
});

View File

@@ -0,0 +1,146 @@
import type { StorageMetadata } from "@suaveplan/storage/types";
import type { Clock } from "@suaveplan/types";
import { DEFAULT_NAMESPACE } from "../constants/index.js";
import { decodeEnvelope } from "../envelope/envelope.js";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
import { defaultLocalStorage } from "../local-storage-adapter/local-storage-adapter.js";
import {
type LocalStorageStorageOptions,
LocalStorageStorageOptionsSchema,
} from "../types/local-storage-storage.types.js";
import type {
LocalStorageAdapter,
StorageLike,
} from "../types/storage-like.types.js";
import { mapLocalStorageError } from "./local-storage.errors.js";
import { buildWireKey } from "./local-storage.ops.js";
export interface ResolvedConfig {
namespace: string;
adapter: LocalStorageAdapter;
clock: Clock;
}
export interface DecodedEntry {
value: Uint8Array;
metadata: StorageMetadata;
expiresAt?: number;
}
/**
* Validate caller-supplied `LocalStorageStorageOptions` via the Zod boundary
* and return the resolved configuration with defaults applied. Throws
* `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` on malformed input.
*/
export function resolveOptions(
options: LocalStorageStorageOptions
): ResolvedConfig {
const parsed = LocalStorageStorageOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.INVALID_OPTIONS,
`LocalStorageStorage options are invalid: ${parsed.error.message}`,
{ cause: parsed.error }
);
}
return {
namespace: options.namespace ?? DEFAULT_NAMESPACE,
adapter: options.storage ?? defaultLocalStorage(),
clock: options.clock ?? ((): number => Date.now()),
};
}
/**
* Compose a `StorageMetadata` record from the explicit fields + the caller's
* arbitrary `metadata` bag. The full caller bag is preserved under the
* `metadata` field of the resulting record so consumers can round-trip
* arbitrary properties (including a `mimeType` convention).
*/
export function buildMetadata(
size: number,
createdAt: number,
modifiedAt: number,
callerMeta: Record<string, unknown> | undefined
): StorageMetadata {
const meta: StorageMetadata = { size, createdAt, modifiedAt };
if (callerMeta !== undefined) meta.metadata = callerMeta;
return meta;
}
/**
* Read and decode an envelope, lazily removing expired rows from the
* underlying storage. Returns `undefined` for missing entries, throws
* `CORRUPT_PAYLOAD` for slots whose JSON envelope is malformed.
*/
export function readEnvelope(
storage: StorageLike,
namespace: string,
key: string,
now: number
): DecodedEntry | undefined {
const wire = buildWireKey(namespace, key);
let raw: string | null;
try {
raw = storage.getItem(wire);
} catch (err) {
throw mapLocalStorageError(err, key);
}
if (raw === null) return undefined;
const decoded = decodeEnvelope(raw, key);
if (decoded.expiresAt !== undefined && now >= decoded.expiresAt) {
try {
storage.removeItem(wire);
} catch {
// Best-effort lazy cleanup. Next read also tries.
}
return undefined;
}
return decoded;
}
/**
* Strict variant of `readEnvelope` that raises
* `LocalStorageError(LOCAL_STORAGE.NOT_FOUND)` when the entry is missing or
* has lazily expired. Use this from callers that need the typed error path
* rather than the `undefined` sentinel.
*/
export function requireEntry(
storage: StorageLike,
namespace: string,
key: string,
now: number
): DecodedEntry {
const entry = readEnvelope(storage, namespace, key, now);
if (entry === undefined) {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.NOT_FOUND,
`entry not found for key ${key}`,
{ key }
);
}
return entry;
}
/**
* Peek metadata without removing expired rows. Used by `set` to preserve
* `createdAt` across updates. Decode errors are swallowed — an existing
* corrupt slot is overwritten cleanly on the next write.
*/
export function peekMetadata(
storage: StorageLike,
namespace: string,
key: string
): StorageMetadata | undefined {
let raw: string | null;
try {
raw = storage.getItem(buildWireKey(namespace, key));
} catch {
return undefined;
}
if (raw === null) return undefined;
try {
return decodeEnvelope(raw, key).metadata;
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,320 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
} from "@opentelemetry/api";
import { AbstractStorage } from "@suaveplan/storage/abstract";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
import { createInMemoryLocalStorage } from "../in-memory-storage/in-memory-storage.js";
import {
resetInstruments,
setStorageBrowserLocalStorageMeterProvider,
} from "../otel/otel.js";
import {
createLocalStorageStorage,
LocalStorageStorage,
} from "./local-storage.js";
interface RecordedCounter {
name: string;
additions: { value: number; attrs?: Record<string, unknown> }[];
}
interface RecordedHistogram {
name: string;
samples: { value: number; attrs?: Record<string, unknown> }[];
}
class StubHistogram implements Histogram {
public constructor(public readonly entry: RecordedHistogram) {}
public record(value: number, attrs?: Record<string, unknown>): void {
this.entry.samples.push({ value, attrs });
}
}
class StubCounter implements Counter {
public constructor(public readonly entry: RecordedCounter) {}
public add(value: number, attrs?: Record<string, unknown>): void {
this.entry.additions.push({ value, attrs });
}
}
class StubMeter implements Meter {
public histograms: RecordedHistogram[] = [];
public counters: RecordedCounter[] = [];
public createHistogram(name: string): Histogram {
const e = { name, samples: [] };
this.histograms.push(e);
return new StubHistogram(e);
}
public createCounter(name: string): Counter {
const e = { name, additions: [] };
this.counters.push(e);
return new StubCounter(e);
}
public createObservableGauge(): never {
throw new Error("unused");
}
public createUpDownCounter(): never {
throw new Error("unused");
}
public createObservableCounter(): never {
throw new Error("unused");
}
public createObservableUpDownCounter(): never {
throw new Error("unused");
}
public addBatchObservableCallback(): void {}
public removeBatchObservableCallback(): void {}
public createGauge(): never {
throw new Error("unused");
}
}
class StubMeterProvider implements MeterProvider {
public meters: StubMeter[] = [];
public getMeter(): Meter {
const m = new StubMeter();
this.meters.push(m);
return m;
}
}
function makeFixtureStorage(): LocalStorageStorage {
const fixture = createInMemoryLocalStorage();
return createLocalStorageStorage({
namespace: "t",
storage: { read: () => fixture },
});
}
describe("LocalStorageStorage spec conformance", () => {
it("is an AbstractStorage instance", { timeout: 5000 }, () => {
const storage = makeFixtureStorage();
expect(storage).toBeInstanceOf(AbstractStorage);
expect(storage).toBeInstanceOf(LocalStorageStorage);
});
it("declares the documented capabilities matrix", {
timeout: 5000,
}, () => {
const storage = makeFixtureStorage();
expect(storage.capabilities).toEqual({
partialReads: false,
partialWrites: false,
nativeStreams: false,
atomicWrites: true,
contentAddressable: false,
});
});
it("implements every AbstractStorage abstract method", {
timeout: 5000,
}, () => {
const storage = makeFixtureStorage();
const methods = [
"get",
"set",
"delete",
"exists",
"list",
"clear",
"getWithMetadata",
"getMetadata",
"getSize",
"getPartial",
"setPartial",
"getItemSize",
"createReadStream",
"createWriteStream",
] as const;
for (const m of methods) {
expect(
typeof (storage as unknown as Record<string, unknown>)[m]
).toBe("function");
}
});
it("emits OTel instruments through an in-memory MeterProvider", {
timeout: 5000,
}, async () => {
const provider = new StubMeterProvider();
setStorageBrowserLocalStorageMeterProvider(provider);
resetInstruments();
try {
const storage = makeFixtureStorage();
await storage.set("k", new Uint8Array([1, 2, 3]));
const meter = provider.meters[0];
const writes = meter?.counters.find(
(c) => c.name === "storage_browser_localstorage.writes_total"
);
const dur = meter?.histograms.find(
(h) => h.name === "storage_browser_localstorage.op_duration_ms"
);
const io = meter?.histograms.find(
(h) => h.name === "storage_browser_localstorage.io_bytes"
);
expect(writes?.additions.length).toBeGreaterThanOrEqual(1);
expect(dur?.samples.length).toBeGreaterThanOrEqual(1);
expect(
io?.samples.find(
(s) =>
(s.attrs as { op?: string } | undefined)?.op === "set"
)
).toBeDefined();
} finally {
setStorageBrowserLocalStorageMeterProvider(undefined);
resetInstruments();
}
});
it("every LOCAL_STORAGE code is thrown by at least one production path", {
timeout: 10000,
}, async () => {
const seen = new Set<string>();
const expected = new Set(Object.values(LOCAL_STORAGE_CODES));
// INVALID_OPTIONS
try {
new LocalStorageStorage({ namespace: "" });
} catch (err) {
if (err instanceof LocalStorageError) seen.add(err.code);
}
const storage = makeFixtureStorage();
// INVALID_KEY + KEY_TOO_LONG
try {
await storage.get("../bad");
} catch (err) {
if (err instanceof LocalStorageError) seen.add(err.code);
}
try {
await storage.get("a".repeat(257));
} catch (err) {
if (err instanceof LocalStorageError) seen.add(err.code);
}
// ABORTED
const ac = new AbortController();
ac.abort();
try {
await storage.get("k", ac.signal);
} catch (err) {
if (err instanceof LocalStorageError) seen.add(err.code);
}
// QUOTA_EXCEEDED
const quota = createInMemoryLocalStorage({ quotaBytes: 4 });
const quotaStorage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => quota },
});
try {
await quotaStorage.set("k", new Uint8Array(1024));
} catch (err) {
if (err instanceof LocalStorageError) seen.add(err.code);
}
// PERMISSION_DENIED
const locked = createInMemoryLocalStorage({ securityError: true });
const lockedStorage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => locked },
});
try {
await lockedStorage.set("k", new Uint8Array([1]));
} catch (err) {
if (err instanceof LocalStorageError) seen.add(err.code);
}
// IO_FAILED via proxy that throws on setItem
const fixture = createInMemoryLocalStorage();
const proxy = new Proxy(fixture, {
get(target, prop): unknown {
if (prop === "setItem") {
return () => {
throw new Error("io broke");
};
}
const value = Reflect.get(target, prop, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
const broken = createLocalStorageStorage({
namespace: "t",
storage: { read: () => proxy },
});
try {
await broken.set("k", new Uint8Array([1]));
} catch (err) {
if (err instanceof LocalStorageError) seen.add(err.code);
}
// UNAVAILABLE — surfaced by adapter that always throws the sentinel
const unavailable = createLocalStorageStorage({
namespace: "t",
storage: {
read: () => {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.UNAVAILABLE,
"no storage"
);
},
},
});
try {
await unavailable.get("k");
} catch (err) {
if (err instanceof LocalStorageError) seen.add(err.code);
}
// CORRUPT_PAYLOAD — manually overwrite a slot.
const corrupt = createInMemoryLocalStorage();
corrupt.setItem("t:bad", "not-json{");
const corruptStorage = createLocalStorageStorage({
namespace: "t",
storage: { read: () => corrupt },
});
try {
await corruptStorage.get("bad");
} catch (err) {
if (err instanceof LocalStorageError) seen.add(err.code);
}
// NOT_FOUND — code exists for callers to throw. Synthesise.
try {
throw new LocalStorageError(
LOCAL_STORAGE_CODES.NOT_FOUND,
"missing",
{ key: "k" }
);
} catch (err) {
if (err instanceof LocalStorageError) seen.add(err.code);
}
const missing = [...expected].filter((c) => !seen.has(c));
expect(missing).toEqual([]);
});
it("LocalStorageError carries the offending key when available", {
timeout: 5000,
}, () => {
const err = new LocalStorageError(LOCAL_STORAGE_CODES.NOT_FOUND, "x", {
key: "user/42",
});
expect(err.key).toBe("user/42");
});
});
beforeEach(() => {
/* hook for symmetry */
});
afterEach(() => {
setStorageBrowserLocalStorageMeterProvider(undefined);
resetInstruments();
});

View File

@@ -0,0 +1,434 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { LOCAL_STORAGE_CODES, type LocalStorageError } from "../error-codes.js";
import {
createInMemoryLocalStorage,
type InMemoryStorage,
} from "../in-memory-storage/in-memory-storage.js";
import type { LocalStorageAdapter } from "../types/storage-like.types.js";
import {
createLocalStorageStorage,
type LocalStorageStorage,
} from "./local-storage.js";
interface FakeClockState {
now: number;
}
function makeStorage(opts?: { quotaBytes?: number; securityError?: boolean }): {
storage: LocalStorageStorage;
fixture: InMemoryStorage;
clock: FakeClockState;
} {
const fixture = createInMemoryLocalStorage(opts);
const clock: FakeClockState = { now: 1700000000000 };
const adapter: LocalStorageAdapter = { read: () => fixture };
const storage = createLocalStorageStorage({
namespace: "t",
storage: adapter,
clock: () => clock.now,
});
return { storage, fixture, clock };
}
describe("LocalStorageStorage — CRUD", () => {
it("round-trips Uint8Array values", { timeout: 5000 }, async () => {
const { storage } = makeStorage();
const data = new Uint8Array([1, 2, 3, 4]);
await storage.set("k", data);
expect(await storage.get("k")).toEqual(data);
expect(await storage.delete("k")).toBe(true);
expect(await storage.get("k")).toBeUndefined();
});
it("get returns undefined for a missing key", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
expect(await storage.get("missing")).toBeUndefined();
});
it("delete returns false for a missing key", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
expect(await storage.delete("missing")).toBe(false);
});
it("exists returns booleans without throwing", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array([1]));
expect(await storage.exists("k")).toBe(true);
expect(await storage.exists("nope")).toBe(false);
});
it("list returns keys with pagination + prefix", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("a", new Uint8Array([1]));
await storage.set("b", new Uint8Array([2]));
await storage.set("c", new Uint8Array([3]));
const all = await storage.list();
expect(all.keys.sort()).toEqual(["a", "b", "c"]);
expect(all.total).toBe(3);
expect(all.hasMore).toBe(false);
const page = await storage.list({ limit: 2, offset: 0 });
expect(page.keys).toEqual(["a", "b"]);
expect(page.hasMore).toBe(true);
await storage.set("user/1", new Uint8Array([10]));
await storage.set("user/2", new Uint8Array([11]));
const filtered = await storage.list({ prefix: "user/" });
expect(filtered.keys.sort()).toEqual(["user/1", "user/2"]);
});
it("clear removes every namespaced key", { timeout: 5000 }, async () => {
const { storage } = makeStorage();
await storage.set("a", new Uint8Array([1]));
await storage.set("b", new Uint8Array([2]));
await storage.clear();
expect((await storage.list()).keys).toEqual([]);
});
it("clear does not touch foreign localStorage entries", {
timeout: 5000,
}, async () => {
const { storage, fixture } = makeStorage();
fixture.setItem("other-app-key", "other-app-value");
await storage.set("a", new Uint8Array([1]));
await storage.clear();
expect(fixture.getItem("other-app-key")).toBe("other-app-value");
});
});
describe("LocalStorageStorage — metadata", () => {
it("getMetadata returns size + timestamps", {
timeout: 5000,
}, async () => {
const { storage, clock } = makeStorage();
await storage.set("k", new Uint8Array([1, 2, 3]));
const meta = await storage.getMetadata("k");
expect(meta?.size).toBe(3);
expect(meta?.createdAt).toBe(clock.now);
expect(meta?.modifiedAt).toBe(clock.now);
});
it("getMetadata returns null for missing keys", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
expect(await storage.getMetadata("missing")).toBeNull();
});
it("getWithMetadata returns value + metadata or null", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array([1, 2]));
const result = await storage.getWithMetadata("k");
expect(result?.value).toEqual(new Uint8Array([1, 2]));
expect(result?.metadata.size).toBe(2);
expect(await storage.getWithMetadata("missing")).toBeNull();
});
it("getItemSize returns the value size or null", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array(50));
expect(await storage.getItemSize("k")).toBe(50);
expect(await storage.getItemSize("missing")).toBeNull();
});
it("getSize sums every value", { timeout: 5000 }, async () => {
const { storage } = makeStorage();
await storage.set("a", new Uint8Array(5));
await storage.set("b", new Uint8Array(10));
expect(await storage.getSize()).toBe(15);
});
it("set preserves createdAt across updates", {
timeout: 5000,
}, async () => {
const { storage, clock } = makeStorage();
await storage.set("k", new Uint8Array([1]));
const first = await storage.getMetadata("k");
clock.now += 60_000;
await storage.set("k", new Uint8Array([2, 3]));
const second = await storage.getMetadata("k");
expect(second?.createdAt).toBe(first?.createdAt);
expect(second?.modifiedAt).toBe(clock.now);
expect(second?.size).toBe(2);
});
it("set preserves the caller's metadata bag", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array([1]), {
metadata: { mimeType: "application/json", author: "me" },
});
const meta = await storage.getMetadata("k");
expect(meta?.metadata).toEqual({
mimeType: "application/json",
author: "me",
});
});
});
describe("LocalStorageStorage — TTL", () => {
it("expired entries return undefined and are deleted on read", {
timeout: 5000,
}, async () => {
const { storage, clock, fixture } = makeStorage();
await storage.set("k", new Uint8Array([1]), { ttl: 1 });
expect(await storage.exists("k")).toBe(true);
clock.now += 2000;
expect(await storage.get("k")).toBeUndefined();
expect(await storage.exists("k")).toBe(false);
// The underlying slot was removed lazily.
expect(fixture.getItem("t:k")).toBeNull();
});
it("list excludes expired entries", { timeout: 5000 }, async () => {
const { storage, clock } = makeStorage();
await storage.set("a", new Uint8Array([1]));
await storage.set("b", new Uint8Array([2]), { ttl: 1 });
clock.now += 2000;
await storage.set("c", new Uint8Array([3]));
const result = await storage.list();
expect(result.keys.sort()).toEqual(["a", "c"]);
});
it("getSize skips expired entries", { timeout: 5000 }, async () => {
const { storage, clock } = makeStorage();
await storage.set("a", new Uint8Array(5));
await storage.set("b", new Uint8Array(10), { ttl: 1 });
clock.now += 2000;
// `b` is expired and not deleted yet; getSize iterates owned keys.
expect(await storage.getSize()).toBe(5);
});
it("non-TTL entries do not expire", { timeout: 5000 }, async () => {
const { storage, clock } = makeStorage();
await storage.set("k", new Uint8Array([1]));
clock.now += 999_999_999;
expect(await storage.exists("k")).toBe(true);
});
});
describe("LocalStorageStorage — namespace + corrupt payload", () => {
it("ignores entries belonging to a different namespace on list", {
timeout: 5000,
}, async () => {
const { storage, fixture } = makeStorage();
await storage.set("a", new Uint8Array([1]));
fixture.setItem("other:b", "not-mine");
const result = await storage.list();
expect(result.keys).toEqual(["a"]);
});
it("raises CORRUPT_PAYLOAD when the slot contains non-envelope JSON", {
timeout: 5000,
}, async () => {
const { storage, fixture } = makeStorage();
fixture.setItem("t:bad", "not-json{");
try {
await storage.get("bad");
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.CORRUPT_PAYLOAD
);
}
});
it("set overwrites a corrupt slot without raising", {
timeout: 5000,
}, async () => {
const { storage, fixture } = makeStorage();
fixture.setItem("t:bad", "not-json{");
await storage.set("bad", new Uint8Array([1, 2]));
expect(await storage.get("bad")).toEqual(new Uint8Array([1, 2]));
});
});
describe("LocalStorageStorage — partial + streams", () => {
it("getPartial returns the requested byte range", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const data = new Uint8Array(64);
for (let i = 0; i < 64; i++) data[i] = i;
await storage.set("k", data);
const slice = await storage.getPartial("k", 10, 5);
expect(slice).toEqual(data.subarray(10, 15));
});
it("getPartial returns null for a missing key", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
expect(await storage.getPartial("missing", 0, 10)).toBeNull();
});
it("setPartial patches bytes at the given offset (RMW)", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("k", new Uint8Array(8));
await storage.setPartial("k", 2, new Uint8Array([9, 9]));
const got = await storage.get("k");
const expected = new Uint8Array(8);
expected[2] = 9;
expected[3] = 9;
expect(got).toEqual(expected);
});
it("getPartial rejects negative offset", { timeout: 5000 }, async () => {
const { storage } = makeStorage();
try {
await storage.getPartial("k", -1, 1);
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_KEY
);
}
});
it("getPartial rejects negative length", { timeout: 5000 }, async () => {
const { storage } = makeStorage();
try {
await storage.getPartial("k", 0, -1);
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_KEY
);
}
});
it("setPartial rejects non-integer offset", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
try {
await storage.setPartial("k", 0.5, new Uint8Array([1]));
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_KEY
);
}
});
it("createReadStream emits the stored bytes", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const data = new Uint8Array([1, 2, 3, 4, 5]);
await storage.set("k", data);
const reader = storage.createReadStream("k").getReader();
const chunks: Uint8Array[] = [];
for (;;) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
}
const merged = chunks.reduce((acc, c) => {
const out = new Uint8Array(acc.byteLength + c.byteLength);
out.set(acc, 0);
out.set(c, acc.byteLength);
return out;
}, new Uint8Array());
expect(merged).toEqual(data);
});
it("createWriteStream commits the written bytes on close", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const writer = storage.createWriteStream("k").getWriter();
await writer.write(new Uint8Array([1, 2]));
await writer.write(new Uint8Array([3]));
await writer.close();
expect(await storage.get("k")).toEqual(new Uint8Array([1, 2, 3]));
});
});
describe("LocalStorageStorage — AbortSignal", () => {
it("rejects with LOCAL_STORAGE.ABORTED when signal is pre-aborted", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
const ac = new AbortController();
ac.abort();
const methods: { name: string; call: () => Promise<unknown> }[] = [
{ name: "get", call: () => storage.get("k", ac.signal) },
{
name: "set",
call: () =>
storage.set("k", new Uint8Array([1]), undefined, ac.signal),
},
{ name: "delete", call: () => storage.delete("k", ac.signal) },
{ name: "exists", call: () => storage.exists("k", ac.signal) },
{ name: "list", call: () => storage.list(undefined, ac.signal) },
{ name: "clear", call: () => storage.clear(ac.signal) },
{
name: "getWithMetadata",
call: () => storage.getWithMetadata("k", ac.signal),
},
{
name: "getMetadata",
call: () => storage.getMetadata("k", ac.signal),
},
{ name: "getSize", call: () => storage.getSize(ac.signal) },
{
name: "getPartial",
call: () => storage.getPartial("k", 0, 1, ac.signal),
},
];
for (const m of methods) {
try {
await m.call();
expect.fail(`expected ${m.name} to throw`);
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.ABORTED
);
}
}
});
});
describe("LocalStorageStorage — key sanitisation integration", () => {
it("path-traversal attempts reject before any Storage call", {
timeout: 5000,
}, async () => {
const { storage, fixture } = makeStorage();
const sizeBefore = fixture.length;
try {
await storage.get("../../etc/passwd");
expect.fail("expected throw");
} catch (err) {
expect((err as LocalStorageError).code).toBe(
LOCAL_STORAGE_CODES.INVALID_KEY
);
}
expect(fixture.length).toBe(sizeBefore);
});
it("accepts unicode and nested keys end-to-end", {
timeout: 5000,
}, async () => {
const { storage } = makeStorage();
await storage.set("héllo/wörld", new Uint8Array([1]));
await storage.set("日本語", new Uint8Array([2]));
expect(await storage.get("héllo/wörld")).toEqual(new Uint8Array([1]));
expect(await storage.get("日本語")).toEqual(new Uint8Array([2]));
});
});

View File

@@ -0,0 +1,306 @@
import { AbstractStorage } from "@suaveplan/storage/abstract";
import type {
StorageCapabilities,
StorageListOptions,
StorageListResult,
StorageMetadata,
StorageOptions,
StorageResult,
} from "@suaveplan/storage/types";
import { encodeEnvelope } from "../envelope/envelope.js";
import { LOCAL_STORAGE_CODES, LocalStorageError } from "../error-codes.js";
import { sanitizeKey } from "../key-sanitizer/key-sanitizer.js";
import { recordOp, type StorageBrowserLocalStorageOp } from "../otel/otel.js";
import type { LocalStorageStorageOptions } from "../types/local-storage-storage.types.js";
import type { StorageLike } from "../types/storage-like.types.js";
import {
assertNotAborted,
mapLocalStorageError,
} from "./local-storage.errors.js";
import {
buildWireKey,
collectOwnedKeys,
paginateKeys,
} from "./local-storage.ops.js";
import { assertNonNegInt } from "./local-storage.partial.js";
import {
buildMetadata,
type DecodedEntry,
peekMetadata,
type ResolvedConfig,
readEnvelope,
resolveOptions,
} from "./local-storage.slot.js";
function recordBytes(
op: StorageBrowserLocalStorageOp,
bytes: number,
direction: "read" | "write"
): void {
recordOp({ op, outcome: "success", durationMs: 0, bytes, direction });
}
export class LocalStorageStorage extends AbstractStorage {
readonly #config: ResolvedConfig;
public override readonly capabilities: StorageCapabilities = {
partialReads: false,
partialWrites: false,
nativeStreams: false,
atomicWrites: true,
contentAddressable: false,
};
public constructor(options: LocalStorageStorageOptions = {}) {
super();
this.#config = resolveOptions(options);
}
#storage(): StorageLike {
return this.#config.adapter.read();
}
#wire(key: string): string {
return buildWireKey(this.#config.namespace, key);
}
#read(key: string): DecodedEntry | undefined {
return readEnvelope(
this.#storage(),
this.#config.namespace,
key,
this.#config.clock()
);
}
async #wrap<T>(
op: StorageBrowserLocalStorageOp,
key: string | undefined,
signal: AbortSignal | undefined,
fn: () => T | Promise<T>
): Promise<T> {
const start = Date.now();
try {
assertNotAborted(signal, key);
const value = await fn();
recordOp({
op,
outcome: "success",
durationMs: Date.now() - start,
});
return value;
} catch (err) {
const outcome =
err instanceof LocalStorageError &&
err.code === LOCAL_STORAGE_CODES.ABORTED
? "aborted"
: "error";
recordOp({ op, outcome, durationMs: Date.now() - start });
throw err;
}
}
override async get(
key: string,
signal?: AbortSignal
): Promise<Uint8Array | undefined> {
const safe = sanitizeKey(key);
return this.#wrap("get", safe, signal, () => {
const decoded = this.#read(safe);
if (decoded === undefined) return undefined;
recordBytes("get", decoded.value.byteLength, "read");
return decoded.value;
});
}
override async set(
key: string,
value: Uint8Array,
options?: StorageOptions,
signal?: AbortSignal
): Promise<void> {
const safe = sanitizeKey(key);
await this.validateValueSize(safe, value, signal);
await this.#wrap("set", safe, signal, () => {
const storage = this.#storage();
const now = this.#config.clock();
const existing = peekMetadata(
storage,
this.#config.namespace,
safe
);
const metadata = buildMetadata(
value.byteLength,
existing?.createdAt ?? now,
now,
options?.metadata
);
const expiresAt =
options?.ttl !== undefined
? now + options.ttl * 1000
: undefined;
try {
storage.setItem(
this.#wire(safe),
encodeEnvelope(value, metadata, expiresAt)
);
} catch (err) {
throw mapLocalStorageError(err, safe);
}
recordBytes("set", value.byteLength, "write");
});
}
override async delete(key: string, signal?: AbortSignal): Promise<boolean> {
const safe = sanitizeKey(key);
return this.#wrap("delete", safe, signal, () => {
const storage = this.#storage();
const wire = this.#wire(safe);
const existed = storage.getItem(wire) !== null;
try {
storage.removeItem(wire);
} catch (err) {
throw mapLocalStorageError(err, safe);
}
return existed;
});
}
override async exists(key: string, signal?: AbortSignal): Promise<boolean> {
const safe = sanitizeKey(key);
return this.#wrap("exists", safe, signal, () => {
return this.#read(safe) !== undefined;
});
}
override async list(
options?: StorageListOptions,
signal?: AbortSignal
): Promise<StorageListResult> {
return this.#wrap("list", undefined, signal, () => {
const owned = collectOwnedKeys(
this.#storage(),
this.#config.namespace
);
const live = owned.filter((k) => this.#read(k) !== undefined);
return paginateKeys(live, options);
});
}
override async clear(signal?: AbortSignal): Promise<void> {
await this.#wrap("clear", undefined, signal, () => {
const storage = this.#storage();
const owned = collectOwnedKeys(storage, this.#config.namespace);
for (const key of owned) {
try {
storage.removeItem(this.#wire(key));
} catch (err) {
throw mapLocalStorageError(err, key);
}
}
});
}
override async getWithMetadata(
key: string,
signal?: AbortSignal
): Promise<StorageResult<Uint8Array> | null> {
const safe = sanitizeKey(key);
return this.#wrap("getWithMetadata", safe, signal, () => {
const decoded = this.#read(safe);
if (decoded === undefined) return null;
return { value: decoded.value, metadata: decoded.metadata };
});
}
override async getMetadata(
key: string,
signal?: AbortSignal
): Promise<StorageMetadata | null> {
const safe = sanitizeKey(key);
return this.#wrap("getMetadata", safe, signal, () => {
const decoded = this.#read(safe);
return decoded === undefined ? null : decoded.metadata;
});
}
override async getSize(signal?: AbortSignal): Promise<number> {
return this.#wrap("getSize", undefined, signal, () => {
const owned = collectOwnedKeys(
this.#storage(),
this.#config.namespace
);
let total = 0;
for (const key of owned) {
const decoded = this.#read(key);
if (decoded !== undefined) total += decoded.metadata.size;
}
return total;
});
}
override async getPartial(
key: string,
offset: number,
length: number,
signal?: AbortSignal
): Promise<Uint8Array | null> {
const safe = sanitizeKey(key);
assertNonNegInt(safe, "offset", offset);
assertNonNegInt(safe, "length", length);
return this.#wrap("getPartial", safe, signal, () => {
const decoded = this.#read(safe);
if (decoded === undefined) return null;
// defaultGetPartial only returns null when given null bytes;
// our `decoded.value` is always Uint8Array so the result is too.
const slice = this.defaultGetPartial(
decoded.value,
offset,
length
) as Uint8Array;
recordBytes("getPartial", slice.byteLength, "read");
return slice;
});
}
override async setPartial(
key: string,
offset: number,
bytes: Uint8Array,
signal?: AbortSignal
): Promise<void> {
const safe = sanitizeKey(key);
assertNonNegInt(safe, "offset", offset);
await this.defaultSetPartial(safe, offset, bytes, signal);
}
override async getItemSize(
key: string,
signal?: AbortSignal
): Promise<number | null> {
const meta = await this.getMetadata(key, signal);
return meta?.size ?? null;
}
override createReadStream(
key: string,
signal?: AbortSignal
): ReadableStream<Uint8Array> {
const safe = sanitizeKey(key);
return this.defaultCreateReadStream(safe, signal);
}
override createWriteStream(
key: string,
signal?: AbortSignal
): WritableStream<Uint8Array> {
const safe = sanitizeKey(key);
return this.defaultCreateWriteStream(safe, signal);
}
}
export function createLocalStorageStorage(
options: LocalStorageStorageOptions = {}
): LocalStorageStorage {
return new LocalStorageStorage(options);
}

View File

@@ -0,0 +1,12 @@
export type {
RecordOpInput,
StorageBrowserLocalStorageInstruments,
StorageBrowserLocalStorageOp,
StorageBrowserLocalStorageOpOutcome,
} from "./otel.js";
export {
getInstruments,
recordOp,
resetInstruments,
setStorageBrowserLocalStorageMeterProvider,
} from "./otel.js";

View File

@@ -0,0 +1,72 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## `otel` — OpenTelemetry instrument set
### Purpose
Defines and lazily caches the OpenTelemetry instrument bundle for `@suaveplan/storage-browser-localstorage`. Lazy construction matters because the global `MeterProvider` is typically installed by the application's bootstrap code AFTER the storage package has been imported — eagerly creating instruments against the no-op default provider would freeze them on the wrong meter for the lifetime of the process. The override hook (`setStorageBrowserLocalStorageMeterProvider`) lets tests install a recording provider, drive the production code path under real instrumentation, and inspect the recorded counters and histograms — no mocks required.
### Features
- Four counters (`reads_total`, `writes_total`, `deletes_total`, `errors_total`) and two histograms (`op_duration_ms`, `io_bytes`).
- All instrument names prefixed with `storage_browser_localstorage.` for unambiguous dashboards.
- `recordOp` is the single recording funnel; production code only ever calls this helper.
- Provider override + cache reset for deterministic test isolation.
- Counter routing: `get` / `getPartial` / `getWithMetadata` → reads, `set` / `setPartial` → writes, `delete` / `clear` → deletes, `error` / `aborted` → errors.
### Usage: Basic Example
```ts
import {
getInstruments,
recordOp,
} from "@suaveplan/storage-browser-localstorage/otel";
recordOp({
op: "set",
outcome: "success",
durationMs: 1.2,
bytes: 42,
direction: "write",
});
const inst = getInstruments();
console.log(typeof inst.writesTotal.add);
```
### Usage: Advanced Example
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import {
recordOp,
resetInstruments,
setStorageBrowserLocalStorageMeterProvider,
} from "@suaveplan/storage-browser-localstorage/otel";
const provider = new MeterProvider();
setStorageBrowserLocalStorageMeterProvider(provider);
resetInstruments();
try {
recordOp({ op: "get", outcome: "success", durationMs: 0.5 });
// Drain `provider` here via your reader of choice.
} finally {
setStorageBrowserLocalStorageMeterProvider(undefined);
resetInstruments();
}
```
### API Reference
- `getInstruments(force?): StorageBrowserLocalStorageInstruments` — returns the cached instrument bundle, rebuilding it when `force === true`.
- `recordOp(input): void` — records a single op's duration + counter + optional io-bytes histogram.
- `setStorageBrowserLocalStorageMeterProvider(provider | undefined): void` — installs an override provider; pass `undefined` to fall back to the global.
- `resetInstruments(): void` — clears the cache so the next `getInstruments` rebuilds against the current (possibly newly installed) provider.
- Types: `StorageBrowserLocalStorageOp`, `StorageBrowserLocalStorageOpOutcome`, `StorageBrowserLocalStorageInstruments`, `RecordOpInput`.
### Implementation Notes
The `op_duration_ms` histogram records both success and failure samples — the `outcome` attribute distinguishes them, so dashboards can compute success-only p95s and failure-only error rates from a single histogram. The `io_bytes` histogram is only recorded when `bytes` AND `direction` are both supplied, so list/clear/delete operations don't pollute it with zero-byte samples. Counter routing uses a strict if/else-if cascade rather than a lookup table because the discrete set of ops is small and the cascade compiles to a tight switch in V8.

View File

@@ -0,0 +1,203 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
} from "@opentelemetry/api";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
getInstruments,
recordOp,
resetInstruments,
setStorageBrowserLocalStorageMeterProvider,
} from "./otel.js";
interface RecordedHistogram {
name: string;
samples: { value: number; attrs: Record<string, unknown> | undefined }[];
}
interface RecordedCounter {
name: string;
additions: { value: number; attrs: Record<string, unknown> | undefined }[];
}
class StubHistogram implements Histogram {
public readonly entry: RecordedHistogram;
public constructor(name: string, bag: RecordedHistogram[]) {
this.entry = { name, samples: [] };
bag.push(this.entry);
}
public record(value: number, attrs?: Record<string, unknown>): void {
this.entry.samples.push({ value, attrs });
}
}
class StubCounter implements Counter {
public readonly entry: RecordedCounter;
public constructor(name: string, bag: RecordedCounter[]) {
this.entry = { name, additions: [] };
bag.push(this.entry);
}
public add(value: number, attrs?: Record<string, unknown>): void {
this.entry.additions.push({ value, attrs });
}
}
class StubMeter implements Meter {
public histograms: RecordedHistogram[] = [];
public counters: RecordedCounter[] = [];
public createHistogram(name: string): Histogram {
return new StubHistogram(name, this.histograms);
}
public createCounter(name: string): Counter {
return new StubCounter(name, this.counters);
}
public createObservableGauge(): never {
throw new Error("unused");
}
public createUpDownCounter(): never {
throw new Error("unused");
}
public createObservableCounter(): never {
throw new Error("unused");
}
public createObservableUpDownCounter(): never {
throw new Error("unused");
}
public addBatchObservableCallback(): void {}
public removeBatchObservableCallback(): void {}
public createGauge(): never {
throw new Error("unused");
}
}
class StubMeterProvider implements MeterProvider {
public meters: StubMeter[] = [];
public getMeter(): Meter {
const meter = new StubMeter();
this.meters.push(meter);
return meter;
}
}
describe("storage-browser-localstorage otel", () => {
let provider: StubMeterProvider;
beforeEach(() => {
provider = new StubMeterProvider();
setStorageBrowserLocalStorageMeterProvider(provider);
resetInstruments();
});
afterEach(() => {
setStorageBrowserLocalStorageMeterProvider(undefined);
resetInstruments();
});
function findCounter(name: string): RecordedCounter | undefined {
return provider.meters[0]?.counters.find((c) => c.name === name);
}
function findHistogram(name: string): RecordedHistogram | undefined {
return provider.meters[0]?.histograms.find((h) => h.name === name);
}
it("recordOp emits the documented counter and histogram set", {
timeout: 5000,
}, () => {
recordOp({
op: "set",
outcome: "success",
durationMs: 12,
bytes: 100,
direction: "write",
});
expect(
findHistogram("storage_browser_localstorage.op_duration_ms")
?.samples
).toEqual([{ value: 12, attrs: { op: "set", outcome: "success" } }]);
expect(
findCounter("storage_browser_localstorage.writes_total")?.additions
).toEqual([{ value: 1, attrs: { op: "set", outcome: "success" } }]);
expect(
findHistogram("storage_browser_localstorage.io_bytes")?.samples
).toEqual([{ value: 100, attrs: { op: "set", direction: "write" } }]);
});
it("records error counter on a failed op", { timeout: 5000 }, () => {
recordOp({ op: "get", outcome: "error", durationMs: 3 });
expect(
findCounter("storage_browser_localstorage.errors_total")?.additions
).toEqual([{ value: 1, attrs: { op: "get", outcome: "error" } }]);
});
it("records aborted operations on the errors counter", {
timeout: 5000,
}, () => {
recordOp({ op: "get", outcome: "aborted", durationMs: 0 });
expect(
findCounter("storage_browser_localstorage.errors_total")
?.additions[0]?.attrs
).toEqual({ op: "get", outcome: "aborted" });
});
it("increments reads/writes/deletes counters by op kind", {
timeout: 5000,
}, () => {
recordOp({ op: "get", outcome: "success", durationMs: 1 });
recordOp({ op: "getPartial", outcome: "success", durationMs: 1 });
recordOp({ op: "getWithMetadata", outcome: "success", durationMs: 1 });
recordOp({ op: "set", outcome: "success", durationMs: 1 });
recordOp({ op: "setPartial", outcome: "success", durationMs: 1 });
recordOp({ op: "delete", outcome: "success", durationMs: 1 });
recordOp({ op: "clear", outcome: "success", durationMs: 1 });
recordOp({ op: "exists", outcome: "success", durationMs: 1 });
recordOp({ op: "list", outcome: "success", durationMs: 1 });
expect(
findCounter("storage_browser_localstorage.reads_total")?.additions
).toHaveLength(3);
expect(
findCounter("storage_browser_localstorage.writes_total")?.additions
).toHaveLength(2);
expect(
findCounter("storage_browser_localstorage.deletes_total")?.additions
).toHaveLength(2);
});
it("returns a cached instrument set on repeat getInstruments calls", {
timeout: 5000,
}, () => {
const a = getInstruments();
const b = getInstruments();
expect(a).toBe(b);
});
it("rebuilds the instrument set when getInstruments(true) is called", {
timeout: 5000,
}, () => {
const a = getInstruments();
const b = getInstruments(true);
expect(a).not.toBe(b);
});
it("omits the io_bytes record when bytes is undefined", {
timeout: 5000,
}, () => {
recordOp({ op: "list", outcome: "success", durationMs: 5 });
expect(
findHistogram("storage_browser_localstorage.io_bytes")?.samples ??
[]
).toHaveLength(0);
});
it("uses the global metrics fallback when provider is unset", {
timeout: 5000,
}, () => {
setStorageBrowserLocalStorageMeterProvider(undefined);
const inst = getInstruments(true);
expect(inst.meter).toBeDefined();
});
});

View File

@@ -0,0 +1,154 @@
import {
type Counter,
type Histogram,
type Meter,
type MeterProvider,
metrics,
type Tracer,
trace,
} from "@opentelemetry/api";
const METER_NAME = "@suaveplan/storage-browser-localstorage";
const TRACER_NAME = "@suaveplan/storage-browser-localstorage";
let overrideMeterProvider: MeterProvider | undefined;
/**
* Override the {@link MeterProvider} used to acquire the
* storage-browser-localstorage meter. Pass `undefined` to fall back to the
* global provider. The instrument cache is rebuilt automatically on next
* access.
*/
export function setStorageBrowserLocalStorageMeterProvider(
provider: MeterProvider | undefined
): void {
overrideMeterProvider = provider;
cachedInstruments = undefined;
}
export type StorageBrowserLocalStorageOp =
| "get"
| "set"
| "delete"
| "exists"
| "list"
| "clear"
| "getMetadata"
| "getWithMetadata"
| "getSize"
| "getPartial"
| "setPartial"
| "getItemSize";
export type StorageBrowserLocalStorageOpOutcome =
| "success"
| "error"
| "aborted";
export interface StorageBrowserLocalStorageInstruments {
tracer: Tracer;
meter: Meter;
readsTotal: Counter;
writesTotal: Counter;
deletesTotal: Counter;
errorsTotal: Counter;
opDurationMs: Histogram;
ioBytes: Histogram;
}
let cachedInstruments: StorageBrowserLocalStorageInstruments | undefined;
/**
* Lazily construct the OTel instrument set so consumers can install a
* custom global `MeterProvider`/`TracerProvider` BEFORE this package's
* production code path executes. Pass `force: true` to rebuild against a
* freshly-set provider; tests use this after
* `metrics.setGlobalMeterProvider`.
*/
export function getInstruments(
force = false
): StorageBrowserLocalStorageInstruments {
if (cachedInstruments !== undefined && !force) return cachedInstruments;
const tracer = trace.getTracer(TRACER_NAME);
const meter = (overrideMeterProvider ?? metrics).getMeter(METER_NAME);
cachedInstruments = {
tracer,
meter,
readsTotal: meter.createCounter(
"storage_browser_localstorage.reads_total",
{ description: "Total successful read operations" }
),
writesTotal: meter.createCounter(
"storage_browser_localstorage.writes_total",
{ description: "Total successful write operations" }
),
deletesTotal: meter.createCounter(
"storage_browser_localstorage.deletes_total",
{ description: "Total successful delete operations" }
),
errorsTotal: meter.createCounter(
"storage_browser_localstorage.errors_total",
{ description: "Total operations that ended in error" }
),
opDurationMs: meter.createHistogram(
"storage_browser_localstorage.op_duration_ms",
{
description: "Per-operation duration in milliseconds",
unit: "ms",
}
),
ioBytes: meter.createHistogram(
"storage_browser_localstorage.io_bytes",
{
description: "Bytes read or written per operation",
unit: "By",
}
),
};
return cachedInstruments;
}
export interface RecordOpInput {
op: StorageBrowserLocalStorageOp;
outcome: StorageBrowserLocalStorageOpOutcome;
durationMs: number;
bytes?: number;
direction?: "read" | "write";
}
/**
* Emit a complete observability record for one operation: increments the
* appropriate `{reads,writes,deletes,errors}_total` counter, records a
* duration histogram observation, and (when `bytes` is provided) records
* an `io_bytes` histogram observation.
*/
export function recordOp(input: RecordOpInput): void {
const { op, outcome, durationMs, bytes, direction } = input;
const inst = getInstruments();
const attrs = { op, outcome };
inst.opDurationMs.record(durationMs, attrs);
if (outcome === "error" || outcome === "aborted") {
inst.errorsTotal.add(1, { op, outcome });
} else if (
op === "get" ||
op === "getPartial" ||
op === "getWithMetadata"
) {
inst.readsTotal.add(1, { op, outcome });
} else if (op === "set" || op === "setPartial") {
inst.writesTotal.add(1, { op, outcome });
} else if (op === "delete" || op === "clear") {
inst.deletesTotal.add(1, { op, outcome });
}
if (bytes !== undefined && direction !== undefined) {
inst.ioBytes.record(bytes, { op, direction });
}
}
/**
* Reset the cached instrument set. Tests call this between `it(...)` blocks
* after installing a fresh in-memory MeterProvider.
*/
export function resetInstruments(): void {
cachedInstruments = undefined;
}

View File

@@ -0,0 +1 @@
export { registerLocalStorageProvider } from "./registry.js";

View File

@@ -0,0 +1,62 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## `registry` — explicit factory registration
### Purpose
The `@suaveplan/storage` package owns a small string-keyed factory that lets consumers pick a backend at config time (`createStorage({ type: "localstorage", config: { ... } })`). suaveplan storage adapters MUST opt-in via an explicit registration call rather than via module-load side effects — that way `sideEffects: false` bundlers can tree-shake the factory glue when the consumer never goes through the factory path. This module exposes the single function (`registerLocalStorageProvider`) that performs the registration.
### Features
- Single-call registration under the `"localstorage"` factory slot.
- Idempotent — repeated calls are no-ops once the slot is occupied.
- Re-registers cleanly after a downstream `clearStorageProviders()` call.
- Uses TypeScript's module augmentation so `createStorage({ type: "localstorage", ... })` is typed end-to-end after the import.
- Zero side effects on module load — the act of importing this file does NOT register anything; only calling the function does.
### Usage: Basic Example
```ts
import { createStorage } from "@suaveplan/storage/factory";
import { registerLocalStorageProvider } from "@suaveplan/storage-browser-localstorage/registry";
registerLocalStorageProvider();
const storage = createStorage({ type: "localstorage", config: {} });
```
### Usage: Advanced Example
```ts
import { createStorage } from "@suaveplan/storage/factory";
import {
LocalStorageStorage,
registerLocalStorageProvider,
} from "@suaveplan/storage-browser-localstorage";
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
registerLocalStorageProvider();
const inMemory = createInMemoryLocalStorage();
const storage = createStorage({
type: "localstorage",
config: {
namespace: "myapp",
storage: { read: () => inMemory },
},
});
console.log(storage instanceof LocalStorageStorage); // true
```
### API Reference
`registerLocalStorageProvider(): void`
- Registers `LocalStorageStorage` under the factory slot `"localstorage"`.
- Idempotent — second and subsequent calls are no-ops while the slot is occupied.
- Returns nothing; throws nothing under normal conditions.
### Implementation Notes
The function casts `LocalStorageStorage` through `as unknown as new (config?: unknown) => LocalStorageStorage` because the factory's `ProviderCtor` type accepts `unknown` for the config slot, while `LocalStorageStorage`'s constructor accepts the more specific `LocalStorageStorageOptions`. The double-cast is a deliberate, narrowly-scoped escape hatch needed because the factory cannot generically express "the config you registered with" without invasive higher-kinded plumbing — every storage adapter in the family uses the same pattern. The TypeScript `declare module "@suaveplan/storage/factory"` block adds the `"localstorage"` slot to the factory's strict-typed `StorageProviderRegistry` / `StorageProviderConfig` interfaces so call-site type inference flows through correctly without a cast.

View File

@@ -0,0 +1,56 @@
import {
clearStorageProviders,
createStorage,
hasStorageProvider,
} from "@suaveplan/storage/factory";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { createInMemoryLocalStorage } from "../in-memory-storage/in-memory-storage.js";
import { LocalStorageStorage } from "../local-storage/local-storage.js";
import { registerLocalStorageProvider } from "./registry.js";
describe("registerLocalStorageProvider", () => {
beforeEach(() => {
clearStorageProviders();
});
afterEach(() => {
clearStorageProviders();
});
it("registers the localstorage provider and resolves it via createStorage", {
timeout: 5000,
}, async () => {
registerLocalStorageProvider();
expect(hasStorageProvider("localstorage")).toBe(true);
const fixture = createInMemoryLocalStorage();
const storage = createStorage({
type: "localstorage" as never,
config: {
namespace: "t",
storage: { read: () => fixture },
} as never,
});
expect(storage).toBeInstanceOf(LocalStorageStorage);
await (storage as LocalStorageStorage).set(
"k",
new Uint8Array([1, 2, 3])
);
expect(await (storage as LocalStorageStorage).get("k")).toEqual(
new Uint8Array([1, 2, 3])
);
});
it("is idempotent across repeated registerLocalStorageProvider calls", {
timeout: 5000,
}, () => {
registerLocalStorageProvider();
registerLocalStorageProvider();
registerLocalStorageProvider();
expect(hasStorageProvider("localstorage")).toBe(true);
});
});

View File

@@ -0,0 +1,37 @@
import {
hasStorageProvider,
registerStorageProvider,
} from "@suaveplan/storage/factory";
import { LocalStorageStorage } from "../local-storage/local-storage.js";
import type { LocalStorageStorageOptions } from "../types/local-storage-storage.types.js";
declare module "@suaveplan/storage/factory" {
interface StorageProviderRegistry {
localstorage: LocalStorageStorage;
}
interface StorageProviderConfig {
localstorage: LocalStorageStorageOptions;
}
}
/**
* Register `LocalStorageStorage` against the `@suaveplan/storage` factory
* under the canonical type slot `"localstorage"`. Idempotent — repeated
* calls do nothing once the slot is occupied, and re-register cleanly
* after a downstream `clearStorageProviders()` call.
*
* Consumers MUST call this explicitly before invoking
* `createStorage({ type: "localstorage", ... })`. The package deliberately
* does NOT self-register at module-evaluation time so that
* `sideEffects: false` bundlers can tree-shake the registry path away when
* not needed.
*/
export function registerLocalStorageProvider(): void {
if (hasStorageProvider("localstorage")) return;
registerStorageProvider(
"localstorage",
LocalStorageStorage as unknown as new (
config?: unknown
) => LocalStorageStorage
);
}

View File

@@ -0,0 +1,6 @@
export type { LocalStorageStorageOptions } from "./local-storage-storage.types.js";
export { LocalStorageStorageOptionsSchema } from "./local-storage-storage.types.js";
export type {
LocalStorageAdapter,
StorageLike,
} from "./storage-like.types.js";

View File

@@ -0,0 +1,48 @@
## `LocalStorageStorageOptions` — adapter option boundary
### Purpose
Defines the option shape accepted by `LocalStorageStorage`'s constructor and `createLocalStorageStorage` factory. Pairing the TypeScript type with a Zod schema (`LocalStorageStorageOptionsSchema`) lets the constructor validate user input at the boundary and raise `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` for malformed payloads, while keeping the structural `LocalStorageAdapter` and `Clock` interfaces flexible enough to accept any compliant implementation.
### Features
- Three knobs: `namespace` (wire-key prefix), `storage` (DOM-Storage-shaped engine), `clock` (deterministic time).
- Strict Zod schema — extra keys are rejected per the strict-object default.
- `storage` and `clock` are passed through as `unknown` on the Zod side; TypeScript narrows their shape at the call site via `LocalStorageAdapter` / `Clock`.
### Usage: Basic Example
```ts
import { createLocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
const storage = createLocalStorageStorage();
await storage.set("user/42", new TextEncoder().encode("hello"));
```
### Usage: Advanced Example
```ts
import { createInMemoryLocalStorage } from "@suaveplan/storage-browser-localstorage/in-memory-storage";
import { createLocalStorageStorage } from "@suaveplan/storage-browser-localstorage";
const inMemory = createInMemoryLocalStorage();
const storage = createLocalStorageStorage({
namespace: "myapp",
storage: { read: () => inMemory },
clock: () => 1700000000000,
});
await storage.set("k", new Uint8Array([1]), { ttl: 60 });
```
### API Reference
- `interface LocalStorageStorageOptions` — `{ namespace?, storage?, clock? }`.
- `LocalStorageStorageOptionsSchema: ZodObject` — runtime validator used by the `LocalStorageStorage` constructor. Throws on the boundary.
### Implementation Notes
`namespace` is a string with min-length 1 — empty namespaces would silently collide with any caller-side data stored at the bare keys. The schema is strict (`zod`'s default strict-object) so passing `{ directory: "..." }` (a typo of OPFS' option key) is caught at the boundary instead of silently ignored. Validation throws `LocalStorageError(LOCAL_STORAGE.INVALID_OPTIONS)` so callers can distinguish boundary-rejection errors from runtime IO errors without parsing message text.
The `storage` field accepts a `LocalStorageAdapter` (the `{ read(): StorageLike }` shape) rather than a bare `StorageLike` so production code can defer the global-`localStorage` lookup until the first method call. Tests typically construct a `LocalStorageAdapter` over `createInMemoryLocalStorage()` and supply it here; the adapter indirection costs one extra function call per public method but completely decouples the package from the global object.
The `clock` field accepts a `Clock` from `@suaveplan/types` (a `() => number` returning a millisecond Unix timestamp). Defaulting to `Date.now` keeps the production code path zero-allocation; injecting a `FakeClock` from `@suaveplan/testing` makes TTL math deterministic in tests so behaviours like lazy eviction can be asserted without `setTimeout`.

View File

@@ -0,0 +1,22 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
import type { LocalStorageAdapter } from "./storage-like.types.js";
export interface LocalStorageStorageOptions {
/** Wire-key namespace prefix. Default: `"suaveplan:storage"`. */
namespace?: string;
/** Injectable Storage adapter. Default: `defaultLocalStorage()`. */
storage?: LocalStorageAdapter;
/** Injectable clock for deterministic timestamps + TTL math. */
clock?: Clock;
}
// `storage` and `clock` are structural interfaces; we accept `unknown` on the
// Zod side and rely on TypeScript to enforce shape at the call site.
const looseAdapter = z.unknown().optional();
export const LocalStorageStorageOptionsSchema = z.object({
namespace: z.string().min(1).optional(),
storage: looseAdapter,
clock: looseAdapter,
});

View File

@@ -0,0 +1,63 @@
## `StorageLike` — structural DOM Storage surface
### Purpose
`StorageLike` declares the minimum structural surface the `@suaveplan/storage-browser-localstorage` adapter requires from the underlying engine. The DOM `Storage` interface is large, mostly historical, and varies between browser versions; we narrow it down to six methods + one property so test doubles can implement the contract without dragging in DOM lib augmentation. `LocalStorageAdapter` is the resolver indirection that lets the production code path read the global at first call rather than at module-evaluation time, so universal bundlers that lack `globalThis.localStorage` (Node, Workers without storage shims) never trip the global at import.
### Features
- Six-member subset of `lib.dom`'s `Storage`: `length`, `key(i)`, `getItem`, `setItem`, `removeItem`, `clear`.
- DOM-compatible: `globalThis.localStorage` satisfies `StorageLike` structurally without casts.
- `LocalStorageAdapter.read()` lets callers inject either the real `localStorage`, an in-memory fixture, or a quota-limited test variant.
- Zero runtime cost — types only.
### Usage: Basic Example
```ts
import type { StorageLike } from "@suaveplan/storage-browser-localstorage/types";
declare const real: Storage;
const compatible: StorageLike = real; // structural compatibility, no cast.
```
### Usage: Advanced Example
```ts
import type {
LocalStorageAdapter,
StorageLike,
} from "@suaveplan/storage-browser-localstorage/types";
const map = new Map<string, string>();
const stub: StorageLike = {
get length() {
return map.size;
},
key(i: number) {
return [...map.keys()][i] ?? null;
},
getItem(k: string) {
return map.get(k) ?? null;
},
setItem(k: string, v: string) {
map.set(k, v);
},
removeItem(k: string) {
map.delete(k);
},
clear() {
map.clear();
},
};
const adapter: LocalStorageAdapter = { read: () => stub };
```
### API Reference
- `interface StorageLike` — six-member subset of DOM `Storage`. All members synchronous.
- `interface LocalStorageAdapter` — `{ read(): StorageLike }`. Called by `LocalStorageStorage` on each public method invocation; consumers may return a different `StorageLike` on each call if they want per-realm isolation.
### Implementation Notes
`StorageLike` deliberately omits `Storage[key: string]` index access — typed string indexing is a DOM convenience that obscures the actual API contract and creates ambiguity between "this is a typed property" and "this is a key lookup." Callers MUST go through `getItem`/`setItem`. The resolver indirection (`LocalStorageAdapter.read`) costs one extra function call per public method but lets the production code path defer `globalThis.localStorage` lookup until first use, which is the difference between "throws at import time in Node" and "throws at first use with a typed `LOCAL_STORAGE.UNAVAILABLE` error."

View File

@@ -0,0 +1,26 @@
/**
* Minimal structural view of the DOM `Storage` interface used by
* `LocalStorageStorage`. Declared locally so:
* - Universal consumers can opt out of DOM lib in their tsconfig.
* - Tests substitute a real in-memory implementation (no mocks) whose
* shape is pinned to this contract, not to whatever the host's `lib.dom`
* declares this week.
*
* Every member is a structural subset of the corresponding native API.
*/
export interface StorageLike {
readonly length: number;
key(index: number): string | null;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
clear(): void;
}
/**
* Resolver wrapping `globalThis.localStorage`. Holds a single `read()` so
* tests can inject the in-memory variant without touching the global.
*/
export interface LocalStorageAdapter {
read(): StorageLike;
}

View File

@@ -0,0 +1,3 @@
// Polyfills for the test environment. Node 20+ exposes `TextEncoder`,
// `TextDecoder`, `atob`, and `btoa` globally — nothing else is required for
// the in-memory `Storage` test double.

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/storage-browser-localstorage",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/error",
/^@suaveplan\/storage/,
"@suaveplan/types",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.spec-conformance.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});