sdxc

Type to search, or start from one of these:

@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/specremix
Used by
uptime, auth-saas

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

Installation

npm add @sdxc/merge-patch

Fallible calls return @sdxc/result values, and @sdxc/merge-patch/request answers refusals with @sdxc/problem documents. Both install alongside this package.

A JSON merge patch is a partial update written in the shape of the resource: the body 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. Its media type is application/merge-patch+json. The root entry point is pure JSON with no HTTP in it, so a client builds patches with it alone; @sdxc/merge-patch/request reads a patch off a standard Request. null always means removal, so a merge patch never sets a member to a literal null; diff refuses to produce such a patch, and a client learns about the conflict before sending.

Usage

Apply And Diff

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

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

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

Read A PATCH Request

A handler that validates the patched resource with the create schema, and writes only what 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";

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

let current = await loadArticle(id); // a JSONValue
let next = applyValidated(current, patch.data, ARTICLE_SCHEMA);
if (isFailure(next)) return validationProblem(issuesFrom(next.error));

await updateArticle(id, unwrap(diff(current, next.data)));

API

@sdxc/merge-patch

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

Applies patch to target per RFC 7396 and returns a new value. Neither input is mutated, and the result shares no object or array with them. A non-object patch replaces the target; an object patch applied to a non-object merges into {}. A member named __proto__ is written as data.

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

The smallest patch that turns source into target, so apply(source, patch) equals target. Removed members become null, changed arrays are sent whole, and {} means two objects are equal. A non-object target is its own patch. Fails with UnrepresentableChangeError, whose pointer is an RFC 6901 JSON Pointer, when the target holds null as an object member at any depth.

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

Reads a merge patch document. Any JSON value is a valid patch, so this fails, with a MergePatchParseError, only on text that is not JSON.

stringify(patch: JSONValue): string

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

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

Applies the patch and validates the result with a Standard Schema (a remix/data-schema schema in practice), so one schema serves create and update: a patch removing a required member fails on that member, and a patch setting a value out of range fails at that value's path. The MergePatchValidationError's issues point into the patched result, and validationProblem(issuesFrom(error)) from @sdxc/problem answers with them. The schema must validate synchronously.

MEDIA_TYPE

"application/merge-patch+json".

MergePatchOf<T>

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

interface Subject {
	email: string;
	displayName?: string;
}
let patch: MergePatchOf<Subject> = { displayName: null }; // ok
let bad: MergePatchOf<Subject> = { email: null }; // type error: email is required

JSONValue / JSONObject

The JSON types every function reads and writes.

@sdxc/merge-patch/request

isMergePatch(request: Request): boolean

Whether the Content-Type essence is application/merge-patch+json, case-insensitively, with parameters ignored.

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

Checks the media type, then reads and parses the body. A request with another media type is refused with its body left unread. alsoAccept (ReadMergePatchOptions) lists further media types read the same way; ["application/json"] keeps an endpoint's existing callers while it adopts merge patch.

MergePatchRequestError

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

mergePatchProblem(error: MergePatchRequestError): Response

The application/problem+json response for a refusal: the error's status, its message as detail, and Accept-Patch: application/merge-patch+json on a 415.

ACCEPT_PATCH_HEADER

["Accept-Patch", "application/merge-patch+json"], a header tuple for an OPTIONS answer: headers.set(...ACCEPT_PATCH_HEADER).

Pattern: Compute A Patch From Two Snapshots

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 clear ${patch.error.pointer} with a merge patch`);

await fetch(url, {
	method: "PATCH",
	headers: { "Content-Type": MEDIA_TYPE },
	body: stringify(patch.data),
});

Pattern: Advertise The Format On OPTIONS

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

return new Response(null, { status: 204, headers: [ACCEPT_PATCH_HEADER, ["Allow", "GET, PATCH"]] });

Pattern: Type The Patches A Client Sends

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