@mmgt-cloud/sync-client
v1.1.1
Published
Offline-first TypeScript client for the MMGT Cloud sync service.
Maintainers
Readme
@mmgt-cloud/sync-client
Offline TypeScript client for Sync contract 2026-09-05. This contract replaces the previous numeric-cursor API in the existing service. Deploy compatible Sync, SDK, and application versions together. Release status is tracked in the remediation ledger.
pnpm add @mmgt-cloud/sync-clientPublic npm installation requires no GitHub credentials. Use the SDK version matching the deployed contract; a source checkout is not evidence that its new version has already been published.
Authenticated identity and storage
import { IndexedDBSyncStore, SyncClient } from "@mmgt-cloud/sync-client";
export function createNotesClient(appId: string, userId: string, baseUrl: string, getToken: () => string) {
return new SyncClient({
appId,
userId, // The user confirmed by Auth for this application.
baseUrl, // e.g. the Sync URL exported for this application/environment.
tokenProvider: getToken,
store: new IndexedDBSyncStore("application-sync"),
});
}Every store operation explicitly binds application and user. Separate feed filters have separate opaque cursors. IndexedDB commits pages and cursors together and acknowledges success only after the transaction completes. Stale responses cannot regress records or overwrite another tab's cursor. Versions are decimal strings, including values larger than JavaScript's safe integer limit.
WebLocksSyncLeaderLock can reduce duplicate work across tabs; correctness does not depend on Web Locks. The fallback relies on transactional store fencing and stable server mutation IDs. A custom SyncLocalStore must implement the same atomic contract.
On logout or account change call client.close() and create a client for the next authenticated identity. cancelPending() cancels current work while allowing another run for the same identity. setTokenProvider() updates the credential source without recreating the store. Bind providers to the correct identity; never reuse another account's token with an old client. Closing a client preserves that account's pending writes.
Workspace grants
Set grantProvider(scope) when using workspace collections. It calls your application's backend, which checks membership and asks Sync for a short-lived grant. Send user credentials only from the browser. App keys and sync:grant issuance belong to the backend; the signing secret remains in Sync.
A grant permits explicit collection–workspace pairs with read/write rights for at most five minutes. The SDK obtains a grant for each request. Renewing a grant does not replace the scope cursor. Unauthorized scope fragments are rejected rather than silently omitted.
Writes, runs, and partial progress
export async function saveNote(client: SyncClient) {
await client.write({
collection: "notes",
recordId: "note-1",
op: "upsert",
baseVersion: "0", // Required for creation in a reject_stale collection.
data: { title: "Offline note" },
});
return client.sync({ collections: ["notes"], limit: 100, maxPages: 100 });
}For strict CAS, updates and deletes use the exact current record version. LWW follows server write order. Collections validate data against their configured JSON Schema 2020-12.
sync() first verifies/drains the selected feed or rebuilds it from a snapshot. It then sends eligible outbox mutations and pulls committed changes. Runs support AbortSignal and return explicit hasMore when their page limit or pending work remains. A push result never advances the pull cursor. A fixed server watermark bounds each pagination pass.
Each mutation persists its original delivery client ID, so retries after restart preserve dedupe identity. Mutations for a record are sent in queue order. replaceLocalMutation() atomically replaces only never-attempted local writes; uncertain deliveries remain until their outcome is recovered. A network/server failure can follow a partially committed batch: retain and retry the same mutation IDs and content. Missing results remain pending. Permanent rejected outcomes move to local review state and are not retried automatically.
Snapshot and migration recovery
Snapshots are frozen for 15 minutes. The SDK stages pages, resumes after restart, and atomically switches server-derived records with the final cursor. Completed snapshot watermarks preserve authoritative presence and absence across overlapping feeds, even for records never seen locally. Newer records received through another overlapping feed are preserved. 410 cursor_expired starts recovery; affected pending writes move to review with their payloads intact. An expired snapshot starts again. 400 cursor_scope_mismatch is a separate contract error; use client.rebuild() for deliberate recovery after a configuration/signing-key change.
The legacy IndexedDB database lacks reliable user-scoped keys. It is upgraded without deleting pending data. If it contains legacy work, explicitly provide { legacyIdentity: { appId, userId } } to IndexedDBSyncStore only after confirming that database's original authenticated owner. Unknown ownership blocks migration with a descriptive error. Legacy mutations/conflicts are preserved as reconciliation items; the old shared numeric cursor and server cache are not imported. Close old SDK tabs if they block the database upgrade.
Review and reconciliation
listLocalConflicts() includes kind: "conflict" | "rejected" | "reconciliation", the original payload, and available server version/error metadata. Display these items so users can compare their work with the rebuilt record.
resolveLocalConflict(id, replacement) atomically creates a fresh mutation and removes the issue. Supply the user's chosen data and current CAS version. clearLocalConflicts(ids) explicitly discards selected local issues. Neither operation changes unrelated accounts' queues.
Local verification
pnpm --filter @mmgt-cloud/sync-client lint
pnpm --filter @mmgt-cloud/sync-client test
pnpm --filter @mmgt-cloud/sync-client buildThe service's README defines grant issuance, endpoint/error contracts, retention, and local rollout requirements.
writeBatch(mutations) commits a local import in one IndexedDB transaction.
Other tabs see all queued items together. Server delivery still handles each
mutation independently. Custom SyncLocalStore implementations must implement
addOutboxBatch with the same atomic guarantee. Empty, whitespace-padded or NUL
scope fragments are rejected; only sorting and deduplication are canonicalized.
IndexedDB schema v4 introduces durable snapshot floors. Upgrading v3 resets feed cursors and unfinished snapshot pages once, retaining records until recovery, queue order, original delivery IDs and conflicts. Existing pending writes require reconciliation after the new snapshot; reopening v4 does not reset its cursors. This source change still requires the npm patch and new stage/prod acceptance recorded under NSDK-05.
