Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
plugins/document-navigator/docs/foundation/core/README.md
Normal file
19
plugins/document-navigator/docs/foundation/core/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# `core`
|
||||
|
||||
Core foundation — types, errors, configuration, schema registry, and utilities.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `core`
|
||||
|
||||
7 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/config`](./config/README.md) | Layered configuration management for Suaveplan. Resolves an ordered set of typed layers (defaults, file, object, env, CLI) in priority order, deep-merges the r… |
|
||||
| [`@suaveplan/env`](./env/README.md) | Type-safe, schema-first environment variable loading for Node.js and edge runtimes. Define a schema of typed field builders, point it at your sources, and rece… |
|
||||
| [`@suaveplan/error`](./error/README.md) | Foundation error substrate for Suaveplan. Every non-testing `@suaveplan/*` package extends this library's `SuaveplanError` base class and uses its stable code… |
|
||||
| [`@suaveplan/schema-registry`](./schema-registry/README.md) | Versioned schema registry with forward/backward/full compatibility checking for evolving data contracts. Register schemas by subject name, enforce evolution ru… |
|
||||
| [`@suaveplan/types`](./types/README.md) | Types-only foundation package. Zero runtime code, zero dependencies, zero bundle bytes. Provides the canonical shared interfaces that allow every `@suaveplan/*… |
|
||||
| [`@suaveplan/typescript-config`](./typescript-config/README.md) | Shared TypeScript configuration presets for Suaveplan. This package ships composable JSON presets consumed via `"extends"` across every workspace package — no… |
|
||||
| [`@suaveplan/utils`](./utils/README.md) | Zero-external-dependency utility library for Suaveplan. Covers debug helpers, deep object manipulation, platform detection, IEEE 754 float packing, pseudo-rand… |
|
||||
<!-- end:auto-docs -->
|
||||
@@ -0,0 +1 @@
|
||||
Core foundation — types, errors, configuration, schema registry, and utilities.
|
||||
184
plugins/document-navigator/docs/foundation/core/config/README.md
Normal file
184
plugins/document-navigator/docs/foundation/core/config/README.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# @suaveplan/config - Layered configuration management: defaults, file, object, env, CLI layers merged in priority order with deep-merge, Zod validation, OTel instrumentation, and event-bus watch support.
|
||||
|
||||
## Summary
|
||||
|
||||
Layered configuration management for Suaveplan. Resolves an ordered set of typed layers (defaults, file, object, env, CLI) in priority order, deep-merges the results, applies per-environment overrides, validates via a caller-supplied Zod schema or custom function, and exposes an immutable `ConfigStore` with live reload and event-bus change propagation.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [common](./src/types/common.md) — This module defines every public TypeScript interface and type alias used across the `@suaveplan/config` package. It is…
|
||||
- [schemas](./src/types/schemas.md) — This module exposes Zod schemas for every public options interface in `@suaveplan/config`. Using these schemas, callers…
|
||||
- [mask](./src/utils/mask.md) — This module provides low-level dot-notation path traversal utilities (`getByPath`, `setByPath`, `hasByPath`) and the `m…
|
||||
- [merge](./src/utils/merge.md) — This module provides two functions that form the merge backbone of the config pipeline: `deepMerge` performs a structur…
|
||||
<!-- end:auto-docs --> - [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module defines every public TypeScript interface and type alias used across the `@suaveplan/config` package. It is…
|
||||
- [schemas](./src/types/schemas.md) — This module exposes Zod schemas for every public options interface in `@suaveplan/config`. Using these schemas, callers…
|
||||
- [mask](./src/utils/mask.md) — This module provides low-level dot-notation path traversal utilities (`getByPath`, `setByPath`, `hasByPath`) and the `m…
|
||||
- [merge](./src/utils/merge.md) — This module provides two functions that form the merge backbone of the config pipeline: `deepMerge` performs a structur…
|
||||
- [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module defines every public TypeScript interface and type alias used across the `@suaveplan/config` package. It is…
|
||||
- [schemas](./src/types/schemas.md) — This module exposes Zod schemas for every public options interface in `@suaveplan/config`. Using these schemas, callers…
|
||||
- [mask](./src/utils/mask.md) — This module provides low-level dot-notation path traversal utilities (`getByPath`, `setByPath`, `hasByPath`) and the `m…
|
||||
- [merge](./src/utils/merge.md) — This module provides two functions that form the merge backbone of the config pipeline: `deepMerge` performs a structur…
|
||||
- [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module defines every public TypeScript interface and type alias used across the `@suaveplan/config` package. It is…
|
||||
- [schemas](./src/types/schemas.md) — This module exposes Zod schemas for every public options interface in `@suaveplan/config`. Using these schemas, callers…
|
||||
- [mask](./src/utils/mask.md) — This module provides low-level dot-notation path traversal utilities (`getByPath`, `setByPath`, `hasByPath`) and the `m…
|
||||
- [merge](./src/utils/merge.md) — This module provides two functions that form the merge backbone of the config pipeline: `deepMerge` performs a structur…
|
||||
- [config-builder](./src/core/config-builder/config-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_ - [config-store](./src/core/config-store/config-store.md) — <!-- begin:auto-docs --> _(no submodules)_- [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cli-layer](./src/layers/cli-layer/cli-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [defaults-layer](./src/layers/defaults-layer/defaults-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [env-layer](./src/layers/env-layer/env-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [file-layer](./src/layers/file-layer/file-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [object-layer](./src/layers/object-layer/object-layer.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — This module defines every public TypeScript interface and type alias used across the `@suaveplan/config` package. It is…
|
||||
- [schemas](./src/types/schemas.md) — This module exposes Zod schemas for every public options interface in `@suaveplan/config`. Using these schemas, callers…
|
||||
- [mask](./src/utils/mask.md) — This module provides low-level dot-notation path traversal utilities (`getByPath`, `setByPath`, `hasByPath`) and the `m…
|
||||
- [merge](./src/utils/merge.md) — This module provides two functions that form the merge backbone of the config pipeline: `deepMerge` performs a structur…
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/config
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createConfig, defaultsLayer, fileLayer, envLayer, cliLayer } from '@suaveplan/config';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import z from 'zod';
|
||||
|
||||
const Schema = z.object({
|
||||
server: z.object({ port: z.number(), host: z.string() }),
|
||||
db: z.object({ url: z.string(), pool: z.number(), password: z.string() }),
|
||||
log: z.object({ level: z.string() }),
|
||||
});
|
||||
type AppConfig = z.infer<typeof Schema>;
|
||||
|
||||
const store = createConfig<AppConfig>({
|
||||
layers: [
|
||||
defaultsLayer({ server: { port: 3000, host: '0.0.0.0' }, db: { pool: 5, password: '' }, log: { level: 'info' } }),
|
||||
fileLayer({ content: JSON.parse(readFileSync('./config.json', 'utf-8')) }),
|
||||
envLayer({ prefix: 'APP_', mapping: { PORT: 'server.port', DB_URL: 'db.url', DB_PASS: 'db.password' } }),
|
||||
cliLayer({ args: process.argv.slice(2), mapping: { port: 'server.port' } }),
|
||||
],
|
||||
validate: (raw) => Schema.parse(raw),
|
||||
sensitive: ['db.password', 'db.url'],
|
||||
environments: { production: { log: { level: 'warn' } }, development: { log: { level: 'debug' } } },
|
||||
environment: process.env.NODE_ENV ?? 'development',
|
||||
});
|
||||
|
||||
console.log(store.get('server.port')); // number
|
||||
console.log(store.toMasked()); // db.password = '****'
|
||||
|
||||
// Live reload
|
||||
await store.reload();
|
||||
|
||||
// Subscribe to changes
|
||||
const unsub = store.subscribe((next) => console.log('Config updated:', next));
|
||||
```
|
||||
|
||||
## Module Documentation
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| [`src/types/common.md`](src/types/common.md) | All public TypeScript interfaces and type aliases |
|
||||
| [`src/types/schemas.md`](src/types/schemas.md) | Zod option schemas for runtime validation |
|
||||
| [`src/core/config-builder/config-builder.md`](src/core/config-builder/config-builder.md) | `createConfig` orchestrator |
|
||||
| [`src/core/config-store/config-store.md`](src/core/config-store/config-store.md) | `createConfigStore` static store factory |
|
||||
| [`src/layers/defaults-layer/defaults-layer.md`](src/layers/defaults-layer/defaults-layer.md) | Static default values layer |
|
||||
| [`src/layers/object-layer/object-layer.md`](src/layers/object-layer/object-layer.md) | In-memory config object layer |
|
||||
| [`src/layers/env-layer/env-layer.md`](src/layers/env-layer/env-layer.md) | Environment variable layer + `coerceValue` |
|
||||
| [`src/layers/file-layer/file-layer.md`](src/layers/file-layer/file-layer.md) | Pre-parsed file content layer with watch mode |
|
||||
| [`src/layers/cli-layer/cli-layer.md`](src/layers/cli-layer/cli-layer.md) | CLI argument layer + `parseArgs` |
|
||||
| [`src/utils/merge.md`](src/utils/merge.md) | `deepMerge` and `mergeLayers` utilities |
|
||||
| [`src/utils/mask.md`](src/utils/mask.md) | Path utilities and `maskSensitive` |
|
||||
|
||||
## Layer Priorities
|
||||
|
||||
| Layer | Priority | Description |
|
||||
|-------|----------|-------------|
|
||||
| `defaultsLayer` | 0 | Static baseline — always overridden |
|
||||
| `fileLayer` | 10 | Pre-parsed file content |
|
||||
| `objectLayer` | 20 | In-memory programmatic overrides |
|
||||
| `envLayer` | 30 | Environment variables |
|
||||
| `cliLayer` | 40 | CLI arguments — highest priority |
|
||||
|
||||
## API
|
||||
|
||||
### Core
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createConfig(options)` | Resolves layers, merges, validates, returns live store |
|
||||
| `createConfigStore(options)` | Low-level static store factory |
|
||||
|
||||
### Layers
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `defaultsLayer(defaults)` | Static defaults at priority 0 |
|
||||
| `fileLayer(options)` | Pre-parsed file content at priority 10 |
|
||||
| `objectLayer(data, priority?)` | In-memory object at priority 20 |
|
||||
| `envLayer(options)` | Environment variables at priority 30 |
|
||||
| `cliLayer(options)` | CLI arguments at priority 40 |
|
||||
| `parseArgs(args, mapping)` | Parse raw argv into flag→value map |
|
||||
|
||||
### Utilities
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `deepMerge(base, override)` | Recursive object merge |
|
||||
| `mergeLayers(layers)` | Fold ordered layers with deepMerge |
|
||||
| `getByPath(obj, path)` | Read dot-notation path |
|
||||
| `setByPath(obj, path, value)` | Write dot-notation path |
|
||||
| `hasByPath(obj, path)` | Check dot-notation path existence |
|
||||
| `maskSensitive(data, paths, mask?)` | Replace sensitive paths |
|
||||
| `coerceValue(raw)` | Convert env-var string to typed value |
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `CONFIG.MISSING` | Required config key not found |
|
||||
| `CONFIG.INVALID_TYPE` | Value has wrong type |
|
||||
| `CONFIG.PARSE_FAILED` | Layer resolution threw |
|
||||
| `CONFIG.FILE_NOT_FOUND` | File layer file not found |
|
||||
| `CONFIG.ENV_NOT_SET` | Required env var not set |
|
||||
| `CONFIG.RELOAD_FAILED` | Reload aborted or layer threw |
|
||||
| `CONFIG.VALIDATION_FAILED` | Validator threw |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Role |
|
||||
|---------|------|
|
||||
| `@suaveplan/error` | `SuaveplanError` base, `defineCodes` |
|
||||
| `@suaveplan/types` | `LoggerLike`, `EventBusLike` |
|
||||
| `@suaveplan/events` | `EventBus` for file-layer watch mode |
|
||||
| `@suaveplan/env` | Coercion functions for env-var values |
|
||||
| `zod` | Option schemas |
|
||||
| `@opentelemetry/api` | Spans, counters, histograms |
|
||||
| `tslib` | TypeScript runtime helpers |
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Run with `bun run bench`.
|
||||
|
||||
| Benchmark | Operations / sec (approx.) |
|
||||
|-----------|---------------------------|
|
||||
| `createConfig` 5 layers (cold) | TBD |
|
||||
| `store.get` 100x deeply nested key | TBD |
|
||||
| `mergeLayers` 2 layers | TBD |
|
||||
| `mergeLayers` 5 layers | TBD |
|
||||
| `mergeLayers` 10 layers | TBD |
|
||||
| `mergeLayers` 20 layers | TBD |
|
||||
|
||||
_Run `bun run bench` to populate these numbers on your hardware._
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# config-builder — `createConfig` orchestrator for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createConfig` is the primary entry point to `@suaveplan/config`. It accepts
|
||||
a list of `ConfigLayer` objects, resolves each in turn, sorts them by priority,
|
||||
deep-merges the results, applies any per-environment overrides, runs the
|
||||
caller-supplied validator, and returns a live `ConfigStore`. The live store
|
||||
supports `reload()` to re-run the full pipeline and `subscribe()` to receive
|
||||
change notifications after each successful reload. All lifecycle events are
|
||||
traced via OTel spans and optionally logged via the injected `LoggerLike`.
|
||||
|
||||
## Features
|
||||
|
||||
- Sorts layers ascending by `priority` before merging
|
||||
- Wraps each layer's `resolve()` in a try/catch and rethrows as
|
||||
`ConfigError(CONFIG.PARSE_FAILED)`
|
||||
- Applies `environments[environment]` override after merging (if present)
|
||||
- Calls `options.validate` if provided; wraps throws as
|
||||
`ConfigError(CONFIG.VALIDATION_FAILED)`
|
||||
- Returns a live store with `reload(signal?)` and `subscribe(listener)`
|
||||
- `reload` re-runs full pipeline; notifies all subscribers on success
|
||||
- `subscribe` returns an unsubscribe function
|
||||
- OTel span `config.create` and counter `config.get_total` / `config.reload_total`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createConfig, defaultsLayer, envLayer } from '@suaveplan/config';
|
||||
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
defaultsLayer({ port: 3000, debug: false }),
|
||||
envLayer({ mapping: { PORT: 'port', DEBUG: 'debug' } }),
|
||||
],
|
||||
});
|
||||
|
||||
store.get('port'); // value from env or 3000
|
||||
store.get('debug'); // true/false from env or false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createConfig, defaultsLayer, fileLayer, envLayer, cliLayer } from '@suaveplan/config';
|
||||
import z from 'zod';
|
||||
|
||||
const Schema = z.object({
|
||||
port: z.number().int().min(1).max(65535),
|
||||
debug: z.boolean(),
|
||||
db: z.object({ url: z.string().url(), pool: z.number().int().positive() }),
|
||||
});
|
||||
type AppConfig = z.infer<typeof Schema>;
|
||||
|
||||
const store = createConfig<AppConfig>({
|
||||
layers: [
|
||||
defaultsLayer({ port: 3000, debug: false, db: { url: 'postgres://localhost/app', pool: 5 } }),
|
||||
fileLayer({ content: JSON.parse(readFileSync('./config.json', 'utf-8')) }),
|
||||
envLayer({ prefix: 'APP_', mapping: { PORT: 'port', DB_URL: 'db.url' } }),
|
||||
cliLayer({ args: process.argv.slice(2), mapping: { port: 'port', debug: 'debug' } }),
|
||||
],
|
||||
validate: (raw) => Schema.parse(raw),
|
||||
sensitive: ['db.url'],
|
||||
environments: { production: { debug: false }, development: { debug: true } },
|
||||
environment: process.env.NODE_ENV ?? 'development',
|
||||
});
|
||||
|
||||
// Live reload on SIGHUP
|
||||
process.on('SIGHUP', () => store.reload());
|
||||
|
||||
// Subscribe to config changes
|
||||
const unsub = store.subscribe((next) => {
|
||||
console.log('Config updated, new port:', next.port);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createConfig<T extends ConfigData>(options: CreateConfigOptions<T>): ConfigStore<T>`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.layers` | `ConfigLayer[]` | Layers to resolve; sorted by priority |
|
||||
| `options.validate` | `ConfigValidator<T>` | Optional post-merge validator |
|
||||
| `options.sensitive` | `string[]` | Paths to mask in `toMasked()` |
|
||||
| `options.environments` | `EnvironmentOverrides<T>` | Per-env overrides |
|
||||
| `options.environment` | `string` | Active environment name |
|
||||
| `options.logger` | `LoggerLike` | Optional structured logger |
|
||||
|
||||
**Returns** a live `ConfigStore<T>` with real `reload` and `subscribe`.
|
||||
|
||||
**Throws:**
|
||||
|
||||
- `ConfigError(CONFIG.PARSE_FAILED)` — a layer's `resolve()` threw
|
||||
- `ConfigError(CONFIG.VALIDATION_FAILED)` — the validator threw
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Layer resolution is synchronous — all layer `resolve()` methods must be
|
||||
synchronous. The `reload()` method is async (returns a `Promise<void>`) to
|
||||
accommodate future async layer support and to allow AbortSignal handling.
|
||||
The store's internal `frozenData` reference is atomically swapped after a
|
||||
successful reload; subscribers see only fully validated data. Partial failures
|
||||
(e.g. one layer throws during reload) leave the store at the last good state
|
||||
and propagate the error to the `reload()` caller.
|
||||
@@ -0,0 +1,114 @@
|
||||
# config-store — Immutable config store factory for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createConfigStore` constructs a static, immutable view of a pre-resolved
|
||||
config object. It deep-freezes the data (including all nested objects) so that
|
||||
accidental runtime mutations are caught in strict mode. The store provides
|
||||
`get`, `has`, `toJSON`, and `toMasked` methods for reading config values, plus
|
||||
no-op stubs for `reload` and `subscribe` that are replaced with live
|
||||
implementations by `createConfig` when a full config pipeline is constructed.
|
||||
|
||||
## Features
|
||||
|
||||
- `structuredClone` + `deepFreeze` on construction — data is immutable
|
||||
- `get(key)` — dot-notation path traversal via `getByPath`
|
||||
- `has(key)` — presence check via `hasByPath`
|
||||
- `toJSON()` — returns the frozen data reference
|
||||
- `toMasked()` — returns a frozen deep clone with sensitive paths replaced
|
||||
- `reload()` — no-op returning `Promise.resolve()` (stub for static stores)
|
||||
- `subscribe()` — no-op returning a no-op unsubscribe (stub for static stores)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createConfigStore } from '@suaveplan/config/core/config-store/config-store';
|
||||
|
||||
const store = createConfigStore({
|
||||
data: { server: { port: 3000 }, db: { password: 'secret' } },
|
||||
sensitive: ['db.password'],
|
||||
});
|
||||
|
||||
store.get('server.port'); // 3000
|
||||
store.has('server.port'); // true
|
||||
store.has('missing.key'); // false
|
||||
store.toMasked();
|
||||
// { server: { port: 3000 }, db: { password: '****' } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createConfigStore } from '@suaveplan/config/core/config-store/config-store';
|
||||
|
||||
interface AppConfig {
|
||||
server: { port: number; host: string };
|
||||
db: { url: string; pool: number; password: string };
|
||||
log: { level: string };
|
||||
}
|
||||
|
||||
const store = createConfigStore<AppConfig>({
|
||||
data: {
|
||||
server: { port: 4000, host: '0.0.0.0' },
|
||||
db: { url: 'postgres://...', pool: 10, password: 'hunter2' },
|
||||
log: { level: 'info' },
|
||||
},
|
||||
sensitive: ['db.password', 'db.url'],
|
||||
});
|
||||
|
||||
// JSON-serialise the full config (includes sensitive values — do not log):
|
||||
JSON.stringify(store.toJSON());
|
||||
|
||||
// Safe for logging — sensitive paths are masked:
|
||||
console.log(store.toMasked());
|
||||
// { server: ..., db: { url: '****', pool: 10, password: '****' }, log: ... }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createConfigStore<T extends ConfigData>(options: ConfigStoreOptions<T>): ConfigStore<T>`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.data` | `T` | Resolved config data |
|
||||
| `options.sensitive` | `string[]` | Dot-notation paths to mask in `toMasked()` |
|
||||
|
||||
**Returns** a `ConfigStore<T>` with all methods implemented.
|
||||
|
||||
#### `store.get<K extends string>(key: K): unknown`
|
||||
|
||||
Returns the value at the dot-notation `key` path, or `undefined` if not found.
|
||||
|
||||
#### `store.has(key: string): boolean`
|
||||
|
||||
Returns `true` if the dot-notation path exists and is not `undefined`.
|
||||
|
||||
#### `store.toJSON(): Readonly<T>`
|
||||
|
||||
Returns the frozen config data reference. This is the full unmasked data; do
|
||||
not log this value if it contains sensitive fields.
|
||||
|
||||
#### `store.toMasked(): Readonly<T>`
|
||||
|
||||
Returns a frozen deep clone with all `sensitive` paths replaced by `"****"`.
|
||||
Safe for logging and telemetry.
|
||||
|
||||
#### `store.reload(signal?: AbortSignal): Promise<void>`
|
||||
|
||||
No-op stub. Returns `Promise.resolve()`. Live reload is provided by `createConfig`.
|
||||
|
||||
#### `store.subscribe(listener): () => void`
|
||||
|
||||
No-op stub. Returns a no-op unsubscribe function. Live subscriptions are
|
||||
provided by `createConfig`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`deepFreeze` is applied recursively after `structuredClone` to prevent both
|
||||
shallow and deep mutations. The frozen data object is returned directly from
|
||||
`toJSON()` (no additional clone) for performance — callers who need a mutable
|
||||
copy must clone it themselves. `toMasked()` calls `structuredClone` internally
|
||||
via `maskSensitive`, producing a new mutable copy before re-freezing it.
|
||||
@@ -0,0 +1,101 @@
|
||||
# cli-layer — CLI argument config layer for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`cliLayer` creates a config layer that reads values from command-line arguments
|
||||
and maps them to dot-notation config paths. It is assigned the highest built-in
|
||||
priority (`CLI = 40`) so that operator-supplied flags always win over defaults,
|
||||
file content, environment variables, and programmatic object overrides. The
|
||||
companion `parseArgs` function handles the full range of common CLI flag forms
|
||||
and is exported for standalone use in argument-parsing utilities.
|
||||
|
||||
## Features
|
||||
|
||||
- Priority `40` (`LAYER_PRIORITIES.CLI`) — overrides all other built-in layers
|
||||
- `parseArgs` handles `--key=value`, `--key value`, `--flag` (boolean), `-k value`
|
||||
- Unknown flags not in `mapping` are silently ignored
|
||||
- Values are coerced via `coerceValue` — numerics, booleans, and JSON objects
|
||||
are automatically converted from their string representations
|
||||
- Writes to arbitrary dot-notation paths via `setByPath`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { cliLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
cliLayer({
|
||||
args: process.argv.slice(2),
|
||||
mapping: { port: 'server.port', debug: 'log.debug' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// When invoked as: node app.js --port 9000 --debug
|
||||
store.get('server.port'); // 9000 (number)
|
||||
store.get('log.debug'); // true (boolean)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseArgs } from '@suaveplan/config';
|
||||
|
||||
// Use parseArgs standalone to inspect raw flag values before coercion:
|
||||
const raw = parseArgs(
|
||||
['--host=prod.example.com', '--port', '443', '--tls'],
|
||||
{ host: 'server.host', port: 'server.port', tls: 'server.tls', p: 'server.port' }
|
||||
);
|
||||
// raw = { host: 'prod.example.com', port: '443', tls: 'true' }
|
||||
|
||||
// Short flags:
|
||||
const short = parseArgs(['-p', '8080'], { p: 'server.port' });
|
||||
// short = { p: '8080' }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `cliLayer(options: CliLayerOptions): ConfigLayer`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.args` | `string[]` | Raw argv array (e.g. `process.argv.slice(2)`) |
|
||||
| `options.mapping` | `Record<string, string>` | Flag name → config path map |
|
||||
|
||||
**Returns** a `ConfigLayer` with:
|
||||
|
||||
- `name`: `"cli"`
|
||||
- `priority`: `40` (`LAYER_PRIORITIES.CLI`)
|
||||
- `resolve()`: parses `args`, coerces values, writes to config paths
|
||||
|
||||
### `parseArgs(args: string[], mapping: Record<string, string>): Record<string, string>`
|
||||
|
||||
Parses raw argv tokens into a flat `{ flagName: rawStringValue }` record.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `args` | `string[]` | Raw argument tokens |
|
||||
| `mapping` | `Record<string, string>` | Allowed flag names (without prefix) |
|
||||
|
||||
**Returns** `Record<string, string>` — only flags present in `mapping` are included.
|
||||
|
||||
**Supported forms:**
|
||||
|
||||
| Form | Example | Key | Value |
|
||||
|------|---------|-----|-------|
|
||||
| `--key=value` | `--port=4000` | `port` | `"4000"` |
|
||||
| `--key value` | `--port 4000` | `port` | `"4000"` |
|
||||
| `--flag` | `--debug` | `debug` | `"true"` |
|
||||
| `-k value` | `-p 4000` | `p` | `"4000"` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Short flags (`-k`) are limited to single-character names. Multi-character short
|
||||
options (e.g. `-vv`) are not supported and are silently skipped. The parser
|
||||
does not validate flag values — coercion happens later in `cliLayer.resolve()`
|
||||
via `coerceValue`. Boolean flags are mapped to the string `"true"` so they
|
||||
flow through the coercion pipeline consistently.
|
||||
@@ -0,0 +1,91 @@
|
||||
# defaults-layer — Static default values layer for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`defaultsLayer` creates a configuration layer that supplies baseline default
|
||||
values for every key in the config schema. It is assigned the lowest possible
|
||||
priority (`LAYER_PRIORITIES.DEFAULTS = 0`) so that every other layer in the
|
||||
stack — file, object, env, or CLI — can override it without any special
|
||||
configuration. This makes it the natural first layer to register when building
|
||||
a `createConfig` call: supply all your application defaults here, and trust that
|
||||
higher-priority layers will selectively override them.
|
||||
|
||||
## Features
|
||||
|
||||
- Priority `0` — always the lowest; all other built-in layers override it
|
||||
- Returns a shallow copy of the provided defaults on `resolve()` — external
|
||||
mutations to the original object do not affect subsequent resolutions
|
||||
- Generic `<T extends ConfigData>` so TypeScript can infer the shape
|
||||
- Named `"defaults"` for clear identification in error messages and logs
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defaultsLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
interface AppConfig {
|
||||
port: number;
|
||||
debug: boolean;
|
||||
logLevel: string;
|
||||
}
|
||||
|
||||
const store = createConfig<AppConfig>({
|
||||
layers: [
|
||||
defaultsLayer<AppConfig>({ port: 3000, debug: false, logLevel: 'info' }),
|
||||
],
|
||||
});
|
||||
|
||||
store.get('port'); // 3000
|
||||
store.get('debug'); // false
|
||||
store.get('logLevel'); // 'info'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { defaultsLayer, envLayer, cliLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
const defaults = defaultsLayer({
|
||||
server: { port: 3000, host: '0.0.0.0' },
|
||||
db: { pool: 5, timeout: 30_000 },
|
||||
log: { level: 'info', pretty: true },
|
||||
});
|
||||
|
||||
// Override only what differs per environment:
|
||||
const env = envLayer({
|
||||
mapping: { PORT: 'server.port', DB_POOL: 'db.pool', LOG_LEVEL: 'log.level' },
|
||||
});
|
||||
|
||||
const cli = cliLayer({
|
||||
args: process.argv.slice(2),
|
||||
mapping: { port: 'server.port', debug: 'log.pretty' },
|
||||
});
|
||||
|
||||
const store = createConfig({ layers: [defaults, env, cli] });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `defaultsLayer<T extends ConfigData>(defaults: T): ConfigLayer`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `defaults` | `T` | Plain object containing default config values |
|
||||
|
||||
**Returns** a `ConfigLayer` with:
|
||||
|
||||
- `name`: `"defaults"`
|
||||
- `priority`: `0` (`LAYER_PRIORITIES.DEFAULTS`)
|
||||
- `resolve()`: returns `{ ...defaults }` (shallow copy)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The shallow copy returned by `resolve()` prevents the caller from mutating
|
||||
the internal defaults reference across successive `reload()` calls. For deeply
|
||||
nested mutable objects, callers should pass plain JSON-serialisable data to
|
||||
guarantee immutability. The function is intentionally minimal — no async
|
||||
resolution, no file I/O, no validation — because defaults are static values
|
||||
known at build time.
|
||||
@@ -0,0 +1,107 @@
|
||||
# env-layer — Environment variable config layer for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`envLayer` creates a config layer that reads environment variables and maps
|
||||
them to dot-notation config paths. It handles prefix stripping, type coercion
|
||||
via `coerceValue`, and missing-variable safety (keys absent from the environment
|
||||
source are simply excluded from the resolved data). With priority
|
||||
`LAYER_PRIORITIES.ENV = 30`, it overrides defaults, file, and object layers but
|
||||
is itself overridden by the CLI layer at priority 40.
|
||||
|
||||
## Features
|
||||
|
||||
- Maps env-var names to arbitrary dot-notation config paths via `mapping`
|
||||
- Optional `prefix` prepended to each env-var name before lookup
|
||||
- `source` override for testing without polluting `process.env`
|
||||
- `coerceValue` exported for standalone use — converts string tokens to the
|
||||
most specific JS type (boolean, number, parsed JSON, or string)
|
||||
- Missing or empty env vars are silently skipped (no error thrown)
|
||||
- Priority `30` — overrides defaults (0), file (10), and object (20) layers
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { envLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
envLayer({
|
||||
mapping: { PORT: 'server.port', DEBUG: 'log.debug' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// If PORT=4000 and DEBUG=true are set:
|
||||
store.get('server.port'); // 4000 (number, not string)
|
||||
store.get('log.debug'); // true (boolean)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { envLayer, defaultsLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
// With prefix — all env vars are prefixed with "APP_"
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
defaultsLayer({ server: { port: 3000 }, db: { pool: 5 } }),
|
||||
envLayer({
|
||||
prefix: 'APP_',
|
||||
mapping: {
|
||||
PORT: 'server.port',
|
||||
DB_POOL: 'db.pool',
|
||||
DB_URL: 'db.url',
|
||||
},
|
||||
// Can override source for testing:
|
||||
source: { APP_PORT: '8080', APP_DB_POOL: '20', APP_DB_URL: '{"host":"db"}' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
store.get('server.port'); // 8080 (number)
|
||||
store.get('db.pool'); // 20 (number)
|
||||
store.get('db.url'); // { host: 'db' } (parsed JSON)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `envLayer(options: EnvLayerOptions): ConfigLayer`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.mapping` | `Record<string, string>` | Env-var name → config path map |
|
||||
| `options.prefix` | `string` | Optional prefix prepended to each env-var name |
|
||||
| `options.source` | `Record<string, string \| undefined>` | Override for `process.env` |
|
||||
| `options.watchBus` | `EventBusLike` | Optional watch bus (reserved for future use) |
|
||||
| `options.watchEvent` | `string` | Watch event name; default `'config:env:changed'` |
|
||||
|
||||
**Returns** a `ConfigLayer` with:
|
||||
|
||||
- `name`: `"env"`
|
||||
- `priority`: `30` (`LAYER_PRIORITIES.ENV`)
|
||||
|
||||
### `coerceValue(raw: string): unknown`
|
||||
|
||||
Converts a raw environment-variable string to the most specific JavaScript type.
|
||||
|
||||
| Input | Output |
|
||||
|-------|--------|
|
||||
| `""` | `""` (empty string returned as-is) |
|
||||
| `"true"`, `"1"`, `"yes"`, `"on"`, `"enabled"` | `true` |
|
||||
| `"false"`, `"0"`, `"no"`, `"off"`, `"disabled"` | `false` |
|
||||
| Finite numeric string (`"42"`, `"3.14"`) | `number` |
|
||||
| JSON object/array string (`'{"a":1}'`) | parsed value |
|
||||
| Anything else | original `string` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Coercion is case-insensitive for boolean tokens. Numeric coercion uses
|
||||
`Number()` + `isFinite` guard to avoid accepting `Infinity` or `NaN`.
|
||||
JSON parsing is attempted only when the trimmed string starts with `{` or `[`
|
||||
to avoid unnecessary `try/catch` overhead on every string value. The `source`
|
||||
parameter defaults to `process.env` in Node/Bun environments and falls back to
|
||||
`{}` in browser environments where `process` is not defined.
|
||||
@@ -0,0 +1,94 @@
|
||||
# file-layer — Pre-parsed file content config layer for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`fileLayer` creates a config layer that serves pre-parsed configuration data
|
||||
read from a file. The layer itself does not perform file I/O — that is the
|
||||
caller's responsibility. This separation keeps the layer synchronous and
|
||||
testable without file-system access. When an optional `watchBus` event bus is
|
||||
provided, the layer subscribes to a configurable event name and triggers a
|
||||
parent-store reload when the event fires, enabling live config reloading
|
||||
without polling.
|
||||
|
||||
## Features
|
||||
|
||||
- Priority `10` (`LAYER_PRIORITIES.FILE`) — above defaults, below object/env/CLI
|
||||
- Serves `structuredClone`-safe shallow copy of provided `content` on `resolve()`
|
||||
- Optional watch mode via `EventBusLike` + configurable event name
|
||||
- `teardown()` method unsubscribes the watch listener to prevent memory leaks
|
||||
- File I/O is the caller's responsibility; this layer is format-agnostic
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fileLayer, createConfig } from '@suaveplan/config';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const content = JSON.parse(readFileSync('./config.json', 'utf-8'));
|
||||
|
||||
const store = createConfig({
|
||||
layers: [fileLayer({ content })],
|
||||
});
|
||||
|
||||
store.get('server.port'); // value from config.json
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { fileLayer, defaultsLayer, createConfig } from '@suaveplan/config';
|
||||
import { EventBus } from '@suaveplan/events';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { watch } from 'node:fs';
|
||||
|
||||
let content = JSON.parse(readFileSync('./app.json', 'utf-8'));
|
||||
const watchBus = new EventBus();
|
||||
|
||||
// Re-parse the file when it changes and signal the bus
|
||||
watch('./app.json', () => {
|
||||
content = JSON.parse(readFileSync('./app.json', 'utf-8'));
|
||||
watchBus.emit('config:file:changed', {});
|
||||
});
|
||||
|
||||
const fileL = fileLayer({
|
||||
content,
|
||||
watchBus,
|
||||
watchEvent: 'config:file:changed',
|
||||
});
|
||||
|
||||
const store = createConfig({
|
||||
layers: [defaultsLayer({ debug: false }), fileL],
|
||||
});
|
||||
|
||||
// Reloads automatically when app.json changes
|
||||
await store.reload();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `fileLayer(options: FileLayerOptions): ConfigLayer`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `options.content` | `ConfigData` | Pre-parsed config object |
|
||||
| `options.watchBus` | `EventBusLike` | Optional event bus for watch mode |
|
||||
| `options.watchEvent` | `string` | Event name to listen for; default `'config:file:changed'` |
|
||||
|
||||
**Returns** a `ConfigLayer` with:
|
||||
|
||||
- `name`: `"file"`
|
||||
- `priority`: `10` (`LAYER_PRIORITIES.FILE`)
|
||||
- `resolve()`: returns `{ ...options.content }` (shallow copy)
|
||||
- `teardown()`: unsubscribes from the watch bus if one was provided
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The watch mechanism is intentionally passive — the layer subscribes to an
|
||||
event but does not read files itself. The event payload is ignored; only its
|
||||
arrival matters. The parent `createConfig` live store injects a `_onReload`
|
||||
callback into the layer after construction to close the loop. If `teardown()`
|
||||
is not called, the subscription remains active; callers responsible for
|
||||
application shutdown should ensure all layer teardowns are invoked.
|
||||
@@ -0,0 +1,83 @@
|
||||
# object-layer — In-memory config object layer for `@suaveplan/config`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`objectLayer` wraps a plain JavaScript object as a named config layer, making it
|
||||
easy to inject programmatically constructed configuration into the `createConfig`
|
||||
pipeline. It is the simplest way to supply test overrides, feature-flag-driven
|
||||
values, or dynamically computed config blobs without resorting to the full
|
||||
`fileLayer` or `envLayer` machinery. The default priority (`OBJECT = 20`) places
|
||||
it above the file layer and defaults layer but below environment variable and CLI
|
||||
layers.
|
||||
|
||||
## Features
|
||||
|
||||
- Default priority `20` (`LAYER_PRIORITIES.OBJECT`) — overrides file and defaults
|
||||
- Configurable priority via the optional second argument
|
||||
- Returns a shallow copy on `resolve()` for mutation safety
|
||||
- Named `"object"` for clear identification in logs and error messages
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { objectLayer, createConfig } from '@suaveplan/config';
|
||||
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
objectLayer({ port: 4000, debug: true }),
|
||||
],
|
||||
});
|
||||
|
||||
store.get('port'); // 4000
|
||||
store.get('debug'); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { defaultsLayer, objectLayer, envLayer, createConfig } from '@suaveplan/config';
|
||||
import { LAYER_PRIORITIES } from '@suaveplan/config/constants';
|
||||
|
||||
// Two object layers at different priorities:
|
||||
const baseObject = objectLayer({ db: { pool: 5, timeout: 30_000 } });
|
||||
const testOverride = objectLayer(
|
||||
{ db: { pool: 1 } },
|
||||
LAYER_PRIORITIES.ENV + 5 // higher than ENV, used in tests
|
||||
);
|
||||
|
||||
const store = createConfig({
|
||||
layers: [
|
||||
defaultsLayer({ port: 3000 }),
|
||||
baseObject,
|
||||
testOverride,
|
||||
],
|
||||
});
|
||||
|
||||
store.get('db.pool'); // 1 (test override wins)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `objectLayer(data: ConfigData, priority?: number): ConfigLayer`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `data` | `ConfigData` | Plain object to expose as config values |
|
||||
| `priority` | `number` | Optional priority; defaults to `LAYER_PRIORITIES.OBJECT` (20) |
|
||||
|
||||
**Returns** a `ConfigLayer` with:
|
||||
|
||||
- `name`: `"object"`
|
||||
- `priority`: provided or `20`
|
||||
- `resolve()`: returns `{ ...data }` (shallow copy)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The shallow copy in `resolve()` means that top-level key mutations to the
|
||||
returned object do not affect subsequent resolutions. Callers who supply deeply
|
||||
nested mutable objects should be aware that nested references are not deep-cloned.
|
||||
If immutability at all depths is required, pass `structuredClone(data)` as the
|
||||
argument. The layer carries no teardown logic since it holds no external resources.
|
||||
@@ -0,0 +1,181 @@
|
||||
# common — Public TypeScript types for `@suaveplan/config`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module defines every public TypeScript interface and type alias used across
|
||||
the `@suaveplan/config` package. It is the single source of truth for the
|
||||
package's public API surface, ensuring that all layer implementations, the
|
||||
config builder, and the config store agree on the same type signatures. By
|
||||
keeping types in a dedicated file, downstream consumers can import only the
|
||||
types they need (zero runtime cost) while benefiting from full IntelliSense
|
||||
support and strict TypeScript narrowing.
|
||||
|
||||
## Features
|
||||
|
||||
- `ConfigData` — base record type for all config objects
|
||||
- `ConfigLayer` — contract every layer implementation must satisfy, with optional
|
||||
`teardown` hook for resource cleanup
|
||||
- `ConfigValidator<T>` — typed validation/transformation callback
|
||||
- `EnvironmentOverrides<T>` — per-environment partial override map
|
||||
- `CreateConfigOptions<T>` — full orchestrator options including `logger` injection
|
||||
- `ConfigStore<T>` — immutable runtime view with `get`, `has`, `toJSON`, `toMasked`,
|
||||
`reload`, and `subscribe`
|
||||
- `ConfigStoreOptions<T>` — low-level static store factory options
|
||||
- `EnvLayerOptions` — env-var source, prefix, mapping, and optional watch bus
|
||||
- `CliLayerOptions` — argv array and flag-to-path mapping
|
||||
- `FileLayerOptions` — pre-parsed content and optional watch bus
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { ConfigData, ConfigLayer, ConfigStore } from '@suaveplan/config/types/common';
|
||||
|
||||
interface AppConfig extends ConfigData {
|
||||
port: number;
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
const layer: ConfigLayer = {
|
||||
name: 'app-defaults',
|
||||
priority: 0,
|
||||
resolve: () => ({ port: 3000, debug: false }),
|
||||
};
|
||||
|
||||
function display(store: ConfigStore<AppConfig>): void {
|
||||
const port = store.get('port');
|
||||
console.log(`Listening on port ${port}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CreateConfigOptions,
|
||||
ConfigStore,
|
||||
ConfigData,
|
||||
ConfigValidator,
|
||||
EnvironmentOverrides,
|
||||
} from '@suaveplan/config/types/common';
|
||||
import z from 'zod';
|
||||
|
||||
const Schema = z.object({ port: z.number(), debug: z.boolean() });
|
||||
type AppConfig = z.infer<typeof Schema> & ConfigData;
|
||||
|
||||
const validate: ConfigValidator<AppConfig> = (raw) => Schema.parse(raw);
|
||||
|
||||
const overrides: EnvironmentOverrides<AppConfig> = {
|
||||
production: { debug: false },
|
||||
development: { debug: true },
|
||||
};
|
||||
|
||||
const opts: CreateConfigOptions<AppConfig> = {
|
||||
layers: [],
|
||||
validate,
|
||||
sensitive: ['db.password'],
|
||||
environments: overrides,
|
||||
environment: process.env.NODE_ENV ?? 'development',
|
||||
logger: console,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ConfigData`
|
||||
|
||||
```ts
|
||||
type ConfigData = Record<string, unknown>
|
||||
```
|
||||
|
||||
Base type for all config data objects. Allows arbitrary string keys and any
|
||||
value type, including nested objects.
|
||||
|
||||
### `ConfigLayer`
|
||||
|
||||
```ts
|
||||
interface ConfigLayer {
|
||||
readonly name: string;
|
||||
readonly priority: number;
|
||||
resolve(): ConfigData;
|
||||
teardown?(): void;
|
||||
}
|
||||
```
|
||||
|
||||
The contract every config layer must implement. `teardown` is optional and is
|
||||
called when the parent store is torn down (e.g. to unsubscribe from an event bus).
|
||||
|
||||
### `ConfigValidator<T>`
|
||||
|
||||
```ts
|
||||
type ConfigValidator<T extends ConfigData = ConfigData> =
|
||||
(data: ConfigData) => T
|
||||
```
|
||||
|
||||
A synchronous function that validates and transforms raw merged config data. Must
|
||||
throw on invalid input; the error is caught and wrapped as `CONFIG.VALIDATION_FAILED`.
|
||||
|
||||
### `EnvironmentOverrides<T>`
|
||||
|
||||
```ts
|
||||
type EnvironmentOverrides<T extends ConfigData = ConfigData> =
|
||||
Record<string, Partial<T>>
|
||||
```
|
||||
|
||||
Maps environment names to partial config overrides. Applied on top of the merged
|
||||
layer data when the active `environment` key matches.
|
||||
|
||||
### `CreateConfigOptions<T>`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `layers` | `ConfigLayer[]` | Yes | Layers to resolve and merge |
|
||||
| `validate` | `ConfigValidator<T>` | No | Post-merge validator |
|
||||
| `sensitive` | `string[]` | No | Paths to mask in `toMasked()` |
|
||||
| `environments` | `EnvironmentOverrides<T>` | No | Per-env overrides |
|
||||
| `environment` | `string` | No | Active environment name |
|
||||
| `logger` | `LoggerLike` | No | Structured logger for lifecycle events |
|
||||
|
||||
### `ConfigStore<T>`
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `get(key)` | `unknown` | Value at dot-notation path, or `undefined` |
|
||||
| `has(key)` | `boolean` | `true` if path exists |
|
||||
| `toJSON()` | `Readonly<T>` | Frozen resolved config |
|
||||
| `toMasked()` | `Readonly<T>` | Config with sensitive paths replaced |
|
||||
| `reload(signal?)` | `Promise<void>` | Re-resolves all layers |
|
||||
| `subscribe(fn)` | `() => void` | Listen for reload events; returns unsubscribe |
|
||||
|
||||
### `EnvLayerOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `mapping` | `Record<string, string>` | Yes | Env-var name → config path |
|
||||
| `prefix` | `string` | No | Prefix stripped before mapping |
|
||||
| `source` | `Record<string, string \| undefined>` | No | Override for `process.env` |
|
||||
| `watchBus` | `EventBusLike` | No | Event bus for watch-mode |
|
||||
| `watchEvent` | `string` | No | Event name (default: `'config:env:changed'`) |
|
||||
|
||||
### `CliLayerOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `args` | `string[]` | Yes | Raw argv (e.g. `process.argv.slice(2)`) |
|
||||
| `mapping` | `Record<string, string>` | Yes | Flag name → config path |
|
||||
|
||||
### `FileLayerOptions`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `content` | `ConfigData` | Yes | Pre-parsed file content |
|
||||
| `watchBus` | `EventBusLike` | No | Event bus for watch-mode |
|
||||
| `watchEvent` | `string` | No | Event name (default: `'config:file:changed'`) |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All types in this module are pure TypeScript interfaces and type aliases — they
|
||||
produce zero runtime output. The `ConfigLayer.teardown` optional method was added
|
||||
to support watch-mode layers that hold event-bus subscriptions. The
|
||||
`ConfigStore.reload` and `ConfigStore.subscribe` methods are optional on the
|
||||
interface for backward compatibility but are always present on concrete stores
|
||||
returned by `createConfig`.
|
||||
@@ -0,0 +1,110 @@
|
||||
# schemas — Zod option schemas for `@suaveplan/config`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module exposes Zod schemas for every public options interface in
|
||||
`@suaveplan/config`. Using these schemas, callers can validate externally
|
||||
sourced configuration objects at runtime — for instance, when reading layer
|
||||
options from a database row, an HTTP request body, or an untrusted JSON file.
|
||||
The schemas complement the TypeScript interfaces in `types/common.ts` by adding
|
||||
runtime validation without duplicating the structural definitions.
|
||||
|
||||
## Features
|
||||
|
||||
- `EnvLayerOptionsSchema` — validates `EnvLayerOptions` (mapping required, prefix optional)
|
||||
- `CliLayerOptionsSchema` — validates `CliLayerOptions` (args array + mapping required)
|
||||
- `FileLayerOptionsSchema` — validates `FileLayerOptions` (content record required)
|
||||
- `ConfigStoreOptionsSchema` — validates `ConfigStoreOptions` (data + sensitive array)
|
||||
- `CreateConfigOptionsSchema` — validates the shape-checkable subset of `CreateConfigOptions`
|
||||
(functions and loggers are excluded, layer structural fields only)
|
||||
- Inferred companion types (`EnvLayerOptionsInput`, etc.) for consumers that want
|
||||
the Zod-inferred type rather than the hand-written TS interface
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { EnvLayerOptionsSchema } from '@suaveplan/config/types/schemas';
|
||||
|
||||
const raw = JSON.parse(process.env.ENV_LAYER_CONFIG ?? '{}');
|
||||
const opts = EnvLayerOptionsSchema.parse(raw);
|
||||
// opts is now typed as EnvLayerOptionsInput
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CreateConfigOptionsSchema } from '@suaveplan/config/types/schemas';
|
||||
import z from 'zod';
|
||||
|
||||
// Validate only the serialisable portion of options coming from a config API.
|
||||
function parseRemoteConfigOptions(body: unknown) {
|
||||
const result = CreateConfigOptionsSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid config options: ${result.error.message}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// Then merge the validated shape with local layer/validate functions before
|
||||
// passing to createConfig.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EnvLayerOptionsSchema`
|
||||
|
||||
Validates `{ mapping: Record<string, string>, prefix?: string, source?: Record<string, string|undefined>, watchEvent?: string }`.
|
||||
|
||||
- `mapping` — required; record of env-var names to dot-notation config paths
|
||||
- `prefix` — optional string stripped from env-var names before mapping
|
||||
- `source` — optional environment source override
|
||||
- `watchEvent` — optional event name for watch-mode
|
||||
|
||||
**Inferred type**: `EnvLayerOptionsInput`
|
||||
|
||||
### `CliLayerOptionsSchema`
|
||||
|
||||
Validates `{ args: string[], mapping: Record<string, string> }`.
|
||||
|
||||
- `args` — required array of raw CLI arguments
|
||||
- `mapping` — required; record of flag names to config paths
|
||||
|
||||
**Inferred type**: `CliLayerOptionsInput`
|
||||
|
||||
### `FileLayerOptionsSchema`
|
||||
|
||||
Validates `{ content: Record<string, unknown>, watchEvent?: string }`.
|
||||
|
||||
- `content` — required; pre-parsed file content
|
||||
- `watchEvent` — optional event name for watch-mode
|
||||
|
||||
**Inferred type**: `FileLayerOptionsInput`
|
||||
|
||||
### `ConfigStoreOptionsSchema`
|
||||
|
||||
Validates `{ data: Record<string, unknown>, sensitive: string[] }`.
|
||||
|
||||
- `data` — required; the resolved config data
|
||||
- `sensitive` — required; list of dot-notation paths to mask
|
||||
|
||||
**Inferred type**: `ConfigStoreOptionsInput`
|
||||
|
||||
### `CreateConfigOptionsSchema`
|
||||
|
||||
Validates the serialisable subset of `CreateConfigOptions`:
|
||||
`{ layers: Array<{ name: string, priority: number }>, sensitive?: string[], environments?: Record<string, Record<string, unknown>>, environment?: string }`.
|
||||
|
||||
Functions (`validate`, `logger`) and layer `resolve` methods are intentionally
|
||||
excluded — they cannot be serialised to JSON or validated with Zod.
|
||||
|
||||
**Inferred type**: `CreateConfigOptionsInput`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `CreateConfigOptionsSchema` deliberately omits the `validate` callback and
|
||||
`logger` fields from the Zod schema. These are functions and cannot be round-
|
||||
tripped through a serialisation boundary. Callers who build `CreateConfigOptions`
|
||||
programmatically should merge the validated structural fields with their local
|
||||
function references before calling `createConfig`. The `watchBus` fields on
|
||||
`EnvLayerOptionsSchema` and `FileLayerOptionsSchema` are also excluded because
|
||||
`EventBusLike` instances are not serialisable; pass them directly in code.
|
||||
@@ -0,0 +1,115 @@
|
||||
# mask — Path utilities and sensitive-key masking for `@suaveplan/config`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module provides low-level dot-notation path traversal utilities
|
||||
(`getByPath`, `setByPath`, `hasByPath`) and the `maskSensitive` function that
|
||||
uses them to redact sensitive keys from config snapshots before they are logged
|
||||
or transmitted. The path utilities are also used internally by `envLayer` and
|
||||
`cliLayer` to write coerced values into the result object without requiring
|
||||
the caller to construct deeply nested structures manually.
|
||||
|
||||
## Features
|
||||
|
||||
- `getByPath` — safely reads a value at any dot-notation depth; returns `undefined`
|
||||
on any missing or non-object intermediate segment
|
||||
- `hasByPath` — `true` if `getByPath` resolves to a non-`undefined` value
|
||||
- `setByPath` — writes a value at a dot-notation path, creating intermediate objects
|
||||
as needed; mutates the target and returns it
|
||||
- `maskSensitive` — `structuredClone` + `setByPath` each sensitive path with
|
||||
`MASK_STRING` (`"****"`) or a custom mask string
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { getByPath, setByPath, maskSensitive } from '@suaveplan/config/utils/mask';
|
||||
|
||||
const cfg = { db: { host: 'localhost', password: 'secret' } };
|
||||
|
||||
getByPath(cfg, 'db.host'); // 'localhost'
|
||||
getByPath(cfg, 'db.missing'); // undefined
|
||||
|
||||
const masked = maskSensitive(cfg, ['db.password']);
|
||||
// { db: { host: 'localhost', password: '****' } }
|
||||
// cfg is unchanged
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { setByPath, getByPath, maskSensitive } from '@suaveplan/config/utils/mask';
|
||||
|
||||
// Build a config object from flat CLI flags via dot paths
|
||||
const result = {};
|
||||
setByPath(result, 'server.port', 4000);
|
||||
setByPath(result, 'server.tls.enabled', true);
|
||||
setByPath(result, 'db.password', 'hunter2');
|
||||
|
||||
// result = { server: { port: 4000, tls: { enabled: true } }, db: { password: 'hunter2' } }
|
||||
|
||||
// Multi-path masking with custom mask string
|
||||
const safe = maskSensitive(result, ['db.password', 'server.tls.enabled'], '[REDACTED]');
|
||||
// { server: { port: 4000, tls: { enabled: '[REDACTED]' } }, db: { password: '[REDACTED]' } }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getByPath(obj: ConfigData, path: string): unknown`
|
||||
|
||||
Traverses `obj` by splitting `path` on `"."` and descending at each segment.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `obj` | `ConfigData` | Root object to traverse |
|
||||
| `path` | `string` | Dot-notation path, e.g. `"db.host"` |
|
||||
|
||||
**Returns** the resolved value or `undefined` if any segment is missing or an
|
||||
intermediate value is not a plain object (e.g. is `null`, an array, or a scalar).
|
||||
|
||||
### `hasByPath(obj: ConfigData, path: string): boolean`
|
||||
|
||||
Returns `getByPath(obj, path) !== undefined`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `obj` | `ConfigData` | Root object |
|
||||
| `path` | `string` | Dot-notation path |
|
||||
|
||||
### `setByPath(obj: ConfigData, path: string, value: unknown): ConfigData`
|
||||
|
||||
Writes `value` at the specified dot-notation path, creating intermediate plain
|
||||
objects as needed. Mutates `obj` in place.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `obj` | `ConfigData` | Root object to mutate |
|
||||
| `path` | `string` | Dot-notation path |
|
||||
| `value` | `unknown` | Value to write |
|
||||
|
||||
**Returns** `obj` (same reference, mutated).
|
||||
|
||||
**Side effect:** Any intermediate segment that is not a plain object (null,
|
||||
array, scalar) is replaced with a new `{}`.
|
||||
|
||||
### `maskSensitive(data: ConfigData, sensitive: string[], mask?: string): ConfigData`
|
||||
|
||||
Returns a deep clone of `data` with every path in `sensitive` replaced by
|
||||
`mask`. Does not modify the original `data`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `data` | `ConfigData` | Source config object |
|
||||
| `sensitive` | `string[]` | Dot-notation paths to redact |
|
||||
| `mask` | `string` | Replacement string; defaults to `"****"` |
|
||||
|
||||
**Returns** a new `ConfigData` with sensitive values replaced. Paths that do
|
||||
not exist in `data` are silently skipped.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`structuredClone` is used in `maskSensitive` to produce a deep copy before
|
||||
mutation. This is safe for all JSON-serialisable values (the only kind that
|
||||
should appear in config data). The function is idempotent: applying it twice
|
||||
with the same arguments yields the same result as applying it once, because the
|
||||
mask string `"****"` is itself a valid value that survives the next clone-and-
|
||||
replace cycle unchanged.
|
||||
@@ -0,0 +1,90 @@
|
||||
# merge — Deep-merge and layer-folding utilities for `@suaveplan/config`
|
||||
|
||||
## Purpose
|
||||
|
||||
This module provides two functions that form the merge backbone of the config
|
||||
pipeline: `deepMerge` performs a structural recursive merge of two plain-object
|
||||
trees, and `mergeLayers` folds an ordered array of named config layers using
|
||||
`deepMerge`. These utilities are deliberately small and self-contained — they
|
||||
carry no external runtime dependencies beyond the OTel API no-op SDK, and are
|
||||
therefore safe to import in any environment.
|
||||
|
||||
## Features
|
||||
|
||||
- `deepMerge` — recursively merges two plain objects; arrays replaced, `undefined`
|
||||
override values skipped, non-object scalars overwrite
|
||||
- `mergeLayers` — folds a pre-sorted list of `{ name, data }` entries using
|
||||
`deepMerge`; records merge duration to `config.merge_duration_ms` OTel histogram
|
||||
- No mutation of input objects — both functions return new objects
|
||||
- TypeScript generics preserve the inferred type of the base object
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { deepMerge } from '@suaveplan/config/utils/merge';
|
||||
|
||||
const base = { port: 3000, db: { host: 'localhost', port: 5432 } };
|
||||
const override = { db: { host: 'prod.db.example.com' } };
|
||||
|
||||
const merged = deepMerge(base, override);
|
||||
// { port: 3000, db: { host: 'prod.db.example.com', port: 5432 } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { mergeLayers } from '@suaveplan/config/utils/merge';
|
||||
|
||||
const layers = [
|
||||
{ name: 'defaults', data: { port: 3000, debug: true, db: { pool: 5 } } },
|
||||
{ name: 'file', data: { port: 4000, db: { pool: 10 } } },
|
||||
{ name: 'env', data: { debug: false } },
|
||||
];
|
||||
|
||||
const result = mergeLayers(layers);
|
||||
// { port: 4000, debug: false, db: { pool: 10 } }
|
||||
// Note: layers must be pre-sorted by priority — mergeLayers does not sort.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `deepMerge<T extends ConfigData>(base: T, override: Partial<T>): T`
|
||||
|
||||
Recursively merges `override` into `base` and returns a new object.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `base` | `T` | The base object to merge into |
|
||||
| `override` | `Partial<T>` | Values to apply on top of `base` |
|
||||
|
||||
**Returns** a new `T`-typed object; neither argument is mutated.
|
||||
|
||||
**Merge rules:**
|
||||
|
||||
- Plain objects (non-array, non-null, prototype === `Object.prototype`) are merged recursively.
|
||||
- Arrays in `override` replace the corresponding value in `base`.
|
||||
- Keys with `undefined` values in `override` are skipped (base value preserved).
|
||||
- All other scalar values in `override` overwrite the base value.
|
||||
|
||||
### `mergeLayers(layers: Array<{ name: string; data: ConfigData }>): ConfigData`
|
||||
|
||||
Folds `layers` left-to-right using `deepMerge`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `layers` | `Array<{ name: string; data: ConfigData }>` | Pre-sorted layer descriptors |
|
||||
|
||||
**Returns** the merged `ConfigData`. Layers must be sorted by priority before
|
||||
calling this function; `mergeLayers` itself does not sort.
|
||||
|
||||
**OTel:** Records the total duration to the `config.merge_duration_ms` histogram
|
||||
with a `config.layer_count` attribute. This is automatically a no-op when no OTel
|
||||
SDK is installed.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`isPlainObject` guards against merging class instances, `Date` objects, `Map`,
|
||||
`Set`, and other non-plain-object values — those are replaced rather than merged.
|
||||
This is intentional: config values should be JSON-serialisable plain data. The
|
||||
`deepMerge` function does not handle circular references; config objects are
|
||||
assumed to be acyclic (a reasonable constraint for any serialisable config tree).
|
||||
242
plugins/document-navigator/docs/foundation/core/env/README.md
vendored
Normal file
242
plugins/document-navigator/docs/foundation/core/env/README.md
vendored
Normal file
@@ -0,0 +1,242 @@
|
||||
# @suaveplan/env - Type-safe environment variable loading: schema-first validation, field types, dotenv loader, presets, structured errors.
|
||||
|
||||
## Summary
|
||||
|
||||
Type-safe, schema-first environment variable loading for Node.js and edge runtimes. Define a schema of typed field builders, point it at your sources, and receive a frozen record with TypeScript types inferred at compile time — no manual type annotations required.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
|
||||
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
|
||||
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
|
||||
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
|
||||
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
|
||||
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
|
||||
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
|
||||
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
|
||||
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
|
||||
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
|
||||
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ - [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_ - [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ - [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_ - [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ - [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Features
|
||||
|
||||
- **Nine field builders**: `str`, `num`, `bool`, `enums`, `url`, `email`, `port`, `json`, `array`
|
||||
- **Fluent builder chains**: constraints, defaults, sensitive marking, and descriptions per field
|
||||
- **Pluggable loaders**: dotenv file, object (`process.env`), or custom loaders with priority ordering
|
||||
- **Three error modes**: `throw` (default), `collect` (partial data), `warn` (log and continue)
|
||||
- **Full-error accumulation**: reports every broken field in a single run, not just the first
|
||||
- **Sensitive value redaction**: `maskSensitive` replaces secret values with `****` before logging
|
||||
- **Preset libraries**: `commonFields` and `serverFields` for the most frequent env variables
|
||||
- **Zod runtime schemas**: for validation at serialisation boundaries
|
||||
- **100% Istanbul coverage**: foundation-grade reliability
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/env
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createEnv, str, num, bool } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
HOST: str().default('localhost'),
|
||||
PORT: num().default(3000),
|
||||
DEBUG: bool().default(false),
|
||||
API_KEY: str().nonempty().sensitive(),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// Full TypeScript types — no manual annotation needed
|
||||
console.log(env.HOST); // string
|
||||
console.log(env.PORT); // number
|
||||
console.log(env.DEBUG); // boolean
|
||||
console.log(env.API_KEY); // string
|
||||
```
|
||||
|
||||
## Field Builders
|
||||
|
||||
| Factory | Output type | Key methods |
|
||||
|---|---|---|
|
||||
| `str()` | `string` | `trim`, `lowercase`, `uppercase`, `minLength`, `maxLength`, `pattern`, `nonempty`, `startsWith`, `endsWith`, `includes` |
|
||||
| `num()` | `number` | `min`, `max`, `integer`, `positive`, `nonNegative` |
|
||||
| `bool()` | `boolean` | `truthy(values)`, `falsy(values)` |
|
||||
| `enums(values)` | `"a" \| "b" \| …` | `caseInsensitive`, `getAllowedValues` |
|
||||
| `url()` | `string` | `protocols(list)`, `noTrailingSlash` |
|
||||
| `email()` | `string` | `normalise`, `pattern(regex)` |
|
||||
| `port()` | `number` | `min(n)`, `max(n)` |
|
||||
| `json<T>()` | `T` (default `unknown`) | `validator(fn)` |
|
||||
| `array(itemField)` | `T[]` | `separator`, `noTrim`, `keepEmpty`, `minItems`, `maxItems` |
|
||||
|
||||
All builders share: `optional()`, `default(value)`, `sensitive()`, `describe(text)`, `addRule(options)`.
|
||||
|
||||
## Loaders
|
||||
|
||||
```ts
|
||||
import { createEnv, str } from '@suaveplan/env';
|
||||
import { dotenvLoader, objectLoader } from '@suaveplan/env/loaders';
|
||||
|
||||
const env = createEnv({
|
||||
schema: { DB_URL: str().sensitive() },
|
||||
loaders: [
|
||||
dotenvLoader({ path: '.env' }), // from file
|
||||
objectLoader({ source: process.env }), // from process.env (wins)
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Later loaders override earlier ones for the same key. `source` (if provided) overrides all loaders.
|
||||
|
||||
## Error Modes
|
||||
|
||||
```ts
|
||||
// Default: throws EnvValidationError with all failures listed
|
||||
const env = createEnv({ schema, source: process.env });
|
||||
|
||||
// Collect: returns partial data silently
|
||||
const env = createEnv({ schema, source: process.env, onError: 'collect' });
|
||||
|
||||
// Warn: logs each failure via console.warn, returns partial data
|
||||
const env = createEnv({ schema, source: process.env, onError: 'warn' });
|
||||
```
|
||||
|
||||
## Prefix Support
|
||||
|
||||
```ts
|
||||
// Reads APP_HOST, APP_PORT from process.env; output keys are HOST, PORT
|
||||
const env = createEnv({
|
||||
schema: { HOST: str().default('localhost'), PORT: num().default(3000) },
|
||||
source: process.env,
|
||||
prefix: 'APP_',
|
||||
});
|
||||
```
|
||||
|
||||
## Presets
|
||||
|
||||
```ts
|
||||
import { commonFields } from '@suaveplan/env/presets/common/common';
|
||||
import { serverFields, databaseUrlField, sessionSecretField } from '@suaveplan/env/presets/server/server';
|
||||
import { createEnv, str } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
...commonFields, // NODE_ENV, PORT, HOST, DEBUG, LOG_LEVEL
|
||||
DATABASE_URL: databaseUrlField(),
|
||||
SESSION_SECRET: sessionSecretField(),
|
||||
CUSTOM: str().optional(),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
```
|
||||
|
||||
## Sensitive Values
|
||||
|
||||
```ts
|
||||
import { validate, maskSensitive } from '@suaveplan/env/core/validator/validator';
|
||||
import { str } from '@suaveplan/env';
|
||||
|
||||
const schema = { SECRET: str().sensitive(), PUBLIC: str() };
|
||||
const result = validate({ schema, values: { SECRET: 'my-secret', PUBLIC: 'ok' } });
|
||||
|
||||
const safe = maskSensitive(schema, result.data);
|
||||
console.log(safe.SECRET); // "****"
|
||||
console.log(safe.PUBLIC); // "ok"
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
```ts
|
||||
import { EnvValidationError, CoercionError } from '@suaveplan/env/errors';
|
||||
import { ENV_CODES } from '@suaveplan/env/errors';
|
||||
|
||||
try {
|
||||
createEnv({ schema, source: {} });
|
||||
} catch (err) {
|
||||
if (err instanceof EnvValidationError) {
|
||||
console.log(err.code); // "ENV.VALIDATION_FAILED"
|
||||
console.log(err.errors); // [{ key, message }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Zod Schemas
|
||||
|
||||
Runtime schemas for validation at API or persistence boundaries:
|
||||
|
||||
```ts
|
||||
import { FieldConfigSchema, ValidationResultSchema } from '@suaveplan/env/schemas';
|
||||
|
||||
const result = FieldConfigSchema.safeParse(unknownPayload);
|
||||
```
|
||||
|
||||
## Module Reference
|
||||
|
||||
| Co-located module | Description |
|
||||
|---|---|
|
||||
| [`src/core/create-env/create-env.md`](src/core/create-env/create-env.md) | `createEnv` orchestrator |
|
||||
| [`src/core/field/field.md`](src/core/field/field.md) | `BaseField` abstract base |
|
||||
| [`src/core/validator/validator.md`](src/core/validator/validator.md) | `validate` and `maskSensitive` |
|
||||
| [`src/errors/error-codes.md`](src/errors/error-codes.md) | `ENV_CODES` registry |
|
||||
| [`src/errors/env-error.md`](src/errors/env-error.md) | Error class hierarchy |
|
||||
| [`src/fields/string-field/string-field.md`](src/fields/string-field/string-field.md) | `str()` |
|
||||
| [`src/fields/number-field/number-field.md`](src/fields/number-field/number-field.md) | `num()` |
|
||||
| [`src/fields/boolean-field/boolean-field.md`](src/fields/boolean-field/boolean-field.md) | `bool()` |
|
||||
| [`src/fields/enum-field/enum-field.md`](src/fields/enum-field/enum-field.md) | `enums()` |
|
||||
| [`src/fields/url-field/url-field.md`](src/fields/url-field/url-field.md) | `url()` |
|
||||
| [`src/fields/email-field/email-field.md`](src/fields/email-field/email-field.md) | `email()` |
|
||||
| [`src/fields/port-field/port-field.md`](src/fields/port-field/port-field.md) | `port()` |
|
||||
| [`src/fields/json-field/json-field.md`](src/fields/json-field/json-field.md) | `json()` |
|
||||
| [`src/fields/array-field/array-field.md`](src/fields/array-field/array-field.md) | `array()` |
|
||||
| [`src/loaders/dotenv/dotenv.md`](src/loaders/dotenv/dotenv.md) | `parseDotenv`, `dotenvLoader` |
|
||||
| [`src/loaders/object/object.md`](src/loaders/object/object.md) | `objectLoader` |
|
||||
| [`src/transforms/coerce/coerce.md`](src/transforms/coerce/coerce.md) | Standalone coercion functions |
|
||||
| [`src/presets/common/common.md`](src/presets/common/common.md) | Common server field presets |
|
||||
| [`src/presets/server/server.md`](src/presets/server/server.md) | Server connection field presets |
|
||||
| [`src/schemas/env.schemas.md`](src/schemas/env.schemas.md) | Zod runtime schemas |
|
||||
| [`src/types/common.md`](src/types/common.md) | Shared TypeScript types |
|
||||
| [`src/utils/interpolation.md`](src/utils/interpolation.md) | `${VAR}` interpolation utilities |
|
||||
|
||||
91
plugins/document-navigator/docs/foundation/core/env/src/core/create-env/create-env.md
vendored
Normal file
91
plugins/document-navigator/docs/foundation/core/env/src/core/create-env/create-env.md
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
# create-env
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createEnv` is the primary public API of `@suaveplan/env`. It orchestrates the complete lifecycle of environment loading: merging values from one or more loaders, overlaying a direct source object, running schema validation, handling errors according to the configured mode, and returning a frozen, fully-typed record. The return type is inferred from the schema at compile time, giving callers precise TypeScript types without any manual annotation.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts any combination of `loaders` and a direct `source` (later sources take priority)
|
||||
- Validates all fields accumulating every error before surfacing failures
|
||||
- Three error modes: `'throw'` (default) raises `EnvValidationError` with the full list; `'collect'` returns partial data; `'warn'` logs each failure via `console.warn` and returns partial data
|
||||
- Optional `prefix` strips a common prefix from schema key lookups (e.g. `'APP_'` looks up `APP_PORT` for schema key `PORT`)
|
||||
- Returns a `Readonly<InferEnv<S>>` frozen record — mutation after construction throws at runtime
|
||||
- Sensitive field values are available in the returned record; use `maskSensitive` before logging
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createEnv, str, num, bool } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
HOST: str().default('localhost'),
|
||||
PORT: num().default(3000),
|
||||
DEBUG: bool().default(false),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// TypeScript knows env.HOST is string, env.PORT is number, env.DEBUG is boolean
|
||||
console.log(env.HOST); // "localhost" (or value from process.env)
|
||||
console.log(env.PORT); // 3000
|
||||
console.log(env.DEBUG); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, str, num } from '@suaveplan/env';
|
||||
import { dotenvLoader } from '@suaveplan/env/loaders';
|
||||
import { objectLoader } from '@suaveplan/env/loaders';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
DB_URL: str().sensitive(),
|
||||
PORT: num().default(8080),
|
||||
},
|
||||
// Load from .env file first, then override with process.env
|
||||
loaders: [
|
||||
dotenvLoader({ path: '.env' }),
|
||||
objectLoader({ source: process.env }),
|
||||
],
|
||||
// Additionally read from a specific object (highest priority)
|
||||
source: { PORT: '9000' },
|
||||
// Look up APP_DB_URL and APP_PORT in the merged values
|
||||
prefix: 'APP_',
|
||||
// Collect errors instead of throwing — useful in test helpers
|
||||
onError: 'collect',
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createEnv<S extends EnvSchema>(options: CreateEnvOptions<S>): Readonly<InferEnv<S>>`
|
||||
|
||||
**`CreateEnvOptions<S>` properties:**
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `schema` | `S` | required | Record of field builders |
|
||||
| `source` | `Record<string, string \| undefined>?` | `undefined` | Direct value map (e.g. `process.env`); overrides loaders |
|
||||
| `loaders` | `ReadonlyArray<Loader>?` | `[]` | Ordered loaders; later loaders override earlier ones |
|
||||
| `prefix` | `string?` | `undefined` | Key prefix stripped during lookup |
|
||||
| `onError` | `'throw' \| 'collect' \| 'warn'` | `'throw'` | Error handling mode |
|
||||
|
||||
**Returns:** `Readonly<InferEnv<S>>` — a frozen object with types derived from the schema.
|
||||
|
||||
**Throws:** `EnvValidationError` when `onError === 'throw'` and any field fails validation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildValues` merges loader results in order (later loaders win), then spreads the `source` object on top. This means `source` always takes priority over any loader, which mirrors how `process.env` should override `.env` file defaults.
|
||||
|
||||
The `validateInput` object is built conditionally (`prefix ? { schema, values, prefix } : { schema, values }`) to satisfy TypeScript's `exactOptionalPropertyTypes` setting, which disallows assigning `string | undefined` to an `optional string` property.
|
||||
|
||||
`Object.freeze` is applied to the result data record before returning. The freeze is shallow — nested objects within JSON fields are not recursively frozen — but it prevents accidental mutation of top-level keys.
|
||||
|
||||
The `'warn'` mode uses `maskSensitive` before logging each error's raw value so that secrets in failed coercions do not appear in console output. The `'collect'` mode returns partial data silently, relying on the caller to inspect `result.errors` via a separate call if needed. Because `createEnv` returns only the data record, the collect mode's errors are only visible if the caller switches to a wrapper that calls `validate` directly.
|
||||
97
plugins/document-navigator/docs/foundation/core/env/src/core/field/field.md
vendored
Normal file
97
plugins/document-navigator/docs/foundation/core/env/src/core/field/field.md
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
# field
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines `BaseField<T, TRequired>`, the abstract base class that all environment field builders extend. It centralises the shared state and behaviour every field needs — required/optional tracking, default values, sensitivity markers, human-readable descriptions, and custom validation rules — so that concrete field classes only have to implement one method: `coerce(raw: string): T`. The phantom type parameter `TRequired` flows through the fluent builder chain to produce compile-time required/optional distinctions in the `InferEnv<S>` mapped type.
|
||||
|
||||
## Features
|
||||
|
||||
- Required/optional tracking with `_markRequired()` / `_markOptional()` protected helpers
|
||||
- Default value support via `_setDefault(value)` — automatically marks the field as required once a default is set
|
||||
- Sensitive flag via `sensitive()` — instructs `maskSensitive` to redact the value before logging
|
||||
- Human-readable descriptions via `describe(text)` — surfaced in error messages and documentation tools
|
||||
- Custom validation rules via `addRule(options)` — rules run in order after `coerce()` succeeds; first failure short-circuits per field
|
||||
- `getConfig()` extracts a snapshot of all state as a plain `FieldConfig<T>` object for use by the validator and diagnostics
|
||||
- Phantom `_phantom` field carries `{ type: T; required: TRequired }` for structural type inference without any runtime overhead
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { str } from '@suaveplan/env';
|
||||
|
||||
// str() returns a StringField which extends BaseField
|
||||
const field = str().default('localhost').sensitive().describe('Server host');
|
||||
|
||||
// Inspect the config
|
||||
const config = field.getConfig();
|
||||
console.log(config.isRequired); // true (default sets required)
|
||||
console.log(config.hasDefault); // true
|
||||
console.log(config.defaultValue); // "localhost"
|
||||
console.log(config.isSensitive); // true
|
||||
console.log(config.description); // "Server host"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { num } from '@suaveplan/env';
|
||||
import { BaseField } from '@suaveplan/env/core/field/field';
|
||||
|
||||
// Add custom validation rules directly to any field
|
||||
const portField = num().addRule({
|
||||
name: 'allowedPorts',
|
||||
message: 'Must be 80, 443, or 8080',
|
||||
validate: (v) => [80, 443, 8080].includes(v),
|
||||
});
|
||||
|
||||
// Implement a custom field by extending BaseField
|
||||
class HexColorField extends BaseField<string> {
|
||||
coerce(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
|
||||
throw new Error(`"${trimmed}" is not a valid hex colour`);
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
required() { this._markRequired(); return this as HexColorField<true>; }
|
||||
optional() { this._markOptional(); return this as HexColorField<false>; }
|
||||
default(v: string) { this._setDefault(v); return this as HexColorField<true>; }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `BaseField<T, TRequired extends boolean = true>` (abstract class)
|
||||
|
||||
| Member | Type | Description |
|
||||
|---|---|---|
|
||||
| `_isRequired` | `boolean` | Whether the field must be present or have a default |
|
||||
| `_hasDefault` | `boolean` | Whether a default value has been set |
|
||||
| `_defaultValue` | `T \| undefined` | The default value (if set) |
|
||||
| `_isSensitive` | `boolean` | Whether the value should be redacted in logs |
|
||||
| `_description` | `string \| undefined` | Human-readable field description |
|
||||
| `_rules` | `Array<ValidationRule<T>>` | Ordered list of post-coercion rules |
|
||||
| `sensitive()` | `this` | Mark the field as carrying sensitive data |
|
||||
| `describe(text)` | `this` | Attach a description string |
|
||||
| `addRule(options)` | `this` | Append a custom `ValidationRule<T>` |
|
||||
| `getConfig()` | `FieldConfig<T>` | Snapshot of all field state |
|
||||
| `coerce(raw)` | `T` | Abstract — must be implemented by subclasses |
|
||||
|
||||
### Protected helpers
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `_markRequired()` | Sets `_isRequired = true`, clears `_hasDefault` |
|
||||
| `_markOptional()` | Sets `_isRequired = false` |
|
||||
| `_setDefault(value)` | Sets `_hasDefault = true`, stores the value, sets `_isRequired = true` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `declare readonly _phantom` field uses `declare` to emit no runtime property while still participating in TypeScript's structural type system. This allows the `InferEnv` mapped type to distinguish `AnyField<T, true>` from `AnyField<T, false>` when computing required vs optional output keys, without any storage cost.
|
||||
|
||||
`_setDefault` intentionally keeps `_isRequired = true` after setting a default. A field with a default always resolves — it is never undefined in the output — so it must satisfy the required type constraint in `InferEnv`. Calling `optional()` after `default()` would override this to produce an optional output key, which is an unusual but valid use case.
|
||||
|
||||
The `ValidationRule<T>` interface deliberately uses method syntax (`validate(value: T): boolean`) rather than a property function type (`validate: (value: T) => boolean`). Method syntax is bivariant in TypeScript, which allows a `StringField` (whose rules have type `ValidationRule<string>`) to be assigned to `AnyField<unknown>` without a variance error.
|
||||
108
plugins/document-navigator/docs/foundation/core/env/src/core/validator/validator.md
vendored
Normal file
108
plugins/document-navigator/docs/foundation/core/env/src/core/validator/validator.md
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
# validator
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the core `validate` function that processes an entire env schema against a raw values map, accumulating all errors before returning. A complete error report — rather than fail-fast — is the defining characteristic of this module: developers see every broken field in a single run rather than fixing one variable and re-running to discover the next failure. The complementary `maskSensitive` function redacts sensitive field values so the parsed record can be logged safely.
|
||||
|
||||
## Features
|
||||
|
||||
- Accumulates errors across all fields before returning — no fail-fast behaviour
|
||||
- Per-field processing: absence check → default resolution → coercion → rule chain
|
||||
- Stops rule evaluation at the first failing rule per field (fail-fast within a field, accumulate across fields)
|
||||
- Optional key prefix: looks up `${prefix}${schemaKey}` in the values map while using `schemaKey` in the output
|
||||
- Graceful handling of non-`Error` coercion throws — produces `"Coercion failed"` rather than crashing
|
||||
- `maskSensitive` replaces values of fields marked `_isSensitive` with `"****"`, leaving non-sensitive keys untouched
|
||||
- Returns a `ValidationResult` with a boolean `success` flag, the typed `data` record, and the `errors` array
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validate } from '@suaveplan/env/core/validator/validator';
|
||||
import { str, num } from '@suaveplan/env';
|
||||
|
||||
const result = validate({
|
||||
schema: { HOST: str(), PORT: num() },
|
||||
values: { HOST: 'localhost', PORT: '3000' },
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(result.data.HOST); // "localhost"
|
||||
console.log(result.data.PORT); // 3000
|
||||
} else {
|
||||
for (const err of result.errors) {
|
||||
console.error(`${err.key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { validate, maskSensitive } from '@suaveplan/env/core/validator/validator';
|
||||
import { str, num, bool } from '@suaveplan/env';
|
||||
|
||||
const schema = {
|
||||
DB_URL: str().sensitive(),
|
||||
PORT: num().default(3000),
|
||||
DEBUG: bool().default(false),
|
||||
};
|
||||
|
||||
// Prefix lookup: reads APP_DB_URL, APP_PORT, APP_DEBUG from values
|
||||
const result = validate({
|
||||
schema,
|
||||
values: { APP_DB_URL: 'postgres://user:pass@host/db' },
|
||||
prefix: 'APP_',
|
||||
});
|
||||
|
||||
// Mask before logging
|
||||
const safe = maskSensitive(schema, result.data);
|
||||
console.log(safe.DB_URL); // "****"
|
||||
console.log(safe.PORT); // 3000
|
||||
console.log(safe.DEBUG); // false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `validate(input: ValidateInput): ValidationResult`
|
||||
|
||||
Validates all fields in the schema against the values map.
|
||||
|
||||
**Parameters (`ValidateInput`):**
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `schema` | `EnvSchema` | Record of field builders keyed by env variable name |
|
||||
| `values` | `Record<string, string \| undefined>` | Raw key→value map to look up values from |
|
||||
| `prefix` | `string?` | Optional prefix prepended to each schema key during lookup |
|
||||
|
||||
**Returns (`ValidationResult`):**
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `success` | `boolean` | `true` if no errors were accumulated |
|
||||
| `data` | `Record<string, unknown>` | Coerced values for all fields that resolved successfully |
|
||||
| `errors` | `EnvError[]` | All per-field errors, in schema iteration order |
|
||||
|
||||
### `maskSensitive(schema, data): Record<string, unknown>`
|
||||
|
||||
Returns a shallow copy of `data` with sensitive field values replaced by `"****"`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `schema` | `Record<string, AnyField>` | The same schema passed to `validate` |
|
||||
| `data` | `Record<string, unknown>` | The parsed data record from `validate` |
|
||||
|
||||
**Returns:** A new record; the original `data` is not mutated. Keys not present in `schema` are passed through unchanged.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`processField` is a private helper that handles the resolution order for a single field: check for absence, check for default, attempt coercion, run rules. The rule loop uses an early `return` on the first failure, so only one error per field reaches the accumulator. This avoids confusing multi-error noise for a single field while still giving the full cross-field picture.
|
||||
|
||||
The `err instanceof Error ? err.message : "Coercion failed"` branch in the catch block handles the edge case where a `coerce` implementation throws a non-`Error` value (e.g. a string literal). This is not a normal case for built-in fields but is possible for custom field implementations.
|
||||
|
||||
`maskSensitive` iterates `data` rather than `schema` so that extra keys that happen to be in `data` but not in `schema` are preserved. It checks `field?._isSensitive` with optional chaining to handle that case gracefully.
|
||||
106
plugins/document-navigator/docs/foundation/core/env/src/errors/env-error.md
vendored
Normal file
106
plugins/document-navigator/docs/foundation/core/env/src/errors/env-error.md
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
# env-error
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `SuaveplanError` from `@suaveplan/error` via the `EnvBaseError` abstract base, ensuring every thrown error carries a stable machine-readable `ENV.*` code, a human-readable message, and optional cause chaining. Structured error classes make it possible for callers to programmatically distinguish between a missing variable, a coercion failure, a format violation, and a dotenv parse error without string-matching.
|
||||
|
||||
## Features
|
||||
|
||||
- `EnvBaseError` abstract base typed to the `EnvCode` union — all concrete classes extend this
|
||||
- `EnvValidationError` — aggregates multiple per-field errors into a single throw; exposes `errors: ReadonlyArray<EnvError>` for structured inspection
|
||||
- `MissingEnvError` — carries the `key` string of the absent required variable
|
||||
- `CoercionError` — carries `key`, `rawValue`, and `targetType` for precise diagnostic messages
|
||||
- `DotenvParseError` — carries `lineNumber` for easy file-navigation on parse failures
|
||||
- `createEnvError` — plain factory producing the `EnvError` value-object interface shape used by the validator (no class instantiation needed for accumulation)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
EnvValidationError,
|
||||
MissingEnvError,
|
||||
CoercionError,
|
||||
DotenvParseError,
|
||||
createEnvError,
|
||||
} from '@suaveplan/env/errors';
|
||||
|
||||
// Thrown automatically by createEnv when onError === 'throw'
|
||||
try {
|
||||
throw new EnvValidationError([
|
||||
{ key: 'PORT', message: 'Required variable is missing' },
|
||||
]);
|
||||
} catch (err) {
|
||||
if (err instanceof EnvValidationError) {
|
||||
for (const e of err.errors) {
|
||||
console.error(`${e.key}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CoercionError, createEnvError } from '@suaveplan/env/errors';
|
||||
|
||||
// Build a value-object array to pass to EnvValidationError
|
||||
const errors = [];
|
||||
|
||||
// createEnvError returns a plain object — used internally by the validator
|
||||
errors.push(createEnvError('HOST', 'Required variable is missing'));
|
||||
errors.push(createEnvError('PORT', 'Cannot coerce "abc" to number', 'abc'));
|
||||
|
||||
// CoercionError carries structured fields for programmatic use
|
||||
const err = new CoercionError('PORT', 'abc', 'number');
|
||||
console.log(err.code); // "ENV.COERCE_ERROR"
|
||||
console.log(err.key); // "PORT"
|
||||
console.log(err.rawValue); // "abc"
|
||||
console.log(err.targetType); // "number"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `EnvBaseError` (abstract class)
|
||||
|
||||
Extends `SuaveplanError<EnvCode>`. All env error classes extend this. Not directly instantiable.
|
||||
|
||||
### `EnvValidationError`
|
||||
|
||||
`constructor(errors: ReadonlyArray<EnvError>, options?: SuaveplanErrorOptions)`
|
||||
|
||||
- `errors` — structured list of per-field failures, also available as `err.errors`
|
||||
- Code: `ENV.VALIDATION_FAILED`
|
||||
- Message: multiline listing each failing field
|
||||
|
||||
### `MissingEnvError`
|
||||
|
||||
`constructor(key: string, options?: SuaveplanErrorOptions)`
|
||||
|
||||
- `key` — name of the missing environment variable, also available as `err.key`
|
||||
- Code: `ENV.MISSING_REQUIRED`
|
||||
|
||||
### `CoercionError`
|
||||
|
||||
`constructor(key: string, rawValue: string, targetType: string, options?: SuaveplanErrorOptions)`
|
||||
|
||||
- `key` — variable name (may be empty string when thrown from a coerce function)
|
||||
- `rawValue` — the string that could not be converted
|
||||
- `targetType` — description of the expected type (e.g. `"number"`, `"URL"`, `"one of: a, b"`)
|
||||
- Code: `ENV.COERCE_ERROR`
|
||||
|
||||
### `DotenvParseError`
|
||||
|
||||
`constructor(message: string, lineNumber: number, options?: SuaveplanErrorOptions)`
|
||||
|
||||
- `lineNumber` — 1-based line number in the dotenv file where the error occurred
|
||||
- Code: `ENV.PARSE_ERROR`
|
||||
|
||||
### `createEnvError(key, message, value?)`
|
||||
|
||||
Returns `EnvError` (plain interface, not a class). Conditionally includes `value` only when defined to satisfy `exactOptionalPropertyTypes`. Used internally by the validator to accumulate errors without class instantiation overhead.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The abstract base class is named `EnvBaseError` rather than `EnvError` to avoid a duplicate-identifier conflict with the `EnvError` interface exported from `types/common.ts`. The `EnvError` interface represents a plain value object `{ key, message, value? }` used for accumulation, while `EnvBaseError` (and its subclasses) are throwable classes.
|
||||
|
||||
`createEnvError` uses a conditional return (`if (value !== undefined)`) to avoid assigning `string | undefined` to the optional `value` property under TypeScript's `exactOptionalPropertyTypes` mode, which disallows explicit `undefined` assignments to optional fields.
|
||||
69
plugins/document-navigator/docs/foundation/core/env/src/errors/error-codes.md
vendored
Normal file
69
plugins/document-navigator/docs/foundation/core/env/src/errors/error-codes.md
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
# error-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@suaveplan/error`. Grouping codes under the `ENV` namespace ensures they are globally unique across the entire project and prevents collisions with error codes from other packages. Every concrete error class in this package uses one of these codes.
|
||||
|
||||
## Features
|
||||
|
||||
- Five registered codes under the `ENV` namespace: `VALIDATION_FAILED`, `MISSING_REQUIRED`, `INVALID_FORMAT`, `PARSE_ERROR`, `COERCE_ERROR`
|
||||
- Fully literal-inferred return type — the TypeScript type of each code is its exact string value, not `string`
|
||||
- `EnvCode` union type derived from the registry for use in function signatures and switch statements
|
||||
- Zero runtime overhead beyond the single `defineCodes` call at module initialisation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ENV_CODES, type EnvCode } from '@suaveplan/env/errors';
|
||||
|
||||
// Access a specific code
|
||||
console.log(ENV_CODES.VALIDATION_FAILED); // "ENV.VALIDATION_FAILED"
|
||||
console.log(ENV_CODES.MISSING_REQUIRED); // "ENV.MISSING_REQUIRED"
|
||||
console.log(ENV_CODES.COERCE_ERROR); // "ENV.COERCE_ERROR"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ENV_CODES, type EnvCode } from '@suaveplan/env/errors';
|
||||
|
||||
// Use EnvCode in a type-safe switch
|
||||
function describe(code: EnvCode): string {
|
||||
switch (code) {
|
||||
case ENV_CODES.VALIDATION_FAILED:
|
||||
return 'One or more fields failed validation';
|
||||
case ENV_CODES.MISSING_REQUIRED:
|
||||
return 'A required variable was absent';
|
||||
case ENV_CODES.INVALID_FORMAT:
|
||||
return 'The value did not match the expected format';
|
||||
case ENV_CODES.PARSE_ERROR:
|
||||
return 'The dotenv file contained malformed syntax';
|
||||
case ENV_CODES.COERCE_ERROR:
|
||||
return 'The raw string could not be coerced to the target type';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ENV_CODES`
|
||||
|
||||
`Record<string, string>` — object mapping each code name to its namespaced string value.
|
||||
|
||||
| Name | Value |
|
||||
|---|---|
|
||||
| `VALIDATION_FAILED` | `"ENV.VALIDATION_FAILED"` |
|
||||
| `MISSING_REQUIRED` | `"ENV.MISSING_REQUIRED"` |
|
||||
| `INVALID_FORMAT` | `"ENV.INVALID_FORMAT"` |
|
||||
| `PARSE_ERROR` | `"ENV.PARSE_ERROR"` |
|
||||
| `COERCE_ERROR` | `"ENV.COERCE_ERROR"` |
|
||||
|
||||
### `EnvCode`
|
||||
|
||||
`type EnvCode = (typeof ENV_CODES)[keyof typeof ENV_CODES]`
|
||||
|
||||
String union of all registered ENV error code values. Use this as the type for `code` parameters in functions that accept or return env errors.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` from `@suaveplan/error` takes a namespace prefix and a `const` tuple of code names. It returns an object whose values are `"${namespace}.${name}"` strings with each value typed as a string literal, so `typeof ENV_CODES.VALIDATION_FAILED` is `"ENV.VALIDATION_FAILED"` rather than `string`. The `as const` assertion on the array is required to trigger literal inference. The `EnvCode` type is derived from the object rather than declared independently to ensure it stays in sync automatically as codes are added or removed.
|
||||
91
plugins/document-navigator/docs/foundation/core/env/src/fields/array-field/array-field.md
vendored
Normal file
91
plugins/document-navigator/docs/foundation/core/env/src/fields/array-field/array-field.md
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
# array-field
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`ArrayField` handles environment variables that encode multiple values in a single string by separating them with a delimiter (most commonly a comma). It delegates element-level coercion to any other field builder, enabling typed arrays of strings, numbers, booleans, URLs, or any custom field type. This composition pattern — wrapping an existing field — keeps `ArrayField` focused on splitting and filtering while the item field handles type coercion.
|
||||
|
||||
## Features
|
||||
|
||||
- Splits on a configurable separator (default: `","`)
|
||||
- Trims whitespace from each item by default (disable with `noTrim()`)
|
||||
- Filters empty strings after splitting by default (disable with `keepEmpty()`)
|
||||
- `minItems(n)` / `maxItems(n)` — validate the resulting array length
|
||||
- Item-level coercion and validation errors bubble up from the item field's `coerce()` call
|
||||
- `optional()` / `default(value: T[])` / `sensitive()` inherited from `BaseField`
|
||||
- The output type `T[]` is inferred from the item field's generic parameter
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { array, str } from '@suaveplan/env';
|
||||
|
||||
const field = array(str());
|
||||
console.log(field.coerce('a,b,c')); // ["a", "b", "c"]
|
||||
console.log(field.coerce(' a , b , c ')); // ["a", "b", "c"] (trimmed)
|
||||
console.log(field.coerce('')); // []
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, array, str, num, url } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
ALLOWED_HOSTS: array(str())
|
||||
.minItems(1)
|
||||
.default(['localhost']),
|
||||
REDIS_PORTS: array(num().integer().min(1).max(65535))
|
||||
.separator(':')
|
||||
.default([6379]),
|
||||
CORS_ORIGINS: array(url().protocols(['https', 'http']))
|
||||
.minItems(1)
|
||||
.maxItems(10),
|
||||
// Semicolon-separated, keeping empty strings
|
||||
RAW_TAGS: array(str()).separator(';').noTrim().keepEmpty(),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.ALLOWED_HOSTS: string[]
|
||||
// env.REDIS_PORTS: number[]
|
||||
// env.CORS_ORIGINS: string[]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `array<T>(itemField: AnyField<T>): ArrayField<T, true>`
|
||||
|
||||
Factory function. Takes any field builder as the item coercer and returns an `ArrayField<T, true>`.
|
||||
|
||||
### `class ArrayField<T, TRequired extends boolean = true>`
|
||||
|
||||
Extends `BaseField<T[], TRequired>`.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|---|---|---|
|
||||
| `separator(sep)` | `this` | Override the split delimiter (default `","`) |
|
||||
| `noTrim()` | `this` | Disable whitespace trimming of items |
|
||||
| `keepEmpty()` | `this` | Retain empty strings after splitting |
|
||||
| `minItems(n)` | `this` | Add a minimum array length rule |
|
||||
| `maxItems(n)` | `this` | Add a maximum array length rule |
|
||||
| `required()` | `ArrayField<T, true>` | Mark as required |
|
||||
| `optional()` | `ArrayField<T, false>` | Mark as optional |
|
||||
| `default(value: T[])` | `ArrayField<T, true>` | Set a default array (already-coerced values) |
|
||||
|
||||
### `coerce(raw: string): T[]`
|
||||
|
||||
Splits on `_separator`, optionally trims each part, optionally filters empty parts, then maps each non-empty part through `_itemField.coerce()`. Returns an empty array `[]` when the input is entirely empty after splitting and filtering.
|
||||
|
||||
**Throws:** Propagates any `CoercionError` thrown by `_itemField.coerce()` for individual items.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`coerce` returns an empty array for empty input rather than throwing. This is correct because the field may be required but validly set to an empty string; `minItems(1)` is the mechanism to enforce non-empty arrays. An absent (undefined) required `ArrayField` still produces a "missing required" error — the empty array shortcut only applies when the raw value is present but blank.
|
||||
|
||||
`default()` takes an already-coerced `T[]` value. The default bypasses `coerce` and goes directly into the output, which is consistent with all other field builders. `minItems` and `maxItems` rules are not applied to the default value.
|
||||
|
||||
Item coercion errors are not caught by `ArrayField.coerce` — they propagate up to the `processField` function in `validator.ts`, which records them as field errors. The `key` in the resulting `CoercionError` will be the schema key of the `ArrayField`, not an item index, which is a known limitation when diagnosing which element failed.
|
||||
84
plugins/document-navigator/docs/foundation/core/env/src/fields/boolean-field/boolean-field.md
vendored
Normal file
84
plugins/document-navigator/docs/foundation/core/env/src/fields/boolean-field/boolean-field.md
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
# boolean-field
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`BooleanField` converts environment variable strings to JavaScript `boolean` values using configurable sets of truthy and falsy tokens. Environment variables rarely contain `"true"` or `"false"` verbatim; operators use `"1"`, `"yes"`, `"on"`, `"enabled"`, and their opposites. `BooleanField` recognises all of these by default and allows the caller to replace the token sets entirely when a project uses a different convention.
|
||||
|
||||
## Features
|
||||
|
||||
- Default truthy tokens: `"true"`, `"1"`, `"yes"`, `"on"`, `"enabled"`
|
||||
- Default falsy tokens: `"false"`, `"0"`, `"no"`, `"off"`, `"disabled"`, `""`
|
||||
- Case-insensitive comparison — input is lowercased before lookup
|
||||
- `truthy(values)` — replace the entire set of truthy tokens
|
||||
- `falsy(values)` — replace the entire set of falsy tokens
|
||||
- Throws `CoercionError` with a descriptive message listing all accepted tokens when the input matches neither set
|
||||
- `optional()` / `default(value)` / `sensitive()` inherited from `BaseField`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { bool } from '@suaveplan/env';
|
||||
|
||||
const field = bool().default(false);
|
||||
console.log(field.coerce('true')); // true
|
||||
console.log(field.coerce('1')); // true
|
||||
console.log(field.coerce('yes')); // true
|
||||
console.log(field.coerce('FALSE')); // false (case insensitive)
|
||||
console.log(field.coerce('off')); // false
|
||||
console.log(field._defaultValue); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, bool } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
DEBUG: bool().default(false),
|
||||
// Custom token sets for a legacy system that uses Y/N
|
||||
FEATURE_FLAG: bool()
|
||||
.truthy(['Y', 'YES', 'TRUE'])
|
||||
.falsy(['N', 'NO', 'FALSE'])
|
||||
.default(false),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.DEBUG and env.FEATURE_FLAG are both typed as boolean
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `bool(): BooleanField<true>`
|
||||
|
||||
Factory function. Returns a new `BooleanField` with the default truthy/falsy sets and `_isRequired = true`.
|
||||
|
||||
### `class BooleanField<TRequired extends boolean = true>`
|
||||
|
||||
Extends `BaseField<boolean, TRequired>`.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|---|---|---|
|
||||
| `truthy(values)` | `this` | Replace the truthy token set (lowercased internally) |
|
||||
| `falsy(values)` | `this` | Replace the falsy token set (lowercased internally) |
|
||||
| `required()` | `BooleanField<true>` | Mark as required |
|
||||
| `optional()` | `BooleanField<false>` | Mark as optional |
|
||||
| `default(value)` | `BooleanField<true>` | Set a boolean default |
|
||||
|
||||
### `coerce(raw: string): boolean`
|
||||
|
||||
Trims and lowercases the input, then checks `_truthyValues.has(lower)` and `_falsyValues.has(lower)`. Returns `true`, `false`, or throws.
|
||||
|
||||
**Throws:** `CoercionError` listing all accepted tokens when neither set matches.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Internally `_truthyValues` and `_falsyValues` are `Set<string>` instances for O(1) lookup, initialised from the `TRUTHY_VALUES` and `FALSY_VALUES` readonly arrays defined in `constants/index.ts`. The `truthy()` and `falsy()` methods replace the sets entirely rather than merging — this prevents accidental accumulation of tokens when chaining and makes the configuration explicit.
|
||||
|
||||
The error message in `CoercionError` includes `[...this._truthyValues, ...this._falsyValues].join(", ")` so the user sees exactly which tokens are accepted. This is more helpful than a generic `"expected boolean"` message, especially when custom token sets are in use.
|
||||
|
||||
The `coerce` function in `transforms/coerce/coerce.ts` implements a simpler boolean coercion (using array `.includes()` on the constants) for use outside of `BooleanField`. `BooleanField.coerce` always uses the instance's `Set` members, so custom token sets specified via `truthy()`/`falsy()` are respected.
|
||||
84
plugins/document-navigator/docs/foundation/core/env/src/fields/email-field/email-field.md
vendored
Normal file
84
plugins/document-navigator/docs/foundation/core/env/src/fields/email-field/email-field.md
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
# email-field
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`EmailField` validates that an environment variable contains a syntactically plausible email address before returning it as a string. It uses a regex pattern that covers the vast majority of real-world email addresses (<local-part@domain.tld>) without the complexity of full RFC 5321 compliance, striking a practical balance between correctness and simplicity. It is the appropriate field to use for `SMTP_FROM`, notification sender addresses, and any other configuration that requires a contact email.
|
||||
|
||||
## Features
|
||||
|
||||
- Validates against a built-in `EMAIL_PATTERN` regex covering standard email formats
|
||||
- `normalise()` — lowercases the entire address before returning (useful for case-insensitive comparison later)
|
||||
- `pattern(regex)` — swap in a custom validation regex when stricter or looser checking is needed
|
||||
- `optional()` / `default(value)` / `sensitive()` inherited from `BaseField`
|
||||
- Throws `CoercionError` with `targetType = "email address"` for invalid inputs
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { email } from '@suaveplan/env';
|
||||
|
||||
const field = email();
|
||||
console.log(field.coerce('Admin@Example.COM')); // "Admin@Example.COM"
|
||||
|
||||
const normalised = email().normalise();
|
||||
console.log(normalised.coerce('Admin@Example.COM')); // "admin@example.com"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, email } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
SMTP_FROM: email()
|
||||
.normalise()
|
||||
.describe('Default sender address for outgoing mail'),
|
||||
SUPPORT_EMAIL: email()
|
||||
.optional()
|
||||
.describe('Support contact email (optional)'),
|
||||
// Strict internal-only email: must end with @company.com
|
||||
ADMIN_EMAIL: email()
|
||||
.pattern(/^[^@]+@company\.com$/)
|
||||
.describe('Admin email — must be a company address'),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.SMTP_FROM: string, env.SUPPORT_EMAIL: string | undefined
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `email(): EmailField<true>`
|
||||
|
||||
Factory function. Returns a new `EmailField` with the default `EMAIL_PATTERN` and `_isRequired = true`.
|
||||
|
||||
### `class EmailField<TRequired extends boolean = true>`
|
||||
|
||||
Extends `BaseField<string, TRequired>`.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|---|---|---|
|
||||
| `pattern(regex)` | `this` | Replace the validation regex |
|
||||
| `normalise()` | `this` | Lowercase the value during coercion |
|
||||
| `required()` | `EmailField<true>` | Mark as required |
|
||||
| `optional()` | `EmailField<false>` | Mark as optional |
|
||||
| `default(value)` | `EmailField<true>` | Set a default email address |
|
||||
|
||||
### `coerce(raw: string): string`
|
||||
|
||||
Trims the input, optionally lowercases, then tests against `_pattern`. Returns the (possibly normalised) value on match.
|
||||
|
||||
**Throws:** `CoercionError` with `targetType = "email address"` when the pattern test fails.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `EMAIL_PATTERN` constant is defined in `constants/index.ts` as a regex that matches `local@domain.tld` with reasonable permissiveness — it allows plus signs, dots, and hyphens in the local part but does not support quoted local parts or IP-literal hosts. This covers `>99%` of real-world addresses in practice.
|
||||
|
||||
The `normalise()` option lowercases the entire address. Email addresses are technically case-sensitive in the local part (before `@`) per RFC 5321, but essentially all major mail services treat the local part as case-insensitive in practice. Normalising avoids duplicates when addresses are stored or compared downstream.
|
||||
|
||||
Normalisation occurs before pattern testing, so a custom `pattern()` receives the lowercased string when `normalise()` is also set. This means patterns can rely on lowercase input when the two options are combined.
|
||||
85
plugins/document-navigator/docs/foundation/core/env/src/fields/enum-field/enum-field.md
vendored
Normal file
85
plugins/document-navigator/docs/foundation/core/env/src/fields/enum-field/enum-field.md
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
# enum-field
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`EnumField` constrains an environment variable to a fixed set of allowed string values and infers the output type as the string-literal union of those values. This is the correct field to use when only a small number of known strings are valid — for example `NODE_ENV` or `LOG_LEVEL` — because TypeScript tracks which specific strings are permitted at compile time and the runtime check ensures the running process cannot use an undeclared value.
|
||||
|
||||
## Features
|
||||
|
||||
- Infers the output type as a string-literal union: `enums(['a', 'b'])` produces `EnumField<"a" | "b">`
|
||||
- Strict exact-match coercion by default; unrecognised values throw `CoercionError` listing all allowed strings
|
||||
- `caseInsensitive()` — normalises both the input and the allowed values to lowercase before comparing
|
||||
- `getAllowedValues()` — returns the readonly tuple of allowed values for documentation and tooling
|
||||
- `default(value: T)` — the default must be one of the allowed values (enforced by the `T` type)
|
||||
- `optional()` / `sensitive()` inherited from `BaseField`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { enums } from '@suaveplan/env';
|
||||
|
||||
const field = enums(['development', 'production', 'test', 'staging'] as const)
|
||||
.default('development');
|
||||
|
||||
console.log(field.coerce('production')); // "production"
|
||||
console.log(field._defaultValue); // "development"
|
||||
// TypeScript type: EnumField<"development" | "production" | "test" | "staging">
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, enums } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
NODE_ENV: enums(['development', 'production', 'test', 'staging'] as const)
|
||||
.default('development'),
|
||||
LOG_LEVEL: enums(['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const)
|
||||
.default('info'),
|
||||
// Case-insensitive: 'PROD', 'prod', 'Prod' all match 'production'
|
||||
REGION: enums(['us-east-1', 'eu-west-1', 'ap-southeast-1'] as const)
|
||||
.caseInsensitive()
|
||||
.default('us-east-1'),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.NODE_ENV: "development" | "production" | "test" | "staging"
|
||||
// env.LOG_LEVEL: "trace" | "debug" | "info" | "warn" | "error" | "fatal"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `enums<const T extends readonly string[]>(values: T): EnumField<T[number], true>`
|
||||
|
||||
Factory function. Takes a const tuple of allowed strings and returns an `EnumField` typed to the union of those strings.
|
||||
|
||||
### `class EnumField<T extends string, TRequired extends boolean = true>`
|
||||
|
||||
Extends `BaseField<T, TRequired>`.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|---|---|---|
|
||||
| `caseInsensitive()` | `this` | Enable case-insensitive matching |
|
||||
| `getAllowedValues()` | `ReadonlyArray<T>` | The original allowed values array |
|
||||
| `required()` | `EnumField<T, true>` | Mark as required |
|
||||
| `optional()` | `EnumField<T, false>` | Mark as optional |
|
||||
| `default(value: T)` | `EnumField<T, true>` | Set a default (must be an allowed value) |
|
||||
|
||||
### `coerce(raw: string): T`
|
||||
|
||||
Trims input. In case-insensitive mode, lowercases both input and each allowed value before comparing; returns the original casing of the matching allowed value. Throws `CoercionError` listing all allowed values when no match is found.
|
||||
|
||||
**Throws:** `CoercionError` with `targetType = "one of: a, b, c"` when the input is not in the allowed set.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `enums` factory uses `const T extends readonly string[]` to capture the literal types from a `const` tuple. Without the `const` modifier the type degrades to `string[]` and literal inference is lost. The `as const` assertion on the passed array is required from the call site for the same reason.
|
||||
|
||||
Case-insensitive mode returns the original casing of the matched allowed value (`return allowed`) rather than the lowercased input. This ensures consistent output regardless of input casing — useful when the value is later used as a dictionary key or log string where the canonical casing matters.
|
||||
|
||||
`getAllowedValues()` is primarily useful for generating documentation, CLI `--help` output, or OpenAPI schema `enum` arrays without duplicating the literal list.
|
||||
90
plugins/document-navigator/docs/foundation/core/env/src/fields/json-field/json-field.md
vendored
Normal file
90
plugins/document-navigator/docs/foundation/core/env/src/fields/json-field/json-field.md
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
# json-field
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`JsonField` handles environment variables whose values are serialised JSON documents. This pattern is used for feature flag objects, CORS origins arrays, TLS certificate configuration, and any other structured value that does not fit a scalar field. The field parses the raw string with `JSON.parse` and optionally applies a type-guard function to narrow the TypeScript type of the parsed result from `unknown` to the caller's expected shape.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses raw strings via `JSON.parse` and throws `CoercionError` on malformed JSON
|
||||
- `validator(fn)` — attach a runtime type guard to narrow from `unknown` to `T`; throws `CoercionError` on guard failure
|
||||
- Type parameter `T` defaults to `unknown`; supply it explicitly when using a validator for full type safety
|
||||
- `optional()` / `default(value: T)` / `sensitive()` inherited from `BaseField`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { json } from '@suaveplan/env';
|
||||
|
||||
const field = json<string[]>();
|
||||
console.log(field.coerce('["a","b","c"]')); // ["a", "b", "c"]
|
||||
console.log(field.coerce('not json')); // throws CoercionError
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, json } from '@suaveplan/env';
|
||||
|
||||
interface FeatureFlags {
|
||||
darkMode: boolean;
|
||||
betaUsers: number;
|
||||
allowedRegions: string[];
|
||||
}
|
||||
|
||||
const isFeatureFlags = (v: unknown): v is FeatureFlags =>
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
'darkMode' in v &&
|
||||
'betaUsers' in v &&
|
||||
'allowedRegions' in v;
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
FEATURE_FLAGS: json<FeatureFlags>()
|
||||
.validator(isFeatureFlags)
|
||||
.default({ darkMode: false, betaUsers: 0, allowedRegions: [] }),
|
||||
CORS_ORIGINS: json<string[]>()
|
||||
.validator((v): v is string[] => Array.isArray(v))
|
||||
.default(['http://localhost:3000']),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.FEATURE_FLAGS is typed as FeatureFlags
|
||||
// env.CORS_ORIGINS is typed as string[]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `json<T = unknown>(): JsonField<T, true>`
|
||||
|
||||
Factory function. Returns a new `JsonField` typed to `T` with `_isRequired = true`.
|
||||
|
||||
### `class JsonField<T = unknown, TRequired extends boolean = true>`
|
||||
|
||||
Extends `BaseField<T, TRequired>`.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|---|---|---|
|
||||
| `validator(fn)` | `this` | Set a runtime type guard `(v: unknown) => v is T` |
|
||||
| `required()` | `JsonField<T, true>` | Mark as required |
|
||||
| `optional()` | `JsonField<T, false>` | Mark as optional |
|
||||
| `default(value: T)` | `JsonField<T, true>` | Set a default parsed value (not a JSON string) |
|
||||
|
||||
### `coerce(raw: string): T`
|
||||
|
||||
Trims input and calls `JSON.parse`. If a validator is set and returns `false`, throws a second `CoercionError`. Returns the parsed value cast to `T`.
|
||||
|
||||
**Throws:** `CoercionError` with `targetType = "JSON"` on parse failure; `targetType = "JSON (failed validation)"` when the validator returns `false`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `default()` method takes a typed `T` value (already parsed), not a JSON string. This means the default bypasses `coerce` entirely — the validator is not applied to defaults. If shape validation of defaults is required, apply it at schema definition time.
|
||||
|
||||
The validator function is typed as `(v: unknown) => v is T` (a user-defined type guard). TypeScript does not enforce that the guard actually validates the full shape at compile time — the `is T` annotation is a programmer assertion. Callers should write guards that actually check the runtime structure to avoid false type safety.
|
||||
|
||||
`json()` intentionally leaves the default type parameter as `unknown` to avoid false confidence when no validator is supplied. Using `json()` without a validator returns `unknown` at the type level, which forces callers to add their own type checks before use.
|
||||
82
plugins/document-navigator/docs/foundation/core/env/src/fields/number-field/number-field.md
vendored
Normal file
82
plugins/document-navigator/docs/foundation/core/env/src/fields/number-field/number-field.md
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
# number-field
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`NumberField` handles environment variables that must be interpreted as JavaScript `number` values. It uses `Number()` coercion for maximum compatibility with decimal, integer, and scientific notation strings, then provides optional constraint rules for range checking, integer enforcement, and sign enforcement. Unlike the generic `num()` builder, the `port()` and similar specialised builders delegate to `NumberField` internally, composing it with additional validation logic.
|
||||
|
||||
## Features
|
||||
|
||||
- Coerces raw strings to `number` via `Number()`, rejecting empty strings and `NaN` with `CoercionError`
|
||||
- `min(n)` / `max(n)` — enforce inclusive numeric bounds
|
||||
- `integer()` — require the value to be a whole number (`Number.isInteger`)
|
||||
- `positive()` — require the value to be strictly greater than zero
|
||||
- `nonNegative()` — require the value to be greater than or equal to zero
|
||||
- `optional()` — marks the field as not required; output type becomes `number | undefined`
|
||||
- `default(value)` — sets a fallback number; keeps the output type as `number`
|
||||
- All constraint rules compose additively; add as many as needed
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { num } from '@suaveplan/env';
|
||||
|
||||
const field = num().min(1).max(65535).integer().default(3000);
|
||||
console.log(field.coerce('8080')); // 8080
|
||||
console.log(field.coerce('3.14')); // 3.14 (passes coerce, fails integer rule)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, num } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
PORT: num().integer().min(1024).max(65535).default(3000),
|
||||
TIMEOUT_MS: num().positive().integer().default(5000),
|
||||
SCALE_FACTOR: num().nonNegative().default(1.0),
|
||||
WORKERS: num().integer().positive().default(4),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.PORT: number, env.TIMEOUT_MS: number, etc.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `num(): NumberField<true>`
|
||||
|
||||
Factory function. Returns a new `NumberField` with `_isRequired = true`.
|
||||
|
||||
### `class NumberField<TRequired extends boolean = true>`
|
||||
|
||||
Extends `BaseField<number, TRequired>`.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|---|---|---|
|
||||
| `min(n)` | `this` | Add a minimum value rule (inclusive) |
|
||||
| `max(n)` | `this` | Add a maximum value rule (inclusive) |
|
||||
| `integer()` | `this` | Add an integer constraint rule |
|
||||
| `positive()` | `this` | Add a strictly-positive constraint rule |
|
||||
| `nonNegative()` | `this` | Add a non-negative constraint rule |
|
||||
| `required()` | `NumberField<true>` | Mark as required |
|
||||
| `optional()` | `NumberField<false>` | Mark as optional |
|
||||
| `default(value)` | `NumberField<true>` | Set a numeric default |
|
||||
|
||||
### `coerce(raw: string): number`
|
||||
|
||||
Trims the input and runs `Number()`. Throws `CoercionError` if the trimmed string is empty or `Number.isNaN(result)` is true. Accepts decimal, integer, and scientific notation (e.g. `"1e3"` → `1000`).
|
||||
|
||||
**Throws:** `CoercionError` with `targetType = "number"` on empty or non-numeric input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
An explicit empty-string check comes before `Number()` because `Number("")` returns `0`, which would silently accept empty values. The NaN check uses `Number.isNaN` (the global version returns false for non-numbers, but the Number method is preferred for clarity).
|
||||
|
||||
`integer()`, `positive()`, and `nonNegative()` are implemented as ordinary validation rules rather than coercion logic because they run after the value is already a `number`. This means the error messages reference the coerced value and the constraint name, which is more informative than a generic coercion failure message.
|
||||
|
||||
Combining `min`, `max`, `integer`, and `positive` rules is fully additive. The validator stops at the first failing rule per field, so the order of rule addition determines which constraint's error message appears when multiple rules fail.
|
||||
78
plugins/document-navigator/docs/foundation/core/env/src/fields/port-field/port-field.md
vendored
Normal file
78
plugins/document-navigator/docs/foundation/core/env/src/fields/port-field/port-field.md
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
# port-field
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`PortField` validates that an environment variable is a valid TCP/UDP port number — an integer in the range 1–65535. It is a specialised numeric field that combines integer enforcement and range checking directly in `coerce`, providing a single focused field for `PORT`, `SMTP_PORT`, `REDIS_PORT`, and similar network configuration variables. The acceptable range can be narrowed via `min()` and `max()` when only a subset of port numbers is appropriate.
|
||||
|
||||
## Features
|
||||
|
||||
- Coerces raw strings to `number` with NaN and non-integer rejection via `CoercionError`
|
||||
- Default range: 1–65535 (full valid TCP/UDP port range)
|
||||
- `min(n)` / `max(n)` — narrow the acceptable range (e.g. 1024–65535 for unprivileged ports only)
|
||||
- Throws `CoercionError` with a human-readable range in the `targetType` message for out-of-range values
|
||||
- `optional()` / `default(value)` / `sensitive()` inherited from `BaseField`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { port } from '@suaveplan/env';
|
||||
|
||||
const field = port().default(3000);
|
||||
console.log(field.coerce('8080')); // 8080
|
||||
console.log(field.coerce('0')); // throws CoercionError (below range)
|
||||
console.log(field.coerce('65536')); // throws CoercionError (above range)
|
||||
console.log(field.coerce('3.14')); // throws CoercionError (not integer)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, port } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
PORT: port().default(3000),
|
||||
SMTP_PORT: port().min(1).max(65535).default(587),
|
||||
// Restrict to unprivileged ports only
|
||||
HTTP_PORT: port().min(1024).default(8080),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.PORT, env.SMTP_PORT, env.HTTP_PORT are all typed as number
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `port(): PortField<true>`
|
||||
|
||||
Factory function. Returns a new `PortField` with range `[PORT_MIN, PORT_MAX]` (1–65535) and `_isRequired = true`.
|
||||
|
||||
### `class PortField<TRequired extends boolean = true>`
|
||||
|
||||
Extends `BaseField<number, TRequired>`.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|---|---|---|
|
||||
| `min(n)` | `this` | Set the minimum allowed port number |
|
||||
| `max(n)` | `this` | Set the maximum allowed port number |
|
||||
| `required()` | `PortField<true>` | Mark as required |
|
||||
| `optional()` | `PortField<false>` | Mark as optional |
|
||||
| `default(value)` | `PortField<true>` | Set a numeric default port |
|
||||
|
||||
### `coerce(raw: string): number`
|
||||
|
||||
Trims the input, runs `Number()`, checks `Number.isNaN` and `Number.isInteger`, then verifies the value is within `[_min, _max]`.
|
||||
|
||||
**Throws:** `CoercionError` with `targetType = "port number"` for NaN or non-integer input, and `targetType = "port number (MIN–MAX)"` for out-of-range values.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Range validation occurs in `coerce` rather than as a post-coercion rule (unlike `NumberField.min/max`). This is an intentional design choice: port validity is definitional, not a secondary constraint. The error message includes the actual range bounds so the user sees `"port number (1–65535)"` rather than a generic failure.
|
||||
|
||||
`PORT_MIN` and `PORT_MAX` are defined in `constants/index.ts` as `1` and `65535`. System ports (0) are excluded — port 0 is a wildcard in socket APIs and would be confusing as a static configuration value.
|
||||
|
||||
The `min()` and `max()` methods mutate `_min` and `_max` directly rather than appending rules, keeping the error message consistent (it always shows the actual current bounds). If rules were used instead, adding a `min()` and `max()` would produce two separate rule messages that could conflict with the base range check.
|
||||
97
plugins/document-navigator/docs/foundation/core/env/src/fields/string-field/string-field.md
vendored
Normal file
97
plugins/document-navigator/docs/foundation/core/env/src/fields/string-field/string-field.md
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
# string-field
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`StringField` is the most general-purpose field builder in `@suaveplan/env`, handling any environment variable whose value is a plain string. It extends `BaseField<string>` and adds optional whitespace trimming, case transformation, length constraints, regex pattern matching, and substring checks — all composable via a fluent builder chain. The raw environment string passes through `coerce` with no conversion beyond the configured transformations; all structural validation is done via rules applied after coercion.
|
||||
|
||||
## Features
|
||||
|
||||
- `trim()` — strip leading and trailing whitespace before validation
|
||||
- `lowercase()` / `uppercase()` — normalise case before returning the value
|
||||
- `minLength(n)` / `maxLength(n)` — enforce character count bounds
|
||||
- `pattern(regex, message?)` — require the value to match a regular expression
|
||||
- `nonempty()` — shorthand for `minLength(1)` with a clear error message
|
||||
- `startsWith(prefix)` / `endsWith(suffix)` / `includes(substring)` — substring constraint rules
|
||||
- `optional()` — marks the field as not required; output type becomes `string | undefined`
|
||||
- `default(value)` — sets a fallback string; keeps the output type as `string`
|
||||
- `sensitive()` — inherited from `BaseField`; redacts value in logs via `maskSensitive`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { str } from '@suaveplan/env';
|
||||
|
||||
const field = str().trim().default('localhost');
|
||||
|
||||
console.log(field.coerce(' example.com ')); // "example.com"
|
||||
console.log(field._defaultValue); // "localhost"
|
||||
console.log(field._hasDefault); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, str } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
APP_NAME: str()
|
||||
.trim()
|
||||
.minLength(1)
|
||||
.maxLength(64)
|
||||
.pattern(/^[a-z][a-z0-9-]*$/, 'Must be a lowercase slug')
|
||||
.describe('Application name'),
|
||||
API_KEY: str()
|
||||
.nonempty()
|
||||
.sensitive()
|
||||
.describe('API authentication key'),
|
||||
REGION: str()
|
||||
.uppercase()
|
||||
.default('US')
|
||||
.describe('Deployment region code'),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.APP_NAME is string, env.API_KEY is string, env.REGION is string
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `str(): StringField<true>`
|
||||
|
||||
Factory function. Returns a new `StringField` instance with `_isRequired = true`.
|
||||
|
||||
### `class StringField<TRequired extends boolean = true>`
|
||||
|
||||
Extends `BaseField<string, TRequired>`.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|---|---|---|
|
||||
| `trim()` | `this` | Trim whitespace during coercion |
|
||||
| `lowercase()` | `this` | Lowercase the value during coercion |
|
||||
| `uppercase()` | `this` | Uppercase the value during coercion |
|
||||
| `minLength(n)` | `this` | Add a min-length validation rule |
|
||||
| `maxLength(n)` | `this` | Add a max-length validation rule |
|
||||
| `pattern(regex, msg?)` | `this` | Add a regex match rule |
|
||||
| `nonempty()` | `this` | Add a non-empty rule |
|
||||
| `startsWith(prefix)` | `this` | Add a startsWith rule |
|
||||
| `endsWith(suffix)` | `this` | Add an endsWith rule |
|
||||
| `includes(substring)` | `this` | Add an includes rule |
|
||||
| `required()` | `StringField<true>` | Mark as required (returns new type) |
|
||||
| `optional()` | `StringField<false>` | Mark as optional (returns new type) |
|
||||
| `default(value)` | `StringField<true>` | Set a default value |
|
||||
| `coerce(raw)` | `string` | Apply transformations and return the value |
|
||||
|
||||
### `coerce(raw: string): string`
|
||||
|
||||
Applies configured transformations in order: trim (if enabled) → lowercase (if enabled) → uppercase (if enabled). Does not throw — string coercion cannot fail.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Transformations (`trim`, `lowercase`, `uppercase`) occur inside `coerce` before any rules run. This means a `minLength` rule counts characters after transformation, which is the intuitive behaviour: `str().trim().minLength(1).coerce(' ')` fails because the trimmed value is empty.
|
||||
|
||||
The `includes`, `startsWith`, and `endsWith` rules all use the native `String` methods of the same names. They operate on the post-coercion value, so they respect the trim/case transformations. Rules are appended to `_rules` and run by the validator in insertion order; the first failing rule produces the error message.
|
||||
89
plugins/document-navigator/docs/foundation/core/env/src/fields/url-field/url-field.md
vendored
Normal file
89
plugins/document-navigator/docs/foundation/core/env/src/fields/url-field/url-field.md
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
# url-field
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`UrlField` validates that an environment variable contains a well-formed URL by delegating to the platform's native `URL` constructor. Using the native parser ensures complete RFC 3986 compliance without any third-party dependency and handles edge cases like percent-encoding, IPv6 literals, and IDN hostnames correctly. It is the right field to use for `DATABASE_URL`, `REDIS_URL`, `API_BASE_URL`, `WEBHOOK_ENDPOINT`, and any other connection or service URL.
|
||||
|
||||
## Features
|
||||
|
||||
- Validates URL structure via the native `URL` constructor; throws `CoercionError` on parse failure
|
||||
- `protocols(list)` — restrict the URL to a specific allowlist of protocols (e.g. `['https']`)
|
||||
- `noTrailingSlash()` — strip a trailing slash from the resolved URL string before returning
|
||||
- Returns the `URL.href` normalised string (e.g. adds trailing slash to bare origins per the WHATWG URL spec)
|
||||
- `optional()` / `default(value)` / `sensitive()` inherited from `BaseField`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { url } from '@suaveplan/env';
|
||||
|
||||
const field = url();
|
||||
console.log(field.coerce('https://example.com/path')); // "https://example.com/path"
|
||||
console.log(field.coerce('not a url')); // throws CoercionError
|
||||
|
||||
const httpsOnly = url().protocols(['https']);
|
||||
console.log(httpsOnly.coerce('http://example.com')); // throws CoercionError
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, url } from '@suaveplan/env';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
API_BASE_URL: url()
|
||||
.protocols(['https'])
|
||||
.noTrailingSlash()
|
||||
.describe('Base URL for the external API'),
|
||||
DATABASE_URL: url()
|
||||
.protocols(['postgres', 'postgresql'])
|
||||
.sensitive()
|
||||
.describe('Primary database connection URL'),
|
||||
REDIS_URL: url()
|
||||
.protocols(['redis', 'rediss'])
|
||||
.sensitive()
|
||||
.optional(),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.API_BASE_URL: string (no trailing slash, https only)
|
||||
// env.DATABASE_URL: string (sensitive, postgres only)
|
||||
// env.REDIS_URL: string | undefined
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `url(): UrlField<true>`
|
||||
|
||||
Factory function. Returns a new `UrlField` with no protocol restriction and `_isRequired = true`.
|
||||
|
||||
### `class UrlField<TRequired extends boolean = true>`
|
||||
|
||||
Extends `BaseField<string, TRequired>`.
|
||||
|
||||
| Method | Returns | Description |
|
||||
|---|---|---|
|
||||
| `protocols(list)` | `this` | Restrict to an allowlist of protocols |
|
||||
| `noTrailingSlash()` | `this` | Remove a trailing slash from the result |
|
||||
| `required()` | `UrlField<true>` | Mark as required |
|
||||
| `optional()` | `UrlField<false>` | Mark as optional |
|
||||
| `default(value)` | `UrlField<true>` | Set a default URL string |
|
||||
|
||||
### `coerce(raw: string): string`
|
||||
|
||||
Trims the input and passes it to `new URL()`. If `protocols()` was called, checks `parsed.protocol` against the allowlist. If `noTrailingSlash()` was set and `parsed.href` ends with `"/"`, removes the trailing character.
|
||||
|
||||
**Throws:** `CoercionError` with `targetType = "URL"` on parse failure; `targetType = "URL with protocol X or Y"` when the protocol is not in the allowlist.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`protocols()` normalises each entry by appending `":"` if absent (`"https"` → `"https:"`), because `URL.protocol` always includes the colon. This means callers can pass either `"https"` or `"https:"` and get consistent behaviour.
|
||||
|
||||
`noTrailingSlash()` strips the trailing character unconditionally when it ends with `"/"`. There is no special case for the `"://"` separator — by the time we check for a trailing slash, the URL is already parsed and `href` only ends with `"/"` when the path itself ends with one (e.g. bare origins like `"https://example.com/"` or paths like `"https://example.com/api/"`). The WHATWG URL parser always adds a trailing slash to bare origins, so `noTrailingSlash()` is the correct way to get `"https://example.com"` from `"https://example.com"`.
|
||||
|
||||
The returned value is `parsed.href` (the normalised canonical string), not the original trimmed input. This means URLs are normalised — percent-encoding is resolved, IDN hostnames may be punycode-converted, etc. — which is generally the desired behaviour for configuration values.
|
||||
98
plugins/document-navigator/docs/foundation/core/env/src/loaders/dotenv/dotenv.md
vendored
Normal file
98
plugins/document-navigator/docs/foundation/core/env/src/loaders/dotenv/dotenv.md
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
# dotenv
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides two exports for working with `.env` files: `parseDotenv`, a pure function that converts dotenv-format content strings into key-value records, and `dotenvLoader`, a Node.js loader that reads a file path and wraps `parseDotenv`. Separating IO from parsing allows the parser to be tested exhaustively without touching the filesystem and reused in contexts where the content arrives from a non-filesystem source (network, database, secrets manager).
|
||||
|
||||
## Features
|
||||
|
||||
- `parseDotenv(content)` — pure, synchronous, no IO; fully deterministic for the same input
|
||||
- Handles double-quoted values with escape sequences: `\n`, `\r`, `\t`, `\"`, `\\`
|
||||
- Handles single-quoted values literally (no escape processing)
|
||||
- Strips `export` prefixes from variable declarations
|
||||
- Ignores comment lines (`#`) and blank lines
|
||||
- Strips inline comments from unquoted values (`KEY=value # comment` → `value`)
|
||||
- Handles `CRLF` and `LF` line endings
|
||||
- Supports `${VAR}` and `$VAR` variable interpolation using earlier-defined variables in the same file
|
||||
- Skips lines without `=` and lines with an empty key
|
||||
- `dotenvLoader(options?)` — reads the file via `readFileSync`; returns empty values when the file does not exist
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseDotenv } from '@suaveplan/env/loaders';
|
||||
|
||||
const content = `
|
||||
# Database configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME="my_app"
|
||||
DB_URL=postgres://${'{'}DB_HOST{'}'}:${'{'}DB_PORT{'}'}/DB_NAME
|
||||
`;
|
||||
|
||||
const values = parseDotenv(content);
|
||||
// { DB_HOST: 'localhost', DB_PORT: '5432', DB_NAME: 'my_app', DB_URL: 'postgres://localhost:5432/DB_NAME' }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, str, num } from '@suaveplan/env';
|
||||
import { dotenvLoader } from '@suaveplan/env/loaders';
|
||||
import { objectLoader } from '@suaveplan/env/loaders';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
DB_URL: str().sensitive(),
|
||||
PORT: num().default(3000),
|
||||
},
|
||||
loaders: [
|
||||
// Read from .env.local first (developer overrides)
|
||||
dotenvLoader({ path: '.env.local', encoding: 'utf8' }),
|
||||
// Then .env (committed defaults)
|
||||
dotenvLoader({ path: '.env' }),
|
||||
// process.env takes final priority (set via objectLoader or source)
|
||||
objectLoader({ source: process.env }),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseDotenv(content: string): Record<string, string>`
|
||||
|
||||
Parses a dotenv-format string and returns a plain object mapping variable names to string values.
|
||||
|
||||
- Processes lines in order; later definitions override earlier ones
|
||||
- Supports `${VAR}` and `$VAR` interpolation against variables defined earlier in the same content string
|
||||
- Skips lines without `=`, lines with an empty key (`=value`), blank lines, and `#` comment lines
|
||||
- Strips `export` prefix from variable names
|
||||
|
||||
### `dotenvLoader(options?: DotenvLoaderOptions): Loader`
|
||||
|
||||
Returns a `Loader` instance backed by the given file path.
|
||||
|
||||
**`DotenvLoaderOptions`:**
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `path` | `string` | `".env"` | Path to the dotenv file |
|
||||
| `encoding` | `BufferEncoding` | `"utf8"` | File encoding for `readFileSync` |
|
||||
|
||||
**`Loader` properties:**
|
||||
|
||||
- `name` — the file path (used in diagnostics)
|
||||
- `load()` — returns `{ values: Record<string, string>, source: string }`. Returns `{ values: {}, source: path }` when the file does not exist (ENOENT is silently swallowed; other errors propagate).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This file intentionally imports `node:fs` (`readFileSync`). It is the only module in the package permitted to use Node.js built-ins — all other modules are runtime-environment agnostic. The `no-disallowed` cross-cutting test explicitly allowlists this file and would fail if any other module imported `node:fs`.
|
||||
|
||||
`parseDotenv` builds an interpolation context incrementally as it parses each line, so `${EARLIER}` tokens are resolved against variables seen earlier in the same file but `${LATER}` tokens remain unresolved. This matches the behaviour of dotenv implementations in other ecosystems.
|
||||
|
||||
Double-quoted escape sequences are processed with a `replace` call using a character-by-character regex: `\\n` → newline, `\\r` → carriage return, `\\t` → tab, `\\"` → double quote, `\\\\` → backslash. Unrecognised escape sequences are passed through unchanged.
|
||||
|
||||
Single-quoted values are returned literally — no escape processing occurs — consistent with POSIX shell single-quoting semantics.
|
||||
76
plugins/document-navigator/docs/foundation/core/env/src/loaders/object/object.md
vendored
Normal file
76
plugins/document-navigator/docs/foundation/core/env/src/loaders/object/object.md
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# object (loader)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`objectLoader` wraps a plain JavaScript object — most commonly `process.env` — in the `Loader` interface so it can participate in the ordered loader chain passed to `createEnv`. It filters out entries whose value is `undefined` (which `process.env` produces for variables that are set but empty vs. absent), converting the rest into a clean `Record<string, string>` that the validator can process without null-checking.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts any `Record<string, string | undefined>` as the source, including `process.env`
|
||||
- Filters out `undefined` values — only string-valued entries are included in the result
|
||||
- Optional `sourceName` — overrides the `name` and `source` fields for diagnostic messages
|
||||
- Synchronous and allocation-cheap: iterates the source entries once per `load()` call
|
||||
- Returns `{ values, source }` conforming to the `LoaderResult` interface
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { objectLoader } from '@suaveplan/env/loaders';
|
||||
|
||||
const loader = objectLoader({ source: process.env, sourceName: 'process.env' });
|
||||
const { values } = loader.load();
|
||||
|
||||
// values contains only string-valued entries from process.env
|
||||
console.log(values.PATH); // e.g. "/usr/local/bin:/usr/bin:/bin"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, str, num } from '@suaveplan/env';
|
||||
import { objectLoader } from '@suaveplan/env/loaders';
|
||||
import { dotenvLoader } from '@suaveplan/env/loaders';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
HOST: str().default('localhost'),
|
||||
PORT: num().default(3000),
|
||||
},
|
||||
loaders: [
|
||||
// Load defaults from .env file
|
||||
dotenvLoader({ path: '.env' }),
|
||||
// Override with the actual process environment (highest priority)
|
||||
objectLoader({
|
||||
source: process.env,
|
||||
sourceName: 'process.env',
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `objectLoader(options: ObjectLoaderOptions): Loader`
|
||||
|
||||
**`ObjectLoaderOptions`:**
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `source` | `Record<string, string \| undefined>` | required | The object to read values from |
|
||||
| `sourceName` | `string?` | `"object"` | Human-readable source name for diagnostics |
|
||||
|
||||
**Returns:** A `Loader` with:
|
||||
|
||||
- `name` — `options.sourceName ?? "object"`
|
||||
- `load()` — returns `{ values: Record<string, string>, source: name }` with `undefined` entries excluded
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `undefined` filter exists because `process.env` on Node.js represents the distinction between `KEY=` (set, empty string) and an absent variable as `""` vs. `undefined`. The loader preserves empty-string values — which is intentional — while dropping `undefined` entries, so the downstream validator can apply required-field checks correctly.
|
||||
|
||||
`load()` creates a new object on every call by iterating `Object.entries(options.source)`. This is intentional: `process.env` can change between calls (via child process inheritance or test-environment manipulation), and a stale snapshot would give incorrect results. For performance-critical hot paths where `load()` is called frequently, consider capturing the result once at startup via `createEnv`.
|
||||
|
||||
The `sourceName` defaults to `"object"` — a deliberately generic string. Callers should always supply a meaningful name (e.g. `"process.env"`, `"test-overrides"`) to make diagnostic messages from `createEnv`'s `warn` mode legible.
|
||||
89
plugins/document-navigator/docs/foundation/core/env/src/presets/common/common.md
vendored
Normal file
89
plugins/document-navigator/docs/foundation/core/env/src/presets/common/common.md
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
# common (presets)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides pre-configured field factory functions and a composite `commonFields` bundle for the environment variables that appear in virtually every server application. Using these factories instead of re-declaring fields from scratch ensures consistent defaults, constraints, and descriptions across all services in the project. They serve as the starting point for a schema before application-specific fields are added.
|
||||
|
||||
## Features
|
||||
|
||||
- `nodeEnvField()` — `NODE_ENV` enum with four allowed values, defaulting to `'development'`
|
||||
- `portField()` — port number field defaulting to `3000`
|
||||
- `hostField()` — string field defaulting to `'0.0.0.0'`
|
||||
- `debugField()` — boolean field defaulting to `false`
|
||||
- `logLevelField()` — log level enum (six standard levels) defaulting to `'info'`
|
||||
- `timezoneField()` — IANA timezone string defaulting to `'UTC'`
|
||||
- `appNameField()` — required application identifier string (1–64 chars)
|
||||
- `appVersionField()` — semver-like version string defaulting to `'0.0.0'`
|
||||
- `workersField()` — positive integer worker count defaulting to `1`
|
||||
- `commonFields` — composite bundle of `NODE_ENV`, `PORT`, `HOST`, `DEBUG`, `LOG_LEVEL`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createEnv } from '@suaveplan/env';
|
||||
import { commonFields } from '@suaveplan/env/presets/common/common';
|
||||
|
||||
// Use the bundle directly for the five most common fields
|
||||
const env = createEnv({
|
||||
schema: commonFields,
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.NODE_ENV: "development" | "production" | "test" | "staging"
|
||||
// env.PORT: number
|
||||
// env.HOST: string
|
||||
// env.DEBUG: boolean
|
||||
// env.LOG_LEVEL: "trace" | "debug" | "info" | "warn" | "error" | "fatal"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, str } from '@suaveplan/env';
|
||||
import {
|
||||
commonFields,
|
||||
appNameField,
|
||||
appVersionField,
|
||||
timezoneField,
|
||||
workersField,
|
||||
} from '@suaveplan/env/presets/common/common';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
// Spread the five-field bundle
|
||||
...commonFields,
|
||||
// Add application-specific fields from the preset library
|
||||
APP_NAME: appNameField(),
|
||||
APP_VERSION: appVersionField(),
|
||||
TZ: timezoneField(),
|
||||
WORKERS: workersField(),
|
||||
// Add custom fields
|
||||
FEATURE_XYZ: str().optional(),
|
||||
},
|
||||
source: process.env,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Field Type | Default | Constraints |
|
||||
|---|---|---|---|
|
||||
| `nodeEnvField()` | `EnumField` | `'development'` | `development \| production \| test \| staging` |
|
||||
| `portField()` | `PortField` | `3000` | `1–65535` |
|
||||
| `hostField()` | `StringField` | `'0.0.0.0'` | none |
|
||||
| `debugField()` | `BooleanField` | `false` | standard truthy/falsy |
|
||||
| `logLevelField()` | `EnumField` | `'info'` | `trace \| debug \| info \| warn \| error \| fatal` |
|
||||
| `timezoneField()` | `StringField` | `'UTC'` | none |
|
||||
| `appNameField()` | `StringField` | required | `minLength(1), maxLength(64)` |
|
||||
| `appVersionField()` | `StringField` | `'0.0.0'` | none |
|
||||
| `workersField()` | `NumberField` | `1` | `integer, positive` |
|
||||
| `commonFields` | composite | — | `NODE_ENV, PORT, HOST, DEBUG, LOG_LEVEL` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Each factory function returns a fresh field builder instance on every call. This is important: `commonFields` is a `const` object whose field values are created at module initialisation time, which means the same instance is shared by all users of `commonFields`. If you need to add rules to a preset field, call the factory directly (`portField().min(1024)`) rather than mutating `commonFields.PORT`, which would affect all consumers of the module-level object.
|
||||
|
||||
All factories call into the same field builder classes (`str`, `num`, `bool`, `port`, `enums`) that are available in the public API, so there is no special treatment — presets are just convenient defaults, not magic.
|
||||
108
plugins/document-navigator/docs/foundation/core/env/src/presets/server/server.md
vendored
Normal file
108
plugins/document-navigator/docs/foundation/core/env/src/presets/server/server.md
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
# server (presets)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides pre-configured field factory functions and a compact `serverFields` bundle for the environment variables commonly required by server-side services — database connections, Redis, SMTP, CORS, session secrets, and rate limiting. All connection credential fields are marked `sensitive()` by default, so they are automatically redacted by `maskSensitive` before values appear in logs. Using these factories ensures consistent naming, protocol restrictions, and security defaults across every service.
|
||||
|
||||
## Features
|
||||
|
||||
- `databaseUrlField()` — `DATABASE_URL` restricted to postgres/mysql/mongodb protocols, sensitive
|
||||
- `redisUrlField()` — `REDIS_URL` restricted to redis/rediss protocols, sensitive
|
||||
- `sessionSecretField()` — `SESSION_SECRET` requiring at least 32 characters, sensitive
|
||||
- `corsOriginField()` — `CORS_ORIGIN` string defaulting to `'*'`
|
||||
- `smtpHostField()` / `smtpPortField()` — mail server host and port (default 587)
|
||||
- `smtpUserField()` / `smtpPassField()` — SMTP credentials, both sensitive
|
||||
- `smtpFromField()` — email field for the default sender address
|
||||
- `rateLimitMaxField()` — positive integer defaulting to `100`
|
||||
- `rateLimitWindowField()` — positive integer (milliseconds window) defaulting to `60`
|
||||
- `trustProxyField()` — boolean defaulting to `false`
|
||||
- `serverFields` — compact composite bundle of five core server fields
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createEnv } from '@suaveplan/env';
|
||||
import { serverFields } from '@suaveplan/env/presets/server/server';
|
||||
|
||||
const env = createEnv({
|
||||
schema: serverFields,
|
||||
source: process.env,
|
||||
});
|
||||
|
||||
// env.DATABASE_URL: string (sensitive — redacted in logs)
|
||||
// env.CORS_ORIGIN: string
|
||||
// env.TRUST_PROXY: boolean
|
||||
// env.RATE_LIMIT_MAX: number
|
||||
// env.RATE_LIMIT_WINDOW: number
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createEnv, str } from '@suaveplan/env';
|
||||
import { commonFields } from '@suaveplan/env/presets/common/common';
|
||||
import {
|
||||
databaseUrlField,
|
||||
redisUrlField,
|
||||
sessionSecretField,
|
||||
smtpHostField,
|
||||
smtpPortField,
|
||||
smtpUserField,
|
||||
smtpPassField,
|
||||
smtpFromField,
|
||||
corsOriginField,
|
||||
rateLimitMaxField,
|
||||
rateLimitWindowField,
|
||||
trustProxyField,
|
||||
} from '@suaveplan/env/presets/server/server';
|
||||
import { dotenvLoader } from '@suaveplan/env/loaders';
|
||||
|
||||
const env = createEnv({
|
||||
schema: {
|
||||
...commonFields,
|
||||
DATABASE_URL: databaseUrlField(),
|
||||
REDIS_URL: redisUrlField().optional(),
|
||||
SESSION_SECRET: sessionSecretField(),
|
||||
CORS_ORIGIN: corsOriginField(),
|
||||
SMTP_HOST: smtpHostField().optional(),
|
||||
SMTP_PORT: smtpPortField(),
|
||||
SMTP_USER: smtpUserField().optional(),
|
||||
SMTP_PASS: smtpPassField().optional(),
|
||||
SMTP_FROM: smtpFromField().optional(),
|
||||
RATE_LIMIT_MAX: rateLimitMaxField(),
|
||||
RATE_LIMIT_WINDOW: rateLimitWindowField(),
|
||||
TRUST_PROXY: trustProxyField(),
|
||||
},
|
||||
loaders: [dotenvLoader()],
|
||||
source: process.env,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Field Type | Default | Notable constraints |
|
||||
|---|---|---|---|
|
||||
| `databaseUrlField()` | `UrlField` | required | protocols: postgres, postgresql, mysql, mongodb; sensitive |
|
||||
| `redisUrlField()` | `UrlField` | required | protocols: redis, rediss; sensitive |
|
||||
| `sessionSecretField()` | `StringField` | required | `minLength(32)`, sensitive |
|
||||
| `corsOriginField()` | `StringField` | `'*'` | none |
|
||||
| `smtpHostField()` | `StringField` | required | none |
|
||||
| `smtpPortField()` | `NumberField` | `587` | `integer, min 1, max 65535` |
|
||||
| `smtpUserField()` | `StringField` | required | sensitive |
|
||||
| `smtpPassField()` | `StringField` | required | sensitive |
|
||||
| `smtpFromField()` | `EmailField` | required | valid email |
|
||||
| `rateLimitMaxField()` | `NumberField` | `100` | `integer, positive` |
|
||||
| `rateLimitWindowField()` | `NumberField` | `60` | `integer, positive` |
|
||||
| `trustProxyField()` | `BooleanField` | `false` | none |
|
||||
| `serverFields` | composite | — | DATABASE_URL, CORS_ORIGIN, TRUST_PROXY, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
As with the `common` presets, each factory function returns a fresh field builder instance on every call. Mutations (e.g. adding rules via `.addRule()`) on a factory return value do not affect other consumers.
|
||||
|
||||
`serverFields` provides only five fields — the minimal set required by most applications — rather than all twelve. SMTP, session, and Redis fields are omitted from the bundle because they are optional in many services. Import the individual factories and compose a custom schema when the full set is needed.
|
||||
|
||||
`sessionSecretField` enforces `minLength(32)` because shorter secrets are vulnerable to brute-force attacks in HMAC-based session signing. Production deployments should use cryptographically random secrets of at least 64 characters (256 bits of entropy).
|
||||
94
plugins/document-navigator/docs/foundation/core/env/src/schemas/env.schemas.md
vendored
Normal file
94
plugins/document-navigator/docs/foundation/core/env/src/schemas/env.schemas.md
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
# env.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines Zod v4 schemas for the runtime-facing data structures of `@suaveplan/env`. These schemas enable structured validation of external data — API payloads, configuration files, interop boundaries — where the shape of a `FieldConfig`, `ValidationResult`, or `CreateEnvOptions` cannot be guaranteed at the TypeScript type level alone. They also serve as the canonical source of truth for serialisation formats when env configuration is persisted or transmitted.
|
||||
|
||||
## Features
|
||||
|
||||
- `FieldConfigSchema` — validates the runtime snapshot of a field builder's state
|
||||
- `ValidationRuleSchema` — validates the `name` and `message` of a rule object
|
||||
- `EnvErrorValueSchema` — validates a single per-field error value object
|
||||
- `ValidationResultSchema` — validates the full result produced by `validate()`
|
||||
- `EnvResultSchema` — generic record schema for the `createEnv` output
|
||||
- `CreateEnvOptionsSchema` — validates the options shape passed to `createEnv`
|
||||
- `DotenvLoaderOptionsSchema` / `ObjectLoaderOptionsSchema` — validate loader-specific options
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FieldConfigSchema, ValidationResultSchema } from '@suaveplan/env/schemas';
|
||||
|
||||
// Validate an incoming config object from an external source
|
||||
const raw = JSON.parse(externalJson);
|
||||
const result = FieldConfigSchema.safeParse(raw);
|
||||
if (result.success) {
|
||||
console.log('Valid field config:', result.data);
|
||||
} else {
|
||||
console.error('Invalid:', result.error.issues);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CreateEnvOptionsSchema, ValidationResultSchema } from '@suaveplan/env/schemas';
|
||||
|
||||
// Validate a serialised env options payload received over HTTP
|
||||
async function handleEnvConfig(body: unknown) {
|
||||
const parsed = CreateEnvOptionsSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return { status: 400, error: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
// Validate a stored validation result
|
||||
const storedResult = await db.get('last-env-validation');
|
||||
const resultParsed = ValidationResultSchema.safeParse(storedResult);
|
||||
return { status: 200, valid: resultParsed.success };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ValidationRuleSchema`
|
||||
|
||||
`z.object({ name: z.string(), message: z.string() })`
|
||||
|
||||
### `FieldConfigSchema`
|
||||
|
||||
Validates `{ isRequired, hasDefault, defaultValue?, isSensitive, description?, rules }`.
|
||||
|
||||
### `EnvErrorValueSchema`
|
||||
|
||||
Validates `{ key: string, message: string, value?: string }`.
|
||||
|
||||
### `ValidationResultSchema`
|
||||
|
||||
Validates `{ success: boolean, data: Record<string, unknown>, errors: EnvErrorValueSchema[] }`.
|
||||
|
||||
### `EnvResultSchema`
|
||||
|
||||
`z.record(z.string(), z.unknown())` — accepts any string-keyed record.
|
||||
|
||||
### `CreateEnvOptionsSchema`
|
||||
|
||||
Validates the optional fields of `CreateEnvOptions`: `source?`, `prefix?`, `onError?` (enum of `"throw" | "collect" | "warn"`). Does not validate `schema` or `loaders` (not serialisable to Zod schemas).
|
||||
|
||||
### `DotenvLoaderOptionsSchema`
|
||||
|
||||
Validates `{ content: string, sourceName?: string }`.
|
||||
|
||||
### `ObjectLoaderOptionsSchema`
|
||||
|
||||
Validates `{ source: Record<string, string?>, sourceName?: string }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All schemas use Zod v4's two-argument `z.record(z.string(), z.unknown())` form — Zod v4 requires the key schema to be explicit. Using the single-argument form (`z.record(z.unknown())`) that worked in Zod v3 throws a runtime error in v4.
|
||||
|
||||
`CreateEnvOptionsSchema` deliberately omits the `schema` property because Zod schemas for Zod schemas would be circular. The partial schema is still useful for validating the serialisable subset of options (source object, prefix, error mode) when options are transmitted as JSON.
|
||||
|
||||
`defaultValue` in `FieldConfigSchema` uses `z.unknown().optional()` because field defaults can be any coerced value type — string, number, boolean, array, or object. Callers that need stricter validation of `defaultValue` should derive a more specific schema using `.extend()` or `.superRefine()`.
|
||||
80
plugins/document-navigator/docs/foundation/core/env/src/transforms/coerce/coerce.md
vendored
Normal file
80
plugins/document-navigator/docs/foundation/core/env/src/transforms/coerce/coerce.md
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
# coerce
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides standalone coercion functions for the five primitive environment variable types: string, number, integer, boolean, and JSON. These functions are extracted from the field builder classes so they can be used independently in custom loaders, middleware, and testing utilities without instantiating a full field builder. Each function is a simple, pure coerce-or-throw operation with no side effects.
|
||||
|
||||
## Features
|
||||
|
||||
- `coerceString` — trims whitespace, returns the result (never throws)
|
||||
- `coerceNumber` — rejects empty strings and non-numeric input with `CoercionError`
|
||||
- `coerceInteger` — rejects non-integers with `CoercionError`
|
||||
- `coerceBoolean` — maps standard truthy/falsy tokens using `TRUTHY_VALUES` / `FALSY_VALUES` constants
|
||||
- `coerceJson` — parses with `JSON.parse`, wraps syntax errors in `CoercionError`
|
||||
- `coerceArray<T>(itemCoerce, separator?)` — factory that returns a splitting coercion function
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
coerceString,
|
||||
coerceNumber,
|
||||
coerceBoolean,
|
||||
} from '@suaveplan/env/transforms/coerce/coerce';
|
||||
|
||||
console.log(coerceString(' hello ')); // "hello"
|
||||
console.log(coerceNumber('3.14')); // 3.14
|
||||
console.log(coerceBoolean('yes')); // true
|
||||
console.log(coerceBoolean('0')); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { coerceArray, coerceNumber, coerceJson } from '@suaveplan/env/transforms/coerce/coerce';
|
||||
|
||||
// Split comma-separated integers
|
||||
const parseInts = coerceArray(coerceNumber, ',');
|
||||
console.log(parseInts('1,2,3')); // [1, 2, 3]
|
||||
|
||||
// Split semicolon-separated JSON blobs
|
||||
const parseJsonList = coerceArray(coerceJson, ';');
|
||||
console.log(parseJsonList('{"a":1};{"b":2}')); // [{ a: 1 }, { b: 2 }]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `coerceString: CoerceFn<string>`
|
||||
|
||||
`(raw: string) => string.trim()`. Never throws.
|
||||
|
||||
### `coerceNumber: CoerceFn<number>`
|
||||
|
||||
`(raw: string) => number`. Throws `CoercionError` if the trimmed string is empty or `Number.isNaN(Number(trimmed))`.
|
||||
|
||||
### `coerceInteger: CoerceFn<number>`
|
||||
|
||||
`(raw: string) => number`. Throws `CoercionError` if the value is NaN or not an integer (`Number.isInteger` check).
|
||||
|
||||
### `coerceBoolean: CoerceFn<boolean>`
|
||||
|
||||
`(raw: string) => boolean`. Lowercases and trims the input. Checks `TRUTHY_VALUES.includes()` then `FALSY_VALUES.includes()`. Throws `CoercionError` with `targetType = "boolean"` on mismatch.
|
||||
|
||||
### `coerceJson: CoerceFn<unknown>`
|
||||
|
||||
`(raw: string) => unknown`. Wraps `JSON.parse`; any thrown `SyntaxError` becomes a `CoercionError` with `targetType = "JSON"`.
|
||||
|
||||
### `coerceArray<T>(itemCoerce: CoerceFn<T>, separator?: string): CoerceFn<T[]>`
|
||||
|
||||
Factory. `separator` defaults to `","`. The returned function splits on the separator, trims each part, filters empty strings, and maps through `itemCoerce`. Returns `[]` for blank input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`coerceBoolean` uses `TRUTHY_VALUES.includes(lower)` and `FALSY_VALUES.includes(lower)` on the imported constant arrays (both `readonly string[]`) rather than a `Set`. This differs from `BooleanField.coerce`, which stores its values in `Set` instances to allow overrides via `truthy()`/`falsy()`. The standalone function always uses the canonical constant arrays, making it suitable for contexts where custom token overrides are not needed.
|
||||
|
||||
`coerceArray` returns `[]` for blank input to match the `ArrayField` behaviour — a blank value means zero items, not a parse error. Callers that need to enforce non-empty arrays should add a post-coercion check or use `ArrayField.minItems(1)`.
|
||||
|
||||
The `CoerceFn<T>` type alias is defined in `coerce.types.ts` as `(raw: string) => T`.
|
||||
112
plugins/document-navigator/docs/foundation/core/env/src/types/common.md
vendored
Normal file
112
plugins/document-navigator/docs/foundation/core/env/src/types/common.md
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
# common (types)
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the shared TypeScript interfaces and utility types that form the structural contract of `@suaveplan/env`. Every other module in the package either imports from this file or is expressed in terms of the types it declares. Keeping all shared types in a single file prevents circular import issues and provides a single source of truth for the public type surface.
|
||||
|
||||
## Features
|
||||
|
||||
- `ValidationRule<T>` — the rule interface used by all field builders; uses method syntax for bivariant `validate`
|
||||
- `FieldConfig<T>` — readonly snapshot of a field builder's state at a point in time
|
||||
- `LoaderResult` / `Loader` — the loader protocol: a named object with a `load()` method returning string key-value pairs
|
||||
- `ErrorMode` — the three-way union controlling how `createEnv` surfaces failures
|
||||
- `EnvError` — plain value-object interface for a per-field error entry (not a class)
|
||||
- `AnyField<T, R>` — the structural interface that all field builder classes satisfy; enables heterogeneous schema records
|
||||
- `EnvSchema` — `Record<string, AnyField>` alias for schema type parameters
|
||||
- `InferFieldType<F>`, `RequiredKeys<S>`, `OptionalKeys<S>` — building blocks for the output inference
|
||||
- `InferEnv<S>` — the main mapped type that converts a schema into its fully-typed output record
|
||||
- `CreateEnvOptions<S>` — options bag for `createEnv`
|
||||
- `Simplify<T>` — flattens intersection types into readable single-object shapes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
AnyField,
|
||||
EnvSchema,
|
||||
InferEnv,
|
||||
Loader,
|
||||
LoaderResult,
|
||||
} from '@suaveplan/env';
|
||||
|
||||
// Describe a custom loader
|
||||
const myLoader: Loader = {
|
||||
name: 'custom',
|
||||
load(): LoaderResult {
|
||||
return { values: { HOST: 'example.com' }, source: 'custom' };
|
||||
},
|
||||
};
|
||||
|
||||
// Use InferEnv to type a helper function
|
||||
function readEnv<S extends EnvSchema>(schema: S, values: Record<string, string>): InferEnv<S> {
|
||||
// ... implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { AnyField, InferFieldType, RequiredKeys, OptionalKeys, InferEnv } from '@suaveplan/env';
|
||||
import { str, num } from '@suaveplan/env';
|
||||
|
||||
const schema = {
|
||||
HOST: str().default('localhost'), // AnyField<string, true>
|
||||
PORT: num().default(3000), // AnyField<number, true>
|
||||
EXTRA: str().optional(), // AnyField<string, false>
|
||||
};
|
||||
|
||||
// TypeScript computes:
|
||||
// RequiredKeys<typeof schema> = 'HOST' | 'PORT'
|
||||
// OptionalKeys<typeof schema> = 'EXTRA'
|
||||
// InferEnv<typeof schema> = { HOST: string; PORT: number; EXTRA?: string }
|
||||
type Env = InferEnv<typeof schema>;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ValidationRule<T>`
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `name` | `string` | Machine-readable rule identifier |
|
||||
| `message` | `string` | Error message shown when the rule fails |
|
||||
| `validate(value: T)` | `boolean` | Returns `true` if the value passes |
|
||||
|
||||
### `FieldConfig<T>`
|
||||
|
||||
Readonly snapshot of a field builder's state: `isRequired`, `hasDefault`, `defaultValue?`, `isSensitive`, `description?`, `rules`.
|
||||
|
||||
### `Loader` / `LoaderResult`
|
||||
|
||||
`Loader`: `{ name: string; load(): LoaderResult }`. `LoaderResult`: `{ values: Record<string, string>; source: string }`.
|
||||
|
||||
### `ErrorMode`
|
||||
|
||||
`"throw" | "collect" | "warn"` — controls `createEnv` error handling.
|
||||
|
||||
### `EnvError`
|
||||
|
||||
`{ key: string; message: string; value?: string }` — plain value object for per-field error accumulation.
|
||||
|
||||
### `AnyField<T, R>`
|
||||
|
||||
Structural interface every field class satisfies. Contains `_isRequired`, `_hasDefault`, `_defaultValue?`, `_isSensitive`, `_description?`, `_rules`, and `coerce(raw: string): T`.
|
||||
|
||||
### `InferEnv<S>`
|
||||
|
||||
Mapped type: required keys (`AnyField<T, true>`) produce non-optional output properties; optional keys (`AnyField<T, false>`) produce `?: T` properties. Flattened via `Simplify<T>`.
|
||||
|
||||
### `CreateEnvOptions<S>`
|
||||
|
||||
Options for `createEnv`: `schema`, `source?`, `loaders?`, `prefix?`, `onError?`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`ValidationRule<T>` uses method syntax (`validate(value: T): boolean`) rather than a property function type to enable bivariance on `T`. This allows `StringField` (rules over `string`) to be assignable to `AnyField<unknown>` without an explicit variance annotation.
|
||||
|
||||
`Simplify<T>` is defined as `{ [K in keyof T]: T[K] } & object`. The `& object` intersection prevents the type from expanding to `string | number` when `T` is a primitive-union intersection, ensuring IDE tooltips display the concrete shape.
|
||||
|
||||
`RequiredKeys` and `OptionalKeys` use `S[K] extends AnyField<unknown, true>` conditional checks. Because `AnyField` uses method syntax for `validate`, this extends-check works correctly across all concrete field classes despite different `T` parameters.
|
||||
80
plugins/document-navigator/docs/foundation/core/env/src/utils/interpolation.md
vendored
Normal file
80
plugins/document-navigator/docs/foundation/core/env/src/utils/interpolation.md
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
# interpolation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements `${VAR}` and `$VAR` token interpolation for use inside the dotenv parser. When a dotenv file references an earlier variable in a value string, the `interpolate` function substitutes the variable's resolved value. The three exported functions — `interpolate`, `hasInterpolation`, `extractVariables` — can also be used independently in custom loaders or configuration preprocessors that need to resolve variable references in arbitrary strings.
|
||||
|
||||
## Features
|
||||
|
||||
- `interpolate(value, lookup, defaultValue?)` — replaces all `${VAR}` and `$VAR` tokens using a lookup map; uses `defaultValue` (default `""`) for absent variables
|
||||
- `hasInterpolation(value)` — fast predicate returning `true` if the string contains any interpolation token
|
||||
- `extractVariables(value)` — returns a de-duplicated array of all variable names referenced in the string
|
||||
- Uses a single shared `INTERPOLATION_PATTERN` constant from `constants/index.ts` for consistent regex behaviour
|
||||
- Pure functions — no IO, no side effects, fully deterministic
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { interpolate, hasInterpolation } from '@suaveplan/env/utils/interpolation';
|
||||
|
||||
const lookup = { HOST: 'example.com', PORT: '3000' };
|
||||
|
||||
console.log(interpolate('${HOST}:${PORT}/api', lookup)); // "example.com:3000/api"
|
||||
console.log(interpolate('$HOST:$PORT/api', lookup)); // "example.com:3000/api"
|
||||
console.log(interpolate('static-value', lookup)); // "static-value"
|
||||
|
||||
console.log(hasInterpolation('hello ${WORLD}')); // true
|
||||
console.log(hasInterpolation('no variables here')); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { interpolate, extractVariables } from '@suaveplan/env/utils/interpolation';
|
||||
|
||||
const content = 'postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_NAME}';
|
||||
|
||||
// Check which variables are needed before resolving
|
||||
const needed = extractVariables(content);
|
||||
console.log(needed); // ["DB_USER", "DB_PASS", "DB_HOST", "DB_NAME"]
|
||||
|
||||
// Resolve only if all needed variables are available
|
||||
const lookup = { DB_USER: 'admin', DB_PASS: 'secret', DB_HOST: 'localhost', DB_NAME: 'app' };
|
||||
const allPresent = needed.every((v) => v in lookup);
|
||||
|
||||
if (allPresent) {
|
||||
console.log(interpolate(content, lookup));
|
||||
// "postgres://admin:secret@localhost/app"
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interpolate(value, lookup, defaultValue?): string`
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `value` | `string` | required | Input string potentially containing `${VAR}` or `$VAR` tokens |
|
||||
| `lookup` | `Record<string, string \| undefined>` | required | Map of variable names to their resolved values |
|
||||
| `defaultValue` | `string` | `""` | Substituted when a referenced variable is absent from `lookup` |
|
||||
|
||||
Returns the string with all tokens replaced. Non-token substrings are passed through unchanged.
|
||||
|
||||
### `hasInterpolation(value: string): boolean`
|
||||
|
||||
Returns `true` if `value` contains at least one `${VAR}` or `$VAR` token. Uses `RegExp.test()` for efficiency.
|
||||
|
||||
### `extractVariables(value: string): string[]`
|
||||
|
||||
Returns an array of unique variable names referenced in `value`, in order of first appearance. Duplicate references to the same variable appear only once.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`INTERPOLATION_PATTERN` is a single regex defined in `constants/index.ts`. All three functions construct a fresh `/global` copy of the pattern via `new RegExp(INTERPOLATION_PATTERN.source, "g")` on each call. This avoids the `lastIndex` state-sharing problem that arises when a stateful global regex is used across multiple calls — reusing the same instance would cause every other call to start matching from a non-zero offset.
|
||||
|
||||
The regex has two capture groups: group 1 matches the variable name inside `${...}` and group 2 matches a bare `$VARNAME`. Exactly one of these groups is always non-undefined for any match, so the `(braced ?? bare) as string` cast is safe and avoids dead code from a conditional branch that would never be reached.
|
||||
|
||||
`extractVariables` uses a `Set<string>` internally to de-duplicate variable names before converting to an array, preserving insertion order for consistent output.
|
||||
113
plugins/document-navigator/docs/foundation/core/error/README.md
Normal file
113
plugins/document-navigator/docs/foundation/core/error/README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# @suaveplan/error - Foundation error substrate — SuaveplanError base class, stable code registry, cause-chain traversal, Zod-validated wire format (JSON + MessagePack), redaction, OTel span-event adapter, i18n resolver hook. Zero @suaveplan/* deps.
|
||||
|
||||
## Summary
|
||||
|
||||
Foundation error substrate for Suaveplan. Every non-testing `@suaveplan/*` package extends this library's `SuaveplanError` base class and uses its stable code registry. The package provides: `SuaveplanError<TCode>` — an abstract base with literal-typed discriminant codes, injectable clock, context bag, traceId, and cause chaining; `defineCodes(namespace, reasons)` — a frozen, literal-typed registry that makes error codes refactor-safe; `walkCauses(err)` — a cycle-safe, depth-bounded generator for traversing nested error causes; lossless JSON and binary wire formats (`toJSON`/`fromJSON`/`toBinary`/`fromBinary`) with Zod validation; `markSensitive`/`unwrapSensitive` for redaction at serialization boundaries; `toSpanEvent` for OpenTelemetry `exception` span events without an OTel SDK import; and a `setMessageResolver` hook for i18n. Zero `@suaveplan/*` runtime or peer dependencies — this is the lowest-level package in the stack.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [package-error](./src/base/package-error.md) — `ErrorPackageError` is the one concrete `SuaveplanError` subclass shipped by `@suaveplan/error` itself. It carries the…
|
||||
- [suaveplan-error](./src/base/suaveplan-error.md) — `SuaveplanError` is the abstract base class that every non-testing package in the project extends. It standardises the…
|
||||
- [causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [walk-causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [define-codes](./src/codes/define-codes.md) — `defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a nam…
|
||||
- [error-codes](./src/codes/error-codes.md) — `ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundat…
|
||||
- [i18n](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [resolver](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [to-span-event](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [redact](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [sensitive](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [from-binary](./src/serialize/from-binary.md) — `fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatib…
|
||||
- [from-json](./src/serialize/from-json.md) — `fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and r…
|
||||
- [to-binary](./src/serialize/to-binary.md) — `toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, W…
|
||||
- [to-json](./src/serialize/to-json.md) — `toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJS…
|
||||
<!-- end:auto-docs --> - [walk-causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_- [define-codes](./src/codes/define-codes.md) — `defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a nam…
|
||||
- [error-codes](./src/codes/error-codes.md) — `ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundat…
|
||||
- [i18n](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [to-span-event](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [redact](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [sensitive](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [from-binary](./src/serialize/from-binary.md) — `fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatib…
|
||||
- [from-json](./src/serialize/from-json.md) — `fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and r…
|
||||
- [to-binary](./src/serialize/to-binary.md) — `toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, W…
|
||||
- [to-json](./src/serialize/to-json.md) — `toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJS…
|
||||
- [walk-causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_- [define-codes](./src/codes/define-codes.md) — `defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a nam…
|
||||
- [error-codes](./src/codes/error-codes.md) — `ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundat…
|
||||
- [i18n](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [to-span-event](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [redact](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [sensitive](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [from-binary](./src/serialize/from-binary.md) — `fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatib…
|
||||
- [from-json](./src/serialize/from-json.md) — `fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and r…
|
||||
- [to-binary](./src/serialize/to-binary.md) — `toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, W…
|
||||
- [to-json](./src/serialize/to-json.md) — `toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJS…
|
||||
- [walk-causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_- [define-codes](./src/codes/define-codes.md) — `defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a nam…
|
||||
- [error-codes](./src/codes/error-codes.md) — `ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundat…
|
||||
- [i18n](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [resolver](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [to-span-event](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [redact](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [sensitive](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [from-binary](./src/serialize/from-binary.md) — `fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatib…
|
||||
- [from-json](./src/serialize/from-json.md) — `fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and r…
|
||||
- [to-binary](./src/serialize/to-binary.md) — `toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, W…
|
||||
- [to-json](./src/serialize/to-json.md) — `toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJS…
|
||||
- [walk-causes](./src/causes/walk-causes.md) — <!-- begin:auto-docs --> _(no submodules)_- [define-codes](./src/codes/define-codes.md) — `defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a nam…
|
||||
- [error-codes](./src/codes/error-codes.md) — `ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundat…
|
||||
- [i18n](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ - [resolver](./src/i18n/resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_ - [to-span-event](./src/otel/to-span-event.md) — <!-- begin:auto-docs --> _(no submodules)_- [redact](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sensitive](./src/redact/sensitive.md) — <!-- begin:auto-docs --> _(no submodules)_- [from-binary](./src/serialize/from-binary.md) — `fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatib…
|
||||
- [from-json](./src/serialize/from-json.md) — `fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and r…
|
||||
- [to-binary](./src/serialize/to-binary.md) — `toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, W…
|
||||
- [to-json](./src/serialize/to-json.md) — `toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJS…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/error
|
||||
```
|
||||
|
||||
## Highlights
|
||||
|
||||
- **`SuaveplanError<TCode>`** — abstract base with literal-typed code discriminants, injectable clock, context, traceId, cause
|
||||
- **`defineCodes(namespace, reasons)`** — frozen, literal-typed code registry
|
||||
- **`walkCauses(err)`** — cycle-safe, depth-bounded cause-chain generator
|
||||
- **`toJSON`/`fromJSON`/`toBinary`/`fromBinary`** — lossless, Zod-validated wire format; binary form is UTF-8-encoded canonical JSON, no external encoder
|
||||
- **`markSensitive` / `unwrapSensitive`** — redaction tagging applied at the serialization boundary
|
||||
- **`toSpanEvent`** — OpenTelemetry `exception` span event payload, no OTel SDK import
|
||||
- **`setMessageResolver`** — i18n hook; default resolver returns the key verbatim
|
||||
|
||||
## Foundation rules honoured
|
||||
|
||||
- Zero `@suaveplan/*` runtime or peer deps
|
||||
- Runtime deps: `tslib`, `zod`
|
||||
- Zero Node / browser built-in references in `src/` (guarded by `tests/no-disallowed.test.ts`)
|
||||
- `sideEffects: false`, per-module subpath exports
|
||||
- 100% statement / branch / function / line coverage (guarded in `vitest.config.ts`)
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const AUTH_CODES = defineCodes("AUTH", [
|
||||
"LOGIN.INVALID",
|
||||
"LOGIN.LOCKED",
|
||||
] as const);
|
||||
|
||||
export class AuthError extends SuaveplanError<
|
||||
(typeof AUTH_CODES)[keyof typeof AUTH_CODES]
|
||||
> {
|
||||
public constructor(
|
||||
code: (typeof AUTH_CODES)[keyof typeof AUTH_CODES],
|
||||
key: string,
|
||||
options?: ConstructorParameters<typeof SuaveplanError>[2]
|
||||
) {
|
||||
super(code, key, options);
|
||||
}
|
||||
}
|
||||
|
||||
throw new AuthError(AUTH_CODES["LOGIN.INVALID"], "error.auth.login.invalid", {
|
||||
context: { user: "alice" },
|
||||
traceId: "trace-1",
|
||||
});
|
||||
```
|
||||
|
||||
## Wire format
|
||||
|
||||
`toJSON(err)` returns a plain object with `__suaveplanError: 1`, ready for JSON-safe transport. `fromJSON(value)` validates and reconstructs the payload. `toBinary` / `fromBinary` exchange the same content as a UTF-8 byte array.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
bunx turbo lint typecheck test --filter=@suaveplan/error
|
||||
bunx turbo test:coverage --filter=@suaveplan/error
|
||||
```
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# package-error
|
||||
|
||||
## Purpose
|
||||
|
||||
`ErrorPackageError` is the one concrete `SuaveplanError` subclass shipped by `@suaveplan/error` itself. It carries the package-local error codes used by the serializer, binary codec, and cause-chain walker so that failure modes inside this foundation package still follow the same structured shape every other package enforces. It exists only because the base class is abstract; callers outside this package should extend `SuaveplanError` in their own subclass rather than using this one.
|
||||
|
||||
## Features
|
||||
|
||||
- `code` narrowed to `ErrorCode` (`ERROR.WIRE.MALFORMED`, `ERROR.BINARY.*`, `ERROR.CAUSE.*`)
|
||||
- Public constructor that forwards to the `protected` base constructor
|
||||
- Preserves `cause`, `context`, `timestamp`, `traceId` through the base class
|
||||
- Used internally by `fromJSON`, `fromBinary`, and depth-bounded traversal
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
|
||||
|
||||
throw new ErrorPackageError(
|
||||
ERROR_CODES["WIRE.MALFORMED"],
|
||||
"error.error.wire.malformed"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ErrorPackageError, ERROR_CODES, toJSON } from "@suaveplan/error";
|
||||
|
||||
function parse(payload: unknown): unknown {
|
||||
try {
|
||||
return JSON.parse(payload as string);
|
||||
} catch (cause) {
|
||||
throw new ErrorPackageError(
|
||||
ERROR_CODES["WIRE.MALFORMED"],
|
||||
"error.error.wire.malformed",
|
||||
{ cause, context: { payload } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
parse("{not json");
|
||||
} catch (err) {
|
||||
console.error(toJSON(err));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class ErrorPackageError extends SuaveplanError<ErrorCode>`
|
||||
|
||||
- `new ErrorPackageError(code, messageKey, options?)` — `code` is one of the literal strings in `ErrorCode`; `options` matches `SuaveplanErrorOptions`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class exists only to widen the base's `protected` constructor to `public` — a Biome suppression is applied because the constructor body is a pass-through, which Biome flags as useless but is intentional for access-modifier widening. Consumers outside this package should not import `ErrorPackageError` to throw their own failures; they should extend `SuaveplanError` with codes created via `defineCodes` so their own error hierarchy is distinct in `instanceof` checks. The code union `ErrorCode` is derived from the runtime `ERROR_CODES` record, so adding a new reason to the registry automatically widens the class's type without any manual declaration maintenance.
|
||||
@@ -0,0 +1,83 @@
|
||||
# suaveplan-error
|
||||
|
||||
## Purpose
|
||||
|
||||
`SuaveplanError` is the abstract base class that every non-testing package in the project extends. It standardises the shape of a thrown failure across processes, packages, and wire formats: a stable namespaced `code`, an i18n-keyed `message`, an optional structured `context`, a traceable `traceId`, an injectable `timestamp`, and a preserved `cause` chain. By centralising that shape, every downstream library gets structured errors for free and every external tool (logger, tracer, transport) can read them without package-specific adapters.
|
||||
|
||||
## Features
|
||||
|
||||
- Abstract class — instantiable only through a subclass
|
||||
- Generic `TCode` parameter narrows `err.code` to the package's literal union
|
||||
- Injectable clock for deterministic timestamps under test
|
||||
- Optional `context` (redaction-aware) and `traceId`
|
||||
- Preserves `cause` via the standard ES2022 `Error.cause`
|
||||
- Calls `Error.captureStackTrace` where available, with a safe fallback for hosts that don't expose it
|
||||
- Resolves `message` via the installed `setMessageResolver`; default returns the i18n key verbatim
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SuaveplanError, defineCodes } from "@suaveplan/error";
|
||||
|
||||
const AUTH_CODES = defineCodes("AUTH", ["LOGIN.INVALID"] as const);
|
||||
|
||||
class AuthError extends SuaveplanError<typeof AUTH_CODES["LOGIN.INVALID"]> {
|
||||
public constructor(messageKey: string) {
|
||||
super(AUTH_CODES["LOGIN.INVALID"], messageKey);
|
||||
}
|
||||
}
|
||||
|
||||
throw new AuthError("error.auth.login.invalid");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SuaveplanError, defineCodes } from "@suaveplan/error";
|
||||
|
||||
const ORDER_CODES = defineCodes("ORDER", ["PAYMENT.DECLINED"] as const);
|
||||
|
||||
class OrderError extends SuaveplanError<
|
||||
typeof ORDER_CODES[keyof typeof ORDER_CODES]
|
||||
> {
|
||||
public constructor(
|
||||
code: typeof ORDER_CODES[keyof typeof ORDER_CODES],
|
||||
key: string,
|
||||
options?: ConstructorParameters<typeof SuaveplanError>[2]
|
||||
) {
|
||||
super(code, key, options);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
throw new OrderError(ORDER_CODES["PAYMENT.DECLINED"], "error.order.payment.declined", {
|
||||
cause: new Error("Stripe 402"),
|
||||
context: { orderId: "o-1", user: "u-42" },
|
||||
traceId: "trace-9",
|
||||
clock: () => 1_700_000_000_000,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof OrderError && err.code === "ORDER.PAYMENT.DECLINED") {
|
||||
console.error(err.traceId, err.timestamp, err.context, err.cause);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class SuaveplanError<TCode extends string = string>` *(abstract)*
|
||||
|
||||
- `readonly code: TCode` — the stable namespaced code (`PACKAGE.DOMAIN.REASON`)
|
||||
- `readonly context?: Readonly<Record<string, unknown>>` — structured fields; redaction-aware at the wire boundary
|
||||
- `readonly timestamp: number` — ms since epoch; filled from `options.clock()` or `defaultClock()`
|
||||
- `readonly traceId?: string` — optional trace correlation id
|
||||
- `cause?: unknown` — the ES2022 `Error.cause`; preserved across `walkCauses`
|
||||
- `constructor(code, messageKey, options?)` *(protected)* — subclasses MUST widen to `public`
|
||||
|
||||
### `defaultClock(): number`
|
||||
|
||||
Thin wrapper over `Date.now()`. Lives in one file so tests can swap it.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The constructor is `protected` so `SuaveplanError` cannot be instantiated directly; every subclass must widen to `public`, which is an intentional review point that forces each consumer to name its error hierarchy. `captureStackTrace` is guarded because not every host (older Safari, some WASM runtimes) exposes it; on those hosts the `stack` is whatever the `Error` superclass produces. Timestamps read through `options.clock ?? defaultClock` — `defaultClock` is the ONLY module-scope `Date.now()` call in the package, which keeps the package deterministic under test. Redaction happens at the serialization boundary (`toJSON`, `toBinary`), not at construction, so in-memory debugging always sees the real values. No Node / browser built-ins are referenced.
|
||||
@@ -0,0 +1,72 @@
|
||||
# walk-causes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`walkCauses` is a generator that traverses the `cause` chain of any error-shaped value and yields each link. It exists so logging, serialization, and debugging surfaces can read a full causal history without manual recursion, while staying safe against the two failure modes that plain recursion invites: cycles (A caused B, B caused A) and pathological depth (an attacker-controlled cause chain that exhausts the stack).
|
||||
|
||||
## Features
|
||||
|
||||
- Generator — consumers can break early or materialise via `Array.from`
|
||||
- Cycle-safe: tracks visited objects in a `WeakSet`
|
||||
- Depth-bounded: default `maxDepth` is 32, override via options
|
||||
- Tolerates non-object causes — a string, number, `null`, or `undefined` cause terminates the walk gracefully
|
||||
- Zero allocation outside the generator's own state for non-cyclic chains
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { walkCauses } from "@suaveplan/error";
|
||||
|
||||
const root = new Error("root");
|
||||
const wrapped = new Error("wrapped", { cause: root });
|
||||
|
||||
for (const link of walkCauses(wrapped)) {
|
||||
console.log((link as Error).message);
|
||||
}
|
||||
// wrapped
|
||||
// root
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SuaveplanError, defineCodes, walkCauses } from "@suaveplan/error";
|
||||
|
||||
const CODES = defineCodes("DEMO", ["A", "B"] as const);
|
||||
class DemoError extends SuaveplanError<typeof CODES[keyof typeof CODES]> {
|
||||
public constructor(
|
||||
code: typeof CODES[keyof typeof CODES],
|
||||
key: string,
|
||||
options?: ConstructorParameters<typeof SuaveplanError>[2]
|
||||
) {
|
||||
super(code, key, options);
|
||||
}
|
||||
}
|
||||
|
||||
const a = new DemoError(CODES.A, "demo.a");
|
||||
const b = new DemoError(CODES.B, "demo.b", { cause: a });
|
||||
// Intentional cycle — walker terminates gracefully.
|
||||
(a as { cause?: unknown }).cause = b;
|
||||
|
||||
const links = Array.from(walkCauses(b, { maxDepth: 10 }));
|
||||
console.log(links.length); // 2, not infinite
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `walkCauses(root: unknown, options?: WalkCausesOptions)`
|
||||
|
||||
- `root` — any value; the walk starts here
|
||||
- `options.maxDepth` — maximum number of links to yield (default `MAX_CAUSE_DEPTH` = 32)
|
||||
- Returns `Generator<unknown, void, void>`
|
||||
|
||||
### `interface WalkCausesOptions`
|
||||
|
||||
- `readonly maxDepth?: number` — inclusive of the root; values `<= 0` yield nothing
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Cycle detection uses `WeakSet.has` BEFORE yielding each object, so a cycle terminates after the second visit rather than producing a duplicate yield. Non-object causes (strings, numbers, `null`) are yielded but not added to the set (`WeakSet` only accepts objects), then the walk stops because a primitive has no `.cause` to descend into. The explicit `<= 0` short-circuit on `maxDepth` is important for callers that want to conditionally disable traversal by passing `0`. The generator form lets callers bail early on the first match without allocating the rest of the chain — important for large logging payloads.
|
||||
@@ -0,0 +1,66 @@
|
||||
# define-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
`defineCodes` is the canonical way to declare a stable, literal-typed error code registry for a package. It takes a namespace string and a tuple of reasons, and returns a frozen object mapping each reason to its fully-qualified code (`<namespace>.<reason>`). Because the return type is inferred from the input tuple, `instanceof` checks and `switch (err.code)` blocks get exhaustiveness checking without any manual enum declarations kept in sync.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure, deterministic, no side effects
|
||||
- Full literal-type inference for both keys and values
|
||||
- `Object.freeze` on the returned record so codes cannot be mutated at runtime
|
||||
- Works with dotted reasons (`"WIRE.MALFORMED"` yields `"ERROR.WIRE.MALFORMED"`) so packages can subdivide their own namespace
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { defineCodes } from "@suaveplan/error";
|
||||
|
||||
const AUTH_CODES = defineCodes("AUTH", ["LOGIN_FAILED", "LOGIN_LOCKED"] as const);
|
||||
|
||||
AUTH_CODES.LOGIN_FAILED; // "AUTH.LOGIN_FAILED"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
const PAYMENT_CODES = defineCodes("PAYMENT", [
|
||||
"CARD.DECLINED",
|
||||
"CARD.EXPIRED",
|
||||
"GATEWAY.TIMEOUT",
|
||||
] as const);
|
||||
|
||||
type PaymentCode = typeof PAYMENT_CODES[keyof typeof PAYMENT_CODES];
|
||||
|
||||
class PaymentError extends SuaveplanError<PaymentCode> {
|
||||
public constructor(
|
||||
code: PaymentCode,
|
||||
key: string,
|
||||
options?: ConstructorParameters<typeof SuaveplanError>[2]
|
||||
) {
|
||||
super(code, key, options);
|
||||
}
|
||||
}
|
||||
|
||||
function explain(err: PaymentError): string {
|
||||
switch (err.code) {
|
||||
case "PAYMENT.CARD.DECLINED": return "The card was declined.";
|
||||
case "PAYMENT.CARD.EXPIRED": return "The card has expired.";
|
||||
case "PAYMENT.GATEWAY.TIMEOUT": return "The gateway timed out.";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `defineCodes<NS extends string, R extends readonly string[]>(ns, reasons)`
|
||||
|
||||
- `ns` — the namespace, conventionally the package identifier in uppercase (`"EVENTS"`, `"AUTH"`)
|
||||
- `reasons` — a `readonly string[]` tuple (always pass `as const` so the literal types survive)
|
||||
- Returns `{ readonly [K in R[number]]: \`${NS}.${K}\` }` — frozen
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The returned record is built via `Object.create(null)` so it has no prototype chain — nothing leaks through from `Object.prototype`, and any accidental `.hasOwnProperty` access returns `undefined` rather than a method. The freeze is a runtime belt-and-braces measure; TypeScript's `readonly` already prevents assignment at compile time, but `Object.freeze` catches runtime mutations from untyped callers. The `reasons` tuple must be passed as `as const` in TypeScript for inference to work; without it, `R` widens to `string[]` and the values collapse to `string`. The helper is intentionally the only place that constructs these records so the entire project shares one audit surface.
|
||||
@@ -0,0 +1,70 @@
|
||||
# error-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
`ERROR_CODES` is the package-local registry for `@suaveplan/error` itself. It enumerates every failure mode the foundation package can raise — wire-format validation failures, binary codec failures, and cause-chain depth violations — using the shared `defineCodes` helper so the shape matches every other package in the project. Consumers typically import these codes to match on them inside a `try`/`catch` around `fromJSON`, `fromBinary`, or `walkCauses`.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen registry of package-internal codes, all prefixed `ERROR.`
|
||||
- Literal-typed values via `defineCodes`
|
||||
- `ErrorCode` type alias for the union of all values
|
||||
- Extends without renumbering — adding a new reason never changes an existing code string
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ERROR_CODES } from "@suaveplan/error";
|
||||
|
||||
ERROR_CODES["WIRE.MALFORMED"]; // "ERROR.WIRE.MALFORMED"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ERROR_CODES,
|
||||
ErrorPackageError,
|
||||
fromJSON,
|
||||
type ErrorCode,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
function isMalformed(err: unknown): err is ErrorPackageError {
|
||||
return (
|
||||
err instanceof ErrorPackageError &&
|
||||
err.code === ERROR_CODES["WIRE.MALFORMED"]
|
||||
);
|
||||
}
|
||||
|
||||
function explain(code: ErrorCode): string {
|
||||
switch (code) {
|
||||
case "ERROR.WIRE.MALFORMED":
|
||||
return "The payload did not match the wire schema.";
|
||||
case "ERROR.WIRE.UNSUPPORTED_VERSION":
|
||||
return "The payload is from a newer wire format than this package supports.";
|
||||
case "ERROR.BINARY.OUT_OF_RANGE":
|
||||
return "A numeric field exceeded the encoder's supported range.";
|
||||
case "ERROR.BINARY.TRUNCATED":
|
||||
return "The binary payload ended before decoding completed.";
|
||||
case "ERROR.BINARY.UNSUPPORTED_TYPE":
|
||||
return "The binary payload contained a type the decoder does not recognise.";
|
||||
case "ERROR.CAUSE.DEPTH_EXCEEDED":
|
||||
return "A cause chain exceeded the configured maximum depth.";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ERROR_CODES`
|
||||
|
||||
- Frozen record mapping reason keys to fully-qualified codes
|
||||
- Keys: `"WIRE.MALFORMED"`, `"WIRE.UNSUPPORTED_VERSION"`, `"BINARY.OUT_OF_RANGE"`, `"BINARY.TRUNCATED"`, `"BINARY.UNSUPPORTED_TYPE"`, `"CAUSE.DEPTH_EXCEEDED"`
|
||||
|
||||
### `type ErrorCode`
|
||||
|
||||
Union of all `ERROR_CODES[K]` string literals. Used to narrow `ErrorPackageError["code"]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is intentionally minimal — it only covers failure modes generated by `@suaveplan/error`'s own code paths. Every other package builds its own registry with `defineCodes` and its own namespace (`EVENTS`, `AUTH`, etc.). Codes are never renumbered or reused: removal of a code requires a deprecation window where the old value remains exported. `ErrorCode` is derived from the runtime values, so the type and runtime stay aligned without manual type declarations to keep in sync.
|
||||
@@ -0,0 +1,83 @@
|
||||
# resolver
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The i18n resolver hook lets an application plug a translator into every `SuaveplanError`'s message formatting without coupling this foundation package to any i18n framework. Error subclasses throw with i18n keys (`"error.auth.login.invalid"`); if no resolver is installed, `resolveMessage` returns the key verbatim so the error is still readable. When the app bootstraps with a locale catalog, it calls `setMessageResolver` once and from that point every error's `message` is translated.
|
||||
|
||||
## Features
|
||||
|
||||
- Single module-scope resolver slot — explicit, not a registry of resolvers
|
||||
- `setMessageResolver` returns the previous resolver so layered installs are reversible
|
||||
- `clearMessageResolver` restores the default pass-through behaviour
|
||||
- No i18n framework imported — caller supplies whatever translator they use
|
||||
- Params optional — resolver signature is `(key, params?) => string`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { setMessageResolver, clearMessageResolver, resolveMessage } from "@suaveplan/error";
|
||||
|
||||
setMessageResolver((key) => `[en] ${key}`);
|
||||
resolveMessage("error.auth.login.invalid");
|
||||
// "[en] error.auth.login.invalid"
|
||||
|
||||
clearMessageResolver();
|
||||
resolveMessage("error.auth.login.invalid");
|
||||
// "error.auth.login.invalid"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { setMessageResolver, SuaveplanError, defineCodes } from "@suaveplan/error";
|
||||
import messages_en from "./locales/en.json";
|
||||
import messages_fr from "./locales/fr.json";
|
||||
|
||||
function installLocale(locale: "en" | "fr"): void {
|
||||
const catalogue = locale === "en" ? messages_en : messages_fr;
|
||||
setMessageResolver((key, params) => {
|
||||
const template = (catalogue as Record<string, string>)[key] ?? key;
|
||||
return params === undefined
|
||||
? template
|
||||
: template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ""));
|
||||
});
|
||||
}
|
||||
|
||||
const CODES = defineCodes("AUTH", ["LOGIN.INVALID"] as const);
|
||||
class AuthError extends SuaveplanError<typeof CODES["LOGIN.INVALID"]> {
|
||||
public constructor(key: string, options?: ConstructorParameters<typeof SuaveplanError>[2]) {
|
||||
super(CODES["LOGIN.INVALID"], key, options);
|
||||
}
|
||||
}
|
||||
|
||||
installLocale("fr");
|
||||
const err = new AuthError("error.auth.login.invalid", { context: { user: "alice" } });
|
||||
console.log(err.message);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `setMessageResolver(resolver: MessageResolver): MessageResolver | undefined`
|
||||
|
||||
Install `resolver`. Returns the previously installed resolver, or `undefined` on first install.
|
||||
|
||||
### `clearMessageResolver(): void`
|
||||
|
||||
Remove any installed resolver. `resolveMessage` returns keys verbatim afterwards.
|
||||
|
||||
### `resolveMessage(key: string, params?: Readonly<Record<string, unknown>>): string`
|
||||
|
||||
Resolve `key` through the installed resolver, or return it unchanged if none is installed.
|
||||
|
||||
### `type MessageResolver`
|
||||
|
||||
```ts
|
||||
(key: string, params?: Readonly<Record<string, unknown>>) => string
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Module-scope state is a single `let` — intentionally shared across consumers in the process. Test isolation comes from calling `clearMessageResolver()` in `afterEach`, which is how this package's own tests work. The resolver is invoked during `super()` in every `SuaveplanError` subclass, so the `message` that ends up on `err` is whatever the resolver returns at throw time. That means switching locales mid-process only affects errors thrown afterwards — already-thrown errors retain their original rendered message.
|
||||
@@ -0,0 +1,78 @@
|
||||
# to-span-event
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`toSpanEvent` translates any error-shaped value into the attribute payload that the OpenTelemetry specification defines for an `exception` span event. It gives tracing code a one-line call to attach a failure to a span without importing any `@opentelemetry/*` SDK. The caller decides how the returned payload is handed to the actual OTel recorder, which keeps `@suaveplan/error` dep-free.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function, no side effects
|
||||
- No `@opentelemetry/*` import — just the shape OTel consumers expect
|
||||
- Tolerates any value: `SuaveplanError`, plain `Error`, `Object.create(null)`, primitives via coercion
|
||||
- Fills every required OTel attribute (`exception.type`, `exception.message`, `exception.stacktrace`, `exception.code`)
|
||||
- Includes `exception.traceId` only when the error carries one
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { toSpanEvent, ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
|
||||
|
||||
const err = new ErrorPackageError(
|
||||
ERROR_CODES["WIRE.MALFORMED"],
|
||||
"error.error.wire.malformed"
|
||||
);
|
||||
const event = toSpanEvent(err);
|
||||
// { name: "exception", attributes: { "exception.type": "ErrorPackageError", ... } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { toSpanEvent } from "@suaveplan/error";
|
||||
|
||||
function recordException(spanName: string, fn: () => void): void {
|
||||
const tracer = trace.getTracer("my-service");
|
||||
tracer.startActiveSpan(spanName, (span) => {
|
||||
try {
|
||||
fn();
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
} catch (err) {
|
||||
const ev = toSpanEvent(err);
|
||||
span.addEvent(ev.name, ev.attributes);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `toSpanEvent(err: unknown): SpanEvent`
|
||||
|
||||
Returns `{ name: "exception", attributes }`. Attributes:
|
||||
|
||||
- `"exception.type"` — `err.constructor.name`, falling back to `err.name`, then `"Error"`
|
||||
- `"exception.message"` — `err.message` if a string, else `String(err.message ?? "")`
|
||||
- `"exception.stacktrace"` — `err.stack` if a string, else `""`
|
||||
- `"exception.code"` — `err.code` if a string, else `"UNKNOWN"`
|
||||
- `"exception.traceId"` — present only when `err.traceId` is a string
|
||||
|
||||
### `interface SpanEvent`
|
||||
|
||||
```ts
|
||||
{
|
||||
readonly name: "exception";
|
||||
readonly attributes: Readonly<Record<string, string>>;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`toSpanEvent` never throws — every lookup is guarded with a `typeof` check before coercion. Values with no discoverable type name fall back to `"Error"`, values with no stack fall back to `""`, codeless values fall back to `"UNKNOWN"` — all of which match OpenTelemetry's published guidance for unknown failures. The function reads `err.constructor.name` first because subclasses of `SuaveplanError` get a useful class name that way, even when `err.name` defaults to the base's string. No OpenTelemetry SDK is imported; the returned shape matches `Span.addEvent`'s expected arguments exactly so consumers can spread or forward it.
|
||||
@@ -0,0 +1,76 @@
|
||||
# sensitive
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `sensitive` module provides the small primitive that controls redaction at the serialization boundary. `markSensitive` wraps a value so that when the error is serialised via `toJSON` or `toBinary`, the field is replaced by the literal string `"[REDACTED]"` — while in-memory access through `err.context` continues to see the real value. This gives debugger sessions the full picture without letting secrets leak into logs, tracing sinks, or transport payloads.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure, no module-scope state
|
||||
- Tag uses `Symbol.for("@suaveplan/error.sensitive")` so the same tag is recognised across bundle boundaries and duplicated module copies
|
||||
- `isSensitive` is a type guard — narrows to `Sensitive<T>` inside the true branch
|
||||
- `unwrapSensitive` is safe to call on non-sensitive values (pass-through)
|
||||
- Wire redaction applied uniformly by `toJSON` — consumers don't have to remember to do it
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { markSensitive, unwrapSensitive } from "@suaveplan/error";
|
||||
|
||||
const ctx = { user: "alice", token: markSensitive("tkn_xyz") };
|
||||
unwrapSensitive(ctx.token); // "tkn_xyz" — in-memory access unchanged
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
markSensitive,
|
||||
SuaveplanError,
|
||||
defineCodes,
|
||||
toJSON,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
const CODES = defineCodes("AUTH", ["LOGIN.INVALID"] as const);
|
||||
class AuthError extends SuaveplanError<typeof CODES["LOGIN.INVALID"]> {
|
||||
public constructor(key: string, options?: ConstructorParameters<typeof SuaveplanError>[2]) {
|
||||
super(CODES["LOGIN.INVALID"], key, options);
|
||||
}
|
||||
}
|
||||
|
||||
const err = new AuthError("error.auth.login.invalid", {
|
||||
context: {
|
||||
user: "alice",
|
||||
apiKey: markSensitive("sk_live_..."),
|
||||
providerToken: markSensitive({ access: "x", refresh: "y" }),
|
||||
},
|
||||
});
|
||||
|
||||
toJSON(err).context;
|
||||
// { user: "alice", apiKey: "[REDACTED]", providerToken: "[REDACTED]" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `markSensitive<T>(value: T): Sensitive<T>`
|
||||
|
||||
Wrap `value` with the redaction tag. The wrapper preserves the value unchanged — downstream consumers who explicitly unwrap still see the original.
|
||||
|
||||
### `unwrapSensitive<T>(value: Sensitive<T> | T): T`
|
||||
|
||||
Return the original value if `value` is tagged; otherwise return `value` unchanged. Always safe.
|
||||
|
||||
### `isSensitive<T>(value: unknown): value is Sensitive<T>`
|
||||
|
||||
Type guard — `true` when `value` carries the redaction tag.
|
||||
|
||||
### `interface Sensitive<T>`
|
||||
|
||||
Opaque tagged wrapper; not intended to be constructed by hand — always go through `markSensitive`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The tag is a `Symbol.for` (global symbol registry) rather than a local `Symbol()` because tests, workers, and even separate bundles can roundtrip a `Sensitive<T>` reliably. Primitive wrappers cost one object allocation per marked value; callers that mark large objects don't pay a deep-clone tax because the wrapper holds the reference, not a copy. The wire boundary (`toJSON`/`toBinary`) is the only place the package writes `"[REDACTED]"` — if a consumer has a bespoke serializer, they are responsible for using `isSensitive` there too.
|
||||
@@ -0,0 +1,58 @@
|
||||
# from-binary
|
||||
|
||||
## Purpose
|
||||
|
||||
`fromBinary` is the decode counterpart to `toBinary`. It accepts a `Uint8Array` produced by `toBinary` (or any compatible encoder that emits UTF-8 JSON matching `wireErrorSchema`) and returns a `ReconstructedError`. Non-UTF-8 input and invalid-JSON input both surface as `ErrorPackageError("ERROR.BINARY.TRUNCATED")` with the original parser failure preserved as `cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict UTF-8 decode — uses `TextDecoder("utf-8", { fatal: true })` so silently-corrupt bytes raise instead of producing garbage
|
||||
- Two-stage failure detection: UTF-8 failure → `BINARY.TRUNCATED` with `TextDecoder` cause; JSON-parse failure → `BINARY.TRUNCATED` with `SyntaxError` cause; schema failure → `WIRE.MALFORMED` via `fromJSON`
|
||||
- Works with Node `Buffer` (subclass of `Uint8Array`)
|
||||
- Pure, no side effects
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { toBinary, fromBinary } from "@suaveplan/error";
|
||||
|
||||
const bytes = toBinary(new Error("boom"));
|
||||
const reconstituted = fromBinary(bytes);
|
||||
reconstituted.message; // "boom"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { fromBinary, ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
|
||||
|
||||
async function handleFrame(bytes: Uint8Array): Promise<void> {
|
||||
try {
|
||||
const err = fromBinary(bytes);
|
||||
metrics.errorByCode.increment({ code: err.code });
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof ErrorPackageError &&
|
||||
err.code === ERROR_CODES["BINARY.TRUNCATED"]
|
||||
) {
|
||||
// Retryable: likely a partial frame.
|
||||
await requeue(bytes);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `fromBinary(bytes: Uint8Array): ReconstructedError`
|
||||
|
||||
- `bytes` — UTF-8 bytes produced by `toBinary` or equivalent encoder
|
||||
- Returns a `ReconstructedError` (same shape as `fromJSON`)
|
||||
- Throws `ErrorPackageError("ERROR.BINARY.TRUNCATED")` on decode/parse failure; `cause` is the original error
|
||||
- Throws `ErrorPackageError("ERROR.WIRE.MALFORMED")` on schema failure (bubbled from `fromJSON`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `fatal: true` flag on `TextDecoder` matters — without it, corrupt bytes would be silently replaced with U+FFFD and the downstream `JSON.parse` would produce a valid-looking but subtly wrong envelope. Both failure paths share the same error code (`BINARY.TRUNCATED`) because the caller's recovery strategy is the same (retry/requeue) regardless of whether the bytes were non-UTF-8 or non-JSON. The separation from `WIRE.MALFORMED` is intentional: schema failures indicate a version or sender-side bug, not a transport issue.
|
||||
@@ -0,0 +1,70 @@
|
||||
# from-json
|
||||
|
||||
## Purpose
|
||||
|
||||
`fromJSON` is the decode counterpart to `toJSON`. It Zod-validates the incoming payload against `wireErrorSchema` and reconstructs a plain object that mirrors the original error's public fields (`code`, `message`, `context`, `timestamp`, `traceId`, `stack`, `name`, and the recursive `cause`). Malformed input raises `ErrorPackageError("ERROR.WIRE.MALFORMED")` so the caller can distinguish parser failures from legitimate domain errors.
|
||||
|
||||
## Features
|
||||
|
||||
- Schema-validated — rejects any payload whose shape or types don't match `wireErrorSchema`
|
||||
- Recursive — reconstructs the full `cause` chain
|
||||
- Pure, deterministic — does NOT re-throw the decoded value, only returns a plain object (callers who want `throw` must wrap)
|
||||
- Does NOT reconstruct `SuaveplanError` subclasses — the returned object is structurally identical but not `instanceof` the original class (by design — the sender's class may not exist in the receiver's bundle)
|
||||
- Optional fields absent from the wire are absent from the output — never `undefined` keys
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { fromJSON } from "@suaveplan/error";
|
||||
|
||||
const wire = {
|
||||
__suaveplanError: 1 as const,
|
||||
code: "AUTH.LOGIN.INVALID",
|
||||
message: "error.auth.login.invalid",
|
||||
timestamp: 0,
|
||||
name: "AuthError",
|
||||
};
|
||||
const err = fromJSON(wire);
|
||||
// err.code === "AUTH.LOGIN.INVALID"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { fromJSON, toJSON, ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
|
||||
|
||||
function decodeFromRemote(body: string): ReturnType<typeof fromJSON> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch (cause) {
|
||||
throw new ErrorPackageError(
|
||||
ERROR_CODES["WIRE.MALFORMED"],
|
||||
"error.error.wire.malformed",
|
||||
{ cause, context: { body } }
|
||||
);
|
||||
}
|
||||
return fromJSON(parsed);
|
||||
}
|
||||
|
||||
// Round-trip sanity check across a transport.
|
||||
const original = toJSON(someLocalError);
|
||||
const reconstituted = fromJSON(JSON.parse(JSON.stringify(original)));
|
||||
console.assert(reconstituted.code === original.code);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `fromJSON(value: unknown): ReconstructedError`
|
||||
|
||||
- `value` — any wire payload; must conform to `wireErrorSchema`
|
||||
- Returns the reconstructed plain object
|
||||
- Throws `ErrorPackageError("ERROR.WIRE.MALFORMED")` on schema failure; the `cause` is the underlying `ZodError`
|
||||
|
||||
### `interface ReconstructedError`
|
||||
|
||||
Same shape as `WireError` — `code`, `message`, `timestamp`, `name`, plus optional `context`, `traceId`, `stack`, `cause`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Deliberately does not resurrect a specific subclass — doing so would require the receiver to have every sender-side subclass imported, which is impossible across service boundaries. Callers who need to re-throw the reconstructed error can wrap it in a known local subclass using the decoded `code` to pick which class to construct. `fromJSON` is recursive: the Zod schema is `z.lazy` so deeply-nested cause chains validate in one pass. The `ErrorPackageError` thrown on malformed input always preserves the original `ZodError` as `cause`, so debugging tools show the exact field that mismatched.
|
||||
@@ -0,0 +1,53 @@
|
||||
# to-binary
|
||||
|
||||
## Purpose
|
||||
|
||||
`toBinary` encodes any error-shaped value into a compact byte payload suitable for binary transports (message queues, WebSocket frames, Redis streams). It reuses `toJSON` for canonicalisation and then UTF-8-encodes the result, keeping the implementation small enough to audit in one sitting while still producing a lossless, Zod-checked wire form on the receiving side.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero external encoder dependency — uses `TextEncoder` (available in every supported runtime)
|
||||
- Deterministic — same input always yields the same bytes (JSON canonicalises field order via the wire builder)
|
||||
- Round-trips losslessly via `fromBinary`
|
||||
- Same `maxDepth` / redaction / cycle-safety guarantees as `toJSON`
|
||||
- Output is a `Uint8Array` — works with Node's `Buffer` (which `extends Uint8Array`), browser `fetch`, and WebSocket frames
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { toBinary, ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
|
||||
|
||||
const bytes = toBinary(
|
||||
new ErrorPackageError(ERROR_CODES["WIRE.MALFORMED"], "error.error.wire.malformed")
|
||||
);
|
||||
bytes.byteLength; // >0
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { toBinary, fromBinary } from "@suaveplan/error";
|
||||
|
||||
async function publishFailure(err: unknown, ws: WebSocket): Promise<void> {
|
||||
const frame = toBinary(err, { maxDepth: 8 });
|
||||
ws.send(frame);
|
||||
}
|
||||
|
||||
async function consumeFailure(event: MessageEvent<ArrayBuffer>): Promise<void> {
|
||||
const bytes = new Uint8Array(event.data);
|
||||
const decoded = fromBinary(bytes);
|
||||
console.error(decoded.code, decoded.traceId);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `toBinary(err: unknown, options?: ToJsonOptions): Uint8Array`
|
||||
|
||||
- `err` — any value; same acceptance rules as `toJSON`
|
||||
- `options.maxDepth` — forwarded to `toJSON`; defaults to `MAX_CAUSE_DEPTH`
|
||||
- Returns UTF-8 bytes of the canonicalised JSON wire form
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The binary form is intentionally UTF-8 JSON rather than MessagePack or Protobuf — audit surface stays in one function, and the runtime cost (one `JSON.stringify` + one `TextEncoder.encode`) is small compared to the transport overhead. If a consumer later wants a more compact format, they can replace this module without touching any public wire contract because `fromBinary` only decodes what `toBinary` encodes. The Zod validation happens on the `fromBinary` side via `fromJSON`, so a corrupted wire always surfaces as `ERROR.WIRE.MALFORMED` rather than an opaque parse error.
|
||||
@@ -0,0 +1,74 @@
|
||||
# to-json
|
||||
|
||||
## Purpose
|
||||
|
||||
`toJSON` serialises any error-shaped value to a wire-safe plain object that round-trips losslessly back through `fromJSON`. It walks the `cause` chain, applies redaction at the boundary, truncates at a configurable depth to prevent unbounded payloads, and emits the current wire version so future decoders can reject or upgrade old payloads deterministically.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts any value — `SuaveplanError`, plain `Error`, bare object, primitives (primitives produce a truncated stub)
|
||||
- Walks and encodes the full `cause` chain, one envelope per link
|
||||
- Replaces `Sensitive<T>`-tagged context fields with `"[REDACTED]"`
|
||||
- Cycle-safe via `WeakSet`
|
||||
- Depth-bounded (default `MAX_CAUSE_DEPTH` = 32; override per call)
|
||||
- Emits `__suaveplanError: 1` version marker for forward-compatible decoding
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { toJSON, ErrorPackageError, ERROR_CODES } from "@suaveplan/error";
|
||||
|
||||
const err = new ErrorPackageError(
|
||||
ERROR_CODES["WIRE.MALFORMED"],
|
||||
"error.error.wire.malformed"
|
||||
);
|
||||
JSON.stringify(toJSON(err));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SuaveplanError, defineCodes, markSensitive, toJSON } from "@suaveplan/error";
|
||||
|
||||
const CODES = defineCodes("ORDER", ["PAYMENT.DECLINED"] as const);
|
||||
class OrderError extends SuaveplanError<typeof CODES[keyof typeof CODES]> {
|
||||
public constructor(key: string, options?: ConstructorParameters<typeof SuaveplanError>[2]) {
|
||||
super(CODES["PAYMENT.DECLINED"], key, options);
|
||||
}
|
||||
}
|
||||
|
||||
const inner = new Error("stripe 402");
|
||||
const err = new OrderError("error.order.payment.declined", {
|
||||
cause: inner,
|
||||
context: {
|
||||
orderId: "o-42",
|
||||
customerToken: markSensitive("tok_live_xyz"),
|
||||
},
|
||||
traceId: "trace-9",
|
||||
});
|
||||
|
||||
const wire = toJSON(err, { maxDepth: 4 });
|
||||
// wire.code === "ORDER.PAYMENT.DECLINED"
|
||||
// wire.context.customerToken === "[REDACTED]"
|
||||
// wire.cause?.message === "stripe 402"
|
||||
// wire.__suaveplanError === 1
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `toJSON(err: unknown, options?: ToJsonOptions): WireError`
|
||||
|
||||
- `err` — any value; non-object primitives produce a truncated stub with `code: "ERROR.CAUSE.TRUNCATED"`
|
||||
- `options.maxDepth` — maximum cause-chain depth; defaults to `MAX_CAUSE_DEPTH` (32)
|
||||
|
||||
### `interface ToJsonOptions`
|
||||
|
||||
- `readonly maxDepth?: number`
|
||||
|
||||
### `interface WireError`
|
||||
|
||||
See `@suaveplan/error/wire.schemas` — shape includes `code`, `message`, `context?`, `timestamp`, `traceId?`, `stack?`, `name`, `cause?`, and the `__suaveplanError: 1` version literal.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Fields with the wrong runtime type (e.g. a numeric `code` or a string `timestamp`) collapse to defaults (`"UNKNOWN"`, `0`, `""`, `"Error"`) rather than throwing, so an ad-hoc `throw { code: 123 }` still produces a valid envelope. Cycles emit a single `TruncatedCause` sentinel rather than infinite recursion — the same sentinel is used when `maxDepth` is exhausted, keeping the consumer-visible wire shape uniform. Redaction is applied one level deep on `context`; nested sensitive values inside arrays or deeper objects are NOT transitively scanned by this package — callers with deeper redaction needs should structure their context accordingly.
|
||||
@@ -0,0 +1,88 @@
|
||||
# @suaveplan/schema-registry - Versioned schema registry with forward/backward compatibility checking for evolving data contracts
|
||||
|
||||
## Summary
|
||||
|
||||
Versioned schema registry with forward/backward/full compatibility checking for evolving data contracts. Register schemas by subject name, enforce evolution rules, and detect breaking changes before they reach production.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schema-registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schema-registry-error](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry-error](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry-error](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry-error](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schema-registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schema-registry-error](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Features
|
||||
|
||||
- **Versioned storage** — schemas are registered per subject with auto-incremented versions
|
||||
- **Compatibility enforcement** — BACKWARD, FORWARD, FULL, or NONE modes
|
||||
- **Per-subject overrides** — `setCompatibility()` lets subjects opt into different rules
|
||||
- **Dry-run checks** — `checkCompatibility()` validates without registering
|
||||
- **OTel instrumentation** — `schema_registry.register.total` counter and `schema_registry.check.duration_ms` histogram
|
||||
- **Structured errors** — `SchemaRegistryError` with stable `SCHEMA_REGISTRY.*` codes
|
||||
- **JSON Schema deep analysis** — inspects `required`, `properties`, and `type` fields
|
||||
- **Format-aware** — `zod`, `avro`, and `custom` formats accepted with informational notes
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/schema-registry
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createRegistry } from "@suaveplan/schema-registry";
|
||||
|
||||
const registry = createRegistry();
|
||||
|
||||
// Register v1
|
||||
registry.register("UserCreated", {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, name: { type: "string" } },
|
||||
required: ["id"],
|
||||
});
|
||||
|
||||
// Register v2 (backward-compatible — adds optional field)
|
||||
registry.register("UserCreated", {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
email: { type: "string" },
|
||||
},
|
||||
required: ["id"],
|
||||
});
|
||||
|
||||
const latest = registry.getLatest("UserCreated");
|
||||
console.log(latest.version); // 2
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Description |
|
||||
|---------|-------------|
|
||||
| `@suaveplan/schema-registry` | Full barrel export |
|
||||
| [`@suaveplan/schema-registry/registry`](src/core/registry/schema-registry.md) | `SchemaRegistry` class and `createRegistry` factory |
|
||||
| [`@suaveplan/schema-registry/compatibility`](src/core/compatibility/compatibility.md) | `checkCompatibility` pure function |
|
||||
| [`@suaveplan/schema-registry/errors`](src/errors/schema-registry-error.md) | `SchemaRegistryError` and `SCHEMA_REGISTRY_CODES` |
|
||||
|
||||
## Compatibility Levels
|
||||
|
||||
| Level | Description |
|
||||
|-------|-------------|
|
||||
| `BACKWARD` (default) | New schema can read data written with old schema |
|
||||
| `FORWARD` | Old schema can read data written with new schema |
|
||||
| `FULL` | Both BACKWARD and FORWARD must pass |
|
||||
| `NONE` | No compatibility checks — any schema accepted |
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `SCHEMA_REGISTRY.SCHEMA_NOT_FOUND` | Requested version does not exist |
|
||||
| `SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA` | Registration rejected by compatibility rule |
|
||||
| `SCHEMA_REGISTRY.INVALID_SCHEMA` | Schema definition fails validation |
|
||||
| `SCHEMA_REGISTRY.SUBJECT_NOT_FOUND` | Subject has no registered versions |
|
||||
@@ -0,0 +1,113 @@
|
||||
# compatibility
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements forward, backward, and full compatibility checking between JSON Schema documents. This module is the core enforcement engine called during schema registration to reject changes that would break producers or consumers of existing data. It is stateless and pure — no registry state is touched; callers supply two schema objects and receive a structured `CompatibilityResult`.
|
||||
|
||||
The module currently provides deep analysis only for `json-schema` format. For `zod`, `avro`, and `custom` formats the candidate schema is accepted with an informational note, allowing those schemas to be registered while the host application adds its own format-specific validation.
|
||||
|
||||
## Features
|
||||
|
||||
- **BACKWARD** check: ensures the new schema can read data written by the old schema. Detects added required fields absent in old data, removed properties that old writers expected, and property type changes.
|
||||
- **FORWARD** check: ensures the old schema can read data written by the new schema. Detects new properties when `additionalProperties: false` is set, removal of required fields, and property type changes.
|
||||
- **FULL** check: both BACKWARD and FORWARD must pass simultaneously.
|
||||
- **NONE** level: always returns `compatible: true` — useful for experimental subjects.
|
||||
- Structured `CompatibilityResult` with a `compatible` boolean, the `level` that was applied, and a `string[]` of human-readable error descriptions.
|
||||
- Pure function — no side effects, no registry state, injectable test inputs.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { checkCompatibility } from "@suaveplan/schema-registry/compatibility";
|
||||
|
||||
const existing = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
email: { type: "string" }, // new optional field
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
|
||||
const result = checkCompatibility(candidate, existing, "BACKWARD", "json-schema");
|
||||
// result.compatible === true (new optional field is fine for backward compat)
|
||||
// result.errors === []
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { checkCompatibility } from "@suaveplan/schema-registry/compatibility";
|
||||
|
||||
// FULL compatibility — both directions must pass
|
||||
const v1 = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, amount: { type: "number" } },
|
||||
required: ["id"],
|
||||
};
|
||||
|
||||
// Breaking: adds a NEW required field — old data won't have it
|
||||
const v2Breaking = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, amount: { type: "number" }, currency: { type: "string" } },
|
||||
required: ["id", "currency"],
|
||||
};
|
||||
|
||||
const full = checkCompatibility(v2Breaking, v1, "FULL", "json-schema");
|
||||
console.log(full.compatible); // false
|
||||
console.log(full.errors);
|
||||
// ["Field 'currency' is required in the new schema but absent in old data — BACKWARD incompatible"]
|
||||
|
||||
// Non-json-schema format — always accepted
|
||||
const zodResult = checkCompatibility(null, null, "BACKWARD", "zod");
|
||||
console.log(zodResult.compatible); // true
|
||||
console.log(zodResult.errors[0]); // note about format not being deeply checked
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `checkCompatibility(newSchema, existingSchema, level, format)`
|
||||
|
||||
Checks whether `newSchema` is compatible with `existingSchema` under the given `level` and `format`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `newSchema` | `unknown` | The candidate schema to be registered |
|
||||
| `existingSchema` | `unknown` | The current latest schema for the subject |
|
||||
| `level` | `CompatibilityLevel` | `"BACKWARD"`, `"FORWARD"`, `"FULL"`, or `"NONE"` |
|
||||
| `format` | `SchemaFormat` | `"json-schema"`, `"zod"`, `"avro"`, or `"custom"` |
|
||||
|
||||
**Returns:** `CompatibilityResult`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `compatible` | `boolean` | `true` if no breaking changes detected |
|
||||
| `level` | `CompatibilityLevel` | Echo of the level that was applied |
|
||||
| `errors` | `string[]` | Human-readable breaking-change descriptions |
|
||||
|
||||
**Throws:** Never — all error conditions are reflected in `CompatibilityResult.errors`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
**BACKWARD semantics:** A new schema can read old data if it doesn't require fields that didn't exist before, and doesn't remove fields that old writers set. The check inspects `required` additions and `properties` removals. Adding optional fields (present in `properties` but not `required`) is always safe.
|
||||
|
||||
**FORWARD semantics:** An old schema can read new data if the old schema doesn't reject unknown fields via `additionalProperties: false`. The check also catches cases where the new schema removes fields that the old schema declares as required.
|
||||
|
||||
**Type comparison:** Types are normalised by sorting array-type unions (`["string","null"]` → `"null|string"`) before comparing, preventing false positives from ordering differences.
|
||||
|
||||
**Non-JSON-Schema formats:** Zod schemas are runtime objects (not plain POJOs in the registry sense) and Avro schemas have their own evolution rules. Deep analysis for those formats is deferred; the module returns `compatible: true` with an informational note rather than a false `compatible: false`.
|
||||
@@ -0,0 +1,168 @@
|
||||
# schema-registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements `SchemaRegistry`, the central in-memory store for versioned schema definitions. Each subject maintains an ordered list of schema versions; registration atomically assigns the next version number and enforces the subject's compatibility level before persisting the new version. The registry emits OpenTelemetry counters and histograms for observability.
|
||||
|
||||
This module is the single integration point for applications: all register, retrieve, list, delete, and compatibility-check operations go through `SchemaRegistry` or the `createRegistry` factory.
|
||||
|
||||
## Features
|
||||
|
||||
- **Versioned storage** per subject — version numbers start at 1 and increment monotonically; old data is preserved.
|
||||
- **Compatibility enforcement** on registration — rejects candidates that violate BACKWARD / FORWARD / FULL rules; NONE disables enforcement.
|
||||
- **Per-subject compatibility override** via `setCompatibility()` — subjects can opt into stricter or looser rules at any time.
|
||||
- **Read API** — `getLatest`, `getVersion`, `getAll` for point-in-time access.
|
||||
- **`checkCompatibility`** — dry-run check without side effects; returns structured `CompatibilityResult`.
|
||||
- **`listSubjects`** / `deleteSubject`** — subject enumeration and removal.
|
||||
- **`clear()`** — wipes all state; useful for test isolation.
|
||||
- **OTel instrumentation** — `schema_registry.register.total` counter and `schema_registry.check.duration_ms` histogram.
|
||||
- **Version pruning** — enforces `maxVersionsPerSubject` cap by dropping oldest versions.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createRegistry } from "@suaveplan/schema-registry/registry";
|
||||
|
||||
const registry = createRegistry();
|
||||
|
||||
// Register first version
|
||||
const v1 = registry.register("OrderPlaced", {
|
||||
type: "object",
|
||||
properties: { orderId: { type: "string" }, amount: { type: "number" } },
|
||||
required: ["orderId"],
|
||||
});
|
||||
console.log(v1.version); // 1
|
||||
console.log(v1.id); // "OrderPlaced#1"
|
||||
|
||||
// Register second compatible version
|
||||
const v2 = registry.register("OrderPlaced", {
|
||||
type: "object",
|
||||
properties: {
|
||||
orderId: { type: "string" },
|
||||
amount: { type: "number" },
|
||||
currency: { type: "string" }, // new optional field — backward compatible
|
||||
},
|
||||
required: ["orderId"],
|
||||
});
|
||||
console.log(v2.version); // 2
|
||||
|
||||
// Retrieve
|
||||
const latest = registry.getLatest("OrderPlaced");
|
||||
console.log(latest.version); // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRegistry,
|
||||
SchemaRegistry,
|
||||
} from "@suaveplan/schema-registry/registry";
|
||||
import {
|
||||
SchemaRegistryError,
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
} from "@suaveplan/schema-registry/errors";
|
||||
|
||||
// FULL compatibility registry
|
||||
const registry = createRegistry({ defaultCompatibility: "FULL" });
|
||||
|
||||
// Subject-level override
|
||||
registry.register("Events.Lax", { type: "object" });
|
||||
registry.setCompatibility("Events.Lax", "NONE");
|
||||
|
||||
// Dry-run check
|
||||
registry.register("Payments.Order", {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
});
|
||||
const result = registry.checkCompatibility("Payments.Order", {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, ref: { type: "string" } },
|
||||
required: ["id"],
|
||||
});
|
||||
console.log(result.compatible); // true — no breaking change
|
||||
|
||||
// Error handling
|
||||
try {
|
||||
registry.register("Payments.Order", {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, ref: { type: "string" } },
|
||||
required: ["id", "ref"], // adding required field — BACKWARD incompatible
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SchemaRegistryError) {
|
||||
console.log(err.code); // "SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA"
|
||||
}
|
||||
}
|
||||
|
||||
// List and delete
|
||||
const subjects = registry.listSubjects();
|
||||
registry.deleteSubject("Events.Lax");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SchemaRegistry`
|
||||
|
||||
```ts
|
||||
class SchemaRegistry
|
||||
```
|
||||
|
||||
#### `constructor(options?: RegistryOptions)`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `defaultCompatibility` | `CompatibilityLevel` | `"BACKWARD"` | Applied to new subjects on first registration |
|
||||
| `maxVersionsPerSubject` | `number` | `1000` | Cap on retained versions per subject |
|
||||
|
||||
#### `register(subject, schema, format?, metadata?): SchemaDefinition`
|
||||
|
||||
Registers a new schema version. Throws `INCOMPATIBLE_SCHEMA` if the candidate violates the subject's compatibility level. Throws `INVALID_SCHEMA` for null/empty inputs.
|
||||
|
||||
#### `getLatest(subject): SchemaDefinition`
|
||||
|
||||
Returns the highest-version schema for the subject. Throws `SUBJECT_NOT_FOUND` if subject is absent.
|
||||
|
||||
#### `getVersion(subject, version): SchemaDefinition`
|
||||
|
||||
Returns a specific version. Throws `SUBJECT_NOT_FOUND` or `SCHEMA_NOT_FOUND` as appropriate.
|
||||
|
||||
#### `getAll(subject): SchemaDefinition[]`
|
||||
|
||||
Returns all versions sorted ascending. Throws `SUBJECT_NOT_FOUND` if subject is absent.
|
||||
|
||||
#### `listSubjects(): string[]`
|
||||
|
||||
Returns names of all subjects with at least one registered version.
|
||||
|
||||
#### `deleteSubject(subject): void`
|
||||
|
||||
Removes all versions for a subject. Throws `SUBJECT_NOT_FOUND` if absent.
|
||||
|
||||
#### `setCompatibility(subject, level): void`
|
||||
|
||||
Sets the compatibility level for a subject. Creates a placeholder if the subject is new.
|
||||
|
||||
#### `checkCompatibility(subject, candidateSchema, format?): CompatibilityResult`
|
||||
|
||||
Dry-runs a compatibility check without registering. Throws `SUBJECT_NOT_FOUND` if subject is absent.
|
||||
|
||||
#### `clear(): void`
|
||||
|
||||
Removes all subjects. Useful for test teardown.
|
||||
|
||||
### `createRegistry(options?): SchemaRegistry`
|
||||
|
||||
Factory function equivalent to `new SchemaRegistry(options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses a `Map<string, SubjectState>` keyed by subject name. `SubjectState` holds `versions: SchemaDefinition[]` (ordered by insertion, i.e. ascending version) and `compatibility: CompatibilityLevel`. Version numbers are assigned as `versions.length + 1` before push, so they always equal the 1-based position in the array (before pruning).
|
||||
|
||||
OTel instruments are lazily initialised on first use to avoid module-level side effects during testing. The `Date.now()` calls for histogram timing are isolated to `register` and `checkCompatibility` and do not affect the pure `checkCompatibility` function in the compatibility module.
|
||||
|
||||
The `getOrCreateSubject` helper is `protected` rather than `private` to allow subclasses (e.g. a persistent database-backed registry) to hook into subject creation without duplicating the initialisation logic.
|
||||
@@ -0,0 +1,107 @@
|
||||
# schema-registry-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the stable error code registry and typed error class for `@suaveplan/schema-registry`. All errors thrown by the registry extend `SuaveplanError` via `SchemaRegistryError`, giving callers reliable `instanceof` checks and a machine-readable `code` property for programmatic error handling.
|
||||
|
||||
The code registry (`SCHEMA_REGISTRY_CODES`) uses `defineCodes` to produce fully-qualified, frozen string constants in the form `SCHEMA_REGISTRY.<REASON>`, enabling exhaustive switch statements on the `code` field.
|
||||
|
||||
## Features
|
||||
|
||||
- Four stable error codes covering every failure mode: `SCHEMA_NOT_FOUND`, `INCOMPATIBLE_SCHEMA`, `INVALID_SCHEMA`, `SUBJECT_NOT_FOUND`.
|
||||
- Fully-qualified code strings (`SCHEMA_REGISTRY.SCHEMA_NOT_FOUND` etc.) produced by `defineCodes` — frozen at build time.
|
||||
- `SchemaRegistryError` extends `SuaveplanError<SchemaRegistryCode>` for narrow type discrimination.
|
||||
- `SuaveplanErrorOptions` passthrough for `cause`, `context`, and `traceId`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SchemaRegistryError,
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
} from "@suaveplan/schema-registry/errors";
|
||||
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND,
|
||||
"Subject 'UserCreated' not found in registry",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SchemaRegistryError,
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
} from "@suaveplan/schema-registry/errors";
|
||||
|
||||
function handleError(err: unknown): void {
|
||||
if (err instanceof SchemaRegistryError) {
|
||||
switch (err.code) {
|
||||
case SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND:
|
||||
console.error("Subject missing — register it first");
|
||||
break;
|
||||
case SCHEMA_REGISTRY_CODES.INCOMPATIBLE_SCHEMA:
|
||||
console.error("Schema rejected by compatibility rule", err.context);
|
||||
break;
|
||||
case SCHEMA_REGISTRY_CODES.SCHEMA_NOT_FOUND:
|
||||
console.error("Requested version not found");
|
||||
break;
|
||||
case SCHEMA_REGISTRY_CODES.INVALID_SCHEMA:
|
||||
console.error("Schema definition is malformed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With cause chaining
|
||||
try {
|
||||
JSON.parse("{bad json}");
|
||||
} catch (cause) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.INVALID_SCHEMA,
|
||||
"Schema must be a valid JSON object",
|
||||
{ cause: cause as Error },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SCHEMA_REGISTRY_CODES`
|
||||
|
||||
`Record<string, SchemaRegistryCode>` — frozen map of reason keys to fully-qualified code strings.
|
||||
|
||||
| Key | Code string |
|
||||
|-----|-------------|
|
||||
| `SCHEMA_NOT_FOUND` | `"SCHEMA_REGISTRY.SCHEMA_NOT_FOUND"` |
|
||||
| `INCOMPATIBLE_SCHEMA` | `"SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA"` |
|
||||
| `INVALID_SCHEMA` | `"SCHEMA_REGISTRY.INVALID_SCHEMA"` |
|
||||
| `SUBJECT_NOT_FOUND` | `"SCHEMA_REGISTRY.SUBJECT_NOT_FOUND"` |
|
||||
|
||||
### `SchemaRegistryCode`
|
||||
|
||||
`type SchemaRegistryCode` — union of all four code strings, derived from `typeof SCHEMA_REGISTRY_CODES`.
|
||||
|
||||
### `SchemaRegistryError`
|
||||
|
||||
```ts
|
||||
class SchemaRegistryError extends SuaveplanError<SchemaRegistryCode>
|
||||
```
|
||||
|
||||
**Constructor params:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `code` | `SchemaRegistryCode` | Machine-readable error code |
|
||||
| `message` | `string` | Human-readable message (also the `messageKey` passed to `SuaveplanError`) |
|
||||
| `options` | `SuaveplanErrorOptions?` | Optional `cause`, `context`, `traceId` |
|
||||
|
||||
**Inherited properties:** `code`, `context`, `timestamp`, `traceId`, `name` (set to `"SchemaRegistryError"`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SuaveplanError` uses the second constructor argument as both the message string and the i18n message key. For schema-registry, the message is passed directly as a plain English string because the registry does not depend on `@suaveplan/i18n`. The `defineCodes` helper produces a frozen `Object.create(null)` map, so property access on `SCHEMA_REGISTRY_CODES` is type-safe but not prototype-pollutable.
|
||||
@@ -0,0 +1,127 @@
|
||||
# schema-registry.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the shared TypeScript types for `@suaveplan/schema-registry`. These types form the stable public contract between the compatibility engine, the registry store, and consumer code. All types are compile-time only — no runtime code is emitted from this file.
|
||||
|
||||
Centralising types here prevents circular imports between the compatibility module, the registry class, and the error layer, while keeping each module's own file focused on logic.
|
||||
|
||||
## Features
|
||||
|
||||
- `CompatibilityLevel` — union of the four enforcement modes: `"BACKWARD"`, `"FORWARD"`, `"FULL"`, `"NONE"`.
|
||||
- `SchemaFormat` — union of supported schema formats: `"json-schema"`, `"zod"`, `"avro"`, `"custom"`.
|
||||
- `SchemaDefinition` — the full wire shape of a registered schema version, including `id`, `version`, `subject`, `schema`, `format`, `createdAt`, and optional `metadata`.
|
||||
- `CompatibilityResult` — the structured output of a compatibility check: `compatible` boolean, the `level` that was applied, and a `string[]` of breaking-change descriptions.
|
||||
- `RegistryOptions` — constructor options for `SchemaRegistry`, covering `defaultCompatibility` and `maxVersionsPerSubject`.
|
||||
- `SubjectState` — internal per-subject registry bookkeeping type; exported for subclass and test access.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CompatibilityLevel,
|
||||
SchemaDefinition,
|
||||
CompatibilityResult,
|
||||
} from "@suaveplan/schema-registry";
|
||||
|
||||
const level: CompatibilityLevel = "BACKWARD";
|
||||
|
||||
const schema: SchemaDefinition = {
|
||||
id: "UserCreated#1",
|
||||
version: 1,
|
||||
subject: "UserCreated",
|
||||
schema: { type: "object", properties: { id: { type: "string" } } },
|
||||
format: "json-schema",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result: CompatibilityResult = {
|
||||
compatible: true,
|
||||
level,
|
||||
errors: [],
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
RegistryOptions,
|
||||
SubjectState,
|
||||
SchemaFormat,
|
||||
} from "@suaveplan/schema-registry";
|
||||
|
||||
const opts: RegistryOptions = {
|
||||
defaultCompatibility: "FULL",
|
||||
maxVersionsPerSubject: 50,
|
||||
};
|
||||
|
||||
// Internal bookkeeping — useful for subclass implementations
|
||||
const state: SubjectState = {
|
||||
versions: [],
|
||||
compatibility: "BACKWARD",
|
||||
};
|
||||
|
||||
// Format discrimination
|
||||
function isDeepCheckSupported(format: SchemaFormat): boolean {
|
||||
return format === "json-schema";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `CompatibilityLevel`
|
||||
|
||||
```ts
|
||||
type CompatibilityLevel = "BACKWARD" | "FORWARD" | "FULL" | "NONE";
|
||||
```
|
||||
|
||||
Controls which direction(s) of schema evolution are enforced on registration.
|
||||
|
||||
### `SchemaFormat`
|
||||
|
||||
```ts
|
||||
type SchemaFormat = "json-schema" | "zod" | "avro" | "custom";
|
||||
```
|
||||
|
||||
Identifies the type system used to interpret the `schema` field.
|
||||
|
||||
### `SchemaDefinition`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | `string` | `"${subject}#${version}"` — unique within the registry |
|
||||
| `version` | `number` | Positive integer, 1-based, auto-assigned |
|
||||
| `subject` | `string` | Logical event/contract name |
|
||||
| `schema` | `unknown` | Raw schema object |
|
||||
| `format` | `SchemaFormat` | How to interpret `schema` |
|
||||
| `createdAt` | `string` | ISO 8601 registration timestamp |
|
||||
| `metadata` | `Record<string, string>?` | Optional caller-supplied key/value pairs |
|
||||
|
||||
### `CompatibilityResult`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `compatible` | `boolean` | `true` if no breaking changes detected |
|
||||
| `level` | `CompatibilityLevel` | The level that was applied |
|
||||
| `errors` | `string[]` | Human-readable breaking-change descriptions |
|
||||
|
||||
### `RegistryOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `defaultCompatibility` | `CompatibilityLevel?` | `"BACKWARD"` | Applied when a subject is first created |
|
||||
| `maxVersionsPerSubject` | `number?` | `1000` | Prune cap for version history |
|
||||
|
||||
### `SubjectState`
|
||||
|
||||
Internal per-subject registry state. Exported for subclass and test access only.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `versions` | `SchemaDefinition[]` | Ordered list of registered versions |
|
||||
| `compatibility` | `CompatibilityLevel` | Active compatibility level for this subject |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All types are interface or type alias — there is no runtime code in this file. TypeScript will erase these entirely during compilation; consumers import them with `import type` to enforce this at the call site. The `SubjectState` interface is exported (not internal) because `SchemaRegistry` uses `protected` members per the project preference for `protected` over `private`, enabling subclasses to access subject state directly without re-exporting the internal map.
|
||||
117
plugins/document-navigator/docs/foundation/core/types/README.md
Normal file
117
plugins/document-navigator/docs/foundation/core/types/README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# @suaveplan/types - Types-only foundation — canonical shared interfaces (Clock, IdGenerator, Rng, MessageResolver, EventBusLike, LoggerLike, StorageLike, DatabaseLike, common option bundles). Zero runtime code, zero dependencies.
|
||||
|
||||
## Summary
|
||||
|
||||
Types-only foundation package. Zero runtime code, zero dependencies, zero bundle bytes. Provides the canonical shared interfaces that allow every `@suaveplan/*` package to accept and return the same shapes without coupling to a concrete implementation: `Clock` (`() => number`), `IdGenerator` (`() => string`), `Rng` (seeded random interface), `MessageResolver` (i18n hook), `EventBusLike<TMap>` (minimal event-bus contract), `LoggerLike` (minimal structured-logger contract), and three composable option bundles (`CancellableOptions`, `TraceableOptions`, `DeterministicOptions`). Always `import type` from this package — it emits nothing at runtime. This is the only `@suaveplan/*` dependency the testing-infrastructure packages (`testing`, `testing-dom`, `testing-e2e`) are permitted to take, because its build emits no runtime bytes and therefore creates no circular hazard.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [fetch-like](./src/fetch-like/fetch-like.md) — `FetchLike` is the canonical zero-runtime contract for an injectable `fetch`-shaped function across the Suaveplan monor…
|
||||
- [fetch-like](./src/fetch-like/fetch-like.md) — `FetchLike` is the canonical zero-runtime contract for an injectable `fetch`-shaped function across the Suaveplan monor…
|
||||
- [aead-ops](./src/aead-ops.md) — `AeadOps` is the canonical interface for an AEAD operations bundle — `encrypt` + `decrypt` over a fixed set of supporte…
|
||||
- [authority](./src/authority.md) — `Authority` is the canonical signing surface presented to every higher layer in the Suaveplan crypto stack. Concrete ba…
|
||||
- [chunk-presence](./src/chunk-presence.md) — A point-in-time snapshot of a file's chunk-availability state, used by `Drive.getPresence(path)` and the optional `pres…
|
||||
- [chunk-ref](./src/chunk-ref.md) — `ChunkRef` identifies a specific version of a file within a copy-on-write (CoW) versioning system. It is used by `CowMa…
|
||||
- [chunk-source](./src/chunk-source.md) — `ChunkSource` is the cross-cutting contract for retrieving content-addressed chunks from arbitrary backends — local sto…
|
||||
- [clock](./src/clock.md) — `Clock` is the canonical type alias for a function that returns the current time as milliseconds since the Unix epoch.…
|
||||
- [database-like](./src/database-like.md) — `DatabaseLike` is the minimal structural adapter contract for database connections. It exists so that consuming package…
|
||||
- [dir-entry](./src/dir-entry.md) — The structural shape of a single entry returned by `Drive.list(prefix)`. Includes the path component name, entry type (…
|
||||
- [domain-tag](./src/domain-tag.md) — `DomainTag` is the canonical branded-string type for cryptographic domain-separation tags. A signature produced under o…
|
||||
- [drive-stats](./src/drive-stats.md) — The structural shape of the result returned by `Drive.stat(path)`. Like `DirEntry` but with absolute `Date` values (ins…
|
||||
- [events-bus-like](./src/events-bus-like.md) — `EventBusLike` is the minimal structural contract for any event-bus implementation. It exists so packages (especially t…
|
||||
- [framing](./src/framing.md) — `FramingConfig` and `FrameParseResult` are the canonical types every Suaveplan byte-stream codec uses to describe how i…
|
||||
- [hash-like](./src/hash-like.md) — `HashLike` is the canonical hash-function contract every Suaveplan package accepts when it needs a digest primitive inj…
|
||||
- [health-status](./src/health-status.md) — Cross-cutting health check result used by storage adapters, chunk sources, network transports, and any component that e…
|
||||
- [i18n-backend](./src/i18n-backend.md) — `Backend` is the canonical plugin interface for any i18n backend implementation in the Suaveplan ecosystem. Memory, HTT…
|
||||
- [i18n-catalog](./src/i18n-catalog.md) — Catalog data shapes for the i18n family of packages. These types live in `@suaveplan/types` so the slim `@suaveplan/i18…
|
||||
- [id-generator](./src/id-generator.md) — `IdGenerator` is the canonical type alias for a function returning unique string identifiers. Every Suaveplan package t…
|
||||
- [key-status](./src/key-status.md) — Cross-cutting union type that names the three encryption states a file can be in: plaintext, encrypted-and-no-key, encr…
|
||||
- [logger-like](./src/logger-like.md) — `LoggerLike` is the minimal structural contract for a structured logger. It gives any package a type-level integration…
|
||||
- [message-envelope](./src/message-envelope.md) — The typed wire envelope every network message uses — generic in `TPayload`. Cross-cutting type so any plugin (transport…
|
||||
- [message-resolver](./src/message-resolver.md) — `MessageResolver` is the canonical type alias for an i18n message-resolution function. It takes an i18n key and optiona…
|
||||
- [middleware](./src/middleware.md) — Cross-cutting middleware contract for operation interception. Used by drive and other packages that need to wrap operat…
|
||||
- [network-plugin](./src/network-plugin.md) — Minimum structural shape every network plugin implements. Cross-cutting type so every plugin (transport, protocol, regi…
|
||||
- [options](./src/options.md) — The `options` module bundles the three cross-cutting option shapes that recur across many Suaveplan packages — cooperat…
|
||||
- [peer-id](./src/peer-id.md) — A type alias for peer identifiers in the mesh, plus the canonical broadcast sentinel `"*"`. Conventionally a 64-charact…
|
||||
- [pipeline-stage](./src/pipeline-stage.md) — `PipelineStage<TIn, TOut>` describes one node in a streaming pipeline: a named, optionally-parallelisable `StreamTransf…
|
||||
- [plugin](./src/plugin.md) — Cross-cutting plugin contracts for extensible packages. Two models are provided:
|
||||
- [rng](./src/rng.md) — `Rng` is the canonical interface for a pluggable random-number source. Every Suaveplan package that needs randomness (f…
|
||||
- [source-hint](./src/source-hint.md) — Cross-cutting union that names the three sources where a chunk may live or be fetched from: `local` (this drive's stora…
|
||||
- [storage-capabilities](./src/storage-capabilities.md) — Declares which optimisations a storage backend supports natively. The drive layer (and the `drive-plugin-chunks` layer)…
|
||||
- [storage-like](./src/storage-like.md) — `StorageLike` is a minimal structural interface for key-value storage backends. It represents the narrowest surface tha…
|
||||
- [stream-transformer](./src/stream-transformer.md) — `StreamTransformer<TIn, TOut>` is the generic contract Suaveplan pipeline nodes implement to participate in a streaming…
|
||||
- [streaming-aead-engine](./src/streaming-aead-engine.md) — `streaming-aead-engine.ts` defines the cross-package contract for streaming-AEAD engines. The interface lives in `@suav…
|
||||
- [timers-like](./src/timers-like.md) — `TimersLike` is the canonical zero-runtime contract for an injectable timer pair across Suaveplan. Every p…
|
||||
- [transform-progress](./src/transform-progress.md) — `TransformProgress` is the canonical progress shape every Suaveplan data-pipeline package emits to observers. By keepin…
|
||||
- [translation-record](./src/translation-record.md) — `TranslationRecord` is the canonical recursive shape for an i18n message catalog. Leaves are strings — the message temp…
|
||||
- [transport](./src/transport.md) — Minimum structural shape every transport implementation provides. Cross-cutting type so every transport plugin (`WebRTC…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Exports
|
||||
|
||||
- **`Clock`** — `() => number` (ms since epoch)
|
||||
- **`IdGenerator`** — `() => string`
|
||||
- **`Rng`** — `{ nextUint32, nextFloat }`
|
||||
- **`MessageResolver`** — `(key, params?) => string`, for i18n
|
||||
- **`EventBusLike<TMap>`** — minimal structural event-bus contract
|
||||
- **`LoggerLike`** — minimal structured-logger contract
|
||||
- **`CancellableOptions`**, **`TraceableOptions`**, **`DeterministicOptions`** — compose by intersection
|
||||
|
||||
### Cross-cutting contracts
|
||||
|
||||
- **`ChunkSource`** — content-addressed chunk retrieval interface
|
||||
- **`ChunkSourceHealth`** — `'healthy' | 'degraded' | 'failed' | 'unknown'`
|
||||
- **`ChunkSourceMetrics`** — performance counters
|
||||
- **`ChunkRef`** — CoW version reference
|
||||
- **`Plugin`** — lifecycle plugin interface
|
||||
- **`PluginType`** — plugin category union
|
||||
- **`PluginRegistration`** — registered plugin metadata
|
||||
- **`DrivePlugin`** — hook-based plugin interface
|
||||
- **`PluginHook`** — hook function signature
|
||||
- **`MiddlewareHandler`** — operation interceptor function
|
||||
- **`MiddlewareContext`** — middleware context with open index signature
|
||||
- **`NextFunction`** — advances the middleware chain
|
||||
- **`HealthStatus`** — generic health check result
|
||||
|
||||
### Drive · storage · network surface
|
||||
|
||||
- **`StorageCapabilities`** — five-flag native-capability matrix every storage backend declares
|
||||
- **`KeyStatus`** — three-state encryption status (`'unencrypted' | 'encrypted-locked' | 'encrypted-unlocked'`)
|
||||
- **`SourceHint`** — chunk source (`'local' | 'mesh' | 'cdn'`)
|
||||
- **`ChunkPresence`** — progress + source snapshot for a file
|
||||
- **`DirEntry`** / **`DirEntryType`** — `Drive.list()` entry shape with optional lock/progress fields
|
||||
- **`DriveStats`** — `Drive.stat(path)` result shape
|
||||
- **`PeerId`** / **`PeerIdBroadcast`** — peer identifier and broadcast sentinel `"*"`
|
||||
- **`MessageEnvelope`** — typed wire envelope `<TPayload = Uint8Array>`
|
||||
- **`TransportLike`** — five-method structural transport contract
|
||||
- **`NetworkPluginLike`** — `install/start/stop/dispose` lifecycle contract
|
||||
|
||||
Subpath exports: `@suaveplan/types/{chunk-source, chunk-ref, plugin, middleware, health-status, storage-capabilities, key-status, source-hint, chunk-presence, dir-entry, drive-stats, peer-id, message-envelope, transport, network-plugin}`.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add -d @suaveplan/types
|
||||
```
|
||||
|
||||
Always `import type { ... }` — the package exports nothing runtime-accessible.
|
||||
|
||||
```ts
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
```
|
||||
|
||||
## Why
|
||||
|
||||
Packages like `@suaveplan/error` and `@suaveplan/events` both need `Clock` and shared option shapes. Duplicating the declarations invites drift (`traceId` vs `trace_id`, `clock?` vs `getClock?`). This package gives every consumer one canonical shape.
|
||||
|
||||
It's also the only `@suaveplan/*` dependency the three testing-infrastructure packages (`testing`, `testing-dom`, `testing-e2e`) are allowed to take (see `CLAUDE.md` rule 8) — because the build emits no runtime bytes, there's no circular "testing-of-testing" hazard.
|
||||
|
||||
## Build guarantee
|
||||
|
||||
`tests/zero-runtime.test.ts` walks `dist/` after every build and fails CI if:
|
||||
|
||||
- Any `.js` file exceeds 200 bytes
|
||||
- Any `.js` file contains a `function`, `class`, `const ... =`, `let`, or `var` declaration
|
||||
- `dependencies` or `peerDependencies` grow beyond empty
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# AeadOps
|
||||
|
||||
`AeadOps` is the canonical interface for an AEAD operations bundle —
|
||||
`encrypt` + `decrypt` over a fixed set of supported algorithm names.
|
||||
Primitives that need to encrypt or decrypt inside a bit-packed envelope
|
||||
accept this bundle via dependency injection so they never need to import
|
||||
`@suaveplan/crypto`'s runtime layer directly.
|
||||
|
||||
## Purpose
|
||||
|
||||
Without `AeadOps`, every primitive that wants to encrypt would import
|
||||
`aeadEncrypt`/`aeadDecrypt` from `@suaveplan/crypto`, creating a hard
|
||||
dependency edge to the crypto package. That edge is what blocked the
|
||||
`bitpacker-crypto-rewrite` canary migration of `crypto::ChainBundle` —
|
||||
`@suaveplan/crypto` cannot depend on `@suaveplan/bitpacker-crypto` if
|
||||
`bitpacker-crypto` already depends on `@suaveplan/crypto`. By accepting
|
||||
`AeadOps` via injection, primitives stay decoupled from the canonical
|
||||
implementation; the caller wires in whatever AEAD source they trust.
|
||||
|
||||
The interface lives in `@suaveplan/types` so any package can declare an
|
||||
`AeadOps`-shaped option without dragging the crypto runtime through the
|
||||
type-system layer.
|
||||
|
||||
## Features
|
||||
|
||||
- `encrypt(algo, key, plaintext, { nonce, aad? })` — symmetric AEAD
|
||||
encrypt; returns ciphertext + tag concatenated
|
||||
- `decrypt(algo, key, ciphertext, { nonce, aad? })` — verifies the tag
|
||||
and returns plaintext; throws on mismatch
|
||||
- Closed list of `AeadAlgorithm` discriminators: `AES-256-GCM`,
|
||||
`ChaCha20-Poly1305`, `XChaCha20-Poly1305`
|
||||
- Shared `AeadOpsOptions` with required `nonce` and optional `aad`
|
||||
- Zero-runtime — pure type definitions
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { AeadOps, AeadAlgorithm } from "@suaveplan/types";
|
||||
|
||||
interface EncryptedSchemaOptions {
|
||||
readonly key: Uint8Array;
|
||||
readonly algorithm: AeadAlgorithm;
|
||||
readonly aead: AeadOps;
|
||||
}
|
||||
|
||||
function encrypt(opts: EncryptedSchemaOptions, plaintext: Uint8Array): Uint8Array {
|
||||
const nonce = new Uint8Array(24); // caller-supplied nonce
|
||||
return opts.aead.encrypt(opts.algorithm, opts.key, plaintext, { nonce });
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — wire the canonical implementation
|
||||
|
||||
```ts
|
||||
import type { AeadOps } from "@suaveplan/types";
|
||||
import { aeadEncrypt, aeadDecrypt } from "@suaveplan/crypto";
|
||||
|
||||
const cryptoAead: AeadOps = { encrypt: aeadEncrypt, decrypt: aeadDecrypt };
|
||||
|
||||
const schema = defineEncryptedBitSchema(bodyCodec, {
|
||||
key: aeadKey,
|
||||
aead: cryptoAead, // dependency-injected
|
||||
});
|
||||
```
|
||||
|
||||
For tests, swap in a recording or deterministic AEAD that satisfies the
|
||||
same shape:
|
||||
|
||||
```ts
|
||||
const recordingAead: AeadOps = {
|
||||
encrypt: (algo, key, pt, opts) => {
|
||||
recorded.push({ algo, ptLen: pt.length });
|
||||
return aeadEncrypt(algo, key, pt, opts);
|
||||
},
|
||||
decrypt: aeadDecrypt,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type AeadAlgorithm`
|
||||
|
||||
Closed union of supported algorithm names:
|
||||
|
||||
- `"AES-256-GCM"` — 12-byte nonce
|
||||
- `"ChaCha20-Poly1305"` — 12-byte nonce
|
||||
- `"XChaCha20-Poly1305"` — 24-byte nonce
|
||||
|
||||
### `interface AeadOpsOptions`
|
||||
|
||||
- `nonce: Uint8Array` — required; length must match the algorithm's
|
||||
expected nonce length
|
||||
- `aad?: Uint8Array` — optional additional authenticated data; participates
|
||||
in the integrity tag
|
||||
|
||||
### `interface AeadOps`
|
||||
|
||||
- `encrypt(algorithm, key, plaintext, options): Uint8Array`
|
||||
- `decrypt(algorithm, key, ciphertext, options): Uint8Array`
|
||||
|
||||
Both methods are synchronous because the canonical
|
||||
`@suaveplan/crypto::aeadEncrypt`/`aeadDecrypt` are synchronous (they
|
||||
delegate to the `noble` engine which has no async surface). A future
|
||||
asynchronous AEAD backend would need a separate interface.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The canonical implementation is `@suaveplan/crypto`'s `aeadEncrypt` /
|
||||
`aeadDecrypt` functions, packaged as `{ encrypt, decrypt }` literally —
|
||||
no adapter required.
|
||||
- This file deliberately ships zero runtime code. Algorithm constants
|
||||
(`AEAD_KEY_LENGTH`, `AEAD_NONCE_LENGTH`, `AEAD_XCHACHA_NONCE_LENGTH`)
|
||||
remain in `@suaveplan/crypto` because they are runtime values.
|
||||
- Callers that need a different engine (libsodium via
|
||||
`@suaveplan/crypto-sodium`, future hardware-accelerated impls) can
|
||||
build their own `AeadOps`-shaped bundle and inject it without
|
||||
changing the consumer primitive.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Authority
|
||||
|
||||
`Authority` is the canonical signing surface presented to every higher
|
||||
layer in the Suaveplan crypto stack. Concrete backends (in-memory v1,
|
||||
future HSM/TPM/threshold) implement this interface directly. Raw
|
||||
`secretKey: Uint8Array` references never cross this boundary — callers
|
||||
see only the abstract `sign(domain, slot, payload)` operation.
|
||||
|
||||
## Purpose
|
||||
|
||||
The interface lives in `@suaveplan/types` so primitives that consume an
|
||||
`Authority` (e.g. `@suaveplan/bitpacker-crypto`'s `SignedBitSchema`,
|
||||
`MultiSignedBitSchema`, `HashChainSchema`) can do so without depending on
|
||||
`@suaveplan/crypto`'s runtime layer. Without this separation, any package
|
||||
that wants to declare an `Authority`-shaped option creates a hard dep edge
|
||||
to `@suaveplan/crypto` — and any package that `@suaveplan/crypto` depends
|
||||
on (or its transitive dependents like the chain-bundle / public-bundle
|
||||
canaries) cannot use those primitives without inducing a circular
|
||||
dependency.
|
||||
|
||||
The default `InMemoryAuthority` implementation lives in
|
||||
`@suaveplan/crypto/authority`. The pure verifier function
|
||||
(`verifyAuthoritySignature`) and the closed list of `DomainTag` values
|
||||
also live in `@suaveplan/crypto`.
|
||||
|
||||
## Features
|
||||
|
||||
- `Authority.sign(domain, slot, payload, signal?)` — async signing under
|
||||
a domain-separation tag with a slot-id-addressed key
|
||||
- `Authority.getPublicSlot(slotId)` — look up the public material for a
|
||||
slot; returns `undefined` if absent
|
||||
- `Authority.hasSlot(slotId)` — used by tests and integration-time guards
|
||||
- `Authority.dispose()` — zeroise every secret-key buffer; idempotent
|
||||
- `Authority.disposed` — readonly flag for assertions
|
||||
- Companion types: `KeySlotPublic<TLayoutFields>`,
|
||||
`KeySlotPrivate<TLayoutFields>`, `SlotId`
|
||||
- Companion verifier function type: `AuthorityVerifier`
|
||||
- Zero-runtime — pure type definitions, no runtime cost
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Authority,
|
||||
AuthorityVerifier,
|
||||
DomainTag,
|
||||
} from "@suaveplan/types";
|
||||
|
||||
interface SignedSchemaOptions {
|
||||
readonly domain: DomainTag;
|
||||
readonly verifier: AuthorityVerifier;
|
||||
}
|
||||
|
||||
async function pack(
|
||||
payload: Uint8Array,
|
||||
authority: Authority,
|
||||
domain: DomainTag
|
||||
): Promise<Uint8Array> {
|
||||
return authority.sign(domain, "sign", payload);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — dependency-injected verifier
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Authority,
|
||||
AuthorityVerifier,
|
||||
DomainTag,
|
||||
KeySlotPublic,
|
||||
} from "@suaveplan/types";
|
||||
|
||||
// A primitive that takes both the Authority (for sign) and the matching
|
||||
// verifier (for verify) via dependency injection. The primitive itself
|
||||
// imports nothing from @suaveplan/crypto's runtime — the caller wires
|
||||
// the canonical implementations.
|
||||
export interface SignedFrameOptions {
|
||||
readonly domain: DomainTag;
|
||||
readonly verifier: AuthorityVerifier;
|
||||
}
|
||||
|
||||
export function defineSignedFrame(opts: SignedFrameOptions) {
|
||||
return {
|
||||
async pack(authority: Authority, payload: Uint8Array): Promise<Uint8Array> {
|
||||
const sig = await authority.sign(opts.domain, "sign", payload);
|
||||
const out = new Uint8Array(payload.length + sig.length);
|
||||
out.set(payload, 0);
|
||||
out.set(sig, payload.length);
|
||||
return out;
|
||||
},
|
||||
verify(bytes: Uint8Array, slot: KeySlotPublic<unknown>): boolean {
|
||||
const split = bytes.length - 64;
|
||||
const payload = bytes.slice(0, split);
|
||||
const sig = bytes.slice(split);
|
||||
return opts.verifier(opts.domain, slot, payload, sig);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Caller-side wiring (the only place crypto's runtime gets imported):
|
||||
// import { verifyAuthoritySignature } from "@suaveplan/crypto/authority";
|
||||
// import { DOMAIN_PUBLIC_KEYCHAIN_V1 } from "@suaveplan/crypto/domain-tags";
|
||||
// const frame = defineSignedFrame({
|
||||
// domain: DOMAIN_PUBLIC_KEYCHAIN_V1,
|
||||
// verifier: verifyAuthoritySignature,
|
||||
// });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface Authority`
|
||||
|
||||
- `sign(domain: DomainTag, slotId: SlotId, payload: Uint8Array, signal?: AbortSignal): Promise<Uint8Array>`
|
||||
- `getPublicSlot(slotId: SlotId): KeySlotPublic<unknown> | undefined`
|
||||
- `hasSlot(slotId: SlotId): boolean`
|
||||
- `dispose(): void`
|
||||
- `readonly disposed: boolean`
|
||||
|
||||
### `interface KeySlotPublic<TLayoutFields = unknown>`
|
||||
|
||||
- `coordinate: unknown` — concrete shape lives in `@suaveplan/crypto`
|
||||
- `algorithm: number` — algorithm-id enum from `@suaveplan/crypto`
|
||||
- `notBefore: number`, `notAfter: number` — validity window
|
||||
- `publicKey: Uint8Array`
|
||||
- `hasPublic: boolean`
|
||||
|
||||
### `interface KeySlotPrivate<TLayoutFields = unknown>`
|
||||
|
||||
- All of `KeySlotPublic` plus `secretKey: Uint8Array`
|
||||
|
||||
### `type SlotId = string`
|
||||
|
||||
Closed enum lives in `@suaveplan/crypto::PurposeTag`. Treated as opaque
|
||||
string here.
|
||||
|
||||
### `type AuthorityVerifier = (domain, publicSlot, payload, signature) => boolean`
|
||||
|
||||
The pure verifier function shape. Returns `true` on verify success,
|
||||
`false` on signature mismatch. MAY throw on structural errors
|
||||
(wrong-length sig / pubkey, unsupported algorithm) — primitives that
|
||||
need a boolean answer should swallow such throws and treat them as
|
||||
`false`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `coordinate` field is typed as `unknown` because the concrete
|
||||
`DerivationCoordinate<TLayoutFields>` shape lives in
|
||||
`@suaveplan/crypto`, and pulling it into `@suaveplan/types` would
|
||||
create a layering loop. Verifiers do not read it; only `algorithm`
|
||||
and `publicKey` are load-bearing for the verify path.
|
||||
- The `_phantomLayout?: TLayoutFields` marker on `KeySlotPublic` carries
|
||||
the generic so callers can preserve a typed layout-fields parameter
|
||||
through the public surface without forcing the type argument to
|
||||
appear at every call site.
|
||||
- This file deliberately ships zero runtime code. The canonical
|
||||
`InMemoryAuthority` implementation, `inMemoryAuthority` factory, and
|
||||
`verifyAuthoritySignature` function all live in
|
||||
`@suaveplan/crypto/authority`.
|
||||
- Future backends (HSM, TPM, threshold signing) implement this same
|
||||
interface and slot in via the same DI pattern shown in the advanced
|
||||
example. No changes to the primitives that consume `Authority` are
|
||||
needed when a new backend ships.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ChunkPresence
|
||||
|
||||
## Purpose
|
||||
|
||||
A point-in-time snapshot of a file's chunk-availability state, used by `Drive.getPresence(path)` and the optional `presence` field on `DirEntry`. Encodes both progress (`chunksPresent / chunksTotal`) and origin (`sourceHints`), plus a `stuckChunks` counter for chunks that failed retrieval across all configured sources. Living in `@suaveplan/types` lets UIs render `███░░░░░░░ 33%` progress bars and ⚠ stuck-state badges without importing drive internals — every consumer that reads a `DirEntry` can type-check the optional `presence` field and render it.
|
||||
|
||||
## Features
|
||||
|
||||
- Four readonly fields covering every facet of presence UX a UI needs.
|
||||
- Combines with `SourceHint` to describe both how much is present and where the rest could come from.
|
||||
- `stuckChunks` provides a stuck-state signal without forcing UIs to track timeouts themselves.
|
||||
- Snapshot semantics — implementations may return cached values (≤ 1 s freshness per the `drive-template` capability spec).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { ChunkPresence } from "@suaveplan/types/chunk-presence";
|
||||
|
||||
const presence: ChunkPresence = {
|
||||
chunksPresent: 264,
|
||||
chunksTotal: 800,
|
||||
sourceHints: ["local", "mesh"],
|
||||
stuckChunks: 0,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { ChunkPresence } from "@suaveplan/types/chunk-presence";
|
||||
|
||||
function renderProgressBar(presence: ChunkPresence): string {
|
||||
const filled = Math.round((presence.chunksPresent / presence.chunksTotal) * 10);
|
||||
const bar = "█".repeat(filled) + "░".repeat(10 - filled);
|
||||
const pct = Math.round((presence.chunksPresent / presence.chunksTotal) * 100);
|
||||
const stuck = presence.stuckChunks > 0 ? ` ⚠${presence.stuckChunks}` : "";
|
||||
return `${bar} ${pct}%${stuck}`;
|
||||
}
|
||||
|
||||
renderProgressBar({
|
||||
chunksPresent: 264,
|
||||
chunksTotal: 800,
|
||||
sourceHints: ["local", "mesh"],
|
||||
stuckChunks: 0,
|
||||
});
|
||||
// "███░░░░░░░ 33%"
|
||||
|
||||
renderProgressBar({
|
||||
chunksPresent: 798,
|
||||
chunksTotal: 800,
|
||||
sourceHints: ["mesh"],
|
||||
stuckChunks: 2,
|
||||
});
|
||||
// "██████████ 100% ⚠2"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface ChunkPresence`
|
||||
|
||||
Four readonly fields:
|
||||
|
||||
- `chunksPresent: number` — count of chunks the drive has locally AND has verified (Ed25519 sig + AEAD tag). A chunk that was downloaded but failed verification is NOT counted; it stays unverified until a clean copy arrives.
|
||||
- `chunksTotal: number` — total chunks in the file according to the manifest. Constant per `(fileId, version)`; only changes when the file version bumps (CoW).
|
||||
- `sourceHints: readonly SourceHint[]` — ordered list of sources where the missing chunks could come from. `local` if some chunks are partially local; `mesh` if peers advertise them; `cdn` if the CDN is configured and the file's policy permits.
|
||||
- `stuckChunks: number` — count of chunks that failed retrieval from every configured source within the last 60 seconds. Drives a `drive:chunk:stuck` event and a UI ⚠ badge. Resets on retry-success (a chunk delivers) or peer-population (a peer advertises a chunk we'd given up on).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`ChunkPresence` is a snapshot, not a subscription. UIs that want to update progress in real time should subscribe to `drive:chunk:received` and `drive:chunk:stuck` bus events and recompute presence from a per-file `Bitset` they maintain locally. Polling `Drive.getPresence` 10× per second works for small drives but doesn't scale — bus subscription is the recommended path.
|
||||
|
||||
The 60-second window for `stuckChunks` is set in the `drive-template` capability spec (`DTP-26` area). Implementations track failed-fetch timestamps per chunk; on each access, chunks whose failures are older than 60 s are excluded from the count. This keeps `stuckChunks` actionable — a chunk that became stuck five minutes ago has either been retried or the user has moved on.
|
||||
|
||||
`sourceHints` ordering is significant: `local` before `mesh` before `cdn`. The drive's read pipeline tries sources in this order; UIs should also sort their visual indicators (e.g., a "P2P" badge appears before a "CDN" badge) so the user's mental model matches the implementation. An empty `sourceHints` array means no source can deliver — the file is fully stuck and the UI should surface that distinctly.
|
||||
@@ -0,0 +1,53 @@
|
||||
# ChunkRef
|
||||
|
||||
## Purpose
|
||||
|
||||
`ChunkRef` identifies a specific version of a file within a copy-on-write (CoW) versioning system. It is used by `CowManager` to track multiple versions of the same file without duplicating chunk data.
|
||||
|
||||
## Features
|
||||
|
||||
- **Immutable reference** — once created, a `ChunkRef` always points to the same version
|
||||
- **Integrity verification** — includes a content checksum for corruption detection
|
||||
- **Lightweight** — three fields, no runtime dependencies
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type { ChunkRef } from '@suaveplan/types/chunk-ref';
|
||||
|
||||
const ref: ChunkRef = {
|
||||
fileId: 'document.pdf',
|
||||
version: 3,
|
||||
checksum: 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type { ChunkRef } from '@suaveplan/types/chunk-ref';
|
||||
|
||||
interface VersionedFile {
|
||||
refs: ChunkRef[];
|
||||
current: number;
|
||||
}
|
||||
|
||||
function rollback(file: VersionedFile, targetVersion: number): ChunkRef | undefined {
|
||||
return file.refs.find(ref => ref.version === targetVersion);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ChunkRef`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `fileId` | `string` | Logical file identifier |
|
||||
| `version` | `number` | Monotonic version number |
|
||||
| `checksum` | `string` | Content checksum for integrity |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `version` starts at `0` for the first write and increments by `1` for each subsequent write.
|
||||
- `checksum` is typically a prefixed hash such as `sha256:hex` or `blake3:hex`.
|
||||
@@ -0,0 +1,155 @@
|
||||
# ChunkSource
|
||||
|
||||
## Purpose
|
||||
|
||||
`ChunkSource` is the cross-cutting contract for retrieving content-addressed chunks from arbitrary backends — local storage, P2P meshes, CDNs, or any other data source. It lives in `@suaveplan/types` so that `drive`, `network-webrtc`, `network-libp2p`, and future transport packages can share a single canonical interface without circular dependencies.
|
||||
|
||||
## Features
|
||||
|
||||
- **Content-addressed retrieval** — chunks are identified by `string` content hashes (typically SHA-256 hex)
|
||||
- **Batch retrieval** — `getChunks` allows fetching multiple chunks in one call
|
||||
- **Availability probing** — `isAvailable` lets callers skip unreachable sources quickly
|
||||
- **Health tracking** — `health` field and optional `updateHealth` for reactive monitoring
|
||||
- **Performance metrics** — optional `getMetrics` for latency and success-rate telemetry
|
||||
- **Cancellation support** — `getChunk` and `getChunks` accept an optional `AbortSignal`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type { ChunkSource, ChunkSourceHealth } from '@suaveplan/types/chunk-source';
|
||||
|
||||
class MemoryChunkSource implements ChunkSource {
|
||||
readonly id = 'memory';
|
||||
readonly type = 'memory';
|
||||
readonly priority = 0;
|
||||
health: ChunkSourceHealth = 'healthy';
|
||||
private chunks = new Map<string, Uint8Array>();
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getChunk(chunkId: string): Promise<Uint8Array | null> {
|
||||
return this.chunks.get(chunkId) ?? null;
|
||||
}
|
||||
|
||||
async getChunks(chunkIds: string[]): Promise<(Uint8Array | null)[]> {
|
||||
return chunkIds.map(id => this.chunks.get(id) ?? null);
|
||||
}
|
||||
|
||||
async hasChunk(chunkId: string): Promise<boolean> {
|
||||
return this.chunks.has(chunkId);
|
||||
}
|
||||
|
||||
setChunk(chunkId: string, data: Uint8Array): void {
|
||||
this.chunks.set(chunkId, data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type { ChunkSource, ChunkSourceMetrics, ChunkSourceHealth } from '@suaveplan/types/chunk-source';
|
||||
|
||||
class PrioritisedChunkSource implements ChunkSource {
|
||||
readonly id: string;
|
||||
readonly type = 'composite';
|
||||
readonly priority: number;
|
||||
health: ChunkSourceHealth = 'unknown';
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
priority: number,
|
||||
private sources: ChunkSource[],
|
||||
) {
|
||||
this.id = id;
|
||||
this.priority = priority;
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const results = await Promise.all(this.sources.map(s => s.isAvailable()));
|
||||
return results.some(Boolean);
|
||||
}
|
||||
|
||||
async getChunk(chunkId: string, signal?: AbortSignal): Promise<Uint8Array | null> {
|
||||
for (const source of this.sources) {
|
||||
if (!await source.isAvailable()) continue;
|
||||
const data = await source.getChunk(chunkId, signal);
|
||||
if (data) return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getChunks(chunkIds: string[], signal?: AbortSignal): Promise<(Uint8Array | null)[]> {
|
||||
return Promise.all(chunkIds.map(id => this.getChunk(id, signal)));
|
||||
}
|
||||
|
||||
async hasChunk(chunkId: string): Promise<boolean> {
|
||||
const results = await Promise.all(this.sources.map(s => s.hasChunk(chunkId)));
|
||||
return results.some(Boolean);
|
||||
}
|
||||
|
||||
updateHealth(health: ChunkSourceHealth): void {
|
||||
this.health = health;
|
||||
}
|
||||
|
||||
async getMetrics(): Promise<ChunkSourceMetrics> {
|
||||
const metrics = await Promise.all(
|
||||
this.sources.map(s => s.getMetrics?.() ?? Promise.resolve({
|
||||
avgResponseTime: 0,
|
||||
successRate: 1,
|
||||
totalRequests: 0,
|
||||
failedRequests: 0,
|
||||
lastAccess: new Date(),
|
||||
})),
|
||||
);
|
||||
const totalRequests = metrics.reduce((sum, m) => sum + m.totalRequests, 0);
|
||||
const failedRequests = metrics.reduce((sum, m) => sum + m.failedRequests, 0);
|
||||
return {
|
||||
avgResponseTime: metrics.reduce((sum, m) => sum + m.avgResponseTime, 0) / metrics.length,
|
||||
successRate: totalRequests > 0 ? (totalRequests - failedRequests) / totalRequests : 1,
|
||||
totalRequests,
|
||||
failedRequests,
|
||||
lastAccess: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `ChunkSourceHealth`
|
||||
|
||||
Union type: `'healthy' | 'degraded' | 'failed' | 'unknown'`.
|
||||
|
||||
### `ChunkSourceMetrics`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `avgResponseTime` | `number` | Average response time in ms |
|
||||
| `successRate` | `number` | Ratio between 0 and 1 |
|
||||
| `totalRequests` | `number` | Total requests made |
|
||||
| `failedRequests` | `number` | Total failed requests |
|
||||
| `lastAccess` | `Date` | Timestamp of last access |
|
||||
|
||||
### `ChunkSource`
|
||||
|
||||
| Property / Method | Type | Description |
|
||||
|-------------------|------|-------------|
|
||||
| `id` | `string` (readonly) | Unique source identifier |
|
||||
| `type` | `string` (readonly) | Source type label |
|
||||
| `priority` | `number` (readonly) | Lower = higher priority |
|
||||
| `health` | `ChunkSourceHealth` (optional, readonly) | Current health |
|
||||
| `isAvailable()` | `() => Promise<boolean>` | Probes readiness |
|
||||
| `getChunk(chunkId, signal?)` | `(string, AbortSignal?) => Promise<Uint8Array \| null>` | Fetch one chunk |
|
||||
| `getChunks(chunkIds, signal?)` | `(string[], AbortSignal?) => Promise<(Uint8Array \| null)[]>` | Fetch many |
|
||||
| `hasChunk(chunkId)` | `(string) => Promise<boolean>` | Existence check |
|
||||
| `updateHealth?(health)` | `(ChunkSourceHealth) => void` | External health update |
|
||||
| `getMetrics?()` | `() => Promise<ChunkSourceMetrics>` | Performance telemetry |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `getChunks` defaults to sequential `getChunk` calls in the base interface. Implementations that support batching (e.g. HTTP multipart or P2P torrents) SHOULD override it.
|
||||
- `priority` follows the convention where lower numbers indicate higher priority. A local cache with priority `1` is tried before a P2P source with priority `10`.
|
||||
- The `AbortSignal` parameter is optional. Sources that do not support cancellation MAY ignore it.
|
||||
@@ -0,0 +1,50 @@
|
||||
# clock
|
||||
|
||||
## Purpose
|
||||
|
||||
`Clock` is the canonical type alias for a function that returns the current time as milliseconds since the Unix epoch. Every Suaveplan package that reads time accepts a `Clock` injection so tests can make it deterministic and cross-package fakes are interchangeable.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-only — zero runtime cost
|
||||
- One canonical shape for every package's time injection
|
||||
- Return value unit is milliseconds (matches `Date.now()` and `performance.timeOrigin + performance.now()`)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
|
||||
const counter: Clock = (() => {
|
||||
let t = 0;
|
||||
return () => ++t;
|
||||
})();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
// Deterministic clock pinned to a specific epoch for snapshot tests.
|
||||
const pinnedClock: Clock = () => 1_700_000_000_000;
|
||||
|
||||
const bus = new EventBus<{ tick: null }>({ clock: pinnedClock });
|
||||
bus.on("tick", (_, envelope) => console.log(envelope.timestamp));
|
||||
// Every emitted envelope carries timestamp: 1700000000000
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type Clock = () => number`
|
||||
|
||||
A function returning milliseconds since epoch. No arguments, no side effects expected.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Milliseconds (not nanoseconds) is the unit every consuming package expects because JavaScript's native `Date` APIs all speak milliseconds. A `Clock` implementation MAY return any monotonically non-decreasing sequence — consumers that need wall-clock semantics should call `Date.now()` via `defaultClock` from the package that owns a particular bus/factory/etc. Consumers that accept a `Clock` option MUST call it exactly once per observation to avoid pulling inconsistent readings.
|
||||
|
||||
### Ecosystem Usage
|
||||
|
||||
Every package in Suaveplan that reads time accepts a `Clock` on its options bundle: `EventBus` in `@suaveplan/events`, `Stopwatch` / `Countdown` / `IntervalTimer` in `@suaveplan/timing`, the business-day and recurrence helpers in `@suaveplan/datetime`, the retry loop in `@suaveplan/retry`, and the `IdempotencyGuard` in `@suaveplan/idempotency`. Uniformity at the type level means a single `FakeClock` from `@suaveplan/testing` drops straight into any of them without adapter code.
|
||||
@@ -0,0 +1,80 @@
|
||||
# DatabaseLike
|
||||
|
||||
## Purpose
|
||||
|
||||
`DatabaseLike` is the minimal structural adapter contract for database connections. It exists so that consuming packages — `audit-logging`, `idempotency`, `feature-flags` — can accept "any database" as a dependency injection point without coupling to a specific driver or pulling in Drizzle types.
|
||||
|
||||
This is deliberately **not** the primary public API of `@suaveplan/db-sqlite`. That package exposes a richer type-safe Drizzle ORM surface (select, insert, update, delete, with full TypeScript inference). Raw query access in `db-sqlite` lives only under the explicit `unsafe` subpath. `DatabaseLike` is the lowest common denominator: the contract that `@suaveplan/surrealdb` wrappers, test doubles, and generic adapters implement.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero runtime code — pure TypeScript interface
|
||||
- Async-first (all methods return `Promise`)
|
||||
- Generic `query<T>` for typed result rows
|
||||
- `transaction()` with automatic commit/rollback semantics
|
||||
- Deliberately minimal — concrete packages extend with their full API on top
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type { DatabaseLike } from '@suaveplan/types';
|
||||
|
||||
class AuditLog {
|
||||
constructor(private readonly db: DatabaseLike) {}
|
||||
|
||||
async record(event: string, userId: string): Promise<void> {
|
||||
await this.db.execute(
|
||||
'INSERT INTO audit_log (event, user_id, ts) VALUES (?, ?, ?)',
|
||||
[event, userId, Date.now()]
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type { DatabaseLike } from '@suaveplan/types';
|
||||
|
||||
// A minimal in-memory stub for unit tests — no real DB required
|
||||
class MemoryDb implements DatabaseLike {
|
||||
private rows: unknown[] = [];
|
||||
|
||||
async query<T>(sql: string, _params?: readonly unknown[]) {
|
||||
return this.rows as T[];
|
||||
}
|
||||
|
||||
async execute(_sql: string, _params?: readonly unknown[]) {
|
||||
return { rowsAffected: 0 };
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (tx: DatabaseLike) => Promise<T>) {
|
||||
return fn(this); // no real transaction semantics in-memory
|
||||
}
|
||||
|
||||
async close() {}
|
||||
}
|
||||
|
||||
// The same AuditLog works with the stub or with a real @suaveplan/db-sqlite
|
||||
const log = new AuditLog(new MemoryDb());
|
||||
await log.record('login', 'user-42');
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DatabaseLike`
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `query` | `<T>(sql: string, params?: readonly unknown[]) => Promise<T[]>` | Execute a query and return all result rows |
|
||||
| `execute` | `(sql: string, params?: readonly unknown[]) => Promise<{ rowsAffected: number }>` | Execute a non-row-returning statement |
|
||||
| `transaction` | `<T>(fn: (tx: DatabaseLike) => Promise<T>) => Promise<T>` | Run `fn` in a transaction; commit on resolve, rollback on throw |
|
||||
| `close` | `() => Promise<void>` | Close the connection and release resources |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `sql` parameter is intentionally typed as `string` rather than a tagged template to keep the interface driver-agnostic. `@suaveplan/db-sqlite` uses SurrealQL-agnostic raw SQL only internally (via Drizzle); SurrealDB adapters pass SurrealQL through the same parameter.
|
||||
|
||||
Drivers that do not support transactions natively (e.g. some SurrealDB modes) should document that limitation on their concrete adapter — the interface contract allows a no-op implementation where rollback is not guaranteed.
|
||||
|
||||
The `query`/`execute` split mirrors the conventional JDBC/ODBC model. Packages that only write (never read) accept `Pick<DatabaseLike, 'execute'>` to express the narrower contract explicitly.
|
||||
@@ -0,0 +1,90 @@
|
||||
# DirEntry
|
||||
|
||||
## Purpose
|
||||
|
||||
The structural shape of a single entry returned by `Drive.list(prefix)`. Includes the path component name, entry type (file or dir), size, mtime, optional POSIX-ish permission bits, and the two UX-driving fields that the drive layer attaches: `keyStatus` (for the lock icon) and `presence` (for the progress bar). Living in `@suaveplan/types` lets UIs iterate a `DirEntry[]` and render every visible state — name, size, lock state, progress — without importing the drive package or any of its plugins. The drive remains free to extend the shape with backend-specific metadata; consumers are required to tolerate unknown fields.
|
||||
|
||||
## Features
|
||||
|
||||
- Five required fields covering the structural minimum every directory entry exposes.
|
||||
- Two optional UX fields (`keyStatus`, `presence`) — present only for files, driven by drive-template / drive-mesh state.
|
||||
- Discriminated `type: "dir" | "file"` enables exhaustive switches in render code.
|
||||
- `readonly` fields prevent UI code from mutating the snapshot accidentally.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { DirEntry } from "@suaveplan/types/dir-entry";
|
||||
|
||||
const file: DirEntry = {
|
||||
name: "wedding.jpg",
|
||||
type: "file",
|
||||
size: 52_428_800,
|
||||
mtime: 1735689600000,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { DirEntry } from "@suaveplan/types/dir-entry";
|
||||
|
||||
function renderRow(entry: DirEntry): string {
|
||||
if (entry.type === "dir") {
|
||||
return `📁 ${entry.name}/`;
|
||||
}
|
||||
const size = entry.size !== undefined ? `${entry.size}B` : "";
|
||||
const lock =
|
||||
entry.keyStatus === "encrypted-locked"
|
||||
? "🔒"
|
||||
: entry.keyStatus === "encrypted-unlocked"
|
||||
? "🔓"
|
||||
: "";
|
||||
const progress = entry.presence
|
||||
? ` ${entry.presence.chunksPresent}/${entry.presence.chunksTotal}`
|
||||
: "";
|
||||
return `📄 ${entry.name} ${size} ${lock}${progress}`;
|
||||
}
|
||||
|
||||
const entries: DirEntry[] = [
|
||||
{ name: "photos", type: "dir" },
|
||||
{
|
||||
name: "wedding.jpg",
|
||||
type: "file",
|
||||
size: 52_428_800,
|
||||
keyStatus: "encrypted-unlocked",
|
||||
presence: {
|
||||
chunksPresent: 264,
|
||||
chunksTotal: 800,
|
||||
sourceHints: ["local", "mesh"],
|
||||
stuckChunks: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
entries.map(renderRow).join("\n");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type DirEntryType`
|
||||
|
||||
Union: `"dir" | "file"`.
|
||||
|
||||
### `interface DirEntry`
|
||||
|
||||
- `name: string` — single path component, no `/`. Drives normalize names (NFC unicode, no leading/trailing whitespace) before listing.
|
||||
- `type: DirEntryType` — `"dir"` for directories, `"file"` for files. Drives never return symlinks at the structural layer; symlink semantics are layered on by the path-resolution middleware.
|
||||
- `size?: number` — plaintext byte count. Present for files. Absent for directories (drives may compute directory size on demand via a separate API; it is not part of the structural shape).
|
||||
- `mtime?: number` — last-modified time in unix milliseconds (Date.now() precision). Optional because some backends do not preserve mtime (`MemoryStorage`, `IndexedDbStorage` round-trips lose it).
|
||||
- `perm?: number` — POSIX-ish permission bits. Optional. Present when the backend stores them (`FileStorage`, `FatStorage`); absent on backends that do not (`S3`, `MemoryStorage`).
|
||||
- `keyStatus?: KeyStatus` — encryption state of the file. Present only for files. Drives compute it from the FileLocation's `encryption` config and the KeyRing's current state.
|
||||
- `presence?: ChunkPresence` — chunk presence snapshot. Present only for files. Drives compute it from the local chunk store, the mesh registry, and the configured CDN.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `keyStatus` and `presence` fields are optional because populating them requires drive-mesh state and KeyRing state that the bare drive core does not have. A drive without the mesh and chunks plugins may return `DirEntry`s with both fields undefined — the UI should treat absence as "unknown" and render a neutral state.
|
||||
|
||||
UIs SHOULD subscribe to `drive:hydrated`, `drive:key:granted`, `drive:key:revoked`, `drive:chunk:received`, and `drive:chunk:stuck` events to refresh `DirEntry`s on state changes rather than polling `Drive.list`. Listing is O(directory depth + children); subscribing is O(events) and updates only what changed.
|
||||
|
||||
The `readonly` modifiers on every field are deliberate — the entry is a snapshot. Mutating a `DirEntry` instance (e.g., to update progress in place) leads to subtle bugs when the same instance is referenced by multiple components. UIs should always treat a `DirEntry` as immutable and create new instances when state changes.
|
||||
@@ -0,0 +1,84 @@
|
||||
# DomainTag
|
||||
|
||||
`DomainTag` is the canonical branded-string type for cryptographic
|
||||
domain-separation tags. A signature produced under one domain MUST NOT
|
||||
verify against the same payload presented under a different domain — the
|
||||
tag bytes themselves participate in the signature input via
|
||||
`buildSigningInput(tag, payload)` in `@suaveplan/crypto/domain-tags`.
|
||||
|
||||
## Purpose
|
||||
|
||||
Domain separation is the standard defence against cross-protocol replay.
|
||||
Without it, a signature produced for, say, a vault payload might verify
|
||||
against a packer envelope that happens to share the same payload bytes.
|
||||
By mixing a per-domain tag into the signing input, the schema makes
|
||||
cross-domain replay impossible at the bytes-don't-match level instead of
|
||||
at any application-layer check.
|
||||
|
||||
The TYPE lives in `@suaveplan/types` so any primitive that accepts a
|
||||
`DomainTag` from a caller — `SignedBitSchema`, `MultiSignedBitSchema`,
|
||||
`HashChainSchema` in `@suaveplan/bitpacker-crypto` — can do so without
|
||||
depending on `@suaveplan/crypto`'s runtime layer. The closed list of
|
||||
registered tag VALUES (`DOMAIN_VAULT_V1`, `DOMAIN_PUBLIC_KEYCHAIN_V1`,
|
||||
`DOMAIN_BITPACKER_SIGNED_V1`, …) and the `encodeDomainTag` /
|
||||
`buildSigningInput` helpers stay in `@suaveplan/crypto/domain-tags` — they
|
||||
are runtime values and runtime functions.
|
||||
|
||||
## Features
|
||||
|
||||
- Branded `string`-based type — cannot be constructed from an arbitrary
|
||||
string at the type level; callers must import a registered constant
|
||||
- Zero-runtime — pure type alias, no runtime cost
|
||||
- Cross-package dependency-free — `@suaveplan/types` has no workspace
|
||||
dependencies, so any package can import this without dragging in
|
||||
`@suaveplan/crypto`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { DomainTag } from "@suaveplan/types";
|
||||
|
||||
interface SignedSchemaOptions {
|
||||
readonly domain: DomainTag;
|
||||
}
|
||||
|
||||
// Caller imports the actual tag VALUE from crypto:
|
||||
import { DOMAIN_PUBLIC_KEYCHAIN_V1 } from "@suaveplan/crypto/domain-tags";
|
||||
const opts: SignedSchemaOptions = { domain: DOMAIN_PUBLIC_KEYCHAIN_V1 };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — typed indirection in a generic primitive
|
||||
|
||||
```ts
|
||||
import type { DomainTag } from "@suaveplan/types";
|
||||
|
||||
export interface SignedBitSchemaOptions<T> {
|
||||
readonly domain: DomainTag;
|
||||
readonly bodyCodec: { pack(b: T): Uint8Array; unpack(b: Uint8Array): T };
|
||||
}
|
||||
|
||||
// The primitive accepts any DomainTag the caller passes; it has no
|
||||
// dependency on @suaveplan/crypto for the type-system part of its API.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type DomainTag = string & { readonly __brand: "DomainTag" }`
|
||||
|
||||
A branded string type. Cannot be constructed at the type level from an
|
||||
arbitrary string; consumers must import a named constant from
|
||||
`@suaveplan/crypto/domain-tags`. The brand exists at type-level only;
|
||||
runtime, the value is just the underlying string.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The brand is a phantom type — it only exists in the type system and
|
||||
has no runtime representation. The string at runtime is just the
|
||||
ASCII-encoded tag value (e.g. `"suaveplan.vault.v1"`).
|
||||
- This file deliberately ships zero runtime code. The companion runtime
|
||||
surface (`encodeDomainTag`, `buildSigningInput`, the closed list of
|
||||
tag values) lives in `@suaveplan/crypto/domain-tags`.
|
||||
- Adding a new tag is a wire-format extension; renaming or removing a
|
||||
tag is a wire-format break. See
|
||||
`@suaveplan/crypto/domain-tags/domain-tags.md` for the registration
|
||||
policy.
|
||||
@@ -0,0 +1,67 @@
|
||||
# DriveStats
|
||||
|
||||
## Purpose
|
||||
|
||||
The structural shape of the result returned by `Drive.stat(path)`. Like `DirEntry` but with absolute `Date` values (instead of optional `mtime` numbers), a required `size`, and the same optional UX fields (`keyStatus`, `presence`). Living in `@suaveplan/types` lets utilities — file managers, metadata-export tools, telemetry collectors — type their stat-result handling without depending on the drive package. The drive may extend `DriveStats` with backend-specific fields; consumers must be tolerant of extra fields.
|
||||
|
||||
## Features
|
||||
|
||||
- Required `size`, `created`, `modified`, `type` — the four facts every stat call resolves.
|
||||
- Optional `perm`, `keyStatus`, `presence` — populated when the drive has the corresponding state, absent when it doesn't.
|
||||
- `readonly` fields throughout — stat results are snapshots, not live views.
|
||||
- Mirrors `DirEntry` fields where they overlap so UI code can switch between list-view and detail-view representations seamlessly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { DriveStats } from "@suaveplan/types/drive-stats";
|
||||
|
||||
const stat: DriveStats = {
|
||||
size: 52_428_800,
|
||||
created: new Date(2026, 0, 1),
|
||||
modified: new Date(2026, 0, 15),
|
||||
type: "file",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { DriveStats } from "@suaveplan/types/drive-stats";
|
||||
|
||||
function describe(stat: DriveStats): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`Type: ${stat.type}`);
|
||||
lines.push(`Size: ${stat.size.toLocaleString()} bytes`);
|
||||
lines.push(`Created: ${stat.created.toISOString()}`);
|
||||
lines.push(`Modified: ${stat.modified.toISOString()}`);
|
||||
if (stat.keyStatus !== undefined) lines.push(`Encryption: ${stat.keyStatus}`);
|
||||
if (stat.presence !== undefined) {
|
||||
const pct = Math.round(
|
||||
(stat.presence.chunksPresent / stat.presence.chunksTotal) * 100,
|
||||
);
|
||||
lines.push(`Available: ${pct}% (${stat.presence.chunksPresent}/${stat.presence.chunksTotal} chunks)`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface DriveStats`
|
||||
|
||||
- `size: number` — plaintext byte count. Required (unlike `DirEntry.size` which is optional). For directories, the size is the count of entries (not the recursive byte count of contents).
|
||||
- `created: Date` — creation timestamp. Required. Backends that do not preserve creation time approximate it from the earliest mtime they observe.
|
||||
- `modified: Date` — last-modified timestamp. Required. Updates atomically on every successful write.
|
||||
- `type: DirEntryType` — `"dir"` or `"file"`.
|
||||
- `perm?: number` — POSIX-ish permission bits when the backend supplies them. Optional.
|
||||
- `keyStatus?: KeyStatus` — encryption state. Optional. Present only for files. Same semantics as `DirEntry.keyStatus`.
|
||||
- `presence?: ChunkPresence` — chunk presence snapshot. Optional. Present only for files. Same semantics as `DirEntry.presence`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`DriveStats` differs from `DirEntry` in three small ways: (1) `size` is required because `stat()` always probes the file's manifest; (2) `created` and `modified` are required `Date` instances (vs. optional `mtime` number); (3) there is no `name` field because `stat(path)` is called with the path the caller already has.
|
||||
|
||||
The `Date` instances are convenience wrappers — implementations construct them from epoch millisecond integers stored in the FileLocationIndex. The conversion is lossless within the JavaScript safe-integer range; for timestamps far in the past or future, callers should use `stat.modified.getTime()` directly to avoid `Date` rounding edge cases.
|
||||
|
||||
When `perm` is absent, callers should NOT assume any default — the absence means the backend does not surface permissions, and the file may still have effective permissions enforced by the OS or by drive-sharing partitions. Treating absence as "0o777" or "0o644" is wrong; treat it as "unknown."
|
||||
@@ -0,0 +1,70 @@
|
||||
# events-bus-like
|
||||
|
||||
## Purpose
|
||||
|
||||
`EventBusLike` is the minimal structural contract for any event-bus implementation. It exists so packages (especially the testing-infrastructure ones, which must stay free of runtime `@suaveplan/*` deps) can accept an optional bus injection, test it, and instrument their own emissions without importing `@suaveplan/events`. `@suaveplan/events`'s `EventBus<TMap>` conforms to this interface by construction.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-only — zero runtime cost
|
||||
- Generic `TMap` maps event name → payload for full type inference
|
||||
- Two methods only: `on` and `emit` — the smallest surface that covers 90%+ of integration points
|
||||
- A consumer that needs `emitAsync` / `once` / `use` interceptors takes a runtime dep on `@suaveplan/events` directly
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
|
||||
function announce(bus: EventBusLike<{ ready: null }>): void {
|
||||
bus.emit("ready", null);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { EventBusLike } from "@suaveplan/types";
|
||||
|
||||
type FixtureEvents = {
|
||||
"fixture.setup": { name: string; durationMs: number };
|
||||
"fixture.teardown": { name: string };
|
||||
};
|
||||
|
||||
class Harness {
|
||||
public constructor(
|
||||
private readonly events: EventBusLike<FixtureEvents> = {
|
||||
on: () => () => {},
|
||||
emit: () => {},
|
||||
}
|
||||
) {}
|
||||
|
||||
public async setup(name: string): Promise<void> {
|
||||
const t0 = performance.now();
|
||||
await this.doSetup(name);
|
||||
this.events.emit("fixture.setup", {
|
||||
name,
|
||||
durationMs: performance.now() - t0,
|
||||
});
|
||||
}
|
||||
|
||||
private async doSetup(_name: string): Promise<void> {
|
||||
/* ... */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface EventBusLike<TMap>`
|
||||
|
||||
- `on<K extends keyof TMap>(type, listener): () => void` — returns unsubscribe
|
||||
- `emit<K extends keyof TMap>(type, payload): void` — synchronous
|
||||
|
||||
### Generic parameters
|
||||
|
||||
- `TMap extends Record<string, unknown>` — event name → payload map; defaults to `Record<string, unknown>` for consumers that don't need type narrowing
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface deliberately excludes `emitAsync`, `use`, `once`, and `off` — consumers that need those take a real runtime dep on `@suaveplan/events`. Keeping `EventBusLike` small means the no-op fallback is literally `{ on: () => () => {}, emit: () => {} }` — four lines, zero external references. `@suaveplan/events`'s `EventBus` is a strict superset of `EventBusLike`, so passing an `EventBus` where an `EventBusLike` is expected always type-checks.
|
||||
@@ -0,0 +1,78 @@
|
||||
# fetch-like
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`FetchLike` is the canonical zero-runtime contract for an injectable `fetch`-shaped function across Suaveplan. Every package that wraps `globalThis.fetch` — for testability (so unit tests inject a controlled stub instead of monkey-patching the global), for cross-cutting middleware (logging, retry, response caching), or to surface the contract on its public option bag (`HttpStorageOptions.fetch`, `HttpsTransportOptions.fetchFn`, `ServerFallbackOptions.fetchFn`, `WebhookClientOptions.fetch`) — accepts a `FetchLike` so production code can pass `globalThis.fetch` while tests can pass a fake.
|
||||
|
||||
The signature matches WHATWG `fetch` byte-for-byte: `(input: string | URL | Request, init?: RequestInit) => Promise<Response>`. `globalThis.fetch` is therefore directly assignable to a `FetchLike` binding without any cast, and a `FetchLike` value is directly callable wherever `fetch` is expected. The contract intentionally does **not** narrow the input to `string | URL` (as some suaveplan packages historically did) because the narrowed shape silently rejects valid call sites that pass a pre-built `Request` instance.
|
||||
|
||||
## Features
|
||||
|
||||
- Types-only — zero runtime cost
|
||||
- One canonical shape every cross-package fetch injection conforms to
|
||||
- Directly assignable from `globalThis.fetch` (no cast required)
|
||||
- Directly callable wherever `fetch` is expected (input shape matches WHATWG)
|
||||
- Compatible with cross-cutting wrappers (logging, retry, throttling) — they accept and return `FetchLike`
|
||||
- Compatible with structural fakes that synthesise `Response` instances for tests
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
const fetchImpl: FetchLike = globalThis.fetch;
|
||||
const response = await fetchImpl("https://cdn.example.com/file.bin");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { FetchLike } from "@suaveplan/types";
|
||||
|
||||
/** Compose two FetchLike wrappers — logging on the outside, retry inside. */
|
||||
function withLogging(inner: FetchLike): FetchLike {
|
||||
return async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
console.log("→", init?.method ?? "GET", url);
|
||||
const response = await inner(input, init);
|
||||
console.log("←", response.status, url);
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
function withRetry(inner: FetchLike): FetchLike {
|
||||
return async (input, init) => {
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
const response = await inner(input, init);
|
||||
if (response.ok || response.status < 500) return response;
|
||||
}
|
||||
// Final attempt result returned even on failure — caller decides.
|
||||
return inner(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
const wrapped: FetchLike = withLogging(withRetry(globalThis.fetch));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type FetchLike`
|
||||
|
||||
```ts
|
||||
(input: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
```
|
||||
|
||||
- `input` — request target. Accepts the three documented WHATWG shapes; consumers that don't construct `Request` instances simply never pass them.
|
||||
- `init` — optional `RequestInit` bag. Consumers inspect `init?.signal` to verify `AbortSignal` threading, `init?.method` for verb dispatch, `init?.headers` for per-request headers, etc.
|
||||
- Return — `Promise<Response>`. Rejections follow WHATWG semantics: network failures throw, HTTP error statuses do not.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The type is contravariant in its parameters. A function declared with a narrower first parameter (e.g. `(input: string, init?: RequestInit)`) is **not** assignable to `FetchLike` — wrap it in `(input, init) => narrower(input as string, init)` to bridge.
|
||||
- The type intentionally does not narrow `init`. Consumers reach into `signal`, `headers`, `method`, `body`, and sometimes `keepalive` / `redirect` / `credentials`; pinning a custom subset would force every consumer to widen at the call site.
|
||||
- For test fakes that need a narrower shape (e.g. `RekorFetch` in `@suaveplan/slsa`, `HealthFetch` in `@suaveplan/testing-integration-step-ca`), declare a separate package-local type. `FetchLike` is the workspace default; not every fetch consumer must use it.
|
||||
- No DOM-only types leak into the public API — `RequestInit`, `Request`, `Response`, and `URL` are provided by `@types/node` as part of Node 18+'s built-in fetch typings. The type therefore compiles unchanged under Node, Bun, Deno, modern browsers, and Cloudflare Workers without lib augmentation.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Framing
|
||||
|
||||
## Purpose
|
||||
|
||||
`FramingConfig` and `FrameParseResult` are the canonical types every Suaveplan byte-stream codec uses to describe how it splits a contiguous stream into discrete record frames. Without a shared abstraction, every codec re-invents the same four strategies — length-prefix, delimiter, fixed-size, none — and downstream consumers have to relearn the API for each one.
|
||||
|
||||
By centralising these in `@suaveplan/types`, a multi-codec pipeline (e.g. bitpacker → encrypt → over-the-wire) can pass a single `FramingConfig` between layers without translation, and observability tools can reason about frame boundaries uniformly across packages.
|
||||
|
||||
## Features
|
||||
|
||||
- Discriminated union covering the four frame-delimiter strategies real wire codecs use.
|
||||
- `length-prefix` width is constrained to `1 | 2 | 4` bytes — the only safe widths for big-endian uint encoding without overflow risk.
|
||||
- `delimiter` and `fixedSize` are always optional on the union, narrowed by the `type` discriminant.
|
||||
- `FrameParseResult` separates fully-decoded frames from trailing bytes that need to be carried into the next read — the shape every streaming parser needs.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { FramingConfig } from "@suaveplan/types";
|
||||
|
||||
const lp: FramingConfig = { type: "length-prefix", lengthPrefixSize: 4 };
|
||||
const nd: FramingConfig = { type: "delimiter", delimiter: new Uint8Array([0x0a]) };
|
||||
const fs: FramingConfig = { type: "fixed-size", fixedSize: 256 };
|
||||
const passthrough: FramingConfig = { type: "none" };
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { FramingConfig, FrameParseResult } from "@suaveplan/types";
|
||||
|
||||
function feed(
|
||||
buffer: Uint8Array,
|
||||
cfg: FramingConfig,
|
||||
parse: (b: Uint8Array, c: FramingConfig) => FrameParseResult
|
||||
): { complete: Uint8Array[]; carry: Uint8Array } {
|
||||
const { frames, remainder } = parse(buffer, cfg);
|
||||
return { complete: frames, carry: remainder };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FramingConfig`
|
||||
|
||||
| Field | Type | Required | Meaning |
|
||||
|---|---|---|---|
|
||||
| `type` | `'length-prefix' \| 'delimiter' \| 'fixed-size' \| 'none'` | yes | Frame delimiter strategy. |
|
||||
| `lengthPrefixSize` | `1 \| 2 \| 4` | when `type === 'length-prefix'` | Big-endian uint width preceding each frame. |
|
||||
| `delimiter` | `Uint8Array` | when `type === 'delimiter'` | Byte sequence separating frames. |
|
||||
| `fixedSize` | `number` | when `type === 'fixed-size'` | Exact byte length of every frame. |
|
||||
|
||||
### `FrameParseResult`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `frames` | `Uint8Array[]` | Fully-decoded frame payloads (framing metadata stripped). |
|
||||
| `remainder` | `Uint8Array` | Trailing bytes not forming a complete frame; carry to next read. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The discriminator is intentionally string-typed rather than enum because most wire formats spell their framing strategy as a string in config files (YAML, TOML, JSON), and a string discriminant lets that config flow into TypeScript without a translation step.
|
||||
|
||||
`length-prefix` widths are limited to 1, 2, and 4 bytes because they are the widths every binary codec on real wire formats actually uses (uint8/uint16/uint32 big-endian). 8-byte length prefixes are excluded — JavaScript's number can represent them safely up to 2^53, but no Suaveplan wire format needs them and adding the option would invite subtle truncation bugs.
|
||||
|
||||
`type: 'none'` exists as the degenerate case so an outer pipeline can be parameterised by `FramingConfig` without forcing every leaf consumer to special-case "no framing at all". This keeps the call sites uniform.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `HashLike`
|
||||
|
||||
## Purpose
|
||||
|
||||
`HashLike` is the canonical hash-function contract every Suaveplan package accepts when it needs a digest primitive injected from outside. It defines a runtime-agnostic shape that supports both synchronous, CPU-bound backends (`@noble/hashes`) AND asynchronous, off-thread backends (`crypto.subtle.digest`) under one interface. This is the lowest common denominator that lets universal packages such as `@suaveplan/srp` run unchanged on Node, Bun, and browsers without compile-time branching.
|
||||
|
||||
## Features
|
||||
|
||||
- Sync OR async `digest` return value — `Uint8Array | Promise<Uint8Array>`. Consumers MUST handle both shapes.
|
||||
- Optional `AbortSignal` for cancellable async paths. Sync backends MAY ignore it.
|
||||
- Stable `algorithm` string tag so consumers can branch on algorithm choice without re-inspecting closure state.
|
||||
- `digestSize` advertised up-front so consumers can pre-allocate fixed buffers without performing a sample digest call.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { HashLike } from "@suaveplan/types";
|
||||
|
||||
async function hashHello(hash: HashLike): Promise<Uint8Array> {
|
||||
const input = new TextEncoder().encode("hello");
|
||||
return await Promise.resolve(hash.digest(input));
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { HashLike } from "@suaveplan/types";
|
||||
|
||||
async function digestWithDeadline(
|
||||
hash: HashLike,
|
||||
input: Uint8Array,
|
||||
deadlineMs: number
|
||||
): Promise<Uint8Array> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(new Error("deadline")), deadlineMs);
|
||||
try {
|
||||
const result = hash.digest(input, controller.signal);
|
||||
return result instanceof Promise ? await result : result;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `HashLike`
|
||||
|
||||
| Property | Type | Description |
|
||||
| ---------------- | ----------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `algorithm` | `string` | Stable algorithm tag, e.g. `"SHA256"`, `"SHA1"`. |
|
||||
| `digestSize` | `number` | Byte length of every `digest` call's output. |
|
||||
| `digest(input, signal?)` | `Uint8Array \| Promise<Uint8Array>` | Produce the digest of `input`. Sync or async at the impl's discretion. Honours `signal` when present and supported. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The deliberately permissive return type (`Uint8Array | Promise<Uint8Array>`) reflects a real-world constraint: browsers' `crypto.subtle.digest` is async, while `@noble/hashes` and `node:crypto.createHash` are sync. Forcing every consumer to `await` would penalise sync paths with microtask scheduling; forcing every backend to be sync would exclude `crypto.subtle`. The union resolves the tension. Consumers that want a uniform `Promise` shape can wrap with `Promise.resolve(hash.digest(input))`.
|
||||
|
||||
`signal` is optional, not required, because not every algorithm benefits from cancellation. A SHA-256 over 32 bytes completes in microseconds; threading an AbortSignal through that path costs more than it saves. Async backends with non-trivial work (large inputs, off-thread digest, hardware-accelerated paths) SHOULD honour the signal both before invocation (reject on pre-abort) and mid-flight where the runtime allows.
|
||||
@@ -0,0 +1,78 @@
|
||||
# HealthStatus
|
||||
|
||||
## Purpose
|
||||
|
||||
Cross-cutting health check result used by storage adapters, chunk sources, network transports, and any component that exposes a health probe. Replaces upstream `StorageHealth` with a more generic contract that does not assume filesystem semantics.
|
||||
|
||||
## Features
|
||||
|
||||
- **Four-state union** — `healthy`, `degraded`, `failed`, `unknown`
|
||||
- **Optional telemetry** — latency, space, and error fields are all optional
|
||||
- **Timestamped** — every check carries `checkedAt` for staleness detection
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type { HealthStatus } from '@suaveplan/types/health-status';
|
||||
|
||||
function checkMemory(): HealthStatus {
|
||||
return {
|
||||
status: 'healthy',
|
||||
checkedAt: new Date(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type { HealthStatus, HealthStatusValue } from '@suaveplan/types/health-status';
|
||||
|
||||
async function checkStorage(adapter: StorageAdapter): Promise<HealthStatus> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await adapter.list('');
|
||||
return {
|
||||
status: 'healthy',
|
||||
latencyMs: Date.now() - start,
|
||||
checkedAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'failed',
|
||||
latencyMs: Date.now() - start,
|
||||
error: error as Error,
|
||||
checkedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function aggregateHealth(statuses: HealthStatus[]): HealthStatusValue {
|
||||
if (statuses.some(s => s.status === 'failed')) return 'failed';
|
||||
if (statuses.some(s => s.status === 'degraded')) return 'degraded';
|
||||
if (statuses.every(s => s.status === 'healthy')) return 'healthy';
|
||||
return 'unknown';
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `HealthStatusValue`
|
||||
|
||||
Union: `'healthy' | 'degraded' | 'failed' | 'unknown'`.
|
||||
|
||||
### `HealthStatus`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `status` | `HealthStatusValue` | Yes | Overall status |
|
||||
| `latencyMs` | `number` | No | Probe latency |
|
||||
| `availableSpaceBytes` | `number` | No | Free space |
|
||||
| `totalSpaceBytes` | `number` | No | Total space |
|
||||
| `error` | `Error` | No | Failure reason |
|
||||
| `checkedAt` | `Date` | Yes | Check timestamp |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `-1` is the conventional value for `availableSpaceBytes` and `totalSpaceBytes` when the underlying system cannot determine capacity.
|
||||
- `error` SHOULD be set when `status` is `'failed'` or `'degraded'` so that operators can diagnose issues.
|
||||
@@ -0,0 +1,226 @@
|
||||
# i18n-backend
|
||||
|
||||
`Backend` is the canonical plugin interface for any i18n backend
|
||||
implementation in the Suaveplan ecosystem. Memory, HTTP, storage,
|
||||
filesystem, and chained backends all satisfy this contract. The
|
||||
interface lives in `@suaveplan/types` so backend packages do NOT
|
||||
need to depend on `@suaveplan/i18n` core — the slim core re-exports
|
||||
the same shape for downstream convenience.
|
||||
|
||||
## Purpose
|
||||
|
||||
The i18n modular split (cross-cutting design
|
||||
`2026-05-05-i18n-modular-split`) breaks the original `@suaveplan/i18n`
|
||||
monolith into 14 sibling packages. The slim core depends on every
|
||||
backend; every backend would have to depend on the slim core for the
|
||||
`Backend` shape — a circular dependency. Lifting the contract into
|
||||
`@suaveplan/types` (zero-runtime, types-only) breaks the cycle: the
|
||||
slim core re-exports `Backend` from `@suaveplan/types` for back-compat
|
||||
with pre-split consumers, while every leaf backend imports it
|
||||
directly from `@suaveplan/types`.
|
||||
|
||||
This mirrors the existing cross-package crypto contract pattern
|
||||
already in this package — `Authority`, `KeySlotPublic`,
|
||||
`KeySlotPrivate`, `SlotId`, `DomainTag`, `AuthorityVerifier`,
|
||||
`AeadOps`, `AeadAlgorithm` — where the canonical shape lives in
|
||||
`@suaveplan/types` and the runtime implementations live in
|
||||
`@suaveplan/crypto`.
|
||||
|
||||
## Features
|
||||
|
||||
- Required surface: `name`, `capabilities`, `read(locale, namespace)`
|
||||
- Optional surface: `readMulti?`, `save?`, `lastModified?`, `init?`,
|
||||
`dispose?`
|
||||
- `BackendCapabilities` — five required flag fields
|
||||
(`canRead` / `canReadMulti` / `canSave` / `canTrackModification` /
|
||||
`canWatch`) the consumer can branch on without
|
||||
`typeof === "function"` feature detection
|
||||
- `BackendInitContext` — supplied by the catalog at registration so
|
||||
watch-capable backends can emit `loaded` events after initial load
|
||||
- `LayoutOption` — discriminator shared by storage- and filesystem-
|
||||
shaped backends so layout-aware consumers never have to redeclare it
|
||||
- `BackendFactory<TOptions>` — convenience factory return type for
|
||||
builder-style construction (`memoryBackend(opts)` →
|
||||
`new MemoryBackend(opts)`)
|
||||
- `LocaleNamespace` — `(locale, namespace)` tuple for cache keys
|
||||
- Type-only — zero runtime cost
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { Backend, TranslationRecord } from "@suaveplan/types";
|
||||
|
||||
class StaticBackend implements Backend {
|
||||
readonly name = "static";
|
||||
readonly capabilities = {
|
||||
canRead: true,
|
||||
canReadMulti: false,
|
||||
canSave: false,
|
||||
canTrackModification: false,
|
||||
canWatch: false,
|
||||
} as const;
|
||||
|
||||
async read(_locale: string, _namespace: string): Promise<TranslationRecord> {
|
||||
return { greet: "Hello" };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — full backend with optional surface
|
||||
|
||||
```ts
|
||||
import type {
|
||||
Backend,
|
||||
BackendCapabilities,
|
||||
BackendInitContext,
|
||||
TranslationRecord,
|
||||
} from "@suaveplan/types";
|
||||
|
||||
export class StorageLikeBackend implements Backend {
|
||||
readonly name = "storage-like";
|
||||
readonly capabilities: BackendCapabilities = {
|
||||
canRead: true,
|
||||
canReadMulti: true,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: false,
|
||||
};
|
||||
|
||||
private readonly store = new Map<string, TranslationRecord>();
|
||||
private readonly mtime = new Map<string, number>();
|
||||
private context?: BackendInitContext;
|
||||
|
||||
async init(context: BackendInitContext): Promise<void> {
|
||||
this.context = context;
|
||||
// One-shot setup — open a connection, prefetch index, etc.
|
||||
}
|
||||
|
||||
async read(locale: string, namespace: string): Promise<TranslationRecord> {
|
||||
const record = this.store.get(`${locale}::${namespace}`);
|
||||
if (!record) throw new Error(`missing: ${locale}/${namespace}`);
|
||||
return record;
|
||||
}
|
||||
|
||||
async readMulti(
|
||||
locale: string,
|
||||
namespaces: readonly string[]
|
||||
): Promise<Record<string, TranslationRecord>> {
|
||||
const out: Record<string, TranslationRecord> = {};
|
||||
for (const ns of namespaces) {
|
||||
out[ns] = await this.read(locale, ns);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async save(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: TranslationRecord
|
||||
): Promise<void> {
|
||||
this.store.set(`${locale}::${namespace}`, record);
|
||||
this.mtime.set(`${locale}::${namespace}`, Date.now());
|
||||
this.context?.emitLoaded(locale, namespace, { backend: this.name });
|
||||
}
|
||||
|
||||
async lastModified(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<number | undefined> {
|
||||
return this.mtime.get(`${locale}::${namespace}`);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.store.clear();
|
||||
this.mtime.clear();
|
||||
this.context = undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface Backend`
|
||||
|
||||
- `readonly name: string` — backend identifier (used in OTel attributes
|
||||
and in chained-backend logging)
|
||||
- `readonly capabilities: BackendCapabilities` — capability flags
|
||||
- `read(locale, namespace): Promise<TranslationRecord>` — mandatory
|
||||
single-namespace load. MUST throw if the namespace is missing.
|
||||
- `readMulti?(locale, namespaces): Promise<Record<string, TranslationRecord>>`
|
||||
— optional bulk-load optimisation. When implemented,
|
||||
`capabilities.canReadMulti === true`.
|
||||
- `save?(locale, namespace, record): Promise<void>` — optional write
|
||||
surface. When implemented, `capabilities.canSave === true`.
|
||||
- `lastModified?(locale, namespace): Promise<number | undefined>` —
|
||||
optional cache-busting hook returning `Date.now()`-style epoch ms.
|
||||
When implemented, `capabilities.canTrackModification === true`.
|
||||
- `init?(context): Promise<void> | void` — optional one-shot setup hook
|
||||
called by the catalog before the first `read`. Receives the
|
||||
`BackendInitContext` so watch-capable backends can emit `loaded`
|
||||
events.
|
||||
- `dispose?(): Promise<void> | void` — optional teardown hook called
|
||||
when the catalog is disposed.
|
||||
|
||||
### `interface BackendCapabilities`
|
||||
|
||||
- `readonly canRead: true` — always literal `true`; included for
|
||||
symmetry so the matrix is uniform across every dimension
|
||||
- `readonly canReadMulti: boolean`
|
||||
- `readonly canSave: boolean`
|
||||
- `readonly canTrackModification: boolean`
|
||||
- `readonly canWatch: boolean`
|
||||
|
||||
All five fields are required so consumers never need to feature-detect
|
||||
via `typeof`. `canRead` is constrained to the literal `true` to make
|
||||
the contract self-describing — a backend that cannot satisfy `read` is
|
||||
not a backend.
|
||||
|
||||
### `interface BackendInitContext`
|
||||
|
||||
- `readonly catalogName: string` — stable name of the catalog this
|
||||
backend is being attached to
|
||||
- `readonly emitLoaded: (locale, namespace, meta?) => void` — emit a
|
||||
`loaded` event after the underlying source changes (file watcher
|
||||
fired, hot-reload socket message arrived, etc.)
|
||||
|
||||
### `interface LocaleNamespace`
|
||||
|
||||
- `readonly locale: string`
|
||||
- `readonly namespace: string`
|
||||
|
||||
Tuple shape used by cache keys, `Backend.read` argument lists, and the
|
||||
`MessageCatalog` lookup surface.
|
||||
|
||||
### `type LayoutOption`
|
||||
|
||||
Discriminator union covering storage- and filesystem-backed layouts:
|
||||
`"single-key-per-namespace"`, `"single-key-per-locale"`, `"flat-keys"`,
|
||||
`"nested"`, `"flat"`, `"glob"`. Backends that accept layout options
|
||||
narrow this union to the subset they implement.
|
||||
|
||||
### `type BackendFactory<TOptions>`
|
||||
|
||||
Convenience factory return type for callers that want to build a
|
||||
backend via a builder function rather than `new`. Concrete backends
|
||||
export both the class and a factory:
|
||||
`class MemoryBackend implements Backend` plus
|
||||
`memoryBackend(opts?: MemoryBackendOptions): Backend`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The contract intentionally does NOT include a `delete?` surface.
|
||||
Catalogs are append-only; namespace removal is the consumer's
|
||||
responsibility (rebuild the catalog without that namespace).
|
||||
- `init?` is called at most once per backend instance. Idempotent
|
||||
implementations are encouraged; the catalog will not retry on
|
||||
`init?` failure.
|
||||
- `lastModified?` returns epoch ms (matching `Clock`); `undefined`
|
||||
signals "not tracked" and the catalog falls back to its in-memory
|
||||
cache lifetime.
|
||||
- `readMulti?` SHOULD be implemented when bulk loading saves round
|
||||
trips (HTTP backends, storage backends with batch APIs). Memory and
|
||||
filesystem backends typically do not need it.
|
||||
- Watch-capable backends (`canWatch: true`) hold the
|
||||
`BackendInitContext` reference and call `emitLoaded` whenever the
|
||||
underlying source changes; consumers without watch capability simply
|
||||
ignore the context argument.
|
||||
@@ -0,0 +1,166 @@
|
||||
# i18n-catalog
|
||||
|
||||
Catalog data shapes for the i18n family of packages. These types live
|
||||
in `@suaveplan/types` so the slim `@suaveplan/i18n` core's re-export
|
||||
bridge does not have to redeclare them, and so downstream React
|
||||
binding packages (`@suaveplan/react-i18n`) can consume them without
|
||||
pulling the entire i18n runtime.
|
||||
|
||||
## Purpose
|
||||
|
||||
The slim `@suaveplan/i18n` core (Wave 6 of the i18n modular split)
|
||||
re-exports `MessageLoader`, `MissingKeyHandler`, `LookupResult`,
|
||||
`NamespacedCatalogMap`, and `TranslationCatalogMap` from this module
|
||||
so every pre-split consumer's import (`import type { MessageLoader }
|
||||
from "@suaveplan/i18n"`) keeps working unchanged. New consumers MAY
|
||||
import directly from `@suaveplan/types` to skip the re-export hop and
|
||||
keep their dependency tree narrower — particularly server-side
|
||||
backend implementations and React tree-shaken bindings.
|
||||
|
||||
## Features
|
||||
|
||||
- `MessageLoader` — legacy single-namespace loader signature
|
||||
- `MissingKeyHandler` — callback for unresolved keys
|
||||
- `LookupResult` — resolved value plus fallback locale
|
||||
- `NamespacedCatalogMap` — per-locale namespace bucket
|
||||
- `TranslationCatalogMap` — full in-memory catalog
|
||||
- Type-only — zero runtime cost
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
LookupResult,
|
||||
MessageLoader,
|
||||
MissingKeyHandler,
|
||||
TranslationRecord,
|
||||
} from "@suaveplan/types";
|
||||
|
||||
const loader: MessageLoader = async (locale, _namespace) => {
|
||||
const data = await import(`./locales/${locale}.json`);
|
||||
return data.default as TranslationRecord;
|
||||
};
|
||||
|
||||
const onMissing: MissingKeyHandler = ({ locale, namespace, key }) => {
|
||||
return `[${locale}/${namespace}/${key}]`;
|
||||
};
|
||||
|
||||
const result: LookupResult = {
|
||||
value: "Hello",
|
||||
fellBackTo: "en",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example — full catalog map
|
||||
|
||||
```ts
|
||||
import type {
|
||||
NamespacedCatalogMap,
|
||||
TranslationCatalogMap,
|
||||
TranslationRecord,
|
||||
} from "@suaveplan/types";
|
||||
|
||||
function buildCatalog(): TranslationCatalogMap {
|
||||
const en: NamespacedCatalogMap = new Map<string, TranslationRecord>();
|
||||
en.set("common", { loading: "Loading…", retry: "Try again" });
|
||||
en.set("auth", {
|
||||
login: { title: "Sign in", submit: "Continue" },
|
||||
logout: { title: "Sign out" },
|
||||
});
|
||||
|
||||
const de: NamespacedCatalogMap = new Map<string, TranslationRecord>();
|
||||
de.set("common", { loading: "Wird geladen…", retry: "Erneut versuchen" });
|
||||
de.set("auth", {
|
||||
login: { title: "Anmelden", submit: "Weiter" },
|
||||
logout: { title: "Abmelden" },
|
||||
});
|
||||
|
||||
const catalog: TranslationCatalogMap = new Map();
|
||||
catalog.set("en", en);
|
||||
catalog.set("de", de);
|
||||
return catalog;
|
||||
}
|
||||
|
||||
// Resolution: lookup `auth.login.title` in `de-CH` falls back to `de`,
|
||||
// then to `en`. The returned `LookupResult` records which locale
|
||||
// ultimately satisfied the lookup so the consumer can warn on
|
||||
// unexpected fallbacks.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type MessageLoader`
|
||||
|
||||
```ts
|
||||
type MessageLoader = (
|
||||
locale: string,
|
||||
namespace?: string
|
||||
) => Promise<TranslationRecord>;
|
||||
```
|
||||
|
||||
Legacy single-namespace loader signature kept for back-compat with the
|
||||
pre-split monolith's `MessageCatalog` constructor option. New code
|
||||
SHOULD prefer `Backend` (with required `namespace`) so the namespace
|
||||
is never accidentally dropped by the consumer.
|
||||
|
||||
### `type MissingKeyHandler`
|
||||
|
||||
```ts
|
||||
type MissingKeyHandler = (info: {
|
||||
readonly locale: string;
|
||||
readonly namespace: string;
|
||||
readonly key: string;
|
||||
}) => string | undefined;
|
||||
```
|
||||
|
||||
Invoked when a key cannot be resolved in any locale of the fallback
|
||||
chain. Returns either a fallback string (rendered in place of the
|
||||
missing key) or `undefined` (let the caller decide — typically render
|
||||
the key path itself for development visibility).
|
||||
|
||||
### `interface LookupResult`
|
||||
|
||||
- `readonly value: string | undefined` — resolved string, or
|
||||
`undefined` if missing in every locale
|
||||
- `readonly fellBackTo: string | undefined` — the locale that
|
||||
ultimately satisfied the lookup; `undefined` when the key was
|
||||
missing everywhere
|
||||
|
||||
### `type NamespacedCatalogMap`
|
||||
|
||||
```ts
|
||||
type NamespacedCatalogMap = Map<string, TranslationRecord>;
|
||||
```
|
||||
|
||||
Bucket of namespaces for a single locale. Keys are namespace names
|
||||
(`"common"`, `"auth"`, `"errors"`); values are the fully-loaded
|
||||
records for that namespace.
|
||||
|
||||
### `type TranslationCatalogMap`
|
||||
|
||||
```ts
|
||||
type TranslationCatalogMap = Map<string, NamespacedCatalogMap>;
|
||||
```
|
||||
|
||||
Full in-memory catalog. Keys are locales (`"en"`, `"de-CH"`); values
|
||||
are the per-locale `NamespacedCatalogMap`. The slim core owns one of
|
||||
these per `MessageCatalog` instance.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Native `Map<string, …>` is preferred over plain object types because
|
||||
the catalog's iteration order is significant for fallback chains
|
||||
(locale insertion order = lookup priority order).
|
||||
- `LookupResult.fellBackTo` is intentionally distinct from
|
||||
`LookupResult.value`. When the requested locale has the key, both
|
||||
fields satisfy `value !== undefined && fellBackTo === requestedLocale`.
|
||||
When the key is missing in the requested locale but present in a
|
||||
fallback, `fellBackTo` documents which one — the consumer can warn
|
||||
if the fallback was unexpected.
|
||||
- `MessageLoader` is kept as a type alias rather than an interface so
|
||||
consumers can declare arrow-function loaders without the syntactic
|
||||
overhead of `class extends`.
|
||||
- `MissingKeyHandler` returns `string | undefined` (not `string`) so
|
||||
the catalog can distinguish "the handler wants to render this
|
||||
fallback" from "the handler didn't have an opinion; render the key
|
||||
path".
|
||||
@@ -0,0 +1,50 @@
|
||||
# id-generator
|
||||
|
||||
## Purpose
|
||||
|
||||
`IdGenerator` is the canonical type alias for a function returning unique string identifiers. Every Suaveplan package that assigns ids (envelopes, records, correlation tokens) accepts an `IdGenerator` injection so tests can substitute a deterministic counter and cross-package implementations are interchangeable.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-only — zero runtime cost
|
||||
- Output is opaque to consumers — strings may be UUIDs, ULIDs, monotonic counters, or any other scheme
|
||||
- One canonical shape across the project
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { IdGenerator } from "@suaveplan/types";
|
||||
|
||||
const counter: IdGenerator = (() => {
|
||||
let n = 0;
|
||||
return () => `id-${++n}`;
|
||||
})();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { IdGenerator } from "@suaveplan/types";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
// Tenant-namespaced id generator for a multi-tenant bus.
|
||||
function tenantIds(tenantId: string): IdGenerator {
|
||||
return () => `${tenantId}:${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
const bus = new EventBus({ idGenerator: tenantIds("tenant-42") });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type IdGenerator = () => string`
|
||||
|
||||
A function returning a string id. Expected to produce distinct outputs across calls; the specific uniqueness guarantee (monotonicity, randomness, namespaced-uniqueness) is a property of the implementation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Downstream packages SHOULD treat id strings as opaque — they MUST NOT parse for structural information. A production default typically uses `crypto.randomUUID()` (36-char RFC 4122 v4 UUID); deterministic test implementations commonly use `"id-${++n}"` counters. Whatever scheme an `IdGenerator` implements, the returned strings must be safe for use as object keys, URL path segments, and log attribute values without additional escaping.
|
||||
|
||||
### Ecosystem Usage
|
||||
|
||||
Events, logger records, idempotency keys, and serialize envelopes all accept an `IdGenerator` so tests can pin IDs to deterministic sequences. Production wiring typically passes a nanoid or UUID v7 generator; tests commonly inject a counter that returns `"id-1"`, `"id-2"`, and so on. The return value is an opaque string so consumers never try to parse structure out of it.
|
||||
@@ -0,0 +1,72 @@
|
||||
# KeyStatus
|
||||
|
||||
## Purpose
|
||||
|
||||
Cross-cutting union type that names the three encryption states a file can be in: plaintext, encrypted-and-no-key, encrypted-and-key-loaded. UIs use this to render lock icons (🔒 for `encrypted-locked`, 🔓 for `encrypted-unlocked`, no icon for `unencrypted`). Living in `@suaveplan/types` lets the drive return it from `Drive.getKeyStatus(path)` and embed it in `DirEntry` without forcing UI consumers to import the drive package — the type is structural, the implementations live elsewhere.
|
||||
|
||||
## Features
|
||||
|
||||
- Three-state discriminated union covering every encryption state a file can be in.
|
||||
- Symmetric naming — `unencrypted` / `encrypted-locked` / `encrypted-unlocked` reads naturally and pairs to UI icons one-to-one.
|
||||
- Stable contract — adding a fourth state is a breaking change at the type level, so the design is forced to be intentional about state additions.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { KeyStatus } from "@suaveplan/types/key-status";
|
||||
|
||||
const lockIcon = (status: KeyStatus): string => {
|
||||
if (status === "unencrypted") return "";
|
||||
return status === "encrypted-locked" ? "🔒" : "🔓";
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { KeyStatus } from "@suaveplan/types/key-status";
|
||||
|
||||
// Exhaustive switch — TypeScript enforces every case is handled.
|
||||
function describe(status: KeyStatus): string {
|
||||
switch (status) {
|
||||
case "unencrypted":
|
||||
return "Plaintext file; no decryption needed.";
|
||||
case "encrypted-locked":
|
||||
return "File is encrypted; awaiting key grant.";
|
||||
case "encrypted-unlocked":
|
||||
return "File is encrypted and key is loaded.";
|
||||
}
|
||||
}
|
||||
|
||||
// React-style usage in a list item:
|
||||
function FileRow({ name, keyStatus }: { name: string; keyStatus?: KeyStatus }) {
|
||||
const icon = keyStatus === undefined ? "" : describe(keyStatus);
|
||||
return `${name} ${icon}`;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type KeyStatus`
|
||||
|
||||
Union: `"unencrypted" | "encrypted-locked" | "encrypted-unlocked"`.
|
||||
|
||||
- `"unencrypted"` — file is plaintext; the chunks plugin's encryption pipeline is bypassed for this file. No key is needed; reads return decrypted bytes (which equal the encrypted bytes — the AEAD step is skipped). UI: no lock icon.
|
||||
- `"encrypted-locked"` — the file's `FileLocation.encryption` is configured AND the drive's KeyRing does NOT hold the unwrapped `fileKey`. Reads throw `DRIVE.KEY_MISSING` until a `KeyGrantEnvelope` arrives or the user retro-actively adds the key. UI: 🔒.
|
||||
- `"encrypted-unlocked"` — the file's `FileLocation.encryption` is configured AND the drive's KeyRing holds the unwrapped `fileKey`. Reads succeed; the chunks plugin verifies the per-chunk Ed25519 sig and decrypts the AEAD ciphertext. UI: 🔓.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The three states map to three concrete drive paths:
|
||||
|
||||
1. `Drive.read(path)` for `unencrypted` files: skip the chunks-plugin AEAD stage, return raw chunk bytes after sig verification.
|
||||
2. `Drive.read(path)` for `encrypted-locked` files: throw immediately without even attempting the read.
|
||||
3. `Drive.read(path)` for `encrypted-unlocked` files: full AEAD decrypt + sig verify pipeline.
|
||||
|
||||
Lock-icon transitions occur on three events:
|
||||
|
||||
- `drive:hydrated` — every file in the freshly-hydrated template has its initial state computed (inline-key files start `encrypted-unlocked`, retroactive-grant files start `encrypted-locked`).
|
||||
- `drive:key:granted` — emitted when a `KeyGrantEnvelope` is validated and the key lands in the KeyRing. Affected files transition `encrypted-locked` → `encrypted-unlocked`.
|
||||
- `drive:key:revoked` — emitted when a key is removed (expiry, partition rotation, manual revocation). Affected files transition `encrypted-unlocked` → `encrypted-locked`.
|
||||
|
||||
UIs subscribe to these events to flip lock icons without polling.
|
||||
@@ -0,0 +1,64 @@
|
||||
# logger-like
|
||||
|
||||
## Purpose
|
||||
|
||||
`LoggerLike` is the minimal structural contract for a structured logger. It gives any package a type-level integration point with `@suaveplan/logger` without taking a runtime dep — testing packages can accept an optional `LoggerLike` injection with a no-op fallback, and consumer code that already has a `@suaveplan/logger` instance passes it straight in.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-only — zero runtime cost
|
||||
- Four standard log levels: `debug`, `info`, `warn`, `error`
|
||||
- Optional structured context on every method
|
||||
- No `trace` / `fatal` surface — deliberately lean; specialised loggers can still implement the interface while adding more methods
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
const noop: LoggerLike = {
|
||||
debug() {}, info() {}, warn() {}, error() {},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
function createConsoleLogger(tag: string): LoggerLike {
|
||||
return {
|
||||
debug: (msg, ctx) => console.debug(`[${tag}] ${msg}`, ctx ?? {}),
|
||||
info: (msg, ctx) => console.info(`[${tag}] ${msg}`, ctx ?? {}),
|
||||
warn: (msg, ctx) => console.warn(`[${tag}] ${msg}`, ctx ?? {}),
|
||||
error: (msg, ctx) => console.error(`[${tag}] ${msg}`, ctx ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function runHarness(
|
||||
logger: LoggerLike = {
|
||||
debug() {}, info() {}, warn() {}, error() {},
|
||||
}
|
||||
): void {
|
||||
logger.info("harness start");
|
||||
// ...
|
||||
logger.info("harness done", { durationMs: 42 });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface LoggerLike`
|
||||
|
||||
- `debug(message: string, context?: Readonly<Record<string, unknown>>): void`
|
||||
- `info(message: string, context?: Readonly<Record<string, unknown>>): void`
|
||||
- `warn(message: string, context?: Readonly<Record<string, unknown>>): void`
|
||||
- `error(message: string, context?: Readonly<Record<string, unknown>>): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Methods return `void` — logging is fire-and-forget from the caller's perspective. Implementations MAY buffer or async-dispatch internally, but the call site must not await them, so the type cannot be `Promise<void>`. The `context` argument is deliberately `Readonly<Record<string, unknown>>`: consumers should not mutate it (implementations may capture by reference) and implementations should not rely on specific types beyond JSON-serialisable shapes.
|
||||
|
||||
### Ecosystem Usage
|
||||
|
||||
Every Suaveplan package that wants to emit diagnostic output without taking a hard runtime dependency on `@suaveplan/logger` accepts an optional `LoggerLike`. The real concrete loggers shipped from `@suaveplan/logger` all satisfy this shape by construction, and consumers needing silence can pass the trivial `{ debug: noop, info: noop, warn: noop, error: noop }` object. Keeping the surface tiny keeps the rule-8 dependency story honest: downstream libraries only see the type, not the implementation.
|
||||
@@ -0,0 +1,83 @@
|
||||
# MessageEnvelope
|
||||
|
||||
## Purpose
|
||||
|
||||
The typed wire envelope every network message uses — generic in `TPayload`. Cross-cutting type so any plugin (transport, protocol, content-delivery, audit-mesh, etc.) can declare envelope shapes without importing the network package. The concrete `MessageEnvelope` runtime factory and Zod validator live in `@suaveplan/network`; this is the structural contract every implementation conforms to. Living in `@suaveplan/types` keeps `network` and `network-plugin-*` from forming a dependency cycle — every plugin can declare its envelope shapes against this type and the core never needs to know about the plugin.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic in `TPayload` — plugins can express their encoded payload type at compile time (e.g., `MessageEnvelope<ChunkAdvertisePayload>`).
|
||||
- All seven fields `readonly` — envelopes are immutable snapshots.
|
||||
- `targetId` accepts `PeerId | PeerIdBroadcast` so unicast and broadcast share the same type.
|
||||
- `headers` is `Readonly<Record<string, string>>` — string keys and values keep the wire format simple and serializable.
|
||||
- `payload` defaults to `Uint8Array` matching the on-wire form after `network-plugin-protocol` encoding.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { MessageEnvelope } from "@suaveplan/types/message-envelope";
|
||||
|
||||
const envelope: MessageEnvelope = {
|
||||
id: "msg-1729a4f72c3b",
|
||||
type: "cd:request",
|
||||
senderId: "peerA-pubkey-hex",
|
||||
targetId: "peerB-pubkey-hex",
|
||||
payload: new Uint8Array([1, 2, 3]),
|
||||
timestamp: Date.now(),
|
||||
headers: { "trace-id": "abc-123" },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { MessageEnvelope } from "@suaveplan/types/message-envelope";
|
||||
|
||||
// A typed envelope for a specific message kind.
|
||||
interface ChunkRequestPayload {
|
||||
fileId: string;
|
||||
chunkIds: number[];
|
||||
}
|
||||
|
||||
type ChunkRequestEnvelope = MessageEnvelope<ChunkRequestPayload>;
|
||||
|
||||
function handleChunkRequest(envelope: ChunkRequestEnvelope): void {
|
||||
// payload is typed as ChunkRequestPayload — no casting.
|
||||
const { fileId, chunkIds } = envelope.payload;
|
||||
console.log(`peer ${envelope.senderId} wants ${chunkIds.length} chunks of ${fileId}`);
|
||||
}
|
||||
|
||||
// On the wire, envelopes are typed `MessageEnvelope<Uint8Array>`;
|
||||
// plugins decode the payload into the typed form on receipt.
|
||||
function decodeChunkRequest(
|
||||
raw: MessageEnvelope,
|
||||
decoder: (bytes: Uint8Array) => ChunkRequestPayload,
|
||||
): ChunkRequestEnvelope {
|
||||
return {
|
||||
...raw,
|
||||
payload: decoder(raw.payload),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface MessageEnvelope<TPayload = Uint8Array>`
|
||||
|
||||
Seven readonly fields:
|
||||
|
||||
- `id: string` — unique envelope identifier. Implementations typically generate ULIDs or UUIDv7 so chronological sort order matches insertion order.
|
||||
- `type: string` — discriminator string used by the dispatch layer to route to the right handler. Conventional namespacing: `"cd:request"`, `"election:vote"`, `"key:grant"`, `"peer:joined"`. The `network-plugin-protocol` ships a frozen `NetworkEvents` enum of standard topics.
|
||||
- `senderId: PeerId` — originating peer's identity. Verified at receive time against the registry's pubkey table; mismatched signatures cause the envelope to be dropped.
|
||||
- `targetId: PeerId | PeerIdBroadcast` — target peer's identity or the broadcast sentinel `"*"`. Routing decisions branch on `isBroadcast(targetId)`.
|
||||
- `payload: TPayload` — encoded payload. Defaults to `Uint8Array`; plugins decode to a typed form internally.
|
||||
- `timestamp: number` — creation timestamp in unix milliseconds. Implementations use an injectable `Clock` from `@suaveplan/types` so tests can run with `FakeClock`.
|
||||
- `headers: Readonly<Record<string, string>>` — optional metadata. Common headers: `trace-id` (W3C trace context), `correlation-id` (matching request/response), `signature` (Ed25519 over the envelope).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The type is generic in `TPayload` so plugins can author typed envelope handlers at compile time. The on-wire form is always `MessageEnvelope<Uint8Array>` — the protocol plugin encodes the payload before transport and decodes it after — but plugin code that sees a decoded envelope works with `MessageEnvelope<MyPayload>` typed correctly through the dispatch layer.
|
||||
|
||||
The `readonly` modifiers are non-negotiable. Mutating an envelope after dispatch leads to subtle bugs when the same envelope is fanned out to multiple handlers (or replicated across the relay backbone). Plugins that need to "modify" an envelope must construct a new one with the desired changes — the protocol layer's middleware chain returns new envelopes from each stage rather than mutating the input.
|
||||
|
||||
The 13-byte binary header that `network-plugin-protocol` (NET-15) prefixes onto encoded envelopes carries `version`, `flags`, `messageType`, `timestamp`, and `payloadLength`. This header is wire-format only; the structural `MessageEnvelope` shape is the in-memory representation and is what every plugin works with. Conversion happens at the protocol pipeline boundary.
|
||||
@@ -0,0 +1,89 @@
|
||||
# message-resolver
|
||||
|
||||
## Purpose
|
||||
|
||||
`MessageResolver` is the canonical type alias for an i18n message-resolution function. It takes an i18n key and optional template parameters and returns the rendered string. `@suaveplan/error` installs one globally via `setMessageResolver`; the default returns the key verbatim, keeping errors readable without an i18n catalogue.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-only — zero runtime cost
|
||||
- Same shape across error, i18n, and any package that wants to render keyed strings
|
||||
- Params are `Readonly<Record<string, unknown>>` — opaque to the type, interpreted by the implementation
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { MessageResolver } from "@suaveplan/types";
|
||||
|
||||
const simpleResolver: MessageResolver = (key) => `[en] ${key}`;
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { MessageResolver } from "@suaveplan/types";
|
||||
import { setMessageResolver } from "@suaveplan/error";
|
||||
import catalogue from "./locales/en.json";
|
||||
|
||||
const catalogueResolver: MessageResolver = (key, params) => {
|
||||
const template = (catalogue as Record<string, string>)[key] ?? key;
|
||||
if (params === undefined) return template;
|
||||
return template.replace(
|
||||
/\{(\w+)\}/g,
|
||||
(_, name) => String(params[name] ?? "")
|
||||
);
|
||||
};
|
||||
|
||||
setMessageResolver(catalogueResolver);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type MessageResolver`
|
||||
|
||||
```ts
|
||||
(key: string, params?: Readonly<Record<string, unknown>>) => string
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Implementations MUST be pure (same `(key, params)` → same output) so structured logs and error snapshots are reproducible. Implementations MUST NOT throw for unknown keys — unknown keys SHOULD return the key itself so the resulting error message is still diagnostic. Template interpolation syntax (e.g. `{name}`, `%{name}`) is a choice of the implementation — the type does not prescribe one.
|
||||
|
||||
## Further Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { MessageResolver } from "@suaveplan/types";
|
||||
|
||||
const resolver: MessageResolver = (key, params) => {
|
||||
if (key === "greeting" && params?.name) return `Hello ${params.name}`;
|
||||
return key;
|
||||
};
|
||||
```
|
||||
|
||||
## Further Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { MessageResolver } from "@suaveplan/types";
|
||||
import { setMessageResolver } from "@suaveplan/error";
|
||||
|
||||
const dict = new Map<string, string>([
|
||||
["crypto.utils.primitive.missing", "No implementation for {id}"],
|
||||
]);
|
||||
|
||||
const resolver: MessageResolver = (key, params) => {
|
||||
const tpl = dict.get(key) ?? key;
|
||||
return tpl.replace(/\{(\w+)\}/g, (_, k) => String(params?.[k] ?? ""));
|
||||
};
|
||||
|
||||
setMessageResolver(resolver);
|
||||
```
|
||||
|
||||
## Further API Reference
|
||||
|
||||
### `type MessageResolver = (key: string, params?: Readonly<Record<string, unknown>>) => string`
|
||||
|
||||
A resolver that turns an i18n key plus optional params into a localised, interpolated string. Return the key verbatim when no translation is available.
|
||||
|
||||
## Further Implementation Notes
|
||||
|
||||
The default pass-through resolver returns the raw key so errors thrown before i18n bootstrap still surface a stable, greppable identifier. Installing a resolver via `setMessageResolver` is process-global and best done at app-startup time before any error is thrown. The function signature deliberately matches the `@suaveplan/i18n` `translate` shape so apps can pass their configured translator directly.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Middleware
|
||||
|
||||
## Purpose
|
||||
|
||||
Cross-cutting middleware contract for operation interception. Used by drive and other packages that need to wrap operations with cross-cutting concerns such as logging, caching, retry logic, and timing.
|
||||
|
||||
The `MiddlewareContext` uses an open index signature so consumers can attach domain-specific properties (e.g. `drive`, `args`, `metadata`) without forcing `@suaveplan/types` to depend on those domains.
|
||||
|
||||
## Features
|
||||
|
||||
- **Open context** — index signature allows consumer extensions without circular deps
|
||||
- **Short-circuit support** — handlers may skip `next()` to abort the chain
|
||||
- **Composable** — handlers can be chained via `combineMiddleware` utilities in consuming packages
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type { MiddlewareHandler, MiddlewareContext, NextFunction } from '@suaveplan/types/middleware';
|
||||
|
||||
const loggingMiddleware: MiddlewareHandler = async (context, next) => {
|
||||
console.log(`[${context.operation}] ${context.path}`);
|
||||
await next();
|
||||
console.log(`[${context.operation}] done`);
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type { MiddlewareHandler, MiddlewareContext, NextFunction } from '@suaveplan/types/middleware';
|
||||
|
||||
// Consumer-extended context
|
||||
interface DriveMiddlewareContext extends MiddlewareContext {
|
||||
drive: unknown;
|
||||
metadata: Map<string, unknown>;
|
||||
}
|
||||
|
||||
const timingMiddleware: MiddlewareHandler = async (context: DriveMiddlewareContext, next) => {
|
||||
const start = performance.now();
|
||||
context.metadata.set('startTime', start);
|
||||
await next();
|
||||
context.metadata.set('durationMs', performance.now() - start);
|
||||
};
|
||||
|
||||
const cacheMiddleware: MiddlewareHandler = async (context, next) => {
|
||||
if (context.operation === 'read' && cache.has(context.path)) {
|
||||
// short-circuit: do not call next
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MiddlewareContext`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `operation` | `string` | Operation name |
|
||||
| `path` | `string` | Target path |
|
||||
| `[key: string]` | `unknown` | Open extension slot |
|
||||
|
||||
### `NextFunction`
|
||||
|
||||
`() => Promise<void>` — advances the chain.
|
||||
|
||||
### `MiddlewareHandler`
|
||||
|
||||
`(context: MiddlewareContext, next: NextFunction) => Promise<void>` — interceptor function.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The index signature `[key: string]: unknown` is the only way to keep `@suaveplan/types` zero-deps while still allowing consumers to pass rich contexts. Consumers that need type safety should define a narrower interface that extends `MiddlewareContext`.
|
||||
- `MiddlewareHandler` returns `Promise<void>` rather than `Promise<unknown>` because middleware is typically side-effectful (logging, caching) rather than result-transforming. Result transformation is the domain of `PluginHook`.
|
||||
@@ -0,0 +1,94 @@
|
||||
# NetworkPluginLike
|
||||
|
||||
## Purpose
|
||||
|
||||
Minimum structural shape every network plugin implements. Cross-cutting type so every plugin (transport, protocol, registry, governance, content-delivery, audit-mesh, relay, etc.) can be authored against the same `install → start → stop → dispose` lifecycle without importing the network package. The concrete `NetworkPlugin` interface that ships in `@suaveplan/network` is structurally compatible with `NetworkPluginLike` — they describe the same four-method lifecycle. Living in `@suaveplan/types` lets plugin packages exist in their own packages without forcing the network core to know about every plugin variant — the core just consumes anything that satisfies the `*Like` shape.
|
||||
|
||||
## Features
|
||||
|
||||
- Four-stage lifecycle (`install`, `start`, `stop`, optional `dispose`) covering every network-plugin pattern in Suaveplan.
|
||||
- Generic in `TNode` so plugins that need a typed handle to the network node can declare it without losing the type at the boundary.
|
||||
- Sync-or-async lifecycle methods (`Promise<void> | void`) — both work, the core awaits the result either way.
|
||||
- `name` is `readonly` — plugin names are baked at construction.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { NetworkPluginLike } from "@suaveplan/types/network-plugin";
|
||||
|
||||
const heartbeatPlugin: NetworkPluginLike = {
|
||||
name: "heartbeat",
|
||||
install: () => {},
|
||||
start: () => {
|
||||
console.log("heartbeat started");
|
||||
},
|
||||
stop: () => {
|
||||
console.log("heartbeat stopped");
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { NetworkPluginLike } from "@suaveplan/types/network-plugin";
|
||||
|
||||
interface NetworkBus {
|
||||
publish(topic: string, payload: unknown): void;
|
||||
subscribe(topic: string, handler: (payload: unknown) => void): () => void;
|
||||
}
|
||||
|
||||
interface NetworkNode {
|
||||
bus: NetworkBus;
|
||||
}
|
||||
|
||||
class GovernancePlugin implements NetworkPluginLike<NetworkNode> {
|
||||
public readonly name = "governance";
|
||||
private node?: NetworkNode;
|
||||
private unsubscribe?: () => void;
|
||||
|
||||
async install(node: NetworkNode): Promise<void> {
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (!this.node) throw new Error("install must precede start");
|
||||
this.unsubscribe = this.node.bus.subscribe("election:vote", (payload) => {
|
||||
// tally vote
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = undefined;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.node = undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface NetworkPluginLike<TNode = unknown>`
|
||||
|
||||
Four methods plus a name:
|
||||
|
||||
- `name: string` — unique within a `PluginRegistry`. Drives bus topic prefixes (`<name>:vote`, `<name>:elected`) and OTel span names.
|
||||
- `install(node: TNode): Promise<void> | void` — called exactly once when the plugin is registered. Receives a typed handle to the network node so the plugin can subscribe to the bus, register transports, or read the registry.
|
||||
- `start(): Promise<void> | void` — begin operating. Idempotent — `start` after `start` is a no-op. The network core enforces ordering: every plugin's `install` resolves before any plugin's `start` runs.
|
||||
- `stop(): Promise<void> | void` — halt operating. Inverse of `start`. Idempotent.
|
||||
- `dispose?(): Promise<void> | void` — optional final cleanup. Called once after the last `stop`. Plugins release sockets, workers, and watchers here.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lifecycle is strict, not optional. The network core (and tests) call `install → start → ... → stop → dispose` in this exact order; plugins MUST NOT assume any other ordering. A plugin that reads from the bus before `install` has resolved, or sends an envelope after `stop`, is a bug.
|
||||
|
||||
`install` is the right place to record the node handle, register transports, and subscribe to bus topics; do NOT begin emitting events from `install` because peer plugins may not yet have registered their handlers. Begin emitting in `start`.
|
||||
|
||||
`stop` MUST be idempotent. Common bug: a plugin's `stop` performs cleanup and sets a state field; a second `stop` (e.g., during error recovery) throws because the state is already cleaned. Defend against this by treating already-stopped state as the success case.
|
||||
|
||||
`dispose` is for resources that survive across stop/start cycles — open file descriptors, child workers, outstanding HTTP keep-alives. Anything that should be re-acquired on the next `start` should be released in `stop` instead.
|
||||
|
||||
The `*Like` suffix mirrors `StorageLike`, `LoggerLike`, `EventBusLike` — structural-compatibility types that callers can use without importing the package that defines the canonical interface. The corresponding `NetworkPlugin` (no suffix) in `@suaveplan/network` is the canonical interface; the network core consumes both shapes interchangeably because they are structurally identical.
|
||||
@@ -0,0 +1,74 @@
|
||||
# options
|
||||
|
||||
## Purpose
|
||||
|
||||
The `options` module bundles the three cross-cutting option shapes that recur across many Suaveplan packages — cooperative cancellation, trace correlation, and determinism injection. Defining them in one canonical place keeps signatures uniform and prevents drift (e.g. two packages named the same field differently — `traceId` vs `trace_id`).
|
||||
|
||||
## Features
|
||||
|
||||
- Three named bundles: `CancellableOptions`, `TraceableOptions`, `DeterministicOptions`
|
||||
- All fields optional — bundles compose via intersection when multiple are needed
|
||||
- Type-only — zero runtime cost
|
||||
- Documented semantics for every field (unit, default, expected behaviour)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { CancellableOptions } from "@suaveplan/types";
|
||||
|
||||
async function wait(ms: number, options: CancellableOptions = {}): Promise<void> {
|
||||
await new Promise((resolve, reject) => {
|
||||
const handle = setTimeout(resolve, ms);
|
||||
options.signal?.addEventListener("abort", () => {
|
||||
clearTimeout(handle);
|
||||
reject(options.signal?.reason);
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
CancellableOptions,
|
||||
DeterministicOptions,
|
||||
TraceableOptions,
|
||||
} from "@suaveplan/types";
|
||||
|
||||
type SetupOptions = CancellableOptions & TraceableOptions & DeterministicOptions;
|
||||
|
||||
export async function runSetup(options: SetupOptions = {}): Promise<void> {
|
||||
const clock = options.clock ?? (() => Date.now());
|
||||
const t0 = clock();
|
||||
// honour signal, emit traceId, use deterministic id generator
|
||||
if (options.signal?.aborted === true) {
|
||||
throw options.signal.reason;
|
||||
}
|
||||
const elapsed = clock() - t0;
|
||||
logger.info("setup complete", {
|
||||
traceId: options.traceId,
|
||||
elapsedMs: elapsed,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface CancellableOptions`
|
||||
|
||||
- `readonly signal?: AbortSignal` — cooperative cancellation
|
||||
- `readonly timeoutMs?: number` — deadline in milliseconds; 0 or negative means "no timeout"
|
||||
|
||||
### `interface TraceableOptions`
|
||||
|
||||
- `readonly traceId?: string` — trace correlation id; propagates through envelopes, log context, and span attributes
|
||||
|
||||
### `interface DeterministicOptions`
|
||||
|
||||
- `readonly clock?: Clock` — time source injection
|
||||
- `readonly idGenerator?: IdGenerator` — id source injection
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Composing bundles via intersection (`CancellableOptions & TraceableOptions`) is preferred over a single mega-bundle so each signature advertises exactly the concerns it respects. Consumers that don't honour a bundle's semantics MUST omit it from their type rather than silently ignore the field — TypeScript's structural typing would otherwise accept the field at the call site and the caller would think the setting worked. `timeoutMs` of 0 and negative are reserved for "no timeout" to match Node's `setTimeout` semantics (which fires immediately on 0 but callers usually mean "disabled"); consumers should document their own interpretation.
|
||||
@@ -0,0 +1,60 @@
|
||||
# PeerId
|
||||
|
||||
## Purpose
|
||||
|
||||
A type alias for peer identifiers in the mesh, plus the canonical broadcast sentinel `"*"`. Conventionally a 64-character lowercase hex string (32-byte Ed25519 public key), but the type does not enforce the format — implementations validate at boundaries via Zod or bitpacker schemas. Living in `@suaveplan/types` lets every cross-cutting structure (`MessageEnvelope`, `Transport`, `ChunkAdvertisePayload`, etc.) reference peers by a stable type without importing a runtime package, and the broadcast sentinel is a single source of truth so plugins do not redefine `"*"` locally.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-safe alias for peer identifiers (currently `string`; future versions may switch to a branded string type).
|
||||
- Canonical broadcast sentinel type `PeerIdBroadcast = "*"` for `MessageEnvelope.targetId`. The literal value `"*"` is the sentinel; the type ensures it cannot be mistyped at compile time.
|
||||
- Companion type `PeerIdBroadcast` for code that wants to discriminate broadcast vs. unicast.
|
||||
- Zero runtime cost — the package is type-only so the sentinel is exported as a literal type rather than a const value.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { PeerId } from "@suaveplan/types/peer-id";
|
||||
|
||||
const alice: PeerId = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { PeerId, PeerIdBroadcast } from "@suaveplan/types/peer-id";
|
||||
|
||||
function isBroadcast(target: PeerId | PeerIdBroadcast): target is PeerIdBroadcast {
|
||||
return target === "*";
|
||||
}
|
||||
|
||||
function describeTarget(target: PeerId | PeerIdBroadcast): string {
|
||||
if (isBroadcast(target)) return "all peers";
|
||||
return `peer ${target.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
describeTarget("*"); // "all peers"
|
||||
describeTarget("abcdef12..."); // "peer abcdef12…"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type PeerId`
|
||||
|
||||
Type alias for `string`. Conventionally a 64-character lowercase hex string. Implementations:
|
||||
|
||||
- Encode peer ids as `lowercase hex(crypto.subtle.exportKey('raw', publicKey))` for Ed25519 keys.
|
||||
- Validate format at boundaries (network-plugin-registry on `peer:joined`, drive-mesh on `cd:advertise`).
|
||||
- Treat the type as opaque — never parse it for anything other than equality and routing.
|
||||
|
||||
### `type PeerIdBroadcast`
|
||||
|
||||
The literal type `"*"`. Reserved as the broadcast target in `MessageEnvelope.targetId`. MUST NOT be used as a real peer id; validators reject any peer claiming this id. Use literal `"*"` in code or declare your own runtime constant typed as `PeerIdBroadcast` if you prefer a named symbol.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The current type is a bare `string`. A future version may upgrade to a branded type (`string & { readonly __peerIdBrand: unique symbol }`) to catch accidental swaps with other string types (`fileId`, `chunkHash`, `messageType`). The migration would be additive — a brand applied to a `string` type is structurally compatible at runtime — and would land as a new requirement in the specification.
|
||||
|
||||
The broadcast sentinel is exposed as a literal type (`PeerIdBroadcast = "*"`) rather than a runtime constant because `@suaveplan/types` is bound by a hard zero-runtime invariant — the `tests/zero-runtime.test.ts` walks `dist/` after every build and rejects any file containing a `const`, `let`, `var`, `function`, or `class` declaration. Consumers that want a named constant declare one locally: `const PEER_ID_BROADCAST: PeerIdBroadcast = "*"`.
|
||||
|
||||
Validating peer id format (length, hex-ness, lowercase) at every boundary is the responsibility of the consuming package, not this type. The justification: different deployments may use different identifiers (raw Ed25519 hex, multi-base encoded keys, DIDs), and forcing a single format here would over-constrain the contract.
|
||||
@@ -0,0 +1,64 @@
|
||||
# PipelineStage
|
||||
|
||||
## Purpose
|
||||
|
||||
`PipelineStage<TIn, TOut>` describes one node in a streaming pipeline: a named, optionally-parallelisable `StreamTransformer`. Centralising this shape in `@suaveplan/types` means a pipeline runner in one package (say a future `@suaveplan/pipeline`) can host stages contributed by another package (bitpacker, compression, encryption) without packages needing to know about each other.
|
||||
|
||||
The `name` field exists specifically for observability — every pipeline stage is a natural span boundary, a natural log scope, and a natural progress checkpoint. Forcing every stage to declare a name keeps observability code uniform.
|
||||
|
||||
## Features
|
||||
|
||||
- Three fields, each with a single clear job: `name` for ops, `transform` for work, `parallel` for hints.
|
||||
- Generic over input and output types so a typed pipeline can statically prove stage type compatibility (`PipelineStage<A, B>` then `PipelineStage<B, C>` → result type `C`).
|
||||
- `parallel` is an optional advisory flag — runners that ignore it still receive correct output, runners that honour it accelerate stateless stages.
|
||||
- Pure type — zero runtime cost.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { PipelineStage, StreamTransformer } from "@suaveplan/types";
|
||||
|
||||
const upperT: StreamTransformer<string, string> = {
|
||||
transform: (s) => s.toUpperCase(),
|
||||
};
|
||||
const upperStage: PipelineStage<string, string> = {
|
||||
name: "upper",
|
||||
transform: upperT,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { PipelineStage, StreamTransformer } from "@suaveplan/types";
|
||||
|
||||
const compressT: StreamTransformer<Uint8Array, Uint8Array> = {
|
||||
async transform(chunk) {
|
||||
return await someCompressor.encode(chunk);
|
||||
},
|
||||
};
|
||||
|
||||
const compressStage: PipelineStage<Uint8Array, Uint8Array> = {
|
||||
name: "gzip-encode",
|
||||
transform: compressT,
|
||||
parallel: true, // codec is stateless per chunk
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PipelineStage<TIn = unknown, TOut = unknown>`
|
||||
|
||||
| Field | Type | Required | Meaning |
|
||||
|---|---|---|---|
|
||||
| `name` | `string` | yes | Observability label (log scope, span name, progress checkpoint). |
|
||||
| `transform` | `StreamTransformer<TIn, TOut>` | yes | The work this stage performs on each chunk. |
|
||||
| `parallel` | `boolean` | no | Hint that chunks may be fanned out across workers. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`name` is required (not optional) on purpose — letting it default to `"unnamed"` would silently degrade observability and make production traces useless. Forcing every stage to declare a name pushes the cost up to authoring time, where it belongs.
|
||||
|
||||
`parallel` is advisory because the pipeline runner is the only piece that knows the available worker pool, the chunk size, and the IPC cost. A stage flagged `parallel: true` may legitimately run sequentially when the runner decides the parallel overhead exceeds the per-chunk work.
|
||||
|
||||
The default generic parameters `unknown` exist so consumers writing dynamically-typed pipelines (e.g. config-driven runners) don't need to thread through type parameters they don't have. Type-safe consumers always parameterise explicitly.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Plugin
|
||||
|
||||
## Purpose
|
||||
|
||||
Cross-cutting plugin contracts for extensible packages. Two models are provided:
|
||||
|
||||
1. **`Plugin`** — lifecycle-based (`initialize` / `destroy`). Used by upstream plugin managers and simple registration systems.
|
||||
2. **`DrivePlugin`** — hook-based (`hooks: Record<string, PluginHook>`). Used by suaveplan drive and similar systems that execute hooks in priority order.
|
||||
|
||||
Both models share `PluginType` and `PluginRegistration` for metadata.
|
||||
|
||||
## Features
|
||||
|
||||
- **Lifecycle plugins** — `initialize` / `destroy` with typed config generics
|
||||
- **Hook plugins** — register named hooks executed via `PluginManager.execute`
|
||||
- **Priority ordering** — both models support priority for execution order
|
||||
- **Type-safe config** — `Plugin<TConfig>` generic flows through `initialize`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type { Plugin, PluginType } from '@suaveplan/types/plugin';
|
||||
|
||||
class LoggingPlugin implements Plugin<{ level: string }> {
|
||||
readonly name = 'logging';
|
||||
readonly version = '1.0.0';
|
||||
readonly type: PluginType = 'hook';
|
||||
|
||||
async initialize(config: { level: string }): Promise<void> {
|
||||
console.log(`Logging level: ${config.level}`);
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
console.log('Logging plugin destroyed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type { DrivePlugin, PluginHook, NextFunction } from '@suaveplan/types/plugin';
|
||||
|
||||
const metricsHook: PluginHook = async (context, next) => {
|
||||
const start = Date.now();
|
||||
const result = await next();
|
||||
console.log(`Duration: ${Date.now() - start}ms`);
|
||||
return result;
|
||||
};
|
||||
|
||||
const metricsPlugin: DrivePlugin = {
|
||||
name: 'metrics',
|
||||
priority: 100,
|
||||
hooks: {
|
||||
'drive:read': metricsHook,
|
||||
'drive:write': metricsHook,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PluginType`
|
||||
|
||||
Union: `'storage' | 'transform' | 'hook' | 'middleware' | 'kms' | 'dedup' | 'cow'`.
|
||||
|
||||
### `Plugin<TConfig>`
|
||||
|
||||
| Property / Method | Type | Description |
|
||||
|-------------------|------|-------------|
|
||||
| `name` | `string` (readonly) | Unique plugin name |
|
||||
| `version` | `string` (readonly) | Semver version |
|
||||
| `type` | `PluginType` (readonly) | Category |
|
||||
| `initialize(config)` | `(TConfig) => Promise<void>` | Lifecycle start |
|
||||
| `destroy()` | `() => Promise<void>` | Lifecycle end |
|
||||
|
||||
### `PluginRegistration<T>`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `plugin` | `T` | Plugin instance |
|
||||
| `priority` | `number` | Execution priority |
|
||||
| `enabled` | `boolean` | Active flag |
|
||||
|
||||
### `NextFunction`
|
||||
|
||||
`() => Promise<void>` — advances to the next hook or middleware.
|
||||
|
||||
### `PluginHook`
|
||||
|
||||
`(context: unknown, next: NextFunction) => Promise<unknown>` — hook function signature.
|
||||
|
||||
### `DrivePlugin`
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `name` | `string` | Unique plugin name |
|
||||
| `hooks` | `Record<string, PluginHook>` | Named hooks |
|
||||
| `priority` | `number` (optional) | Execution priority |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `Plugin` and `DrivePlugin` are intentionally separate interfaces. A single class may implement both if the host system supports both models.
|
||||
- `PluginHook` returns `Promise<unknown>` so hooks can transform or replace the result.
|
||||
@@ -0,0 +1,65 @@
|
||||
# rng
|
||||
|
||||
## Purpose
|
||||
|
||||
`Rng` is the canonical interface for a pluggable random-number source. Every Suaveplan package that needs randomness (factories, fixture shuffling, seeded replays) accepts an `Rng` injection so tests can substitute a deterministic seeded generator and cross-package implementations stay interchangeable.
|
||||
|
||||
## Features
|
||||
|
||||
- Interface-shaped — consumers accept any conformant implementation
|
||||
- Two surfaces: `nextUint32()` for discrete ranges, `nextFloat()` for continuous
|
||||
- Unit-normalised — `nextFloat()` always returns `[0, 1)` with 32-bit resolution
|
||||
- Cryptographic vs seeded is a property of the implementation, not the type
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
const constantRng: Rng = {
|
||||
nextUint32: () => 42,
|
||||
nextFloat: () => 0.5,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
// Seeded PRNG (Mulberry32) — deterministic by construction.
|
||||
function mulberry32(seed: number): Rng {
|
||||
let a = seed >>> 0;
|
||||
return {
|
||||
nextUint32: () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0);
|
||||
},
|
||||
nextFloat: function () {
|
||||
return this.nextUint32() / 0x1_0000_0000;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const rng = mulberry32(42);
|
||||
const u = rng.nextUint32();
|
||||
const f = rng.nextFloat();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface Rng`
|
||||
|
||||
- `nextUint32(): number` — uniform random integer in `[0, 2^32)`
|
||||
- `nextFloat(): number` — uniform random float in `[0, 1)` with ≥32-bit resolution
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Consumers SHOULD call `nextFloat()` by composing `nextUint32() / 2^32` rather than `Math.random()` internally, so the implementation controls both surfaces. Cryptographic sources (e.g. backed by `crypto.getRandomValues`) conform to the same interface; consumers that require cryptographic quality SHOULD document that requirement rather than baking it into the type (since a seeded Mulberry32 is indistinguishable at the type level).
|
||||
|
||||
### Ecosystem Usage
|
||||
|
||||
Factories in `@suaveplan/testing` shuffle fixture records via `nextUint32()`, jitter helpers in `@suaveplan/retry` call `nextFloat()` for decorrelated-jitter decisions, sampler distributions in `@suaveplan/random` pull continuous variates from `nextFloat()`, and seeded replays in `@suaveplan/pseudo-random` implement the same interface with a deterministic Mulberry32 or xorshift generator so property tests always reproduce exactly.
|
||||
@@ -0,0 +1,58 @@
|
||||
# SourceHint
|
||||
|
||||
## Purpose
|
||||
|
||||
Cross-cutting union that names the three sources where a chunk may live or be fetched from: `local` (this drive's storage layer), `mesh` (peers over the network via `cd:request`), and `cdn` (the configured CDN fallback via `drive-transport-https`). Used inside `ChunkPresence.sourceHints` so UIs and progress trackers can describe a file's source mix without importing any drive or network internals. Living in `@suaveplan/types` keeps the contract decoupled — the runtime layers that actually consult these sources are `drive`, `drive-mesh`, and `drive-transport-*`, none of which the UI needs to import.
|
||||
|
||||
## Features
|
||||
|
||||
- Three concrete states matching the three real sources the drive can pull bytes from.
|
||||
- Stable contract — adding a fourth state requires an OpenSpec change, so the design is forced to be intentional.
|
||||
- Combines naturally with `ChunkPresence` to describe progress and origin in one snapshot.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { SourceHint } from "@suaveplan/types/source-hint";
|
||||
|
||||
const sources: readonly SourceHint[] = ["local", "mesh"];
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { SourceHint } from "@suaveplan/types/source-hint";
|
||||
|
||||
const sourceLabels: Record<SourceHint, string> = {
|
||||
local: "Disk",
|
||||
mesh: "Peers",
|
||||
cdn: "CDN",
|
||||
};
|
||||
|
||||
function describePresence(sources: readonly SourceHint[]): string {
|
||||
if (sources.length === 0) return "no source available";
|
||||
return `available from: ${sources.map((s) => sourceLabels[s]).join(", ")}`;
|
||||
}
|
||||
|
||||
describePresence(["local"]); // "available from: Disk"
|
||||
describePresence(["local", "mesh"]); // "available from: Disk, Peers"
|
||||
describePresence(["mesh", "cdn"]); // "available from: Peers, CDN"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `type SourceHint`
|
||||
|
||||
Union: `"local" | "mesh" | "cdn"`.
|
||||
|
||||
- `"local"` — the chunk is present in this drive's storage. `Drive.read()` returns it without any network activity.
|
||||
- `"mesh"` — the chunk is known to be available from at least one peer in the mesh registry. The drive's `mesh` plugin can satisfy a request via `cd:request` envelope.
|
||||
- `"cdn"` — the chunk is available at the configured CDN fallback URL (constructed from `CdnFallbackConfig.baseUrl` and the reversed-ID URL convention). The `drive-transport-https` plugin can fetch it.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SourceHint` is ordered by typical preference: `local` first (zero round-trip), `mesh` second (LAN-or-better latency), `cdn` last (predictable but distant). When the drive lists sources for a missing chunk, it should return them in priority order so consumers can render and the read pipeline can race them in the same order.
|
||||
|
||||
The set is closed at three. New sources are not foreseen; the design assumption is that any other byte-providing service either fronts as a CDN (HTTPS-addressable) or joins as a mesh peer (registers with `network-plugin-registry`). Adding a fourth state would require resolving the priority semantics — currently implicit in the order.
|
||||
|
||||
A `sourceHints: []` (empty array) on a `ChunkPresence` describes a chunk that no source can deliver — the drive should surface this as a "stuck" state in the UI and emit a `drive:chunk:stuck` event for monitoring.
|
||||
@@ -0,0 +1,70 @@
|
||||
# StorageCapabilities
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares which optimisations a storage backend supports natively. The drive layer (and the `drive-plugin-chunks` layer) reads these flags at composition time to choose optimal layouts and avoid runtime probing — for example, `chunks-plugin` picks the **sparse-file** layout when `partialWrites === true` and the **chunk-keys** layout otherwise. Living in `@suaveplan/types` lets every concrete backend (`MemoryStorage`, `S3`, `OpfsStorage`, `FileStorage`, `SqliteStorage`, `FatStorage`, etc.) declare its capabilities without anyone having to import a runtime package — the drive simply reads `storage.capabilities` and routes accordingly.
|
||||
|
||||
## Features
|
||||
|
||||
- Five boolean flags covering the access patterns drive consumers care about.
|
||||
- Defaults to all-`false` so backends opt-in to optimisations they truly support.
|
||||
- Allows the drive layer to pick storage layouts statically (no runtime feature detection).
|
||||
- `readonly` fields mean the capability descriptor is fixed at construction — no late-flip surprises.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { StorageCapabilities } from "@suaveplan/types/storage-capabilities";
|
||||
|
||||
const memoryCaps: StorageCapabilities = {
|
||||
partialReads: true,
|
||||
partialWrites: true,
|
||||
nativeStreams: true,
|
||||
atomicWrites: true,
|
||||
contentAddressable: false,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { StorageCapabilities } from "@suaveplan/types/storage-capabilities";
|
||||
|
||||
// A backend may declare its capabilities and consumers can branch
|
||||
// without importing the backend.
|
||||
function pickChunkLayout(caps: StorageCapabilities): "sparse-file" | "chunk-keys" {
|
||||
return caps.partialWrites ? "sparse-file" : "chunk-keys";
|
||||
}
|
||||
|
||||
// S3 cannot do native partial writes — the chunks plugin picks
|
||||
// chunk-keys layout for it.
|
||||
const s3Caps: StorageCapabilities = {
|
||||
partialReads: true,
|
||||
partialWrites: false,
|
||||
nativeStreams: true,
|
||||
atomicWrites: true,
|
||||
contentAddressable: false,
|
||||
};
|
||||
|
||||
pickChunkLayout(s3Caps); // "chunk-keys"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface StorageCapabilities`
|
||||
|
||||
Five readonly boolean fields:
|
||||
|
||||
- `partialReads` — `getPartial(key, start, end)` is native (no read-modify-write fallback).
|
||||
- `partialWrites` — `setPartial(key, offset, bytes)` is native (no read-modify-write fallback). Drives use this flag to pick chunk layouts.
|
||||
- `nativeStreams` — `createReadStream` and `createWriteStream` are native (no whole-blob buffering). The drive's stream methods fall back to buffered reads/writes when this is `false`.
|
||||
- `atomicWrites` — `set` is crash-atomic (e.g., `rename` over `write`-and-`fsync`). Important for index blobs that must survive a crash mid-write.
|
||||
- `contentAddressable` — the storage backend derives keys from content hashes (BLAKE3, SHA-256). Two writes of identical content coalesce. Used by the dedup plugin and `network-plugin-content-delivery`'s redundancy manager.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `StorageCapabilities` interface is the wire that connects the drive layer's optimisation strategies to the backend layer's actual implementation. By declaring the contract in `@suaveplan/types` (zero deps, types-only), every concrete backend can publish its capabilities without forcing a circular dependency on `@suaveplan/storage` — the package that defines `AbstractStorage`.
|
||||
|
||||
The `AbstractStorage` base class implements the contract structurally: every subclass overrides `capabilities` to its own readonly record. The drive's `chunks-plugin` reads `storage.capabilities.partialWrites` once during initialisation to pick the chunk layout — never at runtime, so storage backends cannot change their capabilities mid-flight.
|
||||
|
||||
The five flags are deliberately conservative. New flags require adding a new requirement to the storage specification (the `STORAGE-04` capability matrix) — the contract evolves through the specification process, not through ad-hoc additions.
|
||||
@@ -0,0 +1,75 @@
|
||||
# StorageLike
|
||||
|
||||
## Purpose
|
||||
|
||||
`StorageLike` is a minimal structural interface for key-value storage backends. It represents the narrowest surface that consuming packages need in order to type-check their storage dependency injection points without coupling to any concrete implementation or incurring a runtime dependency on `@suaveplan/storage`.
|
||||
|
||||
The interface is intentionally kept small: get, set, delete, exists, list, clear. Enhanced capabilities — partial reads, streaming, item-size queries — belong on `AbstractStorage` in `@suaveplan/storage`, which extends this contract.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero runtime code — pure TypeScript interface
|
||||
- Binary-safe (`Uint8Array` values)
|
||||
- Async-first (all methods return `Promise`)
|
||||
- Optional prefix + limit filtering on `list()`
|
||||
- Structural (duck-typed) — any conformant object satisfies it
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type { StorageLike } from '@suaveplan/types';
|
||||
|
||||
class TokenCache {
|
||||
constructor(private readonly store: StorageLike) {}
|
||||
|
||||
async set(id: string, token: Uint8Array): Promise<void> {
|
||||
await this.store.set(`token:${id}`, token);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Uint8Array | undefined> {
|
||||
return this.store.get(`token:${id}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import type { StorageLike } from '@suaveplan/types';
|
||||
|
||||
// A stub for testing — satisfies StorageLike without pulling in @suaveplan/storage
|
||||
class MemoryStore implements StorageLike {
|
||||
private data = new Map<string, Uint8Array>();
|
||||
|
||||
async get(key: string) { return this.data.get(key); }
|
||||
async set(key: string, value: Uint8Array) { this.data.set(key, value); }
|
||||
async delete(key: string) { const had = this.data.has(key); this.data.delete(key); return had; }
|
||||
async exists(key: string) { return this.data.has(key); }
|
||||
async list({ prefix = '', limit }: { prefix?: string; limit?: number } = {}) {
|
||||
const keys = [...this.data.keys()].filter(k => k.startsWith(prefix));
|
||||
return limit !== undefined ? keys.slice(0, limit) : keys;
|
||||
}
|
||||
async clear() { this.data.clear(); }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `StorageLike`
|
||||
|
||||
| Method | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `get` | `(key: string) => Promise<Uint8Array \| undefined>` | Retrieve value or `undefined` if absent |
|
||||
| `set` | `(key: string, value: Uint8Array) => Promise<void>` | Persist value, overwriting any existing entry |
|
||||
| `delete` | `(key: string) => Promise<boolean>` | Remove entry; returns `true` if it existed |
|
||||
| `exists` | `(key: string) => Promise<boolean>` | Check whether a key is present |
|
||||
| `list` | `(options?: { prefix?: string; limit?: number }) => Promise<string[]>` | List keys, optionally filtered by prefix and capped |
|
||||
| `clear` | `() => Promise<void>` | Remove all entries |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Values are typed as `Uint8Array` rather than `string | Buffer` to enforce binary-safe storage from the outset. String values must be encoded by callers (e.g. `new TextEncoder().encode(str)`). This prevents encoding bugs from leaking across storage backend boundaries.
|
||||
|
||||
The `delete` return value (`boolean`) is a deliberate divergence from void-returning conventions: knowing whether the key existed is useful for optimistic-delete patterns without an additional `exists()` round-trip.
|
||||
|
||||
`list()` parameters are optional to keep simple reads concise. Backends that cannot natively filter by prefix must emulate it in memory; `@suaveplan/storage`'s `AbstractStorage` handles this transparently.
|
||||
@@ -0,0 +1,69 @@
|
||||
# StreamTransformer
|
||||
|
||||
## Purpose
|
||||
|
||||
`StreamTransformer<TIn, TOut>` is the generic contract Suaveplan pipeline nodes implement to participate in a streaming pipeline. It deliberately mirrors only the smallest useful slice of the WHATWG `TransformStream` API — `transform(chunk)` and an optional `flush()` — so pipeline internals stay unit-testable without spinning up real `ReadableStream`/`WritableStream` machinery.
|
||||
|
||||
This shape is consumed by both the bitpacker streaming pipeline and any other Suaveplan codec that wants to compose a pipeline (compression, encryption, hashing, normalisation). Keeping the contract in `@suaveplan/types` means a pipeline runner from one package can host nodes implemented in another package without circular dependencies.
|
||||
|
||||
## Features
|
||||
|
||||
- Two-method interface: `transform` is required, `flush` is optional.
|
||||
- Both methods return either sync values or Promises — nodes pick whichever fits, callers always `await`.
|
||||
- `flush` may return an array, supporting nodes that buffer many input chunks before emitting their output (e.g. block-aligned codecs).
|
||||
- Pure type — zero runtime cost, fully tree-shakable.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { StreamTransformer } from "@suaveplan/types";
|
||||
|
||||
const upper: StreamTransformer<string, string> = {
|
||||
transform: (chunk) => chunk.toUpperCase(),
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { StreamTransformer } from "@suaveplan/types";
|
||||
|
||||
function blockAlign(blockSize: number): StreamTransformer<Uint8Array, Uint8Array> {
|
||||
let buffer = new Uint8Array(0);
|
||||
return {
|
||||
transform(chunk) {
|
||||
const merged = new Uint8Array(buffer.length + chunk.length);
|
||||
merged.set(buffer);
|
||||
merged.set(chunk, buffer.length);
|
||||
buffer = merged;
|
||||
const aligned = Math.floor(buffer.length / blockSize) * blockSize;
|
||||
const out = buffer.slice(0, aligned);
|
||||
buffer = buffer.slice(aligned);
|
||||
return out;
|
||||
},
|
||||
flush() {
|
||||
const tail = buffer;
|
||||
buffer = new Uint8Array(0);
|
||||
return tail;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `StreamTransformer<TIn, TOut>`
|
||||
|
||||
#### `transform(chunk: TIn): TOut | Promise<TOut>`
|
||||
|
||||
Process one input chunk. Called once per chunk arriving on the writable side. Must produce exactly one output chunk per call — nodes that need fan-out should emit composite chunks and let a downstream node split.
|
||||
|
||||
#### `flush(): TOut | TOut[] | Promise<TOut | TOut[]>` *(optional)*
|
||||
|
||||
Terminal handler invoked when the writable side closes. May emit zero, one, or many chunks. Use to drain buffers accumulated by `transform`. If omitted, no terminal output is emitted.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The interface is deliberately narrower than the WHATWG `TransformStream` `transformer` parameter, which exposes a `controller` argument with `enqueue`/`error`/`terminate`. Hiding the controller keeps node implementations pure: a node that throws is the failure signal, and a node that returns a value is the emission signal. The pipeline runner is responsible for wiring this contract to a `TransformStreamDefaultController` at the stream boundary.
|
||||
|
||||
`flush` returning either `TOut` or `TOut[]` is the smallest compromise that supports both stream-aligned codecs (single trailing chunk) and block-aligned codecs (many trailing chunks) without forcing every implementer to handle an array return type.
|
||||
@@ -0,0 +1,81 @@
|
||||
# streaming-aead-engine
|
||||
|
||||
## Purpose
|
||||
|
||||
`streaming-aead-engine.ts` defines the cross-package contract for streaming-AEAD engines. The interface lives in `@suaveplan/types` (not in `@suaveplan/crypto-cipher` where the canonical implementations live) because two packages share it: `@suaveplan/crypto-cipher` (defines `aesGcmStreamingEngine` and `xchacha20StreamingEngine`) and `@suaveplan/crypto` (the high-level streaming façade and Web-Streams adapters consume the engines through this interface). Per the suaveplan convention — types shared across ≥2 packages live in `@suaveplan/types` to prevent circular dependencies and keep the public contract in one auditable place — this is the right home.
|
||||
|
||||
A consumer that wants to plug a custom backend (HSM-backed AEAD, recording test spy, threshold scheme) implements this interface directly without depending on `@suaveplan/crypto-cipher` at all. The interface stays dependency-free at the type level: every field is a primitive byte buffer, number, or callback, and the `seal` / `open` methods return raw `Uint8Array` rather than a package-specific result type.
|
||||
|
||||
## Features
|
||||
|
||||
- `StreamingAeadEngine` — the engine interface itself: stable identity (`id`, `wireId`), key/nonce/tag dimensions, `deriveNonce`, `seal`, `open`.
|
||||
- `StreamingChunkInput` — the per-chunk encrypt/decrypt input bundle.
|
||||
- `StreamingChunkTag` — the per-chunk authentication record exposed to attestation consumers via `onChunk` callbacks.
|
||||
- `StreamingHeader` — the per-stream header record exposed to attestation consumers via the `onHeader` callback.
|
||||
- `EncryptStreamAeadOptions` / `DecryptStreamAeadOptions` — the options bundles consumed by the high-level streaming entry points.
|
||||
- Types-only — zero runtime emission; the package's byte-size guard test verifies this on every build.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { StreamingAeadEngine } from "@suaveplan/types";
|
||||
import { encryptStreamAead } from "@suaveplan/crypto-cipher/streaming/streaming-aead";
|
||||
|
||||
declare const myEngine: StreamingAeadEngine;
|
||||
const key = crypto.getRandomValues(new Uint8Array(myEngine.keyLength));
|
||||
const baseNonce = crypto.getRandomValues(new Uint8Array(myEngine.nonceBaseLength));
|
||||
|
||||
for await (const wire of encryptStreamAead(key, source(), {
|
||||
engine: myEngine,
|
||||
baseNonce,
|
||||
})) {
|
||||
sink.write(wire);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
StreamingAeadEngine,
|
||||
StreamingChunkInput,
|
||||
} from "@suaveplan/types";
|
||||
|
||||
// A recording test-spy engine that wraps any inner engine. Useful for
|
||||
// asserting the streaming layer makes exactly the seal/open calls the
|
||||
// spec requires, with the right counters and final-flag bits.
|
||||
function recordingEngine(inner: StreamingAeadEngine): {
|
||||
engine: StreamingAeadEngine;
|
||||
sealCalls: ReadonlyArray<{ counter: number; finalFlag: 0 | 1 }>;
|
||||
} {
|
||||
const sealCalls: Array<{ counter: number; finalFlag: 0 | 1 }> = [];
|
||||
return {
|
||||
sealCalls,
|
||||
engine: {
|
||||
...inner,
|
||||
async seal(input: StreamingChunkInput): Promise<Uint8Array> {
|
||||
sealCalls.push({
|
||||
counter: input.counter,
|
||||
finalFlag: input.finalFlag,
|
||||
});
|
||||
return inner.seal(input);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `StreamingAeadEngine { id, wireId, keyLength, nonceLength, nonceBaseLength, tagLength, deriveNonce, seal, open }`
|
||||
- `StreamingChunkInput { key, baseNonce, counter, finalFlag, data, aad? }`
|
||||
- `StreamingChunkTag { counter, finalFlag, tag }`
|
||||
- `StreamingHeader { engineId, wireId, version, baseNonceLength, baseNonce }`
|
||||
- `EncryptStreamAeadOptions { baseNonce, engine?, signal?, onHeader?, onChunk? }`
|
||||
- `DecryptStreamAeadOptions { engine?, allowUnterminated?, signal?, onHeader?, onChunk? }`
|
||||
|
||||
All types are exported from `@suaveplan/types` and re-exported from `@suaveplan/crypto-cipher` for backwards compatibility — both import paths resolve to the identical type at the TypeScript level.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `seal` / `open` methods are typed as `Promise<Uint8Array>` returning bytes — the concrete error type when `open` rejects is the implementing package's own error class (e.g. `CryptoCipherError(STREAM_TAG_MISMATCH)`). Callers that need to narrow on the rejection branch use `instanceof` against their own error class, not against a type defined here. This keeps the interface free of package-specific dependencies. The interface includes a `wireId` field separate from `id` because the wire format encodes the engine choice as a single byte; `id` is the human-readable string version used in OTel labels, error messages, and debug output. The `nonceBaseLength` field disambiguates the caller-supplied base nonce length from the actual per-chunk nonce length (`nonceLength = nonceBaseLength + 4` for the 32-bit counter suffix).
|
||||
@@ -0,0 +1,96 @@
|
||||
# timers-like
|
||||
|
||||
## Purpose
|
||||
|
||||
`TimersLike` is the canonical zero-runtime contract for an injectable timer pair across Suaveplan. Every package that needs to schedule a callback after a delay — `@suaveplan/cron`, `@suaveplan/retry`, `@suaveplan/job-scheduler`, and any future control-flow primitive — accepts a `TimersLike` so production code can pass the platform `setTimeout`/`clearTimeout` while tests can pass a `FakeClock`-backed pair that drives the scheduler deterministically in zero real wall-clock time.
|
||||
|
||||
The shape is intentionally minimal: a single-method `setTimer(callback, delayMs)` returning a numeric handle, plus a single-method `clearTimer(handle)`. No variadic forwarding, no Node-specific `Timeout` object, no `unref`/`ref` hooks — every caller closes over its own state via the callback closure, and every handle is a plain `number` so it round-trips through generic queues without erasing identity.
|
||||
|
||||
## Features
|
||||
|
||||
- Types-only — zero runtime cost
|
||||
- One canonical shape every cross-package timer injection conforms to
|
||||
- Compatible with the platform `globalThis.setTimeout` / `clearTimeout` pair via a trivial adapter
|
||||
- Compatible with `FakeClock`-driven test backends that fire callbacks synchronously when their deadline elapses
|
||||
- `clearTimer` is contractually idempotent — unknown handles are a no-op, never an error
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
|
||||
const platformTimers: TimersLike = {
|
||||
setTimer: (cb, ms) =>
|
||||
globalThis.setTimeout(cb, ms) as unknown as number,
|
||||
clearTimer: (h) => globalThis.clearTimeout(h),
|
||||
};
|
||||
|
||||
const handle = platformTimers.setTimer(() => console.log("fired"), 100);
|
||||
platformTimers.clearTimer(handle);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { TimersLike } from "@suaveplan/types";
|
||||
|
||||
interface FakeState {
|
||||
now: number;
|
||||
nextId: number;
|
||||
pending: Map<number, { fireAt: number; cb: () => void }>;
|
||||
}
|
||||
|
||||
function createFakeTimers(state: FakeState): {
|
||||
timers: TimersLike;
|
||||
advance: (ms: number) => void;
|
||||
} {
|
||||
const timers: TimersLike = {
|
||||
setTimer: (cb, ms) => {
|
||||
const id = state.nextId++;
|
||||
state.pending.set(id, { fireAt: state.now + ms, cb });
|
||||
return id;
|
||||
},
|
||||
clearTimer: (h) => {
|
||||
state.pending.delete(h);
|
||||
},
|
||||
};
|
||||
const advance = (ms: number): void => {
|
||||
const target = state.now + ms;
|
||||
for (;;) {
|
||||
let nextId: number | undefined;
|
||||
let nextAt = Number.POSITIVE_INFINITY;
|
||||
for (const [id, entry] of state.pending) {
|
||||
if (entry.fireAt <= target && entry.fireAt < nextAt) {
|
||||
nextId = id;
|
||||
nextAt = entry.fireAt;
|
||||
}
|
||||
}
|
||||
if (nextId === undefined) break;
|
||||
const entry = state.pending.get(nextId);
|
||||
if (entry === undefined) break;
|
||||
state.pending.delete(nextId);
|
||||
state.now = entry.fireAt;
|
||||
entry.cb();
|
||||
}
|
||||
state.now = target;
|
||||
};
|
||||
return { timers, advance };
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface TimersLike`
|
||||
|
||||
| Member | Signature | Description |
|
||||
|--------|-----------|-------------|
|
||||
| `setTimer` | `(callback: () => void, delayMs: number) => number` | Schedule `callback` to run after `delayMs` milliseconds. Returns an opaque numeric handle suitable for `clearTimer`. |
|
||||
| `clearTimer` | `(handle: number) => void` | Cancel a pending timer by handle. Idempotent — passing an unknown or already-cleared handle MUST NOT throw. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Returning `number` rather than the Node-specific `Timeout` object is a deliberate constraint: it forces every implementation through the same surface and lets handles flow through generic data structures (numeric maps, bit-packed queues, JSON snapshots) without identity loss. The platform `setTimeout` return value is structurally a `Timeout` object on Node and a `number` on the browser; consumers wrap that with `as unknown as number` at the call site exactly once, and every downstream package gets a uniform contract.
|
||||
|
||||
The idempotency requirement on `clearTimer` matters for cancellation races — a retry loop or cron scheduler frequently clears a handle in a `finally` block that may also fire during the timer's callback path. An implementation that throws on unknown handles forces every caller to guard with try/catch; specifying idempotency at the interface layer pushes that complexity inside the timer backend where it belongs.
|
||||
|
||||
`TimersLike` is intentionally a peer of `Clock` rather than derived from it: time observation (`Clock`) and time scheduling (`TimersLike`) are independent injections because tests sometimes want to read a deterministic clock while still using real platform timers, or vice versa. Packages that need both list both options independently.
|
||||
@@ -0,0 +1,62 @@
|
||||
# TransformProgress
|
||||
|
||||
## Purpose
|
||||
|
||||
`TransformProgress` is the canonical progress shape every Suaveplan data-pipeline package emits to observers. By keeping the shape in `@suaveplan/types` rather than duplicating it per package, observability tools — log adapters, OTel exporters, progress UIs — only need to know one schema. A consumer that watches the `bitpacker-streaming` pack stream sees the same `TransformProgress` it would see watching `compress-image`'s downscale pipeline or `packer`'s chunked AEAD encrypt.
|
||||
|
||||
The shape is intentionally minimal: items, bytes, pending, throughput, direction. Anything package-specific (algorithm name, frame index, retry count) goes in a sibling event payload, not in this contract — generic observability tooling shouldn't need to know about every codec's quirks to render a progress bar.
|
||||
|
||||
## Features
|
||||
|
||||
- Five fields covering the most common questions consumers ask: how much done, how much left, how fast, in what direction.
|
||||
- `operation: 'pack' | 'unpack'` tag is sufficient for round-trip pipelines; codecs that don't round-trip embed their own enum on a wrapping payload.
|
||||
- Pure type — zero runtime cost, fully tree-shakable.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { TransformProgress } from "@suaveplan/types";
|
||||
|
||||
function logProgress(p: TransformProgress): void {
|
||||
console.log(
|
||||
`${p.operation}: ${p.itemsProcessed} items, ${p.throughput.toFixed(0)} items/s`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { TransformProgress } from "@suaveplan/types";
|
||||
|
||||
interface CodecProgress {
|
||||
progress: TransformProgress;
|
||||
algorithm: "gzip" | "deflate" | "brotli";
|
||||
frameIndex: number;
|
||||
}
|
||||
|
||||
const sink = (event: CodecProgress): void => {
|
||||
metrics.histogram("codec.throughput").record(event.progress.throughput, {
|
||||
algorithm: event.algorithm,
|
||||
operation: event.progress.operation,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TransformProgress`
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `itemsProcessed` | `number` | Items already emitted on the readable side. |
|
||||
| `bytesProcessed` | `number` | Bytes already emitted on the readable side. |
|
||||
| `itemsPending` | `number` | Items buffered on the writable side, not yet emitted. |
|
||||
| `throughput` | `number` | Items per second since the pipeline started. |
|
||||
| `operation` | `'pack' \| 'unpack'` | Direction tag. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The shape is opinionated: it counts both items and bytes because some pipelines naturally chunk by item (records, frames, files) while others chunk by byte (compression streams, hash streams). Reporting both lets a single consumer render meaningful progress for either kind without branching on pipeline type.
|
||||
|
||||
Throughput is items/sec rather than bytes/sec because items are the unit consumers care about ("100 records left to encode"). Bytes/sec is trivial to derive on the consumer side via `bytesProcessed / elapsed`. Codec-internal latency is a separate measurement; this contract is for steady-state pipeline progress only.
|
||||
@@ -0,0 +1,125 @@
|
||||
# TranslationRecord
|
||||
|
||||
`TranslationRecord` is the canonical recursive shape for an i18n
|
||||
message catalog. Leaves are strings — the message templates rendered
|
||||
to end users; branches are nested `TranslationRecord`s, allowing
|
||||
namespaces and dotted paths to be modelled as object trees with no
|
||||
imposed depth limit.
|
||||
|
||||
## Purpose
|
||||
|
||||
The shape lives in `@suaveplan/types` so every package in the i18n
|
||||
family (`@suaveplan/i18n-shared`, `@suaveplan/i18n-icu-parser`, the
|
||||
eight `@suaveplan/i18n-formatter-*` siblings, the five
|
||||
`@suaveplan/i18n-backend-*` siblings, and the slim `@suaveplan/i18n`
|
||||
core) can declare arguments of this shape without taking a runtime
|
||||
dependency on `@suaveplan/i18n` core. Backend implementations,
|
||||
catalog readers, missing-key handlers, and the slim core's re-export
|
||||
bridge all consume the same definition. This mirrors the existing
|
||||
cross-package crypto contract (`Authority`, `KeySlotPublic`, `AeadOps`)
|
||||
already in this package — the canonical shape lives here, every
|
||||
runtime implementation lives elsewhere.
|
||||
|
||||
## Features
|
||||
|
||||
- Type-only — zero runtime cost, zero bundle bytes
|
||||
- Arbitrary nesting depth, modelled by self-reference
|
||||
- `readonly` index signature — catalogs are immutable from the
|
||||
consumer's point of view
|
||||
- Leaves restricted to `string` — TypeScript rejects accidental
|
||||
`number`, `boolean`, `null`, `Date`, or `Uint8Array` values at
|
||||
compile time
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { TranslationRecord } from "@suaveplan/types";
|
||||
|
||||
const en: TranslationRecord = {
|
||||
greet: "Hello",
|
||||
farewell: "Goodbye",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { TranslationRecord } from "@suaveplan/types";
|
||||
|
||||
// Nested namespaces — the same shape collapses arbitrary depth.
|
||||
const en: TranslationRecord = {
|
||||
common: {
|
||||
loading: "Loading…",
|
||||
retry: "Try again",
|
||||
},
|
||||
auth: {
|
||||
login: {
|
||||
title: "Sign in",
|
||||
submit: "Continue",
|
||||
errors: {
|
||||
invalidCredentials: "Wrong email or password.",
|
||||
rateLimited: "Too many attempts; try again in a minute.",
|
||||
},
|
||||
},
|
||||
logout: { title: "Sign out" },
|
||||
},
|
||||
};
|
||||
|
||||
// A backend implementor can return any subtree without redeclaring
|
||||
// the shape:
|
||||
async function loadAuth(): Promise<TranslationRecord> {
|
||||
return en.auth as TranslationRecord;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface TranslationRecord`
|
||||
|
||||
```ts
|
||||
interface TranslationRecord {
|
||||
readonly [key: string]: string | TranslationRecord;
|
||||
}
|
||||
```
|
||||
|
||||
A recursive index signature. Every value is either a string (a leaf
|
||||
message template) or another `TranslationRecord` (a nested namespace).
|
||||
The `readonly` modifier reflects the invariant that catalog snapshots
|
||||
should never be mutated in place — backends produce fresh records on
|
||||
each load.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The recursion is intentional: a sibling type
|
||||
`Record<string, string>` would force every namespace to be flat
|
||||
(`"auth.login.title"` as a literal key), which most catalog formats
|
||||
(JSON, YAML, ICU file bundles) do not honour. Modelling the tree as
|
||||
the type lets backends round-trip nested JSON without flattening.
|
||||
- Leaves are strings only — ICU placeholders (`"{count, plural, …}"`)
|
||||
are still strings; rendering happens at lookup time inside the
|
||||
parser. Storing pre-parsed ASTs in a catalog is out of scope for
|
||||
this contract.
|
||||
- The `readonly` index signature does NOT prevent the underlying
|
||||
object from being mutated; it documents intent. Backend
|
||||
implementations SHOULD freeze records (`Object.freeze`) before
|
||||
returning them when defensive copies are too expensive.
|
||||
- Used by `Backend.read` (return type), `Backend.save` (parameter
|
||||
type), `MessageLoader` (return type), `NamespacedCatalogMap` (value
|
||||
type), and `TranslationCatalogMap` (transitively).
|
||||
|
||||
## Ecosystem Usage
|
||||
|
||||
Every i18n package consumes this shape:
|
||||
|
||||
- `@suaveplan/i18n-backend-memory` — `MemoryBackend` stores records
|
||||
keyed by `(locale, namespace)`
|
||||
- `@suaveplan/i18n-backend-http` — `HttpBackend.read` decodes JSON
|
||||
responses as `TranslationRecord`
|
||||
- `@suaveplan/i18n-backend-storage` — `StorageBackend.read` decodes
|
||||
bytes from `@suaveplan/storage` into `TranslationRecord`
|
||||
- `@suaveplan/i18n-backend-fs-multifile` — server-side
|
||||
`FsMultiFileBackend.read` decodes per-file JSON
|
||||
- `@suaveplan/i18n-backend-chained` — `ChainedBackend` composes
|
||||
multiple backends, all returning the same shape
|
||||
- `@suaveplan/i18n` — slim core's `MessageCatalog.add(locale, ns, record)`
|
||||
takes a `TranslationRecord`
|
||||
@@ -0,0 +1,92 @@
|
||||
# TransportLike
|
||||
|
||||
## Purpose
|
||||
|
||||
Minimum structural shape every transport implementation provides. Cross-cutting type so every transport plugin (`WebRTCTransport`, `WebSocketTransport`, `HttpsTransport`, `Libp2pTransport`, `MemoryTransport` for tests) can be authored against the same contract without importing the network package. The concrete `Transport` interface that ships in `@suaveplan/network` is structurally compatible with `TransportLike` — they describe the same five-method surface. Living in `@suaveplan/types` lets transport plugins exist in their own packages without forcing the network core to know about every transport variant — the core just consumes anything that satisfies `TransportLike`.
|
||||
|
||||
## Features
|
||||
|
||||
- Five-method surface covering connect, disconnect, send, receive, and peer enumeration.
|
||||
- Generic in `TEnvelope` so plugins that wrap a typed payload shape can express it without losing the type at the boundary.
|
||||
- `onMessage` returns an unsubscribe function — the established `() => () => void` pattern that every JS event-bus uses.
|
||||
- `getConnectedPeers` returns a `readonly` array, signalling that the transport's peer set is the canonical source — callers should not mutate the result.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { TransportLike } from "@suaveplan/types/transport";
|
||||
|
||||
const noopTransport: TransportLike = {
|
||||
connect: async () => {},
|
||||
disconnect: async () => {},
|
||||
send: async () => {},
|
||||
onMessage: () => () => {},
|
||||
getConnectedPeers: () => [],
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { TransportLike } from "@suaveplan/types/transport";
|
||||
import type { MessageEnvelope } from "@suaveplan/types/message-envelope";
|
||||
import type { PeerId } from "@suaveplan/types/peer-id";
|
||||
|
||||
// A typed transport that handles only chunk-request envelopes.
|
||||
interface ChunkRequestPayload {
|
||||
fileId: string;
|
||||
chunkIds: number[];
|
||||
}
|
||||
|
||||
type ChunkTransport = TransportLike<MessageEnvelope<ChunkRequestPayload>>;
|
||||
|
||||
class FakeChunkTransport implements ChunkTransport {
|
||||
private handlers: Array<(envelope: MessageEnvelope<ChunkRequestPayload>) => void> = [];
|
||||
private peers: Set<PeerId> = new Set();
|
||||
|
||||
async connect(peerId: PeerId, _address: string): Promise<void> {
|
||||
this.peers.add(peerId);
|
||||
}
|
||||
|
||||
async disconnect(peerId: PeerId): Promise<void> {
|
||||
this.peers.delete(peerId);
|
||||
}
|
||||
|
||||
async send(_peerId: PeerId, _envelope: MessageEnvelope<ChunkRequestPayload>): Promise<void> {
|
||||
// implementation
|
||||
}
|
||||
|
||||
onMessage(handler: (envelope: MessageEnvelope<ChunkRequestPayload>) => void): () => void {
|
||||
this.handlers.push(handler);
|
||||
return () => {
|
||||
this.handlers = this.handlers.filter((h) => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
getConnectedPeers(): readonly PeerId[] {
|
||||
return Array.from(this.peers);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface TransportLike<TEnvelope = MessageEnvelope>`
|
||||
|
||||
Five methods:
|
||||
|
||||
- `connect(peerId: PeerId, address: string): Promise<void>` — open a connection. Resolves on success; rejects on failure with a transport-specific error. Idempotent: connecting an already-connected peer is a no-op.
|
||||
- `disconnect(peerId: PeerId): Promise<void>` — close the connection. Resolves on success. Idempotent: disconnecting an unknown peer is a no-op.
|
||||
- `send(peerId: PeerId, envelope: TEnvelope): Promise<void>` — deliver an envelope. Resolves when the underlying transport accepts the bytes (not when the peer acknowledges; backpressure is the transport's job).
|
||||
- `onMessage(handler): () => void` — register a handler for incoming envelopes. The returned function unsubscribes.
|
||||
- `getConnectedPeers(): readonly PeerId[]` — list the peers currently connected via this transport. Returns a snapshot — UIs that need live updates should subscribe to the network bus's `peer:joined` / `peer:left` topics.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`TransportLike` deliberately does not include capability negotiation, priority routing, or backpressure controls. Those concerns belong to higher layers — `network-plugin-transport-manager` (NET-23..NET-26) wraps multiple `TransportLike` instances, picks the best one per message based on capability bitmaps, and routes by priority. A bare transport just opens connections and moves bytes.
|
||||
|
||||
The `onMessage` handler signature does NOT include an envelope ack — transports SHOULD invoke handlers exactly once per envelope and SHOULD NOT retry handlers that throw. Retry, dedup, and ack logic live in the protocol plugin, not the transport.
|
||||
|
||||
`getConnectedPeers` returning a `readonly` array is significant: plugins that want a mutable copy must explicitly `[...transport.getConnectedPeers()]`. This prevents accidental aliasing where a plugin mutates the array and the transport's internal state changes underneath it.
|
||||
|
||||
The companion concrete `Transport` interface in `@suaveplan/network` extends `TransportLike` with optional capability-declaration fields (e.g., `getCapabilities(): Capabilities`) that the transport-manager reads. Bare `TransportLike` consumers do not need that surface and ignore it.
|
||||
@@ -0,0 +1,159 @@
|
||||
# @suaveplan/typescript-config - Shared TypeScript configuration presets for Suaveplan
|
||||
|
||||
## Summary
|
||||
|
||||
Shared TypeScript configuration presets for Suaveplan. This package ships composable JSON presets consumed via `"extends"` across every workspace package — no runtime code, no build step, zero dependencies.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add -D @suaveplan/typescript-config
|
||||
```
|
||||
|
||||
## Presets
|
||||
|
||||
| Preset | File | Extends | Purpose |
|
||||
|--------|------|---------|---------|
|
||||
| **base** | `base.json` | — | Strict ESM baseline for all packages |
|
||||
| **library** | `library.json` | `base.json` | Library builds with declarations + source maps |
|
||||
| **library-browser** | `library-browser.json` | `library.json` | Library with DOM typings |
|
||||
| **library-node** | `library-node.json` | `library.json` | Library with Node.js `@types/node` |
|
||||
| **test** | `test.json` | `base.json` | Vitest test files, no emit |
|
||||
| **stories** | `stories.json` | `library-browser.json` | Storybook `.stories.tsx` files |
|
||||
| **react-library** | `react-library.json` | `library-browser.json` | React component libraries with JSX |
|
||||
|
||||
## Usage
|
||||
|
||||
### Universal library package
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@suaveplan/typescript-config/library.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
### Browser library (DOM APIs)
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@suaveplan/typescript-config/library-browser.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
### Node.js server package
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@suaveplan/typescript-config/library-node.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
### Test files (vitest.config.ts companion)
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@suaveplan/typescript-config/test.json",
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
```
|
||||
|
||||
### React component library
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@suaveplan/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
### Storybook stories
|
||||
|
||||
```json
|
||||
{
|
||||
"extends": "@suaveplan/typescript-config/stories.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "stories"]
|
||||
}
|
||||
```
|
||||
|
||||
## Preset details
|
||||
|
||||
### `base.json`
|
||||
|
||||
The foundation for all other presets. Enables the strictest TypeScript flags:
|
||||
|
||||
- `strict: true` — enables all strict type checks
|
||||
- `noUncheckedIndexedAccess: true` — array/object index access includes `undefined`
|
||||
- `exactOptionalPropertyTypes: true` — distinguishes `undefined` from absent properties
|
||||
- `verbatimModuleSyntax: true` — type imports must use `import type`
|
||||
- `moduleResolution: "bundler"` — optimised for Vite/tsup/Bun bundlers
|
||||
- `target: "ES2022"`, `lib: ["ES2023"]`, `module: "ESNext"`
|
||||
- `esModuleInterop: false` — no synthetic CJS default imports
|
||||
|
||||
### `library.json`
|
||||
|
||||
Extends `base.json` for library packages that emit declarations:
|
||||
|
||||
- `declaration: true`, `declarationMap: true` — emits `.d.ts` and source maps
|
||||
- `sourceMap: true` — enables debuggability
|
||||
- `composite: true`, `incremental: true` — project references and incremental builds
|
||||
- `tsBuildInfoFile: ".tsbuildinfo"` — incremental cache at package root
|
||||
- `rootDir: "src"`, `outDir: "dist"` — standard layout (override per-package as needed)
|
||||
|
||||
### `library-browser.json`
|
||||
|
||||
Extends `library.json` and adds `lib: ["ES2023", "DOM", "DOM.Iterable"]` for packages that access browser APIs directly.
|
||||
|
||||
### `library-node.json`
|
||||
|
||||
Extends `library.json` and adds `types: ["node"]` for packages that use Node.js built-ins. Add `@types/node` to your package's `devDependencies`.
|
||||
|
||||
### `test.json`
|
||||
|
||||
Extends `base.json` for test files:
|
||||
|
||||
- `types: ["vitest/globals"]` — Vitest global APIs (`describe`, `it`, `expect`)
|
||||
- `noEmit: true` — test files are never compiled to output
|
||||
|
||||
### `stories.json`
|
||||
|
||||
Extends `library-browser.json` for Storybook `.stories.tsx` files:
|
||||
|
||||
- `verbatimModuleSyntax: false` — relaxed to allow Storybook's mixed import patterns
|
||||
- `jsx: "react-jsx"` — React 17+ automatic JSX transform
|
||||
|
||||
### `react-library.json`
|
||||
|
||||
Extends `library-browser.json` for React component libraries:
|
||||
|
||||
- `jsx: "react-jsx"` — React 17+ automatic JSX transform without explicit `React` import
|
||||
|
||||
## Why composable presets?
|
||||
|
||||
Each workspace package in the project reduces its `tsconfig.json` to a two-line extends + overrides. This ensures consistent strictness settings, module resolution, and output shape across all 40+ packages without copy-paste drift. Changes to compiler flags propagate to every consumer on the next `bun install`.
|
||||
|
||||
## Contributing
|
||||
|
||||
Preset changes require a design proposal. Any relaxation of strict flags requires explicit sign-off from the platform team.
|
||||
118
plugins/document-navigator/docs/foundation/core/utils/README.md
Normal file
118
plugins/document-navigator/docs/foundation/core/utils/README.md
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,87 @@
|
||||
# debug
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `debug` module provides a lightweight, injectable debug logging utility that deduplicates repeated messages using a circular buffer. Unlike `console.log`, which emits every call unconditionally, `createDebug` tracks recently seen messages and suppresses duplicates until they age out of the buffer. This makes it practical to place debug statements in hot paths without flooding logs.
|
||||
|
||||
The module is deliberately free of OpenTelemetry instrumentation — it is itself an instrumentation primitive and instrumenting it would create infinite recursion.
|
||||
|
||||
## Features
|
||||
|
||||
- Circular ring buffer with configurable capacity (`bufferSize`, default 10)
|
||||
- Automatic eviction of the oldest entry when buffer is full
|
||||
- Keyed by `JSON.stringify` of all arguments; `Error` objects are keyed by their `.message`
|
||||
- Injectable log/warn/error/info functions for testing without mocking
|
||||
- Module-level `debug` singleton for drop-in use
|
||||
- Supports multi-argument log calls (each unique combination is tracked)
|
||||
- Zero external dependencies
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { debug } from "@suaveplan/utils/debug";
|
||||
|
||||
// Only logs once even if called many times with the same args
|
||||
debug.log("Connecting to database...");
|
||||
debug.log("Connecting to database..."); // suppressed
|
||||
debug.warn("Low memory");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createDebug } from "@suaveplan/utils/debug";
|
||||
|
||||
// Custom buffer and injectable log function for testing
|
||||
const captured: unknown[][] = [];
|
||||
const dbg = createDebug({
|
||||
bufferSize: 3,
|
||||
log: (...args) => captured.push(args),
|
||||
});
|
||||
|
||||
dbg.log("a"); // logged
|
||||
dbg.log("a"); // suppressed
|
||||
dbg.log("b"); // logged
|
||||
dbg.log("c"); // logged — buffer now [a, b, c]
|
||||
dbg.log("d"); // logged — evicts "a"
|
||||
dbg.log("a"); // logged — "a" was evicted, so fresh
|
||||
|
||||
console.log(captured.length); // 5
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createDebug(options?: DebugOptions): DebugInstance`
|
||||
|
||||
Creates a new debug instance with an independent circular buffer.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `options.log` — `(...args: unknown[]) => void` — custom log function (default: `console.log`)
|
||||
- `options.warn` — `(...args: unknown[]) => void` — custom warn function (default: `console.warn`)
|
||||
- `options.error` — `(...args: unknown[]) => void` — custom error function (default: `console.error`)
|
||||
- `options.info` — `(...args: unknown[]) => void` — custom info function (default: `console.info`)
|
||||
- `options.bufferSize` — `number` — maximum remembered messages before eviction (default: `10`)
|
||||
|
||||
**Returns:** `DebugInstance` with `.log`, `.warn`, `.error`, `.info` methods.
|
||||
|
||||
### `debug: DebugInstance`
|
||||
|
||||
Module-level singleton created with default options. Suitable for direct import and use without configuration.
|
||||
|
||||
### `DebugOptions`
|
||||
|
||||
Interface defining injectable overrides for the debug factory.
|
||||
|
||||
### `DebugInstance`
|
||||
|
||||
Interface returned by `createDebug` with `log`, `warn`, `error`, `info` methods.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The buffer is implemented as a fixed-size ring array with a head pointer. Adding a new message writes to `head % maxSize` and advances `head`. Reading iterates backward from head across `size` entries. This gives O(n) lookup — acceptable for the default size of 10, which is always tiny. Message identity is computed via `JSON.stringify` after normalizing `Error` instances to their `.message` string, so `new Error("boom")` repeated is treated as a duplicate.
|
||||
|
||||
Each `createDebug` call creates an independent buffer. The module-level `debug` singleton shares no state with other instances, making instances safe to use in parallel.
|
||||
@@ -0,0 +1,94 @@
|
||||
# email
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `email` module provides a provider-aware email normalization utility that converts raw email addresses into their canonical form. Different email providers apply different rules for aliases, dots, and sub-addressing — Gmail ignores dots and uses `+` aliasing, Yahoo uses `-` aliasing, Microsoft uses `+` aliasing. By applying provider-specific canonicalization, the module enables reliable duplicate detection, login normalization, and spam-prevention heuristics across a diverse user base.
|
||||
|
||||
## Features
|
||||
|
||||
- **Gmail / Googlemail canonicalization**: strips all dots from the local part and removes `+alias` suffixes, so `t.e.s.t+tag@gmail.com` → `test@gmail.com`.
|
||||
- **Yahoo canonicalization**: strips `-alias` suffixes while preserving dots, so `user-tag@yahoo.com` → `user@yahoo.com`.
|
||||
- **Microsoft (Outlook / Hotmail / Live) canonicalization**: strips `+alias` suffixes while preserving dots.
|
||||
- **Generic-domain canonicalization**: strips `+alias` suffix and removes all dots from the local part for any domain not in the known-provider lists.
|
||||
- **Graceful malformed-input handling**: inputs without an `@` character or with an empty local part after stripping are returned unchanged (lowercased), so the function never throws.
|
||||
- **Zod schemas**: `emailSchema` and `normalizedEmailSchema` are exported for schema-driven validation pipelines.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { normalizeEmail } from "@suaveplan/utils/email";
|
||||
|
||||
// Gmail: dots ignored, plus alias stripped
|
||||
normalizeEmail("John.Doe+newsletter@gmail.com");
|
||||
// → "johndoe@gmail.com"
|
||||
|
||||
// Yahoo: dash alias stripped, dots kept
|
||||
normalizeEmail("jane.smith-promo@yahoo.com");
|
||||
// → "jane.smith@yahoo.com"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { normalizeEmail } from "@suaveplan/utils/email";
|
||||
|
||||
// De-duplicate user registrations
|
||||
function deduplicateUsers(emails: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const email of emails) {
|
||||
const canonical = normalizeEmail(email);
|
||||
if (!seen.has(canonical)) {
|
||||
seen.add(canonical);
|
||||
unique.push(email);
|
||||
}
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
const raw = [
|
||||
"alice@gmail.com",
|
||||
"a.l.i.c.e@gmail.com",
|
||||
"alice+spam@gmail.com",
|
||||
"bob@yahoo.com",
|
||||
"bob-promo@yahoo.com",
|
||||
];
|
||||
|
||||
deduplicateUsers(raw);
|
||||
// → ["alice@gmail.com", "bob@yahoo.com"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `normalizeEmail(email: string): string`
|
||||
|
||||
Normalize an email address by applying provider-specific canonicalization rules.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|----------|--------------------------------------|
|
||||
| `email` | `string` | Raw email address to normalize. |
|
||||
|
||||
**Returns:** `string` — the canonicalized email address, always lowercased.
|
||||
|
||||
**Does not throw.** Malformed inputs (no `@`, empty parts) are returned as-is lowercased.
|
||||
|
||||
### `emailSchema`
|
||||
|
||||
Zod schema (`z.string().email()`) for validating that a value is a well-formed email address.
|
||||
|
||||
### `normalizedEmailSchema`
|
||||
|
||||
Zod schema (`z.string().min(1)`) for the output of `normalizeEmail`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The provider lookup uses three static `const` arrays (`GOOGLE_DOMAINS`, `YAHOO_DOMAINS`, `MICROSOFT_DOMAINS`) that cover the most common hosted-email providers. `Array.includes()` is O(n) but the arrays are tiny (2–3 elements each), making the cost negligible vs. a Map lookup.
|
||||
|
||||
The local-part stripping uses a single `split(/[+-]/)[0]` to find the first `+` or `-` delimiter. This is intentionally conservative: it does not try to validate full RFC 5321 local-part syntax, only to strip the most common alias prefixes.
|
||||
|
||||
The function never modifies the input domain. Domain normalization (punycode, trailing-dot removal) is outside the scope of this utility.
|
||||
|
||||
Known limitation: provider lists are hardcoded. Organizations with custom Google Workspace or Microsoft 365 domains are not recognized as such; they follow the generic path. A future version could accept a custom provider-config option.
|
||||
@@ -0,0 +1,83 @@
|
||||
# errors/utils-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `utils-error` module defines the typed error class and stable error-code registry for the entire `@suaveplan/utils` package. Every runtime error produced by the package is an instance of `UtilsError`, which extends `SuaveplanError` from `@suaveplan/error`. This gives consumers a single `instanceof` check plus a stable, machine-readable `code` string to branch on — far more reliable than parsing error messages, which may change between releases.
|
||||
|
||||
## Features
|
||||
|
||||
- **`UTILS_CODES`**: a registry of all error codes in the package, defined with `defineCodes("UTILS", [...])` from `@suaveplan/error`. Each code is prefixed `UTILS/` and namespaced by subsystem (e.g. `UTILS/OBJECT.PROTOTYPE_POLLUTION`, `UTILS/SOUND.NO_VARIATIONS`).
|
||||
- **`UtilsError`**: typed subclass of `SuaveplanError<UtilsCode>`. Carries `.code`, `.message`, and optional `SuaveplanErrorOptions` (cause, context, redact). The constructor visibility is `public` to allow direct instantiation anywhere in the package.
|
||||
- **`UtilsCode`**: TypeScript type alias for the union of all values in `UTILS_CODES`, enabling exhaustive `switch` statements and narrowed error handling.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { UTILS_CODES, UtilsError } from "@suaveplan/utils/errors";
|
||||
|
||||
throw new UtilsError(
|
||||
UTILS_CODES["VALIDATION.SIZE_EXCEEDED"],
|
||||
"Payload is too large",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { UTILS_CODES, UtilsError, type UtilsCode } from "@suaveplan/utils/errors";
|
||||
|
||||
function handleError(err: unknown): void {
|
||||
if (!(err instanceof UtilsError)) throw err;
|
||||
|
||||
const code: UtilsCode = err.code;
|
||||
switch (code) {
|
||||
case UTILS_CODES["VALIDATION.SIZE_EXCEEDED"]:
|
||||
console.error("Payload too large:", err.message);
|
||||
break;
|
||||
case UTILS_CODES["SOUND.NO_VARIATIONS"]:
|
||||
console.warn("Audio not found:", err.message);
|
||||
break;
|
||||
default:
|
||||
throw err; // re-throw unexpected utils errors
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `UTILS_CODES`
|
||||
|
||||
Object mapping dot-path keys to fully-qualified error code strings.
|
||||
|
||||
| Key | Code |
|
||||
|-----|------|
|
||||
| `"OBJECT.PROTOTYPE_POLLUTION"` | `"UTILS/OBJECT.PROTOTYPE_POLLUTION"` |
|
||||
| `"OBJECT.CIRCULAR"` | `"UTILS/OBJECT.CIRCULAR"` |
|
||||
| `"NUMBER.OUT_OF_RANGE"` | `"UTILS/NUMBER.OUT_OF_RANGE"` |
|
||||
| `"RANDOM.EMPTY_ARRAY"` | `"UTILS/RANDOM.EMPTY_ARRAY"` |
|
||||
| `"RANDOM.INDEX_OUT_OF_BOUNDS"` | `"UTILS/RANDOM.INDEX_OUT_OF_BOUNDS"` |
|
||||
| `"VALIDATION.SIZE_EXCEEDED"` | `"UTILS/VALIDATION.SIZE_EXCEEDED"` |
|
||||
| `"VALIDATION.PARSE_FAILED"` | `"UTILS/VALIDATION.PARSE_FAILED"` |
|
||||
| `"PLATFORM.UNAVAILABLE"` | `"UTILS/PLATFORM.UNAVAILABLE"` |
|
||||
| `"SOUND.NO_VARIATIONS"` | `"UTILS/SOUND.NO_VARIATIONS"` |
|
||||
|
||||
### `class UtilsError extends SuaveplanError<UtilsCode>`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `code` | `UtilsCode` | Stable error code from `UTILS_CODES`. |
|
||||
| `message` | `string` | Human-readable description. |
|
||||
| `options` | `SuaveplanErrorOptions?` | Optional cause, context, redact fields. |
|
||||
|
||||
### `type UtilsCode`
|
||||
|
||||
Union type of all string values in `UTILS_CODES`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` is a helper from `@suaveplan/error` that prefixes each code with the registry namespace (`"UTILS"`) and freezes the result. This ensures codes are immutable at runtime and catch typos at TypeScript compile time through the `as const` assertion on the code array.
|
||||
|
||||
The `public constructor` visibility is explicit (and annotated with a biome-ignore comment) to satisfy the linter's `noUselessConstructor` rule while documenting the intentional widening from the `protected` default inherited from `SuaveplanError`.
|
||||
@@ -0,0 +1,134 @@
|
||||
# number
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `number` module provides IEEE-754 float encoding and decoding utilities for Float16, Float32, and Float64 formats. JavaScript's native `number` type is always Float64, but many binary protocols, graphics APIs, and machine-learning frameworks use the more compact Float16 or Float32 representations. This module allows lossless round-tripping between JavaScript numbers and their binary wire format using `DataView`-based bit manipulation.
|
||||
|
||||
All pack functions emit an OTel counter increment so throughput can be observed in production without adding latency to the hot path.
|
||||
|
||||
## Features
|
||||
|
||||
- `packFloat16` / `unpackFloat16` — 2-byte IEEE-754 half-precision encoding/decoding
|
||||
- `packFloat32` / `unpackFloat32` — 4-byte IEEE-754 single-precision using `DataView.setFloat32`
|
||||
- `packFloat64` / `unpackFloat64` — 8-byte IEEE-754 double-precision using `DataView.setFloat64`
|
||||
- Correct handling of `NaN`, `+Infinity`, `-Infinity`, `+0`, and `-0`
|
||||
- Subnormal Float16 support (values below `2^-14`)
|
||||
- Range guards: `isInFloat16Range`, `isInFloat32Range`, `isInFloat64Range`
|
||||
- Constants: `min16`, `max16`, `min32`, `max32`, `min64`, `max64`
|
||||
- OTel counters for pack call throughput
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
packFloat16,
|
||||
unpackFloat16,
|
||||
packFloat32,
|
||||
unpackFloat32,
|
||||
packFloat64,
|
||||
unpackFloat64,
|
||||
} from "@suaveplan/utils/number";
|
||||
|
||||
// Float16 round-trip
|
||||
const bytes16 = packFloat16(1.5); // Uint8Array(2)
|
||||
const back16 = unpackFloat16(bytes16); // ~1.5
|
||||
|
||||
// Float32 round-trip
|
||||
const bytes32 = packFloat32(3.14);
|
||||
const back32 = unpackFloat32(bytes32); // ~3.14
|
||||
|
||||
// Float64 round-trip (lossless)
|
||||
const bytes64 = packFloat64(Math.PI);
|
||||
const back64 = unpackFloat64(bytes64); // Math.PI exactly
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
packFloat16,
|
||||
unpackFloat16,
|
||||
isInFloat16Range,
|
||||
isInFloat32Range,
|
||||
max16,
|
||||
max32,
|
||||
} from "@suaveplan/utils/number";
|
||||
|
||||
// Check range before packing
|
||||
function safePackFloat16(value: number): Uint8Array {
|
||||
if (!isInFloat16Range(value)) {
|
||||
throw new RangeError(`${value} is out of Float16 range`);
|
||||
}
|
||||
return packFloat16(value);
|
||||
}
|
||||
|
||||
// Handle special values
|
||||
console.log(unpackFloat16(packFloat16(Infinity))); // Infinity
|
||||
console.log(unpackFloat16(packFloat16(-0))); // -0
|
||||
console.log(Number.isNaN(unpackFloat16(packFloat16(NaN)))); // true
|
||||
|
||||
// max values
|
||||
console.log(max16); // 65504
|
||||
console.log(max32); // ~3.4028235e+38
|
||||
|
||||
// Range guards
|
||||
console.log(isInFloat16Range(65504)); // true
|
||||
console.log(isInFloat16Range(70000)); // false
|
||||
console.log(isInFloat32Range(Number.MAX_VALUE)); // false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `packFloat16(value: number): Uint8Array`
|
||||
|
||||
Encodes a number as a 2-byte Float16. Handles NaN, ±Infinity, subnormals. Increments OTel counter.
|
||||
|
||||
### `unpackFloat16(buffer: Uint8Array): number`
|
||||
|
||||
Decodes a 2-byte Uint8Array to a JavaScript number. Throws if buffer.length !== 2.
|
||||
|
||||
### `packFloat32(value: number): Uint8Array`
|
||||
|
||||
Encodes a number as a 4-byte Float32 using `DataView.setFloat32`. Increments OTel counter.
|
||||
|
||||
### `unpackFloat32(buffer: Uint8Array): number`
|
||||
|
||||
Decodes a 4-byte Uint8Array to a JavaScript number using `DataView.getFloat32`.
|
||||
|
||||
### `packFloat64(value: number): Uint8Array`
|
||||
|
||||
Encodes a number as an 8-byte Float64 using `DataView.setFloat64`. Lossless. Increments OTel counter.
|
||||
|
||||
### `unpackFloat64(buffer: Uint8Array): number`
|
||||
|
||||
Decodes an 8-byte Uint8Array to a JavaScript number using `DataView.getFloat64`.
|
||||
|
||||
### `isInFloat16Range(value: number): boolean`
|
||||
|
||||
Returns true when `value` is zero, ±Infinity, NaN, or within `[min16, max16]`.
|
||||
|
||||
### `isInFloat32Range(value: number): boolean`
|
||||
|
||||
Returns true when `value` is zero, ±Infinity, NaN, or within `[min32, max32]`.
|
||||
|
||||
### `isInFloat64Range(value: number): boolean`
|
||||
|
||||
Returns true when `value` is finite. Infinity and NaN return false.
|
||||
|
||||
### Constants
|
||||
|
||||
- `min16` — `2^-14` (~6.1e-5), minimum positive normal Float16
|
||||
- `max16` — `65504`, maximum finite Float16
|
||||
- `min32` — `2^-126`, minimum positive normal Float32
|
||||
- `max32` — `(2 - 2^-23) * 2^127`, maximum finite Float32
|
||||
- `min64` — `Number.MIN_VALUE`, minimum positive subnormal Float64
|
||||
- `max64` — `Number.MAX_VALUE`, maximum finite Float64
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Float16 is not natively supported by `DataView`, so `packFloat16` and `unpackFloat16` implement the IEEE-754 half-precision format manually using bit shifting. The sign bit is bit 15, exponent is bits 14–10 (5 bits, biased by 15), and mantissa is bits 9–0 (10 bits). Subnormal numbers (exponent field = 0) are handled explicitly.
|
||||
|
||||
Float32 and Float64 use `DataView.setFloat32`/`setFloat64` which delegate to the CPU's native IEEE-754 implementation and are essentially free. The `unpack` functions construct a `DataView` using the buffer's `byteOffset` and `byteLength` to correctly handle sliced `Uint8Array` views.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user