diff.ts 2.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. import type { JsonPatch } from "./types";
  2. /**
  3. * Escape a single JSON Pointer token per RFC 6901.
  4. * `~` → `~0`, `/` → `~1`.
  5. */
  6. function escapeToken(token: string): string {
  7. return token.replace(/~/g, "~0").replace(/\//g, "~1");
  8. }
  9. function buildPath(basePath: string, key: string): string {
  10. return `${basePath}/${escapeToken(key)}`;
  11. }
  12. function isPlainObject(value: unknown): value is Record<string, unknown> {
  13. return value !== null && typeof value === "object" && !Array.isArray(value);
  14. }
  15. /**
  16. * Shallow equality for arrays — used to avoid emitting patches when the
  17. * children list hasn't actually changed.
  18. */
  19. function arraysEqual(a: unknown[], b: unknown[]): boolean {
  20. if (a.length !== b.length) return false;
  21. for (let i = 0; i < a.length; i++) {
  22. if (a[i] !== b[i]) return false;
  23. }
  24. return true;
  25. }
  26. /**
  27. * Produce RFC 6902 JSON Patch operations that transform `oldObj` into `newObj`.
  28. *
  29. * - New keys → `add`
  30. * - Changed scalar/array values → `replace`
  31. * - Removed keys → `remove`
  32. * - Arrays are compared shallowly and replaced atomically (not element-diffed)
  33. * - Plain objects recurse
  34. */
  35. export function diffToPatches(
  36. oldObj: Record<string, unknown>,
  37. newObj: Record<string, unknown>,
  38. basePath = "",
  39. ): JsonPatch[] {
  40. const patches: JsonPatch[] = [];
  41. // Keys present in newObj
  42. for (const key of Object.keys(newObj)) {
  43. const path = buildPath(basePath, key);
  44. const oldVal = oldObj[key];
  45. const newVal = newObj[key];
  46. if (!(key in oldObj)) {
  47. patches.push({ op: "add", path, value: newVal });
  48. continue;
  49. }
  50. // Both exist — compare
  51. if (isPlainObject(oldVal) && isPlainObject(newVal)) {
  52. patches.push(...diffToPatches(oldVal, newVal, path));
  53. } else if (Array.isArray(oldVal) && Array.isArray(newVal)) {
  54. if (!arraysEqual(oldVal, newVal)) {
  55. patches.push({ op: "replace", path, value: newVal });
  56. }
  57. } else if (oldVal !== newVal) {
  58. patches.push({ op: "replace", path, value: newVal });
  59. }
  60. }
  61. // Keys removed from oldObj
  62. for (const key of Object.keys(oldObj)) {
  63. if (!(key in newObj)) {
  64. patches.push({ op: "remove", path: buildPath(basePath, key) });
  65. }
  66. }
  67. return patches;
  68. }