@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.
package/README.md CHANGED
@@ -14,6 +14,9 @@ server-authoritative commands, and live query watches through the remote
14
14
  operation transport. See [remote server operations](https://syncular.dev/guide-remote-operations/).
15
15
  Its schema and sync transport are optional for query-only or command-only
16
16
  processes.
17
+ Ordinary commits use wire version 1 until the caller supplies an acquired
18
+ partition `logEpoch`. Set `logEpoch` after a restore rotation requires epoch
19
+ validation.
17
20
 
18
21
  ## Client-local FTS5 projections
19
22
 
@@ -69,6 +72,20 @@ SPEC §8.4); the supported page-level realtime supervisor owns reconnect and
69
72
  resume policy. The main thread gets `onSyncNeeded` / `onConflict` / `onSynced`
70
73
  events for rendering.
71
74
 
75
+ A direct `SyncClient` used by a long-running service exposes
76
+ `onSyncNeeded()` and `onSyncIntent()`. Install the shared single-flight loop
77
+ instead of maintaining host timers:
78
+
79
+ ```ts
80
+ const scheduler = installSyncScheduler(client, { onError: reportSyncError });
81
+ // During shutdown:
82
+ scheduler.stop();
83
+ ```
84
+
85
+ Creation-time window helpers produce immutable UTC month scope values.
86
+ `creationTimeBucket(createdAtMs, 'month')` returns `YYYY-MM`, and
87
+ `last(3, 'month')` returns the current and preceding two buckets oldest first.
88
+
72
89
  OPFS is best effort until the browser grants origin persistence. The page owns
73
90
  that decision because `StorageManager.persist()` is a Window API and should be
74
91
  requested from a user action:
@@ -5,6 +5,11 @@
5
5
  */
6
6
  import { Database } from 'bun:sqlite';
7
7
  import { type ClientDatabase, type SqlRow, type SqlValue } from './database.js';
8
+ declare module 'bun:sqlite' {
9
+ interface Database {
10
+ clearQueryCache(): void;
11
+ }
12
+ }
8
13
  export declare class BunClientDatabase implements ClientDatabase {
9
14
  #private;
10
15
  readonly db: Database;
@@ -23,6 +23,11 @@ export class BunClientDatabase {
23
23
  }
24
24
  exec(sql, params = []) {
25
25
  this.db.query(sql).run(...coerceParams(params));
26
+ // `Database.query()` caches prepared statements. Clear that cache after
27
+ // schema DDL so a reset does not reprepare every later row upsert.
28
+ if (/^\s*(?:CREATE|DROP|ALTER)\b/i.test(sql)) {
29
+ this.db.clearQueryCache();
30
+ }
26
31
  }
27
32
  query(sql, params = []) {
28
33
  return this.db.query(sql).all(...coerceParams(params));
package/dist/client.d.ts CHANGED
@@ -285,6 +285,10 @@ export declare class SyncClient {
285
285
  onInvalidate(listener: InvalidationListener): () => void;
286
286
  /** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
287
287
  onChange(listener: ClientChangeListener): () => void;
288
+ /** Subscribe to host wake signals raised by startup and realtime. */
289
+ onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
290
+ /** Subscribe to exact core-owned scheduling instructions. */
291
+ onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
288
292
  /** Subscribe to complete, privacy-safe diagnostic snapshots. */
289
293
  onDiagnostics(listener: ClientDiagnosticsListener): () => void;
290
294
  /**
package/dist/client.js CHANGED
@@ -71,6 +71,7 @@ function emptySummary(pushed) {
71
71
  failed: [],
72
72
  };
73
73
  }
74
+ const LOG_EPOCH_META_KEY = 'logEpoch';
74
75
  function isFinalPushResult(frame) {
75
76
  return (frame.status !== 'rejected' ||
76
77
  !frame.results.some((result) => result.status === 'error' &&
@@ -131,6 +132,8 @@ export class SyncClient {
131
132
  #invalidation = new InvalidationEmitter();
132
133
  /** §8.6: subscribable presence-change listeners (twin of onPresence). */
133
134
  #presenceListeners = new Set();
135
+ #syncNeededListeners = new Set();
136
+ #syncIntentListeners = new Set();
134
137
  #diagnostics = new ClientDiagnosticsEmitter();
135
138
  #diagnosticsDeferralDepth = 0;
136
139
  #diagnosticsPending = false;
@@ -233,8 +236,8 @@ export class SyncClient {
233
236
  subscriptions.some((sub) => sub.status === 'active'));
234
237
  if (startupWork && this.#securityLifecycle === 'active') {
235
238
  this.#needsPull = true;
236
- this.#config.onSyncNeeded?.('startup');
237
- this.#config.onSyncIntent?.({ kind: 'interactive' });
239
+ this.#emitSyncNeeded('startup');
240
+ this.#emitSyncIntent({ kind: 'interactive' });
238
241
  }
239
242
  // Console introspection is a no-op outside a dev page.
240
243
  this.#devtoolsUnregister = registerDevtools({
@@ -298,6 +301,29 @@ export class SyncClient {
298
301
  this.#setSchemaFloor(undefined);
299
302
  this.#replayOutbox();
300
303
  }
304
+ /** §2.1 reset after the server reports a different log continuity. */
305
+ #runLogEpochReset(logEpoch) {
306
+ const subscriptions = loadSubscriptions(this.#db);
307
+ const pending = listOutbox(this.#db);
308
+ this.#setUpgrading(true);
309
+ this.#applyBatch((batch) => {
310
+ this.#db.transaction(() => {
311
+ dropAndRecreateSyncedTables(this.#db, this.#schema);
312
+ resetSubscriptionsForBump(this.#db);
313
+ setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
314
+ for (const commit of pending) {
315
+ this.#applyOperationsLocally(commit.operations, batch);
316
+ }
317
+ });
318
+ for (const table of this.#schema.tables.values())
319
+ batch.table(table.name);
320
+ });
321
+ this.#localResetEpoch += 1;
322
+ this.#setSyncNeeded(true);
323
+ this.#emitSyncNeeded('startup');
324
+ this.#emitSyncIntent({ kind: 'interactive' });
325
+ return subscriptions.map((subscription) => subscription.id);
326
+ }
301
327
  #setUpgrading(upgrading) {
302
328
  if (this.#upgrading === upgrading)
303
329
  return;
@@ -325,6 +351,7 @@ export class SyncClient {
325
351
  });
326
352
  }
327
353
  async close() {
354
+ this.#emitSyncIntent({ kind: 'none' });
328
355
  this.#devtoolsUnregister?.();
329
356
  this.#devtoolsUnregister = undefined;
330
357
  this.disconnectRealtime();
@@ -332,6 +359,40 @@ export class SyncClient {
332
359
  await this.#lease?.release();
333
360
  this.#lease = undefined;
334
361
  this.#started = false;
362
+ this.#syncNeededListeners.clear();
363
+ this.#syncIntentListeners.clear();
364
+ }
365
+ #emitSyncNeeded(reason) {
366
+ try {
367
+ this.#config.onSyncNeeded?.(reason);
368
+ }
369
+ catch {
370
+ // An observer cannot alter sync correctness.
371
+ }
372
+ for (const listener of this.#syncNeededListeners) {
373
+ try {
374
+ listener(reason);
375
+ }
376
+ catch {
377
+ // An observer cannot alter sync correctness.
378
+ }
379
+ }
380
+ }
381
+ #emitSyncIntent(intent) {
382
+ try {
383
+ this.#config.onSyncIntent?.(intent);
384
+ }
385
+ catch {
386
+ // An observer cannot alter sync correctness.
387
+ }
388
+ for (const listener of this.#syncIntentListeners) {
389
+ try {
390
+ listener(intent);
391
+ }
392
+ catch {
393
+ // An observer cannot alter sync correctness.
394
+ }
395
+ }
335
396
  }
336
397
  /** Current fail-closed local-replica security state. */
337
398
  get securityLifecycle() {
@@ -382,8 +443,8 @@ export class SyncClient {
382
443
  loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
383
444
  if (startupWork) {
384
445
  this.#setSyncNeeded(true);
385
- this.#config.onSyncNeeded?.('startup');
386
- this.#config.onSyncIntent?.({ kind: 'interactive' });
446
+ this.#emitSyncNeeded('startup');
447
+ this.#emitSyncIntent({ kind: 'interactive' });
387
448
  }
388
449
  this.#emitDiagnostics();
389
450
  }
@@ -479,6 +540,20 @@ export class SyncClient {
479
540
  onChange(listener) {
480
541
  return this.#changes.on(listener);
481
542
  }
543
+ /** Subscribe to host wake signals raised by startup and realtime. */
544
+ onSyncNeeded(listener) {
545
+ this.#syncNeededListeners.add(listener);
546
+ return () => {
547
+ this.#syncNeededListeners.delete(listener);
548
+ };
549
+ }
550
+ /** Subscribe to exact core-owned scheduling instructions. */
551
+ onSyncIntent(listener) {
552
+ this.#syncIntentListeners.add(listener);
553
+ return () => {
554
+ this.#syncIntentListeners.delete(listener);
555
+ };
556
+ }
482
557
  /** Subscribe to complete, privacy-safe diagnostic snapshots. */
483
558
  onDiagnostics(listener) {
484
559
  return this.#diagnostics.on(listener);
@@ -1119,11 +1194,17 @@ export class SyncClient {
1119
1194
  cursor: -1,
1120
1195
  status: 'active',
1121
1196
  });
1197
+ this.#setSyncNeeded(true);
1198
+ this.#emitSyncIntent({ kind: 'interactive' });
1122
1199
  this.#emitDiagnostics();
1123
1200
  }
1124
1201
  unsubscribe(id) {
1125
1202
  this.#requireActive();
1203
+ if (getSubscription(this.#db, id) === undefined)
1204
+ return;
1126
1205
  deleteSubscription(this.#db, id);
1206
+ this.#setSyncNeeded(true);
1207
+ this.#emitSyncIntent({ kind: 'interactive' });
1127
1208
  this.#emitDiagnostics();
1128
1209
  }
1129
1210
  // -- windowed subscriptions (§4.8) ------------------------------------------
@@ -1179,7 +1260,9 @@ export class SyncClient {
1179
1260
  status: 'active',
1180
1261
  });
1181
1262
  });
1263
+ this.#needsPull = true;
1182
1264
  batch.window(baseKey, base.table, unit);
1265
+ batch.status();
1183
1266
  });
1184
1267
  changed = true;
1185
1268
  widened = true;
@@ -1195,6 +1278,9 @@ export class SyncClient {
1195
1278
  const effects = {
1196
1279
  sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
1197
1280
  };
1281
+ if (effects.sync.kind === 'interactive') {
1282
+ this.#emitSyncIntent(effects.sync);
1283
+ }
1198
1284
  return { value: undefined, effects };
1199
1285
  }
1200
1286
  /**
@@ -1254,6 +1340,8 @@ export class SyncClient {
1254
1340
  });
1255
1341
  batch.scopeMap(table, effective);
1256
1342
  batch.window(baseKey, table.name, unit);
1343
+ this.#needsPull = true;
1344
+ batch.status();
1257
1345
  });
1258
1346
  }
1259
1347
  /**
@@ -1349,7 +1437,9 @@ export class SyncClient {
1349
1437
  this.#applyOperationsLocally(operations, batch);
1350
1438
  batch.status();
1351
1439
  });
1440
+ this.#needsPull = true;
1352
1441
  });
1442
+ this.#emitSyncIntent({ kind: 'interactive' });
1353
1443
  return clientCommitId;
1354
1444
  }
1355
1445
  /** Host-facing mutation result with explicit network work intent (§7.5). */
@@ -1577,8 +1667,8 @@ export class SyncClient {
1577
1667
  this.#localResetEpoch += 1;
1578
1668
  if (!priorUpgrading)
1579
1669
  this.#config.onUpgrading?.(true);
1580
- this.#config.onSyncNeeded?.('startup');
1581
- this.#config.onSyncIntent?.({ kind: 'interactive' });
1670
+ this.#emitSyncNeeded('startup');
1671
+ this.#emitSyncIntent({ kind: 'interactive' });
1582
1672
  return {
1583
1673
  alreadyApplied: false,
1584
1674
  retainedCommits: pending.length,
@@ -1798,9 +1888,12 @@ export class SyncClient {
1798
1888
  // survive it — the reference server keeps no replay buffer (§8.2).
1799
1889
  this.#setSyncNeeded(false);
1800
1890
  try {
1891
+ const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
1801
1892
  // §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
1802
1893
  // them, so the server-side existence check (§6.6) passes.
1803
- if (this.#hasBlobs && this.#config.blobs !== undefined) {
1894
+ if (logEpoch !== undefined &&
1895
+ this.#hasBlobs &&
1896
+ this.#config.blobs !== undefined) {
1804
1897
  await this.flushBlobUploads();
1805
1898
  }
1806
1899
  // §7.4.4: encode the outbox with the CURRENT codec; a commit that
@@ -1808,7 +1901,9 @@ export class SyncClient {
1808
1901
  // is removed from the push and surfaced as a rejection, never wedging
1809
1902
  // the queue. `pushFrames` and `outbox` stay index-aligned for result
1810
1903
  // mapping.
1811
- const { pushFrames, outbox, deferred } = await this.#encodeOutboxForPush();
1904
+ const { pushFrames, outbox, deferred } = logEpoch === undefined
1905
+ ? { pushFrames: [], outbox: [], deferred: 0 }
1906
+ : await this.#encodeOutboxForPush();
1812
1907
  // Captured together with the subscription state below: the response
1813
1908
  // apply persists SUB_END cursors only while this epoch is current.
1814
1909
  const resetEpoch = this.#localResetEpoch;
@@ -1819,6 +1914,7 @@ export class SyncClient {
1819
1914
  type: 'REQ_HEADER',
1820
1915
  clientId: this.#clientId,
1821
1916
  schemaVersion: this.#schema.version,
1917
+ ...(logEpoch !== undefined ? { logEpoch } : {}),
1822
1918
  },
1823
1919
  ...pushFrames,
1824
1920
  {
@@ -1885,12 +1981,7 @@ export class SyncClient {
1885
1981
  delayMs: this.#retryDelayMs,
1886
1982
  };
1887
1983
  this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
1888
- try {
1889
- this.#config.onSyncIntent?.(intent);
1890
- }
1891
- catch {
1892
- // An observer cannot alter sync correctness.
1893
- }
1984
+ this.#emitSyncIntent(intent);
1894
1985
  }
1895
1986
  throw error;
1896
1987
  }
@@ -2078,14 +2169,14 @@ export class SyncClient {
2078
2169
  if (event.event === 'hello') {
2079
2170
  if (event.data.requiresSync) {
2080
2171
  this.#setSyncNeeded(true);
2081
- this.#config.onSyncNeeded?.('hello');
2172
+ this.#emitSyncNeeded('hello');
2082
2173
  }
2083
2174
  return;
2084
2175
  }
2085
2176
  if (event.event === 'sync') {
2086
2177
  // §8.3: any wake-up means "run a pull soon", never data.
2087
2178
  this.#setSyncNeeded(true);
2088
- this.#config.onSyncNeeded?.(event.data.reason);
2179
+ this.#emitSyncNeeded(event.data.reason);
2089
2180
  return;
2090
2181
  }
2091
2182
  if (event.event === 'presence') {
@@ -2155,7 +2246,7 @@ export class SyncClient {
2155
2246
  catch {
2156
2247
  // A delta that cannot be applied is recovered by a pull (§8.3).
2157
2248
  this.#setSyncNeeded(true);
2158
- this.#config.onSyncNeeded?.('catchup-required');
2249
+ this.#emitSyncNeeded('catchup-required');
2159
2250
  }
2160
2251
  });
2161
2252
  }
@@ -2195,6 +2286,11 @@ export class SyncClient {
2195
2286
  if (header?.type !== 'RESP_HEADER') {
2196
2287
  throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
2197
2288
  }
2289
+ if (message.wireVersion < 2 ||
2290
+ header.logEpoch === undefined ||
2291
+ header.resetRequired === undefined) {
2292
+ throw new ClientSyncError('client.invalid_host_response', 'the server response does not carry wire version 2 log-epoch state');
2293
+ }
2198
2294
  if (header.requiredSchemaVersion !== undefined) {
2199
2295
  // §1.6 schema floor: nothing else was processed — stop syncing and
2200
2296
  // surface the upgrade requirement. A live-round floor always stops:
@@ -2216,6 +2312,20 @@ export class SyncClient {
2216
2312
  schemaFloor,
2217
2313
  };
2218
2314
  }
2315
+ const currentLogEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
2316
+ if (header.resetRequired) {
2317
+ if (mode !== 'pull' || message.frames.length !== 1) {
2318
+ throw new ClientSyncError('client.invalid_host_response', 'a log-epoch reset response must contain only RESP_HEADER');
2319
+ }
2320
+ return {
2321
+ ...summary,
2322
+ resets: this.#runLogEpochReset(header.logEpoch),
2323
+ bootstrapping: [],
2324
+ };
2325
+ }
2326
+ if (currentLogEpoch === undefined || currentLogEpoch !== header.logEpoch) {
2327
+ throw new ClientSyncError('client.invalid_host_response', 'the server changed logEpoch without requiring a reset');
2328
+ }
2219
2329
  let section;
2220
2330
  let errorFrame;
2221
2331
  let deltaCursor = -1;
package/dist/index.d.ts CHANGED
@@ -35,6 +35,7 @@ export * from './realtime-supervisor.js';
35
35
  export * from './schema.js';
36
36
  export * from './sql-tag.js';
37
37
  export * from './state.js';
38
+ export * from './sync-scheduler.js';
38
39
  export * from './transport.js';
39
40
  export * from './window.js';
40
41
  export * from './worker-host.js';
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ export * from './realtime-supervisor.js';
35
35
  export * from './schema.js';
36
36
  export * from './sql-tag.js';
37
37
  export * from './state.js';
38
+ export * from './sync-scheduler.js';
38
39
  export * from './transport.js';
39
40
  export * from './window.js';
40
41
  export * from './worker-host.js';
package/dist/remote.d.ts CHANGED
@@ -18,6 +18,8 @@ export interface SyncRemoteClientConfig {
18
18
  readonly operations?: RemoteOperationTransport;
19
19
  readonly operationRealtime?: RemoteOperationRealtimeConnector;
20
20
  readonly encryption?: EncryptionConfig;
21
+ /** Acquired partition log epoch for restore-safe ordinary commits (§2.1). */
22
+ readonly logEpoch?: string;
21
23
  }
22
24
  export interface RemoteCommitInput {
23
25
  /** Stable caller-owned idempotency identity for this logical commit. */
package/dist/remote.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Database-less SSP2 producer (§6.10). It prepares and sends ordinary commits
3
3
  * through the existing push path without creating a local replica or outbox.
4
4
  */
5
- import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
5
+ import { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeRow, } from '@syncular/core';
6
6
  import { encryptRowValues } from './encryption.js';
7
7
  import { ClientSyncError } from './errors.js';
8
8
  import { compileClientSchema, recordToRowValues, } from './schema.js';
@@ -71,10 +71,14 @@ export class SyncRemoteClient {
71
71
  #operationSocketGeneration = 0;
72
72
  #watches = new Map();
73
73
  #encryption;
74
+ #logEpoch;
74
75
  constructor(config) {
75
76
  if (config.clientId.length === 0) {
76
77
  throw invalid('SyncRemoteClient clientId must be non-empty');
77
78
  }
79
+ if (config.logEpoch !== undefined && config.logEpoch.length === 0) {
80
+ throw invalid('SyncRemoteClient logEpoch must be non-empty');
81
+ }
78
82
  this.#schema =
79
83
  config.schema === undefined
80
84
  ? undefined
@@ -84,6 +88,7 @@ export class SyncRemoteClient {
84
88
  this.#operations = config.operations;
85
89
  this.#operationRealtime = config.operationRealtime;
86
90
  this.#encryption = config.encryption;
91
+ this.#logEpoch = config.logEpoch;
87
92
  }
88
93
  async prepareCommit(input) {
89
94
  const schema = this.#schema;
@@ -137,13 +142,16 @@ export class SyncRemoteClient {
137
142
  return {
138
143
  requestId: input.requestId,
139
144
  bytes: encodeMessage({
140
- wireVersion: PROTOCOL_WIRE_VERSION,
145
+ wireVersion: this.#logEpoch === undefined ? 1 : 2,
141
146
  msgKind: 'request',
142
147
  frames: [
143
148
  {
144
149
  type: 'REQ_HEADER',
145
150
  clientId: this.#clientId,
146
151
  schemaVersion: schema.version,
152
+ ...(this.#logEpoch !== undefined
153
+ ? { logEpoch: this.#logEpoch }
154
+ : {}),
147
155
  },
148
156
  {
149
157
  type: 'PUSH_COMMIT',
@@ -0,0 +1,23 @@
1
+ import type { WakeReason } from '@syncular/core';
2
+ import type { SecurityLifecycle } from './client.js';
3
+ import type { SyncIntent } from './invalidation.js';
4
+ export interface SyncSchedulerClient {
5
+ readonly syncNeeded: boolean;
6
+ readonly securityLifecycle: SecurityLifecycle;
7
+ syncUntilIdle(maxRounds?: number): Promise<unknown>;
8
+ onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
9
+ onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
10
+ }
11
+ export interface SyncSchedulerOptions {
12
+ readonly maxRounds?: number;
13
+ readonly onError?: (error: unknown) => void;
14
+ readonly now?: () => number;
15
+ readonly queueMicrotask?: (callback: () => void) => void;
16
+ readonly schedule?: (callback: () => void, delayMs: number) => () => void;
17
+ }
18
+ export interface SyncScheduler {
19
+ readonly stopped: boolean;
20
+ stop(): void;
21
+ }
22
+ /** Install the event-driven single-flight host loop for a direct client. */
23
+ export declare function installSyncScheduler(client: SyncSchedulerClient, options?: SyncSchedulerOptions): SyncScheduler;
@@ -0,0 +1,122 @@
1
+ /** Install the event-driven single-flight host loop for a direct client. */
2
+ export function installSyncScheduler(client, options = {}) {
3
+ const now = options.now ?? Date.now;
4
+ const enqueue = options.queueMicrotask ?? globalThis.queueMicrotask;
5
+ const schedule = options.schedule ??
6
+ ((callback, delayMs) => {
7
+ const timer = globalThis.setTimeout(callback, delayMs);
8
+ return () => globalThis.clearTimeout(timer);
9
+ });
10
+ let stopped = false;
11
+ let running = false;
12
+ let immediatePending = false;
13
+ let immediateQueued = false;
14
+ let backgroundReady = false;
15
+ let backgroundDue = Number.POSITIVE_INFINITY;
16
+ let cancelBackground;
17
+ const report = (error) => {
18
+ if (options.onError !== undefined) {
19
+ options.onError(error);
20
+ return;
21
+ }
22
+ const root = globalThis;
23
+ if (root.reportError !== undefined)
24
+ root.reportError(error);
25
+ else
26
+ console.error(error);
27
+ };
28
+ const clearBackground = () => {
29
+ cancelBackground?.();
30
+ cancelBackground = undefined;
31
+ backgroundReady = false;
32
+ backgroundDue = Number.POSITIVE_INFINITY;
33
+ };
34
+ const queueImmediate = () => {
35
+ immediatePending = true;
36
+ if (stopped || running || immediateQueued)
37
+ return;
38
+ immediateQueued = true;
39
+ enqueue(() => {
40
+ immediateQueued = false;
41
+ if (stopped || !immediatePending)
42
+ return;
43
+ immediatePending = false;
44
+ run();
45
+ });
46
+ };
47
+ const run = () => {
48
+ if (stopped || running)
49
+ return;
50
+ if (client.securityLifecycle === 'preflight') {
51
+ immediatePending = false;
52
+ return;
53
+ }
54
+ running = true;
55
+ backgroundReady = false;
56
+ void client
57
+ .syncUntilIdle(options.maxRounds)
58
+ .catch((error) => {
59
+ if (!stopped)
60
+ report(error);
61
+ })
62
+ .finally(() => {
63
+ running = false;
64
+ if (stopped)
65
+ return;
66
+ if (immediatePending || backgroundReady) {
67
+ queueImmediate();
68
+ return;
69
+ }
70
+ if (cancelBackground !== undefined && backgroundDue <= now()) {
71
+ clearBackground();
72
+ queueImmediate();
73
+ }
74
+ });
75
+ };
76
+ const consume = (intent) => {
77
+ if (stopped)
78
+ return;
79
+ if (intent.kind === 'none') {
80
+ clearBackground();
81
+ immediatePending = false;
82
+ return;
83
+ }
84
+ if (intent.kind === 'interactive') {
85
+ clearBackground();
86
+ queueImmediate();
87
+ return;
88
+ }
89
+ if (immediatePending || immediateQueued)
90
+ return;
91
+ clearBackground();
92
+ backgroundDue = now() + Math.max(0, intent.delayMs);
93
+ cancelBackground = schedule(() => {
94
+ cancelBackground = undefined;
95
+ backgroundDue = Number.POSITIVE_INFINITY;
96
+ backgroundReady = true;
97
+ if (!running)
98
+ queueImmediate();
99
+ }, Math.max(0, intent.delayMs));
100
+ };
101
+ const unsubscribeNeeded = client.onSyncNeeded(() => {
102
+ consume({ kind: 'interactive' });
103
+ });
104
+ const unsubscribeIntent = client.onSyncIntent(consume);
105
+ if (client.syncNeeded && client.securityLifecycle === 'active') {
106
+ consume({ kind: 'interactive' });
107
+ }
108
+ return {
109
+ get stopped() {
110
+ return stopped;
111
+ },
112
+ stop() {
113
+ if (stopped)
114
+ return;
115
+ stopped = true;
116
+ clearBackground();
117
+ immediatePending = false;
118
+ unsubscribeNeeded();
119
+ unsubscribeIntent();
120
+ },
121
+ };
122
+ }
package/dist/window.d.ts CHANGED
@@ -14,6 +14,11 @@
14
14
  */
15
15
  import { type ScopeMap } from '@syncular/core';
16
16
  import type { ClientDatabase } from './database.js';
17
+ export type TimeBucketUnit = 'month';
18
+ /** Derive the immutable UTC scope value stored when a row is created. */
19
+ export declare function creationTimeBucket(createdAtMs: number, unit: TimeBucketUnit): string;
20
+ /** Return a rolling UTC month window ordered from oldest to newest. */
21
+ export declare function last(count: number, unit: TimeBucketUnit, nowMs?: number): string[];
17
22
  /**
18
23
  * A window base: one table, one variable whose values are the window
19
24
  * units, and any FIXED scopes every unit shares (other variables pinned
package/dist/window.js CHANGED
@@ -13,6 +13,45 @@
13
13
  * transaction and the invalidation choke point.
14
14
  */
15
15
  import { canonicalScopeJson } from '@syncular/core';
16
+ import { ClientSyncError } from './errors.js';
17
+ const MAX_TIME_BUCKET_MS = 253_402_300_799_999;
18
+ function monthBucket(year, month) {
19
+ return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}`;
20
+ }
21
+ /** Derive the immutable UTC scope value stored when a row is created. */
22
+ export function creationTimeBucket(createdAtMs, unit) {
23
+ if (unit !== 'month' ||
24
+ !Number.isSafeInteger(createdAtMs) ||
25
+ createdAtMs < 0 ||
26
+ createdAtMs > MAX_TIME_BUCKET_MS) {
27
+ throw new ClientSyncError('sync.invalid_request', 'creationTimeBucket requires a supported unit and a UTC timestamp from 1970 through 9999');
28
+ }
29
+ const date = new Date(createdAtMs);
30
+ return monthBucket(date.getUTCFullYear(), date.getUTCMonth() + 1);
31
+ }
32
+ /** Return a rolling UTC month window ordered from oldest to newest. */
33
+ export function last(count, unit, nowMs = Date.now()) {
34
+ if (unit !== 'month' ||
35
+ !Number.isSafeInteger(count) ||
36
+ count < 1 ||
37
+ count > 1_200 ||
38
+ !Number.isSafeInteger(nowMs) ||
39
+ nowMs < 0 ||
40
+ nowMs > MAX_TIME_BUCKET_MS) {
41
+ throw new ClientSyncError('sync.invalid_request', 'last requires a supported unit, a count from 1 through 1200, and a UTC timestamp from 1970 through 9999');
42
+ }
43
+ const date = new Date(nowMs);
44
+ const current = date.getUTCFullYear() * 12 + date.getUTCMonth();
45
+ if (current - (count - 1) < 1970 * 12) {
46
+ throw new ClientSyncError('sync.invalid_request', 'last requires every returned UTC month to fall from 1970 through 9999');
47
+ }
48
+ const units = [];
49
+ for (let offset = count - 1; offset >= 0; offset -= 1) {
50
+ const value = current - offset;
51
+ units.push(monthBucket(Math.floor(value / 12), (value % 12) + 1));
52
+ }
53
+ return units;
54
+ }
16
55
  /**
17
56
  * A stable, server-opaque key for a window base — table + variable +
18
57
  * canonical fixed scopes. Two `setWindow` calls with the same base