Files
2026-05-27 23:29:53 +01:00

19 KiB

Capability Spec Delta: schema-registry (schema-registry-completion)

Package: @suaveplan/schema-registry Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions. (See openspec/AGENTS.md Section 13.)

ADDED Requirements

Requirement: Transitive compatibility checking against full version history

The package SHALL expose checkTransitiveCompatibility(history, candidate, mode) that, given an ordered list of prior schema versions, validates the candidate against EVERY prior version under the supplied transitive CompatibilityLevel (BACKWARD_TRANSITIVE / FORWARD_TRANSITIVE / FULL_TRANSITIVE). It SHALL also expose buildTransitiveReport(history, candidate, mode) that returns a per-version breakdown including pass/fail status and per-version errors, plus the helper functions isTransitiveMode(mode) and stripTransitive(mode). Errors collected from individual versions SHALL be prefixed with [v<n>] to make their origin explicit.

Scenario: BACKWARD_TRANSITIVE rejects when ANY old version disagrees

  • WHEN the history has versions v1, v2, v3 and the candidate is backward-compatible with v3 but NOT with v1
  • THEN checkTransitiveCompatibility(history, candidate, "BACKWARD_TRANSITIVE") returns { compatible: false, errors: [<some entries prefixed with "[v1] ">], mode: "BACKWARD_TRANSITIVE" }

Scenario: BACKWARD_TRANSITIVE accepts when every old version agrees

  • WHEN the candidate is backward-compatible with v1, v2, AND v3
  • THEN the result is { compatible: true, errors: [], mode: "BACKWARD_TRANSITIVE" }

Scenario: buildTransitiveReport surfaces per-version pass/fail

  • WHEN buildTransitiveReport is called against a 3-version history where v1 fails and v2/v3 pass
  • THEN the result has compatible: false, totalVersions: 3, failedVersions: 1, and versionResults[0].compatible === false (with errors.length > 0), versionResults[1].compatible === true, versionResults[2].compatible === true

Scenario: isTransitiveMode discriminates the three transitive levels

  • WHEN isTransitiveMode is called with each of the seven CompatibilityLevel values
  • THEN it returns true for BACKWARD_TRANSITIVE, FORWARD_TRANSITIVE, FULL_TRANSITIVE; false otherwise

Scenario: stripTransitive maps to the corresponding non-transitive level

  • WHEN stripTransitive is called
  • THEN BACKWARD_TRANSITIVE → "BACKWARD", FORWARD_TRANSITIVE → "FORWARD", FULL_TRANSITIVE → "FULL", and the four non-transitive values pass through unchanged

Scenario: SchemaRegistry register routes through transitive checker for transitive subjects

  • WHEN a subject's compatibility is set to FULL_TRANSITIVE and a candidate is registered against a multi-version history
  • THEN the registry calls checkTransitiveCompatibility(state.versions, candidate, "FULL_TRANSITIVE") rather than checkCompatibility(candidate, latest, "FULL")

Verified by: packages/core/schema-registry/src/core/transitive/transitive.test.ts::"BACKWARD_TRANSITIVE rejects when an older version disagrees" AND packages/core/schema-registry/src/core/transitive/transitive.test.ts::"BACKWARD_TRANSITIVE accepts when every old version agrees" AND packages/core/schema-registry/src/core/transitive/transitive.test.ts::"buildTransitiveReport surfaces per-version pass/fail" AND packages/core/schema-registry/src/core/transitive/transitive.spec-conformance.test.ts::"isTransitiveMode discriminates the three transitive levels exhaustively" AND packages/core/schema-registry/src/core/transitive/transitive.spec-conformance.test.ts::"stripTransitive maps to the corresponding non-transitive level" AND packages/core/schema-registry/src/core/registry/schema-registry.test.ts::"register routes through transitive checker for FULL_TRANSITIVE subject"


Requirement: Schema diff engine

The package SHALL expose diffSchemas(oldSchema, newSchema) returning a SchemaDiffResult that enumerates every added / removed / modified field with dot-notation paths and breaking-change flags, plus formatDiffReport(result) returning a human-readable summary. The diff engine SHALL detect: schema-type changes (top-level type field), removed fields (breaking unless the old field had a default or was optional), added fields (breaking when added as required), modified fields (type change is always breaking; required-flag change is breaking only when becoming required). The result SHALL include a summary string of the form "<n> added, <m> removed, <k> modified[ (has breaking changes)]".

Scenario: detects added fields and flags requiredness

  • WHEN the new schema has a field email that the old schema lacks, with required: true
  • THEN the result includes { changeType: "added", path: "email", breaking: true, ... }

Scenario: detects added optional fields as non-breaking

  • WHEN the new schema has a field nickname with required: false (or defaultValue present)
  • THEN the result includes { changeType: "added", path: "nickname", breaking: false, ... }

Scenario: detects removed required fields as breaking

  • WHEN the old schema had a required field id that the new schema lacks
  • THEN the result includes { changeType: "removed", path: "id", breaking: true, ... }

Scenario: detects type changes as breaking

  • WHEN the field age changed from "number" to "string"
  • THEN the result includes { changeType: "modified", path: "age", breaking: true, ... }

Scenario: detects optional→required as breaking, required→optional as non-breaking

  • WHEN field phone changed from optional to required
  • THEN the change is breaking: true
  • WHEN field phone changed from required to optional
  • THEN the change is breaking: false

Scenario: top-level type change short-circuits

  • WHEN the old schema has type: "json" and the new one has type: "avro"
  • THEN the result has exactly one change with path "$type", breaking: true, and field-level changes are not emitted

Scenario: identical schemas produce no changes

  • WHEN diffSchemas(a, a) is called
  • THEN result.changes === [], hasBreakingChanges === false, summary === "No changes"

Scenario: formatDiffReport renders no-changes path

  • WHEN formatDiffReport({ changes: [], hasBreakingChanges: false, summary: "No changes" }) is called
  • THEN the returned string is "No changes detected."

Scenario: formatDiffReport annotates breaking entries

  • WHEN the result has at least one breaking change
  • THEN the rendered report contains "BREAKING CHANGES DETECTED" and the per-change line ends with " [BREAKING]"

Verified by: packages/core/schema-registry/src/core/diff/diff.test.ts::"detects added required fields as breaking" AND packages/core/schema-registry/src/core/diff/diff.test.ts::"detects added optional fields as non-breaking" AND packages/core/schema-registry/src/core/diff/diff.test.ts::"detects removed required fields as breaking" AND packages/core/schema-registry/src/core/diff/diff.test.ts::"detects type changes as breaking" AND packages/core/schema-registry/src/core/diff/diff.test.ts::"detects optional-to-required as breaking and required-to-optional as non-breaking" AND packages/core/schema-registry/src/core/diff/diff.test.ts::"top-level type change short-circuits and emits a single $type change" AND packages/core/schema-registry/src/core/diff/diff.spec-conformance.test.ts::"diffSchemas of identical schemas produces no changes" AND packages/core/schema-registry/src/core/diff/diff.test.ts::"formatDiffReport renders the no-changes path" AND packages/core/schema-registry/src/core/diff/diff.test.ts::"formatDiffReport annotates breaking entries"


Requirement: SchemaStoreAdapter persistence interface

The package SHALL expose a SchemaStoreAdapter interface declaring eight async methods: getVersions(subject), getVersion(subject, version), getLatest(subject), putVersion(version), getSubjects(), hasSubject(subject), deleteSubject(subject) (returning boolean — false when subject absent), getConfig(subject) and setConfig(subject, config). The interface SHALL be the contract every store implementation satisfies; consumers programming against it SHALL be able to swap stores without registry-side changes.

Scenario: every method is present and async

  • WHEN any concrete store implementation is type-checked
  • THEN all eight methods exist on the prototype and each returns a Promise

Scenario: deleteSubject returns false when the subject is absent

  • WHEN deleteSubject("never-registered") is called on a store with no such subject
  • THEN the returned Promise resolves to false (not true, not undefined)

Scenario: deleteSubject returns true when the subject existed and was removed

  • WHEN a subject has been registered and deleteSubject(subject) is called
  • THEN the returned Promise resolves to true AND a subsequent hasSubject(subject) resolves to false

Verified by: packages/core/schema-registry/src/store/memory/memory.spec-conformance.test.ts::"every SchemaStoreAdapter method is present and async" AND packages/core/schema-registry/src/store/memory/memory.test.ts::"deleteSubject returns false when subject absent" AND packages/core/schema-registry/src/store/memory/memory.test.ts::"deleteSubject returns true when subject existed and was removed"


Requirement: MemorySchemaStore in-memory implementation

The package SHALL expose MemorySchemaStore implementing SchemaStoreAdapter against an internal Map<string, SchemaDefinition[]> for versions and Map<string, SubjectConfig> for configs. All methods SHALL be implemented; getVersions SHALL return a defensive copy so caller mutation does not leak; getLatest SHALL return the highest-versioned entry or undefined.

Scenario: round-trip a single subject

  • WHEN a version is putVersioned, then read via getLatest, getVersion, and getVersions
  • THEN all three return the equivalent SchemaDefinition (deep-equal)

Scenario: getVersions returns a defensive copy

  • WHEN getVersions(subject) is called and the caller mutates the returned array
  • THEN a subsequent getVersions(subject) returns the unmutated original

Scenario: getLatest returns undefined for unknown subject

  • WHEN getLatest("never-registered") is called
  • THEN the resolved value is undefined

Scenario: getSubjects lists every subject with at least one version

  • WHEN subjects A, B, C have versions and subject D was deleted
  • THEN getSubjects() resolves to a list containing exactly ["A", "B", "C"] in some order

Verified by: packages/core/schema-registry/src/store/memory/memory.test.ts::"round-trips a single subject" AND packages/core/schema-registry/src/store/memory/memory.test.ts::"getVersions returns a defensive copy" AND packages/core/schema-registry/src/store/memory/memory.test.ts::"getLatest returns undefined for unknown subject" AND packages/core/schema-registry/src/store/memory/memory.test.ts::"getSubjects lists every subject with at least one version"


Requirement: DatabaseSchemaStore over @suaveplan/db

The package SHALL expose a generic DatabaseSchemaStore<TAdapter extends DatabaseAdapter> class implementing SchemaStoreAdapter over the DatabaseAdapter interface from @suaveplan/db. Constructor signature: (db: TAdapter, tableName?: string, configTableName?: string) defaulting to "schema_versions" / "schema_configs". Every method SHALL issue parameterised SQL via db.query / db.execute. When the underlying adapter rejects, the store SHALL wrap the failure in SchemaRegistryError(SCHEMA_REGISTRY.STORE_FAILURE, ...) preserving the original error in the cause chain.

Scenario: putVersion issues a parameterised INSERT

  • WHEN putVersion is called with a SchemaDefinition
  • THEN db.execute is called once with an INSERT INTO schema_versions ... statement and positional params [subject, version, schemaJson, fingerprint, registeredAt, deprecated]

Scenario: getLatest issues an ORDER BY DESC LIMIT 1 query

  • WHEN getLatest("user-created") is called
  • THEN db.query receives a SELECT ... ORDER BY version DESC LIMIT 1 statement and the params ["user-created"]

Scenario: deleteSubject removes versions AND configs

  • WHEN deleteSubject(subject) is called for a subject that exists
  • THEN db.execute is called twice: once on schema_versions WHERE subject = ? and once on schema_configs WHERE subject = ? AND the resolved value is true

Scenario: adapter rejection raises STORE_FAILURE

  • WHEN db.query rejects with an arbitrary error
  • THEN the call to the store method rejects with SchemaRegistryError whose code === SCHEMA_REGISTRY.STORE_FAILURE and whose cause is the original error

Scenario: configurable table names

  • WHEN new DatabaseSchemaStore(db, "schemas_v2", "config_v2") is constructed and any method runs
  • THEN the issued SQL references schemas_v2 / config_v2, not the defaults

Verified by: packages/core/schema-registry/src/store/database/database.test.ts::"putVersion issues a parameterised INSERT" AND packages/core/schema-registry/src/store/database/database.test.ts::"getLatest issues an ORDER BY DESC LIMIT 1 query" AND packages/core/schema-registry/src/store/database/database.test.ts::"deleteSubject removes versions and configs and returns true" AND packages/core/schema-registry/src/store/database/database.spec-conformance.test.ts::"adapter rejection raises STORE_FAILURE preserving the cause" AND packages/core/schema-registry/src/store/database/database.test.ts::"references configured table names instead of defaults"


Requirement: Canonicalisation utilities for deterministic schema fingerprints

The package SHALL expose canonicalizeSchema(schema) returning a stable JSON string with fields sorted alphabetically at every level of nesting; computeFingerprint(schema) returning an 8-character lower-hex djb2 hash of the canonical form; djb2Hash(input) exposed as a building block; flattenFields(fields, prefix?) returning a Map<string, SchemaField> keyed by dot-notation paths; isOptionalOrHasDefault(field) returning true when field.required === false OR field.defaultValue !== undefined. Canonicalisation SHALL be field-order independent — schemas that differ only in field declaration order SHALL produce identical canonical strings and identical fingerprints.

Scenario: canonicalizeSchema sorts fields at every level

  • WHEN a schema has top-level fields ["b", "a"] and b has nested fields ["d", "c"]
  • THEN the canonical string places fields in the order [a, b] and b.fields in the order [c, d]

Scenario: computeFingerprint is order-independent

  • WHEN two SchemaDefinition values differ only in field declaration order
  • THEN computeFingerprint(a) === computeFingerprint(b)

Scenario: djb2Hash matches a known vector

  • WHEN djb2Hash("hello") is called
  • THEN the result is the lower-hex 8-char djb2 hash of "hello" (deterministic, independent of host endianness)

Scenario: flattenFields produces dot-notation paths

  • WHEN flattenFields is called against [{ name: "user", fields: [{ name: "id" }, { name: "name" }] }]
  • THEN the resulting map has keys "user", "user.id", "user.name"

Scenario: isOptionalOrHasDefault returns true for either condition

  • WHEN the field has required: false OR defaultValue: <anything other than undefined>
  • THEN isOptionalOrHasDefault(field) === true

Scenario: SchemaRegistry de-duplicates re-registration via fingerprint

  • WHEN a schema is registered, then the IDENTICAL schema is registered again on the same subject
  • THEN the registry returns the existing SchemaDefinition (same version, same id) without assigning a new version number

Verified by: packages/core/schema-registry/src/utils/canonical/canonical.test.ts::"canonicalizeSchema sorts fields at every level" AND packages/core/schema-registry/src/utils/canonical/canonical.spec-conformance.test.ts::"computeFingerprint is field-order independent" AND packages/core/schema-registry/src/utils/canonical/canonical.test.ts::"djb2Hash matches a known vector" AND packages/core/schema-registry/src/utils/canonical/canonical.test.ts::"flattenFields produces dot-notation paths" AND packages/core/schema-registry/src/utils/canonical/canonical.test.ts::"isOptionalOrHasDefault returns true when either condition holds" AND packages/core/schema-registry/src/core/registry/schema-registry.test.ts::"register de-duplicates identical schema by fingerprint"


Requirement: SubjectConfig with compatibility, version cap, and metadata

The package SHALL expose SubjectConfig interface — { compatibility: CompatibilityLevel; maxVersions?: number; metadata?: Record<string, string> }. Per-subject configuration SHALL be persisted via the SchemaStoreAdapter (getConfig / setConfig) and consumed by the registry's compatibility check at register-time. Setting an unrecognised compatibility value via setCompatibility SHALL throw SchemaRegistryError(SCHEMA_REGISTRY.UNKNOWN_COMPATIBILITY_MODE, ...) with the offending value in context.mode. maxVersions (when present) overrides the registry-wide maxVersionsPerSubject for that subject.

Scenario: setCompatibility persists through the store

  • WHEN a registry is constructed with a MemorySchemaStore and setCompatibilityAsync(subject, "FULL_TRANSITIVE") is called
  • THEN store.getConfig(subject) resolves to a SubjectConfig with compatibility === "FULL_TRANSITIVE"

Scenario: per-subject maxVersions overrides the default

  • WHEN setConfig(subject, { compatibility: "BACKWARD", maxVersions: 5 }) is set and 6 versions are registered
  • THEN getAll(subject) returns the most recent 5 (oldest is pruned)

Scenario: metadata round-trips

  • WHEN setConfig(subject, { compatibility: "BACKWARD", metadata: { owner: "platform" } }) is set
  • THEN getConfig(subject) returns a config whose metadata.owner === "platform"

Scenario: unknown compatibility mode throws UNKNOWN_COMPATIBILITY_MODE

  • WHEN setCompatibility(subject, "MAGIC" as never) is called
  • THEN the call throws SchemaRegistryError with code === SCHEMA_REGISTRY.UNKNOWN_COMPATIBILITY_MODE and context.mode === "MAGIC"

Verified by: packages/core/schema-registry/src/core/registry/schema-registry.test.ts::"setCompatibility persists through the store" AND packages/core/schema-registry/src/core/registry/schema-registry.test.ts::"per-subject maxVersions overrides the default" AND packages/core/schema-registry/src/core/registry/schema-registry.test.ts::"SubjectConfig metadata round-trips through getConfig" AND packages/core/schema-registry/src/core/registry/schema-registry.spec-conformance.test.ts::"setCompatibility throws UNKNOWN_COMPATIBILITY_MODE for unrecognised mode"