first commit
This commit is contained in:
101
docs/persistence/storage/storage-browser-localstorage/README.md
Normal file
101
docs/persistence/storage/storage-browser-localstorage/README.md
Normal 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.
|
||||
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user