@syncular/client 0.15.17 → 0.15.18
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 +76 -21
- package/dist/diagnostics.d.ts +1 -0
- package/dist/diagnostics.js +3 -0
- package/package.json +3 -3
- package/src/client.ts +102 -29
- 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 {
|
|
@@ -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.18",
|
|
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.18"
|
|
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.18",
|
|
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;
|
|
@@ -2346,6 +2388,7 @@ export class SyncClient {
|
|
|
2346
2388
|
);
|
|
2347
2389
|
}
|
|
2348
2390
|
this.#syncOutstanding = true;
|
|
2391
|
+
this.#beginDiagnosticsDeferral();
|
|
2349
2392
|
const startedAtMs = this.#now();
|
|
2350
2393
|
return this.#serialize(() => this.#runSync())
|
|
2351
2394
|
.then(
|
|
@@ -2380,6 +2423,7 @@ export class SyncClient {
|
|
|
2380
2423
|
)
|
|
2381
2424
|
.finally(() => {
|
|
2382
2425
|
this.#syncOutstanding = false;
|
|
2426
|
+
this.#endDiagnosticsDeferral();
|
|
2383
2427
|
});
|
|
2384
2428
|
}
|
|
2385
2429
|
|
|
@@ -2842,12 +2886,20 @@ export class SyncClient {
|
|
|
2842
2886
|
string,
|
|
2843
2887
|
ReadonlyMap<number, RejectionDetails>
|
|
2844
2888
|
>();
|
|
2889
|
+
let lastFinalPushResult: PushResultFrame | undefined;
|
|
2845
2890
|
for (const frame of message.frames) {
|
|
2846
|
-
if (frame.type
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2891
|
+
if (frame.type === 'PUSH_RESULT_DETAILS') {
|
|
2892
|
+
rejectionDetailsByCommit.set(
|
|
2893
|
+
frame.clientCommitId,
|
|
2894
|
+
new Map(frame.entries.map((entry) => [entry.opIndex, entry.details])),
|
|
2895
|
+
);
|
|
2896
|
+
} else if (
|
|
2897
|
+
frame.type === 'PUSH_RESULT' &&
|
|
2898
|
+
commitsById.has(frame.clientCommitId) &&
|
|
2899
|
+
isFinalPushResult(frame)
|
|
2900
|
+
) {
|
|
2901
|
+
lastFinalPushResult = frame;
|
|
2902
|
+
}
|
|
2851
2903
|
}
|
|
2852
2904
|
|
|
2853
2905
|
const header = message.frames[0];
|
|
@@ -2879,9 +2931,11 @@ export class SyncClient {
|
|
|
2879
2931
|
let section: OpenSection | undefined;
|
|
2880
2932
|
let errorFrame: ClientSyncError | undefined;
|
|
2881
2933
|
let deltaCursor = -1;
|
|
2934
|
+
let responseOutboxCount: number | undefined;
|
|
2882
2935
|
|
|
2883
2936
|
// Each durable observer transaction emits its own revisioned batch.
|
|
2884
2937
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
2938
|
+
this.#beginDiagnosticsDeferral();
|
|
2885
2939
|
try {
|
|
2886
2940
|
for (const frame of message.frames.slice(1)) {
|
|
2887
2941
|
switch (frame.type) {
|
|
@@ -2895,17 +2949,26 @@ export class SyncClient {
|
|
|
2895
2949
|
expiresAtMs: frame.expiresAtMs,
|
|
2896
2950
|
});
|
|
2897
2951
|
break;
|
|
2898
|
-
case 'PUSH_RESULT':
|
|
2899
|
-
|
|
2900
|
-
this.#
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2952
|
+
case 'PUSH_RESULT': {
|
|
2953
|
+
let outboxCount =
|
|
2954
|
+
responseOutboxCount ?? listOutbox(this.#db).length;
|
|
2955
|
+
this.#applyBatch(
|
|
2956
|
+
(batch) => {
|
|
2957
|
+
const drained = this.#handlePushResult(
|
|
2958
|
+
frame,
|
|
2959
|
+
commitsById,
|
|
2960
|
+
summary,
|
|
2961
|
+
batch,
|
|
2962
|
+
rejectionDetailsByCommit.get(frame.clientCommitId),
|
|
2963
|
+
frame === lastFinalPushResult,
|
|
2964
|
+
);
|
|
2965
|
+
if (drained) outboxCount -= 1;
|
|
2966
|
+
},
|
|
2967
|
+
() => this.#statusSnapshot(outboxCount),
|
|
2907
2968
|
);
|
|
2969
|
+
responseOutboxCount = outboxCount;
|
|
2908
2970
|
break;
|
|
2971
|
+
}
|
|
2909
2972
|
case 'PUSH_RESULT_DETAILS':
|
|
2910
2973
|
// Pre-indexed above so companion ordering remains wire-additive.
|
|
2911
2974
|
break;
|
|
@@ -3103,10 +3166,14 @@ export class SyncClient {
|
|
|
3103
3166
|
if (errorFrame !== undefined) break;
|
|
3104
3167
|
}
|
|
3105
3168
|
} finally {
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3169
|
+
try {
|
|
3170
|
+
// §7.1: local reads see outbox state applied optimistically — replay
|
|
3171
|
+
// the still-pending commits on top of the freshly applied server state.
|
|
3172
|
+
this.#replayOutbox();
|
|
3173
|
+
this.#reconcileBlobs(false);
|
|
3174
|
+
} finally {
|
|
3175
|
+
this.#endDiagnosticsDeferral();
|
|
3176
|
+
}
|
|
3110
3177
|
}
|
|
3111
3178
|
|
|
3112
3179
|
if (errorFrame !== undefined) throw errorFrame;
|
|
@@ -3136,9 +3203,10 @@ export class SyncClient {
|
|
|
3136
3203
|
summary: MutableSummary,
|
|
3137
3204
|
batch: ChangeAccumulator,
|
|
3138
3205
|
rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
|
|
3139
|
-
|
|
3206
|
+
pruneOutcomes: boolean,
|
|
3207
|
+
): boolean {
|
|
3140
3208
|
const commit = commitsById.get(frame.clientCommitId);
|
|
3141
|
-
if (commit === undefined) return;
|
|
3209
|
+
if (commit === undefined) return false;
|
|
3142
3210
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
3143
3211
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
3144
3212
|
// "already applied, you may have missed the ack".
|
|
@@ -3152,11 +3220,13 @@ export class SyncClient {
|
|
|
3152
3220
|
})),
|
|
3153
3221
|
});
|
|
3154
3222
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
3155
|
-
|
|
3223
|
+
if (pruneOutcomes) {
|
|
3224
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3225
|
+
}
|
|
3156
3226
|
batch.status();
|
|
3157
3227
|
batch.outcomes();
|
|
3158
3228
|
summary.applied.push(frame.clientCommitId);
|
|
3159
|
-
return;
|
|
3229
|
+
return true;
|
|
3160
3230
|
}
|
|
3161
3231
|
// rejected
|
|
3162
3232
|
const cacheMiss = frame.results.some(
|
|
@@ -3169,7 +3239,7 @@ export class SyncClient {
|
|
|
3169
3239
|
// §6.3: a serving failure, not the commit's outcome — keep the
|
|
3170
3240
|
// commit queued and retry the identical push later.
|
|
3171
3241
|
summary.retryable.push(frame.clientCommitId);
|
|
3172
|
-
return;
|
|
3242
|
+
return false;
|
|
3173
3243
|
}
|
|
3174
3244
|
const outcomeResults: CommitOperationOutcome[] = [];
|
|
3175
3245
|
for (const result of frame.results) {
|
|
@@ -3218,7 +3288,9 @@ export class SyncClient {
|
|
|
3218
3288
|
results: outcomeResults,
|
|
3219
3289
|
operations: commit.operations,
|
|
3220
3290
|
});
|
|
3221
|
-
|
|
3291
|
+
if (pruneOutcomes) {
|
|
3292
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3293
|
+
}
|
|
3222
3294
|
batch.outcomes();
|
|
3223
3295
|
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
3224
3296
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
@@ -3228,6 +3300,7 @@ export class SyncClient {
|
|
|
3228
3300
|
});
|
|
3229
3301
|
batch.status();
|
|
3230
3302
|
summary.rejected.push(frame.clientCommitId);
|
|
3303
|
+
return true;
|
|
3231
3304
|
}
|
|
3232
3305
|
|
|
3233
3306
|
#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);
|