first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
# compatibility
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements forward, backward, and full compatibility checking between JSON Schema documents. This module is the core enforcement engine called during schema registration to reject changes that would break producers or consumers of existing data. It is stateless and pure — no registry state is touched; callers supply two schema objects and receive a structured `CompatibilityResult`.
The module currently provides deep analysis only for `json-schema` format. For `zod`, `avro`, and `custom` formats the candidate schema is accepted with an informational note, allowing those schemas to be registered while the host application adds its own format-specific validation.
## Features
- **BACKWARD** check: ensures the new schema can read data written by the old schema. Detects added required fields absent in old data, removed properties that old writers expected, and property type changes.
- **FORWARD** check: ensures the old schema can read data written by the new schema. Detects new properties when `additionalProperties: false` is set, removal of required fields, and property type changes.
- **FULL** check: both BACKWARD and FORWARD must pass simultaneously.
- **NONE** level: always returns `compatible: true` — useful for experimental subjects.
- Structured `CompatibilityResult` with a `compatible` boolean, the `level` that was applied, and a `string[]` of human-readable error descriptions.
- Pure function — no side effects, no registry state, injectable test inputs.
## Usage: Basic Example
```ts
import { checkCompatibility } from "@suaveplan/schema-registry/compatibility";
const existing = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
},
required: ["name"],
};
const candidate = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number" },
email: { type: "string" }, // new optional field
},
required: ["name"],
};
const result = checkCompatibility(candidate, existing, "BACKWARD", "json-schema");
// result.compatible === true (new optional field is fine for backward compat)
// result.errors === []
```
## Usage: Advanced Example
```ts
import { checkCompatibility } from "@suaveplan/schema-registry/compatibility";
// FULL compatibility — both directions must pass
const v1 = {
type: "object",
properties: { id: { type: "string" }, amount: { type: "number" } },
required: ["id"],
};
// Breaking: adds a NEW required field — old data won't have it
const v2Breaking = {
type: "object",
properties: { id: { type: "string" }, amount: { type: "number" }, currency: { type: "string" } },
required: ["id", "currency"],
};
const full = checkCompatibility(v2Breaking, v1, "FULL", "json-schema");
console.log(full.compatible); // false
console.log(full.errors);
// ["Field 'currency' is required in the new schema but absent in old data — BACKWARD incompatible"]
// Non-json-schema format — always accepted
const zodResult = checkCompatibility(null, null, "BACKWARD", "zod");
console.log(zodResult.compatible); // true
console.log(zodResult.errors[0]); // note about format not being deeply checked
```
## API Reference
### `checkCompatibility(newSchema, existingSchema, level, format)`
Checks whether `newSchema` is compatible with `existingSchema` under the given `level` and `format`.
**Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `newSchema` | `unknown` | The candidate schema to be registered |
| `existingSchema` | `unknown` | The current latest schema for the subject |
| `level` | `CompatibilityLevel` | `"BACKWARD"`, `"FORWARD"`, `"FULL"`, or `"NONE"` |
| `format` | `SchemaFormat` | `"json-schema"`, `"zod"`, `"avro"`, or `"custom"` |
**Returns:** `CompatibilityResult`
| Field | Type | Description |
|-------|------|-------------|
| `compatible` | `boolean` | `true` if no breaking changes detected |
| `level` | `CompatibilityLevel` | Echo of the level that was applied |
| `errors` | `string[]` | Human-readable breaking-change descriptions |
**Throws:** Never — all error conditions are reflected in `CompatibilityResult.errors`.
## Implementation Notes
**BACKWARD semantics:** A new schema can read old data if it doesn't require fields that didn't exist before, and doesn't remove fields that old writers set. The check inspects `required` additions and `properties` removals. Adding optional fields (present in `properties` but not `required`) is always safe.
**FORWARD semantics:** An old schema can read new data if the old schema doesn't reject unknown fields via `additionalProperties: false`. The check also catches cases where the new schema removes fields that the old schema declares as required.
**Type comparison:** Types are normalised by sorting array-type unions (`["string","null"]` → `"null|string"`) before comparing, preventing false positives from ordering differences.
**Non-JSON-Schema formats:** Zod schemas are runtime objects (not plain POJOs in the registry sense) and Avro schemas have their own evolution rules. Deep analysis for those formats is deferred; the module returns `compatible: true` with an informational note rather than a false `compatible: false`.

View File

@@ -0,0 +1,168 @@
# schema-registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements `SchemaRegistry`, the central in-memory store for versioned schema definitions. Each subject maintains an ordered list of schema versions; registration atomically assigns the next version number and enforces the subject's compatibility level before persisting the new version. The registry emits OpenTelemetry counters and histograms for observability.
This module is the single integration point for applications: all register, retrieve, list, delete, and compatibility-check operations go through `SchemaRegistry` or the `createRegistry` factory.
## Features
- **Versioned storage** per subject — version numbers start at 1 and increment monotonically; old data is preserved.
- **Compatibility enforcement** on registration — rejects candidates that violate BACKWARD / FORWARD / FULL rules; NONE disables enforcement.
- **Per-subject compatibility override** via `setCompatibility()` — subjects can opt into stricter or looser rules at any time.
- **Read API** — `getLatest`, `getVersion`, `getAll` for point-in-time access.
- **`checkCompatibility`** — dry-run check without side effects; returns structured `CompatibilityResult`.
- **`listSubjects`** / `deleteSubject`** — subject enumeration and removal.
- **`clear()`** — wipes all state; useful for test isolation.
- **OTel instrumentation** — `schema_registry.register.total` counter and `schema_registry.check.duration_ms` histogram.
- **Version pruning** — enforces `maxVersionsPerSubject` cap by dropping oldest versions.
## Usage: Basic Example
```ts
import { createRegistry } from "@suaveplan/schema-registry/registry";
const registry = createRegistry();
// Register first version
const v1 = registry.register("OrderPlaced", {
type: "object",
properties: { orderId: { type: "string" }, amount: { type: "number" } },
required: ["orderId"],
});
console.log(v1.version); // 1
console.log(v1.id); // "OrderPlaced#1"
// Register second compatible version
const v2 = registry.register("OrderPlaced", {
type: "object",
properties: {
orderId: { type: "string" },
amount: { type: "number" },
currency: { type: "string" }, // new optional field — backward compatible
},
required: ["orderId"],
});
console.log(v2.version); // 2
// Retrieve
const latest = registry.getLatest("OrderPlaced");
console.log(latest.version); // 2
```
## Usage: Advanced Example
```ts
import {
createRegistry,
SchemaRegistry,
} from "@suaveplan/schema-registry/registry";
import {
SchemaRegistryError,
SCHEMA_REGISTRY_CODES,
} from "@suaveplan/schema-registry/errors";
// FULL compatibility registry
const registry = createRegistry({ defaultCompatibility: "FULL" });
// Subject-level override
registry.register("Events.Lax", { type: "object" });
registry.setCompatibility("Events.Lax", "NONE");
// Dry-run check
registry.register("Payments.Order", {
type: "object",
properties: { id: { type: "string" } },
required: ["id"],
});
const result = registry.checkCompatibility("Payments.Order", {
type: "object",
properties: { id: { type: "string" }, ref: { type: "string" } },
required: ["id"],
});
console.log(result.compatible); // true — no breaking change
// Error handling
try {
registry.register("Payments.Order", {
type: "object",
properties: { id: { type: "string" }, ref: { type: "string" } },
required: ["id", "ref"], // adding required field — BACKWARD incompatible
});
} catch (err) {
if (err instanceof SchemaRegistryError) {
console.log(err.code); // "SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA"
}
}
// List and delete
const subjects = registry.listSubjects();
registry.deleteSubject("Events.Lax");
```
## API Reference
### `SchemaRegistry`
```ts
class SchemaRegistry
```
#### `constructor(options?: RegistryOptions)`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `defaultCompatibility` | `CompatibilityLevel` | `"BACKWARD"` | Applied to new subjects on first registration |
| `maxVersionsPerSubject` | `number` | `1000` | Cap on retained versions per subject |
#### `register(subject, schema, format?, metadata?): SchemaDefinition`
Registers a new schema version. Throws `INCOMPATIBLE_SCHEMA` if the candidate violates the subject's compatibility level. Throws `INVALID_SCHEMA` for null/empty inputs.
#### `getLatest(subject): SchemaDefinition`
Returns the highest-version schema for the subject. Throws `SUBJECT_NOT_FOUND` if subject is absent.
#### `getVersion(subject, version): SchemaDefinition`
Returns a specific version. Throws `SUBJECT_NOT_FOUND` or `SCHEMA_NOT_FOUND` as appropriate.
#### `getAll(subject): SchemaDefinition[]`
Returns all versions sorted ascending. Throws `SUBJECT_NOT_FOUND` if subject is absent.
#### `listSubjects(): string[]`
Returns names of all subjects with at least one registered version.
#### `deleteSubject(subject): void`
Removes all versions for a subject. Throws `SUBJECT_NOT_FOUND` if absent.
#### `setCompatibility(subject, level): void`
Sets the compatibility level for a subject. Creates a placeholder if the subject is new.
#### `checkCompatibility(subject, candidateSchema, format?): CompatibilityResult`
Dry-runs a compatibility check without registering. Throws `SUBJECT_NOT_FOUND` if subject is absent.
#### `clear(): void`
Removes all subjects. Useful for test teardown.
### `createRegistry(options?): SchemaRegistry`
Factory function equivalent to `new SchemaRegistry(options)`.
## Implementation Notes
The registry uses a `Map<string, SubjectState>` keyed by subject name. `SubjectState` holds `versions: SchemaDefinition[]` (ordered by insertion, i.e. ascending version) and `compatibility: CompatibilityLevel`. Version numbers are assigned as `versions.length + 1` before push, so they always equal the 1-based position in the array (before pruning).
OTel instruments are lazily initialised on first use to avoid module-level side effects during testing. The `Date.now()` calls for histogram timing are isolated to `register` and `checkCompatibility` and do not affect the pure `checkCompatibility` function in the compatibility module.
The `getOrCreateSubject` helper is `protected` rather than `private` to allow subclasses (e.g. a persistent database-backed registry) to hook into subject creation without duplicating the initialisation logic.

View File

@@ -0,0 +1,107 @@
# schema-registry-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error code registry and typed error class for `@suaveplan/schema-registry`. All errors thrown by the registry extend `SuaveplanError` via `SchemaRegistryError`, giving callers reliable `instanceof` checks and a machine-readable `code` property for programmatic error handling.
The code registry (`SCHEMA_REGISTRY_CODES`) uses `defineCodes` to produce fully-qualified, frozen string constants in the form `SCHEMA_REGISTRY.<REASON>`, enabling exhaustive switch statements on the `code` field.
## Features
- Four stable error codes covering every failure mode: `SCHEMA_NOT_FOUND`, `INCOMPATIBLE_SCHEMA`, `INVALID_SCHEMA`, `SUBJECT_NOT_FOUND`.
- Fully-qualified code strings (`SCHEMA_REGISTRY.SCHEMA_NOT_FOUND` etc.) produced by `defineCodes` — frozen at build time.
- `SchemaRegistryError` extends `SuaveplanError<SchemaRegistryCode>` for narrow type discrimination.
- `SuaveplanErrorOptions` passthrough for `cause`, `context`, and `traceId`.
## Usage: Basic Example
```ts
import {
SchemaRegistryError,
SCHEMA_REGISTRY_CODES,
} from "@suaveplan/schema-registry/errors";
throw new SchemaRegistryError(
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND,
"Subject 'UserCreated' not found in registry",
);
```
## Usage: Advanced Example
```ts
import {
SchemaRegistryError,
SCHEMA_REGISTRY_CODES,
} from "@suaveplan/schema-registry/errors";
function handleError(err: unknown): void {
if (err instanceof SchemaRegistryError) {
switch (err.code) {
case SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND:
console.error("Subject missing — register it first");
break;
case SCHEMA_REGISTRY_CODES.INCOMPATIBLE_SCHEMA:
console.error("Schema rejected by compatibility rule", err.context);
break;
case SCHEMA_REGISTRY_CODES.SCHEMA_NOT_FOUND:
console.error("Requested version not found");
break;
case SCHEMA_REGISTRY_CODES.INVALID_SCHEMA:
console.error("Schema definition is malformed");
break;
}
}
}
// With cause chaining
try {
JSON.parse("{bad json}");
} catch (cause) {
throw new SchemaRegistryError(
SCHEMA_REGISTRY_CODES.INVALID_SCHEMA,
"Schema must be a valid JSON object",
{ cause: cause as Error },
);
}
```
## API Reference
### `SCHEMA_REGISTRY_CODES`
`Record<string, SchemaRegistryCode>` — frozen map of reason keys to fully-qualified code strings.
| Key | Code string |
|-----|-------------|
| `SCHEMA_NOT_FOUND` | `"SCHEMA_REGISTRY.SCHEMA_NOT_FOUND"` |
| `INCOMPATIBLE_SCHEMA` | `"SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA"` |
| `INVALID_SCHEMA` | `"SCHEMA_REGISTRY.INVALID_SCHEMA"` |
| `SUBJECT_NOT_FOUND` | `"SCHEMA_REGISTRY.SUBJECT_NOT_FOUND"` |
### `SchemaRegistryCode`
`type SchemaRegistryCode` — union of all four code strings, derived from `typeof SCHEMA_REGISTRY_CODES`.
### `SchemaRegistryError`
```ts
class SchemaRegistryError extends SuaveplanError<SchemaRegistryCode>
```
**Constructor params:**
| Param | Type | Description |
|-------|------|-------------|
| `code` | `SchemaRegistryCode` | Machine-readable error code |
| `message` | `string` | Human-readable message (also the `messageKey` passed to `SuaveplanError`) |
| `options` | `SuaveplanErrorOptions?` | Optional `cause`, `context`, `traceId` |
**Inherited properties:** `code`, `context`, `timestamp`, `traceId`, `name` (set to `"SchemaRegistryError"`).
## Implementation Notes
`SuaveplanError` uses the second constructor argument as both the message string and the i18n message key. For schema-registry, the message is passed directly as a plain English string because the registry does not depend on `@suaveplan/i18n`. The `defineCodes` helper produces a frozen `Object.create(null)` map, so property access on `SCHEMA_REGISTRY_CODES` is type-safe but not prototype-pollutable.

View File

@@ -0,0 +1,127 @@
# schema-registry.types
## Purpose
Defines the shared TypeScript types for `@suaveplan/schema-registry`. These types form the stable public contract between the compatibility engine, the registry store, and consumer code. All types are compile-time only — no runtime code is emitted from this file.
Centralising types here prevents circular imports between the compatibility module, the registry class, and the error layer, while keeping each module's own file focused on logic.
## Features
- `CompatibilityLevel` — union of the four enforcement modes: `"BACKWARD"`, `"FORWARD"`, `"FULL"`, `"NONE"`.
- `SchemaFormat` — union of supported schema formats: `"json-schema"`, `"zod"`, `"avro"`, `"custom"`.
- `SchemaDefinition` — the full wire shape of a registered schema version, including `id`, `version`, `subject`, `schema`, `format`, `createdAt`, and optional `metadata`.
- `CompatibilityResult` — the structured output of a compatibility check: `compatible` boolean, the `level` that was applied, and a `string[]` of breaking-change descriptions.
- `RegistryOptions` — constructor options for `SchemaRegistry`, covering `defaultCompatibility` and `maxVersionsPerSubject`.
- `SubjectState` — internal per-subject registry bookkeeping type; exported for subclass and test access.
## Usage: Basic Example
```ts
import type {
CompatibilityLevel,
SchemaDefinition,
CompatibilityResult,
} from "@suaveplan/schema-registry";
const level: CompatibilityLevel = "BACKWARD";
const schema: SchemaDefinition = {
id: "UserCreated#1",
version: 1,
subject: "UserCreated",
schema: { type: "object", properties: { id: { type: "string" } } },
format: "json-schema",
createdAt: new Date().toISOString(),
};
const result: CompatibilityResult = {
compatible: true,
level,
errors: [],
};
```
## Usage: Advanced Example
```ts
import type {
RegistryOptions,
SubjectState,
SchemaFormat,
} from "@suaveplan/schema-registry";
const opts: RegistryOptions = {
defaultCompatibility: "FULL",
maxVersionsPerSubject: 50,
};
// Internal bookkeeping — useful for subclass implementations
const state: SubjectState = {
versions: [],
compatibility: "BACKWARD",
};
// Format discrimination
function isDeepCheckSupported(format: SchemaFormat): boolean {
return format === "json-schema";
}
```
## API Reference
### `CompatibilityLevel`
```ts
type CompatibilityLevel = "BACKWARD" | "FORWARD" | "FULL" | "NONE";
```
Controls which direction(s) of schema evolution are enforced on registration.
### `SchemaFormat`
```ts
type SchemaFormat = "json-schema" | "zod" | "avro" | "custom";
```
Identifies the type system used to interpret the `schema` field.
### `SchemaDefinition`
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | `"${subject}#${version}"` — unique within the registry |
| `version` | `number` | Positive integer, 1-based, auto-assigned |
| `subject` | `string` | Logical event/contract name |
| `schema` | `unknown` | Raw schema object |
| `format` | `SchemaFormat` | How to interpret `schema` |
| `createdAt` | `string` | ISO 8601 registration timestamp |
| `metadata` | `Record<string, string>?` | Optional caller-supplied key/value pairs |
### `CompatibilityResult`
| Field | Type | Description |
|-------|------|-------------|
| `compatible` | `boolean` | `true` if no breaking changes detected |
| `level` | `CompatibilityLevel` | The level that was applied |
| `errors` | `string[]` | Human-readable breaking-change descriptions |
### `RegistryOptions`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `defaultCompatibility` | `CompatibilityLevel?` | `"BACKWARD"` | Applied when a subject is first created |
| `maxVersionsPerSubject` | `number?` | `1000` | Prune cap for version history |
### `SubjectState`
Internal per-subject registry state. Exported for subclass and test access only.
| Field | Type | Description |
|-------|------|-------------|
| `versions` | `SchemaDefinition[]` | Ordered list of registered versions |
| `compatibility` | `CompatibilityLevel` | Active compatibility level for this subject |
## Implementation Notes
All types are interface or type alias — there is no runtime code in this file. TypeScript will erase these entirely during compilation; consumers import them with `import type` to enforce this at the call site. The `SubjectState` interface is exported (not internal) because `SchemaRegistry` uses `protected` members per the project preference for `protected` over `private`, enabling subclasses to access subject state directly without re-exporting the internal map.