sdxc

[ API design ]

@sdxc/merge-patch

Apply, diff and read RFC 7396 JSON Merge Patch documents

npm add @sdxc/merge-patch
pnpm add @sdxc/merge-patch
yarn add @sdxc/merge-patch
bun add @sdxc/merge-patch
Depends on
@standard-schema/spec
Used by
uptime, auth-saas

Apply, diff and read RFC 7396 JSON Merge Patch documents.

A JSON merge patch is a partial update in the shape of the resource: it lists only the members that change, a nested object merges into the member it lands on, null removes a member, and arrays and scalars replace what was there. The root entry point works on plain JSON values, so clients and servers share it; @sdxc/merge-patch/request reads a patch off a standard Request.

Installation

npm add @sdxc/merge-patch

Fallible calls return @sdxc/result values, and the ./request entry point answers refusals with @sdxc/problem responses.

Usage

Apply a patch

import { apply } from "@sdxc/merge-patch";

apply(
	{ title: "Goodbye!", tags: ["a", "b"], draft: true },
	{ title: "Hello!", tags: ["a"], draft: null },
);
// { title: "Hello!", tags: ["a"] }

Compute a patch

import { diff } from "@sdxc/merge-patch";
import { unwrap } from "@sdxc/result";

unwrap(diff({ name: "Home", interval: 60, note: "x" }, { name: "Home", interval: 30 }));
// { interval: 30, note: null }

diff fails when the target holds null as an object member, because a merge patch reads null as removal.

Parse and write patch text

import { parse, stringify } from "@sdxc/merge-patch";
import { isFailure } from "@sdxc/result";

let patch = parse('{"title":"Hello!"}');
if (isFailure(patch)) console.error(patch.error.message); // not JSON

stringify({ title: null }); // '{"title":null}'

Read a PATCH request

import { mergePatchProblem, readMergePatch } from "@sdxc/merge-patch/request";
import { isFailure } from "@sdxc/result";

let patch = await readMergePatch(request);
if (isFailure(patch)) return mergePatchProblem(patch.error); // 415 or 400

API

@sdxc/merge-patch

apply(target: JSONValue, patch: JSONValue): JSONValue

Applies patch to target per RFC 7396 and returns a new value that shares no object or array with either input. A non-object patch replaces the target, and an object patch applied to a non-object merges into {}.

diff(source: JSONValue, target: JSONValue): Result<JSONValue, UnrepresentableChangeError>

The smallest patch for which apply(source, patch) equals target: removed members become null, changed arrays are sent whole, {} means two objects are equal, and a non-object target is its own patch.

applyValidated(target, patch, schema): Result<Output, MergePatchValidationError>

Applies the patch and validates the result with a synchronous Standard Schema, such as one from remix/data-schema, so the create schema also judges updates. Issue paths point into the patched result.

parse(text: string): Result<JSONValue, MergePatchParseError>

Reads a merge patch document; any JSON value is a valid patch, so it fails only on text that is not JSON.

stringify(patch: JSONValue): string

Writes a patch as the JSON text a request body carries.

MEDIA_TYPE

"application/merge-patch+json".

UnrepresentableChangeError

The diff failure, with pointer: an RFC 6901 JSON Pointer to the member that holds null in the target.

MergePatchValidationError

The applyValidated failure, with the schema's issues; issuesFrom from @sdxc/problem reads it directly.

MergePatchParseError

The parse failure, with the JSON parser's message.

@sdxc/merge-patch/request

readMergePatch(request: Request, options?: ReadMergePatchOptions): Promise<Result<JSONValue, MergePatchRequestError>>

Checks the Content-Type first, leaving a refused request's body unread, then reads and parses the body. options.alsoAccept lists further media types read the same way.

isMergePatch(request: Request): boolean

Whether the Content-Type is application/merge-patch+json, case-insensitively and ignoring parameters.

mergePatchProblem(error: MergePatchRequestError): Response

The application/problem+json response for a refusal, with the error's status and message, and Accept-Patch on a 415.

MergePatchRequestError

Carries reason ("unsupported-media-type" or "invalid-json") and the status to answer with (415 or 400).

ACCEPT_PATCH_HEADER

["Accept-Patch", "application/merge-patch+json"], a header tuple for Headers or a ResponseInit.

Types

MergePatchOf<T>

A valid patch for a resource T: every member optional, null only where the member itself is optional, objects recursively patchable, arrays and scalars replaced whole.

JSONValue / JSONObject

The JSON values every function reads and writes.

ReadMergePatchOptions

{ alsoAccept?: readonly string[] }, the options of readMergePatch.

Pattern: Validate A PATCH With The Create Schema

The handler applies the patch to the stored resource, validates the result with the schema the create endpoint uses, and writes only the members that changed.

import { applyValidated, diff } from "@sdxc/merge-patch";
import { mergePatchProblem, readMergePatch } from "@sdxc/merge-patch/request";
import { issuesFrom, validationProblem } from "@sdxc/problem";
import { isFailure, unwrap } from "@sdxc/result";
import * as s from "remix/data-schema";
import { minLength } from "remix/data-schema/checks";

let articleSchema = s.object({
	title: s.string().pipe(minLength(1)),
	summary: s.optional(s.string()),
	tags: s.array(s.string()),
});

async function updateArticle(request: Request, id: string) {
	let patch = await readMergePatch(request);
	if (isFailure(patch)) return mergePatchProblem(patch.error);

	let current = await articles.find(id);
	let next = applyValidated(current, patch.data, articleSchema);
	if (isFailure(next)) return validationProblem(issuesFrom(next.error)); // 422, pointers into the article

	await articles.update(id, unwrap(diff(current, next.data)));
	return Response.json(next.data);
}

A patch removing title fails on /title, and a patch setting it to "" fails there too.

Pattern: Send Only What Changed

A client keeps the snapshot it loaded, lets the user edit a copy, and sends the difference.

import { diff, MEDIA_TYPE, stringify } from "@sdxc/merge-patch";
import { isFailure } from "@sdxc/result";

let patch = diff(original, edited);
if (isFailure(patch)) throw new Error(`Cannot set ${patch.error.pointer} to null in a merge patch`);

await fetch(`/articles/${id}`, {
	method: "PATCH",
	headers: { "Content-Type": MEDIA_TYPE },
	body: stringify(patch.data),
});

Pattern: Type The Patches A Client Builds

import type { MergePatchOf } from "@sdxc/merge-patch";

import { MEDIA_TYPE, stringify } from "@sdxc/merge-patch";

interface Article {
	title: string;
	summary?: string;
	tags: string[];
}

function patchArticle(id: string, patch: MergePatchOf<Article>) {
	return fetch(`/articles/${id}`, {
		method: "PATCH",
		headers: { "Content-Type": MEDIA_TYPE },
		body: stringify(patch),
	});
}

await patchArticle("a1", { summary: null, tags: ["http"] }); // clears summary, replaces tags
await patchArticle("a1", { title: null }); // type error: title is required

Pattern: Adopt Merge Patch On An Existing Endpoint

alsoAccept keeps callers that send application/json working, isMergePatch tells the two apart, and ACCEPT_PATCH_HEADER advertises the format on OPTIONS.

import {
	ACCEPT_PATCH_HEADER,
	isMergePatch,
	mergePatchProblem,
	readMergePatch,
} from "@sdxc/merge-patch/request";
import { isFailure } from "@sdxc/result";

async function handle(request: Request) {
	if (request.method === "OPTIONS") {
		return new Response(null, {
			status: 204,
			headers: [ACCEPT_PATCH_HEADER, ["Allow", "GET, PATCH"]],
		});
	}

	let patch = await readMergePatch(request, { alsoAccept: ["application/json"] });
	if (isFailure(patch)) return mergePatchProblem(patch.error);

	let response = await applyPatch(patch.data);
	if (!isMergePatch(request)) response.headers.set("Deprecation", "true");
	return response;
}

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.