diff --git a/docs/tutorials/sync/getting-started.mdx b/docs/tutorials/sync/getting-started.mdx index 47671c22..a12759d3 100644 --- a/docs/tutorials/sync/getting-started.mdx +++ b/docs/tutorials/sync/getting-started.mdx @@ -88,11 +88,12 @@ Clients that do not use the Sync API can keep using the direct collection endpoi ### lastMutationAt -A Unix timestamp (milliseconds) tracking the most recent mutation for a user. This is your sync cursor. +`lastMutationAt` is the user's **sync head**: a server-assigned Unix timestamp in milliseconds for the most recent mutation, including virtual resources such as the default Favorites collection. -- **Stored locally** — Your app saves this after each successful sync -- **Sent with requests** — Include it to get only newer changes or validate your state -- **Updated by server** — The server assigns timestamps, ensuring consistent ordering +- **Authoritative on the server** — it is always assigned by the server; clients never invent it +- **Stored locally** — persist the value returned by the latest `GET` or `POST` +- **Sent as a precondition on push** — `POST /v1/sync` requires the current head and rejects a stale or different value with `409` +- **Sent as the continuation head on production pulls** — page 2 and later of a production `GET /v1/sync` pull send the head returned by page 1 (see [Step 2](#step-2-pull-and-apply-mutations)) ### First Sync Behavior @@ -122,17 +123,24 @@ flowchart TD H -->|No| K ``` -### Step 1: Check Sync Status +### Step 1: Read the Sync Head Optionally check the current head with `metadataOnly=true`. This returns only `lastMutationAt` without fetching mutations. (You still need to pass `mutationsSince`, even when `metadataOnly=true`.) Use it to skip a pull when nothing changed since your stored value. -### Step 2: Pull Existing Data +### Step 2: Pull and Apply Mutations Fetch all mutations since `0` on a new device (or since your stored value later), page through until `hasMore=false`, and apply them locally. Store the `lastMutationAt` from the final page for future requests. +For pagination, keep `mutationsSince`, `resources` and `limit` the same on every page of a pull. + +- **Production:** page 2 and later must also send `lastMutationAt` set to the head returned by page 1. If the head changed, the server returns `409 OutOfSyncError` because another write landed mid-pull: restart from page 1 with the original `mutationsSince`. A missing head also returns `409`; that is a request bug, so add the parameter rather than restarting. +- **Pre-live:** do not send `lastMutationAt` on `GET /v1/sync` (pre-live rejects the parameter). Instead, compare the `lastMutationAt` returned by each page with page 1's, and restart from page 1 if it changed. + ### Step 3: Push Local Changes -When the user creates data offline, push mutations to the server using `POST /v1/sync?lastMutationAt=...`. Update your local `lastMutationAt` from the response. +When the user creates data offline, send `POST /v1/sync?lastMutationAt=` with up to 100 mutations, using the head returned by the latest `GET`, pull, or push. Store the head returned by the response. + +If the push returns `409 OutOfSyncError`, another device wrote first: pull, replay the local changes on top, then push again with the new head. ### API References @@ -160,6 +168,7 @@ For the full request/response schema, see: - `resources=BOOKMARK,COLLECTION` limits results to those resources - `limit` and `page` paginate results (max `limit` is 1000) - Continue fetching pages until `hasMore=false` +- Page 2 and later follow the continuation rules in [Step 2](#step-2-pull-and-apply-mutations) When `metadataOnly=true`, the response includes only `lastMutationAt` (no pagination fields). diff --git a/docs/tutorials/sync/handling-conflicts.mdx b/docs/tutorials/sync/handling-conflicts.mdx index 3822fc66..91458f50 100644 --- a/docs/tutorials/sync/handling-conflicts.mdx +++ b/docs/tutorials/sync/handling-conflicts.mdx @@ -17,6 +17,7 @@ A 409 `OutOfSyncError` happens when: 2. **Concurrent mutations** — Server received mutations while you were offline 3. **Missed header** — Client didn't capture the `X-Mutation-At` response header 4. **Made-up value** — Sending a value the server never returned, such as `-1` +5. **Head changed mid-pull (production `GET`)** — Page 2 or later of a pull sent the page-1 head after another write landed. Restart the pull from page 1 with the original `mutationsSince`, `resources` and `limit`; no local mutations need retrying ```mermaid sequenceDiagram diff --git a/docs/tutorials/sync/offline-first-patterns.mdx b/docs/tutorials/sync/offline-first-patterns.mdx index 5f458007..abfa5fe6 100644 --- a/docs/tutorials/sync/offline-first-patterns.mdx +++ b/docs/tutorials/sync/offline-first-patterns.mdx @@ -102,9 +102,9 @@ Your sync engine should follow a **pull → apply → push** cycle and handle 40 ### Pull → Apply → Push (with pagination) -1. **Pull** — Call `GET /v1/sync?mutationsSince=T` and page through results (`page`/`limit`) until `hasMore=false`. +1. **Pull** — Call `GET /v1/sync?mutationsSince=T` and page through results (`page`/`limit`) until `hasMore=false`, following the [continuation rules](/docs/tutorials/sync/getting-started#step-2-pull-and-apply-mutations) for page 2 and later. 2. **Apply** — Apply mutations in timestamp order and update local records. -3. **Push** — Send queued mutations with `POST /v1/sync?lastMutationAt=T` and update `lastMutationAt` from the response. +3. **Push** — Send queued mutations with `POST /v1/sync?lastMutationAt=`, using the head returned by the pull (not the `T` you pulled with), and store the head from the response. ## Connectivity Handling diff --git a/tests/sync-docs-last-mutation-at.test.cjs b/tests/sync-docs-last-mutation-at.test.cjs new file mode 100644 index 00000000..c2f301c5 --- /dev/null +++ b/tests/sync-docs-last-mutation-at.test.cjs @@ -0,0 +1,81 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const repoRoot = path.join(__dirname, '..'); + +const read = (relativePath) => + fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); + +const SYNC_SPECS = [ + 'openAPI/user-related-apis/v1.json', + 'openAPI/user-related-apis/pre-live/v1.json', +]; + +const SYNC_TUTORIALS = [ + 'docs/tutorials/sync/getting-started.mdx', + 'docs/tutorials/sync/handling-conflicts.mdx', +]; + +test('the pinned sync specs require the positive sync head, not -1', () => { + for (const specPath of SYNC_SPECS) { + const spec = JSON.parse(read(specPath)); + const syncPath = spec.paths['/v1/sync']; + + const pushParameter = syncPath.post.parameters.find( + (parameter) => parameter.name === 'lastMutationAt', + ); + assert.match( + pushParameter.description, + /rather than -1/u, + `${specPath} should state that a new user sends the positive head instead of -1`, + ); + + const pullHead = + syncPath.get.responses['200'].content['application/json'].schema + .properties.data.properties.lastMutationAt; + assert.match( + pullHead.description, + /sync head/u, + `${specPath} should describe the GET lastMutationAt response field as the sync head`, + ); + } +}); + +test('the sync tutorials do not document a -1 first-push sentinel', () => { + for (const page of SYNC_TUTORIALS) { + const content = read(page); + + // A historical note ("Older versions of this guide said to send ...") is allowed. + const instructions = content + .split('\n') + .filter((line) => !/Older versions of this guide/u.test(line)) + .join('\n'); + assert.doesNotMatch( + instructions, + /lastMutationAt\s*[=:]\s*-1/u, + `${page} must not tell clients to push with lastMutationAt=-1`, + ); + assert.doesNotMatch( + content, + /first-sync state/iu, + `${page} must not describe a -1 first-sync state`, + ); + assert.doesNotMatch( + content, + /First sync detected\. Please use lastMutationAt=-1/u, + `${page} must not quote the retired first-sync error message`, + ); + } +}); + +test('the sync getting-started guide explains that the first head is positive', () => { + const content = read('docs/tutorials/sync/getting-started.mdx'); + + assert.match( + content, + /never `-1`/u, + 'the guide should state that a new user already has a positive sync head', + ); +});