From e9316f18d8e8afd9ced15f8c6b9edb67a78df429 Mon Sep 17 00:00:00 2001 From: haroon0x Date: Thu, 5 Nov 2026 00:19:32 +0530 Subject: [PATCH 1/4] docs(sync): require the positive sync head on the first push The sync tutorials told clients to send `lastMutationAt=-1` for a user's first mutation and to read `-1` as a "first-sync state". Both pinned specs say the opposite: the head returned by GET already includes virtual resources such as the default Favorites collection, so a new user has a positive head, and `-1` is rejected with a 409 OutOfSyncError. - describe `lastMutationAt` as the push precondition, separate from the `mutationsSince` pull cursor - rewrite the first-sync table, flowchart and steps around the positive head - document the page-1 continuation head that GET pagination requires - drop the retired "First sync detected..." error variant - add a test pinning both specs and both tutorials to this contract --- docs/tutorials/sync/getting-started.mdx | 65 +++++++++-------- docs/tutorials/sync/handling-conflicts.mdx | 9 +-- tests/sync-docs-last-mutation-at.test.cjs | 81 ++++++++++++++++++++++ 3 files changed, 119 insertions(+), 36 deletions(-) create mode 100644 tests/sync-docs-last-mutation-at.test.cjs diff --git a/docs/tutorials/sync/getting-started.mdx b/docs/tutorials/sync/getting-started.mdx index cef483db..e9a60b95 100644 --- a/docs/tutorials/sync/getting-started.mdx +++ b/docs/tutorials/sync/getting-started.mdx @@ -84,24 +84,26 @@ Production clients should keep using the direct collection endpoints until `/v1/ ### 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` + +`lastMutationAt` is **not** the pull cursor. Pull with `mutationsSince`, which is the timestamp of the last mutation your client has applied. ### First Sync Behavior -When a user has never synced before (no `lastMutationAt` stored on the server): +There is no "first sync" sentinel value. A brand-new user still gets a positive `lastMutationAt`, because the sync head includes virtual resources such as the default Favorites collection. -| Scenario | Server Response | Client Action | -| ------------------------------- | ----------------------------- | ------------------------------------------------- | -| No sync history yet | `lastMutationAt: -1` | Store `-1`, treat as first-sync state | -| First mutation from this device | Send `lastMutationAt=-1` | Server accepts, returns new timestamp | -| User already has sync history | `lastMutationAt: ` | Store timestamp, fetch mutations since `0` (or T) | +| Scenario | Server Response | Client Action | +| ------------------------------- | ---------------------------------------- | --------------------------------------------------------------------- | +| No local state yet | `lastMutationAt: ` | Store the head, pull with `mutationsSince=0`, push with the stored head | +| First mutation from this device | Returns the new head | Store the returned `lastMutationAt` | +| Another device synced first | `409 OutOfSyncError` on push | Pull, replay local changes, push again with the new head | -:::warning First Sync Requirement -For a user's very first mutation via `POST /v1/sync`, you **must** send `lastMutationAt=-1`. Any other value will be rejected. +:::warning Do not send `-1` +`POST /v1/sync` accepts only the head returned by the most recent `GET`, pull, or push. Sending `-1`, `0`, or a value captured before a pull returns `409 OutOfSyncError`. ::: ## Quick Start: Your First Sync @@ -109,33 +111,35 @@ For a user's very first mutation via `POST /v1/sync`, you **must** send `lastMut ```mermaid flowchart TD A[Start] --> B["GET /v1/sync?mutationsSince=0&metadataOnly=true"] - B --> C{lastMutationAt?} - C -->|"-1 (first-sync state)"| D["Store lastMutationAt = -1"] - C -->|"timestamp (existing user)"| E["GET /v1/sync?mutationsSince=0"] - E --> F["Apply mutations locally"] - F --> G["Store lastMutationAt"] - D --> H{User makes changes offline?} - G --> H - H -->|Yes| I["POST /v1/sync?lastMutationAt=-1 (first time)
or lastMutationAt=T (later)
with mutations array"] - I --> J["Update lastMutationAt from response"] - J --> K[Done] - H -->|No| K + B --> C["Store lastMutationAt (positive sync head)"] + C --> D{"Local changes to push?"} + D -->|No| E["GET /v1/sync?mutationsSince=0"] + E --> F["Apply mutations locally
store the returned head"] + D -->|Yes| G["Pull first with mutationsSince=applied cursor"] + G --> F + F --> H["POST /v1/sync?lastMutationAt=current head
with the mutations array"] + H --> I["Store the lastMutationAt from the response"] + I --> J[Done] ``` -### Step 1: Check Sync Status +### Step 1: Read the Sync Head + +Call `GET /v1/sync` with `metadataOnly=true` to read the current head without fetching mutations. (You still need to pass `mutationsSince`, even when `metadataOnly=true`.) -First, check if the user has any existing data using `metadataOnly=true`. This returns only `lastMutationAt` without fetching mutations. (You still need to pass `mutationsSince`, even when `metadataOnly=true`.) +- The response is always a positive `lastMutationAt`, including for a user with no history +- Store it: it is the precondition for your next push -- `lastMutationAt: -1` means no sync history yet (first-sync state) -- Any positive number means existing data exists +### Step 2: Pull and Apply Mutations -### Step 2: Pull Existing Data +Call `GET /v1/sync?mutationsSince=` with `mutationsSince=0` for a full pull, and apply each mutation locally. Persist `lastMutationAt` only after the final page. -If the user has data (`lastMutationAt > -1`), fetch all mutations and apply them locally. Store the `lastMutationAt` value for future requests. +For pagination, page 2 and later must send the `lastMutationAt` returned by page 1. If the head moves while you are paging, the request fails with `409` and the pull must restart from page 1 with the original cursor. ### 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 @@ -163,6 +167,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 must also send the `lastMutationAt` returned by page 1. A missing continuation head, or a head that changed between pages, returns `409` and the pull restarts from page 1. 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 2c6810cb..d4ca2dff 100644 --- a/docs/tutorials/sync/handling-conflicts.mdx +++ b/docs/tutorials/sync/handling-conflicts.mdx @@ -17,7 +17,7 @@ A 409 `OutOfSyncError` happens when: 1. **Another device synced** — User made changes on a different device 2. **Concurrent mutations** — Server received mutations while you were offline 3. **Missed header** — Client didn't capture the `X-Mutation-At` response header -4. **First sync mismatch** — Sending wrong `lastMutationAt` for a new user +4. **Head mismatch** — Sending a value that is not the current sync head, such as `-1`, `0`, or a head captured before a pull ```mermaid sequenceDiagram @@ -45,12 +45,9 @@ When a conflict occurs, the server responds: **HTTP Status:** `409 Conflict` -### First-Sync Variant +### No First-Sync Exception -If this is a new user's first sync and you send the wrong `lastMutationAt`: - -- **Error code:** `OutOfSyncError` -- **Message:** `First sync detected. Please use lastMutationAt=-1 for initial sync.` +There is no separate error for a new user, and no special value for a first push. A user with no history already has a positive sync head (it includes virtual resources such as the default Favorites collection), so a first push uses the same rule as every later push: send the head returned by the most recent `GET`, pull, or push. Any other value, including `-1`, returns the same `OutOfSyncError` shown above. ## Conflict Resolution Flow 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..4835ee64 --- /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); + + assert.doesNotMatch( + content, + /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, + /positive `lastMutationAt`/u, + 'the guide should state that a new user already has a positive sync head', + ); + assert.match( + content, + /not\*\* the pull cursor/u, + 'the guide should separate the push precondition from the pull cursor', + ); +}); From 044860e8615eef07cf8869a6ff60afff8e6adbe8 Mon Sep 17 00:00:00 2001 From: haroon0x Date: Thu, 5 Nov 2026 00:26:51 +0530 Subject: [PATCH 2/4] docs(sync): attribute the continuation head to the production contract The guide says Sync is pre-live only, but the pre-live spec does not declare a `lastMutationAt` parameter on GET /v1/sync; only the production spec does. Scope the pagination rule accordingly: keep the same `mutationsSince` across pages, and use the page-1 continuation head where the deployment accepts it. --- docs/tutorials/sync/getting-started.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorials/sync/getting-started.mdx b/docs/tutorials/sync/getting-started.mdx index e9a60b95..ec9ab1ff 100644 --- a/docs/tutorials/sync/getting-started.mdx +++ b/docs/tutorials/sync/getting-started.mdx @@ -133,7 +133,7 @@ Call `GET /v1/sync` with `metadataOnly=true` to read the current head without fe Call `GET /v1/sync?mutationsSince=` with `mutationsSince=0` for a full pull, and apply each mutation locally. Persist `lastMutationAt` only after the final page. -For pagination, page 2 and later must send the `lastMutationAt` returned by page 1. If the head moves while you are paging, the request fails with `409` and the pull must restart from page 1 with the original cursor. +For pagination, keep the same `mutationsSince` across pages. The production `GET /v1/sync` contract also declares a `lastMutationAt` continuation head for page 2 and later: when your deployment accepts it, send the head returned by page 1, and a head that changes mid-pull returns `409`, which restarts the pull from page 1 with the original cursor. The pre-live contract does not declare that parameter yet. ### Step 3: Push Local Changes @@ -167,7 +167,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 must also send the `lastMutationAt` returned by page 1. A missing continuation head, or a head that changed between pages, returns `409` and the pull restarts from page 1. +- Page 2 and later keep the same `mutationsSince`. The production contract additionally accepts the page-1 `lastMutationAt` as a continuation head; where it is supported, a changed head returns `409` and the pull restarts from page 1. When `metadataOnly=true`, the response includes only `lastMutationAt` (no pagination fields). From 5c1108dda085a2b18dfa88367a9e05071a8dba82 Mon Sep 17 00:00:00 2001 From: Basit Minhas Date: Thu, 5 Nov 2026 09:38:35 +0500 Subject: [PATCH 3/4] docs(sync): require the page-1 head on continuation pages Production rejects GET /v1/sync page 2+ without the page-1 lastMutationAt, or with a different one, with 409 OutOfSyncError (verified live). Describe it as required rather than optional. Co-Authored-By: Claude Opus 5.5 --- docs/tutorials/sync/getting-started.mdx | 4 ++-- docs/tutorials/sync/offline-first-patterns.mdx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorials/sync/getting-started.mdx b/docs/tutorials/sync/getting-started.mdx index ec9ab1ff..40a2ee9b 100644 --- a/docs/tutorials/sync/getting-started.mdx +++ b/docs/tutorials/sync/getting-started.mdx @@ -133,7 +133,7 @@ Call `GET /v1/sync` with `metadataOnly=true` to read the current head without fe Call `GET /v1/sync?mutationsSince=` with `mutationsSince=0` for a full pull, and apply each mutation locally. Persist `lastMutationAt` only after the final page. -For pagination, keep the same `mutationsSince` across pages. The production `GET /v1/sync` contract also declares a `lastMutationAt` continuation head for page 2 and later: when your deployment accepts it, send the head returned by page 1, and a head that changes mid-pull returns `409`, which restarts the pull from page 1 with the original cursor. The pre-live contract does not declare that parameter yet. +For pagination, keep the same `mutationsSince` across pages. On production, page 2 and later must also send `lastMutationAt` set to the head returned by page 1. A missing or changed head returns `409 OutOfSyncError`: restart the pull from page 1 with the original `mutationsSince`. (The pre-live contract does not declare this parameter yet.) ### Step 3: Push Local Changes @@ -167,7 +167,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 keep the same `mutationsSince`. The production contract additionally accepts the page-1 `lastMutationAt` as a continuation head; where it is supported, a changed head returns `409` and the pull restarts from page 1. +- Page 2 and later keep the same `mutationsSince` and, on production, must send the page-1 `lastMutationAt`. A missing or changed head returns `409`, and the pull restarts from page 1. When `metadataOnly=true`, the response includes only `lastMutationAt` (no pagination fields). diff --git a/docs/tutorials/sync/offline-first-patterns.mdx b/docs/tutorials/sync/offline-first-patterns.mdx index e4483e82..07004aea 100644 --- a/docs/tutorials/sync/offline-first-patterns.mdx +++ b/docs/tutorials/sync/offline-first-patterns.mdx @@ -103,7 +103,7 @@ 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`, sending the page-1 `lastMutationAt` on 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. From 0a9fcf12f47c8a5fbc4774cde81ed7e0f6e1557a Mon Sep 17 00:00:00 2001 From: Basit Minhas Date: Thu, 5 Nov 2026 09:46:03 +0500 Subject: [PATCH 4/4] docs(sync): split pull continuation rules for production and pre-live - Production GET page 2+ requires the page-1 head; a changed head means restart the pull, a missing head is a request bug (no restart loop). - Pre-live does not accept lastMutationAt on GET: compare each page's returned head with page 1's instead. - Keep mutationsSince, resources and limit fixed across pages. - State the rule once in Step 2 and link to it; mention the GET continuation use in Key Concepts. - offline-first-patterns: push with the head returned by the pull, not the mutationsSince cursor. - handling-conflicts: add the mid-pull 409 case and use the pull cursor, not the head, as mutationsSince when recovering. Co-Authored-By: Claude Opus 5.5 --- docs/tutorials/sync/getting-started.mdx | 8 ++++++-- docs/tutorials/sync/handling-conflicts.mdx | 5 +++-- docs/tutorials/sync/offline-first-patterns.mdx | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/tutorials/sync/getting-started.mdx b/docs/tutorials/sync/getting-started.mdx index 40a2ee9b..143eb989 100644 --- a/docs/tutorials/sync/getting-started.mdx +++ b/docs/tutorials/sync/getting-started.mdx @@ -89,6 +89,7 @@ Production clients should keep using the direct collection endpoints until `/v1/ - **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)) `lastMutationAt` is **not** the pull cursor. Pull with `mutationsSince`, which is the timestamp of the last mutation your client has applied. @@ -133,7 +134,10 @@ Call `GET /v1/sync` with `metadataOnly=true` to read the current head without fe Call `GET /v1/sync?mutationsSince=` with `mutationsSince=0` for a full pull, and apply each mutation locally. Persist `lastMutationAt` only after the final page. -For pagination, keep the same `mutationsSince` across pages. On production, page 2 and later must also send `lastMutationAt` set to the head returned by page 1. A missing or changed head returns `409 OutOfSyncError`: restart the pull from page 1 with the original `mutationsSince`. (The pre-live contract does not declare this parameter yet.) +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 @@ -167,7 +171,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 keep the same `mutationsSince` and, on production, must send the page-1 `lastMutationAt`. A missing or changed head returns `409`, and the pull restarts from page 1. +- 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 d4ca2dff..7402a72f 100644 --- a/docs/tutorials/sync/handling-conflicts.mdx +++ b/docs/tutorials/sync/handling-conflicts.mdx @@ -18,6 +18,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. **Head mismatch** — Sending a value that is not the current sync head, such as `-1`, `0`, or a head captured before a pull +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 @@ -63,7 +64,7 @@ sequenceDiagram Note over Client: Start recovery... - Client->>Server: GET /v1/sync?mutationsSince=T1 + Client->>Server: GET /v1/sync?mutationsSince=C (last applied) Server-->>Client: { mutations: [...], lastMutationAt: T_server } Client->>Client: Apply server changes locally @@ -78,7 +79,7 @@ sequenceDiagram ### Step-by-Step Recovery 1. **Catch the 409 error** -2. **Fetch server changes** — `GET /v1/sync?mutationsSince=` +2. **Fetch server changes** — `GET /v1/sync?mutationsSince=` (the pull cursor, not the head) 3. **Apply server mutations** to your local database 4. **Resolve conflicts** if the same resource was modified both locally and remotely 5. **Retry your mutations** with the new `lastMutationAt` diff --git a/docs/tutorials/sync/offline-first-patterns.mdx b/docs/tutorials/sync/offline-first-patterns.mdx index 07004aea..ada136ee 100644 --- a/docs/tutorials/sync/offline-first-patterns.mdx +++ b/docs/tutorials/sync/offline-first-patterns.mdx @@ -103,9 +103,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`, sending the page-1 `lastMutationAt` on page 2 and later. +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 `mutationsSince` cursor), and store the head from the response. ## Connectivity Handling