Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b9d8bfd
feat(faustwp): add an opt-in versioned document store with conflict d…
josephfusco Oct 20, 2026
ffe318f
fix(faustwp): return empty objects in documents as objects
josephfusco Oct 20, 2026
835d32c
test(faustwp): cover concurrent creates in the document store
josephfusco Oct 20, 2026
0fbc7cc
docs: show the full conflict response for document writes
josephfusco Oct 20, 2026
138c61e
ci(faustwp): give PHPStan 2 GB of memory
josephfusco Oct 20, 2026
630bca7
docs(examples): add a document store example that runs WordPress locally
josephfusco Oct 20, 2026
ad9248a
docs(examples): show WordPress errors that aren't JSON in the documen…
josephfusco Oct 20, 2026
13dd94e
docs(examples): explain how to work on the plugin with the document s…
josephfusco Oct 20, 2026
8528b1a
fix(examples): hide the expected errors while the document store setu…
josephfusco Oct 20, 2026
3d7ec21
feat(faustwp): record when and by whom each document was last saved
josephfusco Oct 20, 2026
53a0a1c
feat(faustwp): list stored documents under Tools → Faust Documents
josephfusco Oct 20, 2026
3ab72b9
feat(examples): add a three-way merge helper to the document store ex…
josephfusco Oct 20, 2026
39b9a89
feat(examples): merge concurrent edits in the document store example
josephfusco Oct 20, 2026
57587c2
docs(examples): describe merging and field-level conflicts in the doc…
josephfusco Oct 20, 2026
64ac670
fix(examples): label conflicting fields with their form labels
josephfusco Oct 20, 2026
e284813
fix(examples): point the document store example's WordPress at its ow…
josephfusco Oct 20, 2026
6bcbcb2
fix(faustwp): show document JSON without per-line code backgrounds
josephfusco Oct 20, 2026
84d5414
docs(faustwp): mention the Tools screen in the document post type doc…
josephfusco Oct 20, 2026
fe4f36a
perf(faustwp): list documents without a slug lookup per row
josephfusco Oct 20, 2026
46516e3
refactor(faustwp): store documents in a dedicated table instead of posts
josephfusco Oct 20, 2026
3456726
fix(faustwp): retry a failed documents table install and drop the tab…
josephfusco Oct 20, 2026
087677c
docs(faustwp): clarify the documents schema version and shared owner …
josephfusco Oct 20, 2026
aea1486
refactor(faustwp): organize documents into collections with IDs
josephfusco Oct 20, 2026
5c28245
feat(faustwp): list, add and delete documents, with timestamps and sa…
josephfusco Oct 20, 2026
178c0fc
docs: describe listing, adding and deleting documents and the documen…
josephfusco Oct 20, 2026
0510d71
feat(examples): add a waitlist page that adds, lists and removes docu…
josephfusco Oct 20, 2026
3a9d0ef
fix(faustwp): never report a 1970 creation date after a racing delete
josephfusco Oct 20, 2026
bea7d06
refactor: use the faust prefix for the document store's hooks, routes…
josephfusco Oct 20, 2026
85707b1
fix(faustwp): never reuse document versions, and only take identifier…
josephfusco Oct 20, 2026
ee5d2e7
fix(examples): keep the document store example's data and edits safe
josephfusco Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
110 changes: 108 additions & 2 deletions docs/how-to/write-data-to-wordpress/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions examples/next/document-store/.env.local.sample
Original file line number Diff line number Diff line change
@@ -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=
52 changes: 52 additions & 0 deletions examples/next/document-store/README.md
Original file line number Diff line number Diff line change
@@ -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`.
13 changes: 13 additions & 0 deletions examples/next/document-store/docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
josephfusco marked this conversation as resolved.
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
123 changes: 123 additions & 0 deletions examples/next/document-store/lib/mergeDocuments.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading