@syncular/client 0.15.46 → 0.15.48

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,
@@ -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(
@@ -398,14 +394,11 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
398
394
  subscribe: (input) => requireClient().subscribe(input),
399
395
  unsubscribe: (id) => requireClient().unsubscribe(id),
400
396
  setWindow: async (base, units) => {
401
- const result = await requireClient().setWindowCommand(base, units);
402
- consumeEffects(result.effects);
397
+ await requireClient().setWindowCommand(base, units);
403
398
  },
404
399
  windowState: (base) => requireClient().windowState(base),
405
400
  mutate: (mutations) => {
406
- const result = requireClient().mutateCommand(mutations);
407
- consumeEffects(result.effects);
408
- return result.value;
401
+ return requireClient().mutateCommand(mutations).value;
409
402
  },
410
403
  patch: (table, rowId, partial, options) => {
411
404
  // Same §8.4 rule as `mutate`: a local write must push without the app
@@ -416,7 +409,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
416
409
  partial,
417
410
  options,
418
411
  );
419
- consumeEffects(result.effects);
420
412
  return result.value;
421
413
  },
422
414
  purgeLocalData: (input) => requireClient().purgeLocalData(input),