Initial Commit
This commit is contained in:
88
packages/foundation/core/schema-registry/README.md
Normal file
88
packages/foundation/core/schema-registry/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# @suaveplan/schema-registry - Versioned schema registry with forward/backward compatibility checking for evolving data contracts
|
||||
|
||||
## Summary
|
||||
|
||||
Versioned schema registry with forward/backward/full compatibility checking for evolving data contracts. Register schemas by subject name, enforce evolution rules, and detect breaking changes before they reach production.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schema-registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [schema-registry-error](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry-error](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry-error](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schema-registry-error](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [compatibility](./src/core/compatibility/compatibility.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schema-registry](./src/core/registry/schema-registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schema-registry-error](./src/errors/schema-registry-error.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Features
|
||||
|
||||
- **Versioned storage** — schemas are registered per subject with auto-incremented versions
|
||||
- **Compatibility enforcement** — BACKWARD, FORWARD, FULL, or NONE modes
|
||||
- **Per-subject overrides** — `setCompatibility()` lets subjects opt into different rules
|
||||
- **Dry-run checks** — `checkCompatibility()` validates without registering
|
||||
- **OTel instrumentation** — `schema_registry.register.total` counter and `schema_registry.check.duration_ms` histogram
|
||||
- **Structured errors** — `SchemaRegistryError` with stable `SCHEMA_REGISTRY.*` codes
|
||||
- **JSON Schema deep analysis** — inspects `required`, `properties`, and `type` fields
|
||||
- **Format-aware** — `zod`, `avro`, and `custom` formats accepted with informational notes
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/schema-registry
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```ts
|
||||
import { createRegistry } from "@suaveplan/schema-registry";
|
||||
|
||||
const registry = createRegistry();
|
||||
|
||||
// Register v1
|
||||
registry.register("UserCreated", {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, name: { type: "string" } },
|
||||
required: ["id"],
|
||||
});
|
||||
|
||||
// Register v2 (backward-compatible — adds optional field)
|
||||
registry.register("UserCreated", {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
email: { type: "string" },
|
||||
},
|
||||
required: ["id"],
|
||||
});
|
||||
|
||||
const latest = registry.getLatest("UserCreated");
|
||||
console.log(latest.version); // 2
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Description |
|
||||
|---------|-------------|
|
||||
| `@suaveplan/schema-registry` | Full barrel export |
|
||||
| [`@suaveplan/schema-registry/registry`](src/core/registry/schema-registry.md) | `SchemaRegistry` class and `createRegistry` factory |
|
||||
| [`@suaveplan/schema-registry/compatibility`](src/core/compatibility/compatibility.md) | `checkCompatibility` pure function |
|
||||
| [`@suaveplan/schema-registry/errors`](src/errors/schema-registry-error.md) | `SchemaRegistryError` and `SCHEMA_REGISTRY_CODES` |
|
||||
|
||||
## Compatibility Levels
|
||||
|
||||
| Level | Description |
|
||||
|-------|-------------|
|
||||
| `BACKWARD` (default) | New schema can read data written with old schema |
|
||||
| `FORWARD` | Old schema can read data written with new schema |
|
||||
| `FULL` | Both BACKWARD and FORWARD must pass |
|
||||
| `NONE` | No compatibility checks — any schema accepted |
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `SCHEMA_REGISTRY.SCHEMA_NOT_FOUND` | Requested version does not exist |
|
||||
| `SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA` | Registration rejected by compatibility rule |
|
||||
| `SCHEMA_REGISTRY.INVALID_SCHEMA` | Schema definition fails validation |
|
||||
| `SCHEMA_REGISTRY.SUBJECT_NOT_FOUND` | Subject has no registered versions |
|
||||
94
packages/foundation/core/schema-registry/package.json
Normal file
94
packages/foundation/core/schema-registry/package.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "@suaveplan/schema-registry",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Versioned schema registry with forward/backward compatibility checking for evolving data contracts",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./compatibility": {
|
||||
"import": {
|
||||
"types": "./dist/core/compatibility/compatibility.d.ts",
|
||||
"default": "./dist/core/compatibility/compatibility.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/compatibility/compatibility.d.ts",
|
||||
"default": "./dist/core/compatibility/compatibility.js"
|
||||
}
|
||||
},
|
||||
"./registry": {
|
||||
"import": {
|
||||
"types": "./dist/core/registry/schema-registry.d.ts",
|
||||
"default": "./dist/core/registry/schema-registry.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/registry/schema-registry.d.ts",
|
||||
"default": "./dist/core/registry/schema-registry.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/schema-registry-error.d.ts",
|
||||
"default": "./dist/errors/schema-registry-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/schema-registry-error.d.ts",
|
||||
"default": "./dist/errors/schema-registry-error.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/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms",
|
||||
"@opentelemetry/api": "catalog:observability"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { CompatibilityLevel } from "../types/schema-registry.types.js";
|
||||
|
||||
/** Default compatibility level when none is specified. */
|
||||
export const DEFAULT_COMPATIBILITY: CompatibilityLevel = "BACKWARD";
|
||||
|
||||
/** Default maximum number of versions retained per subject. */
|
||||
export const DEFAULT_MAX_VERSIONS = 1000;
|
||||
@@ -0,0 +1,113 @@
|
||||
# compatibility
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements forward, backward, and full compatibility checking between JSON Schema documents. This module is the core enforcement engine called during schema registration to reject changes that would break producers or consumers of existing data. It is stateless and pure — no registry state is touched; callers supply two schema objects and receive a structured `CompatibilityResult`.
|
||||
|
||||
The module currently provides deep analysis only for `json-schema` format. For `zod`, `avro`, and `custom` formats the candidate schema is accepted with an informational note, allowing those schemas to be registered while the host application adds its own format-specific validation.
|
||||
|
||||
## Features
|
||||
|
||||
- **BACKWARD** check: ensures the new schema can read data written by the old schema. Detects added required fields absent in old data, removed properties that old writers expected, and property type changes.
|
||||
- **FORWARD** check: ensures the old schema can read data written by the new schema. Detects new properties when `additionalProperties: false` is set, removal of required fields, and property type changes.
|
||||
- **FULL** check: both BACKWARD and FORWARD must pass simultaneously.
|
||||
- **NONE** level: always returns `compatible: true` — useful for experimental subjects.
|
||||
- Structured `CompatibilityResult` with a `compatible` boolean, the `level` that was applied, and a `string[]` of human-readable error descriptions.
|
||||
- Pure function — no side effects, no registry state, injectable test inputs.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { checkCompatibility } from "@suaveplan/schema-registry/compatibility";
|
||||
|
||||
const existing = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
email: { type: "string" }, // new optional field
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
|
||||
const result = checkCompatibility(candidate, existing, "BACKWARD", "json-schema");
|
||||
// result.compatible === true (new optional field is fine for backward compat)
|
||||
// result.errors === []
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { checkCompatibility } from "@suaveplan/schema-registry/compatibility";
|
||||
|
||||
// FULL compatibility — both directions must pass
|
||||
const v1 = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, amount: { type: "number" } },
|
||||
required: ["id"],
|
||||
};
|
||||
|
||||
// Breaking: adds a NEW required field — old data won't have it
|
||||
const v2Breaking = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, amount: { type: "number" }, currency: { type: "string" } },
|
||||
required: ["id", "currency"],
|
||||
};
|
||||
|
||||
const full = checkCompatibility(v2Breaking, v1, "FULL", "json-schema");
|
||||
console.log(full.compatible); // false
|
||||
console.log(full.errors);
|
||||
// ["Field 'currency' is required in the new schema but absent in old data — BACKWARD incompatible"]
|
||||
|
||||
// Non-json-schema format — always accepted
|
||||
const zodResult = checkCompatibility(null, null, "BACKWARD", "zod");
|
||||
console.log(zodResult.compatible); // true
|
||||
console.log(zodResult.errors[0]); // note about format not being deeply checked
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `checkCompatibility(newSchema, existingSchema, level, format)`
|
||||
|
||||
Checks whether `newSchema` is compatible with `existingSchema` under the given `level` and `format`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `newSchema` | `unknown` | The candidate schema to be registered |
|
||||
| `existingSchema` | `unknown` | The current latest schema for the subject |
|
||||
| `level` | `CompatibilityLevel` | `"BACKWARD"`, `"FORWARD"`, `"FULL"`, or `"NONE"` |
|
||||
| `format` | `SchemaFormat` | `"json-schema"`, `"zod"`, `"avro"`, or `"custom"` |
|
||||
|
||||
**Returns:** `CompatibilityResult`
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `compatible` | `boolean` | `true` if no breaking changes detected |
|
||||
| `level` | `CompatibilityLevel` | Echo of the level that was applied |
|
||||
| `errors` | `string[]` | Human-readable breaking-change descriptions |
|
||||
|
||||
**Throws:** Never — all error conditions are reflected in `CompatibilityResult.errors`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
**BACKWARD semantics:** A new schema can read old data if it doesn't require fields that didn't exist before, and doesn't remove fields that old writers set. The check inspects `required` additions and `properties` removals. Adding optional fields (present in `properties` but not `required`) is always safe.
|
||||
|
||||
**FORWARD semantics:** An old schema can read new data if the old schema doesn't reject unknown fields via `additionalProperties: false`. The check also catches cases where the new schema removes fields that the old schema declares as required.
|
||||
|
||||
**Type comparison:** Types are normalised by sorting array-type unions (`["string","null"]` → `"null|string"`) before comparing, preventing false positives from ordering differences.
|
||||
|
||||
**Non-JSON-Schema formats:** Zod schemas are runtime objects (not plain POJOs in the registry sense) and Avro schemas have their own evolution rules. Deep analysis for those formats is deferred; the module returns `compatible: true` with an informational note rather than a false `compatible: false`.
|
||||
@@ -0,0 +1,441 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { checkCompatibility } from "./compatibility.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const base = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
},
|
||||
required: ["id"],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NONE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkCompatibility – NONE", () => {
|
||||
it("always returns compatible: true", { timeout: 5000 }, () => {
|
||||
const result = checkCompatibility(
|
||||
{ type: "string" },
|
||||
{ type: "object" },
|
||||
"NONE",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
expect(result.level).toBe("NONE");
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-JSON-Schema formats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkCompatibility – non-json-schema formats", () => {
|
||||
for (const format of ["zod", "avro", "custom"] as const) {
|
||||
it(`${format} returns compatible: true with informational note`, {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = checkCompatibility({}, {}, "BACKWARD", format);
|
||||
expect(result.compatible).toBe(true);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0]).toContain(format);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BACKWARD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkCompatibility – BACKWARD", () => {
|
||||
it("adding a required field is incompatible", { timeout: 5000 }, () => {
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
email: { type: "string" },
|
||||
},
|
||||
required: ["id", "email"], // 'email' newly required — old data won't have it
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
base,
|
||||
"BACKWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("email"))).toBe(true);
|
||||
});
|
||||
|
||||
it("adding an optional field is compatible", { timeout: 5000 }, () => {
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
email: { type: "string" }, // new but NOT required
|
||||
},
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
base,
|
||||
"BACKWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("removing a field is compatible (old data can still be read)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
base,
|
||||
"BACKWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("changing type of a property is incompatible", { timeout: 5000 }, () => {
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: { id: { type: "number" }, name: { type: "string" } }, // id changed to number
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
base,
|
||||
"BACKWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("id"))).toBe(true);
|
||||
});
|
||||
|
||||
it("root type change is incompatible", { timeout: 5000 }, () => {
|
||||
const candidate = { type: "array", items: { type: "string" } };
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
base,
|
||||
"BACKWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("type"))).toBe(true);
|
||||
});
|
||||
|
||||
it("removing a previously required field from properties is incompatible", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const oldSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
critical: { type: "string" },
|
||||
},
|
||||
required: ["id", "critical"],
|
||||
};
|
||||
const newSchema = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } }, // 'critical' removed from props entirely
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
newSchema,
|
||||
oldSchema,
|
||||
"BACKWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("critical"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns compatible: false when schema is not a plain object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = checkCompatibility(
|
||||
"string-schema",
|
||||
base,
|
||||
"BACKWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FORWARD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkCompatibility – FORWARD", () => {
|
||||
it("adding a field is compatible when additionalProperties is not false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
extra: { type: "string" },
|
||||
},
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
base,
|
||||
"FORWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("adding a field is incompatible when old schema has additionalProperties: false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const oldStrict = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, extra: { type: "string" } },
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
oldStrict,
|
||||
"FORWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("extra"))).toBe(true);
|
||||
});
|
||||
|
||||
it("removing a required field from old schema is incompatible", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const oldSchema = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, status: { type: "string" } },
|
||||
required: ["id", "status"],
|
||||
};
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
oldSchema,
|
||||
"FORWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("status"))).toBe(true);
|
||||
});
|
||||
|
||||
it("root type change is incompatible", { timeout: 5000 }, () => {
|
||||
const candidate = { type: "array" };
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
base,
|
||||
"FORWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it("property type change is incompatible", { timeout: 5000 }, () => {
|
||||
const oldSchema = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
};
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: { id: { type: "number" } },
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
oldSchema,
|
||||
"FORWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("id"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema without type field (exercises getType returning null)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkCompatibility – schemas without type", () => {
|
||||
it("compatible when no type on either schema (BACKWARD)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const old = {
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
};
|
||||
const candidate = {
|
||||
properties: { id: { type: "string" }, extra: { type: "string" } },
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
old,
|
||||
"BACKWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("compatible when no type on either schema (FORWARD)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const old = {
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
};
|
||||
const candidate = {
|
||||
properties: { id: { type: "string" }, extra: { type: "string" } },
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
old,
|
||||
"FORWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("handles array type field (BACKWARD compatible)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const old = {
|
||||
type: ["object", "null"],
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
};
|
||||
const candidate = {
|
||||
type: ["null", "object"], // same types, different order — normalised same
|
||||
properties: { id: { type: "string" }, extra: { type: "string" } },
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
old,
|
||||
"BACKWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("handles array type field change (BACKWARD incompatible)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const old = {
|
||||
type: ["object", "null"],
|
||||
properties: { id: { type: "string" } },
|
||||
};
|
||||
const candidate = {
|
||||
type: ["string", "null"],
|
||||
properties: { id: { type: "string" } },
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
old,
|
||||
"BACKWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it("handles array type field change (FORWARD incompatible)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const old = {
|
||||
type: ["object", "null"],
|
||||
properties: { id: { type: "string" } },
|
||||
};
|
||||
const candidate = {
|
||||
type: ["string", "null"],
|
||||
properties: { id: { type: "string" } },
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
old,
|
||||
"FORWARD",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FULL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkCompatibility – FULL", () => {
|
||||
it("passes when both BACKWARD and FORWARD are satisfied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
age: { type: "number" },
|
||||
extra: { type: "string" }, // optional new field
|
||||
},
|
||||
required: ["id"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
base,
|
||||
"FULL",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when BACKWARD is violated", { timeout: 5000 }, () => {
|
||||
const candidate = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
required_new: { type: "string" },
|
||||
},
|
||||
required: ["id", "required_new"],
|
||||
};
|
||||
const result = checkCompatibility(
|
||||
candidate,
|
||||
base,
|
||||
"FULL",
|
||||
"json-schema"
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
expect(result.errors.some((e) => e.includes("BACKWARD"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import type {
|
||||
CompatibilityLevel,
|
||||
CompatibilityResult,
|
||||
SchemaFormat,
|
||||
} from "../../types/schema-registry.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema shape helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JsonSchema {
|
||||
type?: string | string[];
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean | JsonSchema;
|
||||
}
|
||||
|
||||
function asJsonSchema(schema: unknown): JsonSchema | null {
|
||||
if (
|
||||
schema !== null &&
|
||||
typeof schema === "object" &&
|
||||
!Array.isArray(schema)
|
||||
) {
|
||||
return schema as JsonSchema;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRequired(schema: JsonSchema): Set<string> {
|
||||
return new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
}
|
||||
|
||||
function getProperties(schema: JsonSchema): Set<string> {
|
||||
return new Set(
|
||||
schema.properties !== undefined ? Object.keys(schema.properties) : []
|
||||
);
|
||||
}
|
||||
|
||||
function getType(schema: JsonSchema): string | null {
|
||||
if (schema.type === undefined) return null;
|
||||
return Array.isArray(schema.type)
|
||||
? schema.type.sort().join("|")
|
||||
: schema.type;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directional checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* BACKWARD compatibility: new schema can read data written with the old schema.
|
||||
*
|
||||
* Breaking changes:
|
||||
* - A field that was required in the OLD schema is removed from the new schema's
|
||||
* properties entirely (old writers set it; new readers don't know it).
|
||||
* - A required field is ADDED in the new schema that didn't exist in the old
|
||||
* schema at all (old data won't have it, new reader requires it).
|
||||
* - The `type` of any shared property changes.
|
||||
*/
|
||||
function checkBackward(newSchema: JsonSchema, oldSchema: JsonSchema): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Type-level change at root
|
||||
const oldType = getType(oldSchema);
|
||||
const newType = getType(newSchema);
|
||||
if (oldType !== null && newType !== null && oldType !== newType) {
|
||||
errors.push(
|
||||
`Root type changed from '${oldType}' to '${newType}' — BACKWARD incompatible`
|
||||
);
|
||||
}
|
||||
|
||||
const oldRequired = getRequired(oldSchema);
|
||||
const newRequired = getRequired(newSchema);
|
||||
const oldProps = getProperties(oldSchema);
|
||||
const newProps = getProperties(newSchema);
|
||||
|
||||
// New schema adds a required field that doesn't exist in old data
|
||||
for (const field of newRequired) {
|
||||
if (!oldProps.has(field)) {
|
||||
errors.push(
|
||||
`Field '${field}' is required in the new schema but absent in old data — BACKWARD incompatible`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// New schema removes a field that was required in old schema
|
||||
for (const field of oldRequired) {
|
||||
if (!newProps.has(field)) {
|
||||
errors.push(
|
||||
`Required field '${field}' from old schema is missing in new schema properties — BACKWARD incompatible`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Property type changes
|
||||
if (
|
||||
newSchema.properties !== undefined &&
|
||||
oldSchema.properties !== undefined
|
||||
) {
|
||||
for (const [key, newProp] of Object.entries(newSchema.properties)) {
|
||||
const oldProp = oldSchema.properties[key];
|
||||
if (oldProp !== undefined) {
|
||||
const oldPropType = getType(oldProp);
|
||||
const newPropType = getType(newProp);
|
||||
if (
|
||||
oldPropType !== null &&
|
||||
newPropType !== null &&
|
||||
oldPropType !== newPropType
|
||||
) {
|
||||
errors.push(
|
||||
`Property '${key}' type changed from '${oldPropType}' to '${newPropType}' — BACKWARD incompatible`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* FORWARD compatibility: old schema can read data written with the new schema.
|
||||
*
|
||||
* Breaking changes:
|
||||
* - New schema adds fields AND old schema has `additionalProperties: false`
|
||||
* (old readers reject unknown fields).
|
||||
* - New schema removes a required field (old readers may depend on its presence).
|
||||
* - The `type` of any shared property changes.
|
||||
*/
|
||||
function checkForward(newSchema: JsonSchema, oldSchema: JsonSchema): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Type-level change at root
|
||||
const oldType = getType(oldSchema);
|
||||
const newType = getType(newSchema);
|
||||
if (oldType !== null && newType !== null && oldType !== newType) {
|
||||
errors.push(
|
||||
`Root type changed from '${oldType}' to '${newType}' — FORWARD incompatible`
|
||||
);
|
||||
}
|
||||
|
||||
const oldRequired = getRequired(oldSchema);
|
||||
const newRequired = getRequired(newSchema);
|
||||
const oldProps = getProperties(oldSchema);
|
||||
const newProps = getProperties(newSchema);
|
||||
|
||||
// New schema adds properties that old schema doesn't handle
|
||||
if (oldSchema.additionalProperties === false) {
|
||||
for (const field of newProps) {
|
||||
if (!oldProps.has(field)) {
|
||||
errors.push(
|
||||
`Field '${field}' is new in the new schema but old schema rejects additional properties — FORWARD incompatible`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New schema removes a field that was required in old schema
|
||||
for (const field of oldRequired) {
|
||||
if (!newRequired.has(field) && !newProps.has(field)) {
|
||||
errors.push(
|
||||
`Required field '${field}' from old schema is absent in new schema — FORWARD incompatible`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Property type changes
|
||||
if (
|
||||
newSchema.properties !== undefined &&
|
||||
oldSchema.properties !== undefined
|
||||
) {
|
||||
for (const [key, oldProp] of Object.entries(oldSchema.properties)) {
|
||||
const newProp = newSchema.properties[key];
|
||||
if (newProp !== undefined) {
|
||||
const oldPropType = getType(oldProp);
|
||||
const newPropType = getType(newProp);
|
||||
if (
|
||||
oldPropType !== null &&
|
||||
newPropType !== null &&
|
||||
oldPropType !== newPropType
|
||||
) {
|
||||
errors.push(
|
||||
`Property '${key}' type changed from '${oldPropType}' to '${newPropType}' — FORWARD incompatible`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check compatibility between a candidate (new) schema and an existing schema.
|
||||
*
|
||||
* For `json-schema` format, the check inspects `required`, `properties`, and
|
||||
* `type` fields to identify breaking changes. For `zod`, `avro`, and `custom`
|
||||
* formats a note is returned that deep checking is not implemented.
|
||||
*
|
||||
* @param newSchema - The candidate schema to validate.
|
||||
* @param existingSchema - The currently registered schema to check against.
|
||||
* @param level - The compatibility level to enforce.
|
||||
* @param format - The schema format; deep checks are only implemented for `json-schema`.
|
||||
* @returns A `CompatibilityResult` with `compatible`, `level`, and `errors`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = checkCompatibility(
|
||||
* { type: "object", properties: { name: { type: "string" } } },
|
||||
* { type: "object", properties: { name: { type: "string" }, age: { type: "number" } }, required: ["age"] },
|
||||
* "BACKWARD",
|
||||
* "json-schema",
|
||||
* );
|
||||
* // result.compatible === false — new schema doesn't have required field 'age' in old props
|
||||
* ```
|
||||
*/
|
||||
export function checkCompatibility(
|
||||
newSchema: unknown,
|
||||
existingSchema: unknown,
|
||||
level: CompatibilityLevel,
|
||||
format: SchemaFormat
|
||||
): CompatibilityResult {
|
||||
if (level === "NONE") {
|
||||
return { compatible: true, level, errors: [] };
|
||||
}
|
||||
|
||||
if (format !== "json-schema") {
|
||||
return {
|
||||
compatible: true,
|
||||
level,
|
||||
errors: [
|
||||
`Deep compatibility checking is not implemented for '${format}' format; schema accepted`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const newJs = asJsonSchema(newSchema);
|
||||
const existingJs = asJsonSchema(existingSchema);
|
||||
|
||||
if (newJs === null || existingJs === null) {
|
||||
return {
|
||||
compatible: false,
|
||||
level,
|
||||
errors: [
|
||||
"Both schemas must be plain objects for JSON Schema compatibility checking",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
if (level === "BACKWARD" || level === "FULL") {
|
||||
errors = errors.concat(checkBackward(newJs, existingJs));
|
||||
}
|
||||
|
||||
if (level === "FORWARD" || level === "FULL") {
|
||||
errors = errors.concat(checkForward(newJs, existingJs));
|
||||
}
|
||||
|
||||
return { compatible: errors.length === 0, level, errors };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { checkCompatibility } from "./compatibility";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { checkCompatibility } from "./compatibility";
|
||||
export { createRegistry, SchemaRegistry } from "./registry";
|
||||
@@ -0,0 +1 @@
|
||||
export { createRegistry, SchemaRegistry } from "./schema-registry";
|
||||
@@ -0,0 +1,168 @@
|
||||
# schema-registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements `SchemaRegistry`, the central in-memory store for versioned schema definitions. Each subject maintains an ordered list of schema versions; registration atomically assigns the next version number and enforces the subject's compatibility level before persisting the new version. The registry emits OpenTelemetry counters and histograms for observability.
|
||||
|
||||
This module is the single integration point for applications: all register, retrieve, list, delete, and compatibility-check operations go through `SchemaRegistry` or the `createRegistry` factory.
|
||||
|
||||
## Features
|
||||
|
||||
- **Versioned storage** per subject — version numbers start at 1 and increment monotonically; old data is preserved.
|
||||
- **Compatibility enforcement** on registration — rejects candidates that violate BACKWARD / FORWARD / FULL rules; NONE disables enforcement.
|
||||
- **Per-subject compatibility override** via `setCompatibility()` — subjects can opt into stricter or looser rules at any time.
|
||||
- **Read API** — `getLatest`, `getVersion`, `getAll` for point-in-time access.
|
||||
- **`checkCompatibility`** — dry-run check without side effects; returns structured `CompatibilityResult`.
|
||||
- **`listSubjects`** / `deleteSubject`** — subject enumeration and removal.
|
||||
- **`clear()`** — wipes all state; useful for test isolation.
|
||||
- **OTel instrumentation** — `schema_registry.register.total` counter and `schema_registry.check.duration_ms` histogram.
|
||||
- **Version pruning** — enforces `maxVersionsPerSubject` cap by dropping oldest versions.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createRegistry } from "@suaveplan/schema-registry/registry";
|
||||
|
||||
const registry = createRegistry();
|
||||
|
||||
// Register first version
|
||||
const v1 = registry.register("OrderPlaced", {
|
||||
type: "object",
|
||||
properties: { orderId: { type: "string" }, amount: { type: "number" } },
|
||||
required: ["orderId"],
|
||||
});
|
||||
console.log(v1.version); // 1
|
||||
console.log(v1.id); // "OrderPlaced#1"
|
||||
|
||||
// Register second compatible version
|
||||
const v2 = registry.register("OrderPlaced", {
|
||||
type: "object",
|
||||
properties: {
|
||||
orderId: { type: "string" },
|
||||
amount: { type: "number" },
|
||||
currency: { type: "string" }, // new optional field — backward compatible
|
||||
},
|
||||
required: ["orderId"],
|
||||
});
|
||||
console.log(v2.version); // 2
|
||||
|
||||
// Retrieve
|
||||
const latest = registry.getLatest("OrderPlaced");
|
||||
console.log(latest.version); // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRegistry,
|
||||
SchemaRegistry,
|
||||
} from "@suaveplan/schema-registry/registry";
|
||||
import {
|
||||
SchemaRegistryError,
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
} from "@suaveplan/schema-registry/errors";
|
||||
|
||||
// FULL compatibility registry
|
||||
const registry = createRegistry({ defaultCompatibility: "FULL" });
|
||||
|
||||
// Subject-level override
|
||||
registry.register("Events.Lax", { type: "object" });
|
||||
registry.setCompatibility("Events.Lax", "NONE");
|
||||
|
||||
// Dry-run check
|
||||
registry.register("Payments.Order", {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" } },
|
||||
required: ["id"],
|
||||
});
|
||||
const result = registry.checkCompatibility("Payments.Order", {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, ref: { type: "string" } },
|
||||
required: ["id"],
|
||||
});
|
||||
console.log(result.compatible); // true — no breaking change
|
||||
|
||||
// Error handling
|
||||
try {
|
||||
registry.register("Payments.Order", {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, ref: { type: "string" } },
|
||||
required: ["id", "ref"], // adding required field — BACKWARD incompatible
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof SchemaRegistryError) {
|
||||
console.log(err.code); // "SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA"
|
||||
}
|
||||
}
|
||||
|
||||
// List and delete
|
||||
const subjects = registry.listSubjects();
|
||||
registry.deleteSubject("Events.Lax");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SchemaRegistry`
|
||||
|
||||
```ts
|
||||
class SchemaRegistry
|
||||
```
|
||||
|
||||
#### `constructor(options?: RegistryOptions)`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `defaultCompatibility` | `CompatibilityLevel` | `"BACKWARD"` | Applied to new subjects on first registration |
|
||||
| `maxVersionsPerSubject` | `number` | `1000` | Cap on retained versions per subject |
|
||||
|
||||
#### `register(subject, schema, format?, metadata?): SchemaDefinition`
|
||||
|
||||
Registers a new schema version. Throws `INCOMPATIBLE_SCHEMA` if the candidate violates the subject's compatibility level. Throws `INVALID_SCHEMA` for null/empty inputs.
|
||||
|
||||
#### `getLatest(subject): SchemaDefinition`
|
||||
|
||||
Returns the highest-version schema for the subject. Throws `SUBJECT_NOT_FOUND` if subject is absent.
|
||||
|
||||
#### `getVersion(subject, version): SchemaDefinition`
|
||||
|
||||
Returns a specific version. Throws `SUBJECT_NOT_FOUND` or `SCHEMA_NOT_FOUND` as appropriate.
|
||||
|
||||
#### `getAll(subject): SchemaDefinition[]`
|
||||
|
||||
Returns all versions sorted ascending. Throws `SUBJECT_NOT_FOUND` if subject is absent.
|
||||
|
||||
#### `listSubjects(): string[]`
|
||||
|
||||
Returns names of all subjects with at least one registered version.
|
||||
|
||||
#### `deleteSubject(subject): void`
|
||||
|
||||
Removes all versions for a subject. Throws `SUBJECT_NOT_FOUND` if absent.
|
||||
|
||||
#### `setCompatibility(subject, level): void`
|
||||
|
||||
Sets the compatibility level for a subject. Creates a placeholder if the subject is new.
|
||||
|
||||
#### `checkCompatibility(subject, candidateSchema, format?): CompatibilityResult`
|
||||
|
||||
Dry-runs a compatibility check without registering. Throws `SUBJECT_NOT_FOUND` if subject is absent.
|
||||
|
||||
#### `clear(): void`
|
||||
|
||||
Removes all subjects. Useful for test teardown.
|
||||
|
||||
### `createRegistry(options?): SchemaRegistry`
|
||||
|
||||
Factory function equivalent to `new SchemaRegistry(options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses a `Map<string, SubjectState>` keyed by subject name. `SubjectState` holds `versions: SchemaDefinition[]` (ordered by insertion, i.e. ascending version) and `compatibility: CompatibilityLevel`. Version numbers are assigned as `versions.length + 1` before push, so they always equal the 1-based position in the array (before pruning).
|
||||
|
||||
OTel instruments are lazily initialised on first use to avoid module-level side effects during testing. The `Date.now()` calls for histogram timing are isolated to `register` and `checkCompatibility` and do not affect the pure `checkCompatibility` function in the compatibility module.
|
||||
|
||||
The `getOrCreateSubject` helper is `protected` rather than `private` to allow subclasses (e.g. a persistent database-backed registry) to hook into subject creation without duplicating the initialisation logic.
|
||||
@@ -0,0 +1,457 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
SchemaRegistryError,
|
||||
} from "../../errors/schema-registry-error.js";
|
||||
import { createRegistry, SchemaRegistry } from "./schema-registry.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const userSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
},
|
||||
required: ["id"],
|
||||
};
|
||||
|
||||
const userSchemaV2 = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
email: { type: "string" }, // new optional field
|
||||
},
|
||||
required: ["id"],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createRegistry", () => {
|
||||
it("returns a SchemaRegistry instance", { timeout: 5000 }, () => {
|
||||
const registry = createRegistry();
|
||||
expect(registry).toBeInstanceOf(SchemaRegistry);
|
||||
});
|
||||
|
||||
it("accepts options", { timeout: 5000 }, () => {
|
||||
const registry = createRegistry({
|
||||
defaultCompatibility: "NONE",
|
||||
maxVersionsPerSubject: 5,
|
||||
});
|
||||
expect(registry).toBeInstanceOf(SchemaRegistry);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// register
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SchemaRegistry.register", () => {
|
||||
let registry: SchemaRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = createRegistry();
|
||||
});
|
||||
|
||||
it("registers first schema at version 1", { timeout: 5000 }, () => {
|
||||
const def = registry.register("User", userSchema);
|
||||
expect(def.version).toBe(1);
|
||||
expect(def.subject).toBe("User");
|
||||
expect(def.id).toBe("User#1");
|
||||
expect(def.format).toBe("json-schema");
|
||||
expect(def.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it("increments version on subsequent register", { timeout: 5000 }, () => {
|
||||
registry.register("User", userSchema);
|
||||
const def2 = registry.register("User", userSchemaV2);
|
||||
expect(def2.version).toBe(2);
|
||||
expect(def2.id).toBe("User#2");
|
||||
});
|
||||
|
||||
it("stores metadata when provided", { timeout: 5000 }, () => {
|
||||
const def = registry.register("User", userSchema, "json-schema", {
|
||||
owner: "team-a",
|
||||
});
|
||||
expect(def.metadata).toEqual({ owner: "team-a" });
|
||||
});
|
||||
|
||||
it("stores schema without metadata when not provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const def = registry.register("User", userSchema);
|
||||
expect(def.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws INCOMPATIBLE_SCHEMA when adding a required field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registry.register("User", userSchema);
|
||||
const breakingSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
requiredNew: { type: "string" },
|
||||
},
|
||||
required: ["id", "requiredNew"],
|
||||
};
|
||||
expect(() => registry.register("User", breakingSchema)).toThrow(
|
||||
SchemaRegistryError
|
||||
);
|
||||
try {
|
||||
registry.register("User", breakingSchema);
|
||||
} catch (err) {
|
||||
expect(err instanceof SchemaRegistryError).toBe(true);
|
||||
expect((err as SchemaRegistryError).code).toBe(
|
||||
SCHEMA_REGISTRY_CODES.INCOMPATIBLE_SCHEMA
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_SCHEMA for empty subject", { timeout: 5000 }, () => {
|
||||
expect(() => registry.register("", userSchema)).toThrow(
|
||||
SchemaRegistryError
|
||||
);
|
||||
try {
|
||||
registry.register("", userSchema);
|
||||
} catch (err) {
|
||||
expect((err as SchemaRegistryError).code).toBe(
|
||||
SCHEMA_REGISTRY_CODES.INVALID_SCHEMA
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_SCHEMA for null schema", { timeout: 5000 }, () => {
|
||||
expect(() => registry.register("User", null)).toThrow(
|
||||
SchemaRegistryError
|
||||
);
|
||||
try {
|
||||
registry.register("User", null);
|
||||
} catch (err) {
|
||||
expect((err as SchemaRegistryError).code).toBe(
|
||||
SCHEMA_REGISTRY_CODES.INVALID_SCHEMA
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows non-json-schema format", { timeout: 5000 }, () => {
|
||||
const def = registry.register("User", { type: "record" }, "avro");
|
||||
expect(def.format).toBe("avro");
|
||||
});
|
||||
|
||||
it("allows registration with NONE compatibility even for breaking changes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
registry.setCompatibility("FreeSubject", "NONE");
|
||||
registry.register("FreeSubject", { type: "string" });
|
||||
const def = registry.register("FreeSubject", { type: "number" }); // type change — normally breaking
|
||||
expect(def.version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getLatest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SchemaRegistry.getLatest", () => {
|
||||
let registry: SchemaRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = createRegistry();
|
||||
});
|
||||
|
||||
it("returns the latest version", { timeout: 5000 }, () => {
|
||||
registry.register("User", userSchema);
|
||||
registry.register("User", userSchemaV2);
|
||||
const latest = registry.getLatest("User");
|
||||
expect(latest.version).toBe(2);
|
||||
});
|
||||
|
||||
it("throws SUBJECT_NOT_FOUND for unknown subject", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => registry.getLatest("Unknown")).toThrow(
|
||||
SchemaRegistryError
|
||||
);
|
||||
try {
|
||||
registry.getLatest("Unknown");
|
||||
} catch (err) {
|
||||
expect((err as SchemaRegistryError).code).toBe(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getVersion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SchemaRegistry.getVersion", () => {
|
||||
let registry: SchemaRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = createRegistry();
|
||||
registry.register("User", userSchema);
|
||||
registry.register("User", userSchemaV2);
|
||||
});
|
||||
|
||||
it("returns version 1 even after v2 is registered", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const v1 = registry.getVersion("User", 1);
|
||||
expect(v1.version).toBe(1);
|
||||
expect(v1.schema).toEqual(userSchema);
|
||||
});
|
||||
|
||||
it("returns version 2", { timeout: 5000 }, () => {
|
||||
const v2 = registry.getVersion("User", 2);
|
||||
expect(v2.version).toBe(2);
|
||||
});
|
||||
|
||||
it("throws SCHEMA_NOT_FOUND for missing version", { timeout: 5000 }, () => {
|
||||
expect(() => registry.getVersion("User", 99)).toThrow(
|
||||
SchemaRegistryError
|
||||
);
|
||||
try {
|
||||
registry.getVersion("User", 99);
|
||||
} catch (err) {
|
||||
expect((err as SchemaRegistryError).code).toBe(
|
||||
SCHEMA_REGISTRY_CODES.SCHEMA_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws SUBJECT_NOT_FOUND for unknown subject", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => registry.getVersion("Unknown", 1)).toThrow(
|
||||
SchemaRegistryError
|
||||
);
|
||||
try {
|
||||
registry.getVersion("Unknown", 1);
|
||||
} catch (err) {
|
||||
expect((err as SchemaRegistryError).code).toBe(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getAll
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SchemaRegistry.getAll", () => {
|
||||
let registry: SchemaRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = createRegistry();
|
||||
registry.register("User", userSchema);
|
||||
registry.register("User", userSchemaV2);
|
||||
});
|
||||
|
||||
it("returns all versions sorted by version number", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const all = registry.getAll("User");
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all[0].version).toBe(1);
|
||||
expect(all[1].version).toBe(2);
|
||||
});
|
||||
|
||||
it("throws SUBJECT_NOT_FOUND for unknown subject", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => registry.getAll("Unknown")).toThrow(SchemaRegistryError);
|
||||
try {
|
||||
registry.getAll("Unknown");
|
||||
} catch (err) {
|
||||
expect((err as SchemaRegistryError).code).toBe(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listSubjects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SchemaRegistry.listSubjects", () => {
|
||||
it("returns registered subjects", { timeout: 5000 }, () => {
|
||||
const registry = createRegistry();
|
||||
registry.register("User", userSchema);
|
||||
registry.register("Order", { type: "object" }, "avro");
|
||||
const subjects = registry.listSubjects();
|
||||
expect(subjects).toContain("User");
|
||||
expect(subjects).toContain("Order");
|
||||
expect(subjects).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns empty array when no subjects", { timeout: 5000 }, () => {
|
||||
const registry = createRegistry();
|
||||
expect(registry.listSubjects()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not include placeholder subjects with no versions", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createRegistry();
|
||||
// setCompatibility creates a placeholder
|
||||
registry.setCompatibility("Placeholder", "NONE");
|
||||
expect(registry.listSubjects()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteSubject
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SchemaRegistry.deleteSubject", () => {
|
||||
it("removes all versions for a subject", { timeout: 5000 }, () => {
|
||||
const registry = createRegistry();
|
||||
registry.register("User", userSchema);
|
||||
registry.deleteSubject("User");
|
||||
expect(() => registry.getLatest("User")).toThrow(SchemaRegistryError);
|
||||
});
|
||||
|
||||
it("throws SUBJECT_NOT_FOUND for unknown subject", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createRegistry();
|
||||
expect(() => registry.deleteSubject("Unknown")).toThrow(
|
||||
SchemaRegistryError
|
||||
);
|
||||
try {
|
||||
registry.deleteSubject("Unknown");
|
||||
} catch (err) {
|
||||
expect((err as SchemaRegistryError).code).toBe(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setCompatibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SchemaRegistry.setCompatibility", () => {
|
||||
it("changes the compatibility level for subsequent registrations", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createRegistry({ defaultCompatibility: "BACKWARD" });
|
||||
registry.register("User", userSchema);
|
||||
|
||||
// This would fail under BACKWARD — adding a required field
|
||||
const breakingSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
requiredNew: { type: "string" },
|
||||
},
|
||||
required: ["id", "requiredNew"],
|
||||
};
|
||||
expect(() => registry.register("User", breakingSchema)).toThrow();
|
||||
|
||||
// Switch to NONE so anything goes
|
||||
registry.setCompatibility("User", "NONE");
|
||||
const def = registry.register("User", breakingSchema);
|
||||
expect(def.version).toBe(2);
|
||||
});
|
||||
|
||||
it("creates a placeholder when subject is not yet registered", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createRegistry();
|
||||
// Should not throw
|
||||
registry.setCompatibility("NewSubject", "FORWARD");
|
||||
// Placeholder should not appear in listSubjects
|
||||
expect(registry.listSubjects()).not.toContain("NewSubject");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkCompatibility (dry-run)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SchemaRegistry.checkCompatibility", () => {
|
||||
it("returns compatible result for valid candidate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createRegistry();
|
||||
registry.register("User", userSchema);
|
||||
const result = registry.checkCompatibility("User", userSchemaV2);
|
||||
expect(result.compatible).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns incompatible result for breaking change", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createRegistry();
|
||||
registry.register("User", userSchema);
|
||||
const breakingSchema = {
|
||||
type: "object",
|
||||
properties: { id: { type: "string" }, req: { type: "string" } },
|
||||
required: ["id", "req"],
|
||||
};
|
||||
const result = registry.checkCompatibility("User", breakingSchema);
|
||||
expect(result.compatible).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("throws SUBJECT_NOT_FOUND for unknown subject", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const registry = createRegistry();
|
||||
expect(() =>
|
||||
registry.checkCompatibility("Unknown", userSchema)
|
||||
).toThrow(SchemaRegistryError);
|
||||
try {
|
||||
registry.checkCompatibility("Unknown", userSchema);
|
||||
} catch (err) {
|
||||
expect((err as SchemaRegistryError).code).toBe(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clear
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SchemaRegistry.clear", () => {
|
||||
it("removes all subjects", { timeout: 5000 }, () => {
|
||||
const registry = createRegistry();
|
||||
registry.register("A", userSchema);
|
||||
registry.register("B", { type: "string" }, "avro");
|
||||
registry.clear();
|
||||
expect(registry.listSubjects()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// maxVersionsPerSubject pruning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SchemaRegistry – maxVersionsPerSubject", () => {
|
||||
it("prunes old versions beyond the cap", { timeout: 5000 }, () => {
|
||||
const registry = createRegistry({
|
||||
maxVersionsPerSubject: 3,
|
||||
defaultCompatibility: "NONE",
|
||||
});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
registry.register("Subject", { type: "object", version: i });
|
||||
}
|
||||
const all = registry.getAll("Subject");
|
||||
expect(all.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import type { Counter, Histogram } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
DEFAULT_COMPATIBILITY,
|
||||
DEFAULT_MAX_VERSIONS,
|
||||
} from "../../constants/index.js";
|
||||
import {
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
SchemaRegistryError,
|
||||
} from "../../errors/schema-registry-error.js";
|
||||
import type {
|
||||
CompatibilityLevel,
|
||||
CompatibilityResult,
|
||||
RegistryOptions,
|
||||
SchemaDefinition,
|
||||
SchemaFormat,
|
||||
SubjectState,
|
||||
} from "../../types/schema-registry.types.js";
|
||||
import { checkCompatibility } from "../compatibility/compatibility.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OTel instruments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let registerCounter: Counter | null = null;
|
||||
let checkHistogram: Histogram | null = null;
|
||||
|
||||
function getRegisterCounter(): Counter {
|
||||
if (registerCounter === null) {
|
||||
const meter = metrics.getMeter("@suaveplan/schema-registry");
|
||||
registerCounter = meter.createCounter(
|
||||
"schema_registry.register.total",
|
||||
{
|
||||
description: "Total schema registrations",
|
||||
}
|
||||
);
|
||||
}
|
||||
return registerCounter;
|
||||
}
|
||||
|
||||
function getCheckHistogram(): Histogram {
|
||||
if (checkHistogram === null) {
|
||||
const meter = metrics.getMeter("@suaveplan/schema-registry");
|
||||
checkHistogram = meter.createHistogram(
|
||||
"schema_registry.check.duration_ms",
|
||||
{
|
||||
description: "Duration of compatibility checks in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
}
|
||||
return checkHistogram;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaRegistry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* In-memory versioned schema registry with configurable compatibility enforcement.
|
||||
*
|
||||
* Schemas are registered by subject name; each registration atomically assigns the
|
||||
* next version number and validates the candidate against the latest registered
|
||||
* version using the subject's compatibility level.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const registry = createRegistry();
|
||||
* registry.register("UserCreated", { type: "object", properties: { id: { type: "string" } } });
|
||||
* const latest = registry.getLatest("UserCreated");
|
||||
* console.log(latest.version); // 1
|
||||
* ```
|
||||
*/
|
||||
export class SchemaRegistry {
|
||||
protected readonly subjects = new Map<string, SubjectState>();
|
||||
protected readonly defaultCompatibility: CompatibilityLevel;
|
||||
protected readonly maxVersionsPerSubject: number;
|
||||
|
||||
constructor(options?: RegistryOptions) {
|
||||
this.defaultCompatibility =
|
||||
options?.defaultCompatibility ?? DEFAULT_COMPATIBILITY;
|
||||
this.maxVersionsPerSubject =
|
||||
options?.maxVersionsPerSubject ?? DEFAULT_MAX_VERSIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new schema version for the given subject.
|
||||
*
|
||||
* The schema is checked for compatibility against the current latest version
|
||||
* (if any). If the check fails, `SchemaRegistryError` with code
|
||||
* `INCOMPATIBLE_SCHEMA` is thrown. On success, the new `SchemaDefinition` is
|
||||
* returned with the auto-assigned version number.
|
||||
*
|
||||
* @param subject - Logical subject name.
|
||||
* @param schema - The schema object to register.
|
||||
* @param format - Schema format (default `"json-schema"`).
|
||||
* @param metadata - Optional caller-supplied metadata key/value pairs.
|
||||
* @throws `SchemaRegistryError` with `INCOMPATIBLE_SCHEMA` on compatibility failure.
|
||||
* @throws `SchemaRegistryError` with `INVALID_SCHEMA` if subject or schema is invalid.
|
||||
*/
|
||||
register(
|
||||
subject: string,
|
||||
schema: unknown,
|
||||
format: SchemaFormat = "json-schema",
|
||||
metadata?: Record<string, string>
|
||||
): SchemaDefinition {
|
||||
if (typeof subject !== "string" || subject.trim().length === 0) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.INVALID_SCHEMA,
|
||||
"Subject must be a non-empty string"
|
||||
);
|
||||
}
|
||||
|
||||
if (schema === null || schema === undefined) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.INVALID_SCHEMA,
|
||||
"Schema must not be null or undefined"
|
||||
);
|
||||
}
|
||||
|
||||
const state = this.getOrCreateSubject(subject);
|
||||
|
||||
if (state.versions.length > 0) {
|
||||
const latest = state.versions.at(-1)!;
|
||||
const startMs = Date.now();
|
||||
const result = checkCompatibility(
|
||||
schema,
|
||||
latest.schema,
|
||||
state.compatibility,
|
||||
format
|
||||
);
|
||||
getCheckHistogram().record(Date.now() - startMs, {
|
||||
subject,
|
||||
level: state.compatibility,
|
||||
});
|
||||
|
||||
if (!result.compatible) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.INCOMPATIBLE_SCHEMA,
|
||||
`Schema for subject '${subject}' is incompatible: ${result.errors.join("; ")}`,
|
||||
{ context: { subject, errors: result.errors.join("; ") } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const version = state.versions.length + 1;
|
||||
const definition: SchemaDefinition = {
|
||||
id: `${subject}#${version}`,
|
||||
version,
|
||||
subject,
|
||||
schema,
|
||||
format,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
};
|
||||
|
||||
state.versions.push(definition);
|
||||
|
||||
// Prune old versions if necessary
|
||||
if (state.versions.length > this.maxVersionsPerSubject) {
|
||||
state.versions.splice(
|
||||
0,
|
||||
state.versions.length - this.maxVersionsPerSubject
|
||||
);
|
||||
}
|
||||
|
||||
getRegisterCounter().add(1, { subject, format });
|
||||
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the latest registered version for a subject.
|
||||
*
|
||||
* @param subject - Subject name.
|
||||
* @throws `SchemaRegistryError` with `SUBJECT_NOT_FOUND` if the subject doesn't exist.
|
||||
*/
|
||||
getLatest(subject: string): SchemaDefinition {
|
||||
const state = this.subjects.get(subject);
|
||||
if (state === undefined || state.versions.length === 0) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND,
|
||||
`Subject '${subject}' not found in registry`
|
||||
);
|
||||
}
|
||||
return state.versions.at(-1)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a specific version of a schema.
|
||||
*
|
||||
* @param subject - Subject name.
|
||||
* @param version - Positive integer version number.
|
||||
* @throws `SchemaRegistryError` with `SUBJECT_NOT_FOUND` if the subject doesn't exist.
|
||||
* @throws `SchemaRegistryError` with `SCHEMA_NOT_FOUND` if the version doesn't exist.
|
||||
*/
|
||||
getVersion(subject: string, version: number): SchemaDefinition {
|
||||
const state = this.subjects.get(subject);
|
||||
if (state === undefined || state.versions.length === 0) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND,
|
||||
`Subject '${subject}' not found in registry`
|
||||
);
|
||||
}
|
||||
const found = state.versions.find((v) => v.version === version);
|
||||
if (found === undefined) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SCHEMA_NOT_FOUND,
|
||||
`Version ${version} of subject '${subject}' not found`
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all registered versions for a subject, sorted ascending by version.
|
||||
*
|
||||
* @param subject - Subject name.
|
||||
* @throws `SchemaRegistryError` with `SUBJECT_NOT_FOUND` if the subject doesn't exist.
|
||||
*/
|
||||
getAll(subject: string): SchemaDefinition[] {
|
||||
const state = this.subjects.get(subject);
|
||||
if (state === undefined || state.versions.length === 0) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND,
|
||||
`Subject '${subject}' not found in registry`
|
||||
);
|
||||
}
|
||||
return [...state.versions].sort((a, b) => a.version - b.version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the names of all registered subjects.
|
||||
*/
|
||||
listSubjects(): string[] {
|
||||
const result: string[] = [];
|
||||
for (const [key, state] of this.subjects) {
|
||||
if (state.versions.length > 0) {
|
||||
result.push(key);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all versions for a subject.
|
||||
*
|
||||
* @param subject - Subject name.
|
||||
* @throws `SchemaRegistryError` with `SUBJECT_NOT_FOUND` if the subject doesn't exist.
|
||||
*/
|
||||
deleteSubject(subject: string): void {
|
||||
if (!this.subjects.has(subject)) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND,
|
||||
`Subject '${subject}' not found in registry`
|
||||
);
|
||||
}
|
||||
this.subjects.delete(subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the compatibility level for a specific subject.
|
||||
*
|
||||
* @param subject - Subject name. If not yet registered, a subject placeholder is created.
|
||||
* @param level - The new compatibility level.
|
||||
*/
|
||||
setCompatibility(subject: string, level: CompatibilityLevel): void {
|
||||
const state = this.getOrCreateSubject(subject);
|
||||
state.compatibility = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a candidate schema would be compatible without registering it.
|
||||
*
|
||||
* @param subject - Subject name.
|
||||
* @param candidateSchema - The schema to test.
|
||||
* @param format - Schema format (default `"json-schema"`).
|
||||
* @throws `SchemaRegistryError` with `SUBJECT_NOT_FOUND` if the subject doesn't exist.
|
||||
*/
|
||||
checkCompatibility(
|
||||
subject: string,
|
||||
candidateSchema: unknown,
|
||||
format: SchemaFormat = "json-schema"
|
||||
): CompatibilityResult {
|
||||
const state = this.subjects.get(subject);
|
||||
if (state === undefined || state.versions.length === 0) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND,
|
||||
`Subject '${subject}' not found in registry`
|
||||
);
|
||||
}
|
||||
const latest = state.versions.at(-1)!;
|
||||
const startMs = Date.now();
|
||||
const result = checkCompatibility(
|
||||
candidateSchema,
|
||||
latest.schema,
|
||||
state.compatibility,
|
||||
format
|
||||
);
|
||||
getCheckHistogram().record(Date.now() - startMs, {
|
||||
subject,
|
||||
level: state.compatibility,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all subjects and their versions from the registry.
|
||||
* Primarily useful for test isolation.
|
||||
*/
|
||||
clear(): void {
|
||||
this.subjects.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Protected helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
protected getOrCreateSubject(subject: string): SubjectState {
|
||||
let state = this.subjects.get(subject);
|
||||
if (state === undefined) {
|
||||
state = { versions: [], compatibility: this.defaultCompatibility };
|
||||
this.subjects.set(subject, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that creates a new `SchemaRegistry` instance.
|
||||
*
|
||||
* @param options - Optional configuration for compatibility and max versions.
|
||||
* @returns A fresh `SchemaRegistry`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const registry = createRegistry({ defaultCompatibility: "FULL" });
|
||||
* ```
|
||||
*/
|
||||
export function createRegistry(options?: RegistryOptions): SchemaRegistry {
|
||||
return new SchemaRegistry(options);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
type SchemaRegistryCode,
|
||||
SchemaRegistryError,
|
||||
} from "./schema-registry-error";
|
||||
@@ -0,0 +1,107 @@
|
||||
# schema-registry-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the stable error code registry and typed error class for `@suaveplan/schema-registry`. All errors thrown by the registry extend `SuaveplanError` via `SchemaRegistryError`, giving callers reliable `instanceof` checks and a machine-readable `code` property for programmatic error handling.
|
||||
|
||||
The code registry (`SCHEMA_REGISTRY_CODES`) uses `defineCodes` to produce fully-qualified, frozen string constants in the form `SCHEMA_REGISTRY.<REASON>`, enabling exhaustive switch statements on the `code` field.
|
||||
|
||||
## Features
|
||||
|
||||
- Four stable error codes covering every failure mode: `SCHEMA_NOT_FOUND`, `INCOMPATIBLE_SCHEMA`, `INVALID_SCHEMA`, `SUBJECT_NOT_FOUND`.
|
||||
- Fully-qualified code strings (`SCHEMA_REGISTRY.SCHEMA_NOT_FOUND` etc.) produced by `defineCodes` — frozen at build time.
|
||||
- `SchemaRegistryError` extends `SuaveplanError<SchemaRegistryCode>` for narrow type discrimination.
|
||||
- `SuaveplanErrorOptions` passthrough for `cause`, `context`, and `traceId`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SchemaRegistryError,
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
} from "@suaveplan/schema-registry/errors";
|
||||
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND,
|
||||
"Subject 'UserCreated' not found in registry",
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SchemaRegistryError,
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
} from "@suaveplan/schema-registry/errors";
|
||||
|
||||
function handleError(err: unknown): void {
|
||||
if (err instanceof SchemaRegistryError) {
|
||||
switch (err.code) {
|
||||
case SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND:
|
||||
console.error("Subject missing — register it first");
|
||||
break;
|
||||
case SCHEMA_REGISTRY_CODES.INCOMPATIBLE_SCHEMA:
|
||||
console.error("Schema rejected by compatibility rule", err.context);
|
||||
break;
|
||||
case SCHEMA_REGISTRY_CODES.SCHEMA_NOT_FOUND:
|
||||
console.error("Requested version not found");
|
||||
break;
|
||||
case SCHEMA_REGISTRY_CODES.INVALID_SCHEMA:
|
||||
console.error("Schema definition is malformed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With cause chaining
|
||||
try {
|
||||
JSON.parse("{bad json}");
|
||||
} catch (cause) {
|
||||
throw new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.INVALID_SCHEMA,
|
||||
"Schema must be a valid JSON object",
|
||||
{ cause: cause as Error },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SCHEMA_REGISTRY_CODES`
|
||||
|
||||
`Record<string, SchemaRegistryCode>` — frozen map of reason keys to fully-qualified code strings.
|
||||
|
||||
| Key | Code string |
|
||||
|-----|-------------|
|
||||
| `SCHEMA_NOT_FOUND` | `"SCHEMA_REGISTRY.SCHEMA_NOT_FOUND"` |
|
||||
| `INCOMPATIBLE_SCHEMA` | `"SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA"` |
|
||||
| `INVALID_SCHEMA` | `"SCHEMA_REGISTRY.INVALID_SCHEMA"` |
|
||||
| `SUBJECT_NOT_FOUND` | `"SCHEMA_REGISTRY.SUBJECT_NOT_FOUND"` |
|
||||
|
||||
### `SchemaRegistryCode`
|
||||
|
||||
`type SchemaRegistryCode` — union of all four code strings, derived from `typeof SCHEMA_REGISTRY_CODES`.
|
||||
|
||||
### `SchemaRegistryError`
|
||||
|
||||
```ts
|
||||
class SchemaRegistryError extends SuaveplanError<SchemaRegistryCode>
|
||||
```
|
||||
|
||||
**Constructor params:**
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `code` | `SchemaRegistryCode` | Machine-readable error code |
|
||||
| `message` | `string` | Human-readable message (also the `messageKey` passed to `SuaveplanError`) |
|
||||
| `options` | `SuaveplanErrorOptions?` | Optional `cause`, `context`, `traceId` |
|
||||
|
||||
**Inherited properties:** `code`, `context`, `timestamp`, `traceId`, `name` (set to `"SchemaRegistryError"`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`SuaveplanError` uses the second constructor argument as both the message string and the i18n message key. For schema-registry, the message is passed directly as a plain English string because the registry does not depend on `@suaveplan/i18n`. The `defineCodes` helper produces a frozen `Object.create(null)` map, so property access on `SCHEMA_REGISTRY_CODES` is type-safe but not prototype-pollutable.
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
type SchemaRegistryCode,
|
||||
SchemaRegistryError,
|
||||
} from "./schema-registry-error.js";
|
||||
|
||||
describe("SCHEMA_REGISTRY_CODES", () => {
|
||||
it("has the four expected codes", { timeout: 5000 }, () => {
|
||||
expect(SCHEMA_REGISTRY_CODES.SCHEMA_NOT_FOUND).toBe(
|
||||
"SCHEMA_REGISTRY.SCHEMA_NOT_FOUND"
|
||||
);
|
||||
expect(SCHEMA_REGISTRY_CODES.INCOMPATIBLE_SCHEMA).toBe(
|
||||
"SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA"
|
||||
);
|
||||
expect(SCHEMA_REGISTRY_CODES.INVALID_SCHEMA).toBe(
|
||||
"SCHEMA_REGISTRY.INVALID_SCHEMA"
|
||||
);
|
||||
expect(SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND).toBe(
|
||||
"SCHEMA_REGISTRY.SUBJECT_NOT_FOUND"
|
||||
);
|
||||
});
|
||||
|
||||
it("is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(SCHEMA_REGISTRY_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SchemaRegistryError", () => {
|
||||
it("sets name to SchemaRegistryError", { timeout: 5000 }, () => {
|
||||
const err = new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND,
|
||||
"Subject not found"
|
||||
);
|
||||
expect(err.name).toBe("SchemaRegistryError");
|
||||
});
|
||||
|
||||
it("sets code from codes registry", { timeout: 5000 }, () => {
|
||||
const err = new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.INCOMPATIBLE_SCHEMA,
|
||||
"Incompatible"
|
||||
);
|
||||
expect(err.code).toBe("SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA");
|
||||
});
|
||||
|
||||
it("is instanceof Error and SchemaRegistryError", { timeout: 5000 }, () => {
|
||||
const err = new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.INVALID_SCHEMA,
|
||||
"Bad schema"
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(SchemaRegistryError);
|
||||
});
|
||||
|
||||
it("accepts cause option", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root cause");
|
||||
const err = new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SCHEMA_NOT_FOUND,
|
||||
"Not found",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("has a message", { timeout: 5000 }, () => {
|
||||
const err = new SchemaRegistryError(
|
||||
SCHEMA_REGISTRY_CODES.SCHEMA_NOT_FOUND,
|
||||
"Version 42 not found"
|
||||
);
|
||||
expect(err.message).toBe("Version 42 not found");
|
||||
});
|
||||
|
||||
it("code is narrowly typed", { timeout: 5000 }, () => {
|
||||
const code: SchemaRegistryCode =
|
||||
SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND;
|
||||
expect(code).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Error code registry for `@suaveplan/schema-registry`.
|
||||
*
|
||||
* Four stable error codes cover every failure mode exposed by the registry:
|
||||
* - `SCHEMA_REGISTRY.SCHEMA_NOT_FOUND` — a requested version does not exist
|
||||
* - `SCHEMA_REGISTRY.INCOMPATIBLE_SCHEMA` — registration rejected by compatibility rule
|
||||
* - `SCHEMA_REGISTRY.INVALID_SCHEMA` — schema definition fails validation
|
||||
* - `SCHEMA_REGISTRY.SUBJECT_NOT_FOUND` — referenced subject has no versions
|
||||
*/
|
||||
export const SCHEMA_REGISTRY_CODES = defineCodes("SCHEMA_REGISTRY", [
|
||||
"SCHEMA_NOT_FOUND",
|
||||
"INCOMPATIBLE_SCHEMA",
|
||||
"INVALID_SCHEMA",
|
||||
"SUBJECT_NOT_FOUND",
|
||||
] as const);
|
||||
|
||||
export type SchemaRegistryCode =
|
||||
(typeof SCHEMA_REGISTRY_CODES)[keyof typeof SCHEMA_REGISTRY_CODES];
|
||||
|
||||
/**
|
||||
* Typed error for all `@suaveplan/schema-registry` failures.
|
||||
*
|
||||
* Extends `SuaveplanError` with a strongly-typed `code` property drawn from
|
||||
* `SCHEMA_REGISTRY_CODES`, giving callers narrow `instanceof` discrimination.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* throw new SchemaRegistryError(
|
||||
* SCHEMA_REGISTRY_CODES.SUBJECT_NOT_FOUND,
|
||||
* "Subject 'UserCreated' not found",
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class SchemaRegistryError extends SuaveplanError<SchemaRegistryCode> {
|
||||
public constructor(
|
||||
code: SchemaRegistryCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "SchemaRegistryError";
|
||||
}
|
||||
}
|
||||
28
packages/foundation/core/schema-registry/src/index.ts
Normal file
28
packages/foundation/core/schema-registry/src/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Errors
|
||||
|
||||
// Constants
|
||||
export {
|
||||
DEFAULT_COMPATIBILITY,
|
||||
DEFAULT_MAX_VERSIONS,
|
||||
} from "./constants/index.js";
|
||||
// Compatibility
|
||||
export { checkCompatibility } from "./core/compatibility/compatibility.js";
|
||||
// Registry
|
||||
export {
|
||||
createRegistry,
|
||||
SchemaRegistry,
|
||||
} from "./core/registry/schema-registry.js";
|
||||
export {
|
||||
SCHEMA_REGISTRY_CODES,
|
||||
type SchemaRegistryCode,
|
||||
SchemaRegistryError,
|
||||
} from "./errors/schema-registry-error.js";
|
||||
// Types
|
||||
export type {
|
||||
CompatibilityLevel,
|
||||
CompatibilityResult,
|
||||
RegistryOptions,
|
||||
SchemaDefinition,
|
||||
SchemaFormat,
|
||||
SubjectState,
|
||||
} from "./types/schema-registry.types.js";
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
CompatibilityLevel,
|
||||
CompatibilityResult,
|
||||
RegistryOptions,
|
||||
SchemaDefinition,
|
||||
SchemaFormat,
|
||||
SubjectState,
|
||||
} from "./schema-registry.types";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Core types for `@suaveplan/schema-registry`.
|
||||
*
|
||||
* All type definitions live here; implementations import from this file.
|
||||
*/
|
||||
|
||||
/** Supported compatibility enforcement levels. */
|
||||
export type CompatibilityLevel = "BACKWARD" | "FORWARD" | "FULL" | "NONE";
|
||||
|
||||
/** Supported schema formats. */
|
||||
export type SchemaFormat = "json-schema" | "zod" | "avro" | "custom";
|
||||
|
||||
/** A registered schema version with all its metadata. */
|
||||
export interface SchemaDefinition {
|
||||
/** Unique identifier for this schema version (subject + "#" + version). */
|
||||
id: string;
|
||||
/** Monotonically increasing version number, starting at 1 for each subject. */
|
||||
version: number;
|
||||
/** Logical subject name, e.g. "UserCreated" or "OrderPlaced". */
|
||||
subject: string;
|
||||
/** The raw schema object (JSON Schema object, Zod schema instance, etc.). */
|
||||
schema: unknown;
|
||||
/** Format used to interpret the schema. */
|
||||
format: SchemaFormat;
|
||||
/** ISO 8601 timestamp of registration. */
|
||||
createdAt: string;
|
||||
/** Optional caller-supplied metadata key/value pairs. */
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** The result of a compatibility check between two schemas. */
|
||||
export interface CompatibilityResult {
|
||||
/** Whether the candidate schema is compatible under the configured level. */
|
||||
compatible: boolean;
|
||||
/** The compatibility level that was checked. */
|
||||
level: CompatibilityLevel;
|
||||
/** Human-readable descriptions of each detected breaking change. */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** Construction options for `SchemaRegistry`. */
|
||||
export interface RegistryOptions {
|
||||
/**
|
||||
* Default compatibility level applied to all subjects unless overridden
|
||||
* via `setCompatibility()`.
|
||||
* @default 'BACKWARD'
|
||||
*/
|
||||
defaultCompatibility?: CompatibilityLevel;
|
||||
/**
|
||||
* Maximum number of versions retained per subject before old ones are pruned.
|
||||
* @default 1000
|
||||
*/
|
||||
maxVersionsPerSubject?: number;
|
||||
}
|
||||
|
||||
/** Internal per-subject state. */
|
||||
export interface SubjectState {
|
||||
versions: SchemaDefinition[];
|
||||
compatibility: CompatibilityLevel;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("@suaveplan/* runtime deps are exactly error + types", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual(["@suaveplan/error", "@suaveplan/types"]);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual([
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 5 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(5);
|
||||
expect(pkg.exports).toHaveProperty(".");
|
||||
expect(pkg.exports).toHaveProperty("./compatibility");
|
||||
expect(pkg.exports).toHaveProperty("./registry");
|
||||
expect(pkg.exports).toHaveProperty("./errors");
|
||||
});
|
||||
});
|
||||
12
packages/foundation/core/schema-registry/tsconfig.lib.json
Normal file
12
packages/foundation/core/schema-registry/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
20
packages/foundation/core/schema-registry/vite.config.ts
Normal file
20
packages/foundation/core/schema-registry/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/schema-registry",
|
||||
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: ["@suaveplan/error", "@suaveplan/types", "tslib", "zod", "@opentelemetry/api", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/foundation/core/schema-registry/vitest.config.ts
Normal file
28
packages/foundation/core/schema-registry/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user