# 离线数据同步与冲突解决

> 把编辑加入 IndexedDB 队列，通过后台同步或在线回退重试，并用 HTTP 实体标签处理过期写入。

IndexedDB 可以保存待提交的编辑，等网络恢复后再发送。服务器报告基础版本已过期时，继续保留编辑，交给用户处理。支持 Background Sync 时用它同步，否则在页面保持打开时重试。

## 浏览器与平台支持

IndexedDB 通过对象存储和事务保存浏览器端记录。MDN 把 Background Sync 标为有限可用，而且
它依赖安全上下文中的 Service Worker，因此离线写入不能只依赖它。示例会检测
`SyncManager`；如果它不存在，就使用窗口的 `online` 事件并立即尝试提交。

`navigator.onLine` 只是提示。MDN 指出浏览器和操作系统使用不同的判断方式，而且设备可能能
连接局域网却无法访问互联网。应由请求结果决定是否把编辑移出队列。

## 定义同步约定

保存操作 ID、资源 URL、编辑后的文档，以及读取基础文档时收到的实体标签。提交时把该标签
放入 `If-Match`，并把操作 ID 作为端点的 `Idempotency-Key`。这是应用 API 的约定，不是
HTTP 本身提供的行为。示例采用已过期 IETF Internet-Draft 中仍属进行中工作的语义：一个键
只对应一种请求 payload；原请求完成后的重试会收到已保存的结果，并发重试会收到冲突响应。
RFC 9110 规定，当 `If-Match` 前置条件为假时，源服务器不得执行请求的方法。服务器可以返回
`412 Precondition Failed`；如果它确认改变状态的操作已经成功，也可以返回成功状态。

Internet-Draft 要求资源公布其幂等性规范，在适用时包含过期策略，并管理每个键的生命周期。
保留窗口应长于客户端的自动重试窗口，而客户端应在它过期前停止自动重试；过期后，应先读取
资源并确认操作是否完成，再创建新操作和新键。较长的窗口会为更多键、指纹和结果占用存储空间，
较短的窗口则会缩短可安全去重的重试时间。Internet-Draft 没有规定具体时长。

本示例在响应成功后删除队列操作。遇到 `412` 时保留操作并标记为 `conflict`，让界面加载
服务器版本，再由用户合并或替换。网络错误同样会保留操作，等待下次尝试。

:::caution
`online` 事件表示浏览器网络状态发生变化，并不能证明应用服务器可访问。MDN 建议把它作为
提示，不要据此禁用功能。在服务器接受操作之前，把失败操作保留在 IndexedDB 中。
:::

## 示例

页面和 Service Worker 共用一个 IndexedDB 发件箱。

### 把编辑加入队列并提交

存储模块创建一个对象存储，并提供 `queueEdit()` 和 `flushOutbox()`。服务器端点必须在
读取文档时返回 `ETag`，在更新时处理 `If-Match`，并按 `Idempotency-Key` 去重更新。

```js
const database = new Promise((resolve, reject) => {
  const request = indexedDB.open('notes', 1);
  request.onupgradeneeded = () => {
    request.result.createObjectStore('outbox', { keyPath: 'id' });
  };
  request.onsuccess = () => resolve(request.result);
  request.onerror = () => reject(request.error);
});

const LEASE_MS = 60_000;
const RETRY_DELAY_MS = 5_000;

function transactionDone(transaction) {
  return new Promise((resolve, reject) => {
    transaction.oncomplete = () => resolve();
    transaction.onabort = transaction.onerror = () => reject(transaction.error);
  });
}

async function claimNextOperation(owner) {
  const db = await database;
  const transaction = db.transaction('outbox', 'readwrite');
  const store = transaction.objectStore('outbox');
  const request = store.openCursor();
  let claimed;

  await new Promise((resolve, reject) => {
    request.onsuccess = () => {
      const cursor = request.result;
      if (!cursor) return resolve();

      const operation = cursor.value;
      const now = Date.now();
      if (
        operation.status === 'conflict' ||
        operation.leaseUntil > now ||
        operation.retryAfter > now
      ) {
        cursor.continue();
        return;
      }

      claimed = {
        ...operation,
        leaseOwner: owner,
        leaseUntil: now + LEASE_MS,
      };
      const update = cursor.update(claimed);
      update.onsuccess = () => resolve();
      update.onerror = () => reject(update.error);
    };
    request.onerror = () => reject(request.error);
  });
  await transactionDone(transaction);
  return claimed;
}

async function finishOperation(id, owner, outcome) {
  const db = await database;
  const transaction = db.transaction('outbox', 'readwrite');
  const store = transaction.objectStore('outbox');
  const request = store.get(id);
  request.onsuccess = () => {
    const current = request.result;
    if (!current || current.leaseOwner !== owner) return;

    if (outcome === 'accepted') {
      store.delete(id);
      return;
    }

    store.put({
      ...current,
      status: outcome === 'conflict' ? 'conflict' : current.status,
      leaseOwner: null,
      leaseUntil: 0,
      retryAfter: outcome === 'retry' ? Date.now() + RETRY_DELAY_MS : 0,
    });
  };
  await transactionDone(transaction);
}

async function getNextAttemptAt() {
  const db = await database;
  const transaction = db.transaction('outbox', 'readonly');
  const request = transaction.objectStore('outbox').openCursor();
  let earliest = Infinity;

  await new Promise((resolve, reject) => {
    request.onsuccess = () => {
      const cursor = request.result;
      if (!cursor) return resolve();
      const operation = cursor.value;
      const now = Date.now();
      const readyAt = Math.max(operation.retryAfter ?? 0, operation.leaseUntil ?? 0);
      if (operation.status !== 'conflict') {
        earliest = Math.min(earliest, Math.max(now, readyAt));
      }
      cursor.continue();
    };
    request.onerror = () => reject(request.error);
  });
  await transactionDone(transaction);
  return Number.isFinite(earliest) ? earliest : null;
}

export class OutboxRetryError extends Error {
  constructor(retryAt) {
    super('The outbox still has operations awaiting another attempt.');
    this.name = 'OutboxRetryError';
    this.retryAt = retryAt;
  }
}

export async function flushOutbox() {
  const owner = crypto.randomUUID();
  let operation;
  while ((operation = await claimNextOperation(owner))) {
    let response;
    try {
      response = await fetch(operation.url, {
        method: 'PUT',
        headers: {
          'content-type': 'application/json',
          'if-match': operation.etag,
          // The draft defines Idempotency-Key as a Structured Field String.
          'idempotency-key': JSON.stringify(operation.id),
        },
        body: JSON.stringify(operation.document),
      });
    } catch {
      await finishOperation(operation.id, owner, 'retry');
      continue;
    }

    if (response.status === 412) {
      await finishOperation(operation.id, owner, 'conflict');
      continue;
    }
    await finishOperation(operation.id, owner, response.ok ? 'accepted' : 'retry');
  }

  const retryAt = await getNextAttemptAt();
  if (retryAt !== null) {
    throw new OutboxRetryError(retryAt);
  }
}

export async function queueEdit(operation) {
  const db = await database;
  const transaction = db.transaction('outbox', 'readwrite');
  const { leaseOwner, leaseUntil, retryAfter, ...queued } = operation;
  transaction.objectStore('outbox').put({
    ...queued,
    status: operation.status ?? 'pending',
  });
  await transactionDone(transaction);
}
```

本地编辑后调用 `queueEdit({ id, url, document, etag })`。每个逻辑操作使用一个稳定且唯一的
`id`，只有重试同一操作时才复用它。领取操作的事务会同时写入限时租约，因此页面和 Worker
在租约有效时无法同时提交同一操作；在端点公布的保留窗口内，其幂等约定会处理请求执行时间超过租约的
剩余情况。

### 检测 Background Sync 并回退

API 存在时注册一次同步；否则保留 `online` 监听器，直到一次提交不再留下可重试操作，并按最早的
`retryAt` 在页面仍打开时安排下一次尝试。

```js
import { OutboxRetryError, flushOutbox, queueEdit } from './outbox.js';

let retryTimer;
let pageFlush;
let flushAgain = false;

function stopPageRetry() {
  clearTimeout(retryTimer);
  retryTimer = undefined;
  window.removeEventListener('online', onOnline);
}

function schedulePageRetry(retryAt) {
  clearTimeout(retryTimer);
  retryTimer = setTimeout(onOnline, Math.max(0, retryAt - Date.now()));
}

async function flushFromPage() {
  if (pageFlush) {
    flushAgain = true;
    return pageFlush;
  }
  pageFlush = (async () => {
    do {
      flushAgain = false;
      try {
        await flushOutbox();
        if (!flushAgain) stopPageRetry();
      } catch (error) {
        if (!(error instanceof OutboxRetryError)) throw error;
        schedulePageRetry(error.retryAt);
      }
    } while (flushAgain);
  })().finally(() => {
    pageFlush = undefined;
  });
  return pageFlush;
}

function onOnline() {
  void flushFromPage().catch((error) => console.error('Outbox flush failed', error));
}

async function requestFlush() {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    try {
      const registration = await navigator.serviceWorker.ready;
      await registration.sync.register('flush-note-outbox');
      return;
    } catch {
      // 继续执行页面重试路径。
    }
  }
  window.addEventListener('online', onOnline);
  await flushFromPage();
}

if ('indexedDB' in window) {
  await queueEdit({
    id: crypto.randomUUID(),
    url: '/api/notes/42',
    document: { title: '装箱清单' },
    etag: '"version-from-the-last-read"',
  });
  await requestFlush();
} else {
  showManualRetry('此浏览器无法在离线队列中保存编辑。');
}
```

Service Worker 使用同一个存储模块，并通过 `waitUntil()` 延长事件生命周期：

```js
self.addEventListener('sync', (event) => {
  if (event.tag === 'flush-note-outbox') {
    event.waitUntil(flushOutbox());
  }
});
```

Service Worker 和页面现在使用同一个持久队列。IndexedDB 会串行处理它们领取操作的事务，完成
请求时也只有仍持有租约的调用方才能修改记录。失败的请求会先释放租约，再让
`flushOutbox()` 拒绝。这会让 `waitUntil()` 报告同步失败；页面路径则等待到 `retryAt`，
避免不停地立即重试。

:::caution
不要把 `'SyncManager' in window` 当作同步已经注册成功的证明。示例会等待
`registration.sync.register()`；Promise 拒绝时转入页面重试路径，因为 Background Sync 的[可用性有限](https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API)。
该回退只会在页面保持打开时运行，因此页面关闭后，队列中的编辑可能仍处于待处理状态。
:::

## 处理冲突

应用找到状态为 `conflict` 的操作后，获取服务器当前表示，并同时展示两个版本。随后，客户端
可以用新实体标签和 `pending` 状态把合并后的文档重新加入队列，也可以丢弃本地操作。不要仅
因为设备报告在线就删除队列编辑。

IndexedDB 规范规定，读写事务在其覆盖的对象存储内是原子的，因此页面和 Worker 的租约
领取操作是互斥的。租约会过期，避免上下文崩溃后永久阻塞队列；只要键仍在保留期内，端点的幂等
约定就会处理重复请求。超过公布的键过期时间后，应验证服务器状态，而不能假定重复请求仍然
安全。`If-Match` 前置条件另行保护服务器资源，避免过期写入。

## 更正（2026-10-20）

本次修订按 RFC 9110 表述 `If-Match` 条件为假时可选的响应，并把幂等保留期限相关表述限定为
Internet-Draft 对生命周期管理与过期策略的要求。

## 另请参阅

- [离线策略](/zh/guides/offline/)
- [Background Sync API 浏览器支持](/zh/compatibility/background-sync/)
- [存储参考](/zh/reference/storage/)
- [Indexed Database API 3.0](https://www.w3.org/TR/IndexedDB/) (W3C)
- [Background Synchronization API](https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API) (MDN)
- [HTTP Semantics: Preconditions](https://www.rfc-editor.org/rfc/rfc9110.html#name-preconditions) (RFC Editor)
- [The Idempotency-Key HTTP Header Field](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07) (IETF Internet-Draft，进行中的工作)