@syncular/client 0.15.47 → 0.16.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.
@@ -0,0 +1,154 @@
1
+ import type { WakeReason } from '@syncular/core';
2
+ import type { SecurityLifecycle } from './client';
3
+ import type { SyncIntent } from './invalidation';
4
+
5
+ export interface SyncSchedulerClient {
6
+ readonly syncNeeded: boolean;
7
+ readonly securityLifecycle: SecurityLifecycle;
8
+ syncUntilIdle(maxRounds?: number): Promise<unknown>;
9
+ onSyncNeeded(
10
+ listener: (reason: 'startup' | 'hello' | WakeReason) => void,
11
+ ): () => void;
12
+ onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
13
+ }
14
+
15
+ export interface SyncSchedulerOptions {
16
+ readonly maxRounds?: number;
17
+ readonly onError?: (error: unknown) => void;
18
+ readonly now?: () => number;
19
+ readonly queueMicrotask?: (callback: () => void) => void;
20
+ readonly schedule?: (callback: () => void, delayMs: number) => () => void;
21
+ }
22
+
23
+ export interface SyncScheduler {
24
+ readonly stopped: boolean;
25
+ stop(): void;
26
+ }
27
+
28
+ /** Install the event-driven single-flight host loop for a direct client. */
29
+ export function installSyncScheduler(
30
+ client: SyncSchedulerClient,
31
+ options: SyncSchedulerOptions = {},
32
+ ): SyncScheduler {
33
+ const now = options.now ?? Date.now;
34
+ const enqueue = options.queueMicrotask ?? globalThis.queueMicrotask;
35
+ const schedule =
36
+ options.schedule ??
37
+ ((callback: () => void, delayMs: number): (() => void) => {
38
+ const timer = globalThis.setTimeout(callback, delayMs);
39
+ return () => globalThis.clearTimeout(timer);
40
+ });
41
+ let stopped = false;
42
+ let running = false;
43
+ let immediatePending = false;
44
+ let immediateQueued = false;
45
+ let backgroundReady = false;
46
+ let backgroundDue = Number.POSITIVE_INFINITY;
47
+ let cancelBackground: (() => void) | undefined;
48
+
49
+ const report = (error: unknown): void => {
50
+ if (options.onError !== undefined) {
51
+ options.onError(error);
52
+ return;
53
+ }
54
+ const root: typeof globalThis & {
55
+ reportError?: (error: unknown) => void;
56
+ } = globalThis;
57
+ if (root.reportError !== undefined) root.reportError(error);
58
+ else console.error(error);
59
+ };
60
+
61
+ const clearBackground = (): void => {
62
+ cancelBackground?.();
63
+ cancelBackground = undefined;
64
+ backgroundReady = false;
65
+ backgroundDue = Number.POSITIVE_INFINITY;
66
+ };
67
+
68
+ const queueImmediate = (): void => {
69
+ immediatePending = true;
70
+ if (stopped || running || immediateQueued) return;
71
+ immediateQueued = true;
72
+ enqueue(() => {
73
+ immediateQueued = false;
74
+ if (stopped || !immediatePending) return;
75
+ immediatePending = false;
76
+ run();
77
+ });
78
+ };
79
+
80
+ const run = (): void => {
81
+ if (stopped || running) return;
82
+ if (client.securityLifecycle === 'preflight') {
83
+ immediatePending = false;
84
+ return;
85
+ }
86
+ running = true;
87
+ backgroundReady = false;
88
+ void client
89
+ .syncUntilIdle(options.maxRounds)
90
+ .catch((error: unknown) => {
91
+ if (!stopped) report(error);
92
+ })
93
+ .finally(() => {
94
+ running = false;
95
+ if (stopped) return;
96
+ if (immediatePending || backgroundReady) {
97
+ queueImmediate();
98
+ return;
99
+ }
100
+ if (cancelBackground !== undefined && backgroundDue <= now()) {
101
+ clearBackground();
102
+ queueImmediate();
103
+ }
104
+ });
105
+ };
106
+
107
+ const consume = (intent: SyncIntent): void => {
108
+ if (stopped) return;
109
+ if (intent.kind === 'none') {
110
+ clearBackground();
111
+ immediatePending = false;
112
+ return;
113
+ }
114
+ if (intent.kind === 'interactive') {
115
+ clearBackground();
116
+ queueImmediate();
117
+ return;
118
+ }
119
+ if (immediatePending || immediateQueued) return;
120
+ clearBackground();
121
+ backgroundDue = now() + Math.max(0, intent.delayMs);
122
+ cancelBackground = schedule(
123
+ () => {
124
+ cancelBackground = undefined;
125
+ backgroundDue = Number.POSITIVE_INFINITY;
126
+ backgroundReady = true;
127
+ if (!running) queueImmediate();
128
+ },
129
+ Math.max(0, intent.delayMs),
130
+ );
131
+ };
132
+
133
+ const unsubscribeNeeded = client.onSyncNeeded(() => {
134
+ consume({ kind: 'interactive' });
135
+ });
136
+ const unsubscribeIntent = client.onSyncIntent(consume);
137
+ if (client.syncNeeded && client.securityLifecycle === 'active') {
138
+ consume({ kind: 'interactive' });
139
+ }
140
+
141
+ return {
142
+ get stopped() {
143
+ return stopped;
144
+ },
145
+ stop() {
146
+ if (stopped) return;
147
+ stopped = true;
148
+ clearBackground();
149
+ immediatePending = false;
150
+ unsubscribeNeeded();
151
+ unsubscribeIntent();
152
+ },
153
+ };
154
+ }
package/src/window.ts CHANGED
@@ -14,6 +14,71 @@
14
14
  */
15
15
  import { canonicalScopeJson, type ScopeMap } from '@syncular/core';
16
16
  import type { ClientDatabase } from './database';
17
+ import { ClientSyncError } from './errors';
18
+
19
+ export type TimeBucketUnit = 'month';
20
+
21
+ const MAX_TIME_BUCKET_MS = 253_402_300_799_999;
22
+
23
+ function monthBucket(year: number, month: number): string {
24
+ return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}`;
25
+ }
26
+
27
+ /** Derive the immutable UTC scope value stored when a row is created. */
28
+ export function creationTimeBucket(
29
+ createdAtMs: number,
30
+ unit: TimeBucketUnit,
31
+ ): string {
32
+ if (
33
+ unit !== 'month' ||
34
+ !Number.isSafeInteger(createdAtMs) ||
35
+ createdAtMs < 0 ||
36
+ createdAtMs > MAX_TIME_BUCKET_MS
37
+ ) {
38
+ throw new ClientSyncError(
39
+ 'sync.invalid_request',
40
+ 'creationTimeBucket requires a supported unit and a UTC timestamp from 1970 through 9999',
41
+ );
42
+ }
43
+ const date = new Date(createdAtMs);
44
+ return monthBucket(date.getUTCFullYear(), date.getUTCMonth() + 1);
45
+ }
46
+
47
+ /** Return a rolling UTC month window ordered from oldest to newest. */
48
+ export function last(
49
+ count: number,
50
+ unit: TimeBucketUnit,
51
+ nowMs = Date.now(),
52
+ ): string[] {
53
+ if (
54
+ unit !== 'month' ||
55
+ !Number.isSafeInteger(count) ||
56
+ count < 1 ||
57
+ count > 1_200 ||
58
+ !Number.isSafeInteger(nowMs) ||
59
+ nowMs < 0 ||
60
+ nowMs > MAX_TIME_BUCKET_MS
61
+ ) {
62
+ throw new ClientSyncError(
63
+ 'sync.invalid_request',
64
+ 'last requires a supported unit, a count from 1 through 1200, and a UTC timestamp from 1970 through 9999',
65
+ );
66
+ }
67
+ const date = new Date(nowMs);
68
+ const current = date.getUTCFullYear() * 12 + date.getUTCMonth();
69
+ if (current - (count - 1) < 1970 * 12) {
70
+ throw new ClientSyncError(
71
+ 'sync.invalid_request',
72
+ 'last requires every returned UTC month to fall from 1970 through 9999',
73
+ );
74
+ }
75
+ const units: string[] = [];
76
+ for (let offset = count - 1; offset >= 0; offset -= 1) {
77
+ const value = current - offset;
78
+ units.push(monthBucket(Math.floor(value / 12), (value % 12) + 1));
79
+ }
80
+ return units;
81
+ }
17
82
 
18
83
  /**
19
84
  * A window base: one table, one variable whose values are the window
@@ -27,7 +27,7 @@ import {
27
27
  httpSyncTransport,
28
28
  webSocketRealtimeConnector,
29
29
  } from './http';
30
- import type { CommandEffects, SyncIntent } from './invalidation';
30
+ import type { SyncIntent } from './invalidation';
31
31
  import type {
32
32
  RealtimeConnector,
33
33
  SegmentDownloader,
@@ -151,7 +151,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
151
151
  if (
152
152
  closed ||
153
153
  client === undefined ||
154
- client.securityLifecycle === 'preflight'
154
+ client.securityLifecycle() === 'preflight'
155
155
  ) {
156
156
  return;
157
157
  }
@@ -175,7 +175,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
175
175
  !autoSync ||
176
176
  closed ||
177
177
  client === undefined ||
178
- client.securityLifecycle === 'preflight' ||
178
+ client.securityLifecycle() === 'preflight' ||
179
179
  intent.kind === 'none'
180
180
  ) {
181
181
  return;
@@ -211,10 +211,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
211
211
  queueMicrotask(runAutoSync);
212
212
  }
213
213
 
214
- function consumeEffects(effects: CommandEffects): void {
215
- consumeSyncIntent(effects.sync);
216
- }
217
-
218
214
  function requireClient(): SyncClient {
219
215
  if (client === undefined) {
220
216
  throw new ClientSyncError(
@@ -375,12 +371,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
375
371
  // `start()` may discover persisted subscriptions/outbox work. Its callback
376
372
  // fires before this worker publishes the initialized client, so consume
377
373
  // the durable state once here as well; coalescing makes this a single task.
378
- if (started.syncNeeded) consumeSyncIntent({ kind: 'interactive' });
374
+ if (started.statusSnapshot().syncNeeded)
375
+ consumeSyncIntent({ kind: 'interactive' });
379
376
  return { clientId: started.clientId };
380
377
  }
381
378
 
382
379
  const api: WorkerApi = {
383
- securityLifecycle: () => requireClient().securityLifecycle,
380
+ securityLifecycle: () => requireClient().securityLifecycle(),
384
381
  beginSecurityPreflight: async () => {
385
382
  if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
386
383
  backgroundTimer = undefined;
@@ -398,14 +395,11 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
398
395
  subscribe: (input) => requireClient().subscribe(input),
399
396
  unsubscribe: (id) => requireClient().unsubscribe(id),
400
397
  setWindow: async (base, units) => {
401
- const result = await requireClient().setWindowCommand(base, units);
402
- consumeEffects(result.effects);
398
+ await requireClient().setWindowCommand(base, units);
403
399
  },
404
400
  windowState: (base) => requireClient().windowState(base),
405
401
  mutate: (mutations) => {
406
- const result = requireClient().mutateCommand(mutations);
407
- consumeEffects(result.effects);
408
- return result.value;
402
+ return requireClient().mutateCommand(mutations).value;
409
403
  },
410
404
  patch: (table, rowId, partial, options) => {
411
405
  // Same §8.4 rule as `mutate`: a local write must push without the app
@@ -416,7 +410,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
416
410
  partial,
417
411
  options,
418
412
  );
419
- consumeEffects(result.effects);
420
413
  return result.value;
421
414
  },
422
415
  purgeLocalData: (input) => requireClient().purgeLocalData(input),
@@ -443,17 +436,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
443
436
  realtime: snapshot.host.realtime,
444
437
  });
445
438
  },
446
- conflicts: () => requireClient().conflicts,
447
- rejections: () => requireClient().rejections,
439
+ conflicts: () => requireClient().conflicts(),
440
+ rejections: () => requireClient().rejections(),
448
441
  commitOutcome: (clientCommitId) =>
449
442
  requireClient().commitOutcome(clientCommitId),
450
443
  commitOutcomes: (query) => requireClient().commitOutcomes(query),
451
444
  resolveCommitOutcome: (input) =>
452
445
  requireClient().resolveCommitOutcome(input),
453
- schemaFloor: () => requireClient().schemaFloor,
454
- leaseState: () => requireClient().leaseState,
455
- upgrading: () => requireClient().upgrading,
456
- syncNeeded: () => requireClient().syncNeeded,
457
446
  pendingCommits: () => requireClient().pendingCommits(),
458
447
  subscriptions: () => requireClient().subscriptions(),
459
448
  subscription: (id) => requireClient().subscription(id),
@@ -1,3 +1,4 @@
1
+ import type { PromiseMethods } from './client';
1
2
  /**
2
3
  * Main-thread side of the worker mode and the
3
4
  * multi-tab topology.
@@ -25,13 +26,11 @@ import type { WakeReason } from '@syncular/core';
25
26
  import type { BlobRef, CachedBlob } from './blob';
26
27
  import type {
27
28
  ConflictRecord,
28
- LeaseState,
29
29
  MutationInput,
30
30
  PresencePeer,
31
31
  QueryReadSpec,
32
32
  QuerySnapshot,
33
33
  RejectionRecord,
34
- SchemaFloor,
35
34
  SecurityLifecycle,
36
35
  SubscribeInput,
37
36
  SyncClientLimits,
@@ -244,7 +243,7 @@ interface LeaderCore {
244
243
  * promise. `role` is `'leader'` (owns the worker) or `'follower'` (proxies to
245
244
  * the leader over the channel). Constructed via {@link createSyncClientHandle}.
246
245
  */
247
- export class SyncClientHandle {
246
+ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
248
247
  /** True only for a leader handle. Kept for the pre-multiTab contract. */
249
248
  get isLeader(): boolean {
250
249
  return this.#role === 'leader';
@@ -324,12 +323,12 @@ export class SyncClientHandle {
324
323
  ref: this,
325
324
  clientId: () => this.#clientId,
326
325
  role: () => this.#role,
327
- outbox: async () => (await this.pendingCommits()).length,
326
+ outbox: async () => (await this.statusSnapshot()).outbox,
328
327
  subscriptions: () => this.subscriptions(),
329
328
  conflicts: async () => (await this.conflicts()).length,
330
329
  rejections: async () => (await this.rejections()).length,
331
- syncNeeded: () => this.syncNeeded(),
332
- upgrading: () => this.upgrading(),
330
+ syncNeeded: async () => (await this.statusSnapshot()).syncNeeded,
331
+ upgrading: async () => (await this.statusSnapshot()).upgrading,
333
332
  onInvalidate: (listener) => this.onInvalidate(listener),
334
333
  });
335
334
  }
@@ -593,23 +592,7 @@ export class SyncClientHandle {
593
592
  return this.#call('resolveCommitOutcome', [input]);
594
593
  }
595
594
 
596
- schemaFloor(): Promise<SchemaFloor | undefined> {
597
- return this.#call('schemaFloor', []);
598
- }
599
-
600
- leaseState(): Promise<LeaseState | undefined> {
601
- return this.#call('leaseState', []);
602
- }
603
-
604
595
  /** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
605
- upgrading(): Promise<boolean> {
606
- return this.#call('upgrading', []);
607
- }
608
-
609
- syncNeeded(): Promise<boolean> {
610
- return this.#call('syncNeeded', []);
611
- }
612
-
613
596
  pendingCommits(): Promise<OutboxCommit[]> {
614
597
  return this.#call('pendingCommits', []);
615
598
  }
@@ -20,13 +20,11 @@ import type { WakeReason } from '@syncular/core';
20
20
  import type { BlobRef, CachedBlob } from './blob';
21
21
  import type {
22
22
  ConflictRecord,
23
- LeaseState,
23
+ ClientSnapshotMethods,
24
24
  MutationInput,
25
25
  PresencePeer,
26
26
  QueryReadSpec,
27
27
  QuerySnapshot,
28
- RejectionRecord,
29
- SchemaFloor,
30
28
  SecurityLifecycle,
31
29
  SubscribeInput,
32
30
  SyncClientLimits,
@@ -34,27 +32,15 @@ import type {
34
32
  WindowState,
35
33
  } from './client';
36
34
  import type { SqlRow, SqlValue } from './database';
37
- import type {
38
- ClientDiagnosticsRequest,
39
- ClientDiagnosticsSnapshot,
40
- } from './diagnostics';
35
+ import type { ClientDiagnosticsSnapshot } from './diagnostics';
41
36
  import type { EncryptionKeyringConfig } from './encryption';
42
- import type {
43
- ClientChangeBatch,
44
- LocalRevision,
45
- SyncStatusSnapshot,
46
- } from './invalidation';
37
+ import type { ClientChangeBatch, LocalRevision } from './invalidation';
47
38
  import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge';
48
39
  import type {
49
40
  LocalDataRebootstrapInput,
50
41
  LocalDataRebootstrapResult,
51
42
  } from './local-rebootstrap';
52
43
  import type { OutboxCommit } from './outbox';
53
- import type {
54
- CommitOutcome,
55
- CommitOutcomeQuery,
56
- ResolveCommitOutcomeInput,
57
- } from './outcomes';
58
44
  import type { ClientSchema } from './schema';
59
45
  import type { SubscriptionRecord } from './state';
60
46
  import type { WindowBase } from './window';
@@ -137,7 +123,10 @@ export interface WorkerSecurityActivation {
137
123
  // The logical API — the one shared shape (worker implements, handle projects)
138
124
  // ---------------------------------------------------------------------------
139
125
 
140
- export interface WorkerApi {
126
+ export interface WorkerApi extends Omit<
127
+ ClientSnapshotMethods,
128
+ 'querySnapshot'
129
+ > {
141
130
  securityLifecycle(): SecurityLifecycle;
142
131
  beginSecurityPreflight(): Promise<void>;
143
132
  activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
@@ -166,21 +155,6 @@ export interface WorkerApi {
166
155
  query(sql: string, params?: readonly SqlValue[]): SqlRow[];
167
156
  querySnapshot(spec: QueryReadSpec): QuerySnapshot;
168
157
  localRevision(): LocalRevision;
169
- statusSnapshot(): SyncStatusSnapshot;
170
- diagnosticsSnapshot(
171
- request?: ClientDiagnosticsRequest,
172
- ): ClientDiagnosticsSnapshot;
173
- conflicts(): readonly ConflictRecord[];
174
- rejections(): readonly RejectionRecord[];
175
- commitOutcome(clientCommitId: string): CommitOutcome | undefined;
176
- commitOutcomes(query?: CommitOutcomeQuery): readonly CommitOutcome[];
177
- resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
178
- schemaFloor(): SchemaFloor | undefined;
179
- /** §7.3.5: the opaque auth-lease state, or undefined. */
180
- leaseState(): LeaseState | undefined;
181
- /** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
182
- upgrading(): boolean;
183
- syncNeeded(): boolean;
184
158
  pendingCommits(): OutboxCommit[];
185
159
  subscriptions(): SubscriptionRecord[];
186
160
  subscription(id: string): SubscriptionRecord | undefined;
@@ -1,8 +0,0 @@
1
- /**
2
- * Preserve supervisor observation across a facade without transferring
3
- * transport ownership or exposing the source client. Binding packages use
4
- * this when they normalize a client into another object identity.
5
- */
6
- export declare function linkRealtimeSupervisorObservation<Target extends object>(target: Target, source: object): Target;
7
- /** @internal Resolve one facade hop for the supervisor attachment lookup. */
8
- export declare function realtimeSupervisorObservationSource(target: object): object | undefined;
@@ -1,15 +0,0 @@
1
- const observationSources = new WeakMap();
2
- /**
3
- * Preserve supervisor observation across a facade without transferring
4
- * transport ownership or exposing the source client. Binding packages use
5
- * this when they normalize a client into another object identity.
6
- */
7
- export function linkRealtimeSupervisorObservation(target, source) {
8
- if (target !== source)
9
- observationSources.set(target, source);
10
- return target;
11
- }
12
- /** @internal Resolve one facade hop for the supervisor attachment lookup. */
13
- export function realtimeSupervisorObservationSource(target) {
14
- return observationSources.get(target);
15
- }
@@ -1,21 +0,0 @@
1
- const observationSources = new WeakMap<object, object>();
2
-
3
- /**
4
- * Preserve supervisor observation across a facade without transferring
5
- * transport ownership or exposing the source client. Binding packages use
6
- * this when they normalize a client into another object identity.
7
- */
8
- export function linkRealtimeSupervisorObservation<Target extends object>(
9
- target: Target,
10
- source: object,
11
- ): Target {
12
- if (target !== source) observationSources.set(target, source);
13
- return target;
14
- }
15
-
16
- /** @internal Resolve one facade hop for the supervisor attachment lookup. */
17
- export function realtimeSupervisorObservationSource(
18
- target: object,
19
- ): object | undefined {
20
- return observationSources.get(target);
21
- }