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): stringreturns a multi-line string with one operation per line in the formatOP PATH [-> VALUE] [(from FROM)]. Operations:add /path -> valueremove /pathreplace /path -> valuemove /from -> /pathcopy /from -> /pathtest /path -> value
- ADDED:
StringifyPatchOptionsinterface —{ valueFormatter?: (value: unknown) => string; indent?: string; uppercaseOps?: boolean }.valueFormatterdefaults toJSON.stringify(value);indentdefaults to empty string;uppercaseOpsdefaults tofalse(RFC-6902 op names are lowercase). - ADDED: subpath export
./utils/stringify-patchinpackage.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-patchannotated withopCount: number. - MODIFIED:
src/index.tsre-exportsstringifyPatchfrom the root barrel.
Out of scope
- Replacing the existing
stringify(value)helper. Genesis does not currently exportstringifyfromutils/. The newstringifyPatchis a patch formatter — distinct name, distinct surface. We deliberately do not also ship astringify(value)wrapper aroundJSON.stringifybecause 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 composestringifyPatchwithcli'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).stringifyPatchformats the patch operations themselves, not the documents. - Patch parsing (
parsePatch(text)). Going from text back to aJsonPatcharray would re-create whatJSON.parse(text) as JsonPatchalready does. Adding a parsing helper here is busywork. - Sorting / compaction. The patch is rendered as-given.
stringifyPatchis 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 defaultJSON.stringifyformatter.src/utils/stringify-patch/stringify-patch.spec-conformance.test.ts—valueFormatterinjection point invoked once per value-bearing op;uppercaseOpsflag changes case;indentprefix prepended per line;DIFF.INVALID_PATCH_OPthrown on unknown op; OTel span event emitted with correctopCount; 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 (usesapplyPatch/revertPatch; newstringifyPatchis additive)@suaveplan/event-sourcing— [x] tests run / [x] docs reviewed / [ ] no action needed (good candidate; doc recommends migrating from inline patch-format helper tostringifyPatch)@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.