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. (Seeopenspec/AGENTS.mdSection 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
buildTransitiveReportis called against a 3-version history where v1 fails and v2/v3 pass - THEN the result has
compatible: false,totalVersions: 3,failedVersions: 1, andversionResults[0].compatible === false(witherrors.length > 0),versionResults[1].compatible === true,versionResults[2].compatible === true
Scenario: isTransitiveMode discriminates the three transitive levels
- WHEN
isTransitiveModeis called with each of the sevenCompatibilityLevelvalues - THEN it returns
trueforBACKWARD_TRANSITIVE,FORWARD_TRANSITIVE,FULL_TRANSITIVE;falseotherwise
Scenario: stripTransitive maps to the corresponding non-transitive level
- WHEN
stripTransitiveis 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_TRANSITIVEand a candidate is registered against a multi-version history - THEN the registry calls
checkTransitiveCompatibility(state.versions, candidate, "FULL_TRANSITIVE")rather thancheckCompatibility(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
emailthat the old schema lacks, withrequired: 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
nicknamewithrequired: false(ordefaultValuepresent) - 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
idthat the new schema lacks - THEN the result includes
{ changeType: "removed", path: "id", breaking: true, ... }
Scenario: detects type changes as breaking
- WHEN the field
agechanged 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
phonechanged from optional to required - THEN the change is
breaking: true - WHEN field
phonechanged 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 hastype: "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(nottrue, notundefined)
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
trueAND a subsequenthasSubject(subject)resolves tofalse
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 viagetLatest,getVersion, andgetVersions - 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
putVersionis called with aSchemaDefinition - THEN
db.executeis called once with anINSERT 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.queryreceives aSELECT ... ORDER BY version DESC LIMIT 1statement and the params["user-created"]
Scenario: deleteSubject removes versions AND configs
- WHEN
deleteSubject(subject)is called for a subject that exists - THEN
db.executeis called twice: once onschema_versions WHERE subject = ?and once onschema_configs WHERE subject = ?AND the resolved value istrue
Scenario: adapter rejection raises STORE_FAILURE
- WHEN
db.queryrejects with an arbitrary error - THEN the call to the store method rejects with
SchemaRegistryErrorwhosecode === SCHEMA_REGISTRY.STORE_FAILUREand whosecauseis 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"]andbhas nested fields["d", "c"] - THEN the canonical string places fields in the order
[a, b]andb.fieldsin the order[c, d]
Scenario: computeFingerprint is order-independent
- WHEN two
SchemaDefinitionvalues 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: falseORdefaultValue: <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(sameversion, sameid) 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
MemorySchemaStoreandsetCompatibilityAsync(subject, "FULL_TRANSITIVE")is called - THEN
store.getConfig(subject)resolves to aSubjectConfigwithcompatibility === "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 whosemetadata.owner === "platform"
Scenario: unknown compatibility mode throws UNKNOWN_COMPATIBILITY_MODE
- WHEN
setCompatibility(subject, "MAGIC" as never)is called - THEN the call throws
SchemaRegistryErrorwithcode === SCHEMA_REGISTRY.UNKNOWN_COMPATIBILITY_MODEandcontext.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"