@syncular/client 0.15.35 → 0.15.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,14 +54,48 @@ The handle exposes the same logical API as `SyncClient` (subscribe /
54
54
  mutate / sync / query / conflicts / …), every method a promise. It
55
55
  acquires the Web Locks leader lock *before* spawning the worker — one
56
56
  core per origin. Wake-ups are handled inside the worker (`autoSync`,
57
- SPEC §8.4: the sync-needed signal is host-driven and the worker IS the
58
- host); the main thread gets `onSyncNeeded` / `onConflict` / `onSynced`
57
+ SPEC §8.4); the supported page-level realtime supervisor owns reconnect and
58
+ resume policy. The main thread gets `onSyncNeeded` / `onConflict` / `onSynced`
59
59
  events for rendering.
60
60
 
61
61
  **Ephemeral in-memory mode is EXPLICIT.** `openWasmDatabase()` returns an
62
62
  in-memory sqlite-wasm database for tests, demos and SSR. Nothing
63
63
  persists, on purpose, and that is the only main-thread mode.
64
64
 
65
+ ## Supported realtime lifecycle
66
+
67
+ Register subscriptions, then install the cross-host supervisor:
68
+
69
+ ```ts
70
+ import {
71
+ browserConnectivitySignal,
72
+ documentLifecycleSignal,
73
+ installRealtimeSupervisor,
74
+ } from '@syncular/client';
75
+
76
+ await handle.subscribe({ id: 'todos', table: 'todos', scopes });
77
+ installRealtimeSupervisor(handle, {
78
+ connectivity: browserConnectivitySignal(),
79
+ lifecycle: documentLifecycleSignal(),
80
+ // Encrypted/locked apps also pass their active/preflight signal.
81
+ protection,
82
+ });
83
+ ```
84
+
85
+ It owns one connection attempt, retries initial failure and later socket loss
86
+ with bounded exponential jitter, suspends while offline/background/protected,
87
+ runs `syncUntilIdle()` before reporting `connected`, and cancels before
88
+ `close()`. `realtimeSupervisorSnapshot()` and
89
+ `subscribeRealtimeSupervisor()` expose only `phase`, `attempt`, and the bounded
90
+ library delay for UI/diagnostics—never raw transport prose or identities.
91
+
92
+ The lower-level `connectRealtime()` is idempotent while connected and
93
+ single-flight while connecting; disconnect invalidates an in-flight result so
94
+ it cannot install a stale socket. It remains available for custom host loops.
95
+ HTTP rounds still work with no socket, but do not imply continuous convergence:
96
+ a host trigger must actually run them. See the complete
97
+ [realtime lifecycle guide](https://syncular.dev/concepts-realtime/).
98
+
65
99
  ## Multi-tab followers (TODO 3.2, REVISE B3)
66
100
 
67
101
  By default, every tab of the same origin shares ONE core:
@@ -312,9 +346,15 @@ const result = await client.rebootstrapLocalData({
312
346
  The reset, durable idempotency marker, and optimistic outbox replay are one
313
347
  SQLite transaction. An interruption therefore leaves either the old
314
348
  projection or the fully reset projection with pending offline work still
315
- visible. Reusing the same id returns `alreadyApplied: true`. The counts-only
316
- result reports retained commits and reset subscriptions without exposing ids,
317
- rows, scopes, or clinical values.
349
+ visible. Reusing the same id returns `alreadyApplied: true` together with the
350
+ original `retainedCommits` and `resetSubscriptions` counts. The core stores
351
+ that counts-only receipt atomically with the reset, so an application crash
352
+ after the SQLite commit but before its own acknowledgement does not turn the
353
+ retry into a misleading zero-impact result. Markers written before Syncular
354
+ 0.15.36 cannot reconstruct their historical counts and preserve the former
355
+ zero-count replay behavior. A malformed or unreadable persisted receipt fails
356
+ closed with the sanitized `sync.local_corrupt` client-local code and performs
357
+ no reset. No receipt exposes ids, rows, scopes, or clinical values.
318
358
 
319
359
  Worker, Tauri, and React Native adapters strictly decode the exact result
320
360
  shape before returning it. Missing or additional fields, a non-boolean
package/dist/client.js CHANGED
@@ -17,6 +17,7 @@ import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFrom
17
17
  import { singleOwnerLock, } from './leader-lock.js';
18
18
  import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
19
19
  import { compileLocalDataRebootstrap, localDataRebootstrapMetaKey, } from './local-rebootstrap.js';
20
+ import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, } from './local-rebootstrap-receipt.js';
20
21
  import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
21
22
  import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
22
23
  import { assertReadOnlyQuery } from './query-guard.js';
@@ -96,6 +97,8 @@ export class SyncClient {
96
97
  #conflicts = [];
97
98
  #rejections = [];
98
99
  #socket;
100
+ #realtimeConnectPromise;
101
+ #realtimeGeneration = 0;
99
102
  #pendingRound;
100
103
  #needsPull = false;
101
104
  #syncing = false;
@@ -314,8 +317,7 @@ export class SyncClient {
314
317
  async close() {
315
318
  this.#devtoolsUnregister?.();
316
319
  this.#devtoolsUnregister = undefined;
317
- this.#socket?.close();
318
- this.#socket = undefined;
320
+ this.disconnectRealtime();
319
321
  this.#abortPendingRound('client closed mid-round');
320
322
  await this.#lease?.release();
321
323
  this.#lease = undefined;
@@ -1509,11 +1511,13 @@ export class SyncClient {
1509
1511
  this.#requireActive();
1510
1512
  const rebootstrapId = compileLocalDataRebootstrap(input);
1511
1513
  const metaKey = localDataRebootstrapMetaKey(rebootstrapId);
1512
- if (getMeta(this.#db, metaKey) !== undefined) {
1514
+ const persistedReceipt = getMeta(this.#db, metaKey);
1515
+ if (persistedReceipt !== undefined) {
1516
+ const receipt = decodeLocalDataRebootstrapReceipt(persistedReceipt);
1513
1517
  return {
1514
1518
  alreadyApplied: true,
1515
- retainedCommits: 0,
1516
- resetSubscriptions: 0,
1519
+ retainedCommits: receipt.retainedCommits,
1520
+ resetSubscriptions: receipt.resetSubscriptions,
1517
1521
  };
1518
1522
  }
1519
1523
  if (this.#schemaFloor !== undefined) {
@@ -1536,7 +1540,10 @@ export class SyncClient {
1536
1540
  for (const commit of pending) {
1537
1541
  this.#applyOperationsLocally(commit.operations, batch);
1538
1542
  }
1539
- setMeta(this.#db, metaKey, 'v1');
1543
+ setMeta(this.#db, metaKey, encodeLocalDataRebootstrapReceipt({
1544
+ retainedCommits: pending.length,
1545
+ resetSubscriptions,
1546
+ }));
1540
1547
  });
1541
1548
  }
1542
1549
  catch (error) {
@@ -1933,31 +1940,59 @@ export class SyncClient {
1933
1940
  }
1934
1941
  // -- realtime (§8 client side) ----------------------------------------------
1935
1942
  connectRealtime() {
1936
- return this.#runProtectedAsync(() => this.#connectRealtime());
1943
+ this.#requireActive();
1944
+ if (this.#socket !== undefined)
1945
+ return Promise.resolve();
1946
+ if (this.#realtimeConnectPromise !== undefined) {
1947
+ return this.#realtimeConnectPromise;
1948
+ }
1949
+ const generation = this.#realtimeGeneration;
1950
+ const task = this.#runProtectedAsync(() => this.#connectRealtime(generation));
1951
+ this.#realtimeConnectPromise = task;
1952
+ void task.then(() => {
1953
+ if (this.#realtimeConnectPromise === task) {
1954
+ this.#realtimeConnectPromise = undefined;
1955
+ }
1956
+ }, () => {
1957
+ if (this.#realtimeConnectPromise === task) {
1958
+ this.#realtimeConnectPromise = undefined;
1959
+ }
1960
+ });
1961
+ return task;
1937
1962
  }
1938
- async #connectRealtime() {
1963
+ async #connectRealtime(generation) {
1939
1964
  const connector = this.#config.realtime;
1940
1965
  if (connector === undefined) {
1941
1966
  throw new ClientSyncError('sync.invalid_request', 'no realtime connector configured');
1942
1967
  }
1968
+ let openedSocket;
1943
1969
  const socket = await connector({
1944
1970
  onText: (text) => this.#handleRealtimeText(text),
1945
1971
  onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
1946
1972
  onClose: () => {
1973
+ if (openedSocket === undefined || this.#socket !== openedSocket)
1974
+ return;
1947
1975
  this.#socket = undefined;
1948
1976
  this.#presence.clear(); // §8.6.1: presence is per-connection
1949
1977
  this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
1950
1978
  this.#emitDiagnostics();
1951
1979
  },
1952
1980
  });
1981
+ openedSocket = socket;
1953
1982
  if (this.#securityLifecycle === 'preflight') {
1954
1983
  socket.close();
1955
1984
  throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
1956
1985
  }
1986
+ if (generation !== this.#realtimeGeneration || !this.#started) {
1987
+ socket.close();
1988
+ throw new ClientSyncError('client.realtime_cancelled', 'realtime connection was cancelled before activation');
1989
+ }
1957
1990
  this.#socket = socket;
1958
1991
  this.#emitDiagnostics();
1959
1992
  }
1960
1993
  disconnectRealtime() {
1994
+ this.#realtimeGeneration += 1;
1995
+ this.#realtimeConnectPromise = undefined;
1961
1996
  this.#socket?.close();
1962
1997
  this.#socket = undefined;
1963
1998
  this.#presence.clear(); // §8.6.1: presence is per-connection
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export * from './outbox.js';
29
29
  export * from './outcomes.js';
30
30
  export * from './query-guard.js';
31
31
  export * from './reactive-store.js';
32
+ export * from './realtime-supervisor.js';
32
33
  export * from './schema.js';
33
34
  export * from './sql-tag.js';
34
35
  export * from './state.js';
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ export * from './outbox.js';
29
29
  export * from './outcomes.js';
30
30
  export * from './query-guard.js';
31
31
  export * from './reactive-store.js';
32
+ export * from './realtime-supervisor.js';
32
33
  export * from './schema.js';
33
34
  export * from './sql-tag.js';
34
35
  export * from './state.js';
@@ -0,0 +1,12 @@
1
+ export interface LocalDataRebootstrapReceipt {
2
+ readonly retainedCommits: number;
3
+ readonly resetSubscriptions: number;
4
+ }
5
+ /** Encode only the bounded counts that are safe to replay outside the core. */
6
+ export declare function encodeLocalDataRebootstrapReceipt(receipt: LocalDataRebootstrapReceipt): string;
7
+ /**
8
+ * Decode a committed receipt without leaking its application-owned key. The
9
+ * original counts were not retained by pre-0.15.36 `v1` markers, so those
10
+ * historical repairs keep their former zero-count replay behavior.
11
+ */
12
+ export declare function decodeLocalDataRebootstrapReceipt(value: string): LocalDataRebootstrapReceipt;
@@ -0,0 +1,59 @@
1
+ import { ClientSyncError } from './errors.js';
2
+ const LEGACY_MARKER = 'v1';
3
+ const RECEIPT_VERSION = 2;
4
+ const RECEIPT_KEYS = [
5
+ 'resetSubscriptions',
6
+ 'retainedCommits',
7
+ 'version',
8
+ ];
9
+ function invalidReceipt() {
10
+ throw new ClientSyncError('sync.local_corrupt', 'persisted local rebootstrap receipt is invalid');
11
+ }
12
+ function isCount(value) {
13
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
14
+ }
15
+ /** Encode only the bounded counts that are safe to replay outside the core. */
16
+ export function encodeLocalDataRebootstrapReceipt(receipt) {
17
+ if (!isCount(receipt.retainedCommits) ||
18
+ !isCount(receipt.resetSubscriptions)) {
19
+ return invalidReceipt();
20
+ }
21
+ return JSON.stringify({
22
+ version: RECEIPT_VERSION,
23
+ retainedCommits: receipt.retainedCommits,
24
+ resetSubscriptions: receipt.resetSubscriptions,
25
+ });
26
+ }
27
+ /**
28
+ * Decode a committed receipt without leaking its application-owned key. The
29
+ * original counts were not retained by pre-0.15.36 `v1` markers, so those
30
+ * historical repairs keep their former zero-count replay behavior.
31
+ */
32
+ export function decodeLocalDataRebootstrapReceipt(value) {
33
+ if (value === LEGACY_MARKER) {
34
+ return { retainedCommits: 0, resetSubscriptions: 0 };
35
+ }
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(value);
39
+ }
40
+ catch {
41
+ return invalidReceipt();
42
+ }
43
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
44
+ return invalidReceipt();
45
+ }
46
+ const source = parsed;
47
+ const keys = Object.keys(source).sort();
48
+ if (keys.length !== RECEIPT_KEYS.length ||
49
+ keys.some((key, index) => key !== RECEIPT_KEYS[index]) ||
50
+ source.version !== RECEIPT_VERSION ||
51
+ !isCount(source.retainedCommits) ||
52
+ !isCount(source.resetSubscriptions)) {
53
+ return invalidReceipt();
54
+ }
55
+ return {
56
+ retainedCommits: source.retainedCommits,
57
+ resetSubscriptions: source.resetSubscriptions,
58
+ };
59
+ }
@@ -12,7 +12,11 @@ export declare const INVALID_HOST_RESPONSE_CODE = "client.invalid_host_response"
12
12
  export interface LocalDataRebootstrapInput {
13
13
  readonly rebootstrapId: string;
14
14
  }
15
- /** Privacy-safe acknowledgement; no row or subscription identifiers escape. */
15
+ /**
16
+ * Privacy-safe acknowledgement; no row or subscription identifiers escape.
17
+ * An idempotent replay sets `alreadyApplied` while retaining the first call's
18
+ * exact counts when that repair was first applied by Syncular 0.15.36 or later.
19
+ */
16
20
  export interface LocalDataRebootstrapResult {
17
21
  readonly alreadyApplied: boolean;
18
22
  readonly retainedCommits: number;
@@ -0,0 +1,78 @@
1
+ import type { SecurityLifecycle } from './client.js';
2
+ import type { ClientDiagnosticsConnectivity, ClientDiagnosticsListener, ClientDiagnosticsSnapshot } from './diagnostics.js';
3
+ type CancelTimer = () => void;
4
+ export interface RealtimeSupervisorClient {
5
+ connectRealtime(): Promise<void>;
6
+ disconnectRealtime(): void | Promise<void>;
7
+ syncUntilIdle(maxRounds?: number): unknown | Promise<unknown>;
8
+ diagnosticsSnapshot(): ClientDiagnosticsSnapshot | Promise<ClientDiagnosticsSnapshot>;
9
+ onDiagnostics(listener: ClientDiagnosticsListener): () => void;
10
+ close(): void | Promise<void>;
11
+ }
12
+ export interface RealtimeSupervisorSignal<State> {
13
+ current(): State;
14
+ subscribe(listener: (state: State) => void): () => void;
15
+ }
16
+ export type RealtimeSupervisorLifecycleState = 'active' | 'background' | 'unknown';
17
+ export type RealtimeSupervisorProtectionState = SecurityLifecycle | 'unknown';
18
+ type RealtimeSuspendedPhase = 'offline' | 'background' | 'protected';
19
+ export type RealtimeSupervisorPhase = 'idle' | 'connecting' | 'connected' | 'retrying' | RealtimeSuspendedPhase | 'unsupported' | 'stopped';
20
+ export interface RealtimeSupervisorSnapshot {
21
+ readonly phase: RealtimeSupervisorPhase;
22
+ /** One-based for retries; zero for the initial connection. */
23
+ readonly attempt: number;
24
+ /** Bounded host-policy delay, never server or transport prose. */
25
+ readonly retryDelayMs?: number;
26
+ }
27
+ export interface RealtimeSupervisorOptions {
28
+ /** Host online/offline evidence. Unknown remains connectable and observable. */
29
+ readonly connectivity?: RealtimeSupervisorSignal<ClientDiagnosticsConnectivity>;
30
+ /** Browser/native foreground evidence. Background always suspends the socket. */
31
+ readonly lifecycle?: RealtimeSupervisorSignal<RealtimeSupervisorLifecycleState>;
32
+ /** Publish preflight before draining keys so reconnect stops in the same turn. */
33
+ readonly protection?: RealtimeSupervisorSignal<RealtimeSupervisorProtectionState>;
34
+ /** Deterministic test/host timer seam. */
35
+ readonly schedule?: (callback: () => void, delayMs: number) => CancelTimer;
36
+ readonly random?: () => number;
37
+ readonly initialDelayMs?: number;
38
+ readonly maximumDelayMs?: number;
39
+ }
40
+ export interface RealtimeSupervisorEventTarget {
41
+ addEventListener(type: string, listener: () => void): void;
42
+ removeEventListener(type: string, listener: () => void): void;
43
+ }
44
+ export interface BrowserConnectivitySignalOptions {
45
+ readonly events?: RealtimeSupervisorEventTarget;
46
+ readonly network?: {
47
+ readonly onLine?: boolean;
48
+ };
49
+ }
50
+ export interface DocumentLifecycleSignalOptions {
51
+ readonly events?: RealtimeSupervisorEventTarget;
52
+ readonly document?: {
53
+ readonly visibilityState?: string;
54
+ };
55
+ }
56
+ /** Browser/Tauri-webview connectivity evidence without importing DOM types. */
57
+ export declare function browserConnectivitySignal(options?: BrowserConnectivitySignalOptions): RealtimeSupervisorSignal<ClientDiagnosticsConnectivity>;
58
+ /** Browser/Tauri-webview visibility evidence without importing DOM types. */
59
+ export declare function documentLifecycleSignal(options?: DocumentLifecycleSignalOptions): RealtimeSupervisorSignal<RealtimeSupervisorLifecycleState>;
60
+ /**
61
+ * Supported host policy for Syncular's explicit realtime transport. It owns
62
+ * exactly one connect attempt, runs an explicit catch-up round before claiming
63
+ * connected, retries transient loss with bounded exponential jitter, and
64
+ * suspends across offline, background, or protected-preflight state.
65
+ */
66
+ export declare class RealtimeSupervisor {
67
+ #private;
68
+ constructor(client: RealtimeSupervisorClient, options?: RealtimeSupervisorOptions);
69
+ snapshot(): RealtimeSupervisorSnapshot;
70
+ subscribe(listener: () => void): () => void;
71
+ start(): void;
72
+ stop(): void;
73
+ }
74
+ /** Install one supervisor and make client disposal cancel it before close. */
75
+ export declare function installRealtimeSupervisor<T extends RealtimeSupervisorClient>(client: T, options?: RealtimeSupervisorOptions): T;
76
+ export declare function realtimeSupervisorSnapshot(client: object): RealtimeSupervisorSnapshot;
77
+ export declare function subscribeRealtimeSupervisor(client: object, listener: () => void): () => void;
78
+ export {};