Skip to content

feat(faustwp): add an opt-in versioned document store with conflict detection - #2674

Closed
josephfusco wants to merge 30 commits into
canaryfrom
feat/2562-document-store
Closed

josephfusco wants to merge 30 commits into
canaryfrom
feat/2562-document-store

Conversation

@josephfusco

@josephfusco josephfusco commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

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 409 instead 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
  end
Loading
Method Route (faust/v1/collections/{collection}/documents) Does
GET /{id} Read a document
PUT /{id} Create (version 0) or update (current version)
DELETE /{id}?version=N Delete the current version
GET ?limit=&cursor= List newest first
POST Add with a generated ID
  • Plugin: the routes above, the wp_faust_documents table (created when enabled, dropped on uninstall), faust_document_saved/faust_document_deleted actions and a read-only Tools → Faust Documents screen.
  • Docs: a "Store app data with conflict detection" section in the write-data guide.
  • Example: examples/next/document-store with a Team notes page (two tabs, conflict and merge) and a Waitlist page (add, list, remove).

It's off unless the faust_enable_document_store filter returns true, and on WordPress 7.0, 10 concurrent creates and 10 concurrent PUTs over HTTP each produced exactly one success and nine 409s. 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.

@josephfusco
josephfusco marked this pull request as ready for review October 20, 2026 13:47
@josephfusco
josephfusco requested review from a team and colinmurphy as code owners October 20, 2026 13:47
@josephfusco josephfusco added [Type] Feature A new feature within an existing module [Area] WordPress Plugin Issues for the Faust.js WordPress plugin Needs Discussion Anything that needs a discussion/agreement labels Oct 20, 2026
@josephfusco
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.
@josephfusco
josephfusco marked this pull request as ready for review October 20, 2026 16:12
@josephfusco
josephfusco requested a balanced review from Copilot October 20, 2026 16:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Reused versions allow stale clients to overwrite or delete recreated documents, undermining the core conflict-detection guarantee.

8 open issues
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

Comment thread examples/next/document-store/docker-compose.override.yml
Comment thread examples/next/document-store/lib/mergeDocuments.mjs Outdated
return { ok: false };
}

const theirs = { ...json.data.document, data: withDefaults(json.data.document?.data) };
Comment thread examples/next/document-store/pages/index.js Outdated
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.');
}
Comment thread plugins/faustwp/includes/documents/callbacks.php Outdated
Comment thread plugins/faustwp/includes/documents/functions.php Outdated
'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
josephfusco marked this pull request as draft October 20, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Area] WordPress Plugin Issues for the Faust.js WordPress plugin Needs Discussion Anything that needs a discussion/agreement [Type] Feature A new feature within an existing module

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants