Repository navigation
feat(faustwp): add an opt-in versioned document store with conflict detection - #2674
Closed
josephfusco wants to merge 30 commits into
Closed
josephfusco wants to merge 30 commits into
josephfusco wants to merge 30 commits into
Conversation
PHPStan already peaks at about 990 MB on canary, so any new code pushes it past the 1 GB limit and the lint job crashes.
josephfusco
marked this pull request as ready for review
October 20, 2026 13:47
…p waits for MySQL
…ument store README
This was referenced Oct 22, 2026
josephfusco
marked this pull request as draft
October 20, 2026 15:07
Documents move from a private post type with one meta row to wp_faustwp_documents, with a unique key on collection, owner and slug. Creates rely on the unique key instead of cleaning up duplicate posts, updates are a single conditional UPDATE on the version, and uninstall drops the table.
Routes move to faustwp/v1/collections/<collection>/documents/<id>, responses use collection and id instead of slug, and the table column is renamed to document_id. This matches the collection and document terms used by Firestore, Appwrite and PocketBase.
…ve/delete actions Adds GET and POST on a collection (cursor-paginated list, and create with a generated ID), DELETE on a document with a required version, created and modified timestamps on every document, and faustwp_document_saved and faustwp_document_deleted actions.
…, table and capabilities The REST namespace is faust/v1, the table is wp_faust_documents, and hooks, capabilities, options and error codes start with faust_. The plugin's existing faustwp_ hooks and faustwp/v1 routes are unchanged.
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Reused versions allow stale clients to overwrite or delete recreated documents, undermining the core conflict-detection guarantee.
8 open issues
Database volume is not mounted, losing data after container recreation · New Prototype pollution drops proto fields during object merging · New Conflict handling loses version when the document was deleted · New Pending saves overwrite edits made during the request · New Captured signup state resurrects deletions during concurrent loading · New Request parameters override validated route identifiers · New REST routes accept uppercase identifiers despite lowercase restrictions · New Document recreation reuses versions, allowing stale writes · New
What changed in this PR
Adds an opt-in document store to FaustWP, providing REST-based app data persistence with version checks for conflicting writes.
Changes:
- Adds document storage, REST endpoints, capability checks, lifecycle cleanup, and a read-only admin screen.
- Adds integration tests and usage documentation.
- Adds a Next.js example demonstrating shared notes, conflict merging, and waitlist management.
| File | Description |
|---|---|
| plugins/faustwp/uninstall.php | Removes document tables on uninstall. |
| plugins/faustwp/tests/integration/DocumentStoreTests.php | Tests storage, REST access, and conflicts. |
| plugins/faustwp/tests/integration/DocumentAdminTests.php | Tests admin access and rendering. |
| plugins/faustwp/includes/documents/functions.php | Implements storage and version checks. |
| plugins/faustwp/includes/documents/callbacks.php | Registers REST routes and lifecycle hooks. |
| plugins/faustwp/includes/documents/admin.php | Adds read-only document inspection. |
| plugins/faustwp/faustwp.php | Loads document-store modules. |
| plugins/faustwp/docker-compose.yml | Makes development ports configurable. |
| plugins/faustwp/composer.json | Raises PHPStan’s memory limit. |
| plugins/faustwp/.phpcs.xml.dist | Allows the new hook prefix. |
| examples/next/document-store/styles/globals.css | Styles example forms and conflicts. |
| examples/next/document-store/scripts/stop-wordpress.sh | Stops the example’s WordPress environment. |
| examples/next/document-store/scripts/setup-wordpress.sh | Sets up WordPress and service credentials. |
| examples/next/document-store/README.md | Documents setup and example workflows. |
| examples/next/document-store/pages/waitlist.js | Demonstrates adding, listing, and deleting documents. |
| examples/next/document-store/pages/index.js | Demonstrates shared editing and conflict recovery. |
| examples/next/document-store/pages/api/collections/[collection]/documents/index.js | Proxies collection requests. |
| examples/next/document-store/pages/api/collections/[collection]/documents/[id].js | Proxies individual document requests. |
| examples/next/document-store/pages/_app.js | Loads shared styles. |
| examples/next/document-store/package.json | Defines example dependencies and scripts. |
| examples/next/document-store/mu-plugins/enable-document-store.php | Enables the store locally. |
| examples/next/document-store/lib/wordpress.js | Forwards authenticated requests to WordPress. |
| examples/next/document-store/lib/mergeDocuments.test.mjs | Tests three-way merging. |
| examples/next/document-store/lib/mergeDocuments.mjs | Implements merging and conflict resolution. |
| examples/next/document-store/docker-compose.override.yml | Configures the local example environment. |
| examples/next/document-store/.env.local.sample | Documents connection and credential settings. |
| docs/how-to/write-data-to-wordpress/index.md | Documents document-store APIs and usage. |
| .gitignore | Excludes the example’s package lockfile. |
Review effort: Balanced
| return { ok: false }; | ||
| } | ||
|
|
||
| const theirs = { ...json.data.document, data: withDefaults(json.data.document?.data) }; |
Comment on lines
+30
to
+40
| async function load(cursor) { | ||
| const query = cursor ? `&cursor=${cursor}` : ''; | ||
| const { status, json } = await request('GET', `${COLLECTION_URL}?limit=${PAGE_SIZE}${query}`); | ||
| if (status !== 200) { | ||
| setMessage(`Could not load (${status}): ${json.message}`); | ||
| return; | ||
| } | ||
| setSignups(cursor ? [...signups, ...json.documents] : json.documents); | ||
| setNextCursor(json.next_cursor); | ||
| setMessage(json.documents.length || cursor ? '' : 'No signups yet.'); | ||
| } |
| 'collection' => $collection, | ||
| 'owner_id' => 0, | ||
| 'document_id' => $id, | ||
| 'version' => 1, |
…s from the route Deleting a document now keeps its row as a tombstone with a bumped version, and creating it again continues from there, so a client holding a version from before the delete gets a 409 instead of overwriting the new document. Collection names and document IDs are read from the route only, since WordPress lets body and query parameters take precedence, and the route patterns are case-sensitive even though WordPress matches routes case-insensitively.
Keep the database between stop and setup, merge "__proto__" fields as fields, keep the version when a conflict comes from a deleted document, lock the form while a save is in flight, and block adding and removing while the waitlist is loading.
josephfusco
marked this pull request as draft
October 20, 2026 17:18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Related: #2562, wpengine/hwptoolkit#817
Adds an opt-in document store to FaustWP. Apps save JSON documents in collections over REST, and a write based on an old version gets a
409instead of silently overwriting.sequenceDiagram participant App as Your app participant WP as FaustWP participant DB as Table App->>WP: PUT, version 3 WP->>DB: UPDATE if version 3 alt updated WP-->>App: 200, version 4 else someone saved first WP-->>App: 409, current doc endfaust/v1/collections/{collection}/documents)GET/{id}PUT/{id}0) or update (current version)DELETE/{id}?version=NGET?limit=&cursor=POSTwp_faust_documentstable (created when enabled, dropped on uninstall),faust_document_saved/faust_document_deletedactions and a read-only Tools → Faust Documents screen.examples/next/document-storewith a Team notes page (two tabs, conflict and merge) and a Waitlist page (add, list, remove).It's off unless the
faust_enable_document_storefilter returns true, and on WordPress 7.0, 10 concurrent creates and 10 concurrent PUTs over HTTP each produced exactly one success and nine409s. The PHPStan memory bump and configurable Docker ports could move to their own PR.Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5.5
Used for: Assisting with designing and implementing the document store and its table, writing the PHPUnit tests and docs, and testing the routes and concurrent writes in a local WordPress.