Files
suaveplan/openspec/changes/diff-stringify/proposal.md
2026-05-27 23:29:53 +01:00

8.2 KiB

Diff — stringify(patch) human-readable patch formatter

Package: @suaveplan/diff Status: PROPOSED Type: feature

Why

@suaveplan/diff produces RFC 6902 JSON Patch arrays from diff, applies them via applyPatch, and reverts them via revertPatch. What it does not provide is a human-readable rendering of a patch for logs, debug output, or audit trails. The legacy package shipped a stringify(value) helper at packages/universal/diff/src/utils/utils.ts:238-240 that wrapped JSON.stringify — useful for one-off value rendering, but indistinguishable from the platform helper. The 2026-05-02 audit found two genesis dependents (@suaveplan/event-sourcing, @suaveplan/crdt, @suaveplan/undo) re-implementing patch-formatting helpers inline (e.g. patch.map(op => \${op.op} ${op.path}`).join('\n')`) because they need readable patch output for debugging.

Genesis has the opportunity to ship a richer stringify(patch) than legacy did — one that takes a JsonPatch (not just a single value) and produces a multi-line OP path -> value rendering, rather than re-wrapping JSON.stringify. This is what consumers actually want: a single helper they can drop into a logger and get readable output for an entire patch, with stable formatting that's safe to compare in tests. The legacy one-liner wrapper does not pin a stable format, gets mixed up with the platform JSON.stringify at call sites, and leaves the patch-formatting use case unsolved.

What changes

@suaveplan/diff

  • ADDED: src/utils/stringify-patch/stringify-patch.ts — stringifyPatch(patch: JsonPatch, options?: StringifyPatchOptions): string returns a multi-line string with one operation per line in the format OP PATH [-> VALUE] [(from FROM)]. Operations:
    • add /path -> value
    • remove /path
    • replace /path -> value
    • move /from -> /path
    • copy /from -> /path
    • test /path -> value
  • ADDED: StringifyPatchOptions interface — { valueFormatter?: (value: unknown) => string; indent?: string; uppercaseOps?: boolean }. valueFormatter defaults to JSON.stringify(value); indent defaults to empty string; uppercaseOps defaults to false (RFC-6902 op names are lowercase).
  • ADDED: subpath export ./utils/stringify-patch in package.json::exports.
  • ADDED: error code DIFF.INVALID_PATCH_OP — thrown when an unknown patch operation is encountered (defensive; upstream RFC 6902 ops are pinned to a closed set, but the runtime guard still emits a structured error).
  • ADDED: OTel span event diff.stringify-patch annotated with opCount: number.
  • MODIFIED: src/index.ts re-exports stringifyPatch from the root barrel.

Out of scope

  • Replacing the existing stringify(value) helper. Genesis does not currently export stringify from utils/. The new stringifyPatch is a patch formatter — distinct name, distinct surface. We deliberately do not also ship a stringify(value) wrapper around JSON.stringify because the platform helper already exists and the legacy export added no value.
  • Color / ANSI output. The CLI-coloured rendering belongs in @suaveplan/cli. Callers wanting ANSI compose stringifyPatch with cli's colour helpers; they do not get colour from this package.
  • Diff-style rendering (+ a, - b, c). That's a different format (reconstruction of the source / target docs) and a different use case (visual diff). stringifyPatch formats the patch operations themselves, not the documents.
  • Patch parsing (parsePatch(text)). Going from text back to a JsonPatch array would re-create what JSON.parse(text) as JsonPatch already does. Adding a parsing helper here is busywork.
  • Sorting / compaction. The patch is rendered as-given. stringifyPatch is not a normaliser.

Parity Checklist

Capability Upstream Migrated Notes
stringify(value): string (JSON.stringify wrapper) ✅ 🚫 Replaced: the legacy helper added nothing over the platform JSON.stringify. Callers wanting one use the platform helper. The new stringifyPatch covers the actual unmet use case.
stringifyPatch(patch, options?): string (multi-line patch render) 🚫 (not in upstream) ⬆️ Added: the gap that motivated this change.
OTel observability for stringify 🚫 (not in upstream) ⬆️ Genesis-wide rule.
Structured error code for unknown op 🚫 (not in upstream) ⬆️ Genesis-wide error-routing rule.

Source mapping

This change derives from ../suaveplan/packages/universal/diff/src/utils/utils.ts. The legacy implementation (L238-L240) is:

export function stringify(value: unknown): string {
    return JSON.stringify(value);
}

Genesis does not port this verbatim. Source mapping captures the intent (a patch-rendering helper) which legacy under-delivered, then maps the new requirements to RFC 6902 itself for the per-operation grammar.

Requirement Source file Source lines Notes
Concept of a single stringify helper exported from utils/ packages/universal/diff/src/utils/utils.ts L237-L240 Inspirational only; legacy was JSON.stringify wrapper. Genesis ships a dedicated stringifyPatch instead.
add / remove / replace / move / copy / test operation grammar RFC 6902 §4.1-§4.6 n/a Spec-driven. Each operation's path / value / from fields are defined by the RFC.
JsonPatch type used as input packages/data/diff/src/operations/operations.ts (genesis) (existing) The new helper consumes the same JsonPatch type the rest of genesis-diff already uses.

Requirements with no source line

Requirement Justification
Multi-line OP PATH [-> VALUE] [(from FROM)] rendering The 2026-05-02 audit found three downstream packages (event-sourcing, crdt, undo) inlining their own variants. Standardising the format here removes drift.
StringifyPatchOptions.valueFormatter injection point Allows callers to swap JSON.stringify for a redacting / size-capping / pretty-printing variant (audit logging, log aggregation pipelines).
StringifyPatchOptions.uppercaseOps flag Some logging conventions render operation names in uppercase. Cheap config to support; default matches RFC 6902 (lowercase).
StringifyPatchOptions.indent prefix Allows callers to nest the rendered patch inside a larger log message without doing the indent themselves.
Error code DIFF.INVALID_PATCH_OP Defensive guard — RFC 6902 ops are a closed set, but a malformed in-memory JsonPatch could carry an unknown op. Genesis routes the error through DiffError with a stable code; legacy threw nothing (was unreachable in legacy because legacy didn't render ops).
OTel span event diff.stringify-patch with opCount attribute Genesis-wide observability rule.

Verification approach

  • src/utils/stringify-patch/stringify-patch.test.ts — every operation type renders correctly; multi-op patches join with \n; empty patch returns empty string; long values use the default JSON.stringify formatter.
  • src/utils/stringify-patch/stringify-patch.spec-conformance.test.ts — valueFormatter injection point invoked once per value-bearing op; uppercaseOps flag changes case; indent prefix prepended per line; DIFF.INVALID_PATCH_OP thrown on unknown op; OTel span event emitted with correct opCount; subpath export resolves.

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/diff to enumerate. Three packages depend on @suaveplan/diff today.

  • @suaveplan/undo — [ ] tests run / [ ] docs reviewed / [x] no action needed (uses applyPatch / revertPatch; new stringifyPatch is additive)
  • @suaveplan/event-sourcing — [x] tests run / [x] docs reviewed / [ ] no action needed (good candidate; doc recommends migrating from inline patch-format helper to stringifyPatch)
  • @suaveplan/crdt — [x] tests run / [x] docs reviewed / [ ] no action needed (good candidate; same recommendation)

@suaveplan/diff is not on the foundation-package list, but it is depended on by three packages so the dependent sweep (§13 step 10) is run anyway:

bunx turbo test --filter=...@suaveplan/diff

Migration impact

None — additive only. No existing diff / applyPatch / revertPatch / path/* / history/* / sync/* surface is touched.