@baukit/sync-client

中文文档
0.18.3发布于 2026/11/07MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 8082

Client-side scheduling, transport, status reporting, and ordering for offline-capable sync loops.

原始使用文档
文档目录14 节
  1. Hybrid logical clock
  2. Clock migration
  3. Scheduler
  4. Retry and backoff
  5. Browser environment
  6. Expo environment
  7. Transport
  8. Status store
  9. Cold start
  10. Push batch ranking
  11. Payload validators
  12. Conformance harness
  13. Tombstone-horizon conformance
  14. Tombstone-horizon migration

@baukit/sync-client

@baukit/sync-client holds client-side mechanics that offline-capable products rebuild around a sync loop: scheduling, transport, status, push ordering, and hybrid logical timestamps. It does not define a sync protocol. Endpoint paths, payloads, conflict rules, and the sync engine itself stay product-owned, as the offline readiness contract says they must.

The root entry has no runtime dependencies and no React. Timers, foreground state, connectivity, and fetch all arrive by injection, so the same code runs under Node, a browser, and React Native. The optional @baukit/sync-client/expo entry imports react-native and expo-network; neither is loaded by the root entry. The @baukit/sync-client/browser entry reads browser globals only when createBrowserSyncEnvironment is called, so importing it under Node is safe.

Hybrid logical clock

Import the clock from the root package or @baukit/sync-client/hlc. It uses the same integer encoding as baukit_sync::hlc, so Rust and TypeScript can compare stored timestamps directly.

import { HybridLogicalClock } from '@baukit/sync-client/hlc';

const clock = await HybridLogicalClock.open('device-a', metadataStore, Date.now);
const localTimestamp = await clock.now();
const afterRemote = await clock.observe(remoteTimestamp);
const state = clock.snapshot();

The encoding is wallTimeMs * 1000 + counter + 1. The added one reserves zero as invalid. Use encodeHybridLogicalTimestamp, decodeHybridLogicalTimestamp, and compareHybridLogicalTimestamps when no stateful clock is needed. Comparison returns null if either input is invalid.

JavaScript can represent integers exactly only through Number.MAX_SAFE_INTEGER, 9_007_199_254_740_991. The last valid HLC value decodes to wall time 9_007_199_254_740 and counter 990. Calls beyond that point reject with HybridLogicalClockError code exceeds_safe_integer and leave committed state unchanged. Within the normal range, counter 1,000 moves the wall component forward by one millisecond and resets the counter to zero.

The device ID and physical clock are caller inputs. The module does not generate device IDs or choose a storage system. Pass no store for an in-memory clock, or implement HlcStorage for product persistence. A stored state is restored only when its device ID matches and its components form a valid encoded timestamp. Missing, corrupt, and foreign state reset to zero. Storage read and write failures reject unchanged.

When storage is present, now and observe run in call order. A later call waits for the prior write to finish. Failed writes do not update the snapshot and do not stop the queue. snapshot therefore reports the last state committed to storage.

Clock migration

Redemut can replace its local HLC import with @baukit/sync-client/hlc. The existing open(deviceId, storage, physicalClock) call and hlc-state record remain compatible. No stored timestamp or state rewrite is needed. Redemut keeps its IndexedDB and SQLite adapters, device ID creation, merge rules, server compare-and-swap, and cursor protocol.

Scheduler

SyncScheduler takes one opaque run() callback and a SyncSchedulerEnvironment. It guarantees a single active run: a trigger arriving mid-run joins that run instead of starting a second one.

const scheduler = new SyncScheduler(() => engine.run(), environment, { intervalMs: 60_000 });
scheduler.start();

trigger() starts a run or joins the active one. requestFollowUp() is what a local write calls: if a run is already active it queues exactly one replay after that run settles, so a write made while the engine was mid-push is never left unsent. Repeated follow-up requests still collapse into one replay. stop() drops the queued replay along with the interval and subscriptions.

The environment reports foreground state and connectivity. The scheduler runs on start, when the app returns to the foreground, when connectivity returns, and on the interval. It installs no interval while backgrounded and ignores connectivity events there.

A failing run reaches onError and does not stop scheduling. Without retry, the next attempt waits for the next trigger.

Retry and backoff

Pass retry to retry a failed run after a delay. The product decides which failures are worth a retry and how long to wait, because only it knows its error types; the scheduler owns the attempt count, the wait, and waking it early. fullJitterBackoffMs from @baukit/api-runtime/backoff is the Baukit delay policy. The root entry of this package still imports nothing, so the product passes the policy in:

import { fullJitterBackoffMs } from '@baukit/api-runtime/backoff';
import { SyncRateLimitError, SyncScheduler, SyncTransportError } from '@baukit/sync-client';

const scheduler = new SyncScheduler(() => engine.syncOnce(), environment, {
  retry: {
    maxRetries: 4,
    delayMs: (error, retryIndex) => {
      if (error instanceof SyncRateLimitError) return Math.max(0, Date.parse(error.retryAt) - Date.now());
      if (error instanceof SyncTransportError && !error.retryable) return null;
      return fullJitterBackoffMs(retryIndex, { baseDelayMs: 500, maxDelayMs: 30_000 });
    },
    onRetryScheduled: ({ delayMs }) => status.showRetryAt(Date.now() + delayMs),
  },
});

After a failed run the scheduler asks delayMs(error, retryIndex). null means no retry. A number is the wait in milliseconds, and onRetryScheduled hears about it before the wait starts. The retry index counts from 0 and restarts after a success. After maxRetries retries the scheduler waits for the next trigger. A delay that is negative or not finite, or a delayMs that throws, reaches onError and ends the retries. A maxRetries that is not a non-negative integer throws a RangeError from the constructor.

The wait is a one-shot timer built from the environment's setInterval, so no environment changes. Any trigger during the wait ends it at once and restarts the retry index at 0: a manual trigger(), the interval, returning to the foreground, or connectivity returning while in the foreground. A follow-up request during the wait does not end it, because the retry will send the same writes. stop() ends the wait without another run, and a wait that ends while the app is backgrounded runs nothing until the app returns.

If the engine waits inside its own retry delay instead, pass onRecoverySignal to the scheduler and wake that delay there. The scheduler calls it with active before a foreground-triggered run and with online before an online-triggered run. Online recovery is reported even while the scheduler is backgrounded, although the scheduler still waits for foreground before starting another run. Retry wake-up belongs in this scheduler option, not in a decorated environment, because the environment does not know whether an engine has a retry delay.

const scheduler = new SyncScheduler(() => engine.retryNow(), environment, {
  onRecoverySignal: () => engine.wakeRetryDelay(),
});

Browser environment

Browser products can import an environment that maps document visibility, online events, and intervals to the scheduler contract:

import { SyncScheduler } from '@baukit/sync-client';
import { createBrowserSyncEnvironment } from '@baukit/sync-client/browser';

const scheduler = new SyncScheduler(() => engine.retryNow(), createBrowserSyncEnvironment(), {
  onRecoverySignal: () => engine.wakeRetryDelay(),
});

isActive is true only while document.visibilityState is visible. A visibility change reports the new active state, and an online event reports a connectivity wake signal. Each returned cleanup function may be called more than once and removes its listener once.

Tests and non-browser hosts can inject { document, window, timers }. Calling the factory without injections outside a browser throws a specific missing-document or missing-window error. Importing the entry never reads either global. The browser entry has no React, Dexie, or sync-engine import.

Expo environment

Expo products can use the supplied environment instead of repeating AppState and network wiring:

import { SyncScheduler } from '@baukit/sync-client';
import { createExpoSyncEnvironment } from '@baukit/sync-client/expo';

const scheduler = new SyncScheduler(() => engine.run(), createExpoSyncEnvironment());

The environment treats only AppState active as foreground. On native platforms it reads the initial state from expo-network and triggers only when connectivity changes from unusable to usable. Expo web uses the browser online event. Tests can pass { timers } to replace setInterval and clearInterval. react-native and expo-network are optional peers because non-Expo consumers never import this subpath.

Transport

The fetch-backed SyncTransport constructor wraps an injected fetch and auth header provider. It classifies failures as follows:

  • SyncAuthError for 401, never retryable
  • SyncPartitionMismatchError when the body carries the configured partition-mismatch code
  • SyncRateLimitError for 429, always retryable and carrying retryAt plus the raw retryAfter header
  • SyncNetworkError when fetch or response-body reading fails, always retryable
  • SyncServerError for other HTTP failures, retryable for 408 and 5xx
  • SyncPayloadCompatibilityError when a successful response is not valid JSON, never retryable

Retry-After accepts integer delta seconds and HTTP dates. Missing, invalid, negative, and past values use now + retryAfterFallbackMs. The default fallback is 60 seconds. Tests and products with another policy can inject now and retryAfterFallbackMs in the fetch-backed constructor.

Every request has a deadline, requestTimeoutMs, 30 seconds by default (DEFAULT_REQUEST_TIMEOUT_MS). It covers the fetch and the body read. When it passes, the transport aborts the request's signal and rejects with a retryable SyncNetworkError, so the scheduler's next run opens a new connection instead of waiting on a pooled one whose packets get dropped. Both constructors take the setting. A prebuilt request function receives the deadline signal and should pass it on to its fetch. A caller's own signal still aborts the request.

Auth headers resolve per attempt, so a token refreshed between retries is picked up. The partition header is sent only when the caller passes a partitionId, and both its name and the mismatch code are configurable. The transport never names an endpoint: callers pass the path and the response type.

A product API client often already owns the base URL, token refresh, authentication recovery, response decoding, and its error policy. Pass its decoded request function instead of duplicating that work in the transport:

const transport = new SyncTransport({
  request: apiClient.request.bind(apiClient),
  partitionHeader: 'X-Product-Profile-Id',
});

await transport.request('/api/v1/sync/push', {
  method: 'POST',
  body: { changes },
  partitionId: profileId,
});

This mode builds the relative path, query string, JSON body, and partition header, then delegates. The request function owns decoding and errors, which keeps the product API client's existing auth recovery intact. Use the fetch-backed constructor when the product wants package-owned response classification.

Status store

SyncStatusStore is the observable sync status, with subscribe/getSnapshot and no state library. It encodes rules the offline readiness contract requires and products keep getting wrong:

  • an actionable rejection outranks a transport failure, so a failed run over existing conflicts still reads attention;
  • unsent work outranks a bare error, so a failed run with pending changes reads pending, not error;
  • the first run is the initial pull; later runs are settled activity;
  • a failure updates lastAttemptAt without changing lastSuccessAt or clearing pendingCount.

Call setSyncing(attemptAt) when an attempt starts. setIdle(successAt) records a successful attempt and clears work only after the product's success predicate passes. setFailure accepts machine-readable metadata with one of these kinds: auth, partition_mismatch, rate_limited, network, server, payload_compatibility, or local_apply. Product copy remains in the separate error field.

Rate-limit metadata contains retryAt. Other retryable failures can pass retryAt in the SyncFailureUpdate argument or call setRetrying(retryAt). Snapshots expose both retrying and retryAt, so a screen can distinguish scheduled retries and rate limits from an offline state.

refreshRevision increments whenever a run delivers a new authoritative snapshot. deriveLocalStoreReadiness compares it against what a consumer has actually rendered, which is how a cold start avoids flashing an empty state over data that is still arriving. deriveInitialSyncState maps a snapshot onto unknown, syncing, offline-cached, sync-error-cached, or settled. Only a first-run network failure maps to offline-cached.

The store is generic over its attention item and defaults to the existing { entityType, entityId } shape. This keeps one store API while allowing a product to retain the rejection details its UI needs:

interface ProductAttention {
  objectEntityType: string;
  objectEntityId: string;
  rejections: readonly Rejection[];
}

const status = new SyncStatusStore<ProductAttention>();
status.setAttention(items, pendingCount);

Cold start

Persist both attempt and success timestamps, then hydrate with an object:

status.hydrate({
  lastAttemptAt: persisted.lastAttemptAt,
  lastSuccessAt: persisted.lastSuccessAt,
  attention: persisted.attention,
  pendingCount: persisted.pendingCount,
});

Push batch ranking

rankPushBatch orders one push batch so a parent is never sent after its children.

const rank = dependencyRankByOrder(['container', 'group', 'leaf']);
const batch = rankPushBatch(pending, {
  rank,
  batchSize: 200,
  isHeldBack: (change) => hasUnsentChildren(change),
});

Changes are grouped by entityType:entityId. Each group keeps the position of its first occurrence, so foreign-key order survives coalescing, and carries the newest change so the request sends current data. coveredChangeIds lists every queued change the group settles, because server outcomes are keyed by entity rather than by outbox row: one outcome clears all of them.

isHeldBack is the other half of the rule. A parent whose required children are still unsent is dropped from the batch rather than reordered, and retried on a later run once those children have been accepted. Sending it now would show the server a complete parent while a required child is missing. Held-back entities do not consume batch-size slots.

Entity names, the dependency order, and the hold-back predicate are all product inputs. Baukit knows only that a rank exists.

Payload validators

Call validatePushOutcomeCoverage before acknowledging any submitted change. Pass one key reader for submitted entities and one for outcomes, then combine all accepted and rejected outcomes in the second argument. The function throws SyncPayloadCompatibilityError if any submitted entity has no outcome.

Call validatePullPage(currentCursor, page, compare) before following hasMore. Cursors are opaque. The injected comparator defines their order. A regressing cursor, a non-finite comparison, or hasMore without cursor progress throws SyncPayloadCompatibilityError.

commitCursorAfterLocalTransaction runs the supplied local transaction first. It invokes commitCursor only after that promise resolves, and wraps local failures in SyncLocalApplyError. This ordering prevents a failed local apply from skipping remote changes on the next pull.

Conformance harness

@baukit/sync-client/conformance exports createSyncConformanceTests. It returns plain async test cases and has no Vitest or Jest dependency. Register the returned cases with the runner already in the product:

import { createSyncConformanceTests } from '@baukit/sync-client/conformance';

describe('product sync contract', () => {
  for (const testCase of createSyncConformanceTests(productAdapter)) {
    it(testCase.name, testCase.run);
  }
});

The adapter supplies two isolated clients and one fake server per case. It also supplies callbacks for outbox enqueue, listing, and stable pending IDs; atomic submitted-batch settlement; atomic pull application and cursor storage; pending-state reporting; wire encoding and decoding; and fake-server push, pull, seeding, snapshots, and fault injection. Canonical fixture changes contain opaque entity names, values, logical times, dependencies, and tombstones. The adapter maps them to the product's schema and payloads.

The cases cover replay, transaction rollback, paged cursor progress, stalled and regressing cursors, complete push outcomes, late accepted and rejected responses, stale pulls over pending writes, pending state after failures, dependency-safe push order, and two-client convergence with a tombstone. When fullResync is present, five more cases cover monotonic purge horizons, cursor zero, parent and child cleanup, pull-only rows, pending edits, rejection records, reset rollback, reset deferral, and the repeated-stale stop condition. wire.decodePush must call validatePushOutcomeCoverage before returning acknowledged rows. It returns decoded rejectedRows when a rejection contains an authoritative server row.

local.applySubmittedBatchOutcome receives the exact submitted rows, the raw push response, the decoded acknowledged rows and rejections, and any decoded rejected server rows. In one storage transaction it must settle only pending IDs covered by that submission, record actionable rejections, apply safe rejected rows, and perform any accepted-revision stamping. A newer pending row for the same entity blocks an older response from replacing its local payload. Revision stamping is product policy. The harness does not require last-writer-wins or a particular revision type, but a stamp from an older submission must not replace the payload of a newer local write.

The pull callback must keep a pending local row when an older or equal remote revision arrives, while committing the page cursor in the same transaction. Its injected failure must roll back both the staged row and cursor.

Tombstone-horizon conformance

Adapters for products with finite tombstone retention set the optional fullResync field. Products without it receive the original case set and need no adapter change.

zeroCursor is the product's explicit full-rebuild cursor. decodeStaleCursor maps the product's wire error onto { code: 'resync_required', horizon }. The product response must keep the code and horizon field stable. For the HTTP 409 envelope, the source value is error.details.horizonRevision.

isResetSafe is the production policy hook. setResetSafe lets the isolated test client enter a safe or unsafe state. reset clears server-backed rows and writes zeroCursor in one transaction, while keeping pending mutations, rejection records, and their repairable rows. It must honor failAfterRows inside that transaction so the rollback case can inspect the unchanged database. readResetCount lets the suite prove that an immediate second stale response does not start a loop.

The fake-server callbacks stay test-only. purgeTombstones removes the requested fixture rows and returns the owner's current horizon. rejectNextZeroCursor injects a protocol violation for the stop-condition case. Entity registration, deletion SQL, retention periods, and foreign-key order remain in the product.

After reset, the suite submits retained pending work and pulls from zero. The full snapshot must not overwrite a newer pending edit. It must remove server-backed parent, child, and pull-only rows whose tombstones are no longer available.

Tombstone-horizon migration

Existing adapters can omit fullResync; their types and cases are unchanged. A product that enables finite tombstone retention should add the callbacks as one unit. Keep its current cursor type and HTTP envelope, decode the horizon into SyncConformanceStaleCursor, and replace any split "clear rows, then set cursor" path with one local transaction before enabling these cases.

版本与发布记录

@baukit/sync-client 共发布 67 个版本,首次发布于 2026/08/26;过去 12 个月发布了 67 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.18.3latest2026/11/07今天
0.18.22026/11/061 天前
0.18.12026/11/062 天前
0.18.02026/11/052 天前
0.17.12026/11/034 天前
0.17.02026/11/025 天前

关于 @baukit/sync-client 的常见问题

@baukit/sync-client 是什么?

Client-side scheduling, transport, status reporting, and ordering for offline-capable sync loops.

如何安装 @baukit/sync-client?

在项目目录运行 npm install @baukit/sync-client,或使用 pnpm add / yarn add / bun add。

@baukit/sync-client 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@baukit/sync-client 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@baukit/sync-client 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@baukit/sync-client 可以商用吗?

@baukit/sync-client 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@baukit/sync-client 的下载量有多少?

根据 npm 官方下载统计,@baukit/sync-client 在 2026/10/08 – 2026/11/06 这 30 天内被下载 8,082 次,日均约 269 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@baukit/sync-client 还在维护吗?

最新版本 0.18.3 发布于 2026/11/07(今天);至今共发布 67 个版本,其中过去 12 个月发布了 67 个;首次发布于 2026/08/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。