@syncular/client 0.15.16 → 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/README.md +29 -0
- package/dist/client.d.ts +8 -0
- package/dist/client.js +345 -23
- package/dist/diagnostics.d.ts +134 -0
- package/dist/diagnostics.js +27 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/worker-entry.js +42 -0
- package/dist/worker-host.d.ts +6 -0
- package/dist/worker-host.js +30 -0
- package/dist/worker-protocol.d.ts +6 -0
- package/package.json +3 -3
- package/src/client.ts +442 -32
- package/src/diagnostics.ts +192 -0
- package/src/index.ts +1 -0
- package/src/worker-entry.ts +44 -0
- package/src/worker-host.ts +44 -0
- package/src/worker-protocol.ts +12 -0
package/src/client.ts
CHANGED
|
@@ -60,6 +60,20 @@ import {
|
|
|
60
60
|
} from './blob';
|
|
61
61
|
import type { ClientDatabase, SqlRow, SqlValue } from './database';
|
|
62
62
|
import { registerDevtools } from './devtools';
|
|
63
|
+
import {
|
|
64
|
+
CLIENT_DIAGNOSTICS_VERSION,
|
|
65
|
+
ClientDiagnosticsEmitter,
|
|
66
|
+
type ClientDiagnosticsListener,
|
|
67
|
+
type ClientDiagnosticsRequest,
|
|
68
|
+
type ClientDiagnosticsSnapshot,
|
|
69
|
+
type ClientDiagnosticsStorage,
|
|
70
|
+
type DiagnosticLastChange,
|
|
71
|
+
type DiagnosticLastRound,
|
|
72
|
+
type DiagnosticRoundCounters,
|
|
73
|
+
type DiagnosticSubscription,
|
|
74
|
+
MAX_DIAGNOSTIC_DOMAINS,
|
|
75
|
+
MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
76
|
+
} from './diagnostics';
|
|
63
77
|
import type { EncryptionConfig } from './encryption';
|
|
64
78
|
import { ClientSyncError } from './errors';
|
|
65
79
|
import {
|
|
@@ -489,6 +503,18 @@ function emptySummary(pushed: number): MutableSummary {
|
|
|
489
503
|
};
|
|
490
504
|
}
|
|
491
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
|
+
|
|
492
518
|
export class SyncClient {
|
|
493
519
|
readonly #config: SyncClientConfig;
|
|
494
520
|
readonly #db: ClientDatabase;
|
|
@@ -531,6 +557,11 @@ export class SyncClient {
|
|
|
531
557
|
readonly #invalidation = new InvalidationEmitter();
|
|
532
558
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
533
559
|
readonly #presenceListeners = new Set<(scopeKey: string) => void>();
|
|
560
|
+
readonly #diagnostics = new ClientDiagnosticsEmitter();
|
|
561
|
+
#diagnosticsDeferralDepth = 0;
|
|
562
|
+
#diagnosticsPending = false;
|
|
563
|
+
#lastRound: DiagnosticLastRound | undefined;
|
|
564
|
+
#lastChange: DiagnosticLastChange | undefined;
|
|
534
565
|
/** The batch accumulator; non-undefined only inside `#applyBatch`. */
|
|
535
566
|
#batch: ChangeAccumulator | undefined;
|
|
536
567
|
/**
|
|
@@ -669,6 +700,7 @@ export class SyncClient {
|
|
|
669
700
|
upgrading: async () => this.upgrading,
|
|
670
701
|
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
671
702
|
});
|
|
703
|
+
this.#emitDiagnostics();
|
|
672
704
|
}
|
|
673
705
|
|
|
674
706
|
/**
|
|
@@ -819,6 +851,7 @@ export class SyncClient {
|
|
|
819
851
|
this.#config.onSyncNeeded?.('startup');
|
|
820
852
|
this.#config.onSyncIntent?.({ kind: 'interactive' });
|
|
821
853
|
}
|
|
854
|
+
this.#emitDiagnostics();
|
|
822
855
|
}
|
|
823
856
|
|
|
824
857
|
// -- accessors ------------------------------------------------------------
|
|
@@ -928,16 +961,255 @@ export class SyncClient {
|
|
|
928
961
|
return this.#changes.on(listener);
|
|
929
962
|
}
|
|
930
963
|
|
|
964
|
+
/** Subscribe to complete, privacy-safe diagnostic snapshots. */
|
|
965
|
+
onDiagnostics(listener: ClientDiagnosticsListener): () => void {
|
|
966
|
+
return this.#diagnostics.on(listener);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* One atomic support/product-health view. It never returns scope values,
|
|
971
|
+
* rows, SQL, paths, auth material, lease ids, keys, or mutation bodies.
|
|
972
|
+
*/
|
|
973
|
+
diagnosticsSnapshot(
|
|
974
|
+
request: ClientDiagnosticsRequest = {},
|
|
975
|
+
): ClientDiagnosticsSnapshot {
|
|
976
|
+
this.#requireActive();
|
|
977
|
+
const expected = request.expectedSubscriptions ?? [];
|
|
978
|
+
if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
|
|
979
|
+
throw new ClientSyncError(
|
|
980
|
+
'sync.invalid_request',
|
|
981
|
+
`diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`,
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
const registered = loadSubscriptions(this.#db);
|
|
985
|
+
const subscriptions = new Map<string, DiagnosticSubscription>();
|
|
986
|
+
for (const sub of registered) {
|
|
987
|
+
const reset = sub.cursor < 0 && sub.reasonCode === 'sync.cursor_expired';
|
|
988
|
+
const complete =
|
|
989
|
+
sub.status === 'active' &&
|
|
990
|
+
sub.cursor >= 0 &&
|
|
991
|
+
sub.bootstrapState === undefined;
|
|
992
|
+
subscriptions.set(sub.id, {
|
|
993
|
+
id: sub.id,
|
|
994
|
+
table: sub.table,
|
|
995
|
+
state:
|
|
996
|
+
sub.status === 'revoked'
|
|
997
|
+
? 'revoked'
|
|
998
|
+
: sub.status === 'failed'
|
|
999
|
+
? 'failed'
|
|
1000
|
+
: reset
|
|
1001
|
+
? 'reset'
|
|
1002
|
+
: complete
|
|
1003
|
+
? 'complete'
|
|
1004
|
+
: 'bootstrapping',
|
|
1005
|
+
complete,
|
|
1006
|
+
cursor: sub.cursor,
|
|
1007
|
+
...(sub.reasonCode !== undefined
|
|
1008
|
+
? { reasonCode: this.#diagnosticCode(sub.reasonCode) }
|
|
1009
|
+
: {}),
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
for (const item of expected) {
|
|
1013
|
+
if (
|
|
1014
|
+
typeof item.id !== 'string' ||
|
|
1015
|
+
item.id.length === 0 ||
|
|
1016
|
+
typeof item.table !== 'string' ||
|
|
1017
|
+
item.table.length === 0
|
|
1018
|
+
) {
|
|
1019
|
+
throw new ClientSyncError(
|
|
1020
|
+
'sync.invalid_request',
|
|
1021
|
+
'diagnosticsSnapshot expected subscriptions require non-empty id and table strings',
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
const registeredSubscription = subscriptions.get(item.id);
|
|
1025
|
+
if (
|
|
1026
|
+
registeredSubscription !== undefined &&
|
|
1027
|
+
registeredSubscription.table !== item.table
|
|
1028
|
+
) {
|
|
1029
|
+
subscriptions.set(item.id, {
|
|
1030
|
+
id: item.id,
|
|
1031
|
+
table: item.table,
|
|
1032
|
+
state: 'failed',
|
|
1033
|
+
complete: false,
|
|
1034
|
+
reasonCode: 'client.subscription_intent_mismatch',
|
|
1035
|
+
});
|
|
1036
|
+
} else if (registeredSubscription === undefined) {
|
|
1037
|
+
subscriptions.set(item.id, {
|
|
1038
|
+
id: item.id,
|
|
1039
|
+
table: item.table,
|
|
1040
|
+
state: 'unregistered',
|
|
1041
|
+
complete: false,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const capturedAtMs = this.#now();
|
|
1046
|
+
const leaseState = this.#diagnosticLease(capturedAtMs);
|
|
1047
|
+
const connectivity =
|
|
1048
|
+
this.#lastRound?.status === 'succeeded'
|
|
1049
|
+
? 'online'
|
|
1050
|
+
: this.#lastRound?.status === 'failed' &&
|
|
1051
|
+
this.#transportFailureCode(this.#lastRound.errorCode)
|
|
1052
|
+
? 'offline'
|
|
1053
|
+
: 'unknown';
|
|
1054
|
+
const expectedOrder = new Map(
|
|
1055
|
+
expected.map((item, index) => [item.id, index] as const),
|
|
1056
|
+
);
|
|
1057
|
+
const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
|
|
1058
|
+
const aExpected = expectedOrder.get(a.id);
|
|
1059
|
+
const bExpected = expectedOrder.get(b.id);
|
|
1060
|
+
if (aExpected !== undefined || bExpected !== undefined) {
|
|
1061
|
+
return (
|
|
1062
|
+
(aExpected ?? Number.MAX_SAFE_INTEGER) -
|
|
1063
|
+
(bExpected ?? Number.MAX_SAFE_INTEGER)
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
return a.id.localeCompare(b.id);
|
|
1067
|
+
});
|
|
1068
|
+
return {
|
|
1069
|
+
version: CLIENT_DIAGNOSTICS_VERSION,
|
|
1070
|
+
capturedAtMs,
|
|
1071
|
+
host: {
|
|
1072
|
+
kind: 'direct',
|
|
1073
|
+
role: 'single',
|
|
1074
|
+
connectivity,
|
|
1075
|
+
realtime:
|
|
1076
|
+
this.#config.realtime === undefined
|
|
1077
|
+
? 'unsupported'
|
|
1078
|
+
: this.#socket === undefined
|
|
1079
|
+
? 'disconnected'
|
|
1080
|
+
: 'connected',
|
|
1081
|
+
},
|
|
1082
|
+
securityLifecycle: this.#securityLifecycle,
|
|
1083
|
+
schema: {
|
|
1084
|
+
currentVersion: this.#config.schema.version,
|
|
1085
|
+
upgrading: this.#upgrading,
|
|
1086
|
+
...(this.#schemaFloor?.requiredSchemaVersion !== undefined
|
|
1087
|
+
? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
|
|
1088
|
+
: {}),
|
|
1089
|
+
...(this.#schemaFloor?.latestSchemaVersion !== undefined
|
|
1090
|
+
? { latestVersion: this.#schemaFloor.latestSchemaVersion }
|
|
1091
|
+
: {}),
|
|
1092
|
+
},
|
|
1093
|
+
replica: {
|
|
1094
|
+
localRevision: getLocalRevision(this.#db).toString(),
|
|
1095
|
+
syncNeeded: this.#needsPull,
|
|
1096
|
+
pendingOutbox: listOutbox(this.#db).length,
|
|
1097
|
+
},
|
|
1098
|
+
lease: leaseState,
|
|
1099
|
+
subscriptions: allSubscriptions.slice(
|
|
1100
|
+
0,
|
|
1101
|
+
MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
1102
|
+
),
|
|
1103
|
+
subscriptionsTruncated:
|
|
1104
|
+
allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
1105
|
+
...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
|
|
1106
|
+
...(this.#lastChange !== undefined
|
|
1107
|
+
? { lastChange: this.#lastChange }
|
|
1108
|
+
: {}),
|
|
1109
|
+
storage: this.#diagnosticStorage(),
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
#diagnosticLease(nowMs: number): ClientDiagnosticsSnapshot['lease'] {
|
|
1114
|
+
const lease = this.#leaseState;
|
|
1115
|
+
if (lease?.errorCode !== undefined) {
|
|
1116
|
+
return {
|
|
1117
|
+
state: 'stopped',
|
|
1118
|
+
errorCode: this.#diagnosticCode(lease.errorCode),
|
|
1119
|
+
...(lease.expiresAtMs !== undefined
|
|
1120
|
+
? { expiresAtMs: lease.expiresAtMs }
|
|
1121
|
+
: {}),
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
if (lease?.expiresAtMs === undefined) return { state: 'none' };
|
|
1125
|
+
return {
|
|
1126
|
+
state: lease.expiresAtMs <= nowMs ? 'expired' : 'active',
|
|
1127
|
+
expiresAtMs: lease.expiresAtMs,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
#diagnosticStorage(): ClientDiagnosticsStorage {
|
|
1132
|
+
try {
|
|
1133
|
+
const pageCount = Number(
|
|
1134
|
+
this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0,
|
|
1135
|
+
);
|
|
1136
|
+
const pageSize = Number(
|
|
1137
|
+
this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0,
|
|
1138
|
+
);
|
|
1139
|
+
const outboxBytes = Number(
|
|
1140
|
+
this.#db.query(
|
|
1141
|
+
'SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox',
|
|
1142
|
+
)[0]?.bytes ?? 0,
|
|
1143
|
+
);
|
|
1144
|
+
const outcome = this.#db.query(
|
|
1145
|
+
`SELECT COUNT(*) AS entries,
|
|
1146
|
+
COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
|
|
1147
|
+
FROM _syncular_commit_outcomes`,
|
|
1148
|
+
)[0];
|
|
1149
|
+
const blobBytes = this.#hasBlobs
|
|
1150
|
+
? Number(
|
|
1151
|
+
this.#db.query(
|
|
1152
|
+
'SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs',
|
|
1153
|
+
)[0]?.bytes ?? 0,
|
|
1154
|
+
)
|
|
1155
|
+
: 0;
|
|
1156
|
+
const pressure =
|
|
1157
|
+
this.#config.blobCacheMaxBytes !== undefined &&
|
|
1158
|
+
blobBytes > this.#config.blobCacheMaxBytes;
|
|
1159
|
+
return {
|
|
1160
|
+
status: pressure ? 'pressure' : 'healthy',
|
|
1161
|
+
databaseBytesApprox: Math.max(0, pageCount * pageSize),
|
|
1162
|
+
pendingOutboxBytesApprox: Math.max(0, outboxBytes),
|
|
1163
|
+
retainedOutcomeBytesApprox: Math.max(0, Number(outcome?.bytes ?? 0)),
|
|
1164
|
+
retainedOutcomeEntries: Math.max(0, Number(outcome?.entries ?? 0)),
|
|
1165
|
+
blobCacheBytesApprox: Math.max(0, blobBytes),
|
|
1166
|
+
...(pressure
|
|
1167
|
+
? { pressureReasonCode: 'client.blob_cache_over_limit' as const }
|
|
1168
|
+
: {}),
|
|
1169
|
+
};
|
|
1170
|
+
} catch {
|
|
1171
|
+
return { status: 'unreadable' };
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
#emitDiagnostics(): void {
|
|
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
|
+
}
|
|
1187
|
+
this.#diagnostics.emit(this.diagnosticsSnapshot());
|
|
1188
|
+
}
|
|
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
|
+
|
|
931
1203
|
/** One call for the complete status domain used by reactive hosts. */
|
|
932
1204
|
statusSnapshot(): SyncStatusSnapshot {
|
|
933
1205
|
this.#requireStarted();
|
|
934
1206
|
return this.#statusSnapshot();
|
|
935
1207
|
}
|
|
936
1208
|
|
|
937
|
-
#statusSnapshot(): SyncStatusSnapshot {
|
|
1209
|
+
#statusSnapshot(outboxCount?: number): SyncStatusSnapshot {
|
|
938
1210
|
return {
|
|
939
1211
|
currentSchemaVersion: this.#config.schema.version,
|
|
940
|
-
outbox: listOutbox(this.#db).length,
|
|
1212
|
+
outbox: outboxCount ?? listOutbox(this.#db).length,
|
|
941
1213
|
upgrading: this.#upgrading,
|
|
942
1214
|
leaseState: this.#leaseState,
|
|
943
1215
|
schemaFloor: this.#schemaFloor,
|
|
@@ -951,7 +1223,10 @@ export class SyncClient {
|
|
|
951
1223
|
* Re-entrant calls share the outer batch so a nested apply never
|
|
952
1224
|
* double-emits (e.g. purge → blob reconcile → replay inside one round).
|
|
953
1225
|
*/
|
|
954
|
-
#applyBatch<T>(
|
|
1226
|
+
#applyBatch<T>(
|
|
1227
|
+
fn: (batch: ChangeAccumulator) => T,
|
|
1228
|
+
statusSnapshotOverride?: () => SyncStatusSnapshot,
|
|
1229
|
+
): T {
|
|
955
1230
|
if (this.#batch !== undefined) return fn(this.#batch);
|
|
956
1231
|
const batch = new ChangeAccumulator();
|
|
957
1232
|
let revision: LocalRevision | undefined;
|
|
@@ -964,7 +1239,9 @@ export class SyncClient {
|
|
|
964
1239
|
result = fn(batch);
|
|
965
1240
|
if (batch.touched) {
|
|
966
1241
|
revision = bumpLocalRevision(this.#db);
|
|
967
|
-
if (batch.statusChanged)
|
|
1242
|
+
if (batch.statusChanged) {
|
|
1243
|
+
status = statusSnapshotOverride?.() ?? this.#statusSnapshot();
|
|
1244
|
+
}
|
|
968
1245
|
}
|
|
969
1246
|
} finally {
|
|
970
1247
|
this.#batch = undefined;
|
|
@@ -976,9 +1253,25 @@ export class SyncClient {
|
|
|
976
1253
|
}
|
|
977
1254
|
if (revision !== undefined) {
|
|
978
1255
|
const event = batch.finish(revision, status);
|
|
1256
|
+
const tables = [...new Set(event.tables.map((entry) => entry.table))];
|
|
1257
|
+
const windows = [...new Set(event.windows.map((entry) => entry.table))];
|
|
1258
|
+
this.#lastChange = {
|
|
1259
|
+
revision: revision.toString(),
|
|
1260
|
+
recordedAtMs: this.#now(),
|
|
1261
|
+
tables: tables.slice(0, MAX_DIAGNOSTIC_DOMAINS),
|
|
1262
|
+
windows: windows.slice(0, MAX_DIAGNOSTIC_DOMAINS),
|
|
1263
|
+
domainsTruncated:
|
|
1264
|
+
tables.length > MAX_DIAGNOSTIC_DOMAINS ||
|
|
1265
|
+
windows.length > MAX_DIAGNOSTIC_DOMAINS,
|
|
1266
|
+
statusChanged: event.status !== undefined,
|
|
1267
|
+
conflictsChanged: event.conflictsChanged,
|
|
1268
|
+
rejectionsChanged: event.rejectionsChanged,
|
|
1269
|
+
outcomesChanged: event.outcomesChanged,
|
|
1270
|
+
};
|
|
979
1271
|
this.#changes.emit(event);
|
|
980
1272
|
const legacy = invalidationFromChange(event);
|
|
981
1273
|
if (legacy !== undefined) this.#invalidation.emit(legacy);
|
|
1274
|
+
this.#emitDiagnostics();
|
|
982
1275
|
}
|
|
983
1276
|
return result;
|
|
984
1277
|
}
|
|
@@ -1452,6 +1745,7 @@ export class SyncClient {
|
|
|
1452
1745
|
scopes: input.scopes,
|
|
1453
1746
|
...(input.params !== undefined ? { params: input.params } : {}),
|
|
1454
1747
|
});
|
|
1748
|
+
this.#emitDiagnostics();
|
|
1455
1749
|
return;
|
|
1456
1750
|
}
|
|
1457
1751
|
saveSubscription(this.#db, {
|
|
@@ -1462,11 +1756,13 @@ export class SyncClient {
|
|
|
1462
1756
|
cursor: -1,
|
|
1463
1757
|
status: 'active',
|
|
1464
1758
|
});
|
|
1759
|
+
this.#emitDiagnostics();
|
|
1465
1760
|
}
|
|
1466
1761
|
|
|
1467
1762
|
unsubscribe(id: string): void {
|
|
1468
1763
|
this.#requireActive();
|
|
1469
1764
|
deleteSubscription(this.#db, id);
|
|
1765
|
+
this.#emitDiagnostics();
|
|
1470
1766
|
}
|
|
1471
1767
|
|
|
1472
1768
|
// -- windowed subscriptions (§4.8) ------------------------------------------
|
|
@@ -2092,9 +2388,75 @@ export class SyncClient {
|
|
|
2092
2388
|
);
|
|
2093
2389
|
}
|
|
2094
2390
|
this.#syncOutstanding = true;
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2391
|
+
this.#beginDiagnosticsDeferral();
|
|
2392
|
+
const startedAtMs = this.#now();
|
|
2393
|
+
return this.#serialize(() => this.#runSync())
|
|
2394
|
+
.then(
|
|
2395
|
+
(summary) => {
|
|
2396
|
+
const completedAtMs = this.#now();
|
|
2397
|
+
this.#lastRound = {
|
|
2398
|
+
status: 'succeeded',
|
|
2399
|
+
startedAtMs,
|
|
2400
|
+
completedAtMs,
|
|
2401
|
+
durationMs: Math.max(0, completedAtMs - startedAtMs),
|
|
2402
|
+
counters: this.#diagnosticRoundCounters(summary),
|
|
2403
|
+
};
|
|
2404
|
+
this.#emitDiagnostics();
|
|
2405
|
+
return summary;
|
|
2406
|
+
},
|
|
2407
|
+
(error: unknown) => {
|
|
2408
|
+
const completedAtMs = this.#now();
|
|
2409
|
+
const code = (error as { code?: unknown }).code;
|
|
2410
|
+
this.#lastRound = {
|
|
2411
|
+
status: 'failed',
|
|
2412
|
+
startedAtMs,
|
|
2413
|
+
completedAtMs,
|
|
2414
|
+
durationMs: Math.max(0, completedAtMs - startedAtMs),
|
|
2415
|
+
errorCode:
|
|
2416
|
+
typeof code === 'string'
|
|
2417
|
+
? this.#diagnosticCode(code)
|
|
2418
|
+
: 'client.unknown_failure',
|
|
2419
|
+
};
|
|
2420
|
+
this.#emitDiagnostics();
|
|
2421
|
+
throw error;
|
|
2422
|
+
},
|
|
2423
|
+
)
|
|
2424
|
+
.finally(() => {
|
|
2425
|
+
this.#syncOutstanding = false;
|
|
2426
|
+
this.#endDiagnosticsDeferral();
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
#diagnosticRoundCounters(summary: SyncSummary): DiagnosticRoundCounters {
|
|
2431
|
+
return {
|
|
2432
|
+
pushed: summary.pushed,
|
|
2433
|
+
applied: summary.applied.length,
|
|
2434
|
+
rejected: summary.rejected.length,
|
|
2435
|
+
retryable: summary.retryable.length,
|
|
2436
|
+
conflicts: summary.conflicts.length,
|
|
2437
|
+
commitsApplied: summary.commitsApplied,
|
|
2438
|
+
segmentRowsApplied: summary.segmentRowsApplied,
|
|
2439
|
+
bootstrapping: summary.bootstrapping.length,
|
|
2440
|
+
resets: summary.resets.length,
|
|
2441
|
+
revoked: summary.revoked.length,
|
|
2442
|
+
failed: summary.failed.length,
|
|
2443
|
+
deferredCommits: summary.deferredCommits ?? 0,
|
|
2444
|
+
};
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
#transportFailureCode(code: string): boolean {
|
|
2448
|
+
return (
|
|
2449
|
+
code === 'transport.failed' ||
|
|
2450
|
+
code === 'transport.unavailable' ||
|
|
2451
|
+
code === 'sync.transport_failed' ||
|
|
2452
|
+
code === 'client.worker_failed'
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
#diagnosticCode(code: string): string {
|
|
2457
|
+
return code.length <= 96 && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/.test(code)
|
|
2458
|
+
? code
|
|
2459
|
+
: 'client.unknown_failure';
|
|
2098
2460
|
}
|
|
2099
2461
|
|
|
2100
2462
|
async #runSync(): Promise<SyncSummary> {
|
|
@@ -2254,7 +2616,23 @@ export class SyncClient {
|
|
|
2254
2616
|
*/
|
|
2255
2617
|
#roundTrip(request: Uint8Array): Promise<Uint8Array> {
|
|
2256
2618
|
const socket = this.#socket;
|
|
2257
|
-
if (socket === undefined)
|
|
2619
|
+
if (socket === undefined) {
|
|
2620
|
+
return Promise.resolve()
|
|
2621
|
+
.then(() => this.#config.transport(request))
|
|
2622
|
+
.catch((error: unknown) => {
|
|
2623
|
+
if (
|
|
2624
|
+
error instanceof ClientSyncError ||
|
|
2625
|
+
typeof (error as { code?: unknown })?.code === 'string'
|
|
2626
|
+
) {
|
|
2627
|
+
throw error;
|
|
2628
|
+
}
|
|
2629
|
+
throw new ClientSyncError(
|
|
2630
|
+
'sync.transport_failed',
|
|
2631
|
+
`transport round failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2632
|
+
true,
|
|
2633
|
+
);
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2258
2636
|
return new Promise<Uint8Array>((resolve, reject) => {
|
|
2259
2637
|
// sync() already enforces one round in flight (§8.7).
|
|
2260
2638
|
this.#pendingRound = {
|
|
@@ -2309,6 +2687,7 @@ export class SyncClient {
|
|
|
2309
2687
|
this.#socket = undefined;
|
|
2310
2688
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
2311
2689
|
this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
|
|
2690
|
+
this.#emitDiagnostics();
|
|
2312
2691
|
},
|
|
2313
2692
|
});
|
|
2314
2693
|
if (this.#securityLifecycle === 'preflight') {
|
|
@@ -2319,6 +2698,7 @@ export class SyncClient {
|
|
|
2319
2698
|
);
|
|
2320
2699
|
}
|
|
2321
2700
|
this.#socket = socket;
|
|
2701
|
+
this.#emitDiagnostics();
|
|
2322
2702
|
}
|
|
2323
2703
|
|
|
2324
2704
|
disconnectRealtime(): void {
|
|
@@ -2326,6 +2706,7 @@ export class SyncClient {
|
|
|
2326
2706
|
this.#socket = undefined;
|
|
2327
2707
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
2328
2708
|
this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
|
|
2709
|
+
this.#emitDiagnostics();
|
|
2329
2710
|
}
|
|
2330
2711
|
|
|
2331
2712
|
/**
|
|
@@ -2505,12 +2886,20 @@ export class SyncClient {
|
|
|
2505
2886
|
string,
|
|
2506
2887
|
ReadonlyMap<number, RejectionDetails>
|
|
2507
2888
|
>();
|
|
2889
|
+
let lastFinalPushResult: PushResultFrame | undefined;
|
|
2508
2890
|
for (const frame of message.frames) {
|
|
2509
|
-
if (frame.type
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
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
|
+
}
|
|
2514
2903
|
}
|
|
2515
2904
|
|
|
2516
2905
|
const header = message.frames[0];
|
|
@@ -2542,9 +2931,11 @@ export class SyncClient {
|
|
|
2542
2931
|
let section: OpenSection | undefined;
|
|
2543
2932
|
let errorFrame: ClientSyncError | undefined;
|
|
2544
2933
|
let deltaCursor = -1;
|
|
2934
|
+
let responseOutboxCount: number | undefined;
|
|
2545
2935
|
|
|
2546
2936
|
// Each durable observer transaction emits its own revisioned batch.
|
|
2547
2937
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
2938
|
+
this.#beginDiagnosticsDeferral();
|
|
2548
2939
|
try {
|
|
2549
2940
|
for (const frame of message.frames.slice(1)) {
|
|
2550
2941
|
switch (frame.type) {
|
|
@@ -2558,17 +2949,26 @@ export class SyncClient {
|
|
|
2558
2949
|
expiresAtMs: frame.expiresAtMs,
|
|
2559
2950
|
});
|
|
2560
2951
|
break;
|
|
2561
|
-
case 'PUSH_RESULT':
|
|
2562
|
-
|
|
2563
|
-
this.#
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
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),
|
|
2570
2968
|
);
|
|
2969
|
+
responseOutboxCount = outboxCount;
|
|
2571
2970
|
break;
|
|
2971
|
+
}
|
|
2572
2972
|
case 'PUSH_RESULT_DETAILS':
|
|
2573
2973
|
// Pre-indexed above so companion ordering remains wire-additive.
|
|
2574
2974
|
break;
|
|
@@ -2766,10 +3166,14 @@ export class SyncClient {
|
|
|
2766
3166
|
if (errorFrame !== undefined) break;
|
|
2767
3167
|
}
|
|
2768
3168
|
} finally {
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
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
|
+
}
|
|
2773
3177
|
}
|
|
2774
3178
|
|
|
2775
3179
|
if (errorFrame !== undefined) throw errorFrame;
|
|
@@ -2799,9 +3203,10 @@ export class SyncClient {
|
|
|
2799
3203
|
summary: MutableSummary,
|
|
2800
3204
|
batch: ChangeAccumulator,
|
|
2801
3205
|
rejectionDetails: ReadonlyMap<number, RejectionDetails> | undefined,
|
|
2802
|
-
|
|
3206
|
+
pruneOutcomes: boolean,
|
|
3207
|
+
): boolean {
|
|
2803
3208
|
const commit = commitsById.get(frame.clientCommitId);
|
|
2804
|
-
if (commit === undefined) return;
|
|
3209
|
+
if (commit === undefined) return false;
|
|
2805
3210
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
2806
3211
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
2807
3212
|
// "already applied, you may have missed the ack".
|
|
@@ -2815,11 +3220,13 @@ export class SyncClient {
|
|
|
2815
3220
|
})),
|
|
2816
3221
|
});
|
|
2817
3222
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
2818
|
-
|
|
3223
|
+
if (pruneOutcomes) {
|
|
3224
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3225
|
+
}
|
|
2819
3226
|
batch.status();
|
|
2820
3227
|
batch.outcomes();
|
|
2821
3228
|
summary.applied.push(frame.clientCommitId);
|
|
2822
|
-
return;
|
|
3229
|
+
return true;
|
|
2823
3230
|
}
|
|
2824
3231
|
// rejected
|
|
2825
3232
|
const cacheMiss = frame.results.some(
|
|
@@ -2832,7 +3239,7 @@ export class SyncClient {
|
|
|
2832
3239
|
// §6.3: a serving failure, not the commit's outcome — keep the
|
|
2833
3240
|
// commit queued and retry the identical push later.
|
|
2834
3241
|
summary.retryable.push(frame.clientCommitId);
|
|
2835
|
-
return;
|
|
3242
|
+
return false;
|
|
2836
3243
|
}
|
|
2837
3244
|
const outcomeResults: CommitOperationOutcome[] = [];
|
|
2838
3245
|
for (const result of frame.results) {
|
|
@@ -2881,7 +3288,9 @@ export class SyncClient {
|
|
|
2881
3288
|
results: outcomeResults,
|
|
2882
3289
|
operations: commit.operations,
|
|
2883
3290
|
});
|
|
2884
|
-
|
|
3291
|
+
if (pruneOutcomes) {
|
|
3292
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
3293
|
+
}
|
|
2885
3294
|
batch.outcomes();
|
|
2886
3295
|
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
2887
3296
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
@@ -2891,6 +3300,7 @@ export class SyncClient {
|
|
|
2891
3300
|
});
|
|
2892
3301
|
batch.status();
|
|
2893
3302
|
summary.rejected.push(frame.clientCommitId);
|
|
3303
|
+
return true;
|
|
2894
3304
|
}
|
|
2895
3305
|
|
|
2896
3306
|
#decodeServerRow(
|