diff --git a/.gitignore b/.gitignore index 13785b0d5..3a50abf6e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ wordpress/ # would only add transitive Dependabot alerts. examples/next/block-support/package-lock.json examples/next/tutorial/package-lock.json +examples/next/document-store/package-lock.json diff --git a/docs/how-to/write-data-to-wordpress/index.md b/docs/how-to/write-data-to-wordpress/index.md index 6906318e3..af6de5b60 100644 --- a/docs/how-to/write-data-to-wordpress/index.md +++ b/docs/how-to/write-data-to-wordpress/index.md @@ -128,9 +128,115 @@ if (data.post.modified !== modifiedWhenLoaded) { `fetchPolicy: "network-only"` skips Apollo's cache so you get the current value from WordPress. -This narrows the window but doesn't close it: another save can still land between the check and your mutation. Built-in conflict detection is proposed in [#2562](https://github.com/wpengine/faustjs/issues/2562). +This narrows the window but doesn't close it: another save can still land between the check and your mutation. For app data that several writers update, the FaustWP document store below rejects stale writes instead. -## 5. Writing from the server +## 5. Store app data with conflict detection + +The FaustWP plugin includes an optional document store for app data such as settings or structured drafts. Documents are grouped into collections, like `preferences` or `drafts`. Each document has an ID that's unique within its collection, a version number, and data that's a JSON object or array. Every write names the version it was based on, and WordPress rejects the write if someone else saved first. + +The store is off by default. Enable it with a filter in a small plugin or your theme's `functions.php`: + +```php +add_filter( 'faust_enable_document_store', '__return_true' ); +``` + +Documents live in their own database table, `wp_faust_documents` (with your site's table prefix), which FaustWP creates the first time WordPress loads with the store enabled. Deleting the FaustWP plugin deletes the table and its documents. + +Administrators can read and write documents. To give other users access, add the `read_faust_documents` capability to read, and both `read_faust_documents` and `edit_faust_documents` to write. These capabilities don't grant access to anything else in WordPress. + +The routes accept any WordPress REST API authentication, such as the [Application Password](#6-writing-from-the-server) described below. + +Read a document: + +```http +GET /?rest_route=/faust/v1/collections/preferences/documents/site-settings +``` + +```json +{ + "collection": "preferences", + "id": "site-settings", + "version": 3, + "data": { "theme": "dark" }, + "created": "2026-10-01T09:30:00Z", + "modified": "2026-10-20T15:04:05Z" +} +``` + +`created` and `modified` are UTC timestamps. + +Write a document by sending the version you read. Use `0` to create a new document. A successful write returns the new version: + +```http +PUT /?rest_route=/faust/v1/collections/preferences/documents/site-settings +Content-Type: application/json + +{ "version": 3, "data": { "theme": "light" } } +``` + +If the document has changed since version 3, the write returns `409 Conflict` and doesn't change anything. The response includes the current document, so you can merge the changes or ask the user what to keep, then retry with the current version: + +```json +{ + "code": "faust_document_conflict", + "message": "The document has changed since the version this write was based on.", + "data": { + "status": 409, + "collection": "preferences", + "id": "site-settings", + "version": 4, + "document": { "collection": "preferences", "id": "site-settings", "version": 4, "data": { "theme": "blue" }, "created": "…", "modified": "…" } + } +} +``` + +To create a document without choosing an ID, for example a form submission, `POST` to the collection. WordPress generates a random ID and returns the new document with `201 Created`: + +```http +POST /?rest_route=/faust/v1/collections/waitlist/documents +Content-Type: application/json + +{ "data": { "email": "ada@example.com" } } +``` + +List a collection's documents, newest first. Pass `limit` (1 to 100, default 20), and to get the next page, pass the `next_cursor` from the previous response as `cursor`. `next_cursor` is `null` on the last page: + +```http +GET /?rest_route=/faust/v1/collections/waitlist/documents&limit=20 +``` + +```json +{ "documents": [ { "collection": "waitlist", "id": "3f9c2a7e1b0d4c6a8e5f", "version": 1, "data": { "email": "ada@example.com" }, "created": "…", "modified": "…" } ], "next_cursor": null } +``` + +Delete a document by sending the version you read. Like a write, a stale version returns `409 Conflict`, and a document that doesn't exist returns `404`: + +```http +DELETE /?rest_route=/faust/v1/collections/waitlist/documents/3f9c2a7e1b0d4c6a8e5f&version=1 +``` + +To run code when documents change, for example to send an email for each form submission, use the `faust_document_saved` and `faust_document_deleted` actions: + +```php +add_action( + 'faust_document_saved', + function ( $document, $created ) { + if ( $created && 'waitlist' === $document['collection'] ) { + wp_mail( get_option( 'admin_email' ), 'New waitlist signup', $document['data']->email ); + } + }, + 10, + 2 +); +``` + +Collection names and document IDs use lowercase letters, numbers and hyphens. A collection exists as soon as it has a document. Documents are limited to 100 KB of JSON by default, which you can change with the `faust_document_max_bytes` filter. + +To see what your app has stored, go to **Tools → Faust Documents** in WordPress. It lists each document with its collection, version and when and by whom it was last saved, and shows a document's data. The screen is read-only. + +To try it locally, see the [document store example](https://github.com/wpengine/faustjs/tree/canary/examples/next/document-store). It runs WordPress in Docker, shows a conflict between two browser tabs, and has a waitlist page that adds, lists and removes documents. + +## 6. Writing from the server The flow above runs in the browser on behalf of a logged-in user. Faust.js doesn't yet provide a supported way for server-side code (a build step, a cron job, an API route acting on its own) to authenticate to WordPress. diff --git a/examples/next/document-store/.env.local.sample b/examples/next/document-store/.env.local.sample new file mode 100644 index 000000000..34c662f70 --- /dev/null +++ b/examples/next/document-store/.env.local.sample @@ -0,0 +1,8 @@ +# Your WordPress site URL +NEXT_PUBLIC_WORDPRESS_URL=http://localhost:8090 + +# A WordPress user that can read and write documents, and an Application +# Password for it. `npm run wordpress:setup` creates both and prints the values. +# These stay on the server: never prefix them with NEXT_PUBLIC_. +WP_SERVICE_USER=document-app +WP_SERVICE_APP_PASSWORD= diff --git a/examples/next/document-store/README.md b/examples/next/document-store/README.md new file mode 100644 index 000000000..c56026979 --- /dev/null +++ b/examples/next/document-store/README.md @@ -0,0 +1,52 @@ +# Document store example + +A small Next.js app that uses the [FaustWP document store](https://faustjs.org/docs/how-to/write-data-to-wordpress/). The **Team notes** page edits a shared document and shows what happens when two people save at the same time. The **Waitlist** page adds, lists and removes documents in a collection. + +The app's API route talks to WordPress with an [Application Password](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/), so the credentials stay on the server. It doesn't use Faust.js login: every visitor edits the document as the same WordPress user. + +## Run it locally + +You need Docker and Node.js. + +1. Start WordPress with FaustWP and the document store enabled: + + ```bash + npm run wordpress:setup + ``` + + This runs WordPress on `http://localhost:8090` (log in with `admin` / `password`), creates a user that can only read and write documents, and prints the values for the next step. Set `WP_PORT` or `DB_PORT` to use other ports, or `WP_VERSION` to pick a WordPress version. + +2. Copy `.env.local.sample` to `.env.local` and paste in the printed values. + +3. Install and start the app: + + ```bash + npm install + npm run dev + ``` + +4. Open `http://localhost:3000` in two tabs and try a few conflicts. The first save in either tab always works. The second is rejected by WordPress with `409 Conflict`, because it's based on a version that's no longer current, and the page decides what to do: + - **No edits in the second tab:** it loads the newer version. + - **The tabs changed different fields** (for example, notes in one and status in the other): it merges both changes and saves a new version. + - **The tabs changed the same field:** it lists just those fields with the earlier value, theirs and yours. Choose what to keep for each and click **Save merged**. Everything else is already merged. + +5. Open `http://localhost:3000/waitlist` and add a few emails. Each one becomes a document with an ID WordPress generates, the list shows five at a time, newest first, and **Remove** deletes a signup. + +Stop WordPress with `npm run wordpress:stop`. Add `-- --volumes` to also delete its database. + +## Working on the document store + +WordPress loads FaustWP straight from `plugins/faustwp` in this repository, so changes to the plugin take effect on the next request without restarting anything. To run WP-CLI against the site, for example to inspect a document: + +```bash +docker compose --project-name faust-document-store exec wordpress wp --allow-root eval 'global $wpdb; print_r( $wpdb->get_results( "SELECT collection, document_id, version, modified_gmt FROM {$wpdb->prefix}faust_documents" ) );' +``` + +## How it works + +- `mu-plugins/enable-document-store.php` is a must-use plugin that turns on the document store with the `faust_enable_document_store` filter. +- `lib/wordpress.js` forwards requests to `/?rest_route=/faust/v1/...` with the Application Password, and passes WordPress's status code back: `404` before a document exists, `201` when one is created, `200` when it's updated or deleted and `409` for a stale write or delete. +- `pages/api/collections/[collection]/documents/[id].js` handles `GET`, `PUT` and `DELETE` for one document, and `pages/api/collections/[collection]/documents/index.js` handles `GET` (list) and `POST` (add with a generated ID) for a collection. +- `pages/waitlist.js` adds signups with `POST`, pages through the list with `next_cursor`, and removes a signup by sending the version it was listed at. +- `pages/index.js` keeps the version it last read and sends it with every save. On a `409`, the response includes the current document, and the page merges it with the local edits. +- `lib/mergeDocuments.mjs` is the three-way merge: it compares the version the tab started from with theirs and yours, field by field, and reports fields both sides changed differently. Run its tests with `npm test`. diff --git a/examples/next/document-store/docker-compose.override.yml b/examples/next/document-store/docker-compose.override.yml new file mode 100644 index 000000000..1bbb10590 --- /dev/null +++ b/examples/next/document-store/docker-compose.override.yml @@ -0,0 +1,13 @@ +# Used with plugins/faustwp/docker-compose.yml by scripts/setup-wordpress.sh. +# Paths are relative to plugins/faustwp, the directory of the first compose file. +services: + wordpress: + environment: + # WordPress only allows Application Passwords over HTTPS or on local sites. + WP_ENVIRONMENT_TYPE: local + volumes: + - ../../examples/next/document-store/mu-plugins/enable-document-store.php:/var/www/html/wp-content/mu-plugins/enable-document-store.php + db: + volumes: + # Keep the database when `npm run wordpress:stop` removes the container. + - db:/var/lib/mysql diff --git a/examples/next/document-store/lib/mergeDocuments.mjs b/examples/next/document-store/lib/mergeDocuments.mjs new file mode 100644 index 000000000..c3a0631de --- /dev/null +++ b/examples/next/document-store/lib/mergeDocuments.mjs @@ -0,0 +1,123 @@ +// Three-way merge for plain JSON objects. +// +// `base` is the version both sides started from, `theirs` is what's stored now, +// and `yours` is the local edit. Fields only one side changed are merged +// automatically. Fields both sides changed to different values are returned as +// conflicts, with the merged result keeping theirs until someone decides. +// +// Nested plain objects are merged field by field. Arrays and other values are +// compared as a whole. + +function isPlainObject(value) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +export function deepEqual(a, b) { + if (a === b) { + return true; + } + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, i) => deepEqual(item, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const keys = Object.keys(a); + return ( + keys.length === Object.keys(b).length && + keys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key])) + ); + } + return false; +} + +// A missing key and a key set to undefined both mean "not there". +const MISSING = undefined; + +function mergeValue(base, theirs, yours, path, conflicts) { + const theyChanged = !deepEqual(base, theirs); + const youChanged = !deepEqual(base, yours); + + if (!youChanged) { + return theirs; + } + if (!theyChanged || deepEqual(theirs, yours)) { + return yours; + } + if (isPlainObject(base) && isPlainObject(theirs) && isPlainObject(yours)) { + return mergeObjects(base, theirs, yours, path, conflicts); + } + + conflicts.push({ path, base, theirs, yours }); + return theirs; +} + +function mergeObjects(base, theirs, yours, path, conflicts) { + const merged = {}; + const keys = new Set([ + ...Object.keys(base), + ...Object.keys(theirs), + ...Object.keys(yours), + ]); + + for (const key of keys) { + const value = mergeValue( + Object.hasOwn(base, key) ? base[key] : MISSING, + Object.hasOwn(theirs, key) ? theirs[key] : MISSING, + Object.hasOwn(yours, key) ? yours[key] : MISSING, + [...path, key], + conflicts, + ); + if (value !== MISSING) { + // defineProperty rather than assignment, so a "__proto__" key from + // the JSON becomes a field instead of changing the prototype. + Object.defineProperty(merged, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); + } + } + + return merged; +} + +/** + * @param {object} base The document both sides started from. + * @param {object} theirs The document as it's stored now. + * @param {object} yours The local edit. + * @returns {{ merged: object, conflicts: Array<{ path: string[], base: any, theirs: any, yours: any }> }} + */ +export function mergeDocuments(base, theirs, yours) { + const conflicts = []; + const merged = mergeObjects(base ?? {}, theirs ?? {}, yours ?? {}, [], conflicts); + + return { merged, conflicts }; +} + +/** + * Return a copy of `object` with the value at `path` replaced. + * + * @param {object} object + * @param {string[]} path + * @param {any} value Use undefined to remove the key. + * @returns {object} + */ +export function setAtPath(object, path, value) { + const [key, ...rest] = path; + // A computed key defines an own property, even for "__proto__", so the + // assignments below write a field instead of changing the prototype. + const copy = { ...object, [key]: undefined }; + if (rest.length) { + copy[key] = setAtPath(isPlainObject(object[key]) ? object[key] : {}, rest, value); + } else if (value === MISSING) { + delete copy[key]; + } else { + copy[key] = value; + } + return copy; +} diff --git a/examples/next/document-store/lib/mergeDocuments.test.mjs b/examples/next/document-store/lib/mergeDocuments.test.mjs new file mode 100644 index 000000000..f51a80620 --- /dev/null +++ b/examples/next/document-store/lib/mergeDocuments.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { deepEqual, mergeDocuments, setAtPath } from './mergeDocuments.mjs'; + +const base = { title: 'Launch', notes: 'Draft', status: 'draft' }; + +test('takes their change when you left the field alone', () => { + const { merged, conflicts } = mergeDocuments(base, { ...base, title: 'Launch v2' }, base); + + assert.deepEqual(merged, { ...base, title: 'Launch v2' }); + assert.deepEqual(conflicts, []); +}); + +test('keeps your change when they left the field alone', () => { + const { merged, conflicts } = mergeDocuments(base, base, { ...base, notes: 'Mine' }); + + assert.deepEqual(merged, { ...base, notes: 'Mine' }); + assert.deepEqual(conflicts, []); +}); + +test('merges changes to different fields', () => { + const { merged, conflicts } = mergeDocuments( + base, + { ...base, title: 'Theirs' }, + { ...base, status: 'done' }, + ); + + assert.deepEqual(merged, { title: 'Theirs', notes: 'Draft', status: 'done' }); + assert.deepEqual(conflicts, []); +}); + +test('accepts the same change on both sides', () => { + const { merged, conflicts } = mergeDocuments( + base, + { ...base, status: 'done' }, + { ...base, status: 'done' }, + ); + + assert.equal(merged.status, 'done'); + assert.deepEqual(conflicts, []); +}); + +test('reports different changes to the same field and keeps theirs', () => { + const { merged, conflicts } = mergeDocuments( + base, + { ...base, notes: 'Theirs' }, + { ...base, notes: 'Yours', title: 'Also yours' }, + ); + + assert.deepEqual(merged, { title: 'Also yours', notes: 'Theirs', status: 'draft' }); + assert.deepEqual(conflicts, [ + { path: ['notes'], base: 'Draft', theirs: 'Theirs', yours: 'Yours' }, + ]); +}); + +test('merges nested objects field by field', () => { + const nestedBase = { meta: { color: 'red', size: 1 } }; + const { merged, conflicts } = mergeDocuments( + nestedBase, + { meta: { color: 'blue', size: 1 } }, + { meta: { color: 'red', size: 2 } }, + ); + + assert.deepEqual(merged, { meta: { color: 'blue', size: 2 } }); + assert.deepEqual(conflicts, []); +}); + +test('reports nested conflicts with their full path', () => { + const { conflicts } = mergeDocuments( + { meta: { color: 'red' } }, + { meta: { color: 'blue' } }, + { meta: { color: 'green' } }, + ); + + assert.deepEqual(conflicts, [ + { path: ['meta', 'color'], base: 'red', theirs: 'blue', yours: 'green' }, + ]); +}); + +test('treats arrays as single values', () => { + const { merged, conflicts } = mergeDocuments( + { tags: ['a'] }, + { tags: ['a', 'b'] }, + { tags: ['a', 'c'] }, + ); + + assert.deepEqual(merged, { tags: ['a', 'b'] }); + assert.equal(conflicts.length, 1); + assert.deepEqual(conflicts[0].path, ['tags']); +}); + +test('handles added and removed keys', () => { + const { merged, conflicts } = mergeDocuments( + { keep: 1, drop: 2 }, + { keep: 1, drop: 2, added: 'theirs' }, + { keep: 1 }, + ); + + assert.deepEqual(merged, { keep: 1, added: 'theirs' }); + assert.deepEqual(conflicts, []); +}); + +test('reports removing a field they changed as a conflict', () => { + const { merged, conflicts } = mergeDocuments({ a: 1 }, { a: 2 }, {}); + + assert.deepEqual(merged, { a: 2 }); + assert.deepEqual(conflicts, [{ path: ['a'], base: 1, theirs: 2, yours: undefined }]); +}); + +test('deepEqual compares objects and arrays by value', () => { + assert.ok(deepEqual({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] })); + assert.ok(!deepEqual({ a: 1 }, { a: 1, b: undefined })); + assert.ok(!deepEqual([1, 2], [2, 1])); +}); + +test('setAtPath replaces and removes nested values without mutating', () => { + const original = { meta: { color: 'red' }, title: 'x' }; + + assert.deepEqual(setAtPath(original, ['meta', 'color'], 'blue'), { + meta: { color: 'blue' }, + title: 'x', + }); + assert.deepEqual(setAtPath(original, ['title'], undefined), { meta: { color: 'red' } }); + assert.deepEqual(original, { meta: { color: 'red' }, title: 'x' }); +}); + +test('keeps a "__proto__" field from the JSON as a field', () => { + const doc = JSON.parse('{"__proto__": {"x": 1}, "title": "a"}'); + const { merged } = mergeDocuments(doc, doc, { ...doc, title: 'b' }); + + assert.equal(Object.getPrototypeOf(merged), Object.prototype); + assert.equal(JSON.stringify(merged), '{"__proto__":{"x":1},"title":"b"}'); +}); + +test('setAtPath writes a "__proto__" field instead of changing the prototype', () => { + const result = setAtPath({ title: 'a' }, ['__proto__'], { x: 1 }); + + assert.equal(Object.getPrototypeOf(result), Object.prototype); + assert.equal(JSON.stringify(result), '{"title":"a","__proto__":{"x":1}}'); +}); diff --git a/examples/next/document-store/lib/wordpress.js b/examples/next/document-store/lib/wordpress.js new file mode 100644 index 000000000..263be9a59 --- /dev/null +++ b/examples/next/document-store/lib/wordpress.js @@ -0,0 +1,65 @@ +// Forwards requests to the FaustWP document store, authenticating with an +// Application Password so the credentials never reach the browser. + +export const COLLECTION_PATTERN = /^[a-z0-9-]{1,64}$/; +export const ID_PATTERN = /^[a-z0-9-]{1,190}$/; +const NUMBER_PATTERN = /^[0-9]+$/; + +// Builds a query string from the numeric parameters WordPress accepts, such +// as limit, cursor and version, and drops anything else. +export function numericQuery(query, names) { + return names + .filter((name) => NUMBER_PATTERN.test(query[name] ?? '')) + .map((name) => `&${name}=${query[name]}`) + .join(''); +} + +export function rejectMethod(req, res, allowed) { + if (allowed.includes(req.method)) { + return false; + } + res.setHeader('Allow', allowed.join(', ')); + res.status(405).json({ message: 'Method not allowed.' }); + return true; +} + +export async function forwardToWordPress(req, res, path, query = '') { + const { NEXT_PUBLIC_WORDPRESS_URL, WP_SERVICE_USER, WP_SERVICE_APP_PASSWORD } = + process.env; + if (!NEXT_PUBLIC_WORDPRESS_URL || !WP_SERVICE_USER || !WP_SERVICE_APP_PASSWORD) { + return res.status(500).json({ + message: + 'Set NEXT_PUBLIC_WORDPRESS_URL, WP_SERVICE_USER and WP_SERVICE_APP_PASSWORD in .env.local.', + }); + } + + const credentials = Buffer.from( + `${WP_SERVICE_USER}:${WP_SERVICE_APP_PASSWORD}`, + ).toString('base64'); + const hasBody = req.method === 'PUT' || req.method === 'POST'; + + let response; + try { + response = await fetch( + `${NEXT_PUBLIC_WORDPRESS_URL}/?rest_route=/faust/v1/${path}${query}`, + { + method: req.method, + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json', + }, + body: hasBody ? JSON.stringify(req.body) : undefined, + }, + ); + } catch (error) { + return res + .status(502) + .json({ message: `Could not reach WordPress: ${error.message}` }); + } + + // Pass WordPress's status and body through, including 404 for a document + // that doesn't exist yet and 409 for a stale write. + res.status(response.status); + res.setHeader('Content-Type', 'application/json'); + return res.send(await response.text()); +} diff --git a/examples/next/document-store/mu-plugins/enable-document-store.php b/examples/next/document-store/mu-plugins/enable-document-store.php new file mode 100644 index 000000000..c1568e9a4 --- /dev/null +++ b/examples/next/document-store/mu-plugins/enable-document-store.php @@ -0,0 +1,7 @@ +=20.9", + "npm": ">=8" + } +} diff --git a/examples/next/document-store/pages/_app.js b/examples/next/document-store/pages/_app.js new file mode 100644 index 000000000..efb4e5993 --- /dev/null +++ b/examples/next/document-store/pages/_app.js @@ -0,0 +1,5 @@ +import '../styles/globals.css'; + +export default function App({ Component, pageProps }) { + return ; +} diff --git a/examples/next/document-store/pages/api/collections/[collection]/documents/[id].js b/examples/next/document-store/pages/api/collections/[collection]/documents/[id].js new file mode 100644 index 000000000..08eb8cf98 --- /dev/null +++ b/examples/next/document-store/pages/api/collections/[collection]/documents/[id].js @@ -0,0 +1,30 @@ +// Reads, writes and deletes one document. + +import { + COLLECTION_PATTERN, + ID_PATTERN, + forwardToWordPress, + numericQuery, + rejectMethod, +} from '../../../../../lib/wordpress'; + +export default async function handler(req, res) { + if (rejectMethod(req, res, ['GET', 'PUT', 'DELETE'])) { + return; + } + + const { collection, id } = req.query; + if (!COLLECTION_PATTERN.test(collection) || !ID_PATTERN.test(id)) { + return res + .status(400) + .json({ message: 'Use lowercase letters, numbers and hyphens.' }); + } + + // A delete sends the version it's based on as a query parameter. + return forwardToWordPress( + req, + res, + `collections/${collection}/documents/${id}`, + numericQuery(req.query, ['version']), + ); +} diff --git a/examples/next/document-store/pages/api/collections/[collection]/documents/index.js b/examples/next/document-store/pages/api/collections/[collection]/documents/index.js new file mode 100644 index 000000000..3699d6cb8 --- /dev/null +++ b/examples/next/document-store/pages/api/collections/[collection]/documents/index.js @@ -0,0 +1,28 @@ +// Lists a collection's documents and adds documents with generated IDs. + +import { + COLLECTION_PATTERN, + forwardToWordPress, + numericQuery, + rejectMethod, +} from '../../../../../lib/wordpress'; + +export default async function handler(req, res) { + if (rejectMethod(req, res, ['GET', 'POST'])) { + return; + } + + const { collection } = req.query; + if (!COLLECTION_PATTERN.test(collection)) { + return res + .status(400) + .json({ message: 'Use lowercase letters, numbers and hyphens.' }); + } + + return forwardToWordPress( + req, + res, + `collections/${collection}/documents`, + numericQuery(req.query, ['limit', 'cursor']), + ); +} diff --git a/examples/next/document-store/pages/index.js b/examples/next/document-store/pages/index.js new file mode 100644 index 000000000..233fee809 --- /dev/null +++ b/examples/next/document-store/pages/index.js @@ -0,0 +1,276 @@ +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { deepEqual, mergeDocuments, setAtPath } from '../lib/mergeDocuments.mjs'; + +const COLLECTION = 'notes'; +const DOCUMENT_ID = 'team'; +const EMPTY_NOTES = { title: '', notes: '', status: 'draft' }; +const FIELDS = [ + { name: 'title', label: 'Title' }, + { name: 'notes', label: 'Notes' }, + { name: 'status', label: 'Status' }, +]; +// How many times a save merges with a newer version and retries before +// asking the user, in case other tabs keep saving in between. +const MAX_AUTO_MERGES = 2; + +async function request(method, body) { + const response = await fetch(`/api/collections/${COLLECTION}/documents/${DOCUMENT_ID}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + try { + return { status: response.status, json: JSON.parse(text) }; + } catch { + // WordPress can answer with an HTML error page, for example on a fatal error. + return { status: response.status, json: { message: text.slice(0, 200) } }; + } +} + +function withDefaults(data) { + return { ...EMPTY_NOTES, ...data }; +} + +// The form label for a conflicting field, or its path for anything else. +function fieldLabel(path) { + const field = path.length === 1 && FIELDS.find(({ name }) => name === path[0]); + return field ? field.label : path.join('.'); +} + +function formatValue(value) { + return value === undefined ? '(removed)' : JSON.stringify(value); +} + +export default function Home() { + // The version and data last read from or saved to WordPress. Edits in the + // form are based on this, so it's the base for merging. + const [saved, setSaved] = useState(null); + // What's in the form now. + const [draft, setDraft] = useState(EMPTY_NOTES); + // Fields both you and someone else changed, while waiting for a decision. + const [conflict, setConflict] = useState(null); + const [message, setMessage] = useState('Loading...'); + const [saving, setSaving] = useState(false); + + function showDocument(document) { + setSaved({ ...document, data: withDefaults(document.data) }); + setDraft(withDefaults(document.data)); + setConflict(null); + } + + async function load() { + const { status, json } = await request('GET'); + if (status === 200) { + showDocument(json); + setMessage(`Loaded version ${json.version}.`); + } else if (status === 404) { + showDocument({ + collection: COLLECTION, + id: DOCUMENT_ID, + version: 0, + data: EMPTY_NOTES, + }); + setMessage('No document yet. Saving creates it.'); + } else { + setMessage(`Could not load (${status}): ${json.message}`); + } + } + + // Save `data` on top of `version`. On a conflict, `base` is the version the + // data was edited from, used to work out who changed what. + async function save(version, data, base, merges = 0) { + setSaving(true); + const { status, json } = await request('PUT', { version, data }); + setSaving(false); + + if (status === 200 || status === 201) { + showDocument(json); + return { ok: true, version: json.version }; + } + if (status !== 409) { + setMessage(`Could not save (${status}): ${json.message}`); + return { ok: false }; + } + + // The document is null if someone else deleted it, so take the version + // (0 in that case) from the conflict itself. + const theirs = { + collection: COLLECTION, + id: DOCUMENT_ID, + ...json.data.document, + version: json.data.version, + data: withDefaults(json.data.document?.data), + }; + + // Nothing to keep from this tab, so just catch up. + if (deepEqual(data, base)) { + showDocument(theirs); + setMessage(`Updated to version ${theirs.version} from another tab.`); + return { ok: false }; + } + + const { merged, conflicts } = mergeDocuments(base, theirs.data, data); + + if (conflicts.length === 0 && merges < MAX_AUTO_MERGES) { + const result = await save(theirs.version, merged, theirs.data, merges + 1); + if (result.ok && !result.merged) { + setMessage( + `Merged your changes with version ${theirs.version} and saved version ${result.version}.`, + ); + } + return { ...result, merged: true }; + } + + setSaved(theirs); + setDraft(merged); + setConflict({ + theirs, + merged, + fields: conflicts.map((item) => ({ ...item, choice: 'theirs' })), + }); + setMessage( + conflicts.length + ? `Version ${theirs.version} changed the same fields you did. Choose what to keep.` + : `Version ${theirs.version} keeps changing. Review the merged result and save again.`, + ); + return { ok: false }; + } + + function choose(index, choice) { + setConflict({ + ...conflict, + fields: conflict.fields.map((field, i) => (i === index ? { ...field, choice } : field)), + }); + } + + async function saveMerged() { + const data = conflict.fields.reduce( + (result, field) => + setAtPath(result, field.path, field.choice === 'yours' ? field.yours : field.theirs), + conflict.merged, + ); + const result = await save(conflict.theirs.version, data, conflict.theirs.data); + if (result.ok && !result.merged) { + setMessage(`Saved version ${result.version} with your choices.`); + } + } + + useEffect(() => { + load(); + }, []); + + function update(field) { + return (event) => setDraft({ ...draft, [field]: event.target.value }); + } + + // Fields with edits that aren't saved yet. + const changed = new Set( + saved ? FIELDS.filter(({ name }) => draft[name] !== saved.data[name]).map(({ name }) => name) : [], + ); + const labelClass = (name) => (changed.has(name) ? 'changed' : undefined); + + return ( +
+

Team notes

+

+ Waitlist example → +

+

+ Open this page in two tabs and save in both. If the tabs changed + different fields, the second save merges them. If they changed the + same field, you choose which value to keep. +

+ +

+ Version: {saved ? saved.version : '...'} +

+ +
{ + event.preventDefault(); + save(saved.version, draft, saved.data).then((result) => { + if (result.ok && !result.merged) { + setMessage(`Saved version ${result.version}.`); + } + }); + }} + > + +