@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.
- package/README.md +59 -0
- package/dist/bun-database.d.ts +5 -0
- package/dist/bun-database.js +5 -0
- package/dist/client.d.ts +17 -20
- package/dist/client.js +152 -60
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/outbox.d.ts +5 -1
- package/dist/outbox.js +26 -3
- package/dist/reactive-store.d.ts +12 -6
- package/dist/reactive-store.js +219 -45
- package/dist/realtime-supervisor.d.ts +1 -1
- package/dist/realtime-supervisor.js +2 -8
- 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 +8 -19
- package/dist/worker-host.d.ts +4 -7
- package/dist/worker-host.js +3 -15
- package/dist/worker-protocol.d.ts +4 -18
- package/package.json +3 -11
- package/src/bun-database.ts +11 -0
- package/src/client.ts +203 -63
- package/src/index.ts +1 -0
- package/src/outbox.ts +39 -8
- package/src/reactive-store.ts +226 -56
- package/src/realtime-supervisor.ts +2 -11
- package/src/remote.ts +11 -2
- package/src/sync-scheduler.ts +154 -0
- package/src/window.ts +65 -0
- package/src/worker-entry.ts +10 -21
- package/src/worker-host.ts +5 -22
- package/src/worker-protocol.ts +7 -33
- package/dist/realtime-supervisor-observation.d.ts +0 -8
- package/dist/realtime-supervisor-observation.js +0 -15
- package/src/realtime-supervisor-observation.ts +0 -21
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:
|
|
@@ -563,3 +580,45 @@ Tests drive the real worker entry in a bun `Worker` with bun:sqlite
|
|
|
563
580
|
injected through the bootstrap's database-factory override
|
|
564
581
|
(`test/worker-rpc.test.ts`); the OPFS path itself is browser-only and is
|
|
565
582
|
exercised by `apps/demo`.
|
|
583
|
+
|
|
584
|
+
## Snapshot API migration
|
|
585
|
+
|
|
586
|
+
This source-breaking revision uses methods for application reads across the
|
|
587
|
+
direct client, worker leaders and followers, Tauri, and React Native. Replace
|
|
588
|
+
`client.conflicts`, `client.rejections`, and `client.securityLifecycle` on the
|
|
589
|
+
direct client with method calls. Replace `schemaFloor`, `leaseState`,
|
|
590
|
+
`upgrading`, and `syncNeeded` getters or bridge methods with fields from one
|
|
591
|
+
`statusSnapshot()` call:
|
|
592
|
+
|
|
593
|
+
```ts
|
|
594
|
+
const status = await client.statusSnapshot();
|
|
595
|
+
if (status.schemaFloor) showUpgradeRequired(status.schemaFloor);
|
|
596
|
+
const conflicts = await client.conflicts();
|
|
597
|
+
const outcome = await client.commitOutcome(commitId);
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
The direct client returns snapshots synchronously. Worker and native bridges
|
|
601
|
+
return promises; `await` works with both. `querySnapshot` returns rows, coverage,
|
|
602
|
+
and revision from one read. `diagnosticsSnapshot`, `commitOutcome`,
|
|
603
|
+
`commitOutcomes`, and `resolveCommitOutcome` retain their existing arguments.
|
|
604
|
+
The shared `ClientSnapshotMethods` and `PromiseMethods` types describe these
|
|
605
|
+
contracts. Key-bearing security activation stays on each concrete host type.
|
|
606
|
+
|
|
607
|
+
React uses the supplied client directly; `useSyncClient()` preserves its
|
|
608
|
+
identity. Remove imports of `normalizeClient` and the
|
|
609
|
+
`@syncular/client/realtime-supervisor-observation` forwarding utility. Pass the
|
|
610
|
+
client to `SyncProvider` and use `realtimeSupervisorSnapshot(client)` to inspect
|
|
611
|
+
an attached supervisor. Custom React clients must implement the snapshot
|
|
612
|
+
methods and method-form collection reads. See the [React migration](https://syncular.dev/platform-react/)
|
|
613
|
+
for the `onEnqueued` callback rename.
|
|
614
|
+
|
|
615
|
+
## Outbox read costs
|
|
616
|
+
|
|
617
|
+
Request encoding pins the pending count and highest local sequence before its
|
|
618
|
+
first asynchronous step. It reads keyset pages of 32 raw records, decodes only
|
|
619
|
+
the consumed prefix, and stops at the first whole commit that exceeds the
|
|
620
|
+
remaining operation budget. Mutations appended during encoding enter the next
|
|
621
|
+
request. Status and diagnostics use `COUNT(*)` without parsing pending bodies.
|
|
622
|
+
Optimistic replay still reads the remaining outbox after each response. The
|
|
623
|
+
100/1,000/10,000-commit workload and measured limits are recorded in
|
|
624
|
+
[the reliability RFC](../../docs/RFC-RELIABILITY-DX.md#9-implementation-evidence-2026-09-05).
|
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
|
@@ -231,6 +231,16 @@ export interface QuerySnapshot<Row = SqlRow> {
|
|
|
231
231
|
* complete once its bootstrap round finishes — emptiness ≠ pendency.
|
|
232
232
|
*/
|
|
233
233
|
export declare function windowComplete(state: WindowState, unit: string): boolean;
|
|
234
|
+
/** Canonical client reads, shared by synchronous cores and promise hosts. */
|
|
235
|
+
export type ClientSnapshotMethods = Pick<SyncClient, 'querySnapshot' | 'statusSnapshot' | 'diagnosticsSnapshot' | 'conflicts' | 'rejections' | 'commitOutcome' | 'commitOutcomes' | 'resolveCommitOutcome'>;
|
|
236
|
+
/** Project a method contract across an asynchronous host boundary. */
|
|
237
|
+
export type PromiseMethods<Methods> = {
|
|
238
|
+
[Key in keyof Methods]: Methods[Key] extends (...args: infer Args) => infer Result ? (...args: Args) => Promise<Awaited<Result>> : never;
|
|
239
|
+
};
|
|
240
|
+
/** A reader can execute locally or cross a worker/native boundary. */
|
|
241
|
+
export type ClientSnapshotReader = {
|
|
242
|
+
[Key in keyof ClientSnapshotMethods]: (...args: Parameters<ClientSnapshotMethods[Key]>) => ReturnType<ClientSnapshotMethods[Key]> | Promise<ReturnType<ClientSnapshotMethods[Key]>>;
|
|
243
|
+
};
|
|
234
244
|
export declare class SyncClient {
|
|
235
245
|
#private;
|
|
236
246
|
constructor(config: SyncClientConfig);
|
|
@@ -238,7 +248,7 @@ export declare class SyncClient {
|
|
|
238
248
|
start(): Promise<void>;
|
|
239
249
|
close(): Promise<void>;
|
|
240
250
|
/** Current fail-closed local-replica security state. */
|
|
241
|
-
|
|
251
|
+
securityLifecycle(): SecurityLifecycle;
|
|
242
252
|
/**
|
|
243
253
|
* Block new protected operations immediately, then wait for every already
|
|
244
254
|
* serialized database/network operation to settle before releasing key
|
|
@@ -285,6 +295,10 @@ export declare class SyncClient {
|
|
|
285
295
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
286
296
|
/** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
|
|
287
297
|
onChange(listener: ClientChangeListener): () => void;
|
|
298
|
+
/** Subscribe to host wake signals raised by startup and realtime. */
|
|
299
|
+
onSyncNeeded(listener: (reason: 'startup' | 'hello' | WakeReason) => void): () => void;
|
|
300
|
+
/** Subscribe to exact core-owned scheduling instructions. */
|
|
301
|
+
onSyncIntent(listener: (intent: SyncIntent) => void): () => void;
|
|
288
302
|
/** Subscribe to complete, privacy-safe diagnostic snapshots. */
|
|
289
303
|
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
290
304
|
/**
|
|
@@ -316,8 +330,8 @@ export declare class SyncClient {
|
|
|
316
330
|
fetchBlob(blobIdOrRef: string): Promise<CachedBlob>;
|
|
317
331
|
/** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
|
|
318
332
|
flushBlobUploads(): Promise<void>;
|
|
319
|
-
|
|
320
|
-
|
|
333
|
+
conflicts(): readonly ConflictRecord[];
|
|
334
|
+
rejections(): readonly RejectionRecord[];
|
|
321
335
|
/** One durable final outcome by the originating client commit id. */
|
|
322
336
|
commitOutcome(clientCommitId: string): CommitOutcome | undefined;
|
|
323
337
|
/** Newest-first durable outcome journal. */
|
|
@@ -329,28 +343,11 @@ export declare class SyncClient {
|
|
|
329
343
|
* dismissed. The transition is one-way and survives restart.
|
|
330
344
|
*/
|
|
331
345
|
resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
|
|
332
|
-
/** Non-undefined once the server declared a schema floor (§1.6). */
|
|
333
|
-
get schemaFloor(): SchemaFloor | undefined;
|
|
334
|
-
/**
|
|
335
|
-
* §7.4.5: true while a schema-bump reset + first re-bootstrap is in
|
|
336
|
-
* flight — the app's "upgrading…" cue. Clears when the first post-reset
|
|
337
|
-
* bootstrap round reaches idle (every subscription past its fresh
|
|
338
|
-
* bootstrap).
|
|
339
|
-
*/
|
|
340
|
-
get upgrading(): boolean;
|
|
341
|
-
/**
|
|
342
|
-
* §7.3.5: the current auth-lease state (opaque). Undefined until a
|
|
343
|
-
* `LEASE` frame arrives. `errorCode` is set when a round was rejected
|
|
344
|
-
* with a request-level lease code — syncing on the lease has stopped.
|
|
345
|
-
*/
|
|
346
|
-
get leaseState(): LeaseState | undefined;
|
|
347
346
|
/** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
|
|
348
347
|
* `undefined` if no lease is held. Negative once expired. */
|
|
349
348
|
leaseRemainingMs(now?: number): number | undefined;
|
|
350
349
|
/** True when syncing is stopped pending a client upgrade. */
|
|
351
350
|
get stopped(): boolean;
|
|
352
|
-
/** §8: a hello/wake-up asked for a pull that has not run yet. */
|
|
353
|
-
get syncNeeded(): boolean;
|
|
354
351
|
/**
|
|
355
352
|
* §8.6 presence on a scope key: the current peers present there (a map
|
|
356
353
|
* of `actorId clientId` → peer). Empty for a key with no present peers.
|
package/dist/client.js
CHANGED
|
@@ -18,7 +18,7 @@ import { singleOwnerLock, } from './leader-lock.js';
|
|
|
18
18
|
import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatches, } from './local-purge.js';
|
|
19
19
|
import { compileLocalDataRebootstrap, localDataRebootstrapMetaKey, } from './local-rebootstrap.js';
|
|
20
20
|
import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, } from './local-rebootstrap-receipt.js';
|
|
21
|
-
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
21
|
+
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, iterateOutbox, countOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
22
22
|
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
23
23
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
24
24
|
import { compileClientSchema, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
@@ -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;
|
|
@@ -229,12 +232,12 @@ export class SyncClient {
|
|
|
229
232
|
// this as an exact core-owned intent so hosts never need a startup poll or
|
|
230
233
|
// an application-issued sync() call.
|
|
231
234
|
const startupWork = this.#schemaFloor === undefined &&
|
|
232
|
-
(
|
|
235
|
+
(countOutbox(this.#db) > 0 ||
|
|
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({
|
|
@@ -244,10 +247,10 @@ export class SyncClient {
|
|
|
244
247
|
role: () => 'direct',
|
|
245
248
|
outbox: async () => this.pendingCommits().length,
|
|
246
249
|
subscriptions: async () => this.subscriptions(),
|
|
247
|
-
conflicts: async () => this.conflicts.length,
|
|
248
|
-
rejections: async () => this.rejections.length,
|
|
249
|
-
syncNeeded: async () => this.syncNeeded,
|
|
250
|
-
upgrading: async () => this.upgrading,
|
|
250
|
+
conflicts: async () => this.conflicts().length,
|
|
251
|
+
rejections: async () => this.rejections().length,
|
|
252
|
+
syncNeeded: async () => this.statusSnapshot().syncNeeded,
|
|
253
|
+
upgrading: async () => this.statusSnapshot().upgrading,
|
|
251
254
|
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
252
255
|
});
|
|
253
256
|
this.#emitDiagnostics();
|
|
@@ -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,9 +359,43 @@ 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
|
+
securityLifecycle() {
|
|
338
399
|
return this.#securityLifecycle;
|
|
339
400
|
}
|
|
340
401
|
/**
|
|
@@ -378,12 +439,12 @@ export class SyncClient {
|
|
|
378
439
|
this.#encryption = options.encryption;
|
|
379
440
|
this.#securityLifecycle = 'active';
|
|
380
441
|
const startupWork = this.#schemaFloor === undefined &&
|
|
381
|
-
(
|
|
442
|
+
(countOutbox(this.#db) > 0 ||
|
|
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);
|
|
@@ -591,7 +666,7 @@ export class SyncClient {
|
|
|
591
666
|
replica: {
|
|
592
667
|
localRevision: getLocalRevision(this.#db).toString(),
|
|
593
668
|
syncNeeded: this.#needsPull,
|
|
594
|
-
pendingOutbox:
|
|
669
|
+
pendingOutbox: countOutbox(this.#db),
|
|
595
670
|
},
|
|
596
671
|
lease: leaseState,
|
|
597
672
|
subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
|
|
@@ -682,7 +757,7 @@ export class SyncClient {
|
|
|
682
757
|
#statusSnapshot(outboxCount) {
|
|
683
758
|
return {
|
|
684
759
|
currentSchemaVersion: this.#config.schema.version,
|
|
685
|
-
outbox: outboxCount ??
|
|
760
|
+
outbox: outboxCount ?? countOutbox(this.#db),
|
|
686
761
|
upgrading: this.#upgrading,
|
|
687
762
|
leaseState: this.#leaseState,
|
|
688
763
|
schemaFloor: this.#schemaFloor,
|
|
@@ -919,11 +994,11 @@ export class SyncClient {
|
|
|
919
994
|
}
|
|
920
995
|
await transport.upload(blobId, bytes, mediaType);
|
|
921
996
|
}
|
|
922
|
-
|
|
997
|
+
conflicts() {
|
|
923
998
|
this.#requireActive();
|
|
924
999
|
return this.#conflicts;
|
|
925
1000
|
}
|
|
926
|
-
|
|
1001
|
+
rejections() {
|
|
927
1002
|
this.#requireActive();
|
|
928
1003
|
return this.#rejections;
|
|
929
1004
|
}
|
|
@@ -987,27 +1062,6 @@ export class SyncClient {
|
|
|
987
1062
|
return resolved;
|
|
988
1063
|
});
|
|
989
1064
|
}
|
|
990
|
-
/** Non-undefined once the server declared a schema floor (§1.6). */
|
|
991
|
-
get schemaFloor() {
|
|
992
|
-
return this.#schemaFloor;
|
|
993
|
-
}
|
|
994
|
-
/**
|
|
995
|
-
* §7.4.5: true while a schema-bump reset + first re-bootstrap is in
|
|
996
|
-
* flight — the app's "upgrading…" cue. Clears when the first post-reset
|
|
997
|
-
* bootstrap round reaches idle (every subscription past its fresh
|
|
998
|
-
* bootstrap).
|
|
999
|
-
*/
|
|
1000
|
-
get upgrading() {
|
|
1001
|
-
return this.#upgrading;
|
|
1002
|
-
}
|
|
1003
|
-
/**
|
|
1004
|
-
* §7.3.5: the current auth-lease state (opaque). Undefined until a
|
|
1005
|
-
* `LEASE` frame arrives. `errorCode` is set when a round was rejected
|
|
1006
|
-
* with a request-level lease code — syncing on the lease has stopped.
|
|
1007
|
-
*/
|
|
1008
|
-
get leaseState() {
|
|
1009
|
-
return this.#leaseState;
|
|
1010
|
-
}
|
|
1011
1065
|
/** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
|
|
1012
1066
|
* `undefined` if no lease is held. Negative once expired. */
|
|
1013
1067
|
leaseRemainingMs(now = this.#now()) {
|
|
@@ -1018,10 +1072,6 @@ export class SyncClient {
|
|
|
1018
1072
|
get stopped() {
|
|
1019
1073
|
return this.#schemaFloor !== undefined;
|
|
1020
1074
|
}
|
|
1021
|
-
/** §8: a hello/wake-up asked for a pull that has not run yet. */
|
|
1022
|
-
get syncNeeded() {
|
|
1023
|
-
return this.#needsPull;
|
|
1024
|
-
}
|
|
1025
1075
|
/**
|
|
1026
1076
|
* §8.6 presence on a scope key: the current peers present there (a map
|
|
1027
1077
|
* of `actorId clientId` → peer). Empty for a key with no present peers.
|
|
@@ -1119,11 +1169,17 @@ export class SyncClient {
|
|
|
1119
1169
|
cursor: -1,
|
|
1120
1170
|
status: 'active',
|
|
1121
1171
|
});
|
|
1172
|
+
this.#setSyncNeeded(true);
|
|
1173
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1122
1174
|
this.#emitDiagnostics();
|
|
1123
1175
|
}
|
|
1124
1176
|
unsubscribe(id) {
|
|
1125
1177
|
this.#requireActive();
|
|
1178
|
+
if (getSubscription(this.#db, id) === undefined)
|
|
1179
|
+
return;
|
|
1126
1180
|
deleteSubscription(this.#db, id);
|
|
1181
|
+
this.#setSyncNeeded(true);
|
|
1182
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1127
1183
|
this.#emitDiagnostics();
|
|
1128
1184
|
}
|
|
1129
1185
|
// -- windowed subscriptions (§4.8) ------------------------------------------
|
|
@@ -1179,7 +1235,9 @@ export class SyncClient {
|
|
|
1179
1235
|
status: 'active',
|
|
1180
1236
|
});
|
|
1181
1237
|
});
|
|
1238
|
+
this.#needsPull = true;
|
|
1182
1239
|
batch.window(baseKey, base.table, unit);
|
|
1240
|
+
batch.status();
|
|
1183
1241
|
});
|
|
1184
1242
|
changed = true;
|
|
1185
1243
|
widened = true;
|
|
@@ -1195,6 +1253,9 @@ export class SyncClient {
|
|
|
1195
1253
|
const effects = {
|
|
1196
1254
|
sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
|
|
1197
1255
|
};
|
|
1256
|
+
if (effects.sync.kind === 'interactive') {
|
|
1257
|
+
this.#emitSyncIntent(effects.sync);
|
|
1258
|
+
}
|
|
1198
1259
|
return { value: undefined, effects };
|
|
1199
1260
|
}
|
|
1200
1261
|
/**
|
|
@@ -1254,6 +1315,8 @@ export class SyncClient {
|
|
|
1254
1315
|
});
|
|
1255
1316
|
batch.scopeMap(table, effective);
|
|
1256
1317
|
batch.window(baseKey, table.name, unit);
|
|
1318
|
+
this.#needsPull = true;
|
|
1319
|
+
batch.status();
|
|
1257
1320
|
});
|
|
1258
1321
|
}
|
|
1259
1322
|
/**
|
|
@@ -1349,7 +1412,9 @@ export class SyncClient {
|
|
|
1349
1412
|
this.#applyOperationsLocally(operations, batch);
|
|
1350
1413
|
batch.status();
|
|
1351
1414
|
});
|
|
1415
|
+
this.#needsPull = true;
|
|
1352
1416
|
});
|
|
1417
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1353
1418
|
return clientCommitId;
|
|
1354
1419
|
}
|
|
1355
1420
|
/** Host-facing mutation result with explicit network work intent (§7.5). */
|
|
@@ -1577,8 +1642,8 @@ export class SyncClient {
|
|
|
1577
1642
|
this.#localResetEpoch += 1;
|
|
1578
1643
|
if (!priorUpgrading)
|
|
1579
1644
|
this.#config.onUpgrading?.(true);
|
|
1580
|
-
this.#
|
|
1581
|
-
this.#
|
|
1645
|
+
this.#emitSyncNeeded('startup');
|
|
1646
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1582
1647
|
return {
|
|
1583
1648
|
alreadyApplied: false,
|
|
1584
1649
|
retainedCommits: pending.length,
|
|
@@ -1641,21 +1706,27 @@ export class SyncClient {
|
|
|
1641
1706
|
* the encoded push frames index-aligned with the surviving `outbox`.
|
|
1642
1707
|
*/
|
|
1643
1708
|
async #encodeOutboxForPush() {
|
|
1644
|
-
|
|
1709
|
+
// Pin before the first encryption await: mutations can append while a
|
|
1710
|
+
// round is encoding, and belong to the next request.
|
|
1711
|
+
const bounds = this.#db.query('SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox')[0];
|
|
1712
|
+
const pendingCount = bounds.count;
|
|
1713
|
+
const throughSeq = bounds.last_seq ?? 0;
|
|
1645
1714
|
const pushFrames = [];
|
|
1646
1715
|
const outbox = [];
|
|
1647
1716
|
let deferred = 0;
|
|
1648
1717
|
let ops = 0;
|
|
1649
|
-
|
|
1718
|
+
let processed = 0;
|
|
1719
|
+
for (const commit of iterateOutbox(this.#db, throughSeq)) {
|
|
1650
1720
|
// §6.1 splitBatch: whole commits in commit order, stopping before the
|
|
1651
1721
|
// per-request operation cap. A first commit that alone exceeds the cap
|
|
1652
1722
|
// is sent alone — the server rejects it loudly rather than the queue
|
|
1653
1723
|
// wedging silently. Deferred commits stay queued for the next round.
|
|
1654
1724
|
if (outbox.length > 0 &&
|
|
1655
1725
|
ops + commit.operations.length > MAX_OPS_PER_REQUEST) {
|
|
1656
|
-
deferred
|
|
1657
|
-
|
|
1726
|
+
deferred = pendingCount - processed;
|
|
1727
|
+
break;
|
|
1658
1728
|
}
|
|
1729
|
+
processed += 1;
|
|
1659
1730
|
try {
|
|
1660
1731
|
pushFrames.push(
|
|
1661
1732
|
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
@@ -1798,9 +1869,12 @@ export class SyncClient {
|
|
|
1798
1869
|
// survive it — the reference server keeps no replay buffer (§8.2).
|
|
1799
1870
|
this.#setSyncNeeded(false);
|
|
1800
1871
|
try {
|
|
1872
|
+
const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
1801
1873
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
1802
1874
|
// them, so the server-side existence check (§6.6) passes.
|
|
1803
|
-
if (
|
|
1875
|
+
if (logEpoch !== undefined &&
|
|
1876
|
+
this.#hasBlobs &&
|
|
1877
|
+
this.#config.blobs !== undefined) {
|
|
1804
1878
|
await this.flushBlobUploads();
|
|
1805
1879
|
}
|
|
1806
1880
|
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
@@ -1808,7 +1882,9 @@ export class SyncClient {
|
|
|
1808
1882
|
// is removed from the push and surfaced as a rejection, never wedging
|
|
1809
1883
|
// the queue. `pushFrames` and `outbox` stay index-aligned for result
|
|
1810
1884
|
// mapping.
|
|
1811
|
-
const { pushFrames, outbox, deferred } =
|
|
1885
|
+
const { pushFrames, outbox, deferred } = logEpoch === undefined
|
|
1886
|
+
? { pushFrames: [], outbox: [], deferred: 0 }
|
|
1887
|
+
: await this.#encodeOutboxForPush();
|
|
1812
1888
|
// Captured together with the subscription state below: the response
|
|
1813
1889
|
// apply persists SUB_END cursors only while this epoch is current.
|
|
1814
1890
|
const resetEpoch = this.#localResetEpoch;
|
|
@@ -1819,6 +1895,7 @@ export class SyncClient {
|
|
|
1819
1895
|
type: 'REQ_HEADER',
|
|
1820
1896
|
clientId: this.#clientId,
|
|
1821
1897
|
schemaVersion: this.#schema.version,
|
|
1898
|
+
...(logEpoch !== undefined ? { logEpoch } : {}),
|
|
1822
1899
|
},
|
|
1823
1900
|
...pushFrames,
|
|
1824
1901
|
{
|
|
@@ -1885,12 +1962,7 @@ export class SyncClient {
|
|
|
1885
1962
|
delayMs: this.#retryDelayMs,
|
|
1886
1963
|
};
|
|
1887
1964
|
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
|
-
}
|
|
1965
|
+
this.#emitSyncIntent(intent);
|
|
1894
1966
|
}
|
|
1895
1967
|
throw error;
|
|
1896
1968
|
}
|
|
@@ -1912,7 +1984,8 @@ export class SyncClient {
|
|
|
1912
1984
|
last.segmentRowsApplied === 0 &&
|
|
1913
1985
|
last.bootstrapping.length === 0 &&
|
|
1914
1986
|
last.resets.length === 0 &&
|
|
1915
|
-
(last.deferredCommits ?? 0) === 0
|
|
1987
|
+
(last.deferredCommits ?? 0) === 0 &&
|
|
1988
|
+
!this.#needsPull) {
|
|
1916
1989
|
return last;
|
|
1917
1990
|
}
|
|
1918
1991
|
}
|
|
@@ -2078,14 +2151,14 @@ export class SyncClient {
|
|
|
2078
2151
|
if (event.event === 'hello') {
|
|
2079
2152
|
if (event.data.requiresSync) {
|
|
2080
2153
|
this.#setSyncNeeded(true);
|
|
2081
|
-
this.#
|
|
2154
|
+
this.#emitSyncNeeded('hello');
|
|
2082
2155
|
}
|
|
2083
2156
|
return;
|
|
2084
2157
|
}
|
|
2085
2158
|
if (event.event === 'sync') {
|
|
2086
2159
|
// §8.3: any wake-up means "run a pull soon", never data.
|
|
2087
2160
|
this.#setSyncNeeded(true);
|
|
2088
|
-
this.#
|
|
2161
|
+
this.#emitSyncNeeded(event.data.reason);
|
|
2089
2162
|
return;
|
|
2090
2163
|
}
|
|
2091
2164
|
if (event.event === 'presence') {
|
|
@@ -2155,7 +2228,7 @@ export class SyncClient {
|
|
|
2155
2228
|
catch {
|
|
2156
2229
|
// A delta that cannot be applied is recovered by a pull (§8.3).
|
|
2157
2230
|
this.#setSyncNeeded(true);
|
|
2158
|
-
this.#
|
|
2231
|
+
this.#emitSyncNeeded('catchup-required');
|
|
2159
2232
|
}
|
|
2160
2233
|
});
|
|
2161
2234
|
}
|
|
@@ -2195,6 +2268,11 @@ export class SyncClient {
|
|
|
2195
2268
|
if (header?.type !== 'RESP_HEADER') {
|
|
2196
2269
|
throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
|
|
2197
2270
|
}
|
|
2271
|
+
if (message.wireVersion < 2 ||
|
|
2272
|
+
header.logEpoch === undefined ||
|
|
2273
|
+
header.resetRequired === undefined) {
|
|
2274
|
+
throw new ClientSyncError('client.invalid_host_response', 'the server response does not carry wire version 2 log-epoch state');
|
|
2275
|
+
}
|
|
2198
2276
|
if (header.requiredSchemaVersion !== undefined) {
|
|
2199
2277
|
// §1.6 schema floor: nothing else was processed — stop syncing and
|
|
2200
2278
|
// surface the upgrade requirement. A live-round floor always stops:
|
|
@@ -2216,6 +2294,20 @@ export class SyncClient {
|
|
|
2216
2294
|
schemaFloor,
|
|
2217
2295
|
};
|
|
2218
2296
|
}
|
|
2297
|
+
const currentLogEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
2298
|
+
if (header.resetRequired) {
|
|
2299
|
+
if (mode !== 'pull' || message.frames.length !== 1) {
|
|
2300
|
+
throw new ClientSyncError('client.invalid_host_response', 'a log-epoch reset response must contain only RESP_HEADER');
|
|
2301
|
+
}
|
|
2302
|
+
return {
|
|
2303
|
+
...summary,
|
|
2304
|
+
resets: this.#runLogEpochReset(header.logEpoch),
|
|
2305
|
+
bootstrapping: [],
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
if (currentLogEpoch === undefined || currentLogEpoch !== header.logEpoch) {
|
|
2309
|
+
throw new ClientSyncError('client.invalid_host_response', 'the server changed logEpoch without requiring a reset');
|
|
2310
|
+
}
|
|
2219
2311
|
let section;
|
|
2220
2312
|
let errorFrame;
|
|
2221
2313
|
let deltaCursor = -1;
|
|
@@ -2237,7 +2329,7 @@ export class SyncClient {
|
|
|
2237
2329
|
});
|
|
2238
2330
|
break;
|
|
2239
2331
|
case 'PUSH_RESULT': {
|
|
2240
|
-
let outboxCount = responseOutboxCount ??
|
|
2332
|
+
let outboxCount = responseOutboxCount ?? countOutbox(this.#db);
|
|
2241
2333
|
this.#applyBatch((batch) => {
|
|
2242
2334
|
const drained = this.#handlePushResult(frame, commitsById, summary, batch, rejectionDetailsByCommit.get(frame.clientCommitId), frame === lastFinalPushResult);
|
|
2243
2335
|
if (drained)
|
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/outbox.d.ts
CHANGED
|
@@ -38,8 +38,12 @@ export interface OutboxBeforeImage {
|
|
|
38
38
|
readonly values?: Readonly<Record<string, JsonRowValue>>;
|
|
39
39
|
}
|
|
40
40
|
export declare function appendOutboxCommit(db: ClientDatabase, clientCommitId: string, operations: readonly OutboxOperation[], nowMs: number, beforeImages?: readonly OutboxBeforeImage[]): void;
|
|
41
|
-
/** Pending commits in FIFO creation order (§7.1). */
|
|
41
|
+
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
42
42
|
export declare function listOutbox(db: ClientDatabase): OutboxCommit[];
|
|
43
|
+
/** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
|
|
44
|
+
export declare function iterateOutbox(db: ClientDatabase, throughSeq: number): Generator<OutboxCommit>;
|
|
45
|
+
/** Routine status reads never load operation bodies. */
|
|
46
|
+
export declare function countOutbox(db: ClientDatabase): number;
|
|
43
47
|
export declare function deleteOutboxCommit(db: ClientDatabase, clientCommitId: string): void;
|
|
44
48
|
export declare function listOutboxBeforeImages(db: ClientDatabase, clientCommitId: string): OutboxBeforeImage[];
|
|
45
49
|
export declare function replaceOutboxBeforeImages(db: ClientDatabase, clientCommitId: string, replacements: readonly OutboxBeforeImage[]): void;
|