@syncular/client 0.4.0 → 0.5.0

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/src/state.ts CHANGED
@@ -6,6 +6,10 @@
6
6
  */
7
7
  import type { ScopeMap } from '@syncular/core';
8
8
  import type { ClientDatabase } from './database';
9
+ import type { LocalRevision } from './invalidation';
10
+
11
+ export const LOCAL_REVISION_KEY = 'localRevision';
12
+ const MAX_U64 = 18_446_744_073_709_551_615n;
9
13
 
10
14
  export type SubscriptionStatus = 'active' | 'revoked' | 'failed';
11
15
 
@@ -124,3 +128,31 @@ export function setMeta(db: ClientDatabase, key: string, value: string): void {
124
128
  value,
125
129
  ]);
126
130
  }
131
+
132
+ /** Read the durable client-local observer revision (SPEC §7.5). */
133
+ export function getLocalRevision(db: ClientDatabase): LocalRevision {
134
+ const raw = getMeta(db, LOCAL_REVISION_KEY);
135
+ if (raw === undefined) return 0n;
136
+ if (!/^(0|[1-9][0-9]*)$/.test(raw)) {
137
+ throw new Error(`invalid persisted local revision ${JSON.stringify(raw)}`);
138
+ }
139
+ const revision = BigInt(raw);
140
+ if (revision > MAX_U64) {
141
+ throw new Error(`persisted local revision exceeds u64: ${raw}`);
142
+ }
143
+ return revision;
144
+ }
145
+
146
+ /**
147
+ * Increment the durable revision. The caller MUST own the same transaction as
148
+ * the observer-visible writes represented by the corresponding change batch.
149
+ */
150
+ export function bumpLocalRevision(db: ClientDatabase): LocalRevision {
151
+ const current = getLocalRevision(db);
152
+ if (current === MAX_U64) {
153
+ throw new Error('local revision exhausted u64');
154
+ }
155
+ const next = current + 1n;
156
+ setMeta(db, LOCAL_REVISION_KEY, next.toString());
157
+ return next;
158
+ }
package/src/window.ts CHANGED
Binary file
@@ -9,7 +9,7 @@
9
9
  * same bootstrap, the same RPC, a different SQLite.
10
10
  *
11
11
  * The §8.4 host loop lives HERE: wake-ups (hello/`sync` events, delta
12
- * drops) coalesce into one jittered `syncUntilIdle` round in the worker;
12
+ * drops) coalesce into one intent-driven `syncUntilIdle` round in the worker;
13
13
  * RPC-driven and auto-driven sync rounds serialize on one queue because
14
14
  * the core owns exactly one loop.
15
15
  */
@@ -22,6 +22,7 @@ import {
22
22
  httpSyncTransport,
23
23
  webSocketRealtimeConnector,
24
24
  } from './http';
25
+ import type { CommandEffects, SyncIntent } from './invalidation';
25
26
  import type {
26
27
  RealtimeConnector,
27
28
  SegmentDownloader,
@@ -133,36 +134,67 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
133
134
  return next;
134
135
  }
135
136
 
136
- // -- §8.4 host loop: coalesced, jittered, inside the worker --------------
137
+ // -- SPEC §7.5 host loop: event-driven intents, no interactive polling --
137
138
  let autoSync = true;
138
- let wakeJitterMs = 200;
139
139
  let autoSyncScheduled = false;
140
- function scheduleAutoSync(): void {
140
+ let backgroundTimer: ReturnType<typeof setTimeout> | undefined;
141
+ let backgroundDue = Number.POSITIVE_INFINITY;
142
+
143
+ function runAutoSync(): void {
144
+ autoSyncScheduled = false;
145
+ if (closed || client === undefined) return;
146
+ const running = client;
147
+ void serializedSync(() => running.syncUntilIdle())
148
+ .then((summary) => {
149
+ if (!closed) post({ t: 'event', event: { kind: 'synced', summary } });
150
+ })
151
+ .catch((error: unknown) => {
152
+ if (!closed) {
153
+ post({
154
+ t: 'event',
155
+ event: { kind: 'synced', error: toErrorShape(error) },
156
+ });
157
+ }
158
+ });
159
+ }
160
+
161
+ function consumeSyncIntent(intent: SyncIntent): void {
162
+ if (!autoSync || closed || client === undefined || intent.kind === 'none') {
163
+ return;
164
+ }
165
+ if (intent.kind === 'background') {
166
+ if (autoSyncScheduled) return;
167
+ const due = Date.now() + Math.max(0, intent.delayMs);
168
+ if (backgroundTimer !== undefined && due >= backgroundDue) return;
169
+ if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
170
+ backgroundDue = due;
171
+ backgroundTimer = setTimeout(
172
+ () => {
173
+ backgroundTimer = undefined;
174
+ backgroundDue = Number.POSITIVE_INFINITY;
175
+ if (!autoSyncScheduled) {
176
+ autoSyncScheduled = true;
177
+ runAutoSync();
178
+ }
179
+ },
180
+ Math.max(0, due - Date.now()),
181
+ );
182
+ return;
183
+ }
184
+ if (backgroundTimer !== undefined) {
185
+ clearTimeout(backgroundTimer);
186
+ backgroundTimer = undefined;
187
+ backgroundDue = Number.POSITIVE_INFINITY;
188
+ }
141
189
  if (!autoSync || autoSyncScheduled || closed || client === undefined) {
142
190
  return;
143
191
  }
144
192
  autoSyncScheduled = true;
145
- setTimeout(
146
- () => {
147
- autoSyncScheduled = false;
148
- if (closed || client === undefined) return;
149
- const running = client;
150
- void serializedSync(() => running.syncUntilIdle())
151
- .then((summary) => {
152
- if (!closed)
153
- post({ t: 'event', event: { kind: 'synced', summary } });
154
- })
155
- .catch((error: unknown) => {
156
- if (!closed) {
157
- post({
158
- t: 'event',
159
- event: { kind: 'synced', error: toErrorShape(error) },
160
- });
161
- }
162
- });
163
- },
164
- Math.floor(Math.random() * wakeJitterMs),
165
- );
193
+ queueMicrotask(runAutoSync);
194
+ }
195
+
196
+ function consumeEffects(effects: CommandEffects): void {
197
+ consumeSyncIntent(effects.sync);
166
198
  }
167
199
 
168
200
  function requireClient(): SyncClient {
@@ -215,7 +247,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
215
247
  );
216
248
  }
217
249
  autoSync = config.autoSync ?? true;
218
- wakeJitterMs = config.wakeJitterMs ?? 200;
219
250
 
220
251
  database =
221
252
  overrides.openDatabase !== undefined
@@ -270,8 +301,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
270
301
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
271
302
  onSyncNeeded: (reason) => {
272
303
  post({ t: 'event', event: { kind: 'sync-needed', reason } });
273
- scheduleAutoSync();
304
+ consumeSyncIntent({ kind: 'interactive' });
274
305
  },
306
+ onSyncIntent: consumeSyncIntent,
275
307
  onConflict: (conflict) => {
276
308
  post({ t: 'event', event: { kind: 'conflict', conflict } });
277
309
  },
@@ -284,10 +316,8 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
284
316
  post({ t: 'event', event: { kind: 'presence', scopeKey } });
285
317
  },
286
318
  });
287
- // TODO 3.1 / I1: forward every coalesced apply-batch invalidation to the
288
- // UI thread so `SyncClientHandle.onInvalidate` fires with worker parity.
289
- started.onInvalidate((event) => {
290
- if (!closed) post({ t: 'event', event: { kind: 'invalidate', event } });
319
+ started.onChange((batch) => {
320
+ if (!closed) post({ t: 'event', event: { kind: 'change', batch } });
291
321
  });
292
322
  await started.start();
293
323
  realtimeConnector =
@@ -302,6 +332,10 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
302
332
  )
303
333
  : undefined;
304
334
  client = started;
335
+ // `start()` may discover persisted subscriptions/outbox work. Its callback
336
+ // fires before this worker publishes the initialized client, so consume
337
+ // the durable state once here as well; coalescing makes this a single task.
338
+ if (started.syncNeeded) consumeSyncIntent({ kind: 'interactive' });
305
339
  return { clientId: started.clientId };
306
340
  }
307
341
 
@@ -309,35 +343,26 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
309
343
  subscribe: (input) => requireClient().subscribe(input),
310
344
  unsubscribe: (id) => requireClient().unsubscribe(id),
311
345
  setWindow: async (base, units) => {
312
- await requireClient().setWindow(base, units);
313
- // A window change adds/removes value-sharded subscriptions; a widened
314
- // unit needs a bootstrap pull to become visible. In autoSync mode the
315
- // host loop owns rounds, so schedule one now — otherwise the new unit
316
- // waits for an unrelated server wake-up (§8.4). A no-op change still
317
- // schedules a harmless idempotent round.
318
- scheduleAutoSync();
346
+ const result = await requireClient().setWindowCommand(base, units);
347
+ consumeEffects(result.effects);
319
348
  },
320
349
  windowState: (base) => requireClient().windowState(base),
321
350
  mutate: (mutations) => {
322
- const id = requireClient().mutate(mutations);
323
- // In autoSync mode the host loop owns rounds (§8.4): a local write must
324
- // push without the app orchestrating sync, so schedule a jittered round
325
- // to drain the outbox promptly. `mutate` is synchronous — its outbox
326
- // write has committed before this line — and the round itself serializes
327
- // on SyncClient's operation chain, so no ordering hack is needed here;
328
- // the §8.4 jitter in `scheduleAutoSync` is the only deferral, and it
329
- // coalesces bursts. (Historically this used setTimeout to dodge a
330
- // nested-transaction flake; that race is fixed at the source now — the
331
- // real cause was cross-operation interleaving, serialized in the core.)
332
- scheduleAutoSync();
333
- return id;
351
+ const result = requireClient().mutateCommand(mutations);
352
+ consumeEffects(result.effects);
353
+ return result.value;
334
354
  },
335
355
  patch: (table, rowId, partial, options) => {
336
356
  // Same §8.4 rule as `mutate`: a local write must push without the app
337
- // orchestrating sync, so schedule a jittered round to drain the outbox.
338
- const id = requireClient().patch(table, rowId, partial, options);
339
- scheduleAutoSync();
340
- return id;
357
+ // orchestrating sync, so consume the core's immediate intent.
358
+ const result = requireClient().patchCommand(
359
+ table,
360
+ rowId,
361
+ partial,
362
+ options,
363
+ );
364
+ consumeEffects(result.effects);
365
+ return result.value;
341
366
  },
342
367
  sync: () => {
343
368
  const running = requireClient();
@@ -348,6 +373,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
348
373
  return serializedSync(() => running.syncUntilIdle(maxRounds));
349
374
  },
350
375
  query: (sql, params) => requireClient().query(sql, params),
376
+ querySnapshot: (spec) => requireClient().querySnapshot(spec),
377
+ localRevision: () => requireClient().localRevision,
378
+ statusSnapshot: () => requireClient().statusSnapshot(),
351
379
  conflicts: () => requireClient().conflicts,
352
380
  rejections: () => requireClient().rejections,
353
381
  schemaFloor: () => requireClient().schemaFloor,
@@ -369,6 +397,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
369
397
  },
370
398
  close: async () => {
371
399
  closed = true;
400
+ if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
372
401
  await client?.close();
373
402
  database?.close();
374
403
  client = undefined;
@@ -28,6 +28,8 @@ import type {
28
28
  LeaseState,
29
29
  MutationInput,
30
30
  PresencePeer,
31
+ QueryReadSpec,
32
+ QuerySnapshot,
31
33
  RejectionRecord,
32
34
  SchemaFloor,
33
35
  SubscribeInput,
@@ -38,7 +40,15 @@ import type {
38
40
  import type { SqlRow, SqlValue } from './database';
39
41
  import { registerDevtools } from './devtools';
40
42
  import { ClientSyncError } from './errors';
41
- import { InvalidationEmitter, type InvalidationListener } from './invalidation';
43
+ import {
44
+ ChangeEmitter,
45
+ type ClientChangeListener,
46
+ InvalidationEmitter,
47
+ type InvalidationListener,
48
+ invalidationFromChange,
49
+ type LocalRevision,
50
+ type SyncStatusSnapshot,
51
+ } from './invalidation';
42
52
  import {
43
53
  type LeaderLease,
44
54
  type LeaderLock,
@@ -89,7 +99,6 @@ export interface SyncClientHandleConfig {
89
99
  readonly limits?: SyncClientLimits;
90
100
  /** Worker-side host loop (§8.4); default true. */
91
101
  readonly autoSync?: boolean;
92
- readonly wakeJitterMs?: number;
93
102
  /** Default: Web Locks when available, else single-owner. */
94
103
  readonly leaderLock?: LeaderLock;
95
104
  readonly lockName?: string;
@@ -107,7 +116,7 @@ export interface SyncClientHandleConfig {
107
116
  readonly followerCallTimeoutMs?: number;
108
117
  /** Fires when this handle's role changes (follower → leader on promotion). */
109
118
  readonly onRoleChange?: (role: HandleRole) => void;
110
- readonly onSyncNeeded?: (reason: 'hello' | WakeReason) => void;
119
+ readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
111
120
  readonly onConflict?: (conflict: ConflictRecord) => void;
112
121
  /** A worker-side autoSync round finished (or failed). */
113
122
  readonly onSynced?: (result: {
@@ -171,6 +180,7 @@ export class SyncClientHandle {
171
180
  #core: LeaderCore | undefined;
172
181
  #follower: FollowerLink | undefined;
173
182
  readonly #invalidation: InvalidationEmitter;
183
+ readonly #changes: ChangeEmitter;
174
184
  readonly #presence: Set<(scopeKey: string) => void>;
175
185
  readonly #roleListeners: Set<(role: HandleRole) => void>;
176
186
  readonly #devtoolsUnregister: () => void;
@@ -183,6 +193,7 @@ export class SyncClientHandle {
183
193
  core?: LeaderCore;
184
194
  follower?: FollowerLink;
185
195
  invalidation: InvalidationEmitter;
196
+ changes: ChangeEmitter;
186
197
  presence: Set<(scopeKey: string) => void>;
187
198
  roleListeners?: Set<(role: HandleRole) => void>;
188
199
  }) {
@@ -191,6 +202,7 @@ export class SyncClientHandle {
191
202
  this.#core = internals.core;
192
203
  this.#follower = internals.follower;
193
204
  this.#invalidation = internals.invalidation;
205
+ this.#changes = internals.changes;
194
206
  this.#presence = internals.presence;
195
207
  this.#roleListeners = internals.roleListeners ?? new Set();
196
208
  // RFC 0002 §3.2: console introspection — a no-op outside a dev page.
@@ -235,8 +247,10 @@ export class SyncClientHandle {
235
247
  /* a UI listener must never break event dispatch */
236
248
  }
237
249
  }
238
- } else if (event.kind === 'invalidate') {
239
- this.#invalidation.emit(event.event);
250
+ } else if (event.kind === 'change') {
251
+ this.#changes.emit(event.batch);
252
+ const legacy = invalidationFromChange(event.batch);
253
+ if (legacy !== undefined) this.#invalidation.emit(legacy);
240
254
  }
241
255
  }
242
256
 
@@ -250,6 +264,10 @@ export class SyncClientHandle {
250
264
  return this.#invalidation.on(listener);
251
265
  }
252
266
 
267
+ onChange(listener: ClientChangeListener): () => void {
268
+ return this.#changes.on(listener);
269
+ }
270
+
253
271
  /**
254
272
  * §8.6: subscribe to presence changes — the identical surface as
255
273
  * `SyncClient.onPresence`. Returns an unsubscribe function.
@@ -345,6 +363,20 @@ export class SyncClientHandle {
345
363
  return this.#call('query', [sql, params]);
346
364
  }
347
365
 
366
+ querySnapshot<Row = SqlRow>(
367
+ spec: QueryReadSpec,
368
+ ): Promise<QuerySnapshot<Row>> {
369
+ return this.#call('querySnapshot', [spec]) as Promise<QuerySnapshot<Row>>;
370
+ }
371
+
372
+ localRevision(): Promise<LocalRevision> {
373
+ return this.#call('localRevision', []);
374
+ }
375
+
376
+ statusSnapshot(): Promise<SyncStatusSnapshot> {
377
+ return this.#call('statusSnapshot', []);
378
+ }
379
+
348
380
  conflicts(): Promise<readonly ConflictRecord[]> {
349
381
  return this.#call('conflicts', []);
350
382
  }
@@ -574,9 +606,6 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
574
606
  ...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
575
607
  ...(config.limits !== undefined ? { limits: config.limits } : {}),
576
608
  ...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
577
- ...(config.wakeJitterMs !== undefined
578
- ? { wakeJitterMs: config.wakeJitterMs }
579
- : {}),
580
609
  };
581
610
  }
582
611
 
@@ -614,6 +643,7 @@ export async function createSyncClientHandle(
614
643
  const lock = config.leaderLock ?? defaultLeaderLock();
615
644
  const lockName = config.lockName ?? 'syncular-leader';
616
645
  const invalidation = new InvalidationEmitter();
646
+ const changes = new ChangeEmitter();
617
647
  const presence = new Set<(scopeKey: string) => void>();
618
648
  const roleListeners = new Set<(role: HandleRole) => void>();
619
649
  if (config.onRoleChange !== undefined) roleListeners.add(config.onRoleChange);
@@ -633,6 +663,7 @@ export async function createSyncClientHandle(
633
663
  return await bootLeader(config, lockName, lease, {
634
664
  epoch: 0,
635
665
  invalidation,
666
+ changes,
636
667
  presence,
637
668
  roleListeners,
638
669
  });
@@ -645,6 +676,7 @@ export async function createSyncClientHandle(
645
676
  role: 'follower',
646
677
  clientId: '',
647
678
  invalidation,
679
+ changes,
648
680
  presence,
649
681
  roleListeners,
650
682
  });
@@ -653,6 +685,7 @@ export async function createSyncClientHandle(
653
685
  // ---- Follower: proxy to the leader; contest + promote on its close. ----
654
686
  return await bootFollower(config, lockName, lock, {
655
687
  invalidation,
688
+ changes,
656
689
  presence,
657
690
  roleListeners,
658
691
  });
@@ -661,6 +694,7 @@ export async function createSyncClientHandle(
661
694
  interface HandleParts {
662
695
  epoch?: number;
663
696
  invalidation: InvalidationEmitter;
697
+ changes: ChangeEmitter;
664
698
  presence: Set<(scopeKey: string) => void>;
665
699
  roleListeners: Set<(role: HandleRole) => void>;
666
700
  }
@@ -712,6 +746,7 @@ async function bootLeader(
712
746
  clientId: core.clientId,
713
747
  core,
714
748
  invalidation: parts.invalidation,
749
+ changes: parts.changes,
715
750
  presence: parts.presence,
716
751
  roleListeners: parts.roleListeners,
717
752
  });
@@ -809,6 +844,7 @@ async function bootFollower(
809
844
  clientId: '',
810
845
  follower,
811
846
  invalidation: parts.invalidation,
847
+ changes: parts.changes,
812
848
  presence: parts.presence,
813
849
  roleListeners: parts.roleListeners,
814
850
  });
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * Scheduling note (SPEC §8.4): the sync-needed signal is host-driven,
14
14
  * and in worker mode the HOST LOOP LIVES IN THE WORKER — wake-ups
15
- * coalesce into jittered `syncUntilIdle` rounds there (`autoSync`),
15
+ * coalesce into intent-driven `syncUntilIdle` rounds there (`autoSync`),
16
16
  * so the UI thread never has to react to keep data flowing. Events are
17
17
  * still forwarded to the handle for visibility.
18
18
  */
@@ -23,6 +23,8 @@ import type {
23
23
  LeaseState,
24
24
  MutationInput,
25
25
  PresencePeer,
26
+ QueryReadSpec,
27
+ QuerySnapshot,
26
28
  RejectionRecord,
27
29
  SchemaFloor,
28
30
  SubscribeInput,
@@ -31,7 +33,11 @@ import type {
31
33
  WindowState,
32
34
  } from './client';
33
35
  import type { SqlRow, SqlValue } from './database';
34
- import type { InvalidationEvent } from './invalidation';
36
+ import type {
37
+ ClientChangeBatch,
38
+ LocalRevision,
39
+ SyncStatusSnapshot,
40
+ } from './invalidation';
35
41
  import type { OutboxCommit } from './outbox';
36
42
  import type { ClientSchema } from './schema';
37
43
  import type { SubscriptionRecord } from './state';
@@ -88,12 +94,11 @@ export interface WorkerInitConfig {
88
94
  readonly clientId?: string;
89
95
  readonly limits?: SyncClientLimits;
90
96
  /**
91
- * Worker-side host loop (§8.4): coalesce wake-ups into jittered
97
+ * Worker-side host loop (§8.4): coalesce interactive work immediately and
98
+ * honor explicit background retry deadlines
92
99
  * `syncUntilIdle` rounds inside the worker. Default true.
93
100
  */
94
101
  readonly autoSync?: boolean;
95
- /** Max jitter before a coalesced auto-sync round (default 200 ms). */
96
- readonly wakeJitterMs?: number;
97
102
  }
98
103
 
99
104
  /** Successful init reply. */
@@ -123,6 +128,9 @@ export interface WorkerApi {
123
128
  sync(): Promise<SyncSummary>;
124
129
  syncUntilIdle(maxRounds?: number): Promise<SyncSummary>;
125
130
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
131
+ querySnapshot(spec: QueryReadSpec): QuerySnapshot;
132
+ localRevision(): LocalRevision;
133
+ statusSnapshot(): SyncStatusSnapshot;
126
134
  conflicts(): readonly ConflictRecord[];
127
135
  rejections(): readonly RejectionRecord[];
128
136
  schemaFloor(): SchemaFloor | undefined;
@@ -182,7 +190,10 @@ export interface WorkerErrorShape {
182
190
  }
183
191
 
184
192
  export type SyncWorkerEvent =
185
- | { readonly kind: 'sync-needed'; readonly reason: 'hello' | WakeReason }
193
+ | {
194
+ readonly kind: 'sync-needed';
195
+ readonly reason: 'startup' | 'hello' | WakeReason;
196
+ }
186
197
  | { readonly kind: 'conflict'; readonly conflict: ConflictRecord }
187
198
  | {
188
199
  /** An autoSync round finished (or failed) inside the worker. */
@@ -201,13 +212,9 @@ export type SyncWorkerEvent =
201
212
  readonly scopeKey: string;
202
213
  }
203
214
  | {
204
- /**
205
- * TODO 3.1 / I1: one coalesced apply-batch invalidation. `Set`s
206
- * structured-clone across the worker boundary, so the event crosses
207
- * verbatim — the handle re-emits it to `onInvalidate` listeners.
208
- */
209
- readonly kind: 'invalidate';
210
- readonly event: InvalidationEvent;
215
+ /** Exact revisioned core transaction; Sets and bigint clone directly. */
216
+ readonly kind: 'change';
217
+ readonly batch: ClientChangeBatch;
211
218
  };
212
219
 
213
220
  export type WorkerToMainMessage =