Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions docs/tutorials/sync/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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=<current head>` 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

Expand Down Expand Up @@ -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).

Expand Down
1 change: 1 addition & 0 deletions docs/tutorials/sync/handling-conflicts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/tutorials/sync/offline-first-patterns.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=<head>`, using the head returned by the pull (not the `T` you pulled with), and store the head from the response.

## Connectivity Handling

Expand Down
81 changes: 81 additions & 0 deletions tests/sync-docs-last-mutation-at.test.cjs
Original file line number Diff line number Diff line change
@@ -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',
);
});
Loading