254 lines
19 KiB
Markdown
254 lines
19 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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 `putVersion`ed, 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"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|