@syncular/client 0.15.47 → 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.
@@ -147,9 +147,6 @@ export function startSyncWorker(overrides = {}) {
147
147
  autoSyncScheduled = true;
148
148
  queueMicrotask(runAutoSync);
149
149
  }
150
- function consumeEffects(effects) {
151
- consumeSyncIntent(effects.sync);
152
- }
153
150
  function requireClient() {
154
151
  if (client === undefined) {
155
152
  throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker received a call before init completed');
@@ -304,20 +301,16 @@ export function startSyncWorker(overrides = {}) {
304
301
  subscribe: (input) => requireClient().subscribe(input),
305
302
  unsubscribe: (id) => requireClient().unsubscribe(id),
306
303
  setWindow: async (base, units) => {
307
- const result = await requireClient().setWindowCommand(base, units);
308
- consumeEffects(result.effects);
304
+ await requireClient().setWindowCommand(base, units);
309
305
  },
310
306
  windowState: (base) => requireClient().windowState(base),
311
307
  mutate: (mutations) => {
312
- const result = requireClient().mutateCommand(mutations);
313
- consumeEffects(result.effects);
314
- return result.value;
308
+ return requireClient().mutateCommand(mutations).value;
315
309
  },
316
310
  patch: (table, rowId, partial, options) => {
317
311
  // Same §8.4 rule as `mutate`: a local write must push without the app
318
312
  // orchestrating sync, so consume the core's immediate intent.
319
313
  const result = requireClient().patchCommand(table, rowId, partial, options);
320
- consumeEffects(result.effects);
321
314
  return result.value;
322
315
  },
323
316
  purgeLocalData: (input) => requireClient().purgeLocalData(input),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.47",
3
+ "version": "0.15.48",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -97,9 +97,9 @@
97
97
  },
98
98
  "dependencies": {
99
99
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
100
- "@syncular/core": "0.15.47"
100
+ "@syncular/core": "0.15.48"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.15.47"
103
+ "@syncular/server": "0.15.48"
104
104
  }
105
105
  }
@@ -15,6 +15,12 @@ import {
15
15
  type SqlValue,
16
16
  } from './database';
17
17
 
18
+ declare module 'bun:sqlite' {
19
+ interface Database {
20
+ clearQueryCache(): void;
21
+ }
22
+ }
23
+
18
24
  type BunParam = string | number | bigint | Uint8Array | null;
19
25
 
20
26
  function coerceParams(params: readonly SqlValue[]): BunParam[] {
@@ -34,6 +40,11 @@ export class BunClientDatabase implements ClientDatabase {
34
40
 
35
41
  exec(sql: string, params: readonly SqlValue[] = []): void {
36
42
  this.db.query(sql).run(...coerceParams(params));
43
+ // `Database.query()` caches prepared statements. Clear that cache after
44
+ // schema DDL so a reset does not reprepare every later row upsert.
45
+ if (/^\s*(?:CREATE|DROP|ALTER)\b/i.test(sql)) {
46
+ this.db.clearQueryCache();
47
+ }
37
48
  }
38
49
 
39
50
  query(sql: string, params: readonly SqlValue[] = []): SqlRow[] {
package/src/client.ts CHANGED
@@ -513,6 +513,8 @@ function emptySummary(pushed: number): MutableSummary {
513
513
  };
514
514
  }
515
515
 
516
+ const LOG_EPOCH_META_KEY = 'logEpoch';
517
+
516
518
  function isFinalPushResult(frame: PushResultFrame): boolean {
517
519
  return (
518
520
  frame.status !== 'rejected' ||
@@ -579,6 +581,10 @@ export class SyncClient {
579
581
  readonly #invalidation = new InvalidationEmitter();
580
582
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
581
583
  readonly #presenceListeners = new Set<(scopeKey: string) => void>();
584
+ readonly #syncNeededListeners = new Set<
585
+ (reason: 'startup' | 'hello' | WakeReason) => void
586
+ >();
587
+ readonly #syncIntentListeners = new Set<(intent: SyncIntent) => void>();
582
588
  readonly #diagnostics = new ClientDiagnosticsEmitter();
583
589
  #diagnosticsDeferralDepth = 0;
584
590
  #diagnosticsPending = false;
@@ -705,8 +711,8 @@ export class SyncClient {
705
711
  subscriptions.some((sub) => sub.status === 'active'));
706
712
  if (startupWork && this.#securityLifecycle === 'active') {
707
713
  this.#needsPull = true;
708
- this.#config.onSyncNeeded?.('startup');
709
- this.#config.onSyncIntent?.({ kind: 'interactive' });
714
+ this.#emitSyncNeeded('startup');
715
+ this.#emitSyncIntent({ kind: 'interactive' });
710
716
  }
711
717
  // Console introspection is a no-op outside a dev page.
712
718
  this.#devtoolsUnregister = registerDevtools({
@@ -776,6 +782,29 @@ export class SyncClient {
776
782
  this.#replayOutbox();
777
783
  }
778
784
 
785
+ /** §2.1 reset after the server reports a different log continuity. */
786
+ #runLogEpochReset(logEpoch: string): string[] {
787
+ const subscriptions = loadSubscriptions(this.#db);
788
+ const pending = listOutbox(this.#db);
789
+ this.#setUpgrading(true);
790
+ this.#applyBatch((batch) => {
791
+ this.#db.transaction(() => {
792
+ dropAndRecreateSyncedTables(this.#db, this.#schema);
793
+ resetSubscriptionsForBump(this.#db);
794
+ setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
795
+ for (const commit of pending) {
796
+ this.#applyOperationsLocally(commit.operations, batch);
797
+ }
798
+ });
799
+ for (const table of this.#schema.tables.values()) batch.table(table.name);
800
+ });
801
+ this.#localResetEpoch += 1;
802
+ this.#setSyncNeeded(true);
803
+ this.#emitSyncNeeded('startup');
804
+ this.#emitSyncIntent({ kind: 'interactive' });
805
+ return subscriptions.map((subscription) => subscription.id);
806
+ }
807
+
779
808
  #setUpgrading(upgrading: boolean): void {
780
809
  if (this.#upgrading === upgrading) return;
781
810
  this.#applyBatch((batch) => {
@@ -803,6 +832,7 @@ export class SyncClient {
803
832
  }
804
833
 
805
834
  async close(): Promise<void> {
835
+ this.#emitSyncIntent({ kind: 'none' });
806
836
  this.#devtoolsUnregister?.();
807
837
  this.#devtoolsUnregister = undefined;
808
838
  this.disconnectRealtime();
@@ -810,6 +840,38 @@ export class SyncClient {
810
840
  await this.#lease?.release();
811
841
  this.#lease = undefined;
812
842
  this.#started = false;
843
+ this.#syncNeededListeners.clear();
844
+ this.#syncIntentListeners.clear();
845
+ }
846
+
847
+ #emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
848
+ try {
849
+ this.#config.onSyncNeeded?.(reason);
850
+ } catch {
851
+ // An observer cannot alter sync correctness.
852
+ }
853
+ for (const listener of this.#syncNeededListeners) {
854
+ try {
855
+ listener(reason);
856
+ } catch {
857
+ // An observer cannot alter sync correctness.
858
+ }
859
+ }
860
+ }
861
+
862
+ #emitSyncIntent(intent: SyncIntent): void {
863
+ try {
864
+ this.#config.onSyncIntent?.(intent);
865
+ } catch {
866
+ // An observer cannot alter sync correctness.
867
+ }
868
+ for (const listener of this.#syncIntentListeners) {
869
+ try {
870
+ listener(intent);
871
+ } catch {
872
+ // An observer cannot alter sync correctness.
873
+ }
874
+ }
813
875
  }
814
876
 
815
877
  /** Current fail-closed local-replica security state. */
@@ -869,8 +931,8 @@ export class SyncClient {
869
931
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
870
932
  if (startupWork) {
871
933
  this.#setSyncNeeded(true);
872
- this.#config.onSyncNeeded?.('startup');
873
- this.#config.onSyncIntent?.({ kind: 'interactive' });
934
+ this.#emitSyncNeeded('startup');
935
+ this.#emitSyncIntent({ kind: 'interactive' });
874
936
  }
875
937
  this.#emitDiagnostics();
876
938
  }
@@ -982,6 +1044,24 @@ export class SyncClient {
982
1044
  return this.#changes.on(listener);
983
1045
  }
984
1046
 
1047
+ /** Subscribe to host wake signals raised by startup and realtime. */
1048
+ onSyncNeeded(
1049
+ listener: (reason: 'startup' | 'hello' | WakeReason) => void,
1050
+ ): () => void {
1051
+ this.#syncNeededListeners.add(listener);
1052
+ return () => {
1053
+ this.#syncNeededListeners.delete(listener);
1054
+ };
1055
+ }
1056
+
1057
+ /** Subscribe to exact core-owned scheduling instructions. */
1058
+ onSyncIntent(listener: (intent: SyncIntent) => void): () => void {
1059
+ this.#syncIntentListeners.add(listener);
1060
+ return () => {
1061
+ this.#syncIntentListeners.delete(listener);
1062
+ };
1063
+ }
1064
+
985
1065
  /** Subscribe to complete, privacy-safe diagnostic snapshots. */
986
1066
  onDiagnostics(listener: ClientDiagnosticsListener): () => void {
987
1067
  return this.#diagnostics.on(listener);
@@ -1781,12 +1861,17 @@ export class SyncClient {
1781
1861
  cursor: -1,
1782
1862
  status: 'active',
1783
1863
  });
1864
+ this.#setSyncNeeded(true);
1865
+ this.#emitSyncIntent({ kind: 'interactive' });
1784
1866
  this.#emitDiagnostics();
1785
1867
  }
1786
1868
 
1787
1869
  unsubscribe(id: string): void {
1788
1870
  this.#requireActive();
1871
+ if (getSubscription(this.#db, id) === undefined) return;
1789
1872
  deleteSubscription(this.#db, id);
1873
+ this.#setSyncNeeded(true);
1874
+ this.#emitSyncIntent({ kind: 'interactive' });
1790
1875
  this.#emitDiagnostics();
1791
1876
  }
1792
1877
 
@@ -1851,7 +1936,9 @@ export class SyncClient {
1851
1936
  status: 'active',
1852
1937
  });
1853
1938
  });
1939
+ this.#needsPull = true;
1854
1940
  batch.window(baseKey, base.table, unit);
1941
+ batch.status();
1855
1942
  });
1856
1943
  changed = true;
1857
1944
  widened = true;
@@ -1867,6 +1954,9 @@ export class SyncClient {
1867
1954
  const effects: CommandEffects = {
1868
1955
  sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
1869
1956
  };
1957
+ if (effects.sync.kind === 'interactive') {
1958
+ this.#emitSyncIntent(effects.sync);
1959
+ }
1870
1960
  return { value: undefined, effects };
1871
1961
  }
1872
1962
 
@@ -1934,6 +2024,8 @@ export class SyncClient {
1934
2024
  });
1935
2025
  batch.scopeMap(table, effective);
1936
2026
  batch.window(baseKey, table.name, unit);
2027
+ this.#needsPull = true;
2028
+ batch.status();
1937
2029
  });
1938
2030
  }
1939
2031
 
@@ -2043,7 +2135,9 @@ export class SyncClient {
2043
2135
  this.#applyOperationsLocally(operations, batch);
2044
2136
  batch.status();
2045
2137
  });
2138
+ this.#needsPull = true;
2046
2139
  });
2140
+ this.#emitSyncIntent({ kind: 'interactive' });
2047
2141
  return clientCommitId;
2048
2142
  }
2049
2143
 
@@ -2320,8 +2414,8 @@ export class SyncClient {
2320
2414
  this.#localResetEpoch += 1;
2321
2415
 
2322
2416
  if (!priorUpgrading) this.#config.onUpgrading?.(true);
2323
- this.#config.onSyncNeeded?.('startup');
2324
- this.#config.onSyncIntent?.({ kind: 'interactive' });
2417
+ this.#emitSyncNeeded('startup');
2418
+ this.#emitSyncIntent({ kind: 'interactive' });
2325
2419
  return {
2326
2420
  alreadyApplied: false,
2327
2421
  retainedCommits: pending.length,
@@ -2585,9 +2679,14 @@ export class SyncClient {
2585
2679
  // survive it — the reference server keeps no replay buffer (§8.2).
2586
2680
  this.#setSyncNeeded(false);
2587
2681
  try {
2682
+ const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
2588
2683
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
2589
2684
  // them, so the server-side existence check (§6.6) passes.
2590
- if (this.#hasBlobs && this.#config.blobs !== undefined) {
2685
+ if (
2686
+ logEpoch !== undefined &&
2687
+ this.#hasBlobs &&
2688
+ this.#config.blobs !== undefined
2689
+ ) {
2591
2690
  await this.flushBlobUploads();
2592
2691
  }
2593
2692
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
@@ -2596,7 +2695,9 @@ export class SyncClient {
2596
2695
  // the queue. `pushFrames` and `outbox` stay index-aligned for result
2597
2696
  // mapping.
2598
2697
  const { pushFrames, outbox, deferred } =
2599
- await this.#encodeOutboxForPush();
2698
+ logEpoch === undefined
2699
+ ? { pushFrames: [], outbox: [], deferred: 0 }
2700
+ : await this.#encodeOutboxForPush();
2600
2701
  // Captured together with the subscription state below: the response
2601
2702
  // apply persists SUB_END cursors only while this epoch is current.
2602
2703
  const resetEpoch = this.#localResetEpoch;
@@ -2609,6 +2710,7 @@ export class SyncClient {
2609
2710
  type: 'REQ_HEADER',
2610
2711
  clientId: this.#clientId,
2611
2712
  schemaVersion: this.#schema.version,
2713
+ ...(logEpoch !== undefined ? { logEpoch } : {}),
2612
2714
  },
2613
2715
  ...pushFrames,
2614
2716
  {
@@ -2686,11 +2788,7 @@ export class SyncClient {
2686
2788
  delayMs: this.#retryDelayMs,
2687
2789
  };
2688
2790
  this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
2689
- try {
2690
- this.#config.onSyncIntent?.(intent);
2691
- } catch {
2692
- // An observer cannot alter sync correctness.
2693
- }
2791
+ this.#emitSyncIntent(intent);
2694
2792
  }
2695
2793
  throw error;
2696
2794
  } finally {
@@ -2916,14 +3014,14 @@ export class SyncClient {
2916
3014
  if (event.event === 'hello') {
2917
3015
  if (event.data.requiresSync) {
2918
3016
  this.#setSyncNeeded(true);
2919
- this.#config.onSyncNeeded?.('hello');
3017
+ this.#emitSyncNeeded('hello');
2920
3018
  }
2921
3019
  return;
2922
3020
  }
2923
3021
  if (event.event === 'sync') {
2924
3022
  // §8.3: any wake-up means "run a pull soon", never data.
2925
3023
  this.#setSyncNeeded(true);
2926
- this.#config.onSyncNeeded?.(event.data.reason);
3024
+ this.#emitSyncNeeded(event.data.reason);
2927
3025
  return;
2928
3026
  }
2929
3027
  if (event.event === 'presence') {
@@ -2995,7 +3093,7 @@ export class SyncClient {
2995
3093
  } catch {
2996
3094
  // A delta that cannot be applied is recovered by a pull (§8.3).
2997
3095
  this.#setSyncNeeded(true);
2998
- this.#config.onSyncNeeded?.('catchup-required');
3096
+ this.#emitSyncNeeded('catchup-required');
2999
3097
  }
3000
3098
  });
3001
3099
  }
@@ -3057,6 +3155,16 @@ export class SyncClient {
3057
3155
  if (header?.type !== 'RESP_HEADER') {
3058
3156
  throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
3059
3157
  }
3158
+ if (
3159
+ message.wireVersion < 2 ||
3160
+ header.logEpoch === undefined ||
3161
+ header.resetRequired === undefined
3162
+ ) {
3163
+ throw new ClientSyncError(
3164
+ 'client.invalid_host_response',
3165
+ 'the server response does not carry wire version 2 log-epoch state',
3166
+ );
3167
+ }
3060
3168
  if (header.requiredSchemaVersion !== undefined) {
3061
3169
  // §1.6 schema floor: nothing else was processed — stop syncing and
3062
3170
  // surface the upgrade requirement. A live-round floor always stops:
@@ -3078,6 +3186,26 @@ export class SyncClient {
3078
3186
  schemaFloor,
3079
3187
  };
3080
3188
  }
3189
+ const currentLogEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
3190
+ if (header.resetRequired) {
3191
+ if (mode !== 'pull' || message.frames.length !== 1) {
3192
+ throw new ClientSyncError(
3193
+ 'client.invalid_host_response',
3194
+ 'a log-epoch reset response must contain only RESP_HEADER',
3195
+ );
3196
+ }
3197
+ return {
3198
+ ...summary,
3199
+ resets: this.#runLogEpochReset(header.logEpoch),
3200
+ bootstrapping: [],
3201
+ };
3202
+ }
3203
+ if (currentLogEpoch === undefined || currentLogEpoch !== header.logEpoch) {
3204
+ throw new ClientSyncError(
3205
+ 'client.invalid_host_response',
3206
+ 'the server changed logEpoch without requiring a reset',
3207
+ );
3208
+ }
3081
3209
 
3082
3210
  let section: OpenSection | undefined;
3083
3211
  let errorFrame: ClientSyncError | undefined;
package/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export * from './realtime-supervisor';
35
35
  export * from './schema';
36
36
  export * from './sql-tag';
37
37
  export * from './state';
38
+ export * from './sync-scheduler';
38
39
  export * from './transport';
39
40
  export * from './window';
40
41
  export * from './worker-host';
package/src/remote.ts CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  encodeRemoteOperationRequest,
11
11
  encodeRemoteOperationRealtimeMessage,
12
12
  encodeRow,
13
- PROTOCOL_WIRE_VERSION,
14
13
  type PushOperation,
15
14
  type PushOperationResult,
16
15
  type PushResultDetailsFrame,
@@ -42,6 +41,8 @@ export interface SyncRemoteClientConfig {
42
41
  readonly operations?: RemoteOperationTransport;
43
42
  readonly operationRealtime?: RemoteOperationRealtimeConnector;
44
43
  readonly encryption?: EncryptionConfig;
44
+ /** Acquired partition log epoch for restore-safe ordinary commits (§2.1). */
45
+ readonly logEpoch?: string;
45
46
  }
46
47
 
47
48
  export interface RemoteCommitInput {
@@ -203,11 +204,15 @@ export class SyncRemoteClient {
203
204
  }
204
205
  >();
205
206
  readonly #encryption: EncryptionConfig | undefined;
207
+ readonly #logEpoch: string | undefined;
206
208
 
207
209
  constructor(config: SyncRemoteClientConfig) {
208
210
  if (config.clientId.length === 0) {
209
211
  throw invalid('SyncRemoteClient clientId must be non-empty');
210
212
  }
213
+ if (config.logEpoch !== undefined && config.logEpoch.length === 0) {
214
+ throw invalid('SyncRemoteClient logEpoch must be non-empty');
215
+ }
211
216
  this.#schema =
212
217
  config.schema === undefined
213
218
  ? undefined
@@ -217,6 +222,7 @@ export class SyncRemoteClient {
217
222
  this.#operations = config.operations;
218
223
  this.#operationRealtime = config.operationRealtime;
219
224
  this.#encryption = config.encryption;
225
+ this.#logEpoch = config.logEpoch;
220
226
  }
221
227
 
222
228
  async prepareCommit(input: RemoteCommitInput): Promise<PreparedRemoteCommit> {
@@ -277,13 +283,16 @@ export class SyncRemoteClient {
277
283
  return {
278
284
  requestId: input.requestId,
279
285
  bytes: encodeMessage({
280
- wireVersion: PROTOCOL_WIRE_VERSION,
286
+ wireVersion: this.#logEpoch === undefined ? 1 : 2,
281
287
  msgKind: 'request',
282
288
  frames: [
283
289
  {
284
290
  type: 'REQ_HEADER',
285
291
  clientId: this.#clientId,
286
292
  schemaVersion: schema.version,
293
+ ...(this.#logEpoch !== undefined
294
+ ? { logEpoch: this.#logEpoch }
295
+ : {}),
287
296
  },
288
297
  {
289
298
  type: 'PUSH_COMMIT',
@@ -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