@syncular/client 0.15.17 → 0.15.19
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/dist/client.js +83 -28
- package/dist/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +3 -0
- package/package.json +3 -3
- package/src/client.ts +113 -36
- package/src/diagnostics.ts +4 -0
package/dist/client.js
CHANGED
|
@@ -69,6 +69,12 @@ function emptySummary(pushed) {
|
|
|
69
69
|
failed: [],
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
+
function isFinalPushResult(frame) {
|
|
73
|
+
return (frame.status !== 'rejected' ||
|
|
74
|
+
!frame.results.some((result) => result.status === 'error' &&
|
|
75
|
+
result.code === 'sync.idempotency_cache_miss' &&
|
|
76
|
+
result.retryable));
|
|
77
|
+
}
|
|
72
78
|
export class SyncClient {
|
|
73
79
|
#config;
|
|
74
80
|
#db;
|
|
@@ -112,6 +118,8 @@ export class SyncClient {
|
|
|
112
118
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
113
119
|
#presenceListeners = new Set();
|
|
114
120
|
#diagnostics = new ClientDiagnosticsEmitter();
|
|
121
|
+
#diagnosticsDeferralDepth = 0;
|
|
122
|
+
#diagnosticsPending = false;
|
|
115
123
|
#lastRound;
|
|
116
124
|
#lastChange;
|
|
117
125
|
/** The batch accumulator; non-undefined only inside `#applyBatch`. */
|
|
@@ -630,19 +638,38 @@ export class SyncClient {
|
|
|
630
638
|
}
|
|
631
639
|
}
|
|
632
640
|
#emitDiagnostics() {
|
|
633
|
-
if (!this.#started ||
|
|
641
|
+
if (!this.#started ||
|
|
642
|
+
this.#securityLifecycle !== 'active' ||
|
|
643
|
+
!this.#diagnostics.observed) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (this.#diagnosticsDeferralDepth > 0) {
|
|
647
|
+
this.#diagnosticsPending = true;
|
|
634
648
|
return;
|
|
649
|
+
}
|
|
635
650
|
this.#diagnostics.emit(this.diagnosticsSnapshot());
|
|
636
651
|
}
|
|
652
|
+
#beginDiagnosticsDeferral() {
|
|
653
|
+
this.#diagnosticsDeferralDepth += 1;
|
|
654
|
+
}
|
|
655
|
+
#endDiagnosticsDeferral() {
|
|
656
|
+
if (this.#diagnosticsDeferralDepth === 0)
|
|
657
|
+
return;
|
|
658
|
+
this.#diagnosticsDeferralDepth -= 1;
|
|
659
|
+
if (this.#diagnosticsDeferralDepth === 0 && this.#diagnosticsPending) {
|
|
660
|
+
this.#diagnosticsPending = false;
|
|
661
|
+
this.#emitDiagnostics();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
637
664
|
/** One call for the complete status domain used by reactive hosts. */
|
|
638
665
|
statusSnapshot() {
|
|
639
666
|
this.#requireStarted();
|
|
640
667
|
return this.#statusSnapshot();
|
|
641
668
|
}
|
|
642
|
-
#statusSnapshot() {
|
|
669
|
+
#statusSnapshot(outboxCount) {
|
|
643
670
|
return {
|
|
644
671
|
currentSchemaVersion: this.#config.schema.version,
|
|
645
|
-
outbox: listOutbox(this.#db).length,
|
|
672
|
+
outbox: outboxCount ?? listOutbox(this.#db).length,
|
|
646
673
|
upgrading: this.#upgrading,
|
|
647
674
|
leaseState: this.#leaseState,
|
|
648
675
|
schemaFloor: this.#schemaFloor,
|
|
@@ -655,7 +682,7 @@ export class SyncClient {
|
|
|
655
682
|
* Re-entrant calls share the outer batch so a nested apply never
|
|
656
683
|
* double-emits (e.g. purge → blob reconcile → replay inside one round).
|
|
657
684
|
*/
|
|
658
|
-
#applyBatch(fn) {
|
|
685
|
+
#applyBatch(fn, statusSnapshotOverride) {
|
|
659
686
|
if (this.#batch !== undefined)
|
|
660
687
|
return fn(this.#batch);
|
|
661
688
|
const batch = new ChangeAccumulator();
|
|
@@ -669,8 +696,9 @@ export class SyncClient {
|
|
|
669
696
|
result = fn(batch);
|
|
670
697
|
if (batch.touched) {
|
|
671
698
|
revision = bumpLocalRevision(this.#db);
|
|
672
|
-
if (batch.statusChanged)
|
|
673
|
-
status = this.#statusSnapshot();
|
|
699
|
+
if (batch.statusChanged) {
|
|
700
|
+
status = statusSnapshotOverride?.() ?? this.#statusSnapshot();
|
|
701
|
+
}
|
|
674
702
|
}
|
|
675
703
|
}
|
|
676
704
|
finally {
|
|
@@ -1061,13 +1089,13 @@ export class SyncClient {
|
|
|
1061
1089
|
}
|
|
1062
1090
|
const existing = getSubscription(this.#db, input.id);
|
|
1063
1091
|
if (existing !== undefined) {
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1092
|
+
const sameIntent = existing.table === input.table &&
|
|
1093
|
+
canonicalScopeJson(existing.scopes) ===
|
|
1094
|
+
canonicalScopeJson(input.scopes) &&
|
|
1095
|
+
existing.params === input.params;
|
|
1096
|
+
if (!sameIntent) {
|
|
1097
|
+
throw new ClientSyncError('client.subscription_intent_mismatch', 'subscribe: the subscription id is already registered for a different table, scopes, or params');
|
|
1098
|
+
}
|
|
1071
1099
|
return;
|
|
1072
1100
|
}
|
|
1073
1101
|
saveSubscription(this.#db, {
|
|
@@ -1603,6 +1631,7 @@ export class SyncClient {
|
|
|
1603
1631
|
return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
|
|
1604
1632
|
}
|
|
1605
1633
|
this.#syncOutstanding = true;
|
|
1634
|
+
this.#beginDiagnosticsDeferral();
|
|
1606
1635
|
const startedAtMs = this.#now();
|
|
1607
1636
|
return this.#serialize(() => this.#runSync())
|
|
1608
1637
|
.then((summary) => {
|
|
@@ -1633,6 +1662,7 @@ export class SyncClient {
|
|
|
1633
1662
|
})
|
|
1634
1663
|
.finally(() => {
|
|
1635
1664
|
this.#syncOutstanding = false;
|
|
1665
|
+
this.#endDiagnosticsDeferral();
|
|
1636
1666
|
});
|
|
1637
1667
|
}
|
|
1638
1668
|
#diagnosticRoundCounters(summary) {
|
|
@@ -2027,10 +2057,16 @@ export class SyncClient {
|
|
|
2027
2057
|
const commitsById = new Map(sentCommits.map((commit) => [commit.clientCommitId, commit]));
|
|
2028
2058
|
const subsById = new Map((sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]));
|
|
2029
2059
|
const rejectionDetailsByCommit = new Map();
|
|
2060
|
+
let lastFinalPushResult;
|
|
2030
2061
|
for (const frame of message.frames) {
|
|
2031
|
-
if (frame.type
|
|
2032
|
-
|
|
2033
|
-
|
|
2062
|
+
if (frame.type === 'PUSH_RESULT_DETAILS') {
|
|
2063
|
+
rejectionDetailsByCommit.set(frame.clientCommitId, new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])));
|
|
2064
|
+
}
|
|
2065
|
+
else if (frame.type === 'PUSH_RESULT' &&
|
|
2066
|
+
commitsById.has(frame.clientCommitId) &&
|
|
2067
|
+
isFinalPushResult(frame)) {
|
|
2068
|
+
lastFinalPushResult = frame;
|
|
2069
|
+
}
|
|
2034
2070
|
}
|
|
2035
2071
|
const header = message.frames[0];
|
|
2036
2072
|
if (header?.type !== 'RESP_HEADER') {
|
|
@@ -2060,8 +2096,10 @@ export class SyncClient {
|
|
|
2060
2096
|
let section;
|
|
2061
2097
|
let errorFrame;
|
|
2062
2098
|
let deltaCursor = -1;
|
|
2099
|
+
let responseOutboxCount;
|
|
2063
2100
|
// Each durable observer transaction emits its own revisioned batch.
|
|
2064
2101
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
2102
|
+
this.#beginDiagnosticsDeferral();
|
|
2065
2103
|
try {
|
|
2066
2104
|
for (const frame of message.frames.slice(1)) {
|
|
2067
2105
|
switch (frame.type) {
|
|
@@ -2075,9 +2113,16 @@ export class SyncClient {
|
|
|
2075
2113
|
expiresAtMs: frame.expiresAtMs,
|
|
2076
2114
|
});
|
|
2077
2115
|
break;
|
|
2078
|
-
case 'PUSH_RESULT':
|
|
2079
|
-
|
|
2116
|
+
case 'PUSH_RESULT': {
|
|
2117
|
+
let outboxCount = responseOutboxCount ?? listOutbox(this.#db).length;
|
|
2118
|
+
this.#applyBatch((batch) => {
|
|
2119
|
+
const drained = this.#handlePushResult(frame, commitsById, summary, batch, rejectionDetailsByCommit.get(frame.clientCommitId), frame === lastFinalPushResult);
|
|
2120
|
+
if (drained)
|
|
2121
|
+
outboxCount -= 1;
|
|
2122
|
+
}, () => this.#statusSnapshot(outboxCount));
|
|
2123
|
+
responseOutboxCount = outboxCount;
|
|
2080
2124
|
break;
|
|
2125
|
+
}
|
|
2081
2126
|
case 'PUSH_RESULT_DETAILS':
|
|
2082
2127
|
// Pre-indexed above so companion ordering remains wire-additive.
|
|
2083
2128
|
break;
|
|
@@ -2202,10 +2247,15 @@ export class SyncClient {
|
|
|
2202
2247
|
}
|
|
2203
2248
|
}
|
|
2204
2249
|
finally {
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2250
|
+
try {
|
|
2251
|
+
// §7.1: local reads see outbox state applied optimistically — replay
|
|
2252
|
+
// the still-pending commits on top of the freshly applied server state.
|
|
2253
|
+
this.#replayOutbox();
|
|
2254
|
+
this.#reconcileBlobs(false);
|
|
2255
|
+
}
|
|
2256
|
+
finally {
|
|
2257
|
+
this.#endDiagnosticsDeferral();
|
|
2258
|
+
}
|
|
2209
2259
|
}
|
|
2210
2260
|
if (errorFrame !== undefined)
|
|
2211
2261
|
throw errorFrame;
|
|
@@ -2226,10 +2276,10 @@ export class SyncClient {
|
|
|
2226
2276
|
}
|
|
2227
2277
|
return { ...summary, bootstrapping };
|
|
2228
2278
|
}
|
|
2229
|
-
#handlePushResult(frame, commitsById, summary, batch, rejectionDetails) {
|
|
2279
|
+
#handlePushResult(frame, commitsById, summary, batch, rejectionDetails, pruneOutcomes) {
|
|
2230
2280
|
const commit = commitsById.get(frame.clientCommitId);
|
|
2231
2281
|
if (commit === undefined)
|
|
2232
|
-
return;
|
|
2282
|
+
return false;
|
|
2233
2283
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
2234
2284
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
2235
2285
|
// "already applied, you may have missed the ack".
|
|
@@ -2243,11 +2293,13 @@ export class SyncClient {
|
|
|
2243
2293
|
})),
|
|
2244
2294
|
});
|
|
2245
2295
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
2246
|
-
|
|
2296
|
+
if (pruneOutcomes) {
|
|
2297
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2298
|
+
}
|
|
2247
2299
|
batch.status();
|
|
2248
2300
|
batch.outcomes();
|
|
2249
2301
|
summary.applied.push(frame.clientCommitId);
|
|
2250
|
-
return;
|
|
2302
|
+
return true;
|
|
2251
2303
|
}
|
|
2252
2304
|
// rejected
|
|
2253
2305
|
const cacheMiss = frame.results.some((result) => result.status === 'error' &&
|
|
@@ -2257,7 +2309,7 @@ export class SyncClient {
|
|
|
2257
2309
|
// §6.3: a serving failure, not the commit's outcome — keep the
|
|
2258
2310
|
// commit queued and retry the identical push later.
|
|
2259
2311
|
summary.retryable.push(frame.clientCommitId);
|
|
2260
|
-
return;
|
|
2312
|
+
return false;
|
|
2261
2313
|
}
|
|
2262
2314
|
const outcomeResults = [];
|
|
2263
2315
|
for (const result of frame.results) {
|
|
@@ -2308,7 +2360,9 @@ export class SyncClient {
|
|
|
2308
2360
|
results: outcomeResults,
|
|
2309
2361
|
operations: commit.operations,
|
|
2310
2362
|
});
|
|
2311
|
-
|
|
2363
|
+
if (pruneOutcomes) {
|
|
2364
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2365
|
+
}
|
|
2312
2366
|
batch.outcomes();
|
|
2313
2367
|
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
2314
2368
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
@@ -2318,6 +2372,7 @@ export class SyncClient {
|
|
|
2318
2372
|
});
|
|
2319
2373
|
batch.status();
|
|
2320
2374
|
summary.rejected.push(frame.clientCommitId);
|
|
2375
|
+
return true;
|
|
2321
2376
|
}
|
|
2322
2377
|
#decodeServerRow(tableName, payload) {
|
|
2323
2378
|
if (tableName === undefined)
|
package/dist/diagnostics.d.ts
CHANGED
|
@@ -126,6 +126,7 @@ export interface ClientDiagnosticsSnapshot {
|
|
|
126
126
|
export type ClientDiagnosticsListener = (snapshot: ClientDiagnosticsSnapshot) => void;
|
|
127
127
|
export declare class ClientDiagnosticsEmitter {
|
|
128
128
|
#private;
|
|
129
|
+
get observed(): boolean;
|
|
129
130
|
on(listener: ClientDiagnosticsListener): () => void;
|
|
130
131
|
emit(snapshot: ClientDiagnosticsSnapshot): void;
|
|
131
132
|
}
|
package/dist/diagnostics.js
CHANGED
|
@@ -3,6 +3,9 @@ export const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS = 256;
|
|
|
3
3
|
export const MAX_DIAGNOSTIC_DOMAINS = 256;
|
|
4
4
|
export class ClientDiagnosticsEmitter {
|
|
5
5
|
#listeners = new Set();
|
|
6
|
+
get observed() {
|
|
7
|
+
return this.#listeners.size > 0;
|
|
8
|
+
}
|
|
6
9
|
on(listener) {
|
|
7
10
|
this.#listeners.add(listener);
|
|
8
11
|
return () => this.#listeners.delete(listener);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.19",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
84
|
-
"@syncular/core": "0.15.
|
|
84
|
+
"@syncular/core": "0.15.19"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"better-sqlite3": ">=11"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.15.
|
|
95
|
+
"@syncular/server": "0.15.19",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
package/src/client.ts
CHANGED
|
@@ -503,6 +503,18 @@ function emptySummary(pushed: number): MutableSummary {
|
|
|
503
503
|
};
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
function isFinalPushResult(frame: PushResultFrame): boolean {
|
|
507
|
+
return (
|
|
508
|
+
frame.status !== 'rejected' ||
|
|
509
|
+
!frame.results.some(
|
|
510
|
+
(result) =>
|
|
511
|
+
result.status === 'error' &&
|
|
512
|
+
result.code === 'sync.idempotency_cache_miss' &&
|
|
513
|
+
result.retryable,
|
|
514
|
+
)
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
506
518
|
export class SyncClient {
|
|
507
519
|
readonly #config: SyncClientConfig;
|
|
508
520
|
readonly #db: ClientDatabase;
|
|
@@ -546,6 +558,8 @@ export class SyncClient {
|
|
|
546
558
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
547
559
|
readonly #presenceListeners = new Set<(scopeKey: string) => void>();
|
|
548
560
|
readonly #diagnostics = new ClientDiagnosticsEmitter();
|
|
561
|
+
#diagnosticsDeferralDepth = 0;
|
|
562
|
+
#diagnosticsPending = false;
|
|
549
563
|
#lastRound: DiagnosticLastRound | undefined;
|
|
550
564
|
#lastChange: DiagnosticLastChange | undefined;
|
|
551
565
|
/** The batch accumulator; non-undefined only inside `#applyBatch`. */
|
|
@@ -1159,20 +1173,43 @@ export class SyncClient {
|
|
|
1159
1173
|
}
|
|
1160
1174
|
|
|
1161
1175
|
#emitDiagnostics(): void {
|
|
1162
|
-
if (
|
|
1176
|
+
if (
|
|
1177
|
+
!this.#started ||
|
|
1178
|
+
this.#securityLifecycle !== 'active' ||
|
|
1179
|
+
!this.#diagnostics.observed
|
|
1180
|
+
) {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (this.#diagnosticsDeferralDepth > 0) {
|
|
1184
|
+
this.#diagnosticsPending = true;
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1163
1187
|
this.#diagnostics.emit(this.diagnosticsSnapshot());
|
|
1164
1188
|
}
|
|
1165
1189
|
|
|
1190
|
+
#beginDiagnosticsDeferral(): void {
|
|
1191
|
+
this.#diagnosticsDeferralDepth += 1;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
#endDiagnosticsDeferral(): void {
|
|
1195
|
+
if (this.#diagnosticsDeferralDepth === 0) return;
|
|
1196
|
+
this.#diagnosticsDeferralDepth -= 1;
|
|
1197
|
+
if (this.#diagnosticsDeferralDepth === 0 && this.#diagnosticsPending) {
|
|
1198
|
+
this.#diagnosticsPending = false;
|
|
1199
|
+
this.#emitDiagnostics();
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1166
1203
|
/** One call for the complete status domain used by reactive hosts. */
|
|
1167
1204
|
statusSnapshot(): SyncStatusSnapshot {
|
|
1168
1205
|
this.#requireStarted();
|
|
1169
1206
|
return this.#statusSnapshot();
|
|
1170
1207
|
}
|
|
1171
1208
|
|
|
1172
|
-
#statusSnapshot(): SyncStatusSnapshot {
|
|
1209
|
+
#statusSnapshot(outboxCount?: number): SyncStatusSnapshot {
|
|
1173
1210
|
return {
|
|
1174
1211
|
currentSchemaVersion: this.#config.schema.version,
|
|
1175
|
-
outbox: listOutbox(this.#db).length,
|
|
1212
|
+
outbox: outboxCount ?? listOutbox(this.#db).length,
|
|
1176
1213
|
upgrading: this.#upgrading,
|
|
1177
1214
|
leaseState: this.#leaseState,
|
|
1178
1215
|
schemaFloor: this.#schemaFloor,
|
|
@@ -1186,7 +1223,10 @@ export class SyncClient {
|
|
|
1186
1223
|
* Re-entrant calls share the outer batch so a nested apply never
|
|
1187
1224
|
* double-emits (e.g. purge → blob reconcile → replay inside one round).
|
|
1188
1225
|
*/
|
|
1189
|
-
#applyBatch<T>(
|
|
1226
|
+
#applyBatch<T>(
|
|
1227
|
+
fn: (batch: ChangeAccumulator) => T,
|
|
1228
|
+
statusSnapshotOverride?: () => SyncStatusSnapshot,
|
|
1229
|
+
): T {
|
|
1190
1230
|
if (this.#batch !== undefined) return fn(this.#batch);
|
|
1191
1231
|
const batch = new ChangeAccumulator();
|
|
1192
1232
|
let revision: LocalRevision | undefined;
|
|
@@ -1199,7 +1239,9 @@ export class SyncClient {
|
|
|
1199
1239
|
result = fn(batch);
|
|
1200
1240
|
if (batch.touched) {
|
|
1201
1241
|
revision = bumpLocalRevision(this.#db);
|
|
1202
|
-
if (batch.statusChanged)
|
|
1242
|
+
if (batch.statusChanged) {
|
|
1243
|
+
status = statusSnapshotOverride?.() ?? this.#statusSnapshot();
|
|
1244
|
+
}
|
|
1203
1245
|
}
|
|
1204
1246
|
} finally {
|
|
1205
1247
|
this.#batch = undefined;
|
|
@@ -1697,13 +1739,17 @@ export class SyncClient {
|
|
|
1697
1739
|
}
|
|
1698
1740
|
const existing = getSubscription(this.#db, input.id);
|
|
1699
1741
|
if (existing !== undefined) {
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1742
|
+
const sameIntent =
|
|
1743
|
+
existing.table === input.table &&
|
|
1744
|
+
canonicalScopeJson(existing.scopes) ===
|
|
1745
|
+
canonicalScopeJson(input.scopes) &&
|
|
1746
|
+
existing.params === input.params;
|
|
1747
|
+
if (!sameIntent) {
|
|
1748
|
+
throw new ClientSyncError(
|
|
1749
|
+
'client.subscription_intent_mismatch',
|
|
1750
|
+
'subscribe: the subscription id is already registered for a different table, scopes, or params',
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1707
1753
|
return;
|
|
1708
1754
|
}
|
|
1709
1755
|
saveSubscription(this.#db, {
|
|
@@ -2346,6 +2392,7 @@ export class SyncClient {
|
|
|
2346
2392
|
);
|
|
2347
2393
|
}
|
|
2348
2394
|
this.#syncOutstanding = true;
|
|
2395
|
+
this.#beginDiagnosticsDeferral();
|
|
2349
2396
|
const startedAtMs = this.#now();
|
|
2350
2397
|
return this.#serialize(() => this.#runSync())
|
|
2351
2398
|
.then(
|
|
@@ -2380,6 +2427,7 @@ export class SyncClient {
|
|
|
2380
2427
|
)
|
|
2381
2428
|
.finally(() => {
|
|
2382
2429
|
this.#syncOutstanding = false;
|
|
2430
|
+
this.#endDiagnosticsDeferral();
|
|
2383
2431
|
});
|
|
2384
2432
|
}
|
|
2385
2433
|
|
|
@@ -2842,12 +2890,20 @@ export class SyncClient {
|
|
|
2842
2890
|
string,
|
|
2843
2891
|
ReadonlyMap<number, RejectionDetails>
|
|
2844
2892
|
>();
|
|
2893
|
+
let lastFinalPushResult: PushResultFrame | undefined;
|
|
2845
2894
|
for (const frame of message.frames) {
|
|
2846
|
-
if (frame.type
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2895
|
+
if (frame.type === 'PUSH_RESULT_DETAILS') {
|
|
2896
|
+
rejectionDetailsByCommit.set(
|
|
2897
|
+
frame.clientCommitId,
|
|
2898
|
+
new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])),
|
|
2899
|
+
);
|
|
2900
|
+
} else if (
|
|
2901
|
+
frame.type === 'PUSH_RESULT' &&
|
|
2902
|
+
commitsById.has(frame.clientCommitId) &&
|
|
2903
|
+
isFinalPushResult(frame)
|
|
2904
|
+
) {
|
|
2905
|
+
lastFinalPushResult = frame;
|
|
2906
|
+
}
|
|
2851
2907
|
}
|
|
2852
2908
|
|
|
2853
2909
|
const header = message.frames[0];
|
|
@@ -2879,9 +2935,11 @@ export class SyncClient {
|
|
|
2879
2935
|
let section: OpenSection | undefined;
|
|
2880
2936
|
let errorFrame: ClientSyncError | undefined;
|
|
2881
2937
|
let deltaCursor = -1;
|
|
2938
|
+
let responseOutboxCount: number | undefined;
|
|
2882
2939
|
|
|
2883
2940
|
// Each durable observer transaction emits its own revisioned batch.
|
|
2884
2941
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
2942
|
+
this.#beginDiagnosticsDeferral();
|
|
2885
2943
|
try {
|
|
2886
2944
|
for (const frame of message.frames.slice(1)) {
|
|
2887
2945
|
switch (frame.type) {
|
|
@@ -2895,17 +2953,26 @@ export class SyncClient {
|
|
|
2895
2953
|
expiresAtMs: frame.expiresAtMs,
|
|
2896
2954
|
});
|
|
2897
2955
|
break;
|
|
2898
|
-
case 'PUSH_RESULT':
|
|
2899
|
-
|
|
2900
|
-
this.#
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2956
|
+
case 'PUSH_RESULT': {
|
|
2957
|
+
let outboxCount =
|
|
2958
|
+
responseOutboxCount ?? listOutbox(this.#db).length;
|
|
2959
|
+
this.#applyBatch(
|
|
2960
|
+
(batch) => {
|
|
2961
|
+
const drained = this.#handlePushResult(
|
|
2962
|
+
frame,
|
|
2963
|
+
commitsById,
|
|
2964
|
+
summary,
|
|
2965
|
+
batch,
|
|
2966
|
+
rejectionDetailsByCommit.get(frame.clientCommitId),
|
|
2967
|
+
frame === lastFinalPushResult,
|
|
2968
|
+
);
|
|
2969
|
+
if (drained) outboxCount -= 1;
|
|
2970
|
+
},
|
|
2971
|
+
() => this.#statusSnapshot(outboxCount),
|
|
2907
2972
|
);
|
|
2973
|
+
responseOutboxCount = outboxCount;
|
|
2908
2974
|
break;
|
|
2975
|
+
}
|
|
2909
2976
|
case 'PUSH_RESULT_DETAILS':
|
|
2910
2977
|
// Pre-indexed above so companion ordering remains wire-additive.
|
|
2911
2978
|
break;
|
|
@@ -3103,10 +3170,14 @@ export class SyncClient {
|
|
|
3103
3170
|
if (errorFrame !== undefined) break;
|
|
3104
3171
|
}
|
|
3105
3172
|
} finally {
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3173
|
+
try {
|
|
3174
|
+
// §7.1: local reads see outbox state applied optimistically — replay
|
|
3175
|
+
// the still-pending commits on top of the freshly applied server state.
|
|
3176
|
+
this.#replayOutbox();
|
|
3177
|
+
this.#reconcileBlobs(false);
|
|
3178
|
+
} finally {
|
|
3179
|
+
this.#endDiagnosticsDeferral();
|
|
3180
|
+
}
|
|
3110
3181
|
}
|
|
3111
3182
|
|
|
3112
3183
|
if (errorFrame !== undefined) throw errorFrame;
|
|
@@ -3136,9 +3207,10 @@ export class SyncClient {
|
|
|
3136
3207
|
summary: MutableSummary,
|
|
3137
3208
|
batch: ChangeAccumulator,
|
|
3138
3209
|
rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
|
|
3139
|
-
|
|
3210
|
+
pruneOutcomes: boolean,
|
|
3211
|
+
): boolean {
|
|
3140
3212
|
const commit = commitsById.get(frame.clientCommitId);
|
|
3141
|
-
if (commit === undefined) return;
|
|
3213
|
+
if (commit === undefined) return false;
|
|
3142
3214
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
3143
3215
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
3144
3216
|
// "already applied, you may have missed the ack".
|
|
@@ -3152,11 +3224,13 @@ export class SyncClient {
|
|
|
3152
3224
|
})),
|
|
3153
3225
|
});
|
|
3154
3226
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
3155
|
-
|
|
3227
|
+
if (pruneOutcomes) {
|
|
3228
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3229
|
+
}
|
|
3156
3230
|
batch.status();
|
|
3157
3231
|
batch.outcomes();
|
|
3158
3232
|
summary.applied.push(frame.clientCommitId);
|
|
3159
|
-
return;
|
|
3233
|
+
return true;
|
|
3160
3234
|
}
|
|
3161
3235
|
// rejected
|
|
3162
3236
|
const cacheMiss = frame.results.some(
|
|
@@ -3169,7 +3243,7 @@ export class SyncClient {
|
|
|
3169
3243
|
// §6.3: a serving failure, not the commit's outcome — keep the
|
|
3170
3244
|
// commit queued and retry the identical push later.
|
|
3171
3245
|
summary.retryable.push(frame.clientCommitId);
|
|
3172
|
-
return;
|
|
3246
|
+
return false;
|
|
3173
3247
|
}
|
|
3174
3248
|
const outcomeResults: CommitOperationOutcome[] = [];
|
|
3175
3249
|
for (const result of frame.results) {
|
|
@@ -3218,7 +3292,9 @@ export class SyncClient {
|
|
|
3218
3292
|
results: outcomeResults,
|
|
3219
3293
|
operations: commit.operations,
|
|
3220
3294
|
});
|
|
3221
|
-
|
|
3295
|
+
if (pruneOutcomes) {
|
|
3296
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3297
|
+
}
|
|
3222
3298
|
batch.outcomes();
|
|
3223
3299
|
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
3224
3300
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
@@ -3228,6 +3304,7 @@ export class SyncClient {
|
|
|
3228
3304
|
});
|
|
3229
3305
|
batch.status();
|
|
3230
3306
|
summary.rejected.push(frame.clientCommitId);
|
|
3307
|
+
return true;
|
|
3231
3308
|
}
|
|
3232
3309
|
|
|
3233
3310
|
#decodeServerRow(
|
package/src/diagnostics.ts
CHANGED
|
@@ -163,6 +163,10 @@ export type ClientDiagnosticsListener = (
|
|
|
163
163
|
export class ClientDiagnosticsEmitter {
|
|
164
164
|
readonly #listeners = new Set<ClientDiagnosticsListener>();
|
|
165
165
|
|
|
166
|
+
get observed(): boolean {
|
|
167
|
+
return this.#listeners.size > 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
166
170
|
on(listener: ClientDiagnosticsListener): () => void {
|
|
167
171
|
this.#listeners.add(listener);
|
|
168
172
|
return () => this.#listeners.delete(listener);
|