From b9d8bfdb027b85fa9caa6fd3ea4428c181617109 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 20 Oct 2026 09:36:40 -0400 Subject: [PATCH 01/30] feat(faustwp): add an opt-in versioned document store with conflict detection --- docs/how-to/write-data-to-wordpress/index.md | 52 ++- plugins/faustwp/faustwp.php | 2 + .../faustwp/includes/documents/callbacks.php | 171 +++++++++ .../faustwp/includes/documents/functions.php | 359 ++++++++++++++++++ .../tests/integration/DocumentStoreTests.php | 284 ++++++++++++++ 5 files changed, 866 insertions(+), 2 deletions(-) create mode 100644 plugins/faustwp/includes/documents/callbacks.php create mode 100644 plugins/faustwp/includes/documents/functions.php create mode 100644 plugins/faustwp/tests/integration/DocumentStoreTests.php diff --git a/docs/how-to/write-data-to-wordpress/index.md b/docs/how-to/write-data-to-wordpress/index.md index 6906318e3..fb5af5aea 100644 --- a/docs/how-to/write-data-to-wordpress/index.md +++ b/docs/how-to/write-data-to-wordpress/index.md @@ -128,9 +128,57 @@ 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. Each document is a JSON object or array stored under a slug, with a version number. 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( 'faustwp_enable_document_store', '__return_true' ); +``` + +Administrators can read and write documents. To give other users access, add the `read_faustwp_documents` capability to read, and both `read_faustwp_documents` and `edit_faustwp_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=/faustwp/v1/documents/site-settings +``` + +```json +{ "slug": "site-settings", "version": 3, "data": { "theme": "dark" } } +``` + +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=/faustwp/v1/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": "faustwp_document_conflict", + "data": { + "status": 409, + "version": 4, + "document": { "slug": "site-settings", "version": 4, "data": { "theme": "blue" } } + } +} +``` + +Slugs use lowercase letters, numbers and hyphens. Documents are limited to 100 KB of JSON by default, which you can change with the `faustwp_document_max_bytes` filter. + +## 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/plugins/faustwp/faustwp.php b/plugins/faustwp/faustwp.php index 5277ec98d..6cfb48e34 100644 --- a/plugins/faustwp/faustwp.php +++ b/plugins/faustwp/faustwp.php @@ -78,6 +78,7 @@ function () { require FAUSTWP_DIR . '/includes/settings/functions.php'; require FAUSTWP_DIR . '/includes/graphql/functions.php'; require FAUSTWP_DIR . '/includes/utilities/functions.php'; +require FAUSTWP_DIR . '/includes/documents/functions.php'; require FAUSTWP_DIR . '/includes/auth/callbacks.php'; require FAUSTWP_DIR . '/includes/deny-public-access/functions.php'; require FAUSTWP_DIR . '/includes/detect-conflicts/functions.php'; @@ -95,3 +96,4 @@ function () { require FAUSTWP_DIR . '/includes/detect-conflicts/callbacks.php'; require FAUSTWP_DIR . '/includes/blocks/callbacks.php'; require FAUSTWP_DIR . '/includes/telemetry/callbacks.php'; +require FAUSTWP_DIR . '/includes/documents/callbacks.php'; diff --git a/plugins/faustwp/includes/documents/callbacks.php b/plugins/faustwp/includes/documents/callbacks.php new file mode 100644 index 000000000..115c92a3e --- /dev/null +++ b/plugins/faustwp/includes/documents/callbacks.php @@ -0,0 +1,171 @@ +[a-z0-9-]{1,190})', + array( + array( + 'methods' => 'GET', + 'callback' => __NAMESPACE__ . '\\handle_get_document', + 'permission_callback' => __NAMESPACE__ . '\\can_read_documents', + ), + array( + 'methods' => 'PUT', + 'callback' => __NAMESPACE__ . '\\handle_put_document', + 'permission_callback' => __NAMESPACE__ . '\\can_edit_documents', + 'args' => array( + 'version' => array( + 'required' => true, + 'type' => 'integer', + 'minimum' => 0, + 'validate_callback' => 'rest_validate_request_arg', + ), + 'data' => array( + 'required' => true, + 'validate_callback' => __NAMESPACE__ . '\\validate_document_data', + ), + ), + ), + ) + ); +} + +/** + * Permission callback for reading documents. + * + * @return bool True if the current user can read documents. + */ +function can_read_documents() { + return current_user_can( READ_CAPABILITY ); +} + +/** + * Permission callback for writing documents. + * + * Writers also need read access, because a rejected write returns the current + * document. + * + * @return bool True if the current user can read and edit documents. + */ +function can_edit_documents() { + return current_user_can( READ_CAPABILITY ) && current_user_can( EDIT_CAPABILITY ); +} + +/** + * Validate callback for the document data argument. + * + * @param mixed $value Document data from the request body. + * + * @return bool True if the data is a JSON object or array. + */ +function validate_document_data( $value ) { + return is_array( $value ); +} + +/** + * Callback for GET faustwp/v1/documents/. + * + * @param WP_REST_Request $request Current request. + * + * @return WP_REST_Response|\WP_Error The document, or a 404 error. + */ +function handle_get_document( WP_REST_Request $request ) { + $slug = $request->get_param( 'slug' ); + $document = get_document( $slug ); + + if ( ! $document ) { + return new \WP_Error( 'faustwp_document_not_found', __( 'Document not found.', 'faustwp' ), array( 'status' => 404 ) ); + } + + return new WP_REST_Response( $document, 200 ); +} + +/** + * Callback for PUT faustwp/v1/documents/. + * + * The body is JSON with the version the write is based on (0 to create) and + * the new data. A write based on an old version returns a 409 error with the + * current document. + * + * @param WP_REST_Request $request Current request. + * + * @return WP_REST_Response|\WP_Error The saved document, or an error. + */ +function handle_put_document( WP_REST_Request $request ) { + $result = save_document( + $request->get_param( 'slug' ), + (int) $request->get_param( 'version' ), + $request->get_param( 'data' ) + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $status = $result['created'] ? 201 : 200; + unset( $result['created'] ); + + return new WP_REST_Response( $result, $status ); +} diff --git a/plugins/faustwp/includes/documents/functions.php b/plugins/faustwp/includes/documents/functions.php new file mode 100644 index 000000000..b786314a6 --- /dev/null +++ b/plugins/faustwp/includes/documents/functions.php @@ -0,0 +1,359 @@ + __( 'Faust Documents', 'faustwp' ), + 'public' => false, + 'publicly_queryable' => false, + 'exclude_from_search' => true, + 'show_ui' => false, + 'show_in_menu' => false, + 'show_in_nav_menus' => false, + 'show_in_rest' => false, + 'show_in_graphql' => false, + 'rewrite' => false, + 'query_var' => false, + 'can_export' => false, + 'supports' => array( 'title' ), + 'map_meta_cap' => true, + 'capabilities' => array( + 'edit_post' => 'edit_faustwp_document', + 'read_post' => 'read_faustwp_document', + 'delete_post' => 'delete_faustwp_document', + 'read' => READ_CAPABILITY, + 'read_private_posts' => READ_CAPABILITY, + 'create_posts' => EDIT_CAPABILITY, + 'edit_posts' => EDIT_CAPABILITY, + 'edit_others_posts' => EDIT_CAPABILITY, + 'edit_private_posts' => EDIT_CAPABILITY, + 'edit_published_posts' => EDIT_CAPABILITY, + 'publish_posts' => EDIT_CAPABILITY, + 'delete_posts' => EDIT_CAPABILITY, + 'delete_others_posts' => EDIT_CAPABILITY, + 'delete_private_posts' => EDIT_CAPABILITY, + 'delete_published_posts' => EDIT_CAPABILITY, + ), + ) + ); +} + +/** + * Find the post that stores a document. + * + * If a race created more than one post for the same slug, the oldest one wins. + * + * @param string $slug Document slug. + * + * @return int Post ID, or 0 if the document doesn't exist. + */ +function get_document_post_id( $slug ) { + $posts = get_posts( + array( + 'post_type' => POST_TYPE, + 'post_status' => 'private', + 'name' => $slug, + 'orderby' => 'ID', + 'order' => 'ASC', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'suppress_filters' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + + return $posts ? (int) $posts[0] : 0; +} + +/** + * Read the stored version and data for a document post. + * + * Reads the meta row straight from the database rather than the object cache, + * so the value compared during a write is the value that's actually stored. + * + * @param int $post_id Post ID. + * + * @return array|null Array with meta_id, raw, version and data keys, or null if missing. + */ +function get_stored_document( $post_id ) { + global $wpdb; + + $row = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "SELECT meta_id, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s ORDER BY meta_id ASC LIMIT 1", + $post_id, + META_KEY + ) + ); + + if ( ! $row ) { + return null; + } + + $stored = json_decode( $row->meta_value, true ); + if ( ! is_array( $stored ) || ! isset( $stored['version'] ) || ! array_key_exists( 'data', $stored ) ) { + return null; + } + + return array( + 'meta_id' => (int) $row->meta_id, + 'raw' => $row->meta_value, + 'version' => (int) $stored['version'], + 'data' => $stored['data'], + ); +} + +/** + * Get a document. + * + * @param string $slug Document slug. + * + * @return array|null Array with slug, version and data keys, or null if the document doesn't exist. + */ +function get_document( $slug ) { + $post_id = get_document_post_id( $slug ); + if ( ! $post_id ) { + return null; + } + + $stored = get_stored_document( $post_id ); + if ( ! $stored ) { + return null; + } + + return array( + 'slug' => $slug, + 'version' => $stored['version'], + 'data' => $stored['data'], + ); +} + +/** + * Encode a document's version and data for storage. + * + * @param int $version Version number. + * @param mixed $data Document data. + * + * @return string|false JSON string, or false if the data can't be encoded. + */ +function encode_stored_document( $version, $data ) { + return wp_json_encode( + array( + 'version' => $version, + 'data' => $data, + ) + ); +} + +/** + * Build the error returned when a write is based on an old version. + * + * @param string $slug Document slug. + * @param array|null $current The current document, or null if it doesn't exist. + * + * @return WP_Error Conflict error with a 409 status. + */ +function conflict_error( $slug, $current ) { + return new WP_Error( + 'faustwp_document_conflict', + __( 'The document has changed since the version this write was based on.', 'faustwp' ), + array( + 'status' => 409, + 'slug' => $slug, + 'version' => $current ? $current['version'] : 0, + 'document' => $current, + ) + ); +} + +/** + * Create or update a document if it's still at the expected version. + * + * Use version 0 to create a document. Updates replace the stored version and + * data with one UPDATE that only matches the exact value that was read, so if + * two writers send the same version at once, only one of them succeeds. + * + * @param string $slug Document slug. + * @param int $expected_version The version the write is based on. + * @param mixed $data New document data. + * + * @return array|WP_Error The saved document with a created flag, or an error. + */ +function save_document( $slug, $expected_version, $data ) { + $encoded_data = wp_json_encode( $data ); + if ( false === $encoded_data ) { + return new WP_Error( 'faustwp_document_invalid', __( 'The document data could not be encoded as JSON.', 'faustwp' ), array( 'status' => 400 ) ); + } + + if ( strlen( $encoded_data ) > get_max_document_bytes() ) { + return new WP_Error( 'faustwp_document_too_large', __( 'The document is larger than the maximum size.', 'faustwp' ), array( 'status' => 413 ) ); + } + + $post_id = get_document_post_id( $slug ); + + if ( ! $post_id ) { + if ( 0 !== $expected_version ) { + return conflict_error( $slug, null ); + } + + return create_document( $slug, $data ); + } + + $stored = get_stored_document( $post_id ); + if ( ! $stored ) { + return new WP_Error( 'faustwp_document_corrupt', __( 'The stored document could not be read.', 'faustwp' ), array( 'status' => 500 ) ); + } + + if ( $stored['version'] !== $expected_version ) { + return conflict_error( $slug, get_document( $slug ) ); + } + + $new_version = $stored['version'] + 1; + $new_raw = encode_stored_document( $new_version, $data ); + + // The stored value starts with its version, and every successful write + // changes the version, so a value written since the read can't match + // $stored['raw'], even under a case-insensitive collation. + global $wpdb; + $updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery + $wpdb->prepare( + "UPDATE {$wpdb->postmeta} SET meta_value = %s WHERE meta_id = %d AND meta_value = %s", + $new_raw, + $stored['meta_id'], + $stored['raw'] + ) + ); + + wp_cache_delete( $post_id, 'post_meta' ); + + if ( 1 !== $updated ) { + // Another write replaced the stored value after it was read. + return conflict_error( $slug, get_document( $slug ) ); + } + + return array( + 'slug' => $slug, + 'version' => $new_version, + 'data' => $data, + 'created' => false, + ); +} + +/** + * Create a new document at version 1. + * + * WordPress doesn't enforce unique post slugs in the database, so two + * concurrent creates can both insert a post. After inserting, the oldest post + * for the slug is kept and any later one is deleted and reported as a conflict. + * + * @param string $slug Document slug. + * @param mixed $data Document data. + * + * @return array|WP_Error The saved document with a created flag, or an error. + */ +function create_document( $slug, $data ) { + $post_id = wp_insert_post( + array( + 'post_type' => POST_TYPE, + 'post_status' => 'private', + 'post_title' => $slug, + 'post_name' => $slug, + 'meta_input' => array( + META_KEY => wp_slash( encode_stored_document( 1, $data ) ), + ), + ), + true + ); + + if ( is_wp_error( $post_id ) ) { + return $post_id; + } + + $post = get_post( $post_id ); + if ( ! $post || $post->post_name !== $slug || get_document_post_id( $slug ) !== $post_id ) { + // Another request created the document first. + wp_delete_post( $post_id, true ); + return conflict_error( $slug, get_document( $slug ) ); + } + + return array( + 'slug' => $slug, + 'version' => 1, + 'data' => $data, + 'created' => true, + ); +} diff --git a/plugins/faustwp/tests/integration/DocumentStoreTests.php b/plugins/faustwp/tests/integration/DocumentStoreTests.php new file mode 100644 index 000000000..28df06b35 --- /dev/null +++ b/plugins/faustwp/tests/integration/DocumentStoreTests.php @@ -0,0 +1,284 @@ +init_rest_server(); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + } + + public function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; + + remove_all_filters( 'faustwp_enable_document_store' ); + remove_all_filters( 'faustwp_document_max_bytes' ); + if ( post_type_exists( POST_TYPE ) ) { + unregister_post_type( POST_TYPE ); + } + + parent::tearDown(); + } + + private function init_rest_server() { + global $wp_rest_server; + $this->server = $wp_rest_server = new \WP_REST_Server(); + do_action( 'rest_api_init' ); + } + + private function put( $slug, $body ) { + $request = new \WP_REST_Request( 'PUT', self::ROUTE . '/' . $slug ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( is_string( $body ) ? $body : wp_json_encode( $body ) ); + return $this->server->dispatch( $request ); + } + + private function get( $slug ) { + return $this->server->dispatch( new \WP_REST_Request( 'GET', self::ROUTE . '/' . $slug ) ); + } + + public function test_store_is_disabled_by_default() { + remove_all_filters( 'faustwp_enable_document_store' ); + unregister_post_type( POST_TYPE ); + $this->init_rest_server(); + + maybe_register_document_post_type(); + + $this->assertFalse( post_type_exists( POST_TYPE ) ); + $this->assertArrayNotHasKey( self::ROUTE . '/(?P[a-z0-9-]{1,190})', $this->server->get_routes() ); + $this->assertSame( 404, $this->get( 'settings' )->get_status() ); + } + + public function test_post_type_is_not_public() { + $post_type = get_post_type_object( POST_TYPE ); + + $this->assertFalse( $post_type->public ); + $this->assertFalse( $post_type->publicly_queryable ); + $this->assertFalse( $post_type->show_ui ); + $this->assertFalse( $post_type->show_in_rest ); + $this->assertFalse( $post_type->show_in_graphql ); + } + + public function test_put_with_version_zero_creates_document() { + $response = $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'dark' ) ) ); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( + array( 'slug' => 'settings', 'version' => 1, 'data' => array( 'theme' => 'dark' ) ), + $response->get_data() + ); + } + + public function test_get_returns_document_and_version() { + $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'dark' ) ) ); + + $response = $this->get( 'settings' ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( + array( 'slug' => 'settings', 'version' => 1, 'data' => array( 'theme' => 'dark' ) ), + $response->get_data() + ); + } + + public function test_get_missing_document_returns_404() { + $response = $this->get( 'missing' ); + + $this->assertSame( 404, $response->get_status() ); + $this->assertSame( 'faustwp_document_not_found', $response->get_data()['code'] ); + } + + public function test_put_with_matching_version_updates_and_bumps_version() { + $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'dark' ) ) ); + + $response = $this->put( 'settings', array( 'version' => 1, 'data' => array( 'theme' => 'light' ) ) ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 2, $response->get_data()['version'] ); + $this->assertSame( array( 'theme' => 'light' ), get_document( 'settings' )['data'] ); + } + + public function test_put_with_stale_version_returns_409_and_keeps_document() { + $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'dark' ) ) ); + $this->put( 'settings', array( 'version' => 1, 'data' => array( 'theme' => 'light' ) ) ); + + $response = $this->put( 'settings', array( 'version' => 1, 'data' => array( 'theme' => 'blue' ) ) ); + $error = $response->get_data(); + + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( 'faustwp_document_conflict', $error['code'] ); + $this->assertSame( 2, $error['data']['version'] ); + $this->assertSame( array( 'theme' => 'light' ), $error['data']['document']['data'] ); + $this->assertSame( array( 'theme' => 'light' ), get_document( 'settings' )['data'] ); + } + + public function test_create_with_version_zero_when_document_exists_returns_409() { + $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'dark' ) ) ); + + $response = $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'light' ) ) ); + + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( array( 'theme' => 'dark' ), get_document( 'settings' )['data'] ); + } + + public function test_update_of_missing_document_returns_409() { + $response = $this->put( 'settings', array( 'version' => 3, 'data' => array() ) ); + + $this->assertSame( 409, $response->get_status() ); + $this->assertSame( 0, $response->get_data()['data']['version'] ); + $this->assertNull( get_document( 'settings' ) ); + } + + /** + * Simulates another request saving between this write's read and its + * UPDATE. The conditional UPDATE must not match, so only one write wins. + */ + public function test_concurrent_write_with_same_version_is_rejected() { + save_document( 'settings', 0, array( 'theme' => 'dark' ) ); + + $raced = false; + add_filter( + 'query', + function ( $query ) use ( &$raced ) { + global $wpdb; + if ( ! $raced && 0 === strpos( $query, "UPDATE {$wpdb->postmeta} SET meta_value" ) ) { + $raced = true; + save_document( 'settings', 1, array( 'theme' => 'light' ) ); + } + return $query; + } + ); + + $result = save_document( 'settings', 1, array( 'theme' => 'blue' ) ); + + $this->assertTrue( $raced ); + $this->assertWPError( $result ); + $this->assertSame( 'faustwp_document_conflict', $result->get_error_code() ); + $this->assertSame( array( 'theme' => 'light' ), get_document( 'settings' )['data'] ); + $this->assertSame( 2, get_document( 'settings' )['version'] ); + } + + public function test_slugs_outside_the_allowed_characters_do_not_match_the_route() { + $this->assertSame( 404, $this->get( 'Settings' )->get_status() ); + $this->assertSame( 404, $this->put( 'my_settings', array( 'version' => 0, 'data' => array() ) )->get_status() ); + } + + public function test_invalid_json_body_returns_400() { + $response = $this->put( 'settings', '{"version": 0, "data": ' ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertNull( get_document( 'settings' ) ); + } + + public function test_missing_version_returns_400() { + $response = $this->put( 'settings', array( 'data' => array( 'theme' => 'dark' ) ) ); + + $this->assertSame( 400, $response->get_status() ); + } + + public function test_data_must_be_an_object_or_array() { + $response = $this->put( 'settings', array( 'version' => 0, 'data' => 'dark' ) ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertNull( get_document( 'settings' ) ); + } + + public function test_document_larger_than_max_size_returns_413() { + add_filter( + 'faustwp_document_max_bytes', + function () { + return 10; + } + ); + + $response = $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'much too long' ) ) ); + + $this->assertSame( 413, $response->get_status() ); + $this->assertNull( get_document( 'settings' ) ); + } + + public function test_data_with_html_and_slashes_round_trips() { + $data = array( 'html' => '', 'path' => 'C:\\temp\\"quoted"' ); + + $this->put( 'settings', array( 'version' => 0, 'data' => $data ) ); + + $this->assertSame( $data, $this->get( 'settings' )->get_data()['data'] ); + } + + public function test_unauthenticated_requests_return_401() { + $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'dark' ) ) ); + wp_set_current_user( 0 ); + + $this->assertSame( 401, $this->get( 'settings' )->get_status() ); + $this->assertSame( 401, $this->put( 'settings', array( 'version' => 1, 'data' => array() ) )->get_status() ); + } + + public function test_users_without_document_capabilities_get_403() { + $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'dark' ) ) ); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + $this->assertSame( 403, $this->get( 'settings' )->get_status() ); + $this->assertSame( 403, $this->put( 'settings', array( 'version' => 1, 'data' => array() ) )->get_status() ); + $this->assertSame( array( 'theme' => 'dark' ), get_document( 'settings' )['data'] ); + } + + public function test_read_capability_allows_reading_but_not_writing() { + $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'dark' ) ) ); + $user = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $user->add_cap( READ_CAPABILITY ); + wp_set_current_user( $user->ID ); + + $this->assertSame( 200, $this->get( 'settings' )->get_status() ); + $this->assertSame( 403, $this->put( 'settings', array( 'version' => 1, 'data' => array() ) )->get_status() ); + } + + public function test_read_and_edit_capabilities_allow_writing() { + $user = self::factory()->user->create_and_get( array( 'role' => 'subscriber' ) ); + $user->add_cap( READ_CAPABILITY ); + $user->add_cap( EDIT_CAPABILITY ); + wp_set_current_user( $user->ID ); + + $this->assertSame( 201, $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'dark' ) ) )->get_status() ); + } + + public function test_document_data_is_stored_in_protected_meta() { + $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'dark' ) ) ); + + $this->assertTrue( is_protected_meta( META_KEY, 'post' ) ); + } +} From ffe318f189f0f6c37537ad63c2d6e7f974a5c22c Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 20 Oct 2026 09:38:12 -0400 Subject: [PATCH 02/30] fix(faustwp): return empty objects in documents as objects --- .../faustwp/includes/documents/callbacks.php | 23 +++++++++++++- .../faustwp/includes/documents/functions.php | 10 +++--- .../tests/integration/DocumentStoreTests.php | 31 +++++++++++++------ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/plugins/faustwp/includes/documents/callbacks.php b/plugins/faustwp/includes/documents/callbacks.php index 115c92a3e..85088db9e 100644 --- a/plugins/faustwp/includes/documents/callbacks.php +++ b/plugins/faustwp/includes/documents/callbacks.php @@ -124,6 +124,27 @@ function validate_document_data( $value ) { return is_array( $value ); } +/** + * Get the document data from a request. + * + * WordPress decodes JSON request bodies into associative arrays, which turns + * an empty object into an empty array. The body is decoded again here with + * objects kept as objects, so the stored data matches what was sent. + * + * @param WP_REST_Request $request Current request. + * + * @return mixed Document data. + */ +function get_request_document_data( WP_REST_Request $request ) { + $body = json_decode( $request->get_body() ); + + if ( is_object( $body ) && property_exists( $body, 'data' ) ) { + return $body->data; + } + + return $request->get_param( 'data' ); +} + /** * Callback for GET faustwp/v1/documents/. * @@ -157,7 +178,7 @@ function handle_put_document( WP_REST_Request $request ) { $result = save_document( $request->get_param( 'slug' ), (int) $request->get_param( 'version' ), - $request->get_param( 'data' ) + get_request_document_data( $request ) ); if ( is_wp_error( $result ) ) { diff --git a/plugins/faustwp/includes/documents/functions.php b/plugins/faustwp/includes/documents/functions.php index b786314a6..166128b9e 100644 --- a/plugins/faustwp/includes/documents/functions.php +++ b/plugins/faustwp/includes/documents/functions.php @@ -164,16 +164,18 @@ function get_stored_document( $post_id ) { return null; } - $stored = json_decode( $row->meta_value, true ); - if ( ! is_array( $stored ) || ! isset( $stored['version'] ) || ! array_key_exists( 'data', $stored ) ) { + // Decode JSON objects as objects, so an empty object in the data is + // returned as {} rather than []. + $stored = json_decode( $row->meta_value ); + if ( ! is_object( $stored ) || ! isset( $stored->version ) || ! property_exists( $stored, 'data' ) ) { return null; } return array( 'meta_id' => (int) $row->meta_id, 'raw' => $row->meta_value, - 'version' => (int) $stored['version'], - 'data' => $stored['data'], + 'version' => (int) $stored->version, + 'data' => $stored->data, ); } diff --git a/plugins/faustwp/tests/integration/DocumentStoreTests.php b/plugins/faustwp/tests/integration/DocumentStoreTests.php index 28df06b35..06198e36a 100644 --- a/plugins/faustwp/tests/integration/DocumentStoreTests.php +++ b/plugins/faustwp/tests/integration/DocumentStoreTests.php @@ -72,6 +72,13 @@ private function get( $slug ) { return $this->server->dispatch( new \WP_REST_Request( 'GET', self::ROUTE . '/' . $slug ) ); } + /** + * Convert decoded JSON objects to arrays so they can be compared with assertSame. + */ + private function normalize( $value ) { + return json_decode( wp_json_encode( $value ), true ); + } + public function test_store_is_disabled_by_default() { remove_all_filters( 'faustwp_enable_document_store' ); unregister_post_type( POST_TYPE ); @@ -100,7 +107,7 @@ public function test_put_with_version_zero_creates_document() { $this->assertSame( 201, $response->get_status() ); $this->assertSame( array( 'slug' => 'settings', 'version' => 1, 'data' => array( 'theme' => 'dark' ) ), - $response->get_data() + $this->normalize( $response->get_data() ) ); } @@ -112,7 +119,7 @@ public function test_get_returns_document_and_version() { $this->assertSame( 200, $response->get_status() ); $this->assertSame( array( 'slug' => 'settings', 'version' => 1, 'data' => array( 'theme' => 'dark' ) ), - $response->get_data() + $this->normalize( $response->get_data() ) ); } @@ -130,7 +137,7 @@ public function test_put_with_matching_version_updates_and_bumps_version() { $this->assertSame( 200, $response->get_status() ); $this->assertSame( 2, $response->get_data()['version'] ); - $this->assertSame( array( 'theme' => 'light' ), get_document( 'settings' )['data'] ); + $this->assertSame( array( 'theme' => 'light' ), $this->normalize( get_document( 'settings' )['data'] ) ); } public function test_put_with_stale_version_returns_409_and_keeps_document() { @@ -143,8 +150,8 @@ public function test_put_with_stale_version_returns_409_and_keeps_document() { $this->assertSame( 409, $response->get_status() ); $this->assertSame( 'faustwp_document_conflict', $error['code'] ); $this->assertSame( 2, $error['data']['version'] ); - $this->assertSame( array( 'theme' => 'light' ), $error['data']['document']['data'] ); - $this->assertSame( array( 'theme' => 'light' ), get_document( 'settings' )['data'] ); + $this->assertSame( array( 'theme' => 'light' ), $this->normalize( $error['data']['document']['data'] ) ); + $this->assertSame( array( 'theme' => 'light' ), $this->normalize( get_document( 'settings' )['data'] ) ); } public function test_create_with_version_zero_when_document_exists_returns_409() { @@ -153,7 +160,7 @@ public function test_create_with_version_zero_when_document_exists_returns_409() $response = $this->put( 'settings', array( 'version' => 0, 'data' => array( 'theme' => 'light' ) ) ); $this->assertSame( 409, $response->get_status() ); - $this->assertSame( array( 'theme' => 'dark' ), get_document( 'settings' )['data'] ); + $this->assertSame( array( 'theme' => 'dark' ), $this->normalize( get_document( 'settings' )['data'] ) ); } public function test_update_of_missing_document_returns_409() { @@ -189,7 +196,7 @@ function ( $query ) use ( &$raced ) { $this->assertTrue( $raced ); $this->assertWPError( $result ); $this->assertSame( 'faustwp_document_conflict', $result->get_error_code() ); - $this->assertSame( array( 'theme' => 'light' ), get_document( 'settings' )['data'] ); + $this->assertSame( array( 'theme' => 'light' ), $this->normalize( get_document( 'settings' )['data'] ) ); $this->assertSame( 2, get_document( 'settings' )['version'] ); } @@ -237,7 +244,13 @@ public function test_data_with_html_and_slashes_round_trips() { $this->put( 'settings', array( 'version' => 0, 'data' => $data ) ); - $this->assertSame( $data, $this->get( 'settings' )->get_data()['data'] ); + $this->assertSame( $data, $this->normalize( $this->get( 'settings' )->get_data()['data'] ) ); + } + + public function test_empty_objects_are_returned_as_objects() { + $this->put( 'settings', '{"version":0,"data":{"filters":{},"tags":[]}}' ); + + $this->assertSame( '{"filters":{},"tags":[]}', wp_json_encode( $this->get( 'settings' )->get_data()['data'] ) ); } public function test_unauthenticated_requests_return_401() { @@ -254,7 +267,7 @@ public function test_users_without_document_capabilities_get_403() { $this->assertSame( 403, $this->get( 'settings' )->get_status() ); $this->assertSame( 403, $this->put( 'settings', array( 'version' => 1, 'data' => array() ) )->get_status() ); - $this->assertSame( array( 'theme' => 'dark' ), get_document( 'settings' )['data'] ); + $this->assertSame( array( 'theme' => 'dark' ), $this->normalize( get_document( 'settings' )['data'] ) ); } public function test_read_capability_allows_reading_but_not_writing() { From 835d32c506fbc0b47459185658f15b7a4c1f8f43 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 20 Oct 2026 09:39:08 -0400 Subject: [PATCH 03/30] test(faustwp): cover concurrent creates in the document store --- .../tests/integration/DocumentStoreTests.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/plugins/faustwp/tests/integration/DocumentStoreTests.php b/plugins/faustwp/tests/integration/DocumentStoreTests.php index 06198e36a..ff2653bb3 100644 --- a/plugins/faustwp/tests/integration/DocumentStoreTests.php +++ b/plugins/faustwp/tests/integration/DocumentStoreTests.php @@ -7,6 +7,7 @@ namespace WPE\FaustWP\Tests\Integration; +use function WPE\FaustWP\Documents\create_document; use function WPE\FaustWP\Documents\get_document; use function WPE\FaustWP\Documents\maybe_register_document_post_type; use function WPE\FaustWP\Documents\register_document_post_type; @@ -200,6 +201,45 @@ function ( $query ) use ( &$raced ) { $this->assertSame( 2, get_document( 'settings' )['version'] ); } + /** + * Simulates a create that checked for the document before another request + * created it. WordPress gives the second post a different slug, so the + * second create is rejected and its post removed. + */ + public function test_concurrent_create_with_renamed_slug_is_rejected() { + create_document( 'settings', array( 'theme' => 'dark' ) ); + + $result = create_document( 'settings', array( 'theme' => 'light' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'faustwp_document_conflict', $result->get_error_code() ); + $this->assertSame( array( 'theme' => 'dark' ), $this->normalize( get_document( 'settings' )['data'] ) ); + $this->assertCount( 1, get_posts( array( 'post_type' => POST_TYPE, 'post_status' => 'any', 'fields' => 'ids' ) ) ); + } + + /** + * Simulates two creates that both got the same slug. The oldest post is + * kept and the later one is removed. + */ + public function test_concurrent_create_with_same_slug_keeps_the_oldest_post() { + create_document( 'settings', array( 'theme' => 'dark' ) ); + add_filter( + 'wp_unique_post_slug', + function ( $slug, $post_id, $post_status, $post_type, $post_parent, $original_slug ) { + return POST_TYPE === $post_type ? $original_slug : $slug; + }, + 10, + 6 + ); + + $result = create_document( 'settings', array( 'theme' => 'light' ) ); + + $this->assertWPError( $result ); + $this->assertSame( 'faustwp_document_conflict', $result->get_error_code() ); + $this->assertSame( array( 'theme' => 'dark' ), $this->normalize( get_document( 'settings' )['data'] ) ); + $this->assertCount( 1, get_posts( array( 'post_type' => POST_TYPE, 'post_status' => 'any', 'fields' => 'ids' ) ) ); + } + public function test_slugs_outside_the_allowed_characters_do_not_match_the_route() { $this->assertSame( 404, $this->get( 'Settings' )->get_status() ); $this->assertSame( 404, $this->put( 'my_settings', array( 'version' => 0, 'data' => array() ) )->get_status() ); From 0fbc7ccc80d6f039f101dc6d77fbebed3f158628 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 20 Oct 2026 09:40:14 -0400 Subject: [PATCH 04/30] docs: show the full conflict response for document writes --- docs/how-to/write-data-to-wordpress/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/how-to/write-data-to-wordpress/index.md b/docs/how-to/write-data-to-wordpress/index.md index fb5af5aea..f1ebbe351 100644 --- a/docs/how-to/write-data-to-wordpress/index.md +++ b/docs/how-to/write-data-to-wordpress/index.md @@ -168,8 +168,10 @@ If the document has changed since version 3, the write returns `409 Conflict` an ```json { "code": "faustwp_document_conflict", + "message": "The document has changed since the version this write was based on.", "data": { "status": 409, + "slug": "site-settings", "version": 4, "document": { "slug": "site-settings", "version": 4, "data": { "theme": "blue" } } } From 138c61e9437b30169322850321724247bc9ad69a Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 20 Oct 2026 09:45:56 -0400 Subject: [PATCH 05/30] ci(faustwp): give PHPStan 2 GB of memory 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. --- plugins/faustwp/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/faustwp/composer.json b/plugins/faustwp/composer.json index af98eebc1..82886564c 100644 --- a/plugins/faustwp/composer.json +++ b/plugins/faustwp/composer.json @@ -35,7 +35,7 @@ "lint": "parallel-lint -e php --no-colors --exclude vendor .", "phpcs": "phpcs", "phpcs:fix": "phpcbf", - "phpstan": "phpstan analyze --ansi --memory-limit=1G", + "phpstan": "phpstan analyze --ansi --memory-limit=2G", "suite": [ "@lint", "@phpcs", From 630bca7ab37bdd8374f083493cd55a44e8ad3ebe Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Tue, 20 Oct 2026 10:11:28 -0400 Subject: [PATCH 06/30] docs(examples): add a document store example that runs WordPress locally --- .gitignore | 1 + docs/how-to/write-data-to-wordpress/index.md | 2 + .../next/document-store/.env.local.sample | 8 + examples/next/document-store/README.md | 36 +++++ .../docker-compose.override.yml | 9 ++ .../mu-plugins/enable-document-store.php | 7 + examples/next/document-store/package.json | 21 +++ examples/next/document-store/pages/_app.js | 5 + .../pages/api/documents/[slug].js | 57 ++++++++ examples/next/document-store/pages/index.js | 138 ++++++++++++++++++ .../document-store/scripts/setup-wordpress.sh | 83 +++++++++++ .../document-store/scripts/stop-wordpress.sh | 13 ++ .../next/document-store/styles/globals.css | 62 ++++++++ plugins/faustwp/docker-compose.yml | 4 +- 14 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 examples/next/document-store/.env.local.sample create mode 100644 examples/next/document-store/README.md create mode 100644 examples/next/document-store/docker-compose.override.yml create mode 100644 examples/next/document-store/mu-plugins/enable-document-store.php create mode 100644 examples/next/document-store/package.json create mode 100644 examples/next/document-store/pages/_app.js create mode 100644 examples/next/document-store/pages/api/documents/[slug].js create mode 100644 examples/next/document-store/pages/index.js create mode 100644 examples/next/document-store/scripts/setup-wordpress.sh create mode 100644 examples/next/document-store/scripts/stop-wordpress.sh create mode 100644 examples/next/document-store/styles/globals.css 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 f1ebbe351..bc2c47f22 100644 --- a/docs/how-to/write-data-to-wordpress/index.md +++ b/docs/how-to/write-data-to-wordpress/index.md @@ -180,6 +180,8 @@ If the document has changed since version 3, the write returns `409 Conflict` an Slugs use lowercase letters, numbers and hyphens. Documents are limited to 100 KB of JSON by default, which you can change with the `faustwp_document_max_bytes` filter. +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 and shows a conflict between two browser tabs. + ## 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..d0a0e411f --- /dev/null +++ b/examples/next/document-store/README.md @@ -0,0 +1,36 @@ +# Document store example + +A small Next.js app that reads and writes a shared JSON document in the [FaustWP document store](https://faustjs.org/docs/how-to/write-data-to-wordpress/), and shows what happens when two people save at the same time. + +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. Change the notes in both, then save each tab. The first save works. The second is rejected with `409 Conflict`, because it's based on a version that's no longer current, and the page shows both versions so you can keep theirs or overwrite with yours. + +Stop WordPress with `npm run wordpress:stop`. Add `-- --volumes` to also delete its database. + +## How it works + +- `mu-plugins/enable-document-store.php` is a must-use plugin that turns on the document store with the `faustwp_enable_document_store` filter. +- `pages/api/documents/[slug].js` forwards `GET` and `PUT` requests to `/?rest_route=/faustwp/v1/documents/` with the Application Password, and passes WordPress's status code back: `404` before the document exists, `201` when it's created, `200` when it's updated and `409` for a stale write. +- `pages/index.js` keeps the version it last read and sends it with every save. On a `409`, the response includes the current document. 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..ce46773f8 --- /dev/null +++ b/examples/next/document-store/docker-compose.override.yml @@ -0,0 +1,9 @@ +# 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 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..9cf1e69ea --- /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/documents/[slug].js b/examples/next/document-store/pages/api/documents/[slug].js new file mode 100644 index 000000000..16034cd10 --- /dev/null +++ b/examples/next/document-store/pages/api/documents/[slug].js @@ -0,0 +1,57 @@ +// Forwards document reads and writes to the FaustWP document store, +// authenticating with an Application Password so the credentials never reach +// the browser. + +const SLUG_PATTERN = /^[a-z0-9-]{1,190}$/; + +export default async function handler(req, res) { + if (req.method !== 'GET' && req.method !== 'PUT') { + res.setHeader('Allow', 'GET, PUT'); + return res.status(405).json({ message: 'Method not allowed.' }); + } + + const { slug } = req.query; + if (!SLUG_PATTERN.test(slug)) { + return res + .status(400) + .json({ message: 'Use lowercase letters, numbers and hyphens.' }); + } + + 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'); + + let response; + try { + response = await fetch( + `${NEXT_PUBLIC_WORDPRESS_URL}/?rest_route=/faustwp/v1/documents/${slug}`, + { + method: req.method, + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json', + }, + body: req.method === 'PUT' ? 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/pages/index.js b/examples/next/document-store/pages/index.js new file mode 100644 index 000000000..725b8389e --- /dev/null +++ b/examples/next/document-store/pages/index.js @@ -0,0 +1,138 @@ +import { useEffect, useState } from 'react'; + +const SLUG = 'team-notes'; +const EMPTY_NOTES = { title: '', notes: '', status: 'draft' }; + +async function request(method, body) { + const response = await fetch(`/api/documents/${SLUG}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: response.status, json: await response.json() }; +} + +export default function Home() { + // The version and data last read from or saved to WordPress. + const [saved, setSaved] = useState(null); + // What's in the form now. + const [draft, setDraft] = useState(EMPTY_NOTES); + // The document someone else saved, when a write is rejected. + const [theirs, setTheirs] = useState(null); + const [message, setMessage] = useState('Loading...'); + + function showDocument(document) { + setSaved(document); + setDraft({ ...EMPTY_NOTES, ...document.data }); + setTheirs(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({ slug: SLUG, version: 0, data: EMPTY_NOTES }); + setMessage('No document yet. Saving creates it.'); + } else { + setMessage(`Could not load (${status}): ${json.message}`); + } + } + + async function save(version) { + const { status, json } = await request('PUT', { version, data: draft }); + if (status === 200 || status === 201) { + showDocument(json); + setMessage(`Saved version ${json.version}.`); + } else if (status === 409) { + setTheirs(json.data.document); + setMessage( + `Someone saved version ${json.data.version} after you loaded version ${version}. Nothing was changed.`, + ); + } else { + setMessage(`Could not save (${status}): ${json.message}`); + } + } + + useEffect(() => { + load(); + }, []); + + function update(field) { + return (event) => setDraft({ ...draft, [field]: event.target.value }); + } + + return ( +
+

Team notes

+

+ Open this page in two tabs, change the notes in both, then save + each tab. The second save is rejected because it's based on an + older version. +

+ +

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

+ +
{ + event.preventDefault(); + save(saved.version); + }} + > + +