@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/src/client.ts
CHANGED
|
@@ -119,6 +119,8 @@ import {
|
|
|
119
119
|
dropOutboxCommitsInScope,
|
|
120
120
|
encodeOutboxCommit,
|
|
121
121
|
listOutbox,
|
|
122
|
+
iterateOutbox,
|
|
123
|
+
countOutbox,
|
|
122
124
|
listOutboxBeforeImages,
|
|
123
125
|
type OutboxBeforeImage,
|
|
124
126
|
type OutboxCommit,
|
|
@@ -513,6 +515,8 @@ function emptySummary(pushed: number): MutableSummary {
|
|
|
513
515
|
};
|
|
514
516
|
}
|
|
515
517
|
|
|
518
|
+
const LOG_EPOCH_META_KEY = 'logEpoch';
|
|
519
|
+
|
|
516
520
|
function isFinalPushResult(frame: PushResultFrame): boolean {
|
|
517
521
|
return (
|
|
518
522
|
frame.status !== 'rejected' ||
|
|
@@ -525,6 +529,37 @@ function isFinalPushResult(frame: PushResultFrame): boolean {
|
|
|
525
529
|
);
|
|
526
530
|
}
|
|
527
531
|
|
|
532
|
+
/** Canonical client reads, shared by synchronous cores and promise hosts. */
|
|
533
|
+
export type ClientSnapshotMethods = Pick<
|
|
534
|
+
SyncClient,
|
|
535
|
+
| 'querySnapshot'
|
|
536
|
+
| 'statusSnapshot'
|
|
537
|
+
| 'diagnosticsSnapshot'
|
|
538
|
+
| 'conflicts'
|
|
539
|
+
| 'rejections'
|
|
540
|
+
| 'commitOutcome'
|
|
541
|
+
| 'commitOutcomes'
|
|
542
|
+
| 'resolveCommitOutcome'
|
|
543
|
+
>;
|
|
544
|
+
|
|
545
|
+
/** Project a method contract across an asynchronous host boundary. */
|
|
546
|
+
export type PromiseMethods<Methods> = {
|
|
547
|
+
[Key in keyof Methods]: Methods[Key] extends (
|
|
548
|
+
...args: infer Args
|
|
549
|
+
) => infer Result
|
|
550
|
+
? (...args: Args) => Promise<Awaited<Result>>
|
|
551
|
+
: never;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
/** A reader can execute locally or cross a worker/native boundary. */
|
|
555
|
+
export type ClientSnapshotReader = {
|
|
556
|
+
[Key in keyof ClientSnapshotMethods]: (
|
|
557
|
+
...args: Parameters<ClientSnapshotMethods[Key]>
|
|
558
|
+
) =>
|
|
559
|
+
| ReturnType<ClientSnapshotMethods[Key]>
|
|
560
|
+
| Promise<ReturnType<ClientSnapshotMethods[Key]>>;
|
|
561
|
+
};
|
|
562
|
+
|
|
528
563
|
export class SyncClient {
|
|
529
564
|
readonly #config: SyncClientConfig;
|
|
530
565
|
readonly #db: ClientDatabase;
|
|
@@ -579,6 +614,10 @@ export class SyncClient {
|
|
|
579
614
|
readonly #invalidation = new InvalidationEmitter();
|
|
580
615
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
581
616
|
readonly #presenceListeners = new Set<(scopeKey: string) => void>();
|
|
617
|
+
readonly #syncNeededListeners = new Set<
|
|
618
|
+
(reason: 'startup' | 'hello' | WakeReason) => void
|
|
619
|
+
>();
|
|
620
|
+
readonly #syncIntentListeners = new Set<(intent: SyncIntent) => void>();
|
|
582
621
|
readonly #diagnostics = new ClientDiagnosticsEmitter();
|
|
583
622
|
#diagnosticsDeferralDepth = 0;
|
|
584
623
|
#diagnosticsPending = false;
|
|
@@ -701,12 +740,12 @@ export class SyncClient {
|
|
|
701
740
|
// an application-issued sync() call.
|
|
702
741
|
const startupWork =
|
|
703
742
|
this.#schemaFloor === undefined &&
|
|
704
|
-
(
|
|
743
|
+
(countOutbox(this.#db) > 0 ||
|
|
705
744
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
706
745
|
if (startupWork && this.#securityLifecycle === 'active') {
|
|
707
746
|
this.#needsPull = true;
|
|
708
|
-
this.#
|
|
709
|
-
this.#
|
|
747
|
+
this.#emitSyncNeeded('startup');
|
|
748
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
710
749
|
}
|
|
711
750
|
// Console introspection is a no-op outside a dev page.
|
|
712
751
|
this.#devtoolsUnregister = registerDevtools({
|
|
@@ -716,10 +755,10 @@ export class SyncClient {
|
|
|
716
755
|
role: () => 'direct',
|
|
717
756
|
outbox: async () => this.pendingCommits().length,
|
|
718
757
|
subscriptions: async () => this.subscriptions(),
|
|
719
|
-
conflicts: async () => this.conflicts.length,
|
|
720
|
-
rejections: async () => this.rejections.length,
|
|
721
|
-
syncNeeded: async () => this.syncNeeded,
|
|
722
|
-
upgrading: async () => this.upgrading,
|
|
758
|
+
conflicts: async () => this.conflicts().length,
|
|
759
|
+
rejections: async () => this.rejections().length,
|
|
760
|
+
syncNeeded: async () => this.statusSnapshot().syncNeeded,
|
|
761
|
+
upgrading: async () => this.statusSnapshot().upgrading,
|
|
723
762
|
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
724
763
|
});
|
|
725
764
|
this.#emitDiagnostics();
|
|
@@ -776,6 +815,29 @@ export class SyncClient {
|
|
|
776
815
|
this.#replayOutbox();
|
|
777
816
|
}
|
|
778
817
|
|
|
818
|
+
/** §2.1 reset after the server reports a different log continuity. */
|
|
819
|
+
#runLogEpochReset(logEpoch: string): string[] {
|
|
820
|
+
const subscriptions = loadSubscriptions(this.#db);
|
|
821
|
+
const pending = listOutbox(this.#db);
|
|
822
|
+
this.#setUpgrading(true);
|
|
823
|
+
this.#applyBatch((batch) => {
|
|
824
|
+
this.#db.transaction(() => {
|
|
825
|
+
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
826
|
+
resetSubscriptionsForBump(this.#db);
|
|
827
|
+
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
828
|
+
for (const commit of pending) {
|
|
829
|
+
this.#applyOperationsLocally(commit.operations, batch);
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
for (const table of this.#schema.tables.values()) batch.table(table.name);
|
|
833
|
+
});
|
|
834
|
+
this.#localResetEpoch += 1;
|
|
835
|
+
this.#setSyncNeeded(true);
|
|
836
|
+
this.#emitSyncNeeded('startup');
|
|
837
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
838
|
+
return subscriptions.map((subscription) => subscription.id);
|
|
839
|
+
}
|
|
840
|
+
|
|
779
841
|
#setUpgrading(upgrading: boolean): void {
|
|
780
842
|
if (this.#upgrading === upgrading) return;
|
|
781
843
|
this.#applyBatch((batch) => {
|
|
@@ -803,6 +865,7 @@ export class SyncClient {
|
|
|
803
865
|
}
|
|
804
866
|
|
|
805
867
|
async close(): Promise<void> {
|
|
868
|
+
this.#emitSyncIntent({ kind: 'none' });
|
|
806
869
|
this.#devtoolsUnregister?.();
|
|
807
870
|
this.#devtoolsUnregister = undefined;
|
|
808
871
|
this.disconnectRealtime();
|
|
@@ -810,10 +873,42 @@ export class SyncClient {
|
|
|
810
873
|
await this.#lease?.release();
|
|
811
874
|
this.#lease = undefined;
|
|
812
875
|
this.#started = false;
|
|
876
|
+
this.#syncNeededListeners.clear();
|
|
877
|
+
this.#syncIntentListeners.clear();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
#emitSyncNeeded(reason: 'startup' | 'hello' | WakeReason): void {
|
|
881
|
+
try {
|
|
882
|
+
this.#config.onSyncNeeded?.(reason);
|
|
883
|
+
} catch {
|
|
884
|
+
// An observer cannot alter sync correctness.
|
|
885
|
+
}
|
|
886
|
+
for (const listener of this.#syncNeededListeners) {
|
|
887
|
+
try {
|
|
888
|
+
listener(reason);
|
|
889
|
+
} catch {
|
|
890
|
+
// An observer cannot alter sync correctness.
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
#emitSyncIntent(intent: SyncIntent): void {
|
|
896
|
+
try {
|
|
897
|
+
this.#config.onSyncIntent?.(intent);
|
|
898
|
+
} catch {
|
|
899
|
+
// An observer cannot alter sync correctness.
|
|
900
|
+
}
|
|
901
|
+
for (const listener of this.#syncIntentListeners) {
|
|
902
|
+
try {
|
|
903
|
+
listener(intent);
|
|
904
|
+
} catch {
|
|
905
|
+
// An observer cannot alter sync correctness.
|
|
906
|
+
}
|
|
907
|
+
}
|
|
813
908
|
}
|
|
814
909
|
|
|
815
910
|
/** Current fail-closed local-replica security state. */
|
|
816
|
-
|
|
911
|
+
securityLifecycle(): SecurityLifecycle {
|
|
817
912
|
return this.#securityLifecycle;
|
|
818
913
|
}
|
|
819
914
|
|
|
@@ -865,12 +960,12 @@ export class SyncClient {
|
|
|
865
960
|
this.#securityLifecycle = 'active';
|
|
866
961
|
const startupWork =
|
|
867
962
|
this.#schemaFloor === undefined &&
|
|
868
|
-
(
|
|
963
|
+
(countOutbox(this.#db) > 0 ||
|
|
869
964
|
loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
|
|
870
965
|
if (startupWork) {
|
|
871
966
|
this.#setSyncNeeded(true);
|
|
872
|
-
this.#
|
|
873
|
-
this.#
|
|
967
|
+
this.#emitSyncNeeded('startup');
|
|
968
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
874
969
|
}
|
|
875
970
|
this.#emitDiagnostics();
|
|
876
971
|
}
|
|
@@ -982,6 +1077,24 @@ export class SyncClient {
|
|
|
982
1077
|
return this.#changes.on(listener);
|
|
983
1078
|
}
|
|
984
1079
|
|
|
1080
|
+
/** Subscribe to host wake signals raised by startup and realtime. */
|
|
1081
|
+
onSyncNeeded(
|
|
1082
|
+
listener: (reason: 'startup' | 'hello' | WakeReason) => void,
|
|
1083
|
+
): () => void {
|
|
1084
|
+
this.#syncNeededListeners.add(listener);
|
|
1085
|
+
return () => {
|
|
1086
|
+
this.#syncNeededListeners.delete(listener);
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Subscribe to exact core-owned scheduling instructions. */
|
|
1091
|
+
onSyncIntent(listener: (intent: SyncIntent) => void): () => void {
|
|
1092
|
+
this.#syncIntentListeners.add(listener);
|
|
1093
|
+
return () => {
|
|
1094
|
+
this.#syncIntentListeners.delete(listener);
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
985
1098
|
/** Subscribe to complete, privacy-safe diagnostic snapshots. */
|
|
986
1099
|
onDiagnostics(listener: ClientDiagnosticsListener): () => void {
|
|
987
1100
|
return this.#diagnostics.on(listener);
|
|
@@ -1114,7 +1227,7 @@ export class SyncClient {
|
|
|
1114
1227
|
replica: {
|
|
1115
1228
|
localRevision: getLocalRevision(this.#db).toString(),
|
|
1116
1229
|
syncNeeded: this.#needsPull,
|
|
1117
|
-
pendingOutbox:
|
|
1230
|
+
pendingOutbox: countOutbox(this.#db),
|
|
1118
1231
|
},
|
|
1119
1232
|
lease: leaseState,
|
|
1120
1233
|
subscriptions: allSubscriptions.slice(
|
|
@@ -1230,7 +1343,7 @@ export class SyncClient {
|
|
|
1230
1343
|
#statusSnapshot(outboxCount?: number): SyncStatusSnapshot {
|
|
1231
1344
|
return {
|
|
1232
1345
|
currentSchemaVersion: this.#config.schema.version,
|
|
1233
|
-
outbox: outboxCount ??
|
|
1346
|
+
outbox: outboxCount ?? countOutbox(this.#db),
|
|
1234
1347
|
upgrading: this.#upgrading,
|
|
1235
1348
|
leaseState: this.#leaseState,
|
|
1236
1349
|
schemaFloor: this.#schemaFloor,
|
|
@@ -1526,12 +1639,12 @@ export class SyncClient {
|
|
|
1526
1639
|
await transport.upload(blobId, bytes, mediaType);
|
|
1527
1640
|
}
|
|
1528
1641
|
|
|
1529
|
-
|
|
1642
|
+
conflicts(): readonly ConflictRecord[] {
|
|
1530
1643
|
this.#requireActive();
|
|
1531
1644
|
return this.#conflicts;
|
|
1532
1645
|
}
|
|
1533
1646
|
|
|
1534
|
-
|
|
1647
|
+
rejections(): readonly RejectionRecord[] {
|
|
1535
1648
|
this.#requireActive();
|
|
1536
1649
|
return this.#rejections;
|
|
1537
1650
|
}
|
|
@@ -1622,30 +1735,6 @@ export class SyncClient {
|
|
|
1622
1735
|
});
|
|
1623
1736
|
}
|
|
1624
1737
|
|
|
1625
|
-
/** Non-undefined once the server declared a schema floor (§1.6). */
|
|
1626
|
-
get schemaFloor(): SchemaFloor | undefined {
|
|
1627
|
-
return this.#schemaFloor;
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
/**
|
|
1631
|
-
* §7.4.5: true while a schema-bump reset + first re-bootstrap is in
|
|
1632
|
-
* flight — the app's "upgrading…" cue. Clears when the first post-reset
|
|
1633
|
-
* bootstrap round reaches idle (every subscription past its fresh
|
|
1634
|
-
* bootstrap).
|
|
1635
|
-
*/
|
|
1636
|
-
get upgrading(): boolean {
|
|
1637
|
-
return this.#upgrading;
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
/**
|
|
1641
|
-
* §7.3.5: the current auth-lease state (opaque). Undefined until a
|
|
1642
|
-
* `LEASE` frame arrives. `errorCode` is set when a round was rejected
|
|
1643
|
-
* with a request-level lease code — syncing on the lease has stopped.
|
|
1644
|
-
*/
|
|
1645
|
-
get leaseState(): LeaseState | undefined {
|
|
1646
|
-
return this.#leaseState;
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
1738
|
/** §7.3.5: remaining lease validity in ms (`expiresAtMs − now`), or
|
|
1650
1739
|
* `undefined` if no lease is held. Negative once expired. */
|
|
1651
1740
|
leaseRemainingMs(now: number = this.#now()): number | undefined {
|
|
@@ -1658,11 +1747,6 @@ export class SyncClient {
|
|
|
1658
1747
|
return this.#schemaFloor !== undefined;
|
|
1659
1748
|
}
|
|
1660
1749
|
|
|
1661
|
-
/** §8: a hello/wake-up asked for a pull that has not run yet. */
|
|
1662
|
-
get syncNeeded(): boolean {
|
|
1663
|
-
return this.#needsPull;
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
1750
|
/**
|
|
1667
1751
|
* §8.6 presence on a scope key: the current peers present there (a map
|
|
1668
1752
|
* of `actorId clientId` → peer). Empty for a key with no present peers.
|
|
@@ -1781,12 +1865,17 @@ export class SyncClient {
|
|
|
1781
1865
|
cursor: -1,
|
|
1782
1866
|
status: 'active',
|
|
1783
1867
|
});
|
|
1868
|
+
this.#setSyncNeeded(true);
|
|
1869
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1784
1870
|
this.#emitDiagnostics();
|
|
1785
1871
|
}
|
|
1786
1872
|
|
|
1787
1873
|
unsubscribe(id: string): void {
|
|
1788
1874
|
this.#requireActive();
|
|
1875
|
+
if (getSubscription(this.#db, id) === undefined) return;
|
|
1789
1876
|
deleteSubscription(this.#db, id);
|
|
1877
|
+
this.#setSyncNeeded(true);
|
|
1878
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
1790
1879
|
this.#emitDiagnostics();
|
|
1791
1880
|
}
|
|
1792
1881
|
|
|
@@ -1851,7 +1940,9 @@ export class SyncClient {
|
|
|
1851
1940
|
status: 'active',
|
|
1852
1941
|
});
|
|
1853
1942
|
});
|
|
1943
|
+
this.#needsPull = true;
|
|
1854
1944
|
batch.window(baseKey, base.table, unit);
|
|
1945
|
+
batch.status();
|
|
1855
1946
|
});
|
|
1856
1947
|
changed = true;
|
|
1857
1948
|
widened = true;
|
|
@@ -1867,6 +1958,9 @@ export class SyncClient {
|
|
|
1867
1958
|
const effects: CommandEffects = {
|
|
1868
1959
|
sync: changed || widened ? { kind: 'interactive' } : { kind: 'none' },
|
|
1869
1960
|
};
|
|
1961
|
+
if (effects.sync.kind === 'interactive') {
|
|
1962
|
+
this.#emitSyncIntent(effects.sync);
|
|
1963
|
+
}
|
|
1870
1964
|
return { value: undefined, effects };
|
|
1871
1965
|
}
|
|
1872
1966
|
|
|
@@ -1934,6 +2028,8 @@ export class SyncClient {
|
|
|
1934
2028
|
});
|
|
1935
2029
|
batch.scopeMap(table, effective);
|
|
1936
2030
|
batch.window(baseKey, table.name, unit);
|
|
2031
|
+
this.#needsPull = true;
|
|
2032
|
+
batch.status();
|
|
1937
2033
|
});
|
|
1938
2034
|
}
|
|
1939
2035
|
|
|
@@ -2043,7 +2139,9 @@ export class SyncClient {
|
|
|
2043
2139
|
this.#applyOperationsLocally(operations, batch);
|
|
2044
2140
|
batch.status();
|
|
2045
2141
|
});
|
|
2142
|
+
this.#needsPull = true;
|
|
2046
2143
|
});
|
|
2144
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
2047
2145
|
return clientCommitId;
|
|
2048
2146
|
}
|
|
2049
2147
|
|
|
@@ -2320,8 +2418,8 @@ export class SyncClient {
|
|
|
2320
2418
|
this.#localResetEpoch += 1;
|
|
2321
2419
|
|
|
2322
2420
|
if (!priorUpgrading) this.#config.onUpgrading?.(true);
|
|
2323
|
-
this.#
|
|
2324
|
-
this.#
|
|
2421
|
+
this.#emitSyncNeeded('startup');
|
|
2422
|
+
this.#emitSyncIntent({ kind: 'interactive' });
|
|
2325
2423
|
return {
|
|
2326
2424
|
alreadyApplied: false,
|
|
2327
2425
|
retainedCommits: pending.length,
|
|
@@ -2408,12 +2506,19 @@ export class SyncClient {
|
|
|
2408
2506
|
outbox: OutboxCommit[];
|
|
2409
2507
|
deferred: number;
|
|
2410
2508
|
}> {
|
|
2411
|
-
|
|
2509
|
+
// Pin before the first encryption await: mutations can append while a
|
|
2510
|
+
// round is encoding, and belong to the next request.
|
|
2511
|
+
const bounds = this.#db.query(
|
|
2512
|
+
'SELECT COUNT(*) AS count, MAX(seq) AS last_seq FROM _syncular_outbox',
|
|
2513
|
+
)[0]!;
|
|
2514
|
+
const pendingCount = bounds.count as number;
|
|
2515
|
+
const throughSeq = (bounds.last_seq as number | null) ?? 0;
|
|
2412
2516
|
const pushFrames: RequestFrame[] = [];
|
|
2413
2517
|
const outbox: OutboxCommit[] = [];
|
|
2414
2518
|
let deferred = 0;
|
|
2415
2519
|
let ops = 0;
|
|
2416
|
-
|
|
2520
|
+
let processed = 0;
|
|
2521
|
+
for (const commit of iterateOutbox(this.#db, throughSeq)) {
|
|
2417
2522
|
// §6.1 splitBatch: whole commits in commit order, stopping before the
|
|
2418
2523
|
// per-request operation cap. A first commit that alone exceeds the cap
|
|
2419
2524
|
// is sent alone — the server rejects it loudly rather than the queue
|
|
@@ -2422,9 +2527,10 @@ export class SyncClient {
|
|
|
2422
2527
|
outbox.length > 0 &&
|
|
2423
2528
|
ops + commit.operations.length > MAX_OPS_PER_REQUEST
|
|
2424
2529
|
) {
|
|
2425
|
-
deferred
|
|
2426
|
-
|
|
2530
|
+
deferred = pendingCount - processed;
|
|
2531
|
+
break;
|
|
2427
2532
|
}
|
|
2533
|
+
processed += 1;
|
|
2428
2534
|
try {
|
|
2429
2535
|
pushFrames.push(
|
|
2430
2536
|
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
@@ -2585,9 +2691,14 @@ export class SyncClient {
|
|
|
2585
2691
|
// survive it — the reference server keeps no replay buffer (§8.2).
|
|
2586
2692
|
this.#setSyncNeeded(false);
|
|
2587
2693
|
try {
|
|
2694
|
+
const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
2588
2695
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
2589
2696
|
// them, so the server-side existence check (§6.6) passes.
|
|
2590
|
-
if (
|
|
2697
|
+
if (
|
|
2698
|
+
logEpoch !== undefined &&
|
|
2699
|
+
this.#hasBlobs &&
|
|
2700
|
+
this.#config.blobs !== undefined
|
|
2701
|
+
) {
|
|
2591
2702
|
await this.flushBlobUploads();
|
|
2592
2703
|
}
|
|
2593
2704
|
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
@@ -2596,7 +2707,9 @@ export class SyncClient {
|
|
|
2596
2707
|
// the queue. `pushFrames` and `outbox` stay index-aligned for result
|
|
2597
2708
|
// mapping.
|
|
2598
2709
|
const { pushFrames, outbox, deferred } =
|
|
2599
|
-
|
|
2710
|
+
logEpoch === undefined
|
|
2711
|
+
? { pushFrames: [], outbox: [], deferred: 0 }
|
|
2712
|
+
: await this.#encodeOutboxForPush();
|
|
2600
2713
|
// Captured together with the subscription state below: the response
|
|
2601
2714
|
// apply persists SUB_END cursors only while this epoch is current.
|
|
2602
2715
|
const resetEpoch = this.#localResetEpoch;
|
|
@@ -2609,6 +2722,7 @@ export class SyncClient {
|
|
|
2609
2722
|
type: 'REQ_HEADER',
|
|
2610
2723
|
clientId: this.#clientId,
|
|
2611
2724
|
schemaVersion: this.#schema.version,
|
|
2725
|
+
...(logEpoch !== undefined ? { logEpoch } : {}),
|
|
2612
2726
|
},
|
|
2613
2727
|
...pushFrames,
|
|
2614
2728
|
{
|
|
@@ -2686,11 +2800,7 @@ export class SyncClient {
|
|
|
2686
2800
|
delayMs: this.#retryDelayMs,
|
|
2687
2801
|
};
|
|
2688
2802
|
this.#retryDelayMs = Math.min(this.#retryDelayMs * 2, 30_000);
|
|
2689
|
-
|
|
2690
|
-
this.#config.onSyncIntent?.(intent);
|
|
2691
|
-
} catch {
|
|
2692
|
-
// An observer cannot alter sync correctness.
|
|
2693
|
-
}
|
|
2803
|
+
this.#emitSyncIntent(intent);
|
|
2694
2804
|
}
|
|
2695
2805
|
throw error;
|
|
2696
2806
|
} finally {
|
|
@@ -2712,7 +2822,8 @@ export class SyncClient {
|
|
|
2712
2822
|
last.segmentRowsApplied === 0 &&
|
|
2713
2823
|
last.bootstrapping.length === 0 &&
|
|
2714
2824
|
last.resets.length === 0 &&
|
|
2715
|
-
(last.deferredCommits ?? 0) === 0
|
|
2825
|
+
(last.deferredCommits ?? 0) === 0 &&
|
|
2826
|
+
!this.#needsPull
|
|
2716
2827
|
) {
|
|
2717
2828
|
return last;
|
|
2718
2829
|
}
|
|
@@ -2916,14 +3027,14 @@ export class SyncClient {
|
|
|
2916
3027
|
if (event.event === 'hello') {
|
|
2917
3028
|
if (event.data.requiresSync) {
|
|
2918
3029
|
this.#setSyncNeeded(true);
|
|
2919
|
-
this.#
|
|
3030
|
+
this.#emitSyncNeeded('hello');
|
|
2920
3031
|
}
|
|
2921
3032
|
return;
|
|
2922
3033
|
}
|
|
2923
3034
|
if (event.event === 'sync') {
|
|
2924
3035
|
// §8.3: any wake-up means "run a pull soon", never data.
|
|
2925
3036
|
this.#setSyncNeeded(true);
|
|
2926
|
-
this.#
|
|
3037
|
+
this.#emitSyncNeeded(event.data.reason);
|
|
2927
3038
|
return;
|
|
2928
3039
|
}
|
|
2929
3040
|
if (event.event === 'presence') {
|
|
@@ -2995,7 +3106,7 @@ export class SyncClient {
|
|
|
2995
3106
|
} catch {
|
|
2996
3107
|
// A delta that cannot be applied is recovered by a pull (§8.3).
|
|
2997
3108
|
this.#setSyncNeeded(true);
|
|
2998
|
-
this.#
|
|
3109
|
+
this.#emitSyncNeeded('catchup-required');
|
|
2999
3110
|
}
|
|
3000
3111
|
});
|
|
3001
3112
|
}
|
|
@@ -3057,6 +3168,16 @@ export class SyncClient {
|
|
|
3057
3168
|
if (header?.type !== 'RESP_HEADER') {
|
|
3058
3169
|
throw new ClientSyncError('sync.invalid_request', 'missing RESP_HEADER');
|
|
3059
3170
|
}
|
|
3171
|
+
if (
|
|
3172
|
+
message.wireVersion < 2 ||
|
|
3173
|
+
header.logEpoch === undefined ||
|
|
3174
|
+
header.resetRequired === undefined
|
|
3175
|
+
) {
|
|
3176
|
+
throw new ClientSyncError(
|
|
3177
|
+
'client.invalid_host_response',
|
|
3178
|
+
'the server response does not carry wire version 2 log-epoch state',
|
|
3179
|
+
);
|
|
3180
|
+
}
|
|
3060
3181
|
if (header.requiredSchemaVersion !== undefined) {
|
|
3061
3182
|
// §1.6 schema floor: nothing else was processed — stop syncing and
|
|
3062
3183
|
// surface the upgrade requirement. A live-round floor always stops:
|
|
@@ -3078,6 +3199,26 @@ export class SyncClient {
|
|
|
3078
3199
|
schemaFloor,
|
|
3079
3200
|
};
|
|
3080
3201
|
}
|
|
3202
|
+
const currentLogEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
3203
|
+
if (header.resetRequired) {
|
|
3204
|
+
if (mode !== 'pull' || message.frames.length !== 1) {
|
|
3205
|
+
throw new ClientSyncError(
|
|
3206
|
+
'client.invalid_host_response',
|
|
3207
|
+
'a log-epoch reset response must contain only RESP_HEADER',
|
|
3208
|
+
);
|
|
3209
|
+
}
|
|
3210
|
+
return {
|
|
3211
|
+
...summary,
|
|
3212
|
+
resets: this.#runLogEpochReset(header.logEpoch),
|
|
3213
|
+
bootstrapping: [],
|
|
3214
|
+
};
|
|
3215
|
+
}
|
|
3216
|
+
if (currentLogEpoch === undefined || currentLogEpoch !== header.logEpoch) {
|
|
3217
|
+
throw new ClientSyncError(
|
|
3218
|
+
'client.invalid_host_response',
|
|
3219
|
+
'the server changed logEpoch without requiring a reset',
|
|
3220
|
+
);
|
|
3221
|
+
}
|
|
3081
3222
|
|
|
3082
3223
|
let section: OpenSection | undefined;
|
|
3083
3224
|
let errorFrame: ClientSyncError | undefined;
|
|
@@ -3101,8 +3242,7 @@ export class SyncClient {
|
|
|
3101
3242
|
});
|
|
3102
3243
|
break;
|
|
3103
3244
|
case 'PUSH_RESULT': {
|
|
3104
|
-
let outboxCount =
|
|
3105
|
-
responseOutboxCount ?? listOutbox(this.#db).length;
|
|
3245
|
+
let outboxCount = responseOutboxCount ?? countOutbox(this.#db);
|
|
3106
3246
|
this.#applyBatch(
|
|
3107
3247
|
(batch) => {
|
|
3108
3248
|
const drained = this.#handlePushResult(
|
package/src/index.ts
CHANGED
package/src/outbox.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type PushOperation,
|
|
12
12
|
type ScopeMap,
|
|
13
13
|
} from '@syncular/core';
|
|
14
|
-
import type { ClientDatabase } from './database';
|
|
14
|
+
import type { ClientDatabase, SqlRow } from './database';
|
|
15
15
|
import type { EncryptionConfig } from './encryption';
|
|
16
16
|
import { ClientSyncError } from './errors';
|
|
17
17
|
import {
|
|
@@ -85,19 +85,50 @@ export function appendOutboxCommit(
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
/** Pending commits in FIFO creation order (§7.1). */
|
|
88
|
+
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
89
89
|
export function listOutbox(db: ClientDatabase): OutboxCommit[] {
|
|
90
90
|
return db
|
|
91
91
|
.query(
|
|
92
92
|
`SELECT seq, client_commit_id, created_at_ms, operations
|
|
93
93
|
FROM _syncular_outbox ORDER BY seq ASC`,
|
|
94
94
|
)
|
|
95
|
-
.map(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
.map(decodeOutboxRow);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function decodeOutboxRow(row: SqlRow): OutboxCommit {
|
|
99
|
+
return {
|
|
100
|
+
seq: row.seq as number,
|
|
101
|
+
clientCommitId: row.client_commit_id as string,
|
|
102
|
+
createdAtMs: row.created_at_ms as number,
|
|
103
|
+
operations: JSON.parse(row.operations as string) as OutboxOperation[],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
|
|
108
|
+
export function* iterateOutbox(
|
|
109
|
+
db: ClientDatabase,
|
|
110
|
+
throughSeq: number,
|
|
111
|
+
): Generator<OutboxCommit> {
|
|
112
|
+
let afterSeq = 0;
|
|
113
|
+
while (afterSeq < throughSeq) {
|
|
114
|
+
const rows = db.query(
|
|
115
|
+
`SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox
|
|
116
|
+
WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`,
|
|
117
|
+
[afterSeq, throughSeq],
|
|
118
|
+
);
|
|
119
|
+
if (rows.length === 0) return;
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
const commit = decodeOutboxRow(row);
|
|
122
|
+
afterSeq = commit.seq;
|
|
123
|
+
yield commit;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Routine status reads never load operation bodies. */
|
|
129
|
+
export function countOutbox(db: ClientDatabase): number {
|
|
130
|
+
return db.query('SELECT COUNT(*) AS count FROM _syncular_outbox')[0]!
|
|
131
|
+
.count as number;
|
|
101
132
|
}
|
|
102
133
|
|
|
103
134
|
export function deleteOutboxCommit(
|