@syncular/client 0.2.1 → 0.3.1

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.
@@ -8,17 +8,18 @@
8
8
  * holding the lock). The returned {@link SyncClientHandle} is a thin, fully
9
9
  * async proxy over the `worker-protocol` RPC.
10
10
  *
11
- * With `multiTab: true`, a tab that LOSES the election does not resolve to a
12
- * dead not-leader handle: it becomes a FOLLOWER (`role === 'follower'`) that
13
- * proxies every call to the leader tab over a BroadcastChannel (see
14
- * `multi-tab.ts`). When the leader tab closes, its lock releases; the
15
- * followers contest, the winner PROMOTES in place — spawns the worker over
16
- * the persisted OPFS database and re-announces — and the handle's `role`
17
- * flips to `'leader'` with `onRoleChange` firing. The same handle object is
18
- * kept across the transition so React bindings hold a stable reference.
11
+ * Multi-tab is the DEFAULT (RFC 0002 §2.4 the follower path is
12
+ * conformance-covered): a tab that LOSES the election becomes a FOLLOWER
13
+ * (`role === 'follower'`) that proxies every call to the leader tab over a
14
+ * BroadcastChannel (see `multi-tab.ts`). When the leader tab closes, its
15
+ * lock releases; the followers contest, the winner PROMOTES in place —
16
+ * spawns the worker over the persisted OPFS database and re-announces — and
17
+ * the handle's `role` flips to `'leader'` with `onRoleChange` firing. The
18
+ * same handle object is kept across the transition so React bindings hold a
19
+ * stable reference.
19
20
  *
20
- * With `multiTab` off (default), behavior is unchanged: the loser is an
21
- * `isLeader === false` handle whose calls reject with `client.not_leader`.
21
+ * With `multiTab: false`, the loser is an `isLeader === false` handle whose
22
+ * calls reject with `client.not_leader` (the single-tab contract).
22
23
  */
23
24
  import type { WakeReason } from '@syncular/core';
24
25
  import type { BlobRef, CachedBlob } from './blob';
@@ -35,6 +36,7 @@ import type {
35
36
  WindowState,
36
37
  } from './client';
37
38
  import type { SqlRow, SqlValue } from './database';
39
+ import { registerDevtools } from './devtools';
38
40
  import { ClientSyncError } from './errors';
39
41
  import { InvalidationEmitter, type InvalidationListener } from './invalidation';
40
42
  import {
@@ -92,10 +94,11 @@ export interface SyncClientHandleConfig {
92
94
  readonly leaderLock?: LeaderLock;
93
95
  readonly lockName?: string;
94
96
  /**
95
- * Multi-tab followers (TODO 3.2). When true, a tab that loses the leader
96
- * election becomes a FOLLOWER that proxies to the leader over a
97
- * BroadcastChannel, and contests + promotes when the leader closes. When
98
- * false (default), the loser is a dead `isLeader === false` handle.
97
+ * Multi-tab followers (TODO 3.2). On by default: a tab that loses the
98
+ * leader election becomes a FOLLOWER that proxies to the leader over a
99
+ * BroadcastChannel, and contests + promotes when the leader closes. Set
100
+ * false for the single-tab contract — the loser is a dead
101
+ * `isLeader === false` handle rejecting with `client.not_leader`.
99
102
  */
100
103
  readonly multiTab?: boolean;
101
104
  /** Cross-tab channel factory (default `BroadcastChannel`); injectable for tests. */
@@ -170,6 +173,7 @@ export class SyncClientHandle {
170
173
  readonly #invalidation: InvalidationEmitter;
171
174
  readonly #presence: Set<(scopeKey: string) => void>;
172
175
  readonly #roleListeners: Set<(role: HandleRole) => void>;
176
+ readonly #devtoolsUnregister: () => void;
173
177
  #closed = false;
174
178
 
175
179
  /** @internal — use {@link createSyncClientHandle}. */
@@ -189,6 +193,20 @@ export class SyncClientHandle {
189
193
  this.#invalidation = internals.invalidation;
190
194
  this.#presence = internals.presence;
191
195
  this.#roleListeners = internals.roleListeners ?? new Set();
196
+ // RFC 0002 §3.2: console introspection — a no-op outside a dev page.
197
+ this.#devtoolsUnregister = registerDevtools({
198
+ kind: 'handle',
199
+ ref: this,
200
+ clientId: () => this.#clientId,
201
+ role: () => this.#role,
202
+ outbox: async () => (await this.pendingCommits()).length,
203
+ subscriptions: () => this.subscriptions(),
204
+ conflicts: async () => (await this.conflicts()).length,
205
+ rejections: async () => (await this.rejections()).length,
206
+ syncNeeded: () => this.syncNeeded(),
207
+ upgrading: () => this.upgrading(),
208
+ onInvalidate: (listener) => this.onInvalidate(listener),
209
+ });
192
210
  }
193
211
 
194
212
  /** @internal — swap this handle from follower to leader (promotion). */
@@ -266,7 +284,8 @@ export class SyncClientHandle {
266
284
  new ClientSyncError(
267
285
  NOT_LEADER_CODE,
268
286
  'this tab is not the leader — another tab owns the syncular ' +
269
- 'core for this origin (enable multiTab for follower proxying)',
287
+ 'core for this origin (this handle opted out of follower ' +
288
+ 'proxying with multiTab: false)',
270
289
  ),
271
290
  );
272
291
  }
@@ -304,6 +323,16 @@ export class SyncClientHandle {
304
323
  return this.#call('mutate', [mutations]);
305
324
  }
306
325
 
326
+ /** Partial-update convenience: read-merge-write one full-row upsert. */
327
+ patch(
328
+ table: string,
329
+ rowId: string,
330
+ partial: Readonly<Record<string, unknown>>,
331
+ options?: { readonly baseVersion?: number },
332
+ ): Promise<string> {
333
+ return this.#call('patch', [table, rowId, partial, options]);
334
+ }
335
+
307
336
  sync(): Promise<SyncSummary> {
308
337
  return this.#call('sync', []);
309
338
  }
@@ -394,6 +423,7 @@ export class SyncClientHandle {
394
423
  async close(): Promise<void> {
395
424
  if (this.#closed) return;
396
425
  this.#closed = true;
426
+ this.#devtoolsUnregister();
397
427
  if (this.#follower !== undefined) {
398
428
  this.#follower.close();
399
429
  this.#follower = undefined;
@@ -574,9 +604,9 @@ function fireConfigCallbacks(
574
604
  /**
575
605
  * Acquire leadership, spawn the worker, initialize the core inside it.
576
606
  *
577
- * With `multiTab` off: a losing tab resolves to a dead not-leader handle.
578
- * With `multiTab` on: a losing tab becomes a FOLLOWER proxying to the leader,
579
- * and promotes itself if the leader later closes.
607
+ * With `multiTab` on (the default): a losing tab becomes a FOLLOWER proxying
608
+ * to the leader, and promotes itself if the leader later closes. With
609
+ * `multiTab: false`: a losing tab resolves to a dead not-leader handle.
580
610
  */
581
611
  export async function createSyncClientHandle(
582
612
  config: SyncClientHandleConfig,
@@ -609,8 +639,8 @@ export async function createSyncClientHandle(
609
639
  }
610
640
 
611
641
  // ---- Lost the election. ----
612
- if (config.multiTab !== true) {
613
- // Legacy single-tab contract: a dead not-leader handle.
642
+ if (config.multiTab === false) {
643
+ // Opted-out single-tab contract: a dead not-leader handle.
614
644
  return new SyncClientHandle({
615
645
  role: 'follower',
616
646
  clientId: '',
@@ -650,7 +680,7 @@ async function bootLeader(
650
680
  handleRef.handle?.__dispatchEvent(event);
651
681
  };
652
682
  const makeBridge =
653
- config.multiTab === true
683
+ config.multiTab !== false
654
684
  ? (
655
685
  clientId: string,
656
686
  invoke: (
@@ -743,7 +773,7 @@ async function bootFollower(
743
773
  fireConfigCallbacks(config, event);
744
774
  handle.__dispatchEvent(event);
745
775
  },
746
- ...(config.multiTab === true
776
+ ...(config.multiTab !== false
747
777
  ? {
748
778
  makeBridge: (
749
779
  clientId: string,
@@ -113,6 +113,13 @@ export interface WorkerApi {
113
113
  /** §4.8 completeness oracle (I3): the windowed-in units for a base. */
114
114
  windowState(base: WindowBase): WindowState;
115
115
  mutate(mutations: readonly MutationInput[]): string;
116
+ /** Partial-update convenience: read-merge-write one full-row upsert. */
117
+ patch(
118
+ table: string,
119
+ rowId: string,
120
+ partial: Readonly<Record<string, unknown>>,
121
+ options?: { readonly baseVersion?: number },
122
+ ): string;
116
123
  sync(): Promise<SyncSummary>;
117
124
  syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
118
125
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];