17 KiB
Schema-registry completion
Package: @suaveplan/schema-registry
Status: PROPOSED
Type: feature
Why
Genesis ships only a thin in-memory SchemaRegistry (createRegistry, register / getLatest / getVersion / getAll / listSubjects / deleteSubject / setCompatibility / checkCompatibility / clear) plus the checkCompatibility predicate and a four-code error registry. The legacy packages/universal/schema-registry/src shipped a full enterprise schema-registry surface with seven additional capabilities that genesis silently dropped:
- Transitive compatibility —
BACKWARD_TRANSITIVE/FORWARD_TRANSITIVE/FULL_TRANSITIVEmodes that check a candidate against ALL prior versions, not just the latest. Legacysrc/compatibility/transitive-checker.ts(L1-L100) shipscheckTransitiveAgainstAll,buildTransitiveReport,TransitiveReport,TransitiveVersionResult,isTransitiveMode,stripTransitive. Without these, schema-evolution policies that mandate "every old consumer must keep working" cannot be enforced — a real concern for event-sourced systems and message buses. - Schema diff —
diffSchemas(a, b)→SchemaDiffResultandformatDiffReport(result)→ human string, plus theSchemaDiffChangeandDiffChangeTypetypes. Legacysrc/diff/schema-differ.ts(L1-L175). Without diff, callers cannot surface "what specifically broke" to ops users. SchemaStoreAdapterinterface — the contract that lets the registry persist beyond process memory. Legacysrc/types/registry.types.ts(L82-L105) declares 8 methods; genesis has no analogous abstraction.MemorySchemaStore— in-memory implementation ofSchemaStoreAdapter. Legacy ships this implicitly inside the registry; genesis needs it as a standalone module so consumers can swap stores.DatabaseSchemaStore—DatabaseAdapter-backed implementation. Legacysrc/store/database-store.ts(L1-L147). Without it, the genesis registry is process-local.- Canonicalisation utilities —
canonicalizeSchema,computeFingerprint,djb2Hash,flattenFields,isOptionalOrHasDefault. Legacysrc/utils/utils.ts(L1-L70). The registry needs deterministic fingerprints to deduplicate identical re-registrations and to support hash-based subject indexing. SubjectConfig— per-subject configuration object including compatibility mode plus metadata. Legacysrc/types/registry.types.ts(L65-L68) ships a minimal version; the genesis registry hardcodes the compat level onSubjectStatewith no metadata or persisted version cap.
The genesis-original behaviour the registry already ships (auto-incrementing version, INVALID_SCHEMA validation, OTel register.total counter and check.duration_ms histogram) is preserved. This change is purely additive on the existing SchemaRegistry surface — no current export is removed or renamed.
What changes
- ADDED:
CompatibilityLevelwidened to"BACKWARD" | "FORWARD" | "FULL" | "NONE" | "BACKWARD_TRANSITIVE" | "FORWARD_TRANSITIVE" | "FULL_TRANSITIVE". Existing four levels keep their meaning; the three new modes activate per-subject transitive checking. - ADDED:
checkTransitiveCompatibility(history: readonly SchemaDefinition[], candidate: unknown, mode: CompatibilityLevel)→CompatibilityResult— convenience entry point. - ADDED:
buildTransitiveReport(history, candidate, mode)→TransitiveReport— verbose per-version compatibility breakdown. - ADDED:
TransitiveReportinterface —{ compatible: boolean; mode: CompatibilityLevel; versionResults: TransitiveVersionResult[]; totalVersions: number; failedVersions: number }. - ADDED:
TransitiveVersionResultinterface —{ version: number; compatible: boolean; errors: readonly string[] }. - ADDED:
isTransitiveMode(mode)andstripTransitive(mode)helper functions. - ADDED:
diffSchemas(oldSchema, newSchema)→SchemaDiffResult— structural diff identifying added / removed / modified fields and flagging breaking changes. - ADDED:
formatDiffReport(result)→ human-readable string — counts plus per-change+ / - / ~rendering with[BREAKING]annotation. - ADDED:
SchemaDiffChangeinterface —{ changeType: DiffChangeType; path: string; breaking: boolean; description: string; oldField?: SchemaField; newField?: SchemaField }. - ADDED:
SchemaDiffResultinterface —{ changes: readonly SchemaDiffChange[]; hasBreakingChanges: boolean; summary: string }. - ADDED:
DiffChangeTypeunion —"added" | "removed" | "modified". - ADDED:
SchemaFieldinterface —{ name: string; type: string; required?: boolean; defaultValue?: unknown; fields?: readonly SchemaField[] }. Used by the diff and canonicalisation utilities. (Legacy registry already had this; genesis dropped it.) - ADDED:
SchemaStoreAdapterinterface —getVersions(subject),getVersion(subject, version),getLatest(subject),putVersion(version),getSubjects(),hasSubject(subject),deleteSubject(subject),getConfig(subject),setConfig(subject, config). All async. - ADDED:
MemorySchemaStoreclass implementingSchemaStoreAdapteragainst an in-memoryMap. - ADDED:
DatabaseSchemaStore<TAdapter extends DatabaseAdapter>class implementingSchemaStoreAdapterover@suaveplan/db'sDatabaseAdapter. Constructor takes(db, tableName?, configTableName?)defaulting to"schema_versions"/"schema_configs". - ADDED:
canonicalizeSchema(schema)→ stable JSON string with fields sorted at every level. - ADDED:
computeFingerprint(schema)→ 8-hex-char djb2 hash of the canonical form. - ADDED:
djb2Hash(input)→ 8-hex-char hash. Exported as a building block. - ADDED:
flattenFields(fields, prefix?)→Map<string, SchemaField>keyed by dot-notation paths. - ADDED:
isOptionalOrHasDefault(field)→ boolean. - ADDED:
SubjectConfiginterface —{ compatibility: CompatibilityLevel; maxVersions?: number; metadata?: Record<string, string> }. Replaces the inline-on-SubjectStaterepresentation. - MODIFIED:
SchemaRegistryconstructor — accepts an optionalstore?: SchemaStoreAdapter. When supplied, persistence routes through the store; otherwise the existing in-processMappath is used. The synchronous public API is preserved when no store is supplied; when a store IS supplied, an alternative async surface (registerAsync,getLatestAsync,getVersionAsync,getAllAsync,listSubjectsAsync,deleteSubjectAsync,checkCompatibilityAsync) is exposed alongside the existing sync methods (which throw if a store is configured but the call is sync). - MODIFIED:
SchemaDefinition—metadata?: Record<string, string>already supported; ADDED a non-breakingfingerprint?: stringfield that, when present, equalscomputeFingerprint(schema as SchemaDefinition). - ADDED: error code
SCHEMA_REGISTRY.STORE_FAILURE— thrown when the underlyingSchemaStoreAdapterfails (network, DB). - ADDED: error code
SCHEMA_REGISTRY.UNKNOWN_COMPATIBILITY_MODE— thrown when an invalid mode string is supplied.
Out of scope
- Confluent Schema Registry HTTP client — the legacy package never shipped one; the integration with Confluent's REST API is a separate pending change tracked as
schema-registry-confluent-adapter. Reason for splitting: the Confluent contract is large (subjects, configs, deletes, soft/hard deletes, modes, mode reset, schema-by-id lookup, version-by-schema lookup), authentication varies (basic/bearer/mTLS), and pulling it in here would dilute review of the much smaller transitive + diff + store surface. - Avro / Protobuf / JSON-Schema specific format adapters —
SchemaFormatalready includes"json-schema" | "zod" | "avro" | "custom"in genesis. This change ships only the internal Zod-describedSchemaDefinitionshape that drives the diff and compatibility engines; semantic adapters per format land in follow-up changes (schema-registry-avro-adapter,schema-registry-protobuf-adapter). Reason: each format has bespoke compatibility semantics — Avro reader/writer schema rules differ from JSON Schema's$refresolution. - Webhook notifications on schema change — the legacy package did not ship them; any
register→ fan-out integration is delegated to@suaveplan/webhook-clientby composition. - Schema validation (running data through a schema) —
@suaveplan/validationis the canonical home; the registry stores schemas, the validation package validates payloads against them. zod-as-SchemaDefinitionautodiff — the diff engine works onSchemaDefinition({ type, fields }) per the legacy contract. Translating a Zod schema intoSchemaDefinitionis a follow-up; for now consumers either store JSON-Schema-shaped definitions or supply a custom translator.
Source mapping
This change derives from the legacy packages/universal/schema-registry/src tree at ../suaveplan/. No enterprise/schema-registry.md per-feature audit doc exists; source mapping cites the upstream files directly.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
checkTransitiveCompatibility (= checkTransitiveAgainstAll) |
packages/universal/schema-registry/src/compatibility/transitive-checker.ts |
L18-L35 | Renamed for symmetry with checkCompatibility; behaviour verbatim |
buildTransitiveReport |
packages/universal/schema-registry/src/compatibility/transitive-checker.ts |
L58-L84 | Verbatim |
TransitiveReport interface |
packages/universal/schema-registry/src/compatibility/transitive-checker.ts |
L94-L100 | Verbatim shape; widen mode to genesis CompatibilityLevel |
TransitiveVersionResult interface |
packages/universal/schema-registry/src/compatibility/transitive-checker.ts |
L87-L91 | Verbatim |
isTransitiveMode / stripTransitive helpers |
packages/universal/schema-registry/src/compatibility/transitive-checker.ts |
L40-L52 | Verbatim |
diffSchemas |
packages/universal/schema-registry/src/diff/schema-differ.ts |
L16-L40 | Verbatim |
formatDiffReport |
packages/universal/schema-registry/src/diff/schema-differ.ts |
L45-L64 | Verbatim |
SchemaDiffChange interface |
packages/universal/schema-registry/src/types/registry.types.ts |
L118-L132 | Verbatim |
SchemaDiffResult interface |
packages/universal/schema-registry/src/types/registry.types.ts |
L134-L142 | Verbatim |
DiffChangeType union |
packages/universal/schema-registry/src/types/registry.types.ts |
L116 | Verbatim |
SchemaField interface |
packages/universal/schema-registry/src/types/registry.types.ts |
L15-L26 | Verbatim |
SchemaStoreAdapter interface |
packages/universal/schema-registry/src/types/registry.types.ts |
L82-L105 | Verbatim shape; widen mode types to genesis CompatibilityLevel |
DatabaseSchemaStore class |
packages/universal/schema-registry/src/store/database-store.ts |
L1-L147 | Re-target DatabaseAdapter import to @suaveplan/db's DatabaseAdapter (legacy used an inline interface) |
canonicalizeSchema / computeFingerprint / djb2Hash / flattenFields / isOptionalOrHasDefault |
packages/universal/schema-registry/src/utils/utils.ts |
L1-L70 | Verbatim |
SubjectConfig (compatibility, maxVersions?, metadata?) |
packages/universal/schema-registry/src/types/registry.types.ts |
L65-L68 | Extended: legacy ships only compatibility; genesis adds maxVersions and metadata (already partially modelled as constructor option). |
Requirements with no source line
| Requirement | Justification |
|---|---|
MemorySchemaStore standalone class |
Legacy ships the in-memory case implicitly inside the registry; genesis exposes it as a standalone, importable module so consumers can swap stores without re-wiring. Symmetric with DatabaseSchemaStore. |
SchemaRegistry.{registerAsync, getLatestAsync, ...} async surface |
Legacy registry is sync; legacy SchemaStoreAdapter is async. Bridging requires either making the whole API async (BREAKING) or shipping a parallel async surface. Genesis chooses the additive route to preserve the existing sync API for in-memory users. |
SCHEMA_REGISTRY.STORE_FAILURE and SCHEMA_REGISTRY.UNKNOWN_COMPATIBILITY_MODE codes |
Genesis-wide policy — every thrown error routes through @suaveplan/error (CLAUDE.md §Workflow rule 2). |
SchemaDefinition.fingerprint? field |
Required for de-duplication when an identical schema is re-registered; without it, every retry creates a new version. The fingerprint is computed by the registry at register-time using computeFingerprint; consumers do not supply it. |
Verification approach
Each spec delta requirement carries a **Verified by:** line pointing at concrete it(...) titles. The test files that will exist after this change ships:
packages/core/schema-registry/src/core/transitive/transitive.test.ts— coverscheckTransitiveCompatibilityagainst multi-version historiespackages/core/schema-registry/src/core/transitive/transitive.spec-conformance.test.ts— pinsisTransitiveMode/stripTransitiveexhaustively over the sevenCompatibilityLevelvaluespackages/core/schema-registry/src/core/diff/diff.test.ts— coversdiffSchemasfor added / removed / modified / type-changed cases;formatDiffReportrenderingpackages/core/schema-registry/src/core/diff/diff.spec-conformance.test.ts— property testsdiffSchemas(a, a) === { changes: [], hasBreakingChanges: false }andformatDiffReport(diff(a, a)) === "No changes detected."packages/core/schema-registry/src/store/memory/memory.test.ts— coversMemorySchemaStoreround-trips, subject delete, config get/setpackages/core/schema-registry/src/store/memory/memory.spec-conformance.test.ts— pins theSchemaStoreAdaptercontractpackages/core/schema-registry/src/store/database/database.test.ts— coversDatabaseSchemaStoreagainst an in-memoryDatabaseAdapterstubpackages/core/schema-registry/src/store/database/database.spec-conformance.test.ts— pins the same contract; verifiesSTORE_FAILUREis raised on adapter exceptionpackages/core/schema-registry/src/utils/canonical/canonical.test.ts— coverscanonicalizeSchema,computeFingerprint,djb2Hash,flattenFields,isOptionalOrHasDefaultpackages/core/schema-registry/src/utils/canonical/canonical.spec-conformance.test.ts—fast-checkpropertycanonicalizeSchema(a) === canonicalizeSchema(shuffleFields(a))(canonicalisation is order-independent)packages/core/schema-registry/src/core/registry/schema-registry.test.ts— extended to cover the async surface and store wiringpackages/core/schema-registry/src/core/registry/schema-registry.spec-conformance.test.ts— pins theUNKNOWN_COMPATIBILITY_MODEandSTORE_FAILUREerror contracts; pins theSchemaDefinition.fingerprintinvariant (re-register identical schema produces same fingerprint and triggers de-dup)
Migration
This is an additive change to the SchemaRegistry constructor and CompatibilityLevel union. No public API is removed; existing in-memory consumers continue to work without modification.
For consumers that want to switch to a persisted store:
- Old behavior:
new SchemaRegistry({ defaultCompatibility: "BACKWARD" })was process-local. - New behavior:
new SchemaRegistry({ defaultCompatibility: "BACKWARD", store: new DatabaseSchemaStore(dbAdapter) })persists to the supplied store. The async surface (registerAsync,getLatestAsync, ...) replaces the sync one; calling sync methods on a store-backed registry throwsSCHEMA_REGISTRY.STORE_FAILUREwithcontext.reason === "sync_call_on_async_store". - Search command consumers should run:
grep -rln "new SchemaRegistry\|createRegistry" packages/ src/. - Recommended replacement: for in-memory users, no change. For persisted users, switch construction to pass
storeand migrate call sites to the*Asyncvariants.
For consumers using CompatibilityLevel:
- Old behavior: the union was
"BACKWARD" | "FORWARD" | "FULL" | "NONE". - New behavior: the union also includes
"BACKWARD_TRANSITIVE" | "FORWARD_TRANSITIVE" | "FULL_TRANSITIVE". Exhaustiveswitchstatements overCompatibilityLevelwill surface a TypeScript narrowing miss until consumers add the three new arms (or fall through them to the corresponding non-transitive case). - Search command:
grep -rln "CompatibilityLevel\|defaultCompatibility" packages/ src/. - Recommended replacement: in any exhaustive
switch, fall throughBACKWARD_TRANSITIVEto theBACKWARDarm; same forFORWARD_TRANSITIVE/FULL_TRANSITIVE. The transitive variants only differ at register-time (check against all versions vs. latest).
Impact
Affected dependents
Run bunx turbo ls --filter=...@suaveplan/schema-registry to enumerate. From grep -rl "@suaveplan/schema-registry" /home/ubuntu/web/genesis/packages/ --include="package.json":
@suaveplan/events-schema— [x] tests run / [x] docs reviewed / [ ] no action needed. The events-schema package depends on the registry to track event-schema versions; it currently uses the in-memory path. After this change it can opt into transitive compatibility (event consumers commonly span multiple schema versions in flight) and persisted storage (event schemas should outlive process restarts). Tests must verify the package keeps building and passing with the new union shape.
For all other monorepo packages: no action — they do not depend on @suaveplan/schema-registry. This change is additive only.