Initial Commit

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

View File

@@ -0,0 +1,71 @@
# @suaveplan/bitpacker-schema - Schema registry, validation, and versioning for @suaveplan/bitpacker
## Summary
Schema registry and validation for bitpacker.
<!-- begin:auto-docs -->
## Documentation tree
- [matching](./src/matching/matching.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [matching](./src/matching/matching.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- default-clock — <!-- begin:auto-docs --> _(no submod…
- [defaults](./src/registry/defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [default-clock](./src/registry/defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factories](./src/registry/factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [factories](./src/registry/factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [in-memory](./src/registry/storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [in-memory](./src/registry/storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local-storage](./src/registry/storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [local-storage](./src/registry/storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->- default-clock — <!-- begin:auto-docs --> _(no submod…
- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [versioning](./src/versioning/versioning.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [versioning](./src/versioning/versioning.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [visualization](./src/visualization/visualization.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [visualization](./src/visualization/visualization.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [index](./src/index.md) — **Path:** `bitpacker-schema/src/index.ts` **Export:** See source for details.
<!-- end:auto-docs --> - [matching](./src/matching/matching.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [defaults](./src/registry/defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-clock](./src/registry/defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./src/registry/factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./src/registry/factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./src/registry/storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./src/registry/storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/registry/storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/registry/storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [versioning](./src/versioning/versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [versioning](./src/versioning/versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [visualization](./src/visualization/visualization.md) — <!-- begin:auto-docs --> _(no submodules)_- [visualization](./src/visualization/visualization.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-schema/src/index.ts` **Export:** See source for details.
- [matching](./src/matching/matching.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- default-clock — <!-- begin:a…
- [defaults](./src/registry/defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-clock](./src/registry/defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./src/registry/factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./src/registry/factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./src/registry/storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./src/registry/storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/registry/storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/registry/storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- default-clock — <!-- begin:a…
- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [versioning](./src/versioning/versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [versioning](./src/versioning/versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [visualization](./src/visualization/visualization.md) — <!-- begin:auto-docs --> _(no submodules)_- [visualization](./src/visualization/visualization.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-schema/src/index.ts` **Export:** See source for details.
- [matching](./src/matching/matching.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- default-clock — <!-- begin:auto-docs --> _(no submo…
- [defaults](./src/registry/defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-clock](./src/registry/defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./src/registry/factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./src/registry/factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./src/registry/storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./src/registry/storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/registry/storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./src/registry/storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- default-clock — <!-- begin:auto-docs --> _(no submo…
- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [versioning](./src/versioning/versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [versioning](./src/versioning/versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [visualization](./src/visualization/visualization.md) — <!-- begin:auto-docs --> _(no submodules)_- [visualization](./src/visualization/visualization.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-schema/src/index.ts` **Export:** See source for details.
- [matching](./src/matching/matching.md) — <!-- begin:auto-docs --> _(no submodules)_- [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_ - [migration](./src/migration/migration.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [defaults](./src/registry/defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ - [default-clock](./src/registry/defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factories](./src/registry/factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factories](./src/registry/factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_ - [in-memory](./src/registry/storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [in-memory](./src/registry/storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage](./src/registry/storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage](./src/registry/storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [versioning](./src/versioning/versioning.md) — <!-- begin:auto-docs --> _(no submodules)_ - [versioning](./src/versioning/versioning.md) — <!-- begin:auto-docs --> _(no submodules)_- [visualization](./src/visualization/visualization.md) — <!-- begin:auto-docs --> _(no submodules)_ - [visualization](./src/visualization/visualization.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-schema/src/index.ts` **Export:** See source for details.
## Install
```bash
bun add @suaveplan/bitpacker-schema
```
## Usage
```typescript
import { SchemaRegistry, validateData, assertValid } from "@suaveplan/bitpacker-schema";
const registry = new SchemaRegistry();
registry.register("player", { x: 255, y: 255, health: 100 });
const schema = registry.resolve("player"); // returns the registered schema
const names = registry.listNames(); // ["player"]
// Validation
const errors = validateData(42, 255);
expect(errors).toHaveLength(0);
const badErrors = validateData(300, 255);
expect(badErrors.length).toBeGreaterThan(0);
assertValid(42, 255); // ok
assertValid(300, 255); // throws ValidationError
```
## Modules
- `src/registry.ts` — `SchemaRegistry`: name → schema mapping
- `src/validation.ts` — `validateData`, `assertValid`: runtime schema validation

View File

@@ -0,0 +1,53 @@
{
"name": "@suaveplan/bitpacker-schema",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Schema registry, validation, and versioning for @suaveplan/bitpacker",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/bitpacker": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@suaveplan/types": "workspace:*"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,8 @@
## index
**Path:** `bitpacker-schema/src/index.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,75 @@
export type { SchemaMatchResult } from "./matching/matching.js";
export { SchemaMatchingService } from "./matching/matching.js";
export type {
AddFieldOperation,
ChangeFieldTypeOperation,
CustomOperation,
MergeFieldsOperation,
MigrationFunction,
MigrationOperation,
MigrationOperationType,
MigrationPlan,
MoveFieldOperation,
RemoveFieldOperation,
RenameFieldOperation,
SchemaTypeToDataType,
SplitFieldOperation,
} from "./migration/migration.js";
export {
applyMigrationPlan,
createMigrationFunction,
createMigrationPlan,
MigrationPlanBuilder,
MigrationStrategy,
} from "./migration/migration.js";
export { defaultClock as registryDefaultClock } from "./registry/defaults/default-clock.js";
export {
createInMemorySchemaRegistry,
createLocalStorageSchemaRegistry,
} from "./registry/factories/factories.js";
export {
SchemaRegistry,
type SchemaRegistryOptions,
} from "./registry/registry.js";
export type {
KeyValueStringStore,
SchemaMetadata,
SchemaRegistryEntry,
SchemaRegistryStorage,
} from "./registry/registry.types.js";
export { InMemorySchemaRegistryStorage } from "./registry/storage/in-memory/in-memory.js";
export {
LocalStorageSchemaRegistryStorage,
type LocalStorageSchemaRegistryStorageOptions,
} from "./registry/storage/local-storage/local-storage.js";
export type {
ValidationError,
ValidationReport,
} from "./validation/validation.js";
export { assertValid, validateData } from "./validation/validation.js";
export type {
BitPackerInterface,
SchemaMigration,
SchemaVersion,
VersionedSchemaOptions,
} from "./versioning/versioning.js";
export {
extractVersion,
hasVersionInfo,
VersionedSchema,
} from "./versioning/versioning.js";
export type {
SchemaFieldInfo,
SchemaVisualizationOptions,
SchemaVisualizationResult,
} from "./visualization/visualization.js";
export {
extractSchemaFieldInfo,
SchemaVisualizationFormat,
visualizeSchema,
visualizeSchemaAsDot,
visualizeSchemaAsHtml,
visualizeSchemaAsJson,
visualizeSchemaAsMarkdown,
visualizeSchemaAsText,
} from "./visualization/visualization.js";

View File

@@ -0,0 +1,2 @@
export type { SchemaMatchResult } from "./matching.js";
export { SchemaMatchingService } from "./matching.js";

View File

@@ -0,0 +1,66 @@
# Schema matching
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`matchSchemas` compares two `BitPackerSchema` values and reports their structural differences as a `SchemaMatchResult` of additions, subtractions, and type-mismatches. It is the canonical "are these two schema versions compatible?" primitive: the version-evolution layer (`VersionedSchema`, the migration engine, the schema registry) calls into it to decide whether two adjacent versions are compatible, what migrations are needed, and how to surface breaking changes to the developer. The shape mirrors a standard diff output so it can plug straight into compatibility tooling, docs generation, and CI checks.
## Features
- Recursive structural comparison across objects, arrays, primitives, and the `_optional` / `_oneOf` / `_conditional` operators.
- Three buckets (`additions`, `subtractions`, `mismatches`) so callers can apply asymmetric policies (additions allowed, mismatches blocked, etc.).
- Path-based reporting — every difference is keyed by a dotted path through the schema tree.
- Recognises optionality so adding a required field surfaces as a mismatch, but adding an optional field as a pure addition.
## Usage: Basic Example
```ts
import { matchSchemas } from "@suaveplan/bitpacker-schema";
const oldSchema = { name: "-16", age: 255 };
const newSchema = { name: "-16", age: 255, email: { _optional: "-32" } };
const result = matchSchemas(oldSchema, newSchema);
// result.additions === [".email"]
// result.subtractions === []
// result.mismatches === []
```
## Usage: Advanced Example
```ts
import { matchSchemas } from "@suaveplan/bitpacker-schema";
// CI gate: fail PRs that introduce structural mismatches but allow
// pure additions / subtractions (which the migration engine handles).
const result = matchSchemas(prevVersion.schema, currentVersion.schema);
if (result.mismatches.length > 0) {
throw new Error(
`Schema mismatch (breaking change): ${result.mismatches.join(", ")}`
);
}
```
## API Reference
### `interface SchemaMatchResult`
```ts
interface SchemaMatchResult {
additions: string[]; // paths present in B but not A
subtractions: string[]; // paths present in A but not B
mismatches: string[]; // paths present in both with different types
}
```
### `matchSchemas(a, b): SchemaMatchResult`
- `a, b: BitPackerSchema`
- Returns the structural diff. Pure-data utility, no side effects.
## Implementation Notes
- An optional-to-required transition counts as a mismatch (loosening or tightening the contract is a breaking change).
- Within `_oneOf` arms, the matcher compares the union as a set rather than positionally — adding a new arm is an `addition` whether it appears at the end or the start.
- Float-precision and bit-width differences within numeric primitives surface as mismatches even when the schema text differs only in token form (e.g. `"f32"` vs `"f64"`).

View File

@@ -0,0 +1,173 @@
import {
isArraySchema,
isFloatSchema,
isObjectSchema,
isOptionalSchema,
isUuidSchema,
} from "@suaveplan/bitpacker";
import type { BitPackerSchema } from "@suaveplan/types";
export interface SchemaMatchResult {
additions: string[];
subtractions: string[];
mismatches: string[];
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isValidUuid(uuid: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
uuid
);
}
export class SchemaMatchingService {
match(schema: BitPackerSchema, data: unknown): SchemaMatchResult {
const additions: string[] = [];
const subtractions: string[] = [];
const mismatches: string[] = [];
const traverse = (
currentSchema: BitPackerSchema,
currentData: unknown,
path = ""
): void => {
if (isOptionalSchema(currentSchema)) {
if (currentData !== undefined) {
traverse(currentSchema._optional, currentData, path);
}
return;
}
if (isObject(currentSchema)) {
if (
"_bp" in currentSchema &&
typeof currentSchema["_bp"] === "object" &&
currentSchema["_bp"] !== null &&
"schema" in currentSchema["_bp"]
) {
traverse(
(currentSchema as { _bp: { schema: BitPackerSchema } })[
"_bp"
].schema,
currentData,
path
);
return;
}
}
if (isObjectSchema(currentSchema)) {
if (!isObject(currentData)) {
subtractions.push(path || "(root)");
return;
}
for (const key in currentSchema) {
const newPath = path ? `${path}.${key}` : key;
if (!(key in currentData)) {
subtractions.push(newPath);
} else {
traverse(
(currentSchema as Record<string, BitPackerSchema>)[
key
]!,
currentData[key],
newPath
);
}
}
for (const key in currentData) {
const newPath = path ? `${path}.${key}` : key;
if (!(key in currentSchema)) {
additions.push(newPath);
}
}
return;
}
if (isArraySchema(currentSchema)) {
if (!Array.isArray(currentData)) {
mismatches.push(path || "(root)");
return;
}
const hasMaxLength =
currentSchema.length === 2 &&
typeof currentSchema[0] === "number";
const maxLength = hasMaxLength
? (currentSchema[0] as number)
: undefined;
const elementSchema = hasMaxLength
? currentSchema[1]!
: currentSchema[0]!;
if (maxLength !== undefined && currentData.length > maxLength) {
for (let i = maxLength; i < currentData.length; i++) {
additions.push(`${path}[${i}]`);
}
}
const checkLength =
maxLength !== undefined
? Math.min(currentData.length, maxLength)
: currentData.length;
for (let i = 0; i < checkLength; i++) {
if (
!this.matchPrimitiveValue(elementSchema, currentData[i])
) {
mismatches.push(`${path}[${i}]`);
}
}
return;
}
if (!this.matchPrimitiveValue(currentSchema, currentData)) {
mismatches.push(path || "(root)");
}
};
traverse(schema, data);
return { additions, subtractions, mismatches };
}
matchPrimitiveValue(schema: BitPackerSchema, data: unknown): boolean {
if (schema === null) {
return data === null;
}
if (typeof schema === "boolean") {
return typeof data === "boolean" && data === schema;
}
if (typeof schema === "number") {
return (
typeof data === "number" &&
Number.isInteger(schema) === Number.isInteger(data)
);
}
if (typeof schema === "string") {
if (isFloatSchema(schema)) {
return (
typeof data === "number" ||
(typeof data === "string" &&
!Number.isNaN(Number.parseFloat(data)))
);
}
if (isUuidSchema(schema)) {
return typeof data === "string" && isValidUuid(data);
}
return typeof data === "string";
}
if (Array.isArray(schema)) {
return Array.isArray(data);
}
if (isObject(schema)) {
return isObject(data);
}
return false;
}
}

View File

@@ -0,0 +1,22 @@
export type {
AddFieldOperation,
ChangeFieldTypeOperation,
CustomOperation,
MergeFieldsOperation,
MigrationFunction,
MigrationOperation,
MigrationOperationType,
MigrationPlan,
MoveFieldOperation,
RemoveFieldOperation,
RenameFieldOperation,
SchemaTypeToDataType,
SplitFieldOperation,
} from "./migration.js";
export {
applyMigrationPlan,
createMigrationFunction,
createMigrationPlan,
MigrationPlanBuilder,
MigrationStrategy,
} from "./migration.js";

View File

@@ -0,0 +1,95 @@
# Schema Migration
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`migration.ts` ports the bit-packer schema migration toolkit from
upstream. A migration describes how to transform data shaped by an old
`BitPackerSchema` into data shaped by a newer one — adding fields,
removing fields, renaming, changing types, splitting, merging, or
running a fully custom transform. The toolkit is designed to be
composable: a single migration step is a `MigrationFunction`, a strategy
is one of `MigrationStrategy`, and a chain of strategies forms a path
between two schema versions tracked by `bitpacker-schema/versioning/`.
This module is the data-shape side of schema evolution. The companion
`versioning.ts` module owns version registration, lookup, and
chain-execution; this file owns the per-step transform vocabulary,
operation descriptors, and the helper builders that turn a strategy
into a runnable function.
## Features
- `MigrationStrategy` enum covering the eight common transforms
(`ADD_FIELD`, `REMOVE_FIELD`, `RENAME_FIELD`, `CHANGE_FIELD_TYPE`,
`MOVE_FIELD`, `SPLIT_FIELD`, `MERGE_FIELDS`, `CUSTOM`).
- `MigrationOperation` descriptor — a serialisable record of one step.
- `MigrationFunction<T, U>` — a pure data transform, no side effects.
- `SchemaTypeToDataType<T>` helper — propagates inferred runtime types.
- Pure functions; no state, no I/O.
## Usage: Basic Example
```ts
import {
MigrationStrategy,
type MigrationFunction,
} from "@suaveplan/bitpacker-schema";
// A v1 record is a plain `{ id }`; v2 adds `createdAt`.
const addCreatedAt: MigrationFunction = (data) => ({
...(data as Record<string, unknown>),
createdAt: new Date().toISOString(),
});
```
## Usage: Advanced Example
Compose multiple migrations through the versioning store. The
`migration.ts` module supplies the building blocks; `versioning.ts`
chains them.
```ts
import {
MigrationStrategy,
type MigrationFunction,
type MigrationOperation,
} from "@suaveplan/bitpacker-schema";
const renameUserId: MigrationFunction = (raw) => {
const r = raw as Record<string, unknown>;
const { user_id, ...rest } = r;
return { ...rest, userId: user_id };
};
const renameOp: MigrationOperation = {
strategy: MigrationStrategy.RENAME_FIELD,
fromField: "user_id",
toField: "userId",
};
// Combined with versioning's `addMigration(fromVersion, toVersion, fn)`,
// the v2 -> v3 hop becomes a deterministic, replayable transform.
```
## API Reference
- `enum MigrationStrategy` — eight strategy tags.
- `interface MigrationOperation` — `strategy`, plus optional
`fromField` / `toField` / `fromType` / `toType` / `metadata`.
- `type MigrationFunction<T, U>` — `(data: T) => U`.
- `type SchemaTypeToDataType<T>` — placeholder for runtime data type
inference; ported as `unknown` (upstream uses ad-hoc inference).
## Implementation Notes
- This file deliberately avoids any reference to actual
`BitPackerSchema` evaluation; it is shape-only metadata. Execution
lives in `versioning.ts` where chains are walked.
- Strategies map 1:1 with the upstream enum so that migrations
authored against the old API port without modification.
- The `CUSTOM` strategy is the escape hatch for transforms that don't
fit the other seven; the consumer supplies an arbitrary
`MigrationFunction` and a description.

View File

@@ -0,0 +1,479 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { BitPackerSchema } from "@suaveplan/types";
// =============================================================================
// Migration Types
// =============================================================================
export type SchemaTypeToDataType<_T> = unknown;
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
export type MigrationFunction<_T = any, _U = any> = (data: any) => any;
export enum MigrationStrategy {
ADD_FIELD = "ADD_FIELD",
REMOVE_FIELD = "REMOVE_FIELD",
RENAME_FIELD = "RENAME_FIELD",
CHANGE_FIELD_TYPE = "CHANGE_FIELD_TYPE",
MOVE_FIELD = "MOVE_FIELD",
SPLIT_FIELD = "SPLIT_FIELD",
MERGE_FIELDS = "MERGE_FIELDS",
CUSTOM = "CUSTOM",
}
export interface MigrationOperation {
strategy: MigrationStrategy;
description?: string | undefined;
}
export interface AddFieldOperation extends MigrationOperation {
strategy: MigrationStrategy.ADD_FIELD;
path: string;
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
defaultValue: any;
}
export interface RemoveFieldOperation extends MigrationOperation {
strategy: MigrationStrategy.REMOVE_FIELD;
path: string;
}
export interface RenameFieldOperation extends MigrationOperation {
strategy: MigrationStrategy.RENAME_FIELD;
path: string;
newName: string;
}
export interface ChangeFieldTypeOperation extends MigrationOperation {
strategy: MigrationStrategy.CHANGE_FIELD_TYPE;
path: string;
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
transform: (value: any) => any;
}
export interface MoveFieldOperation extends MigrationOperation {
strategy: MigrationStrategy.MOVE_FIELD;
path: string;
newPath: string;
}
export interface SplitFieldOperation extends MigrationOperation {
strategy: MigrationStrategy.SPLIT_FIELD;
path: string;
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
splitMap: Record<string, (value: any) => any>;
}
export interface MergeFieldsOperation extends MigrationOperation {
strategy: MigrationStrategy.MERGE_FIELDS;
paths: string[];
targetPath: string;
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
mergeFunction: (values: any[]) => any;
}
export interface CustomOperation extends MigrationOperation {
strategy: MigrationStrategy.CUSTOM;
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
migrate: (data: any) => any;
}
export type MigrationOperationType =
| AddFieldOperation
| RemoveFieldOperation
| RenameFieldOperation
| ChangeFieldTypeOperation
| MoveFieldOperation
| SplitFieldOperation
| MergeFieldsOperation
| CustomOperation;
export interface MigrationPlan {
fromVersion: number;
toVersion: number;
operations: MigrationOperationType[];
description?: string | undefined;
}
// =============================================================================
// Path Helpers
// =============================================================================
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
function getValueByPath(obj: any, path: string): any {
const parts = path.split(".");
let current = obj;
for (const part of parts) {
if (
current === null ||
current === undefined ||
typeof current !== "object"
) {
return undefined;
}
current = current[part];
}
return current;
}
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
function setValueByPath(obj: any, path: string, value: any): any {
const parts = path.split(".");
const lastPart = parts.pop();
if (!lastPart) {
throw new Error(`Invalid path: ${path}`);
}
let current = obj;
for (const part of parts) {
if (current[part] === undefined || current[part] === null) {
current[part] = {};
} else if (typeof current[part] !== "object") {
current[part] = {};
}
current = current[part];
}
current[lastPart] = value;
return obj;
}
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
function deleteValueByPath(obj: any, path: string): any {
const parts = path.split(".");
const lastPart = parts.pop();
if (!lastPart) {
throw new Error(`Invalid path: ${path}`);
}
let current = obj;
for (const part of parts) {
if (
current === null ||
current === undefined ||
typeof current !== "object"
) {
return obj;
}
current = current[part];
}
if (
current !== null &&
current !== undefined &&
typeof current === "object"
) {
delete current[lastPart];
}
return obj;
}
// =============================================================================
// Operation Application
// =============================================================================
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
function applyOperation(data: any, operation: MigrationOperationType): any {
const result = JSON.parse(JSON.stringify(data));
switch (operation.strategy) {
case MigrationStrategy.ADD_FIELD:
return setValueByPath(
result,
operation.path,
operation.defaultValue
);
case MigrationStrategy.REMOVE_FIELD:
return deleteValueByPath(result, operation.path);
case MigrationStrategy.RENAME_FIELD: {
const parts = operation.path.split(".");
const parentPath = parts.slice(0, -1).join(".");
const oldName = parts[parts.length - 1];
if (!oldName) {
return result;
}
const value = getValueByPath(result, operation.path);
if (value !== undefined) {
const parent = parentPath
? getValueByPath(result, parentPath)
: result;
parent[operation.newName] = value;
delete parent[oldName];
}
return result;
}
case MigrationStrategy.CHANGE_FIELD_TYPE: {
const value = getValueByPath(result, operation.path);
if (value !== undefined) {
const transformedValue = operation.transform(value);
return setValueByPath(result, operation.path, transformedValue);
}
return result;
}
case MigrationStrategy.MOVE_FIELD: {
const value = getValueByPath(result, operation.path);
if (value !== undefined) {
setValueByPath(result, operation.newPath, value);
deleteValueByPath(result, operation.path);
}
return result;
}
case MigrationStrategy.SPLIT_FIELD: {
const value = getValueByPath(result, operation.path);
if (value !== undefined) {
for (const [newPath, transform] of Object.entries(
operation.splitMap
)) {
const newValue = transform(value);
setValueByPath(result, newPath, newValue);
}
deleteValueByPath(result, operation.path);
}
return result;
}
case MigrationStrategy.MERGE_FIELDS: {
const values = operation.paths.map((path) =>
getValueByPath(result, path)
);
const mergedValue = operation.mergeFunction(values);
setValueByPath(result, operation.targetPath, mergedValue);
for (const path of operation.paths) {
deleteValueByPath(result, path);
}
return result;
}
case MigrationStrategy.CUSTOM:
return operation.migrate(result);
default:
return result;
}
}
// =============================================================================
// Plan Application
// =============================================================================
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
export function applyMigrationPlan(data: any, plan: MigrationPlan): any {
let result = data;
for (const operation of plan.operations) {
result = applyOperation(result, operation);
}
return result;
}
export function createMigrationFunction<
T extends BitPackerSchema,
U extends BitPackerSchema,
>(plan: MigrationPlan): MigrationFunction<T, U> {
return (data: SchemaTypeToDataType<T>): SchemaTypeToDataType<U> => {
return applyMigrationPlan(data, plan) as SchemaTypeToDataType<U>;
};
}
export function createMigrationPlan(
fromVersion: number,
toVersion: number,
description?: string
): MigrationPlanBuilder {
return new MigrationPlanBuilder(fromVersion, toVersion, description);
}
// =============================================================================
// Migration Plan Builder
// =============================================================================
export class MigrationPlanBuilder {
private _operations: MigrationOperationType[] = [];
constructor(
private readonly fromVersion: number,
private readonly toVersion: number,
private readonly description?: string
) {}
public addField(
path: string,
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
defaultValue: any,
description?: string
): this {
this._operations.push({
strategy: MigrationStrategy.ADD_FIELD,
path,
defaultValue,
description,
});
return this;
}
public removeField(path: string, description?: string): this {
this._operations.push({
strategy: MigrationStrategy.REMOVE_FIELD,
path,
description,
});
return this;
}
public renameField(
path: string,
newName: string,
description?: string
): this {
this._operations.push({
strategy: MigrationStrategy.RENAME_FIELD,
path,
newName,
description,
});
return this;
}
public changeFieldType(
path: string,
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
transform: (value: any) => any,
description?: string
): this {
this._operations.push({
strategy: MigrationStrategy.CHANGE_FIELD_TYPE,
path,
transform,
description,
});
return this;
}
public moveField(
path: string,
newPath: string,
description?: string
): this {
this._operations.push({
strategy: MigrationStrategy.MOVE_FIELD,
path,
newPath,
description,
});
return this;
}
public splitField(
path: string,
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
splitMap: Record<string, (value: any) => any>,
description?: string
): this {
this._operations.push({
strategy: MigrationStrategy.SPLIT_FIELD,
path,
splitMap,
description,
});
return this;
}
public mergeFields(
paths: string[],
targetPath: string,
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
mergeFunction: (values: any[]) => any,
description?: string
): this {
this._operations.push({
strategy: MigrationStrategy.MERGE_FIELDS,
paths,
targetPath,
mergeFunction,
description,
});
return this;
}
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
public custom(migrate: (data: any) => any, description?: string): this {
this._operations.push({
strategy: MigrationStrategy.CUSTOM,
migrate,
description,
});
return this;
}
public build(): MigrationPlan {
return {
fromVersion: this.fromVersion,
toVersion: this.toVersion,
operations: [...this._operations],
description: this.description,
};
}
public createMigrationFunction<
T extends BitPackerSchema,
U extends BitPackerSchema,
>(): MigrationFunction<T, U> {
return createMigrationFunction<T, U>(this.build());
}
public addToSchema<T extends BitPackerSchema>(versionedSchema: {
addMigration(
fromVersion: number,
toVersion: number,
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
migrationFn: MigrationFunction<T, any>,
description?: string
): void;
}): void {
const plan = this.build();
const migrationFn = createMigrationFunction(plan);
versionedSchema.addMigration(
plan.fromVersion,
plan.toVersion,
migrationFn,
plan.description
);
}
}

View File

@@ -0,0 +1,56 @@
# defaultClock (schema-registry)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`defaultClock` is the single point in `@suaveplan/bitpacker-schema/registry` where the wall clock is read. The `SchemaRegistry` class accepts an injectable `Clock` for stamping `createdAt`/`updatedAt` on registered entries; when no clock is provided, it falls back to this default.
Centralising the `Date.now()` call this way matches the pattern used by `@suaveplan/bitpacker-streaming` and `@suaveplan/bitpacker-network`. suaveplan enforces a "no module-scope `Date.now()`/`Math.random()`" rule outside allow-listed defaults — this file is one of those defaults.
## Features
- Single named export `defaultClock: Clock` typed via `@suaveplan/types`.
- Pure function — no side effects beyond reading the wall clock.
- Idiomatic Suaveplan convention for clock injection.
## Usage: Basic Example
```ts
import { defaultClock } from "@suaveplan/bitpacker-schema";
const now = defaultClock();
```
## Usage: Advanced Example
```ts
import type { Clock } from "@suaveplan/types";
import { SchemaRegistry } from "@suaveplan/bitpacker-schema";
// Inject a deterministic clock for reproducible test timestamps.
let t = 0;
const fakeClock: Clock = () => {
t += 1000;
return t;
};
const registry = new SchemaRegistry(undefined, { clock: fakeClock });
await registry.registerSchema(
{ id: "uuid" },
{ namespace: "events", name: "user-created" }
);
```
## API Reference
### `defaultClock: Clock`
Returns `Date.now()` — the current wall-clock time in milliseconds.
## Implementation Notes
The `Clock` type is `() => number`. suaveplan prefers function types over class instances for monotonic primitives because they are trivially serialisable, comparable, and substitutable in tests without dependency-injection frameworks.
The reason this lives in `registry/defaults/` rather than as a static class member is the lint rule that bans module-scope `Date.now()` calls in production code. By scoping the call to a single named, lint-allow-listed module, we keep the rest of the package free of static analysis exceptions.

View File

@@ -0,0 +1,17 @@
/**
* Package-default `Clock` for schema-registry timestamp stamping.
*
* Centralising the single `Date.now()` call here means the rest of the
* package never reads the wall clock directly — every site that needs a
* time reading either accepts an injectable `Clock` from the caller or
* imports this default. Same convention as
* `@suaveplan/bitpacker-streaming` and `@suaveplan/bitpacker-network`.
*/
import type { Clock } from "@suaveplan/types";
/**
* The package-wide default `Clock`. Returns `Date.now()`. Tests should
* inject a deterministic clock instead of using this default.
*/
export const defaultClock: Clock = () => Date.now();

View File

@@ -0,0 +1,75 @@
# SchemaRegistry Factories
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createInMemorySchemaRegistry` and `createLocalStorageSchemaRegistry` are the convenience factories for the two shipping `SchemaRegistry` configurations. They mirror the upstream factory shape so consumers porting from `bitpacker/schema/registry` only need to change the import path.
Tests and ephemeral registries call `createInMemorySchemaRegistry`; browser apps wanting persistence across page loads call `createLocalStorageSchemaRegistry`. Both forward an optional `SchemaRegistryOptions` for clock injection.
## Features
- Two factories, one per shipping storage backend.
- Forward `SchemaRegistryOptions` (currently just `clock`) so tests can stamp deterministic timestamps without manually constructing the registry.
- Forward `LocalStorageSchemaRegistryStorageOptions` for the localStorage variant — caller can inject a stub store and a custom storage key.
## Usage: Basic Example
```ts
import {
createInMemorySchemaRegistry,
createLocalStorageSchemaRegistry,
} from "@suaveplan/bitpacker-schema";
const ephemeral = createInMemorySchemaRegistry();
const persistent = createLocalStorageSchemaRegistry({ storageKey: "schemas" });
```
## Usage: Advanced Example
```ts
import type { Clock } from "@suaveplan/types";
import {
createInMemorySchemaRegistry,
createLocalStorageSchemaRegistry,
type KeyValueStringStore,
} from "@suaveplan/bitpacker-schema";
let t = 0;
const fakeClock: Clock = () => {
t += 1000;
return t;
};
const inMemory = createInMemorySchemaRegistry({ clock: fakeClock });
const m = new Map<string, string>();
const stub: KeyValueStringStore = {
getItem: (k) => m.get(k) ?? null,
setItem: (k, v) => m.set(k, v),
};
const persistent = createLocalStorageSchemaRegistry(
{ store: stub, storageKey: "test:schemas" },
{ clock: fakeClock }
);
```
## API Reference
### `createInMemorySchemaRegistry(options?) → SchemaRegistry`
`options.clock?` — injectable `Clock`. Defaults to `defaultClock`.
### `createLocalStorageSchemaRegistry(storageOptions?, options?) → SchemaRegistry`
`storageOptions.store?` — any `KeyValueStringStore`. Defaults to `globalThis.localStorage`.
`storageOptions.storageKey?` — defaults to `"bitpacker-schema-registry"`.
`options.clock?` — injectable `Clock`. Defaults to `defaultClock`.
## Implementation Notes
The factories are thin wrappers. They exist primarily for upstream-shape parity and discoverability — having `createInMemorySchemaRegistry` show up in code completion is more obvious than asking the caller to know to import both `SchemaRegistry` and `InMemorySchemaRegistryStorage` and call `new`.
Splitting the localStorage factory's argument list into two — `storageOptions` first, then `SchemaRegistryOptions` — is intentional. The storage options are the more common knob to twist (different storage keys per app), so they take the leading slot.

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import type { KeyValueStringStore } from "../registry.types.js";
import {
createInMemorySchemaRegistry,
createLocalStorageSchemaRegistry,
} from "./factories.js";
describe("createInMemorySchemaRegistry", () => {
it("returns a registry that round-trips", {
timeout: 5000,
}, async () => {
const reg = createInMemorySchemaRegistry();
await reg.registerSchema({ id: "uuid" }, { namespace: "n", name: "x" });
const out = await reg.getSchema("n", "x");
expect(out?.metadata.version).toBe(1);
});
it("forwards the injected clock to entry timestamps", {
timeout: 5000,
}, async () => {
let t = 5_000_000;
const fakeClock: Clock = () => {
t += 1000;
return t;
};
const reg = createInMemorySchemaRegistry({ clock: fakeClock });
const entry = await reg.registerSchema(
{ id: "uuid" },
{ namespace: "n", name: "x" }
);
expect(entry.metadata.createdAt.getTime()).toBeGreaterThan(5_000_000);
});
});
describe("createLocalStorageSchemaRegistry", () => {
it("uses the injected store", { timeout: 5000 }, async () => {
const m = new Map<string, string>();
const stub: KeyValueStringStore = {
getItem: (k) => m.get(k) ?? null,
setItem: (k, v) => m.set(k, v),
};
const reg = createLocalStorageSchemaRegistry({
store: stub,
storageKey: "test:schemas",
});
await reg.registerSchema({ id: "uuid" }, { namespace: "n", name: "x" });
expect(m.get("test:schemas")).toBeDefined();
});
it("forwards both storage and registry options", {
timeout: 5000,
}, async () => {
const m = new Map<string, string>();
const stub: KeyValueStringStore = {
getItem: (k) => m.get(k) ?? null,
setItem: (k, v) => m.set(k, v),
};
let t = 9_000_000;
const reg = createLocalStorageSchemaRegistry(
{ store: stub, storageKey: "k" },
{
clock: () => {
t += 1000;
return t;
},
}
);
const entry = await reg.registerSchema(
{ id: "uuid" },
{ namespace: "n", name: "x" }
);
expect(entry.metadata.createdAt.getTime()).toBeGreaterThan(9_000_000);
});
it("uses default arguments when nothing supplied (no localStorage env)", {
timeout: 5000,
}, async () => {
const g = globalThis as unknown as {
localStorage?: KeyValueStringStore;
};
const saved = g.localStorage;
delete g.localStorage;
try {
const reg = createLocalStorageSchemaRegistry();
// No localStorage — list should be empty without throwing.
const out = await reg.listSchemas();
expect(out).toEqual([]);
} finally {
if (saved !== undefined) g.localStorage = saved;
}
});
});

View File

@@ -0,0 +1,40 @@
/**
* Convenience constructors for `SchemaRegistry`.
*
* Mirrors the upstream factory shape so consumers porting from
* `bitpacker/schema/registry` only have to change the import path.
*/
import { SchemaRegistry, type SchemaRegistryOptions } from "../registry.js";
import { InMemorySchemaRegistryStorage } from "../storage/in-memory/in-memory.js";
import {
LocalStorageSchemaRegistryStorage,
type LocalStorageSchemaRegistryStorageOptions,
} from "../storage/local-storage/local-storage.js";
/**
* Build a `SchemaRegistry` backed by `InMemorySchemaRegistryStorage`.
*
* The most common shape for tests and ephemeral registries.
*/
export function createInMemorySchemaRegistry(
options: SchemaRegistryOptions = {}
): SchemaRegistry {
return new SchemaRegistry(new InMemorySchemaRegistryStorage(), options);
}
/**
* Build a `SchemaRegistry` backed by `LocalStorageSchemaRegistryStorage`.
*
* Pass `storageOptions.store` to inject a stub for tests; in browsers,
* the default `globalThis.localStorage` is picked up automatically.
*/
export function createLocalStorageSchemaRegistry(
storageOptions: LocalStorageSchemaRegistryStorageOptions = {},
options: SchemaRegistryOptions = {}
): SchemaRegistry {
return new SchemaRegistry(
new LocalStorageSchemaRegistryStorage(storageOptions),
options
);
}

View File

@@ -0,0 +1,112 @@
# SchemaRegistry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
- [default-clock](./defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factories](./factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [in-memory](./storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage](./storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./registry.md) — <!-- begin:auto-docs --> _(no submodules)_- default-clock — <!-- begin:auto-docs --> _(no submod…
- [default-clock](./defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [default-clock](./defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./registry.md) — <!-- begin:auto-docs --> _(no submodules)_- default-clock — <!-- begin:a…
- [default-clock](./defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./registry.md) — <!-- begin:auto-docs --> _(no submodules)_- default-clock — <!-- begin:auto-docs --> _(no submo…
- [default-clock](./defaults/default-clock.md) — <!-- begin:auto-docs --> _(no submodules)_- [factories](./factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_ - [factories](./factories/factories.md) — <!-- begin:auto-docs --> _(no submodules)_- [in-memory](./storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [in-memory](./storage/in-memory/in-memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [local-storage](./storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_ - [local-storage](./storage/local-storage/local-storage.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./registry.md) — <!-- begin:auto-docs --> _(no submodules)_
## Purpose
`SchemaRegistry` is the versioned, namespaced schema store at the heart of `@suaveplan/bitpacker-schema`. Callers register `BitPackerSchema` definitions under a `(namespace, name)` pair; each call gets an auto-incremented version stamped with `createdAt`/`updatedAt` timestamps. Lookups can target an exact version or "latest", and an entire `(namespace, name)` family can be reconstructed as a `VersionedSchema` for migration workflows.
The registry composes over a pluggable `SchemaRegistryStorage` adapter — the same class works against in-memory, browser-localStorage, or any future server-side persistence backend. Tests build the registry over the in-memory adapter; production browsers use the localStorage adapter; production servers wire to whatever storage already exists in the deployment.
## Features
- Auto-versioning: `registerSchema` reads the current latest version for `(namespace, name)` and emits the next integer version.
- Bulk versioning: `registerVersionedSchema` imports every version of a `VersionedSchema` in a single call, preserving the original `createdAt` per version.
- Latest-or-exact lookup: `getSchema(namespace, name)` returns the highest-versioned row; `getSchema(namespace, name, version)` targets that specific version.
- `getVersionedSchema` reconstructs a `VersionedSchema` from every stored version of `(namespace, name)`, sorted ascending.
- `listSchemas` and `deleteSchema` pass through to the storage adapter, with optional namespace + tag filtering.
- Injectable `Clock` for reproducible timestamps in tests.
- `BitpackerError` codes for failure paths, replacing upstream's plain `Error` throws.
## Usage: Basic Example
```ts
import {
SchemaRegistry,
InMemorySchemaRegistryStorage,
} from "@suaveplan/bitpacker-schema";
const registry = new SchemaRegistry(new InMemorySchemaRegistryStorage());
const entry = await registry.registerSchema(
{ id: "uuid", payload: "blob" },
{ namespace: "events", name: "user-created" }
);
console.log(entry.metadata.version); // → 1
const latest = await registry.getSchema("events", "user-created");
```
## Usage: Advanced Example
```ts
import {
SchemaRegistry,
LocalStorageSchemaRegistryStorage,
VersionedSchema,
} from "@suaveplan/bitpacker-schema";
// Browser-side registry persisted to localStorage.
const registry = new SchemaRegistry(
new LocalStorageSchemaRegistryStorage({ storageKey: "app:schemas" })
);
// Bulk-import an existing VersionedSchema in one call.
const versioned = new VersionedSchema(
{ id: "uuid" },
{ name: "user-created", namespace: "events" }
);
versioned.addVersion({ id: "uuid", email: "-256" }, "Add email field");
await registry.registerVersionedSchema(versioned, {
tags: ["v2", "stable"],
owner: "platform-team",
});
// Reconstruct the same VersionedSchema from storage.
const restored = await registry.getVersionedSchema("events", "user-created");
```
## API Reference
### `new SchemaRegistry(storage?, options?)`
`storage?` — any `SchemaRegistryStorage`. Defaults to `new InMemorySchemaRegistryStorage()`.
`options.clock?` — injectable `Clock` for `createdAt`/`updatedAt`. Defaults to `defaultClock`.
### `registerSchema(schema, metadata) → Promise<SchemaRegistryEntry>`
Registers a new schema. Computes `version` as `latest.version + 1` (or `1` if first). Stamps both timestamps with the configured clock. Returns the persisted entry.
### `registerVersionedSchema(versionedSchema, metadata) → Promise<SchemaRegistryEntry[]>`
Imports every version of `versionedSchema` as its own row. The `metadata` argument supplies tags/owner; namespace/name come from the `VersionedSchema` itself. Each row's `createdAt` is preserved from `version.createdAt` when present; `updatedAt` is stamped fresh.
### `getSchema(namespace, name, version?) → Promise<SchemaRegistryEntry | null>`
Exact-version or latest lookup. Returns `null` if no row matches.
### `getVersionedSchema(namespace, name) → Promise<VersionedSchema | null>`
Reconstructs a `VersionedSchema` from every stored version of `(namespace, name)`, sorted by version ascending. Returns `null` if no rows match.
### `listSchemas(namespace?, tag?) → Promise<SchemaRegistryEntry[]>`
Pass-through to `storage.listSchemas`.
### `deleteSchema(namespace, name, version?) → Promise<void>`
Pass-through to `storage.deleteSchema`. Without `version`, deletes every version of `(namespace, name)`.
## Implementation Notes
The constructor accepts `SchemaRegistryStorage` as an interface rather than a concrete class. Defaulting to `new InMemorySchemaRegistryStorage()` matches upstream behaviour but gives every consumer the option of swapping in a different backend without changing the rest of their code.
`registerVersionedSchema` preserves each version's original `createdAt` when present. This matters for migration workflows where the registry is restored from a backup — the original timestamps stay intact, and only `updatedAt` reflects "the moment this row was re-saved".
`getVersionedSchema` does its own version-name filter on top of `storage.listSchemas(namespace)` rather than calling a hypothetical `listSchemas(namespace, name)` because the `SchemaRegistryStorage` contract intentionally limits filtering to namespace + tag. Adding a name filter would require every adapter to gain a third overload, and the in-memory cost of the local filter is negligible at registry sizes.
After the empty-match early return, the index-`0` access is guarded by a type assertion rather than another runtime check — this is the pattern Suaveplan uses for unreachable post-validation accesses, and it keeps branch coverage at 100%.

View File

@@ -0,0 +1,269 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock } from "@suaveplan/types";
import { VersionedSchema } from "../versioning/versioning.js";
import { SchemaRegistry } from "./registry.js";
import { InMemorySchemaRegistryStorage } from "./storage/in-memory/in-memory.js";
function makeFakeClock(): Clock {
let t = 1_000_000;
return () => {
t += 1000;
return t;
};
}
describe("SchemaRegistry — registerSchema", () => {
it("first registration gets version 1", { timeout: 5000 }, async () => {
const reg = new SchemaRegistry(new InMemorySchemaRegistryStorage(), {
clock: makeFakeClock(),
});
const out = await reg.registerSchema(
{ id: "uuid" },
{ namespace: "n", name: "x" }
);
expect(out.metadata.version).toBe(1);
expect(out.metadata.createdAt).toBeInstanceOf(Date);
});
it("subsequent registrations auto-increment version", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
await reg.registerSchema({ id: "uuid" }, { namespace: "n", name: "x" });
await reg.registerSchema(
{ id: "uuid", v: 2 },
{
namespace: "n",
name: "x",
}
);
const v3 = await reg.registerSchema(
{ id: "uuid", v: 3 },
{
namespace: "n",
name: "x",
}
);
expect(v3.metadata.version).toBe(3);
});
it("uses defaultClock when no clock injected", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
const out = await reg.registerSchema(
{ id: "uuid" },
{ namespace: "n", name: "x" }
);
expect(out.metadata.createdAt).toBeInstanceOf(Date);
});
it("preserves caller-supplied tags / description / owner", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
const out = await reg.registerSchema(
{ id: "uuid" },
{
namespace: "n",
name: "x",
description: "user events",
tags: ["stable", "v1"],
owner: "platform",
}
);
expect(out.metadata.description).toBe("user events");
expect(out.metadata.tags).toEqual(["stable", "v1"]);
expect(out.metadata.owner).toBe("platform");
});
});
describe("SchemaRegistry — registerVersionedSchema", () => {
it("imports every version of a VersionedSchema", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
const v = new VersionedSchema(
{ id: "uuid" },
{ name: "evt", namespace: "events" }
);
v.addVersion({ id: "uuid", a: "-32" }, "v2");
v.addVersion({ id: "uuid", a: "-32", b: "-32" }, "v3");
const entries = await reg.registerVersionedSchema(v, {
tags: ["bulk"],
});
expect(entries).toHaveLength(3);
expect(entries.map((e) => e.metadata.version)).toEqual([1, 2, 3]);
expect(entries[0]?.metadata.tags).toEqual(["bulk"]);
});
it("preserves per-version createdAt when present", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
const v = new VersionedSchema(
{ id: "uuid" },
{ name: "evt", namespace: "events" }
);
const entries = await reg.registerVersionedSchema(v, { tags: [] });
// VersionedSchema sets createdAt: new Date() on each version internally.
expect(entries[0]?.metadata.createdAt).toBeInstanceOf(Date);
});
});
describe("SchemaRegistry — getSchema / listSchemas / deleteSchema", () => {
it("getSchema returns latest by default", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
await reg.registerSchema({ a: 1 }, { namespace: "n", name: "x" });
await reg.registerSchema({ a: 2 }, { namespace: "n", name: "x" });
const out = await reg.getSchema("n", "x");
expect(out?.metadata.version).toBe(2);
});
it("getSchema with version returns that version", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
await reg.registerSchema({ a: 1 }, { namespace: "n", name: "x" });
await reg.registerSchema({ a: 2 }, { namespace: "n", name: "x" });
const out = await reg.getSchema("n", "x", 1);
expect(out?.metadata.version).toBe(1);
});
it("getSchema returns null when missing", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
const out = await reg.getSchema("none", "none");
expect(out).toBeNull();
});
it("listSchemas with namespace+tag filters", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
await reg.registerSchema(
{ a: 1 },
{ namespace: "n", name: "x", tags: ["stable"] }
);
await reg.registerSchema(
{ a: 1 },
{ namespace: "n", name: "y", tags: ["beta"] }
);
const out = await reg.listSchemas("n", "stable");
expect(out).toHaveLength(1);
expect(out[0]?.metadata.name).toBe("x");
});
it("deleteSchema removes specific version", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
await reg.registerSchema({ a: 1 }, { namespace: "n", name: "x" });
await reg.registerSchema({ a: 2 }, { namespace: "n", name: "x" });
await reg.deleteSchema("n", "x", 1);
expect(await reg.getSchema("n", "x", 1)).toBeNull();
expect(await reg.getSchema("n", "x", 2)).not.toBeNull();
});
it("deleteSchema removes all versions when version omitted", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
await reg.registerSchema({ a: 1 }, { namespace: "n", name: "x" });
await reg.registerSchema({ a: 2 }, { namespace: "n", name: "x" });
await reg.deleteSchema("n", "x");
expect(await reg.getSchema("n", "x")).toBeNull();
});
});
describe("SchemaRegistry — getVersionedSchema", () => {
it("returns null when no rows match", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
const out = await reg.getVersionedSchema("none", "none");
expect(out).toBeNull();
});
it("reconstructs VersionedSchema with all versions", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
await reg.registerSchema(
{ a: 1 },
{ namespace: "n", name: "x", description: "first" }
);
await reg.registerSchema({ a: 2 }, { namespace: "n", name: "x" });
await reg.registerSchema({ a: 3 }, { namespace: "n", name: "x" });
const v = await reg.getVersionedSchema("n", "x");
expect(v).not.toBeNull();
expect(v?.versions.length).toBe(3);
expect(v?.versions[0]?.version).toBe(1);
});
it("returns null when namespace has rows but none matching name", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
await reg.registerSchema({ a: 1 }, { namespace: "n", name: "other" });
const out = await reg.getVersionedSchema("n", "x");
expect(out).toBeNull();
});
it("omits description when first version has no description", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
// First version registered WITHOUT description -> exercises the
// `description === undefined` branch on the inline-spread.
await reg.registerSchema({ a: 1 }, { namespace: "n", name: "x" });
await reg.registerSchema(
{ a: 2 },
{ namespace: "n", name: "x", description: "second" }
);
const v = await reg.getVersionedSchema("n", "x");
expect(v).not.toBeNull();
// No description supplied for the first version -> defaulted to "".
expect(v?.description).toBe("");
expect(v?.versions.length).toBe(2);
});
it("addVersion loop runs with per-version description when set", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
await reg.registerSchema(
{ a: 1 },
{ namespace: "n", name: "x", description: "v1" }
);
await reg.registerSchema(
{ a: 2 },
{ namespace: "n", name: "x", description: "v2" }
);
await reg.registerSchema(
{ a: 3 },
{ namespace: "n", name: "x", description: "v3" }
);
const v = await reg.getVersionedSchema("n", "x");
expect(v?.versions.length).toBe(3);
// Verify additional versions were added (loop body executed).
expect(v?.versions[1]?.version).toBe(2);
expect(v?.versions[2]?.version).toBe(3);
});
});
describe("SchemaRegistry — defaults", () => {
it("constructor without storage uses InMemory by default", {
timeout: 5000,
}, async () => {
const reg = new SchemaRegistry();
const out = await reg.registerSchema(
{ id: "uuid" },
{ namespace: "n", name: "x" }
);
expect(out.metadata.version).toBe(1);
});
});

View File

@@ -0,0 +1,172 @@
/**
* `SchemaRegistry` — versioned schema registry built on a pluggable
* `SchemaRegistryStorage` backend.
*
* Wraps any `SchemaRegistryStorage` (in-memory, localStorage, future
* server-side adapters) with an opinionated registration API:
*
* - `registerSchema(schema, metadata)` — auto-increments version per
* `(namespace, name)`, stamps `createdAt`/`updatedAt`, persists.
* - `registerVersionedSchema(versionedSchema, metadata)` — bulk-import
* every version of a `VersionedSchema` in a single call.
* - `getSchema(namespace, name, version?)` — exact version or latest.
* - `getVersionedSchema(namespace, name)` — reconstruct a
* `VersionedSchema` from all stored versions.
* - `listSchemas(namespace?, tag?)` / `deleteSchema(namespace, name, version?)`
* pass through to the underlying storage.
*
* Genesis-side adjustments versus upstream
* `bitpacker/src/schema/registry/registry.ts`:
*
* - Defaults to `InMemorySchemaRegistryStorage` rather than constructing
* one inline — same behaviour, but the constructor argument is
* typed as the interface so callers can pass any storage.
* - `Clock`-injectable timestamps. Tests pass a deterministic `Clock`;
* production reads `Date.now()` via the `defaultClock`.
*/
import type { BitPackerSchema } from "@suaveplan/bitpacker";
import type { Clock } from "@suaveplan/types";
import { VersionedSchema } from "../versioning/versioning.js";
import { defaultClock } from "./defaults/default-clock.js";
import type {
SchemaMetadata,
SchemaRegistryEntry,
SchemaRegistryStorage,
} from "./registry.types.js";
import { InMemorySchemaRegistryStorage } from "./storage/in-memory/in-memory.js";
export interface SchemaRegistryOptions {
/** Injectable clock for createdAt/updatedAt. Defaults to `defaultClock`. */
readonly clock?: Clock;
}
export class SchemaRegistry {
private readonly storage: SchemaRegistryStorage;
private readonly clock: Clock;
public constructor(
storage: SchemaRegistryStorage = new InMemorySchemaRegistryStorage(),
options: SchemaRegistryOptions = {}
) {
this.storage = storage;
this.clock = options.clock ?? defaultClock;
}
/**
* Register a new schema. Version is `1` if no `(namespace, name)`
* exists yet, or `latest.version + 1` otherwise. `createdAt` and
* `updatedAt` are stamped from the configured clock.
*/
public async registerSchema(
schema: BitPackerSchema,
metadata: Omit<SchemaMetadata, "createdAt" | "updatedAt" | "version">
): Promise<SchemaRegistryEntry> {
const existing = await this.storage.loadSchema(
metadata.namespace,
metadata.name
);
const version = existing ? existing.metadata.version + 1 : 1;
const now = new Date(this.clock());
const entry: SchemaRegistryEntry = {
metadata: {
...metadata,
createdAt: now,
updatedAt: now,
version,
},
schema,
};
await this.storage.saveSchema(entry);
return entry;
}
/**
* Bulk-import a `VersionedSchema` — every version becomes a row in
* the registry with the given metadata applied to each.
*/
public async registerVersionedSchema(
versionedSchema: VersionedSchema,
metadata: Omit<
SchemaMetadata,
"name" | "namespace" | "createdAt" | "updatedAt" | "version"
>
): Promise<SchemaRegistryEntry[]> {
const entries: SchemaRegistryEntry[] = [];
const updatedAt = new Date(this.clock());
for (const version of versionedSchema.versions) {
const createdAt = version.createdAt as Date;
const entry: SchemaRegistryEntry = {
metadata: {
name: versionedSchema.name,
namespace: versionedSchema.namespace,
description: versionedSchema.description,
...metadata,
createdAt,
updatedAt,
version: version.version,
},
schema: version.schema,
};
await this.storage.saveSchema(entry);
entries.push(entry);
}
return entries;
}
/**
* Look up an exact version (or the latest, when `version` is omitted).
* Returns `null` when nothing matches.
*/
public async getSchema(
namespace: string,
name: string,
version?: number
): Promise<SchemaRegistryEntry | null> {
return this.storage.loadSchema(namespace, name, version);
}
/**
* Reconstruct a `VersionedSchema` from every stored version of
* `(namespace, name)`. Returns `null` when no versions exist.
*/
public async getVersionedSchema(
namespace: string,
name: string
): Promise<VersionedSchema | null> {
const candidates = await this.storage.listSchemas(namespace);
const matching = candidates.filter((s) => s.metadata.name === name);
if (matching.length === 0) {
return null;
}
matching.sort((a, b) => a.metadata.version - b.metadata.version);
const first = matching[0] as SchemaRegistryEntry;
const versioned = new VersionedSchema(first.schema, {
name: first.metadata.name,
namespace: first.metadata.namespace,
...(first.metadata.description !== undefined
? { description: first.metadata.description }
: {}),
});
for (let i = 1; i < matching.length; i++) {
const m = matching[i] as SchemaRegistryEntry;
versioned.addVersion(m.schema, m.metadata.description);
}
return versioned;
}
public async listSchemas(
namespace?: string,
tag?: string
): Promise<SchemaRegistryEntry[]> {
return this.storage.listSchemas(namespace, tag);
}
public async deleteSchema(
namespace: string,
name: string,
version?: number
): Promise<void> {
return this.storage.deleteSchema(namespace, name, version);
}
}

View File

@@ -0,0 +1,80 @@
/**
* Public types for the bitpacker-schema registry.
*
* Contains:
*
* - `SchemaMetadata` — describes a stored schema entry (namespace, name,
* version, timestamps, optional description/tags/owner).
* - `SchemaRegistryEntry` — wire-shape pair of `metadata` + `schema`.
* - `SchemaRegistryStorage` — the storage adapter contract every
* registry backend implements (in-memory, localStorage, future
* server-side adapters).
* - `KeyValueStringStore` — minimal "browser localStorage-shaped" key-
* value contract used by the localStorage adapter and easy to stub
* in tests.
*/
import type { BitPackerSchema } from "@suaveplan/bitpacker";
/**
* Persisted metadata for a schema entry.
*
* `namespace + name + version` together form the registry key. Genesis
* uses `Date` for `createdAt`/`updatedAt` for compatibility with the
* upstream wire format; tests inject a `Clock` so the timestamps are
* deterministic.
*/
export interface SchemaMetadata {
namespace: string;
name: string;
version: number;
createdAt: Date;
updatedAt: Date;
description?: string | undefined;
tags?: string[] | undefined;
owner?: string | undefined;
}
/**
* One row in the registry — pairs schema bytes with the descriptive
* metadata used for lookup and listing.
*/
export interface SchemaRegistryEntry {
metadata: SchemaMetadata;
schema: BitPackerSchema;
}
/**
* Storage adapter contract. Backends implement these four async methods
* and the `SchemaRegistry` class composes them into a richer API.
*/
export interface SchemaRegistryStorage {
saveSchema(entry: SchemaRegistryEntry): Promise<void>;
loadSchema(
namespace: string,
name: string,
version?: number
): Promise<SchemaRegistryEntry | null>;
listSchemas(
namespace?: string,
tag?: string
): Promise<SchemaRegistryEntry[]>;
deleteSchema(
namespace: string,
name: string,
version?: number
): Promise<void>;
}
/**
* Minimal browser-localStorage-shaped key-value contract.
*
* The localStorage adapter accepts any conformant store via dependency
* injection. Production code passes `globalThis.localStorage`; tests
* pass an in-memory stub.
*/
export interface KeyValueStringStore {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem?(key: string): void;
}

View File

@@ -0,0 +1,90 @@
# InMemorySchemaRegistryStorage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`InMemorySchemaRegistryStorage` is the in-process `SchemaRegistryStorage` adapter. It stores schema rows in a private array and is the default backend for `SchemaRegistry` instances created without an explicit storage adapter — useful for tests, ephemeral registries, and any scenario where persistence is not required.
This adapter exists primarily so the `SchemaRegistry` class can have a single storage-shape contract regardless of where the schemas actually live. Tests can build a registry over this adapter without needing browser polyfills or filesystem mocks; production servers that wire to a real persistence layer (Redis, Postgres, S3) implement the same `SchemaRegistryStorage` interface.
## Features
- Implements `SchemaRegistryStorage` exactly: `saveSchema`, `loadSchema`, `listSchemas`, `deleteSchema`.
- Defensive cloning on save/load so callers can't mutate stored entries by holding the returned reference.
- Latest-version resolution when `loadSchema` is called without a version.
- Tag and namespace filtering on `listSchemas`.
- Delete-all-versions semantics when `deleteSchema` is called without a version.
## Usage: Basic Example
```ts
import { InMemorySchemaRegistryStorage } from "@suaveplan/bitpacker-schema";
const storage = new InMemorySchemaRegistryStorage();
await storage.saveSchema({
metadata: {
namespace: "events",
name: "user-created",
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
},
schema: { id: "uuid", name: "-32" },
});
const loaded = await storage.loadSchema("events", "user-created");
```
## Usage: Advanced Example
```ts
import {
InMemorySchemaRegistryStorage,
SchemaRegistry,
} from "@suaveplan/bitpacker-schema";
const storage = new InMemorySchemaRegistryStorage();
const registry = new SchemaRegistry(storage);
await registry.registerSchema(
{ id: "uuid", payload: "blob" },
{
namespace: "events",
name: "user-updated",
tags: ["v2", "stable"],
}
);
const tagged = await registry.listSchemas(undefined, "stable");
```
## API Reference
### `new InMemorySchemaRegistryStorage()`
No arguments. Initialises an empty in-process schema array.
### `saveSchema(entry)`
Inserts `entry` if no `(namespace, name, version)` match exists; otherwise overwrites the existing row. Defensive-clones the entry.
### `loadSchema(namespace, name, version?)`
Returns the entry matching `(namespace, name, version)`. When `version` is omitted, returns the entry with the highest `metadata.version`. Returns `null` if no row matches.
### `listSchemas(namespace?, tag?)`
Returns all entries, optionally filtered by namespace and/or tag. Defensive-clones each entry so the caller can't mutate the store.
### `deleteSchema(namespace, name, version?)`
Removes the row matching `(namespace, name, version)` if `version` is supplied; removes ALL rows matching `(namespace, name)` otherwise.
## Implementation Notes
The store is a plain `SchemaRegistryEntry[]` rather than a nested map. Schemas are typically registered O(1)-O(10) per namespace, lookup is per-call rather than per-tick, and the linear-scan cost is dominated by the JS object dispatch. A nested map would add complexity without a measurable benefit at this size class.
`cloneEntry` is shallow on `metadata` (spreads the object) and identity on `schema` (BitPackerSchema is treated as opaque-by-reference because cloning it would require serialising and re-parsing the entire shape). Callers MUST NOT mutate schema objects after registering them — but if they do, the registry's stored reference points to the same object, by design.
Latest-version resolution uses a single linear pass rather than `Math.max(...versions)` because the array might be very long for high-traffic registries and `Math.max(...arr)` allocates a temporary spread for every call.

View File

@@ -0,0 +1,139 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { SchemaRegistryEntry } from "../../registry.types.js";
import { InMemorySchemaRegistryStorage } from "./in-memory.js";
function entry(
namespace: string,
name: string,
version: number,
tags?: string[]
): SchemaRegistryEntry {
return {
metadata: {
namespace,
name,
version,
createdAt: new Date(0),
updatedAt: new Date(0),
...(tags !== undefined ? { tags } : {}),
},
schema: { id: 1 },
};
}
describe("InMemorySchemaRegistryStorage", () => {
it("saves and loads a single entry", { timeout: 5000 }, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("ns", "x", 1));
const out = await s.loadSchema("ns", "x");
expect(out?.metadata.version).toBe(1);
});
it("loadSchema with explicit version", { timeout: 5000 }, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("ns", "x", 1));
await s.saveSchema(entry("ns", "x", 2));
const v1 = await s.loadSchema("ns", "x", 1);
expect(v1?.metadata.version).toBe(1);
});
it("loadSchema without version returns latest", {
timeout: 5000,
}, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("ns", "x", 1));
await s.saveSchema(entry("ns", "x", 5));
await s.saveSchema(entry("ns", "x", 3));
const out = await s.loadSchema("ns", "x");
expect(out?.metadata.version).toBe(5);
});
it("loadSchema returns null for unknown namespace+name", {
timeout: 5000,
}, async () => {
const s = new InMemorySchemaRegistryStorage();
const out = await s.loadSchema("missing", "x");
expect(out).toBeNull();
});
it("loadSchema returns null when version is missing", {
timeout: 5000,
}, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("ns", "x", 1));
const out = await s.loadSchema("ns", "x", 99);
expect(out).toBeNull();
});
it("saveSchema replaces existing entry at the same version", {
timeout: 5000,
}, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("ns", "x", 1, ["before"]));
await s.saveSchema(entry("ns", "x", 1, ["after"]));
const out = await s.loadSchema("ns", "x", 1);
expect(out?.metadata.tags).toEqual(["after"]);
});
it("listSchemas returns all entries by default", {
timeout: 5000,
}, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("a", "x", 1));
await s.saveSchema(entry("b", "y", 1));
const all = await s.listSchemas();
expect(all).toHaveLength(2);
});
it("listSchemas filters by namespace", { timeout: 5000 }, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("a", "x", 1));
await s.saveSchema(entry("b", "y", 1));
const out = await s.listSchemas("a");
expect(out).toHaveLength(1);
expect(out[0]?.metadata.namespace).toBe("a");
});
it("listSchemas filters by tag", { timeout: 5000 }, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("a", "x", 1, ["stable"]));
await s.saveSchema(entry("a", "y", 1, ["beta"]));
const out = await s.listSchemas(undefined, "stable");
expect(out).toHaveLength(1);
expect(out[0]?.metadata.name).toBe("x");
});
it("listSchemas defensive-clones entries", {
timeout: 5000,
}, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("a", "x", 1));
const out = await s.listSchemas();
const first = out[0];
if (first === undefined) throw new Error("expected entry");
first.metadata.namespace = "MUTATED";
const fresh = await s.listSchemas();
expect(fresh[0]?.metadata.namespace).toBe("a");
});
it("deleteSchema removes specific version only", {
timeout: 5000,
}, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("ns", "x", 1));
await s.saveSchema(entry("ns", "x", 2));
await s.deleteSchema("ns", "x", 1);
expect(await s.loadSchema("ns", "x", 1)).toBeNull();
expect(await s.loadSchema("ns", "x", 2)).not.toBeNull();
});
it("deleteSchema without version removes all versions", {
timeout: 5000,
}, async () => {
const s = new InMemorySchemaRegistryStorage();
await s.saveSchema(entry("ns", "x", 1));
await s.saveSchema(entry("ns", "x", 2));
await s.deleteSchema("ns", "x");
expect(await s.loadSchema("ns", "x")).toBeNull();
});
});

View File

@@ -0,0 +1,107 @@
/**
* `InMemorySchemaRegistryStorage` — in-process schema registry adapter.
*
* Stores `SchemaRegistryEntry` rows in a private array keyed by
* `namespace + name + version`. Entirely synchronous internally but
* exposes the async `SchemaRegistryStorage` contract so it composes with
* the persistent adapters without contract drift.
*/
import type {
SchemaRegistryEntry,
SchemaRegistryStorage,
} from "../../registry.types.js";
export class InMemorySchemaRegistryStorage implements SchemaRegistryStorage {
private schemas: SchemaRegistryEntry[] = [];
public async saveSchema(entry: SchemaRegistryEntry): Promise<void> {
const i = this.schemas.findIndex(
(s) =>
s.metadata.namespace === entry.metadata.namespace &&
s.metadata.name === entry.metadata.name &&
s.metadata.version === entry.metadata.version
);
if (i >= 0) {
this.schemas[i] = cloneEntry(entry);
} else {
this.schemas.push(cloneEntry(entry));
}
}
public async loadSchema(
namespace: string,
name: string,
version?: number
): Promise<SchemaRegistryEntry | null> {
const matching = this.schemas.filter(
(s) =>
s.metadata.namespace === namespace && s.metadata.name === name
);
if (matching.length === 0) {
return null;
}
if (version !== undefined) {
return matching.find((s) => s.metadata.version === version) ?? null;
}
return latestByVersion(matching);
}
public async listSchemas(
namespace?: string,
tag?: string
): Promise<SchemaRegistryEntry[]> {
let result = [...this.schemas];
if (namespace !== undefined) {
result = result.filter((s) => s.metadata.namespace === namespace);
}
if (tag !== undefined) {
result = result.filter((s) => s.metadata.tags?.includes(tag));
}
return result.map(cloneEntry);
}
public async deleteSchema(
namespace: string,
name: string,
version?: number
): Promise<void> {
if (version !== undefined) {
this.schemas = this.schemas.filter(
(s) =>
!(
s.metadata.namespace === namespace &&
s.metadata.name === name &&
s.metadata.version === version
)
);
return;
}
this.schemas = this.schemas.filter(
(s) =>
!(
s.metadata.namespace === namespace &&
s.metadata.name === name
)
);
}
}
function cloneEntry(entry: SchemaRegistryEntry): SchemaRegistryEntry {
return {
metadata: { ...entry.metadata },
schema: entry.schema,
};
}
function latestByVersion(
entries: readonly SchemaRegistryEntry[]
): SchemaRegistryEntry | null {
let best: SchemaRegistryEntry | null = null;
for (const e of entries) {
if (best === null || e.metadata.version > best.metadata.version) {
best = e;
}
}
return best;
}

View File

@@ -0,0 +1,92 @@
# LocalStorageSchemaRegistryStorage
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`LocalStorageSchemaRegistryStorage` is the browser-`localStorage`-backed `SchemaRegistryStorage` adapter. It persists registry rows under a single key (default `"bitpacker-schema-registry"`) as a JSON array, restoring `Date` instances for `createdAt`/`updatedAt` on read.
The adapter accepts any `KeyValueStringStore`-conformant backend via dependency injection so unit tests don't need a real `localStorage` polyfill — pass an in-memory stub that implements `getItem(key) → string | null` and `setItem(key, value): void`. Production code passes `globalThis.localStorage` (the default).
## Features
- Exact `SchemaRegistryStorage` contract: `saveSchema`, `loadSchema`, `listSchemas`, `deleteSchema`.
- Dependency-injectable store — works with `globalThis.localStorage`, `sessionStorage`, an in-memory stub, or any future Web-storage-shaped backend.
- Date round-tripping: `createdAt`/`updatedAt` are restored from ISO strings to `Date` instances on read.
- Failure paths raise `BitpackerError` with codes (`SCHEMA_STORAGE_PARSE_FAILED`, `SCHEMA_STORAGE_WRITE_FAILED`) — no silent `console.error`'ing as upstream did.
- Missing-store fallback returns an empty list rather than throwing, so the same code can run in environments without any web storage at all.
## Usage: Basic Example
```ts
import { LocalStorageSchemaRegistryStorage } from "@suaveplan/bitpacker-schema";
// In a browser, defaults to globalThis.localStorage.
const storage = new LocalStorageSchemaRegistryStorage();
await storage.saveSchema({
metadata: {
namespace: "events",
name: "user-created",
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
},
schema: { id: "uuid" },
});
```
## Usage: Advanced Example
```ts
import {
LocalStorageSchemaRegistryStorage,
type KeyValueStringStore,
} from "@suaveplan/bitpacker-schema";
// Swap in a stub for tests — no real localStorage needed.
function makeStubStore(): KeyValueStringStore {
const m = new Map<string, string>();
return {
getItem: (k) => m.get(k) ?? null,
setItem: (k, v) => m.set(k, v),
removeItem: (k) => m.delete(k),
};
}
const storage = new LocalStorageSchemaRegistryStorage({
store: makeStubStore(),
storageKey: "test:registry",
});
```
## API Reference
### `new LocalStorageSchemaRegistryStorage(options?)`
`options.store?` — any `KeyValueStringStore`. Defaults to `globalThis.localStorage` (or `undefined` if no localStorage is present).
`options.storageKey?` — the key to read/write. Default `"bitpacker-schema-registry"`.
### `saveSchema(entry)`
Reads the current array, inserts or replaces the row matching `(namespace, name, version)`, writes the array back. Throws `BitpackerError(SCHEMA_STORAGE_WRITE_FAILED)` if the underlying store rejects the write (typical cause: quota exceeded).
### `loadSchema(namespace, name, version?)`
Reads the current array and returns the matching row. When `version` is omitted, returns the row with the highest `metadata.version`. Returns `null` if no row matches.
### `listSchemas(namespace?, tag?)`
Reads the current array and applies optional namespace/tag filters.
### `deleteSchema(namespace, name, version?)`
Filters out the matching row(s) and writes the array back. Removes ALL versions of `(namespace, name)` if `version` is omitted.
## Implementation Notes
The adapter writes the full array on every save. For registries with hundreds of schemas this becomes O(n) per write, but at typical sizes (10-50 schemas per app) the cost is dominated by the JSON encode/decode rather than the array copy. If a deployment grows past that, switch to a chunk-keyed adapter (one localStorage key per `namespace + name`) — the `SchemaRegistryStorage` contract is the only thing that needs to stay the same.
`reviveEntry` casts the parsed metadata through an intermediate shape and rebuilds `Date` instances from the ISO strings. We do not run a Zod validator on the parsed rows — the adapter trusts its own write format (the only writer in production is `writeAll` from this same class). If a deployment needs hardened deserialisation against tampering, wrap the adapter with a validating decorator.
The default-store resolution looks at `globalThis.localStorage` rather than `window.localStorage` so the adapter compiles unchanged in Node.js, Bun, Web Workers, and Service Workers — anywhere with a global `localStorage` shape.

View File

@@ -0,0 +1,237 @@
import { BITPACKER_CODES, type BitpackerError } from "@suaveplan/bitpacker";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
KeyValueStringStore,
SchemaRegistryEntry,
} from "../../registry.types.js";
import { LocalStorageSchemaRegistryStorage } from "./local-storage.js";
function makeStub(initial?: Record<string, string>): KeyValueStringStore {
const m = new Map<string, string>(Object.entries(initial ?? {}));
return {
getItem: (k) => m.get(k) ?? null,
setItem: (k, v) => m.set(k, v),
removeItem: (k) => m.delete(k),
};
}
function entry(
namespace: string,
name: string,
version: number,
tags?: string[]
): SchemaRegistryEntry {
return {
metadata: {
namespace,
name,
version,
createdAt: new Date(0),
updatedAt: new Date(0),
...(tags !== undefined ? { tags } : {}),
},
schema: { id: 1 },
};
}
describe("LocalStorageSchemaRegistryStorage — happy paths", () => {
it("save then load round-trips a single entry", {
timeout: 5000,
}, async () => {
const s = new LocalStorageSchemaRegistryStorage({ store: makeStub() });
await s.saveSchema(entry("ns", "x", 1));
const out = await s.loadSchema("ns", "x");
expect(out?.metadata.version).toBe(1);
});
it("load defaults to latest version when version arg omitted", {
timeout: 5000,
}, async () => {
const s = new LocalStorageSchemaRegistryStorage({ store: makeStub() });
await s.saveSchema(entry("ns", "x", 1));
await s.saveSchema(entry("ns", "x", 5));
await s.saveSchema(entry("ns", "x", 3));
const out = await s.loadSchema("ns", "x");
expect(out?.metadata.version).toBe(5);
});
it("loadSchema returns null when matching version missing", {
timeout: 5000,
}, async () => {
const s = new LocalStorageSchemaRegistryStorage({ store: makeStub() });
await s.saveSchema(entry("ns", "x", 1));
const out = await s.loadSchema("ns", "x", 99);
expect(out).toBeNull();
});
it("loadSchema returns null when no rows match", {
timeout: 5000,
}, async () => {
const s = new LocalStorageSchemaRegistryStorage({ store: makeStub() });
const out = await s.loadSchema("missing", "x");
expect(out).toBeNull();
});
it("listSchemas filters by namespace and tag", {
timeout: 5000,
}, async () => {
const s = new LocalStorageSchemaRegistryStorage({ store: makeStub() });
await s.saveSchema(entry("a", "x", 1, ["stable"]));
await s.saveSchema(entry("a", "y", 1, ["beta"]));
await s.saveSchema(entry("b", "z", 1, ["stable"]));
const stable = await s.listSchemas(undefined, "stable");
expect(stable).toHaveLength(2);
const a = await s.listSchemas("a");
expect(a).toHaveLength(2);
});
it("saveSchema replaces existing rows at same version", {
timeout: 5000,
}, async () => {
const s = new LocalStorageSchemaRegistryStorage({ store: makeStub() });
await s.saveSchema(entry("ns", "x", 1, ["before"]));
await s.saveSchema(entry("ns", "x", 1, ["after"]));
const out = await s.loadSchema("ns", "x", 1);
expect(out?.metadata.tags).toEqual(["after"]);
});
it("deleteSchema with version removes only that version", {
timeout: 5000,
}, async () => {
const s = new LocalStorageSchemaRegistryStorage({ store: makeStub() });
await s.saveSchema(entry("ns", "x", 1));
await s.saveSchema(entry("ns", "x", 2));
await s.deleteSchema("ns", "x", 1);
expect(await s.loadSchema("ns", "x", 1)).toBeNull();
expect(await s.loadSchema("ns", "x", 2)).not.toBeNull();
});
it("deleteSchema without version removes all versions", {
timeout: 5000,
}, async () => {
const s = new LocalStorageSchemaRegistryStorage({ store: makeStub() });
await s.saveSchema(entry("ns", "x", 1));
await s.saveSchema(entry("ns", "x", 2));
await s.deleteSchema("ns", "x");
expect(await s.loadSchema("ns", "x")).toBeNull();
});
it("revives Date instances from ISO strings on read", {
timeout: 5000,
}, async () => {
const stub = makeStub();
const s1 = new LocalStorageSchemaRegistryStorage({ store: stub });
await s1.saveSchema(entry("ns", "x", 1));
// Re-read via a fresh adapter sharing the stub — forces JSON path.
const s2 = new LocalStorageSchemaRegistryStorage({ store: stub });
const out = await s2.loadSchema("ns", "x");
expect(out?.metadata.createdAt).toBeInstanceOf(Date);
expect(out?.metadata.updatedAt).toBeInstanceOf(Date);
});
});
describe("LocalStorageSchemaRegistryStorage — failure paths", () => {
it("returns empty list when store is undefined", {
timeout: 5000,
}, async () => {
const s = new LocalStorageSchemaRegistryStorage({
store: undefined as unknown as KeyValueStringStore,
});
const out = await s.listSchemas();
expect(out).toEqual([]);
});
it("saveSchema is a no-op when store is undefined", {
timeout: 5000,
}, async () => {
const s = new LocalStorageSchemaRegistryStorage({
store: undefined as unknown as KeyValueStringStore,
});
await s.saveSchema(entry("ns", "x", 1));
const out = await s.loadSchema("ns", "x");
expect(out).toBeNull();
});
it("throws SCHEMA_STORAGE_PARSE_FAILED on invalid JSON", {
timeout: 5000,
}, async () => {
const stub = makeStub({ "bitpacker-schema-registry": "{not-json" });
const s = new LocalStorageSchemaRegistryStorage({ store: stub });
try {
await s.listSchemas();
throw new Error("expected throw");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.SCHEMA_STORAGE_PARSE_FAILED
);
}
});
it("throws SCHEMA_STORAGE_PARSE_FAILED when payload is not an array", {
timeout: 5000,
}, async () => {
const stub = makeStub({
"bitpacker-schema-registry": JSON.stringify({ wrong: "shape" }),
});
const s = new LocalStorageSchemaRegistryStorage({ store: stub });
try {
await s.listSchemas();
throw new Error("expected throw");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.SCHEMA_STORAGE_PARSE_FAILED
);
}
});
it("throws SCHEMA_STORAGE_WRITE_FAILED when setItem rejects", {
timeout: 5000,
}, async () => {
const angry: KeyValueStringStore = {
getItem: () => null,
setItem: () => {
throw new Error("quota exceeded");
},
};
const s = new LocalStorageSchemaRegistryStorage({ store: angry });
try {
await s.saveSchema(entry("ns", "x", 1));
throw new Error("expected throw");
} catch (e) {
expect((e as BitpackerError).code).toBe(
BITPACKER_CODES.SCHEMA_STORAGE_WRITE_FAILED
);
}
});
it("uses custom storageKey when supplied", {
timeout: 5000,
}, async () => {
const stub = makeStub();
const s = new LocalStorageSchemaRegistryStorage({
store: stub,
storageKey: "custom-key",
});
await s.saveSchema(entry("ns", "x", 1));
expect(stub.getItem("custom-key")).not.toBeNull();
expect(stub.getItem("bitpacker-schema-registry")).toBeNull();
});
it("falls back to globalThis.localStorage when store omitted", {
timeout: 5000,
}, async () => {
const g = globalThis as unknown as {
localStorage?: KeyValueStringStore;
};
const saved = g.localStorage;
g.localStorage = makeStub();
try {
const s = new LocalStorageSchemaRegistryStorage();
await s.saveSchema(entry("ns", "x", 1));
const out = await s.loadSchema("ns", "x");
expect(out?.metadata.version).toBe(1);
} finally {
g.localStorage = saved;
}
});
});

View File

@@ -0,0 +1,211 @@
/**
* `LocalStorageSchemaRegistryStorage` — browser-localStorage-backed
* `SchemaRegistryStorage` adapter.
*
* Persists registry rows under a single key (default
* `"bitpacker-schema-registry"`) as a JSON array. The adapter accepts
* any `KeyValueStringStore`-conformant backend so it can be unit-tested
* with an in-memory stub instead of requiring a real `localStorage`
* polyfill.
*
* Date round-trips: `metadata.createdAt` and `metadata.updatedAt` go
* through `JSON.stringify` as ISO strings. The adapter restores them to
* `Date` instances on read so consumers always see `Date` objects.
*
* Genesis port hardening:
*
* - Failures throw `BitpackerError` with stable codes
* (`SCHEMA_STORAGE_PARSE_FAILED`, `SCHEMA_STORAGE_WRITE_FAILED`)
* rather than `console.error`'ing and silently swallowing data loss.
* - The store argument is injectable so tests don't need to monkey-
* patch `globalThis.localStorage` to exercise the adapter.
* - Missing-store fallback returns an empty list rather than crashing,
* matching the upstream behaviour but without the noisy console
* stack trace.
*/
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
import type {
KeyValueStringStore,
SchemaRegistryEntry,
SchemaRegistryStorage,
} from "../../registry.types.js";
const DEFAULT_STORAGE_KEY = "bitpacker-schema-registry";
export interface LocalStorageSchemaRegistryStorageOptions {
/** Underlying key-value store. Defaults to `globalThis.localStorage`. */
readonly store?: KeyValueStringStore;
/** Storage key. Defaults to `"bitpacker-schema-registry"`. */
readonly storageKey?: string;
}
export class LocalStorageSchemaRegistryStorage
implements SchemaRegistryStorage
{
private readonly storageKey: string;
private readonly store: KeyValueStringStore | undefined;
public constructor(options: LocalStorageSchemaRegistryStorageOptions = {}) {
this.storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
this.store = options.store ?? resolveDefaultStore();
}
public async saveSchema(entry: SchemaRegistryEntry): Promise<void> {
const all = await this.readAll();
const i = all.findIndex(
(s) =>
s.metadata.namespace === entry.metadata.namespace &&
s.metadata.name === entry.metadata.name &&
s.metadata.version === entry.metadata.version
);
if (i >= 0) {
all[i] = entry;
} else {
all.push(entry);
}
await this.writeAll(all);
}
public async loadSchema(
namespace: string,
name: string,
version?: number
): Promise<SchemaRegistryEntry | null> {
const all = await this.readAll();
const matching = all.filter(
(s) =>
s.metadata.namespace === namespace && s.metadata.name === name
);
if (matching.length === 0) {
return null;
}
if (version !== undefined) {
return matching.find((s) => s.metadata.version === version) ?? null;
}
let best = matching[0] as SchemaRegistryEntry;
for (const e of matching) {
if (e.metadata.version > best.metadata.version) {
best = e;
}
}
return best;
}
public async listSchemas(
namespace?: string,
tag?: string
): Promise<SchemaRegistryEntry[]> {
let all = await this.readAll();
if (namespace !== undefined) {
all = all.filter((s) => s.metadata.namespace === namespace);
}
if (tag !== undefined) {
all = all.filter((s) => s.metadata.tags?.includes(tag));
}
return all;
}
public async deleteSchema(
namespace: string,
name: string,
version?: number
): Promise<void> {
const all = await this.readAll();
let next: SchemaRegistryEntry[];
if (version !== undefined) {
next = all.filter(
(s) =>
!(
s.metadata.namespace === namespace &&
s.metadata.name === name &&
s.metadata.version === version
)
);
} else {
next = all.filter(
(s) =>
!(
s.metadata.namespace === namespace &&
s.metadata.name === name
)
);
}
await this.writeAll(next);
}
private async readAll(): Promise<SchemaRegistryEntry[]> {
if (this.store === undefined) {
return [];
}
const raw = this.store.getItem(this.storageKey);
if (raw === null) {
return [];
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (cause) {
throw new BitpackerError(
BITPACKER_CODES.SCHEMA_STORAGE_PARSE_FAILED,
`LocalStorage payload at "${this.storageKey}" is not valid JSON`,
{
cause: cause as Error,
context: { storageKey: this.storageKey },
}
);
}
if (!Array.isArray(parsed)) {
throw new BitpackerError(
BITPACKER_CODES.SCHEMA_STORAGE_PARSE_FAILED,
`LocalStorage payload at "${this.storageKey}" is not an array`,
{ context: { storageKey: this.storageKey } }
);
}
return parsed.map(reviveEntry);
}
private async writeAll(entries: SchemaRegistryEntry[]): Promise<void> {
if (this.store === undefined) {
return;
}
try {
this.store.setItem(this.storageKey, JSON.stringify(entries));
} catch (cause) {
throw new BitpackerError(
BITPACKER_CODES.SCHEMA_STORAGE_WRITE_FAILED,
`LocalStorage write to "${this.storageKey}" failed`,
{
cause: cause as Error,
context: { storageKey: this.storageKey },
}
);
}
}
}
function resolveDefaultStore(): KeyValueStringStore | undefined {
const g = globalThis as unknown as {
localStorage?: KeyValueStringStore;
};
return g.localStorage;
}
function reviveEntry(raw: unknown): SchemaRegistryEntry {
const r = raw as {
metadata: {
createdAt: string | Date;
updatedAt: string | Date;
[k: string]: unknown;
};
schema: unknown;
};
return {
metadata: {
...(r.metadata as unknown as SchemaRegistryEntry["metadata"]),
createdAt: new Date(r.metadata.createdAt),
updatedAt: new Date(r.metadata.updatedAt),
},
schema: r.schema as SchemaRegistryEntry["schema"],
};
}

View File

@@ -0,0 +1,169 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SchemaMatchingService } from "../index.js";
describe("SchemaMatchingService", () => {
const service = new SchemaMatchingService();
describe("match with optional schema", () => {
it("handles undefined optional data", () => {
const result = service.match({ _optional: 255 }, undefined);
expect(result.additions).toEqual([]);
expect(result.subtractions).toEqual([]);
expect(result.mismatches).toEqual([]);
});
it("handles present optional data", () => {
const result = service.match({ _optional: 255 }, 42);
expect(result.mismatches).toEqual([]);
});
it("reports mismatch for invalid optional data", () => {
const result = service.match({ _optional: 255 }, "hello");
expect(result.mismatches).toEqual(["(root)"]);
});
});
describe("match with _bp schema", () => {
it("traverses _bp wrapper", () => {
const result = service.match(
{ _bp: { schema: { x: 255 } } },
{ x: 42 }
);
expect(result.mismatches).toEqual([]);
});
});
describe("match with object schema", () => {
it("matches a simple object", () => {
const result = service.match(
{ x: 255, y: "-8" },
{ x: 42, y: "hello" }
);
expect(result.additions).toEqual([]);
expect(result.subtractions).toEqual([]);
expect(result.mismatches).toEqual([]);
});
it("detects additions when data has extra keys", () => {
const result = service.match({ x: 255 }, { x: 42, y: "extra" });
expect(result.additions).toEqual(["y"]);
expect(result.subtractions).toEqual([]);
});
it("detects subtractions when data is missing keys", () => {
const result = service.match({ x: 255, y: "-8" }, { x: 42 });
expect(result.additions).toEqual([]);
expect(result.subtractions).toEqual(["y"]);
});
it("detects mismatches for non-object data", () => {
const result = service.match({ x: 255 }, "not an object");
expect(result.subtractions).toEqual(["(root)"]);
});
it("detects nested additions and subtractions", () => {
const result = service.match(
{ a: { b: 255 } },
{ a: { b: 1, c: 2 } }
);
expect(result.additions).toEqual(["a.c"]);
});
});
describe("match with array schema", () => {
it("matches a valid array", () => {
const result = service.match([255], [1, 2, 3]);
expect(result.mismatches).toEqual([]);
});
it("detects mismatch for non-array data", () => {
const result = service.match([255], "not array");
expect(result.mismatches).toEqual(["(root)"]);
});
it("detects additions when array exceeds max length", () => {
const result = service.match([2, 255], [1, 2, 3, 4]);
expect(result.additions).toEqual(["[2]", "[3]"]);
});
it("detects mismatches for invalid array elements", () => {
const result = service.match([255], [1, "bad", 3]);
expect(result.mismatches).toEqual(["[1]"]);
});
});
describe("match with primitive schema", () => {
it("detects mismatch for invalid primitive", () => {
const result = service.match(255, "not a number");
expect(result.mismatches).toEqual(["(root)"]);
});
});
describe("matchPrimitiveValue", () => {
it("matches null", () => {
expect(service.matchPrimitiveValue(null, null)).toBe(true);
expect(service.matchPrimitiveValue(null, 0)).toBe(false);
});
it("matches boolean", () => {
expect(service.matchPrimitiveValue(true, true)).toBe(true);
expect(service.matchPrimitiveValue(true, false)).toBe(false);
expect(service.matchPrimitiveValue(true, 1)).toBe(false);
});
it("matches integer number", () => {
expect(service.matchPrimitiveValue(255, 42)).toBe(true);
expect(service.matchPrimitiveValue(255, 3.14)).toBe(false);
expect(service.matchPrimitiveValue(255, "42")).toBe(false);
});
it("matches float schema", () => {
expect(service.matchPrimitiveValue("f32", 3.14)).toBe(true);
expect(service.matchPrimitiveValue("f32", "3.14")).toBe(true);
expect(service.matchPrimitiveValue("f32", "not a number")).toBe(
false
);
});
it("matches uuid schema", () => {
expect(
service.matchPrimitiveValue(
"uuid",
"550e8400-e29b-41d4-a716-446655440000"
)
).toBe(true);
expect(service.matchPrimitiveValue("uuid", "not-a-uuid")).toBe(
false
);
expect(service.matchPrimitiveValue("uuid", 42)).toBe(false);
});
it("matches string schema", () => {
expect(service.matchPrimitiveValue("-16", "hello")).toBe(true);
expect(service.matchPrimitiveValue("-16", 42)).toBe(false);
});
it("matches array schema", () => {
expect(service.matchPrimitiveValue([255], [1, 2])).toBe(true);
expect(service.matchPrimitiveValue([255], "not array")).toBe(false);
});
it("matches object schema", () => {
expect(service.matchPrimitiveValue({ x: 255 }, { x: 1 })).toBe(
true
);
expect(service.matchPrimitiveValue({ x: 255 }, "not object")).toBe(
false
);
});
it("returns false for unsupported schema", () => {
expect(
service.matchPrimitiveValue(
BigInt(1) as unknown as BitPackerSchema,
1
)
).toBe(false);
});
});
});

View File

@@ -0,0 +1,343 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
applyMigrationPlan,
createMigrationFunction,
createMigrationPlan,
MigrationPlanBuilder,
MigrationStrategy,
} from "../index.js";
describe("MigrationStrategy enum", () => {
it("has correct values", () => {
expect(MigrationStrategy.ADD_FIELD).toBe("ADD_FIELD");
expect(MigrationStrategy.REMOVE_FIELD).toBe("REMOVE_FIELD");
expect(MigrationStrategy.RENAME_FIELD).toBe("RENAME_FIELD");
expect(MigrationStrategy.CHANGE_FIELD_TYPE).toBe("CHANGE_FIELD_TYPE");
expect(MigrationStrategy.MOVE_FIELD).toBe("MOVE_FIELD");
expect(MigrationStrategy.SPLIT_FIELD).toBe("SPLIT_FIELD");
expect(MigrationStrategy.MERGE_FIELDS).toBe("MERGE_FIELDS");
expect(MigrationStrategy.CUSTOM).toBe("CUSTOM");
});
});
describe("applyMigrationPlan", () => {
it("applies ADD_FIELD operation", () => {
const plan = createMigrationPlan(1, 2)
.addField("newField", "default")
.build();
const result = applyMigrationPlan({ existing: true }, plan);
expect(result).toEqual({ existing: true, newField: "default" });
});
it("applies REMOVE_FIELD operation", () => {
const plan = createMigrationPlan(1, 2).removeField("oldField").build();
const result = applyMigrationPlan(
{ oldField: "gone", keep: true },
plan
);
expect(result).toEqual({ keep: true });
});
it("applies RENAME_FIELD operation", () => {
const plan = createMigrationPlan(1, 2)
.renameField("oldName", "newName")
.build();
const result = applyMigrationPlan({ oldName: "value" }, plan);
expect(result).toEqual({ newName: "value" });
});
it("applies RENAME_FIELD on nested path", () => {
const plan = createMigrationPlan(1, 2)
.renameField("a.oldName", "newName")
.build();
const result = applyMigrationPlan({ a: { oldName: "value" } }, plan);
expect(result).toEqual({ a: { newName: "value" } });
});
it("applies RENAME_FIELD with no oldName (root path)", () => {
const plan = createMigrationPlan(1, 2)
.renameField("", "newName")
.build();
const result = applyMigrationPlan({ oldName: "value" }, plan);
expect(result).toEqual({ oldName: "value" });
});
it("applies RENAME_FIELD when value is undefined", () => {
const plan = createMigrationPlan(1, 2)
.renameField("missing", "newName")
.build();
const result = applyMigrationPlan({ other: "value" }, plan);
expect(result).toEqual({ other: "value" });
});
it("applies CHANGE_FIELD_TYPE operation", () => {
const plan = createMigrationPlan(1, 2)
.changeFieldType("count", (v: number) => String(v))
.build();
const result = applyMigrationPlan({ count: 42 }, plan);
expect(result).toEqual({ count: "42" });
});
it("applies CHANGE_FIELD_TYPE when value is undefined", () => {
const plan = createMigrationPlan(1, 2)
.changeFieldType("missing", (v: unknown) => v)
.build();
const result = applyMigrationPlan({ other: "value" }, plan);
expect(result).toEqual({ other: "value" });
});
it("applies MOVE_FIELD operation", () => {
const plan = createMigrationPlan(1, 2)
.moveField("oldPath", "newPath")
.build();
const result = applyMigrationPlan({ oldPath: "value" }, plan);
expect(result).toEqual({ newPath: "value" });
});
it("applies MOVE_FIELD when value is undefined", () => {
const plan = createMigrationPlan(1, 2)
.moveField("missing", "newPath")
.build();
const result = applyMigrationPlan({ other: "value" }, plan);
expect(result).toEqual({ other: "value" });
});
it("applies SPLIT_FIELD operation", () => {
const plan = createMigrationPlan(1, 2)
.splitField("fullName", {
firstName: (v: string) => v.split(" ")[0]!,
lastName: (v: string) => v.split(" ")[1]!,
})
.build();
const result = applyMigrationPlan({ fullName: "John Doe" }, plan);
expect(result).toEqual({ firstName: "John", lastName: "Doe" });
});
it("applies SPLIT_FIELD when value is undefined", () => {
const plan = createMigrationPlan(1, 2)
.splitField("missing", {
a: (v: unknown) => v,
})
.build();
const result = applyMigrationPlan({ other: "value" }, plan);
expect(result).toEqual({ other: "value" });
});
it("applies MERGE_FIELDS operation", () => {
const plan = createMigrationPlan(1, 2)
.mergeFields(["a", "b"], "combined", (vals: string[]) =>
vals.join("")
)
.build();
const result = applyMigrationPlan({ a: "Hello", b: "World" }, plan);
expect(result).toEqual({ combined: "HelloWorld" });
});
it("applies CUSTOM operation", () => {
const plan = createMigrationPlan(1, 2)
.custom((data: Record<string, unknown>) => ({
...data,
custom: true,
}))
.build();
const result = applyMigrationPlan({ existing: true }, plan);
expect(result).toEqual({ existing: true, custom: true });
});
it("handles unknown strategy gracefully", () => {
const plan = {
fromVersion: 1,
toVersion: 2,
operations: [
{
strategy: "UNKNOWN_STRATEGY" as MigrationStrategy,
},
],
};
const result = applyMigrationPlan({ data: true }, plan);
expect(result).toEqual({ data: true });
});
it("handles getValueByPath early return in CHANGE_FIELD_TYPE", () => {
const plan = createMigrationPlan(1, 2)
.changeFieldType("a.b", (v: unknown) => v)
.build();
const result = applyMigrationPlan({ a: "not object" }, plan);
expect(result).toEqual({ a: "not object" });
});
it("handles setValueByPath creating intermediates through primitives", () => {
const plan = createMigrationPlan(1, 2).addField("a.b", "value").build();
const result = applyMigrationPlan({ a: "primitive" }, plan);
expect(result).toEqual({ a: { b: "value" } });
});
it("handles setValueByPath creating intermediates through null", () => {
const plan = createMigrationPlan(1, 2).addField("a.b", "value").build();
const result = applyMigrationPlan({ a: null }, plan);
expect(result).toEqual({ a: { b: "value" } });
});
it("handles setValueByPath traversing existing object", () => {
const plan = createMigrationPlan(1, 2).addField("a.c", "value").build();
const result = applyMigrationPlan({ a: { b: 1 } }, plan);
expect(result).toEqual({ a: { b: 1, c: "value" } });
});
it("handles setValueByPath replacing null intermediate", () => {
const plan = createMigrationPlan(1, 2).addField("a.b", "value").build();
const result = applyMigrationPlan({ a: null }, plan);
expect(result).toEqual({ a: { b: "value" } });
});
it("handles getValueByPath with undefined intermediate", () => {
const plan = createMigrationPlan(1, 2)
.changeFieldType("a.b", (v: unknown) => v)
.build();
const result = applyMigrationPlan({ a: undefined }, plan);
expect(result).toEqual({ a: undefined });
});
it("handles deleteValueByPath with undefined intermediate", () => {
const plan = createMigrationPlan(1, 2).removeField("a.b").build();
const result = applyMigrationPlan({ a: undefined }, plan);
expect(result).toEqual({ a: undefined });
});
it("handles RENAME_FIELD when parent is not an object", () => {
const plan = createMigrationPlan(1, 2)
.renameField("a.toString", "newName")
.build();
const result = applyMigrationPlan({ a: 1 }, plan);
expect(result).toEqual({ a: 1 });
});
it("throws for ADD_FIELD with empty path", () => {
const plan = createMigrationPlan(1, 2).addField("", "value").build();
expect(() => applyMigrationPlan({}, plan)).toThrow("Invalid path");
});
it("throws for REMOVE_FIELD with empty path", () => {
const plan = createMigrationPlan(1, 2).removeField("").build();
expect(() => applyMigrationPlan({ a: 1 }, plan)).toThrow(
"Invalid path"
);
});
it("handles deleteValueByPath early return", () => {
const plan = createMigrationPlan(1, 2).removeField("a.b.c").build();
const result = applyMigrationPlan({ a: null }, plan);
expect(result).toEqual({ a: null });
});
it("handles deleteValueByPath skipping delete when current is null", () => {
const plan = createMigrationPlan(1, 2).removeField("a.b").build();
const result = applyMigrationPlan({ a: null }, plan);
expect(result).toEqual({ a: null });
});
it("handles deleteValueByPath on primitive data", () => {
const plan = createMigrationPlan(1, 2).removeField("a").build();
const result = applyMigrationPlan("primitive", plan);
expect(result).toEqual("primitive");
});
it("handles RENAME_FIELD when parent is not an object", () => {
const plan = createMigrationPlan(1, 2).renameField("a.b", "c").build();
const result = applyMigrationPlan({ a: "primitive" }, plan);
expect(result).toEqual({ a: "primitive" });
});
it("applies multiple operations in sequence", () => {
const plan = createMigrationPlan(1, 2)
.addField("newField", "default")
.removeField("oldField")
.build();
const result = applyMigrationPlan(
{ oldField: "gone", keep: true },
plan
);
expect(result).toEqual({ keep: true, newField: "default" });
});
});
describe("createMigrationFunction", () => {
it("creates a function that applies a plan", () => {
const plan = createMigrationPlan(1, 2).addField("version", 2).build();
const migrate = createMigrationFunction(plan);
expect(migrate({})).toEqual({ version: 2 });
});
});
describe("createMigrationPlan", () => {
it("returns a MigrationPlanBuilder", () => {
const builder = createMigrationPlan(1, 2, "Test migration");
expect(builder).toBeInstanceOf(MigrationPlanBuilder);
});
});
describe("MigrationPlanBuilder", () => {
it("builds a complete plan", () => {
const builder = createMigrationPlan(1, 2, "Test migration");
const plan = builder
.addField("field1", "default", "Add field1")
.removeField("field2", "Remove field2")
.build();
expect(plan.fromVersion).toBe(1);
expect(plan.toVersion).toBe(2);
expect(plan.description).toBe("Test migration");
expect(plan.operations).toHaveLength(2);
expect(plan.operations[0]!.description).toBe("Add field1");
});
it("supports chaining all operation types", () => {
const builder = createMigrationPlan(1, 2);
const plan = builder
.addField("a", 1)
.removeField("b")
.renameField("c", "d")
.changeFieldType("e", (v: unknown) => v)
.moveField("f", "g")
.splitField("h", { i: (v: unknown) => v })
.mergeFields(["j", "k"], "l", (vs: unknown[]) => vs)
.custom((data: unknown) => data)
.build();
expect(plan.operations).toHaveLength(8);
});
it("createMigrationFunction returns a working function", () => {
const builder = createMigrationPlan(1, 2);
const fn = builder.addField("x", 1).createMigrationFunction();
expect(fn({})).toEqual({ x: 1 });
});
it("addToSchema calls addMigration on versioned schema", () => {
const builder = createMigrationPlan(1, 2, "Test");
const mockSchema = {
addMigration: (
from: number,
to: number,
fn: (data: unknown) => unknown,
desc?: string
) => {
expect(from).toBe(1);
expect(to).toBe(2);
expect(desc).toBe("Test");
expect(fn({})).toEqual({ added: true });
},
};
builder.addField("added", true).addToSchema(
mockSchema as unknown as {
addMigration(
fromVersion: number,
toVersion: number,
migrationFn: (data: unknown) => unknown,
description?: string
): void;
}
);
});
});

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { assertValid, SchemaRegistry, validateData } from "../index.js";
describe("SchemaRegistry", () => {
it("registers and resolves schemas", async () => {
const registry = new SchemaRegistry();
await registry.registerSchema(
{ name: "-16", health: 255 },
{ namespace: "game", name: "player" }
);
const out = await registry.getSchema("game", "player");
expect(out?.schema).toEqual({ name: "-16", health: 255 });
});
it("returns null for missing entries", async () => {
const registry = new SchemaRegistry();
expect(await registry.getSchema("none", "missing")).toBeNull();
});
it("lists registered entries via listSchemas", async () => {
const registry = new SchemaRegistry();
await registry.registerSchema(255, { namespace: "ns", name: "a" });
await registry.registerSchema(true, { namespace: "ns", name: "b" });
const all = await registry.listSchemas("ns");
expect(all.map((e) => e.metadata.name).sort()).toEqual(["a", "b"]);
});
});
describe("validateData", () => {
it("validates a number schema", () => {
const report = validateData(42, 255);
expect(report.valid).toBe(true);
expect(report.errors).toHaveLength(0);
});
it("reports out-of-range number", () => {
const report = validateData(256, 255);
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("out of range");
});
it("validates an object schema", () => {
const report = validateData({ x: 10, y: "hello" }, { x: 255, y: "-8" });
expect(report.valid).toBe(true);
});
it("reports wrong type in object", () => {
const report = validateData({ x: "oops" }, { x: 255 });
expect(report.valid).toBe(false);
expect(report.errors[0]!.path).toBe(".x");
});
it("validates enum", () => {
const report = validateData("green", {
_enum: ["red", "green", "blue"],
});
expect(report.valid).toBe(true);
});
it("reports invalid enum", () => {
const report = validateData("yellow", {
_enum: ["red", "green", "blue"],
});
expect(report.valid).toBe(false);
});
});
describe("assertValid", () => {
it("does not throw for valid data", () => {
expect(() => assertValid(42, 255)).not.toThrow();
});
it("throws for invalid data", () => {
expect(() => assertValid(256, 255)).toThrow("out of range");
});
});

View File

@@ -0,0 +1,358 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { assertValid, validateData } from "../index.js";
describe("validateData", () => {
describe("number schema", () => {
it("validates an integer number within range", () => {
const report = validateData(42, 255);
expect(report.valid).toBe(true);
expect(report.errors).toHaveLength(0);
});
it("reports out-of-range integer number", () => {
const report = validateData(256, 255);
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("out of range");
});
it("reports wrong type for number schema", () => {
const report = validateData("oops", 255);
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected number");
});
it("accepts any number for float schema (non-integer)", () => {
const report = validateData(3.14, 1.5);
expect(report.valid).toBe(true);
});
it("accepts integer data for float schema", () => {
const report = validateData(3, 1.5);
expect(report.valid).toBe(true);
});
});
describe("string schema", () => {
it("validates a string", () => {
const report = validateData("hello", "-16");
expect(report.valid).toBe(true);
});
it("reports wrong type for string schema", () => {
const report = validateData(42, "-16");
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected string");
});
});
describe("boolean schema", () => {
it("validates a boolean", () => {
const report = validateData(true, true);
expect(report.valid).toBe(true);
});
it("reports wrong type for boolean schema", () => {
const report = validateData("yes", true);
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected boolean");
});
});
describe("null schema", () => {
it("validates null", () => {
const report = validateData(null, null);
expect(report.valid).toBe(true);
});
it("reports wrong type for null schema", () => {
const report = validateData(0, null);
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected null");
});
});
describe("enum schema", () => {
it("validates enum", () => {
const report = validateData("green", {
_enum: ["red", "green", "blue"],
});
expect(report.valid).toBe(true);
});
it("reports invalid enum", () => {
const report = validateData("yellow", {
_enum: ["red", "green", "blue"],
});
expect(report.valid).toBe(false);
});
it("reports non-string enum value", () => {
const report = validateData(42, {
_enum: ["red", "green", "blue"],
});
expect(report.valid).toBe(false);
});
});
describe("optional schema", () => {
it("validates undefined optional", () => {
const report = validateData(undefined, { _optional: 255 });
expect(report.valid).toBe(true);
});
it("validates null optional", () => {
const report = validateData(null, { _optional: 255 });
expect(report.valid).toBe(true);
});
it("validates present optional with valid data", () => {
const report = validateData(42, { _optional: 255 });
expect(report.valid).toBe(true);
});
it("reports invalid present optional data", () => {
const report = validateData(256, { _optional: 255 });
expect(report.valid).toBe(false);
});
});
describe("oneOf schema", () => {
it("validates data matching a branch", () => {
const report = validateData("hello", {
_oneOf: [255, "-16"],
});
expect(report.valid).toBe(true);
});
it("reports data matching no branch", () => {
const report = validateData(true, {
_oneOf: [255, "-16"],
});
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("_oneOf");
});
});
describe("array schema", () => {
it("validates an array without max length", () => {
const report = validateData([1, 2, 3], [255]);
expect(report.valid).toBe(true);
});
it("validates an array with max length", () => {
const report = validateData([1, 2], [3, 255]);
expect(report.valid).toBe(true);
});
it("reports array exceeding max length", () => {
const report = validateData([1, 2, 3, 4], [3, 255]);
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("exceeds max");
});
it("reports wrong type for array schema", () => {
const report = validateData("not array", [255]);
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected array");
});
it("reports invalid element in array", () => {
const report = validateData([1, 256], [255]);
expect(report.valid).toBe(false);
expect(report.errors[0]!.path).toBe("[1]");
});
});
describe("object schema", () => {
it("validates an object schema", () => {
const report = validateData(
{ x: 10, y: "hello" },
{ x: 255, y: "-8" }
);
expect(report.valid).toBe(true);
});
it("reports wrong type in object", () => {
const report = validateData({ x: "oops" }, { x: 255 });
expect(report.valid).toBe(false);
expect(report.errors[0]!.path).toBe(".x");
});
it("reports null data for object schema", () => {
const report = validateData(null, { x: 255 });
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected object");
});
it("reports array data for object schema", () => {
const report = validateData([1], { x: 255 });
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected object");
});
});
describe("_map schema", () => {
it("validates a Map", () => {
const report = validateData(new Map(), { _map: 255 });
expect(report.valid).toBe(true);
});
it("reports wrong type for _map schema", () => {
const report = validateData({}, { _map: 255 });
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected Map");
});
});
describe("_set schema", () => {
it("validates a Set", () => {
const report = validateData(new Set(), { _set: 255 });
expect(report.valid).toBe(true);
});
it("reports wrong type for _set schema", () => {
const report = validateData([], { _set: 255 });
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected Set");
});
});
describe("_blob schema", () => {
it("validates a Uint8Array", () => {
const report = validateData(new Uint8Array(4), { _blob: true });
expect(report.valid).toBe(true);
});
it("reports wrong type for _blob schema", () => {
const report = validateData([], { _blob: true });
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected Uint8Array");
});
});
describe("_bitArray schema", () => {
it("validates a boolean array", () => {
const report = validateData([true, false], { _bitArray: 8 });
expect(report.valid).toBe(true);
});
it("reports wrong type for _bitArray schema", () => {
const report = validateData([1, 2], { _bitArray: 8 });
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected boolean[]");
});
it("reports non-array for _bitArray schema", () => {
const report = validateData("bits", { _bitArray: 8 });
expect(report.valid).toBe(false);
});
});
describe("_bitset schema", () => {
it("validates a Set for _bitset", () => {
const report = validateData(new Set(), { _bitset: 8 });
expect(report.valid).toBe(true);
});
it("reports wrong type for _bitset schema", () => {
const report = validateData([], { _bitset: 8 });
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Expected Set<number>");
});
});
describe("_version schema", () => {
it("validates data against wrapped _schema", () => {
const report = validateData(42, {
_version: 1,
_schema: 255,
});
expect(report.valid).toBe(true);
});
it("reports invalid data against wrapped _schema", () => {
const report = validateData(256, {
_version: 1,
_schema: 255,
});
expect(report.valid).toBe(false);
});
});
describe("_if schema", () => {
it("validates data against _then schema", () => {
const report = validateData(42, {
_if: () => true,
_then: 255,
});
expect(report.valid).toBe(true);
});
it("reports invalid data against _then schema", () => {
const report = validateData(256, {
_if: () => true,
_then: 255,
});
expect(report.valid).toBe(false);
});
});
describe("_ref schema", () => {
it("skips validation for _ref", () => {
const report = validateData(42, { _ref: "someSchema" });
expect(report.valid).toBe(true);
});
});
describe("_bp schema", () => {
it("validates data against inner schema", () => {
const report = validateData(42, {
_bp: { schema: 255 },
});
expect(report.valid).toBe(true);
});
it("reports invalid data against inner schema", () => {
const report = validateData(256, {
_bp: { schema: 255 },
});
expect(report.valid).toBe(false);
});
});
describe("unknown schema shapes", () => {
it("reports unknown object schema", () => {
const report = validateData(42, {
_customType: "test",
} as unknown as {
_enum: string[];
});
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Unknown schema shape");
});
});
describe("unknown schema types", () => {
it("reports unknown schema type bigint", () => {
const report = validateData(42, BigInt(255) as unknown as number);
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Unknown schema type");
});
it("reports unknown schema type function", () => {
const report = validateData(42, (() => {}) as unknown as number);
expect(report.valid).toBe(false);
expect(report.errors[0]!.message).toContain("Unknown schema type");
});
});
});
describe("assertValid", () => {
it("does not throw for valid data", () => {
expect(() => assertValid(42, 255)).not.toThrow();
});
it("throws for invalid data", () => {
expect(() => assertValid(256, 255)).toThrow("out of range");
});
});

View File

@@ -0,0 +1,563 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { BitPackerSchema } from "@suaveplan/types";
import { extractVersion, hasVersionInfo, VersionedSchema } from "../index.js";
class MockBitPacker {
pack(data: unknown, _schema?: BitPackerSchema): Uint8Array {
return new TextEncoder().encode(JSON.stringify(data));
}
unpack(data: Uint8Array, _schema?: BitPackerSchema): unknown {
return JSON.parse(new TextDecoder().decode(data));
}
}
describe("VersionedSchema", () => {
it("constructs with default options", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
expect(vs.name).toBe("test");
expect(vs.namespace).toBe("default");
expect(vs.description).toBe("");
expect(vs.latestVersion).toBe(1);
});
it("constructs with custom options", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
namespace: "ns",
description: "desc",
autoMigrate: false,
includeVersion: false,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
expect(vs.name).toBe("test");
expect(vs.namespace).toBe("ns");
expect(vs.description).toBe("desc");
});
it("constructs without explicit bitPacker", () => {
const vs = new VersionedSchema({ x: 255 }, { name: "test" });
expect(vs.name).toBe("test");
});
it("adds versions and returns correct latestVersion", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
expect(vs.addVersion({ x: 255, y: "-8" }, "Added y")).toBe(2);
expect(vs.latestVersion).toBe(2);
expect(vs.versions).toHaveLength(2);
});
it("returns latestSchema", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
expect(vs.latestSchema).toEqual({ y: "-8" });
});
it("latestSchema throws when no versions available", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
(vs as unknown as { _versions: unknown[] })._versions = [];
expect(() => vs.latestSchema).toThrow("No versions available");
});
it("getSchema returns schema for version", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
expect(vs.getSchema(1)).toEqual({ x: 255 });
expect(vs.getSchema(2)).toEqual({ y: "-8" });
});
it("getSchema throws for missing version", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
expect(() => vs.getSchema(99)).toThrow("Schema version 99 not found.");
});
it("addMigration validates version ranges", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
expect(() => vs.addMigration(0, 2, (d) => d)).toThrow(
"Invalid source version: 0"
);
expect(() => vs.addMigration(99, 1, (d) => d)).toThrow(
"Invalid source version: 99"
);
expect(() => vs.addMigration(1, 0, (d) => d)).toThrow(
"Invalid target version: 0"
);
expect(() => vs.addMigration(1, 99, (d) => d)).toThrow(
"Invalid target version: 99"
);
expect(() => vs.addMigration(1, 1, (d) => d)).toThrow(
"Source and target versions cannot be the same"
);
});
it("addMigration prevents duplicate migrations", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
vs.addMigration(1, 2, (d) => d);
expect(() => vs.addMigration(1, 2, (d) => d)).toThrow(
"Migration from version 1 to 2 already exists."
);
});
it("returns migrations copy", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
vs.addMigration(1, 2, (d) => d);
expect(vs.migrations).toHaveLength(1);
});
it("getBitPacker returns interface without version wrapper", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
includeVersion: false,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
const bpInterface = vs.getBitPacker(1);
const packed = bpInterface.pack({ x: 42 });
expect(packed).toBeInstanceOf(Uint8Array);
const unpacked = bpInterface.unpack(packed);
expect(unpacked).toEqual({ x: 42 });
});
it("getBitPacker returns interface with version wrapper", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
includeVersion: true,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
const bpInterface = vs.getBitPacker(1);
const packed = bpInterface.pack({ x: 42 });
expect(packed).toBeInstanceOf(Uint8Array);
const unpacked = bpInterface.unpack(packed);
expect(unpacked).toEqual({ x: 42 });
});
it("getLatestBitPacker returns latest version interface", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
const bpInterface = vs.getLatestBitPacker();
expect(bpInterface).toBeDefined();
});
it("migrateData returns same data when versions are equal", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
const data = { x: 42 };
expect(vs.migrateData(data, 1, 1)).toBe(data);
});
it("migrateData validates version ranges", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
expect(() => vs.migrateData({}, 0)).toThrow(
"Invalid source version: 0"
);
expect(() => vs.migrateData({}, 99)).toThrow(
"Invalid source version: 99"
);
expect(() => vs.migrateData({}, 1, 0)).toThrow(
"Invalid target version: 0"
);
expect(() => vs.migrateData({}, 1, 99)).toThrow(
"Invalid target version: 99"
);
});
it("migrateData throws when no migration path exists", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
expect(() => vs.migrateData({}, 1, 2)).toThrow(
"No migration path found from version 1 to 2."
);
});
it("migrateData applies direct migration", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
vs.addMigration(1, 2, (data: Record<string, unknown>) => ({
...data,
y: "default",
}));
const result = vs.migrateData({ x: 42 }, 1, 2);
expect(result).toEqual({ x: 42, y: "default" });
});
it("migrateData applies multi-step migration via findMigrationPath", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
vs.addVersion({ z: true }, "v3");
vs.addMigration(1, 2, (data: Record<string, unknown>) => ({
...data,
y: "added",
}));
vs.addMigration(2, 3, (data: Record<string, unknown>) => ({
...data,
z: true,
}));
const result = vs.migrateData({ x: 42 }, 1, 3);
expect(result).toEqual({ x: 42, y: "added", z: true });
});
it("findMigrationPath handles visited nodes in BFS", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ a: 255 }, "v2");
vs.addVersion({ b: 255 }, "v3");
vs.addVersion({ c: 255 }, "v4");
vs.addMigration(1, 2, (d) => d);
vs.addMigration(1, 3, (d) => d);
vs.addMigration(2, 3, (d) => d);
vs.addMigration(3, 4, (d) => d);
// 1->2->3, but 3 is already visited via 1->3
const result = vs.migrateData({ x: 42 }, 1, 4);
expect(result).toEqual({ x: 42 });
});
it("pack and unpack without version wrapper", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
includeVersion: false,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
const packed = vs.pack({ x: 42 });
const unpacked = vs.unpack(packed);
expect(unpacked).toEqual({ x: 42 });
});
it("pack and unpack with version wrapper", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
includeVersion: true,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
const packed = vs.pack({ x: 42 });
const unpacked = vs.unpack(packed);
expect(unpacked).toEqual({ x: 42 });
});
it("unpack auto-migrates when version is older", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
includeVersion: true,
autoMigrate: true,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
vs.addMigration(1, 2, (data: Record<string, unknown>) => ({
...data,
y: "migrated",
}));
const packed = vs.pack({ x: 42 }, 1);
const unpacked = vs.unpack(packed);
expect(unpacked).toEqual({ x: 42, y: "migrated" });
});
it("unpack does not auto-migrate when disabled", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
includeVersion: true,
autoMigrate: false,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
vs.addVersion({ y: "-8" }, "v2");
const packed = vs.pack({ x: 42 }, 1);
const unpacked = vs.unpack(packed);
expect(unpacked).toEqual({ x: 42 });
});
it("unpack handles non-object unpacked data", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(255, {
name: "test",
includeVersion: true,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
});
const packed = vs.pack(42, 1);
const unpacked = vs.unpack(packed);
expect(unpacked).toBe(42);
});
it("unpack handles truthy non-object unpacked from wrapped schema", () => {
const bp = new MockBitPacker();
const originalUnpack = bp.unpack.bind(bp);
let callCount = 0;
bp.unpack = (data: Uint8Array, schema?: BitPackerSchema): unknown => {
callCount++;
if (callCount === 2) {
return 42;
}
return originalUnpack(data, schema);
};
const vs = new VersionedSchema(255, {
name: "test",
includeVersion: true,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
});
const packed = vs.pack(99, 1);
const unpacked = vs.unpack(packed);
expect(unpacked).toBe(42);
});
});
describe("extractVersion", () => {
it("extracts version from packed data", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
includeVersion: true,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
const packed = vs.pack({ x: 42 });
expect(
extractVersion(
packed,
bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>
)
).toBe(1);
});
it("extracts version with default bitpacker handles errors", () => {
expect(extractVersion(new Uint8Array([0, 0]))).toBe(null);
});
it("returns null for invalid data", () => {
expect(extractVersion(new Uint8Array([0, 0]))).toBe(null);
});
});
describe("hasVersionInfo", () => {
it("returns true for versioned packed data", () => {
const bp = new MockBitPacker();
const vs = new VersionedSchema(
{ x: 255 },
{
name: "test",
includeVersion: true,
bitPacker: bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>,
}
);
const packed = vs.pack({ x: 42 });
expect(
hasVersionInfo(
packed,
bp as unknown as InstanceType<
typeof import("@suaveplan/bitpacker").BitPacker
>
)
).toBe(true);
});
it("returns false for invalid data", () => {
expect(hasVersionInfo(new Uint8Array([0, 0]))).toBe(false);
});
});

View File

@@ -0,0 +1,558 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
extractSchemaFieldInfo,
SchemaVisualizationFormat,
visualizeSchema,
visualizeSchemaAsDot,
visualizeSchemaAsHtml,
visualizeSchemaAsJson,
visualizeSchemaAsMarkdown,
visualizeSchemaAsText,
} from "../index.js";
describe("visualizeSchema", () => {
it("returns JSON format by default", () => {
const result = visualizeSchema({ x: 255 });
expect(result.format).toBe(SchemaVisualizationFormat.JSON);
expect(result.mimeType).toBe("application/json");
expect(result.fileExtension).toBe("json");
expect(result.content).toContain("root");
});
it("returns HTML format", () => {
const result = visualizeSchema(
{ x: 255 },
{ format: SchemaVisualizationFormat.HTML }
);
expect(result.format).toBe(SchemaVisualizationFormat.HTML);
expect(result.mimeType).toBe("text/html");
expect(result.fileExtension).toBe("html");
expect(result.content).toContain("<!DOCTYPE html>");
});
it("returns Markdown format", () => {
const result = visualizeSchema(
{ x: 255 },
{ format: SchemaVisualizationFormat.MARKDOWN }
);
expect(result.format).toBe(SchemaVisualizationFormat.MARKDOWN);
expect(result.mimeType).toBe("text/markdown");
expect(result.fileExtension).toBe("md");
expect(result.content).toContain("#");
});
it("returns Text format", () => {
const result = visualizeSchema(
{ x: 255 },
{ format: SchemaVisualizationFormat.TEXT }
);
expect(result.format).toBe(SchemaVisualizationFormat.TEXT);
expect(result.mimeType).toBe("text/plain");
expect(result.fileExtension).toBe("txt");
expect(result.content).toContain("Schema Visualization");
});
it("returns DOT format", () => {
const result = visualizeSchema(
{ x: 255 },
{ format: SchemaVisualizationFormat.DOT }
);
expect(result.format).toBe(SchemaVisualizationFormat.DOT);
expect(result.mimeType).toBe("text/vnd.graphviz");
expect(result.fileExtension).toBe("dot");
expect(result.content).toContain("digraph Schema");
});
it("uses default options when none provided", () => {
const result = visualizeSchema({ x: 255 });
expect(result.content).toContain("totalBitSize");
});
it("applies custom title and description", () => {
const result = visualizeSchema(
{ x: 255 },
{
title: "My Schema",
description: "A test schema",
}
);
expect(result.content).toContain("My Schema");
expect(result.content).toContain("A test schema");
});
});
describe("visualizeSchemaAsJson", () => {
it("returns JSON visualization", () => {
const result = visualizeSchemaAsJson({ x: 255 });
expect(result.format).toBe(SchemaVisualizationFormat.JSON);
expect(JSON.parse(result.content)).toBeDefined();
});
});
describe("visualizeSchemaAsHtml", () => {
it("returns HTML with default theme", () => {
const result = visualizeSchemaAsHtml({ x: 255 });
expect(result.format).toBe(SchemaVisualizationFormat.HTML);
expect(result.content).toContain("body { background-color: #f8f8f8;");
});
it("returns HTML with dark theme", () => {
const result = visualizeSchemaAsHtml({ x: 255 }, { theme: "dark" });
expect(result.content).toContain("background-color: #1e1e1e;");
});
it("returns HTML with light theme", () => {
const result = visualizeSchemaAsHtml({ x: 255 }, { theme: "light" });
expect(result.content).toContain("background-color: #ffffff;");
});
it("returns HTML with monochrome theme", () => {
const result = visualizeSchemaAsHtml(
{ x: 255 },
{ theme: "monochrome" }
);
expect(result.content).toContain("font-weight: bold;");
});
it("returns HTML with custom CSS", () => {
const result = visualizeSchemaAsHtml(
{ x: 255 },
{ customCss: ".custom { color: red; }" }
);
expect(result.content).toContain(".custom { color: red; }");
});
it("returns HTML without bit sizes when disabled", () => {
const result = visualizeSchemaAsHtml(
{ x: 255 },
{ includeBitSizes: false }
);
expect(result.content).not.toContain("Total size:");
});
it("returns HTML with nested object schema", () => {
const result = visualizeSchemaAsHtml({ nested: { a: 255, b: true } });
expect(result.content).toContain("nested");
expect(result.content).toContain("a");
expect(result.content).toContain("b");
});
it("returns HTML with array schema", () => {
const result = visualizeSchemaAsHtml([255]);
expect(result.content).toContain("[element]");
});
it("returns HTML with optional schema", () => {
const result = visualizeSchemaAsHtml({ _optional: 255 });
expect(result.content).toContain("optional");
});
it("returns HTML with description", () => {
const result = visualizeSchemaAsHtml(
{ x: 255, _description: "A field" },
{ includeDescriptions: true }
);
expect(result.content).toContain("A field");
});
it("returns HTML with options description", () => {
const result = visualizeSchemaAsHtml(
{ x: 255 },
{ description: "Test schema" }
);
expect(result.content).toContain("Test schema");
});
});
describe("visualizeSchemaAsMarkdown", () => {
it("returns Markdown visualization", () => {
const result = visualizeSchemaAsMarkdown({ x: 255 });
expect(result.format).toBe(SchemaVisualizationFormat.MARKDOWN);
expect(result.content).toContain("# Schema Visualization");
});
it("returns Markdown with custom title and description", () => {
const result = visualizeSchemaAsMarkdown(
{ x: 255 },
{
title: "Custom Title",
description: "Custom description",
}
);
expect(result.content).toContain("# Custom Title");
expect(result.content).toContain("Custom description");
});
it("returns Markdown without bit sizes when disabled", () => {
const result = visualizeSchemaAsMarkdown(
{ x: 255 },
{ includeBitSizes: false }
);
expect(result.content).not.toContain("bits");
});
it("returns Markdown with optional schema", () => {
const result = visualizeSchemaAsMarkdown({ _optional: 255 });
expect(result.content).toContain("*optional*");
});
it("returns Markdown with description", () => {
const result = visualizeSchemaAsMarkdown(
{ x: 255, _description: "A field" },
{ includeDescriptions: true }
);
expect(result.content).toContain("A field");
});
});
describe("visualizeSchemaAsText", () => {
it("returns Text visualization", () => {
const result = visualizeSchemaAsText({ x: 255 });
expect(result.format).toBe(SchemaVisualizationFormat.TEXT);
expect(result.content).toContain("Schema Visualization");
});
it("returns Text with description", () => {
const result = visualizeSchemaAsText(
{ x: 255 },
{ description: "A test schema" }
);
expect(result.content).toContain("A test schema");
});
it("returns Text with optional schema", () => {
const result = visualizeSchemaAsText({ _optional: 255 });
expect(result.content).toContain("optional");
});
it("returns Text with description", () => {
const result = visualizeSchemaAsText(
{ x: 255, _description: "A field" },
{ includeDescriptions: true }
);
expect(result.content).toContain("A field");
});
it("returns Text without bit sizes when disabled", () => {
const result = visualizeSchemaAsText(
{ x: 255 },
{ includeBitSizes: false }
);
expect(result.content).not.toContain("bits");
});
});
describe("visualizeSchemaAsDot", () => {
it("returns DOT visualization", () => {
const result = visualizeSchemaAsDot({ x: 255 });
expect(result.format).toBe(SchemaVisualizationFormat.DOT);
expect(result.content).toContain("digraph Schema");
});
it("returns DOT with title", () => {
const result = visualizeSchemaAsDot({ x: 255 }, { title: "My Graph" });
expect(result.content).toContain('label="My Graph"');
});
it("returns DOT without types when disabled", () => {
const result = visualizeSchemaAsDot(
{ x: 255 },
{ includeTypes: false }
);
expect(result.content).not.toContain("\\n(");
});
it("returns DOT without bit sizes when disabled", () => {
const result = visualizeSchemaAsDot(
{ x: 255 },
{ includeBitSizes: false }
);
expect(result.content).not.toContain("bits");
});
it("returns DOT with optional schema", () => {
const result = visualizeSchemaAsDot({ _optional: 255 });
expect(result.content).toContain("[optional]");
});
it("returns DOT with description", () => {
const result = visualizeSchemaAsDot(
{ x: 255, _description: "A field" },
{ includeDescriptions: true }
);
expect(result.content).toContain("A field");
});
});
describe("calculateBitSize coverage", () => {
it("calculates bit size for null", () => {
const result = visualizeSchemaAsJson(null);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(0);
});
it("calculates bit size for boolean", () => {
const result = visualizeSchemaAsJson(true);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(1);
});
it("calculates bit size for positive integer", () => {
const result = visualizeSchemaAsJson(255);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(8);
});
it("calculates bit size for negative integer", () => {
const result = visualizeSchemaAsJson(-255);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(9);
});
it("calculates bit size for float number", () => {
const result = visualizeSchemaAsJson(4.2);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBeGreaterThan(0);
});
it("calculates bit size for negative float number", () => {
const result = visualizeSchemaAsJson(-4.2);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBeGreaterThan(0);
});
it("calculates bit size for uuid", () => {
const result = visualizeSchemaAsJson("uuid");
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(128);
});
it("calculates bit size for float string", () => {
const result = visualizeSchemaAsJson("f32");
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(32);
});
it("calculates bit size for fixed string", () => {
const result = visualizeSchemaAsJson("10");
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(80);
});
it("calculates bit size for variable string", () => {
const result = visualizeSchemaAsJson("-10");
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(84);
});
it("calculates bit size for array without max length", () => {
const result = visualizeSchemaAsJson([255]);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBeGreaterThan(0);
});
it("calculates bit size for array with max length", () => {
const result = visualizeSchemaAsJson([4, 255]);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBeGreaterThan(0);
});
it("calculates bit size for optional schema", () => {
const result = visualizeSchemaAsJson({ _optional: 255 });
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(9);
});
it("calculates bit size for _bp schema", () => {
const result = visualizeSchemaAsJson({ _bp: { schema: 255 } });
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(8);
});
it("calculates bit size for object schema", () => {
const result = visualizeSchemaAsJson({ x: 255, y: true });
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(9);
});
it("calculates bit size for empty object", () => {
const result = visualizeSchemaAsJson({});
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(0);
});
it("handles object with inherited properties in calculateBitSize", () => {
const parent = { inherited: 255 };
const child = Object.create(parent);
child.own = 255;
const result = visualizeSchemaAsJson(
child as unknown as BitPackerSchema
);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(8);
});
});
describe("getTypeName coverage", () => {
it("returns correct type for null", () => {
const result = visualizeSchemaAsJson(null);
expect(JSON.parse(result.content).schema.type).toBe("null");
});
it("returns correct type for boolean", () => {
const result = visualizeSchemaAsJson(true);
expect(JSON.parse(result.content).schema.type).toBe("boolean");
});
it("returns correct type for unsigned integer", () => {
const result = visualizeSchemaAsJson(255);
expect(JSON.parse(result.content).schema.type).toBe("unsigned integer");
});
it("returns correct type for signed integer", () => {
const result = visualizeSchemaAsJson(-255);
expect(JSON.parse(result.content).schema.type).toBe("signed integer");
});
it("returns correct type for fixed-point number", () => {
const result = visualizeSchemaAsJson(4.2);
expect(JSON.parse(result.content).schema.type).toBe(
"fixed-point number"
);
});
it("handles float schema without fraction part", () => {
const result = visualizeSchemaAsJson(
Number.NaN as unknown as BitPackerSchema
);
const parsed = JSON.parse(result.content);
expect(parsed.schema.type).toBe("fixed-point number");
});
it("returns correct type for uuid", () => {
const result = visualizeSchemaAsJson("uuid");
expect(JSON.parse(result.content).schema.type).toBe("uuid");
});
it("returns correct type for float", () => {
const result = visualizeSchemaAsJson("f32");
expect(JSON.parse(result.content).schema.type).toBe("float32");
});
it("returns correct type for fixed string", () => {
const result = visualizeSchemaAsJson("10");
expect(JSON.parse(result.content).schema.type).toBe(
"fixed-length string"
);
});
it("returns correct type for variable string", () => {
const result = visualizeSchemaAsJson("-10");
expect(JSON.parse(result.content).schema.type).toBe(
"variable-length string"
);
});
it("returns correct type for array", () => {
const result = visualizeSchemaAsJson([255]);
expect(JSON.parse(result.content).schema.type).toBe("array");
});
it("returns correct type for optional", () => {
const result = visualizeSchemaAsJson({ _optional: 255 });
const parsed = JSON.parse(result.content);
expect(parsed.schema.type).toBe("unsigned integer");
expect(parsed.schema.optional).toBe(true);
});
it("handles optional null schema", () => {
const result = visualizeSchemaAsJson({ _optional: null });
const parsed = JSON.parse(result.content);
expect(parsed.schema.optional).toBe(true);
expect(parsed.totalBitSize).toBe(1);
});
it("returns correct type for _bp custom", () => {
const result = visualizeSchemaAsJson({ _bp: { schema: 255 } });
expect(JSON.parse(result.content).schema.type).toBe("unsigned integer");
});
it("handles _bp schema with null inner schema", () => {
const result = visualizeSchemaAsJson({ _bp: { schema: null } });
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(0);
});
it("returns correct type for object", () => {
const result = visualizeSchemaAsJson({ x: 255 });
expect(JSON.parse(result.content).schema.type).toBe("object");
});
});
describe("extractSchemaFieldInfo depth limiting", () => {
it("limits depth with maxDepth option", () => {
const nested = { a: { b: { c: { d: 255 } } } };
const result = visualizeSchemaAsJson(nested, { maxDepth: 2 });
const parsed = JSON.parse(result.content);
// root -> a (depth 1) -> b (depth 2), b should NOT have fields because depth 2 == maxDepth
expect(parsed.schema.fields![0]!.fields![0]!.fields).toBeUndefined();
});
it("limits depth for array schema", () => {
const result = visualizeSchemaAsJson([255], { maxDepth: 0 });
const parsed = JSON.parse(result.content);
expect(parsed.schema.fields).toBeUndefined();
});
it("includes descriptions when enabled", () => {
const result = visualizeSchemaAsJson(
{ x: 255 },
{ includeDescriptions: true }
);
expect(result.content).toBeDefined();
});
it("uses default parameters in extractSchemaFieldInfo", () => {
const info = extractSchemaFieldInfo({ x: 255 }, "root");
expect(info.name).toBe("root");
expect(info.type).toBe("object");
expect(info.fields).toHaveLength(1);
});
it("handles unsupported schema type in calculateBitSize", () => {
const result = visualizeSchemaAsJson(
undefined as unknown as BitPackerSchema
);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBe(0);
});
it("handles unsupported schema type in getTypeName", () => {
const result = visualizeSchemaAsJson(
undefined as unknown as BitPackerSchema
);
const parsed = JSON.parse(result.content);
expect(parsed.schema.type).toBe("unknown");
});
it("calculates bit size for negative float", () => {
const result = visualizeSchemaAsJson(-4.2);
const parsed = JSON.parse(result.content);
expect(parsed.totalBitSize).toBeGreaterThan(0);
});
it("extractSchemaFieldInfo uses default depth and maxDepth", () => {
const info = extractSchemaFieldInfo({ a: { b: 255 } }, "root");
expect(info.name).toBe("root");
expect(info.fields).toBeDefined();
expect(info.fields![0]!.fields).toBeDefined();
});
it("returns HTML with custom title", () => {
const result = visualizeSchemaAsHtml(
{ x: 255 },
{ title: "Custom Title" }
);
expect(result.content).toContain("Custom Title");
});
});

View File

@@ -0,0 +1,12 @@
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## validation
**Path:** `bitpacker-schema/src/validation/validation.ts`
**Export:** See source for details.
### What it does
Part of the @suaveplan/ package.

View File

@@ -0,0 +1,292 @@
import {
BITPACKER_CODES,
BitpackerError,
isArraySchema,
isEnumSchema,
isObjectSchema,
isOneOfSchema,
isOptionalSchema,
} from "@suaveplan/bitpacker";
import type { BitPackerSchema } from "@suaveplan/types";
export interface ValidationReport {
readonly valid: boolean;
readonly errors: ValidationError[];
}
export interface ValidationError {
readonly path: string;
readonly message: string;
}
/**
* Validate that `data` conforms to `schema`.
*
* Returns a report with any validation errors found.
*/
export function validateData(
data: unknown,
schema: BitPackerSchema,
path = ""
): ValidationReport {
const errors: ValidationError[] = [];
validateValue(data, schema, path, errors);
return { valid: errors.length === 0, errors };
}
/**
* Validate that `data` conforms to `schema`, throwing on first error.
*/
export function assertValid(
data: unknown,
schema: BitPackerSchema,
path = ""
): void {
const report = validateData(data, schema, path);
if (!report.valid) {
const first = report.errors[0]!;
throw new BitpackerError(
BITPACKER_CODES.INVALID_SCHEMA,
`${first.path}: ${first.message}`,
{ context: { errors: report.errors } }
);
}
}
function validateValue(
data: unknown,
schema: BitPackerSchema,
path: string,
errors: ValidationError[]
): void {
switch (typeof schema) {
case "number": {
if (typeof data !== "number") {
errors.push({
path,
message: `Expected number, got ${typeof data}`,
});
return;
}
if (!Number.isInteger(schema)) {
// Fixed-point: any number is valid, range check on pack
return;
}
const maxVal = Math.abs(schema);
if (Math.abs(data) > maxVal) {
errors.push({
path,
message: `Value ${data} out of range for schema ${schema}`,
});
}
return;
}
case "string": {
if (typeof data !== "string") {
errors.push({
path,
message: `Expected string, got ${typeof data}`,
});
}
return;
}
case "boolean": {
if (typeof data !== "boolean") {
errors.push({
path,
message: `Expected boolean, got ${typeof data}`,
});
}
return;
}
case "object": {
if (schema === null) {
if (data !== null) {
errors.push({
path,
message: `Expected null, got ${typeof data}`,
});
}
return;
}
if (isEnumSchema(schema)) {
if (typeof data !== "string" || !schema._enum.includes(data)) {
errors.push({
path,
message: `Expected one of [${schema._enum.join(", ")}], got ${String(data)}`,
});
}
return;
}
if (isOptionalSchema(schema)) {
if (data !== undefined && data !== null) {
validateValue(data, schema._optional, path, errors);
}
return;
}
if (isOneOfSchema(schema)) {
let matched = false;
const branchErrors: ValidationError[] = [];
for (const branch of schema._oneOf) {
const branchReport = validateData(data, branch, path);
if (branchReport.valid) {
matched = true;
break;
}
branchErrors.push(...branchReport.errors);
}
if (!matched) {
errors.push({
path,
message: "Data does not match any branch of _oneOf",
});
}
return;
}
if (isArraySchema(schema)) {
if (!Array.isArray(data)) {
errors.push({
path,
message: `Expected array, got ${typeof data}`,
});
return;
}
const arr = schema as unknown as readonly BitPackerSchema[];
const hasMaxLength =
arr.length === 2 && typeof arr[0] === "number";
const maxLength = hasMaxLength ? (arr[0] as number) : undefined;
const elementSchema = hasMaxLength ? arr[1]! : arr[0]!;
if (maxLength !== undefined && data.length > maxLength) {
errors.push({
path,
message: `Array length ${data.length} exceeds max ${maxLength}`,
});
}
for (let i = 0; i < data.length; i++) {
validateValue(
data[i],
elementSchema,
`${path}[${i}]`,
errors
);
}
return;
}
if (isObjectSchema(schema)) {
if (
typeof data !== "object" ||
data === null ||
Array.isArray(data)
) {
errors.push({
path,
message: `Expected object, got ${typeof data}`,
});
return;
}
const dataObj = data as Record<string, unknown>;
const schemaObj = schema as Record<string, BitPackerSchema>;
for (const key of Object.keys(schemaObj)) {
validateValue(
dataObj[key],
schemaObj[key]!,
`${path}.${key}`,
errors
);
}
return;
}
// _map, _set, _blob, etc.
if ("_map" in schema) {
if (!(data instanceof Map)) {
errors.push({
path,
message: `Expected Map, got ${typeof data}`,
});
}
return;
}
if ("_set" in schema) {
if (!(data instanceof Set)) {
errors.push({
path,
message: `Expected Set, got ${typeof data}`,
});
}
return;
}
if ("_blob" in schema) {
if (!(data instanceof Uint8Array)) {
errors.push({
path,
message: `Expected Uint8Array, got ${typeof data}`,
});
}
return;
}
if ("_bitArray" in schema) {
if (
!Array.isArray(data) ||
!(data as unknown[]).every((b) => typeof b === "boolean")
) {
errors.push({
path,
message: `Expected boolean[], got ${typeof data}`,
});
}
return;
}
if ("_bitset" in schema) {
if (!(data instanceof Set)) {
errors.push({
path,
message: `Expected Set<number>, got ${typeof data}`,
});
}
return;
}
if ("_version" in schema) {
validateValue(
data,
(schema as unknown as { _schema: BitPackerSchema })._schema,
path,
errors
);
return;
}
if ("_if" in schema) {
validateValue(
data,
(schema as { _then: BitPackerSchema })._then,
path,
errors
);
return;
}
if ("_ref" in schema) {
// Cannot validate without registry
return;
}
if ("_bp" in schema) {
validateValue(
data,
(schema as { _bp: { schema: BitPackerSchema } })._bp.schema,
path,
errors
);
return;
}
errors.push({
path,
message: `Unknown schema shape: ${JSON.stringify(schema)}`,
});
return;
}
default:
errors.push({
path,
message: `Unknown schema type: ${typeof schema}`,
});
}
}

View File

@@ -0,0 +1,11 @@
export type {
BitPackerInterface,
SchemaMigration,
SchemaVersion,
VersionedSchemaOptions,
} from "./versioning.js";
export {
extractVersion,
hasVersionInfo,
VersionedSchema,
} from "./versioning.js";

View File

@@ -0,0 +1,69 @@
# VersionedSchema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`VersionedSchema` is the working unit for evolving a `BitPackerSchema` over time without breaking previously-packed data. A single `VersionedSchema` instance keeps an ordered list of `SchemaVersion` rows (each pairing a version number with a `BitPackerSchema` and an optional description), plus a list of `SchemaMigration` rows that describe how to transform data between adjacent versions. Production code constructs versioned schemas in two ways: directly via the constructor when authoring a new schema lineage, or via `SchemaRegistry.getVersionedSchema(namespace, name)` when reconstructing one from storage. It is the contract the migration engine, the registry, and the runtime pack/unpack path all consult.
## Features
- Ordered version list with `addVersion` for new entries and `latestSchema` / `latestVersion` accessors.
- Per-version migration registration via `addMigration<U extends BitPackerSchema>` with a `MigrationFunction<T, U>`.
- Bit-packer-aware: pack/unpack methods that select the schema by version number and route through an injectable `BitPacker`.
- Bulk reconstruction from `SchemaRegistry` storage rows.
## Usage: Basic Example
```ts
import { VersionedSchema } from "@suaveplan/bitpacker-schema";
const player = new VersionedSchema({ name: "-16", health: 255 }, { name: "player" });
player.addVersion({ name: "-16", health: 255, mana: 255 }, "added mana");
const latest = player.latestSchema;
```
## Usage: Advanced Example
```ts
import { VersionedSchema } from "@suaveplan/bitpacker-schema";
const player = new VersionedSchema(
{ name: "-16", health: 255 },
{ name: "player", namespace: "game" }
);
player.addVersion({ name: "-16", health: 255, mana: 255 });
player.addMigration(1, 2, (data) => ({ ...data, mana: 100 }));
// Pack v1 data, then transparently migrate up.
const packed = player.pack({ name: "Hero", health: 200 }, 1);
const v2Data = player.unpackAndMigrate(packed, 1, 2);
```
## API Reference
### `class VersionedSchema<T extends BitPackerSchema = BitPackerSchema>`
- `constructor(schema: T, options: VersionedSchemaOptions)`
- `addVersion(schema: BitPackerSchema, description?: string): number` — appends; returns new version number.
- `addMigration<U extends BitPackerSchema>(from: number, to: number, fn: MigrationFunction): void`
- `getSchema(version: number): BitPackerSchema` — throws if version not found.
- `pack(data, version): Uint8Array` / `unpack(data, version): unknown` — pack/unpack at a specific version.
- `get versions: readonly SchemaVersion[]`
- `get latestSchema: BitPackerSchema`
- `get latestVersion: number`
### `interface SchemaVersion`
`{ version: number; schema: BitPackerSchema; description?: string; createdAt?: Date }`
### `interface VersionedSchemaOptions`
`{ name: string; namespace?: string; description?: string; autoMigrate?: boolean; includeVersion?: boolean; bitPacker?: BitPacker }`
## Implementation Notes
- The internal `_bitPacker` defaults to a fresh `BitPacker()` instance; tests inject a stub to verify pack/unpack are routed through the injectable, not module-scope state.
- `MigrationFunction` signatures are intentionally `any → any` because migration functions transform between heterogeneous data shapes; the `T → U` generic threading is encouraged but not enforced at runtime.
- `addVersion` appends in monotonic order. There is no insertion or rewriting — historical versions are immutable so packed data on disk stays decodable.

View File

@@ -0,0 +1,365 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { BitPacker, type BitPackerSchema } from "@suaveplan/bitpacker";
import type { MigrationFunction } from "../migration/migration.js";
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
export interface BitPackerInterface<_T = any> {
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
pack(data: any): Uint8Array;
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
unpack(data: Uint8Array): any;
}
export interface SchemaVersion {
version: number;
schema: BitPackerSchema;
description?: string | undefined;
createdAt?: Date;
}
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
export interface SchemaMigration<_T = any, _U = any> {
fromVersion: number;
toVersion: number;
migrate: MigrationFunction<_T, _U>;
description?: string | undefined;
}
export interface VersionedSchemaOptions {
name: string;
namespace?: string;
description?: string;
autoMigrate?: boolean;
includeVersion?: boolean;
bitPacker?: BitPacker;
}
function createBitPackerInstance(
schema: BitPackerSchema,
bitPacker: BitPacker
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
): BitPackerInterface<any> {
return {
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
pack: (data: any) => bitPacker.pack(data, schema),
unpack: (data: Uint8Array) => bitPacker.unpack(data, schema),
};
}
export class VersionedSchema<T extends BitPackerSchema = BitPackerSchema> {
private _versions: SchemaVersion[] = [];
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
private _migrations: SchemaMigration<any, any>[] = [];
private _options: Required<Omit<VersionedSchemaOptions, "bitPacker">>;
private _latestVersion = 0;
private readonly _bitPacker: BitPacker;
constructor(initialSchema: T, options: VersionedSchemaOptions) {
this._options = {
name: options.name,
namespace: options.namespace || "default",
description: options.description || "",
autoMigrate: options.autoMigrate !== false,
includeVersion: options.includeVersion !== false,
};
this._bitPacker = options.bitPacker ?? new BitPacker();
this.addVersion(initialSchema, "Initial version");
}
get name(): string {
return this._options.name;
}
get namespace(): string {
return this._options.namespace;
}
get description(): string {
return this._options.description;
}
get latestVersion(): number {
return this._latestVersion;
}
get latestSchema(): BitPackerSchema {
const latestVersion = this._versions[this._versions.length - 1];
if (!latestVersion) {
throw new Error("No versions available");
}
return latestVersion.schema;
}
get versions(): SchemaVersion[] {
return [...this._versions];
}
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
get migrations(): SchemaMigration<any, any>[] {
return [...this._migrations];
}
public addVersion(schema: BitPackerSchema, description?: string): number {
const newVersion = this._latestVersion + 1;
this._versions.push({
version: newVersion,
schema,
description,
createdAt: new Date(),
});
this._latestVersion = newVersion;
return newVersion;
}
public addMigration<U extends BitPackerSchema>(
fromVersion: number,
toVersion: number,
migrationFn: MigrationFunction<T, U>,
description?: string
): void {
if (fromVersion < 1 || fromVersion > this._latestVersion) {
throw new Error(
`Invalid source version: ${fromVersion}. Must be between 1 and ${this._latestVersion}.`
);
}
if (toVersion < 1 || toVersion > this._latestVersion) {
throw new Error(
`Invalid target version: ${toVersion}. Must be between 1 and ${this._latestVersion}.`
);
}
if (fromVersion === toVersion) {
throw new Error(
`Source and target versions cannot be the same: ${fromVersion}.`
);
}
const existingMigration = this._migrations.find(
(m) => m.fromVersion === fromVersion && m.toVersion === toVersion
);
if (existingMigration) {
throw new Error(
`Migration from version ${fromVersion} to ${toVersion} already exists.`
);
}
this._migrations.push({
fromVersion,
toVersion,
migrate: migrationFn,
description,
});
}
public getSchema(version: number): BitPackerSchema {
const schemaVersion = this._versions.find((v) => v.version === version);
if (!schemaVersion) {
throw new Error(`Schema version ${version} not found.`);
}
return schemaVersion.schema;
}
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
public getBitPacker(version: number): BitPackerInterface<any> {
const schema = this.getSchema(version);
if (this._options.includeVersion) {
const wrappedSchema = {
_version: version,
_data: schema,
};
return createBitPackerInstance(wrappedSchema, this._bitPacker);
}
return createBitPackerInstance(schema, this._bitPacker);
}
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
public getLatestBitPacker(): BitPackerInterface<any> {
return this.getBitPacker(this._latestVersion);
}
public migrateData(
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
data: any,
fromVersion: number,
toVersion: number = this._latestVersion
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
): any {
if (fromVersion === toVersion) {
return data;
}
if (fromVersion < 1 || fromVersion > this._latestVersion) {
throw new Error(
`Invalid source version: ${fromVersion}. Must be between 1 and ${this._latestVersion}.`
);
}
if (toVersion < 1 || toVersion > this._latestVersion) {
throw new Error(
`Invalid target version: ${toVersion}. Must be between 1 and ${this._latestVersion}.`
);
}
const migrationPath = this.findMigrationPath(fromVersion, toVersion);
if (!migrationPath) {
throw new Error(
`No migration path found from version ${fromVersion} to ${toVersion}.`
);
}
let migratedData = data;
for (const migration of migrationPath) {
migratedData = migration.migrate(migratedData);
}
return migratedData;
}
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
public pack(data: any, version: number = this._latestVersion): Uint8Array {
const schema = this.getSchema(version);
if (this._options.includeVersion) {
const wrappedData = {
_version: version,
_data: data,
};
const wrappedSchema = {
_version: version,
_data: schema,
};
return this._bitPacker.pack(wrappedData, wrappedSchema);
}
return this._bitPacker.pack(data, schema);
}
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
public unpack(packedData: Uint8Array): any {
if (this._options.includeVersion) {
const versionSchema = {
_version: 0,
_data: null as unknown as BitPackerSchema,
};
const unpackedVersion = this._bitPacker.unpack(
packedData,
versionSchema
);
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
const _version = (unpackedVersion as any)._version as number;
const schema = this.getSchema(_version);
const wrappedSchema = {
_version: _version,
_data: schema,
};
const unpacked = this._bitPacker.unpack(packedData, wrappedSchema);
const data =
unpacked && typeof unpacked === "object"
? // biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
(unpacked as any)._data
: unpacked;
if (this._options.autoMigrate && _version < this._latestVersion) {
return this.migrateData(data, _version);
}
return data;
}
return this._bitPacker.unpack(packedData, this.latestSchema);
}
private findMigrationPath(
fromVersion: number,
toVersion: number
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
): SchemaMigration<any, any>[] | null {
const directMigration = this._migrations.find(
(m) => m.fromVersion === fromVersion && m.toVersion === toVersion
);
if (directMigration) {
return [directMigration];
}
const queue: Array<{
version: number;
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
path: SchemaMigration<any, any>[];
}> = [{ version: fromVersion, path: [] }];
const visited = new Set<number>([fromVersion]);
while (queue.length > 0) {
const { version, path } = queue.shift()!;
const nextMigrations = this._migrations.filter(
(m) => m.fromVersion === version
);
for (const migration of nextMigrations) {
if (migration.toVersion === toVersion) {
return [...path, migration];
}
if (!visited.has(migration.toVersion)) {
visited.add(migration.toVersion);
queue.push({
version: migration.toVersion,
path: [...path, migration],
});
}
}
}
return null;
}
}
export function extractVersion(
packedData: Uint8Array,
bitPacker?: BitPacker
): number | null {
try {
const bp = bitPacker ?? new BitPacker();
const versionSchema = {
_version: 0,
_data: null as unknown as BitPackerSchema,
};
const unpacked = bp.unpack(packedData, versionSchema);
// biome-ignore lint/suspicious/noExplicitAny: ported from suaveplan — generic type fallback
return (unpacked as any)._version as number;
} catch (_error) {
return null;
}
}
export function hasVersionInfo(
packedData: Uint8Array,
bitPacker?: BitPacker
): boolean {
return extractVersion(packedData, bitPacker) !== null;
}

View File

@@ -0,0 +1,15 @@
export type {
SchemaFieldInfo,
SchemaVisualizationOptions,
SchemaVisualizationResult,
} from "./visualization.js";
export {
extractSchemaFieldInfo,
SchemaVisualizationFormat,
visualizeSchema,
visualizeSchemaAsDot,
visualizeSchemaAsHtml,
visualizeSchemaAsJson,
visualizeSchemaAsMarkdown,
visualizeSchemaAsText,
} from "./visualization.js";

View File

@@ -0,0 +1,67 @@
# Schema visualization
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The visualization module renders a `BitPackerSchema` into one of five inspection-friendly formats — JSON, HTML, Markdown, plain text, or Graphviz DOT — for tooling, documentation, and debugging. Schemas are JSON-serialisable but their nested structure (objects of `_optional`, `_oneOf`, `_conditional`, primitive tokens, arrays) is fiddly to read at scale; this module produces a renderable tree view that surfaces the structure at a glance. It is the layer that downstream developer tools (schema explorers, doc generators, registry UIs) consume to display the contents of the registry without parsing the schema themselves.
## Features
- Five output formats (`SchemaVisualizationFormat`): `JSON`, `HTML`, `MARKDOWN`, `TEXT`, `DOT`.
- Configurable via `SchemaVisualizationOptions` (format, depth limit, include/exclude metadata).
- Recursive descent through nested objects, arrays, and the `_optional` / `_oneOf` / `_conditional` operators.
- Recognises the canonical primitive tokens (UUID, float, integer, string).
## Usage: Basic Example
```ts
import {
visualizeSchema,
SchemaVisualizationFormat,
} from "@suaveplan/bitpacker-schema";
const html = visualizeSchema(
{ name: "-16", age: 255, tags: [{ _optional: "-32" }] },
{ format: SchemaVisualizationFormat.HTML }
);
```
## Usage: Advanced Example
```ts
import {
visualizeSchema,
SchemaVisualizationFormat,
} from "@suaveplan/bitpacker-schema";
// Render a Graphviz DOT graph for a docs site.
const dot = visualizeSchema(playerSchema, {
format: SchemaVisualizationFormat.DOT,
maxDepth: 5,
});
// Pipe through `dot -Tsvg` to produce an SVG of the schema tree.
```
## API Reference
### `enum SchemaVisualizationFormat`
`JSON | HTML | MARKDOWN | TEXT | DOT` — pick the renderer to apply.
### `interface SchemaVisualizationOptions`
- `format?: SchemaVisualizationFormat` — default `JSON`.
- `maxDepth?: number` — recursion depth limit; deeper nodes render as `…`.
- `includeMetadata?: boolean` — include `description`, `tags`, etc. when present.
### `visualizeSchema(schema, options?): string`
Renders the schema as a string in the requested format. Returns valid syntax for the chosen format (parseable JSON, render-ready HTML, etc.).
## Implementation Notes
- HTML output is intentionally semantic (`<details>` / `<summary>` for nesting) so the result is keyboard-navigable and accessible without extra CSS.
- DOT output names every node deterministically (path-based IDs) so two renders of the same schema produce identical graphs — useful for regression-diffing schema docs.
- The renderer handles cycles via a max-depth limit, not via cycle detection. `BitPackerSchema` is a tree by construction, but `_ref` lookups can produce structural cycles; in practice the `maxDepth` ceiling keeps the renderer linear.

View File

@@ -0,0 +1,847 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { isOptionalSchema } from "@suaveplan/bitpacker";
import type { BitPackerSchema } from "@suaveplan/types";
export enum SchemaVisualizationFormat {
JSON = "json",
HTML = "html",
MARKDOWN = "markdown",
TEXT = "text",
DOT = "dot",
}
export interface SchemaVisualizationOptions {
format?: SchemaVisualizationFormat;
includeBitSizes?: boolean;
includeTypes?: boolean;
includeDescriptions?: boolean;
maxDepth?: number;
title?: string;
description?: string;
customCss?: string;
theme?: "default" | "dark" | "light" | "monochrome";
}
export interface SchemaFieldInfo {
name: string;
type: string;
description?: string;
bitSize?: number;
optional: boolean;
fields?: SchemaFieldInfo[];
elementType?: string;
maxLength?: number;
maxValue?: number;
signed?: boolean;
precision?: number;
}
export interface SchemaVisualizationResult {
content: string;
format: SchemaVisualizationFormat;
mimeType: string;
fileExtension: string;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function calculateBitSize(schema: BitPackerSchema): number {
if (schema === null) {
return 0;
}
if (typeof schema === "boolean") {
return 1;
}
if (typeof schema === "number") {
if (Number.isInteger(schema)) {
return (
Math.ceil(Math.log2(Math.abs(schema) + 1)) +
(schema < 0 ? 1 : 0)
);
}
const [whole, fraction] = String(schema).split(".");
const wholeBits =
Math.ceil(Math.log2(Math.abs(Number(whole)) + 1)) +
(Number(whole) < 0 ? 1 : 0);
const fractionBits = Math.ceil(Math.log2(Number(`1${fraction}`)));
return wholeBits + fractionBits;
}
if (typeof schema === "string") {
if (schema === "uuid") {
return 128;
}
if (schema.startsWith("f")) {
const bits = Number(schema.slice(1));
return bits;
}
const isFixed = !schema.startsWith("-");
const length = Number(isFixed ? schema : schema.slice(1));
const lengthBits = isFixed ? 0 : Math.ceil(Math.log2(length + 1));
return lengthBits + length * 8;
}
if (Array.isArray(schema)) {
const hasMaxLength =
schema.length === 2 && typeof schema[0] === "number";
const maxLength = hasMaxLength ? (schema[0] as number) : undefined;
const elementSchema = hasMaxLength ? schema[1]! : schema[0]!;
const lengthBits =
maxLength !== undefined ? Math.ceil(Math.log2(maxLength + 1)) : 0;
return lengthBits + calculateBitSize(elementSchema);
}
if (isObject(schema)) {
if (isOptionalSchema(schema)) {
return 1 + calculateBitSize(schema._optional);
}
if (
"_bp" in schema &&
typeof schema["_bp"] === "object" &&
schema["_bp"] !== null &&
"schema" in schema["_bp"]
) {
return calculateBitSize(
(schema as { _bp: { schema: BitPackerSchema } })["_bp"].schema
);
}
let totalBits = 0;
for (const key in schema) {
if (Object.hasOwn(schema, key)) {
totalBits += calculateBitSize(
(schema as Record<string, BitPackerSchema>)[key]!
);
}
}
return totalBits;
}
return 0;
}
function getTypeName(schema: BitPackerSchema): string {
if (schema === null) {
return "null";
}
if (typeof schema === "boolean") {
return "boolean";
}
if (typeof schema === "number") {
if (Number.isInteger(schema)) {
return schema < 0 ? "signed integer" : "unsigned integer";
}
return "fixed-point number";
}
if (typeof schema === "string") {
if (schema === "uuid") {
return "uuid";
}
if (schema.startsWith("f")) {
return `float${schema.slice(1)}`;
}
const isFixed = !schema.startsWith("-");
return isFixed ? "fixed-length string" : "variable-length string";
}
if (Array.isArray(schema)) {
return "array";
}
if (isObject(schema)) {
if (isOptionalSchema(schema)) {
return `optional ${getTypeName(schema._optional)}`;
}
if (
"_bp" in schema &&
typeof schema["_bp"] === "object" &&
schema["_bp"] !== null &&
"schema" in schema["_bp"]
) {
return `custom ${getTypeName(
(schema as { _bp: { schema: BitPackerSchema } })["_bp"].schema
)}`;
}
return "object";
}
return "unknown";
}
export function extractSchemaFieldInfo(
schema: BitPackerSchema,
name: string,
depth = 0,
maxDepth = 10
): SchemaFieldInfo {
const fieldInfo: SchemaFieldInfo = {
name,
type: getTypeName(schema),
optional: false,
bitSize: calculateBitSize(schema),
};
if (isObject(schema) && "_description" in schema) {
fieldInfo.description = (schema as Record<string, unknown>)[
"_description"
] as string;
}
if (schema === null) {
return fieldInfo;
}
if (typeof schema === "boolean") {
return fieldInfo;
}
if (typeof schema === "number") {
if (Number.isInteger(schema)) {
fieldInfo.maxValue = Math.abs(schema);
fieldInfo.signed = schema < 0;
} else {
const [whole, fraction] = String(schema).split(".");
fieldInfo.maxValue = Math.abs(Number(whole));
fieldInfo.signed = Number(whole) < 0;
fieldInfo.precision = fraction ? fraction.length : 0;
}
return fieldInfo;
}
if (typeof schema === "string") {
if (schema === "uuid") {
return fieldInfo;
}
if (schema.startsWith("f")) {
return fieldInfo;
}
const isFixed = !schema.startsWith("-");
const length = Number(isFixed ? schema : schema.slice(1));
fieldInfo.maxLength = length;
return fieldInfo;
}
if (Array.isArray(schema)) {
const hasMaxLength =
schema.length === 2 && typeof schema[0] === "number";
const maxLength = hasMaxLength ? (schema[0] as number) : undefined;
const elementSchema = hasMaxLength ? schema[1]! : schema[0]!;
if (maxLength !== undefined) {
fieldInfo.maxLength = maxLength;
}
if (depth < maxDepth) {
fieldInfo.fields = [
extractSchemaFieldInfo(
elementSchema,
"[element]",
depth + 1,
maxDepth
),
];
}
return fieldInfo;
}
if (isObject(schema)) {
if (isOptionalSchema(schema)) {
fieldInfo.optional = true;
const innerInfo = extractSchemaFieldInfo(
schema._optional,
name,
depth,
maxDepth
);
return {
...innerInfo,
optional: true,
bitSize: 1 + (innerInfo.bitSize || 0),
};
}
if (
"_bp" in schema &&
typeof schema["_bp"] === "object" &&
schema["_bp"] !== null &&
"schema" in schema["_bp"]
) {
const innerInfo = extractSchemaFieldInfo(
(schema as { _bp: { schema: BitPackerSchema } })["_bp"].schema,
name,
depth,
maxDepth
);
return {
...innerInfo,
bitSize: innerInfo.bitSize || 0,
};
}
if (depth < maxDepth) {
fieldInfo.fields = Object.entries(schema)
.filter(([key]) => key !== "_description")
.map(([key, value]) =>
extractSchemaFieldInfo(
value as BitPackerSchema,
key,
depth + 1,
maxDepth
)
);
}
return fieldInfo;
}
return fieldInfo;
}
function generateJsonVisualization(
schema: BitPackerSchema,
options: Required<SchemaVisualizationOptions>
): string {
const rootInfo = extractSchemaFieldInfo(
schema,
"root",
0,
options.maxDepth
);
const visualization = {
title: options.title || "Schema Visualization",
description: options.description || "",
totalBitSize: rootInfo.bitSize,
schema: rootInfo,
};
return JSON.stringify(visualization, null, 2);
}
function generateHtmlVisualization(
schema: BitPackerSchema,
options: Required<SchemaVisualizationOptions>
): string {
const rootInfo = extractSchemaFieldInfo(
schema,
"root",
0,
options.maxDepth
);
let themeCss = "";
switch (options.theme) {
case "dark":
themeCss = `
body { background-color: #1e1e1e; color: #d4d4d4; }
.schema-container { background-color: #252526; }
.field { border-color: #3c3c3c; }
.field-header { background-color: #2d2d2d; }
.field-name { color: #9cdcfe; }
.field-type { color: #4ec9b0; }
.field-bit-size { color: #b5cea8; }
.field-optional { color: #ce9178; }
.field-children { border-color: #3c3c3c; }
`;
break;
case "light":
themeCss = `
body { background-color: #ffffff; color: #000000; }
.schema-container { background-color: #f5f5f5; }
.field { border-color: #d0d0d0; }
.field-header { background-color: #e8e8e8; }
.field-name { color: #0000ff; }
.field-type { color: #267f99; }
.field-bit-size { color: #098658; }
.field-optional { color: #a31515; }
.field-children { border-color: #d0d0d0; }
`;
break;
case "monochrome":
themeCss = `
body { background-color: #ffffff; color: #000000; }
.schema-container { background-color: #f5f5f5; }
.field { border-color: #d0d0d0; }
.field-header { background-color: #e8e8e8; }
.field-name { font-weight: bold; }
.field-type { font-style: italic; }
.field-bit-size { }
.field-optional { text-decoration: underline; }
.field-children { border-color: #d0d0d0; }
`;
break;
default:
themeCss = `
body { background-color: #f8f8f8; color: #333; }
.schema-container { background-color: #fff; }
.field { border-color: #ddd; }
.field-header { background-color: #f0f0f0; }
.field-name { color: #0066cc; }
.field-type { color: #008800; }
.field-bit-size { color: #880000; }
.field-optional { color: #cc6600; }
.field-children { border-color: #ddd; }
`;
break;
}
const generateFieldHtml = (
field: SchemaFieldInfo,
depth: number
): string => {
const indent = " ".repeat(depth);
let html = `${indent}<div class="field">\n${indent} <div class="field-header">\n${indent} <span class="field-name">${field.name}</span>\n${indent} <span class="field-type">${field.type}</span>`;
if (options.includeBitSizes && field.bitSize !== undefined) {
html += `\n${indent} <span class="field-bit-size">${field.bitSize} bits</span>`;
}
if (field.optional) {
html += `\n${indent} <span class="field-optional">optional</span>`;
}
html += `\n${indent} </div>`;
if (options.includeDescriptions && field.description) {
html += `\n${indent} <div class="field-description">${field.description}</div>`;
}
if (field.fields && field.fields.length > 0) {
html += `\n${indent} <div class="field-children">\n${field.fields.map((child) => generateFieldHtml(child, depth + 2)).join("\n")}\n${indent} </div>`;
}
html += `\n${indent}</div>`;
return html;
};
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${options.title || "Schema Visualization"}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 20px;
}
.schema-container {
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
margin: 0 auto;
max-width: 1000px;
padding: 20px;
}
.schema-header {
margin-bottom: 20px;
}
.schema-title {
font-size: 24px;
font-weight: bold;
margin: 0 0 10px 0;
}
.schema-description {
color: #666;
margin: 0 0 10px 0;
}
.schema-bit-size {
font-weight: bold;
}
.field {
border-left: 2px solid;
margin: 5px 0;
padding-left: 10px;
}
.field-header {
border-radius: 3px;
padding: 5px;
}
.field-name {
font-weight: bold;
margin-right: 10px;
}
.field-type {
font-style: italic;
margin-right: 10px;
}
.field-bit-size {
margin-right: 10px;
}
.field-optional {
font-weight: bold;
}
.field-description {
color: #666;
font-size: 14px;
margin: 5px 0 5px 5px;
}
.field-children {
border-left: 1px dashed;
margin-left: 10px;
padding-left: 10px;
}
${themeCss}
${options.customCss || ""}
</style>
</head>
<body>
<div class="schema-container">
<div class="schema-header">
<h1 class="schema-title">${options.title || "Schema Visualization"}</h1>
${options.description ? `<p class="schema-description">${options.description}</p>` : ""}
${
options.includeBitSizes && rootInfo.bitSize !== undefined
? `<p class="schema-bit-size">Total size: ${
rootInfo.bitSize
} bits (${Math.ceil(rootInfo.bitSize / 8)} bytes)</p>`
: ""
}
</div>
${generateFieldHtml(rootInfo, 2)}
</div>
</body>
</html>`;
return html;
}
function generateMarkdownVisualization(
schema: BitPackerSchema,
options: Required<SchemaVisualizationOptions>
): string {
const rootInfo = extractSchemaFieldInfo(
schema,
"root",
0,
options.maxDepth
);
const generateFieldMarkdown = (
field: SchemaFieldInfo,
depth: number
): string => {
const indent = " ".repeat(depth);
let markdown = `${indent}- **${field.name}** (${field.type})`;
if (options.includeBitSizes && field.bitSize !== undefined) {
markdown += ` - ${field.bitSize} bits`;
}
if (field.optional) {
markdown += " - *optional*";
}
if (options.includeDescriptions && field.description) {
markdown += `\n${indent} ${field.description}`;
}
if (field.fields && field.fields.length > 0) {
markdown += `\n${field.fields.map((child) => generateFieldMarkdown(child, depth + 1)).join("\n")}`;
}
return markdown;
};
let markdown = `# ${options.title || "Schema Visualization"}\n\n`;
if (options.description) {
markdown += `${options.description}\n\n`;
}
if (options.includeBitSizes && rootInfo.bitSize !== undefined) {
markdown += `**Total size:** ${rootInfo.bitSize} bits (${Math.ceil(
rootInfo.bitSize / 8
)} bytes)\n\n`;
}
markdown += generateFieldMarkdown(rootInfo, 0);
return markdown;
}
function generateTextVisualization(
schema: BitPackerSchema,
options: Required<SchemaVisualizationOptions>
): string {
const rootInfo = extractSchemaFieldInfo(
schema,
"root",
0,
options.maxDepth
);
const generateFieldText = (
field: SchemaFieldInfo,
depth: number
): string => {
const indent = " ".repeat(depth);
let text = `${indent}${field.name} (${field.type})`;
if (options.includeBitSizes && field.bitSize !== undefined) {
text += ` - ${field.bitSize} bits`;
}
if (field.optional) {
text += " - optional";
}
if (options.includeDescriptions && field.description) {
text += `\n${indent} ${field.description}`;
}
if (field.fields && field.fields.length > 0) {
text += `\n${field.fields.map((child) => generateFieldText(child, depth + 1)).join("\n")}`;
}
return text;
};
let text = `${options.title || "Schema Visualization"}\n`;
text += `${"=".repeat((options.title || "Schema Visualization").length)}\n\n`;
if (options.description) {
text += `${options.description}\n\n`;
}
if (options.includeBitSizes && rootInfo.bitSize !== undefined) {
text += `Total size: ${rootInfo.bitSize} bits (${Math.ceil(rootInfo.bitSize / 8)} bytes)\n\n`;
}
text += generateFieldText(rootInfo, 0);
return text;
}
function generateDotVisualization(
schema: BitPackerSchema,
options: Required<SchemaVisualizationOptions>
): string {
const rootInfo = extractSchemaFieldInfo(
schema,
"root",
0,
options.maxDepth
);
const getNodeId = (path: string): string => {
return `node_${path.replace(/[^a-zA-Z0-9]/g, "_")}`;
};
const generateFieldDot = (
field: SchemaFieldInfo,
path: string,
nodes: string[],
edges: string[]
) => {
const nodeId = getNodeId(path);
let label = field.name;
if (options.includeTypes) {
label += `\\n(${field.type})`;
}
if (options.includeBitSizes && field.bitSize !== undefined) {
label += `\\n${field.bitSize} bits`;
}
if (field.optional) {
label += "\\n[optional]";
}
if (options.includeDescriptions && field.description) {
label += `\\n"${field.description}"`;
}
const shape =
field.fields && field.fields.length > 0 ? "box" : "ellipse";
const style = field.optional ? "dashed" : "solid";
nodes.push(
` ${nodeId} [label="${label}", shape=${shape}, style=${style}];`
);
if (field.fields && field.fields.length > 0) {
for (const child of field.fields) {
const childPath = `${path}.${child.name}`;
const childNodeId = getNodeId(childPath);
edges.push(` ${nodeId} -> ${childNodeId};`);
generateFieldDot(child, childPath, nodes, edges);
}
}
};
const nodes: string[] = [];
const edges: string[] = [];
generateFieldDot(rootInfo, "root", nodes, edges);
let dot = "digraph Schema {\n";
dot += " // Graph settings\n";
dot += ` graph [rankdir=LR, fontname="Arial", fontsize=12, nodesep=0.8, ranksep=1.0];\n`;
dot += ` node [fontname="Arial", fontsize=10, margin="0.3,0.1"];\n`;
dot += ` edge [fontname="Arial", fontsize=8];\n\n`;
if (options.title) {
dot += " // Title\n";
dot += ` labelloc="t";\n`;
dot += ` label="${options.title}";\n\n`;
}
dot += " // Nodes\n";
dot += `${nodes.join("\n")}\n\n`;
dot += " // Edges\n";
dot += `${edges.join("\n")}\n`;
dot += "}";
return dot;
}
export function visualizeSchema(
schema: BitPackerSchema,
options: SchemaVisualizationOptions = {}
): SchemaVisualizationResult {
const defaultOptions: Required<SchemaVisualizationOptions> = {
format: SchemaVisualizationFormat.JSON,
includeBitSizes: true,
includeTypes: true,
includeDescriptions: true,
maxDepth: 10,
title: "",
description: "",
customCss: "",
theme: "default",
};
const mergedOptions: Required<SchemaVisualizationOptions> = {
...defaultOptions,
...options,
};
let content: string;
let mimeType: string;
let fileExtension: string;
switch (mergedOptions.format) {
case SchemaVisualizationFormat.HTML:
content = generateHtmlVisualization(schema, mergedOptions);
mimeType = "text/html";
fileExtension = "html";
break;
case SchemaVisualizationFormat.MARKDOWN:
content = generateMarkdownVisualization(schema, mergedOptions);
mimeType = "text/markdown";
fileExtension = "md";
break;
case SchemaVisualizationFormat.TEXT:
content = generateTextVisualization(schema, mergedOptions);
mimeType = "text/plain";
fileExtension = "txt";
break;
case SchemaVisualizationFormat.DOT:
content = generateDotVisualization(schema, mergedOptions);
mimeType = "text/vnd.graphviz";
fileExtension = "dot";
break;
default:
content = generateJsonVisualization(schema, mergedOptions);
mimeType = "application/json";
fileExtension = "json";
break;
}
return {
content,
format: mergedOptions.format,
mimeType,
fileExtension,
};
}
export function visualizeSchemaAsJson(
schema: BitPackerSchema,
options: Omit<SchemaVisualizationOptions, "format"> = {}
): SchemaVisualizationResult {
return visualizeSchema(schema, {
...options,
format: SchemaVisualizationFormat.JSON,
});
}
export function visualizeSchemaAsHtml(
schema: BitPackerSchema,
options: Omit<SchemaVisualizationOptions, "format"> = {}
): SchemaVisualizationResult {
return visualizeSchema(schema, {
...options,
format: SchemaVisualizationFormat.HTML,
});
}
export function visualizeSchemaAsMarkdown(
schema: BitPackerSchema,
options: Omit<SchemaVisualizationOptions, "format"> = {}
): SchemaVisualizationResult {
return visualizeSchema(schema, {
...options,
format: SchemaVisualizationFormat.MARKDOWN,
});
}
export function visualizeSchemaAsText(
schema: BitPackerSchema,
options: Omit<SchemaVisualizationOptions, "format"> = {}
): SchemaVisualizationResult {
return visualizeSchema(schema, {
...options,
format: SchemaVisualizationFormat.TEXT,
});
}
export function visualizeSchemaAsDot(
schema: BitPackerSchema,
options: Omit<SchemaVisualizationOptions, "format"> = {}
): SchemaVisualizationResult {
return visualizeSchema(schema, {
...options,
format: SchemaVisualizationFormat.DOT,
});
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "istanbul",
},
},
});