@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 +17 -0
- package/dist/bun-database.d.ts +5 -0
- package/dist/bun-database.js +5 -0
- package/dist/client.d.ts +4 -0
- package/dist/client.js +127 -17
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/remote.d.ts +2 -0
- package/dist/remote.js +10 -2
- package/dist/sync-scheduler.d.ts +23 -0
- package/dist/sync-scheduler.js +122 -0
- package/dist/window.d.ts +5 -0
- package/dist/window.js +39 -0
- package/dist/worker-entry.js +2 -9
- package/package.json +3 -3
- package/src/bun-database.ts +11 -0
- package/src/client.ts +144 -16
- package/src/index.ts +1 -0
- package/src/remote.ts +11 -2
- package/src/sync-scheduler.ts +154 -0
- package/src/window.ts +65 -0
- package/src/worker-entry.ts +3 -11
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:
|
package/dist/bun-database.d.ts
CHANGED
|
@@ -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;
|
package/dist/bun-database.js
CHANGED
|
@@ -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.#
|
|
237
|
-
this.#
|
|
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.#
|
|
386
|
-
this.#
|
|
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.#
|
|
1581
|
-
this.#
|
|
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 (
|
|
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 } =
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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,
|
|
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:
|
|
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
|