@syncular/client 0.15.15 → 0.15.17
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 +270 -3
- package/dist/diagnostics.d.ts +133 -0
- package/dist/diagnostics.js +24 -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 +341 -4
- package/src/diagnostics.ts +188 -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/dist/worker-host.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { WakeReason } from '@syncular/core';
|
|
|
25
25
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
26
26
|
import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
27
27
|
import type { SqlRow, SqlValue } from './database.js';
|
|
28
|
+
import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot } from './diagnostics.js';
|
|
28
29
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
29
30
|
import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
|
|
30
31
|
import { type LeaderLease, type LeaderLock } from './leader-lock.js';
|
|
@@ -107,6 +108,8 @@ export interface SyncClientHandleConfig {
|
|
|
107
108
|
readonly onUpgrading?: (upgrading: boolean) => void;
|
|
108
109
|
/** §8.6: presence on a scope key changed. */
|
|
109
110
|
readonly onPresence?: (scopeKey: string) => void;
|
|
111
|
+
/** Atomic privacy-safe diagnostics changed. */
|
|
112
|
+
readonly onDiagnostics?: (snapshot: ClientDiagnosticsSnapshot) => void;
|
|
110
113
|
}
|
|
111
114
|
/**
|
|
112
115
|
* A running worker core owned by THIS tab (the leader). Wraps the worker,
|
|
@@ -145,6 +148,7 @@ export declare class SyncClientHandle {
|
|
|
145
148
|
invalidation: InvalidationEmitter;
|
|
146
149
|
changes: ChangeEmitter;
|
|
147
150
|
presence: Set<(scopeKey: string) => void>;
|
|
151
|
+
diagnostics: ClientDiagnosticsEmitter;
|
|
148
152
|
roleListeners?: Set<(role: HandleRole) => void>;
|
|
149
153
|
leadershipListeners?: Set<(state: LeadershipState) => void>;
|
|
150
154
|
leadership?: LeadershipState;
|
|
@@ -163,6 +167,7 @@ export declare class SyncClientHandle {
|
|
|
163
167
|
*/
|
|
164
168
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
165
169
|
onChange(listener: ClientChangeListener): () => void;
|
|
170
|
+
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
166
171
|
/**
|
|
167
172
|
* §8.6: subscribe to presence changes — the identical surface as
|
|
168
173
|
* `SyncClient.onPresence`. Returns an unsubscribe function.
|
|
@@ -190,6 +195,7 @@ export declare class SyncClientHandle {
|
|
|
190
195
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
191
196
|
localRevision(): Promise<LocalRevision>;
|
|
192
197
|
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
198
|
+
diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
|
|
193
199
|
conflicts(): Promise<readonly ConflictRecord[]>;
|
|
194
200
|
rejections(): Promise<readonly RejectionRecord[]>;
|
|
195
201
|
commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
|
package/dist/worker-host.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { registerDevtools } from './devtools.js';
|
|
2
|
+
import { ClientDiagnosticsEmitter, withClientDiagnosticsHost, } from './diagnostics.js';
|
|
2
3
|
import { ClientSyncError } from './errors.js';
|
|
3
4
|
import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
4
5
|
import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
|
|
@@ -60,6 +61,7 @@ export class SyncClientHandle {
|
|
|
60
61
|
#invalidation;
|
|
61
62
|
#changes;
|
|
62
63
|
#presence;
|
|
64
|
+
#diagnostics;
|
|
63
65
|
#roleListeners;
|
|
64
66
|
#leadershipListeners;
|
|
65
67
|
#devtoolsUnregister;
|
|
@@ -84,6 +86,7 @@ export class SyncClientHandle {
|
|
|
84
86
|
this.#invalidation = internals.invalidation;
|
|
85
87
|
this.#changes = internals.changes;
|
|
86
88
|
this.#presence = internals.presence;
|
|
89
|
+
this.#diagnostics = internals.diagnostics;
|
|
87
90
|
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
88
91
|
this.#leadershipListeners = internals.leadershipListeners ?? new Set();
|
|
89
92
|
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
@@ -150,6 +153,13 @@ export class SyncClientHandle {
|
|
|
150
153
|
if (legacy !== undefined)
|
|
151
154
|
this.#invalidation.emit(legacy);
|
|
152
155
|
}
|
|
156
|
+
else if (event.kind === 'diagnostics') {
|
|
157
|
+
this.#diagnostics.emit(withClientDiagnosticsHost(event.snapshot, {
|
|
158
|
+
...event.snapshot.host,
|
|
159
|
+
kind: 'worker',
|
|
160
|
+
role: this.#role,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
153
163
|
}
|
|
154
164
|
/**
|
|
155
165
|
* TODO 3.1 / I1: subscribe to fine-grained invalidation — the identical
|
|
@@ -163,6 +173,9 @@ export class SyncClientHandle {
|
|
|
163
173
|
onChange(listener) {
|
|
164
174
|
return this.#changes.on(listener);
|
|
165
175
|
}
|
|
176
|
+
onDiagnostics(listener) {
|
|
177
|
+
return this.#diagnostics.on(listener);
|
|
178
|
+
}
|
|
166
179
|
/**
|
|
167
180
|
* §8.6: subscribe to presence changes — the identical surface as
|
|
168
181
|
* `SyncClient.onPresence`. Returns an unsubscribe function.
|
|
@@ -252,6 +265,14 @@ export class SyncClientHandle {
|
|
|
252
265
|
statusSnapshot() {
|
|
253
266
|
return this.#call('statusSnapshot', []);
|
|
254
267
|
}
|
|
268
|
+
async diagnosticsSnapshot(request = {}) {
|
|
269
|
+
const snapshot = await this.#call('diagnosticsSnapshot', [request]);
|
|
270
|
+
return withClientDiagnosticsHost(snapshot, {
|
|
271
|
+
...snapshot.host,
|
|
272
|
+
kind: 'worker',
|
|
273
|
+
role: this.#role,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
255
276
|
conflicts() {
|
|
256
277
|
return this.#call('conflicts', []);
|
|
257
278
|
}
|
|
@@ -470,6 +491,9 @@ function fireConfigCallbacks(config, event) {
|
|
|
470
491
|
...(event.error !== undefined ? { error: event.error } : {}),
|
|
471
492
|
});
|
|
472
493
|
}
|
|
494
|
+
else if (event.kind === 'diagnostics') {
|
|
495
|
+
config.onDiagnostics?.(event.snapshot);
|
|
496
|
+
}
|
|
473
497
|
}
|
|
474
498
|
/**
|
|
475
499
|
* Acquire leadership, spawn the worker, initialize the core inside it.
|
|
@@ -485,6 +509,7 @@ export async function createSyncClientHandle(config) {
|
|
|
485
509
|
const invalidation = new InvalidationEmitter();
|
|
486
510
|
const changes = new ChangeEmitter();
|
|
487
511
|
const presence = new Set();
|
|
512
|
+
const diagnostics = new ClientDiagnosticsEmitter();
|
|
488
513
|
const roleListeners = new Set();
|
|
489
514
|
if (resolvedConfig.onRoleChange !== undefined)
|
|
490
515
|
roleListeners.add(resolvedConfig.onRoleChange);
|
|
@@ -507,6 +532,7 @@ export async function createSyncClientHandle(config) {
|
|
|
507
532
|
invalidation,
|
|
508
533
|
changes,
|
|
509
534
|
presence,
|
|
535
|
+
diagnostics,
|
|
510
536
|
roleListeners,
|
|
511
537
|
leadershipListeners,
|
|
512
538
|
});
|
|
@@ -521,6 +547,7 @@ export async function createSyncClientHandle(config) {
|
|
|
521
547
|
invalidation,
|
|
522
548
|
changes,
|
|
523
549
|
presence,
|
|
550
|
+
diagnostics,
|
|
524
551
|
roleListeners,
|
|
525
552
|
leadershipListeners,
|
|
526
553
|
});
|
|
@@ -530,6 +557,7 @@ export async function createSyncClientHandle(config) {
|
|
|
530
557
|
invalidation,
|
|
531
558
|
changes,
|
|
532
559
|
presence,
|
|
560
|
+
diagnostics,
|
|
533
561
|
roleListeners,
|
|
534
562
|
leadershipListeners,
|
|
535
563
|
});
|
|
@@ -571,6 +599,7 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
571
599
|
invalidation: parts.invalidation,
|
|
572
600
|
changes: parts.changes,
|
|
573
601
|
presence: parts.presence,
|
|
602
|
+
diagnostics: parts.diagnostics,
|
|
574
603
|
roleListeners: parts.roleListeners,
|
|
575
604
|
leadershipListeners: parts.leadershipListeners,
|
|
576
605
|
});
|
|
@@ -659,6 +688,7 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
659
688
|
invalidation: parts.invalidation,
|
|
660
689
|
changes: parts.changes,
|
|
661
690
|
presence: parts.presence,
|
|
691
|
+
diagnostics: parts.diagnostics,
|
|
662
692
|
roleListeners: parts.roleListeners,
|
|
663
693
|
leadershipListeners: parts.leadershipListeners,
|
|
664
694
|
});
|
|
@@ -20,6 +20,7 @@ import type { WakeReason } from '@syncular/core';
|
|
|
20
20
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
21
21
|
import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
22
22
|
import type { SqlRow, SqlValue } from './database.js';
|
|
23
|
+
import type { ClientDiagnosticsRequest, ClientDiagnosticsSnapshot } from './diagnostics.js';
|
|
23
24
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
24
25
|
import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
|
|
25
26
|
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
|
|
@@ -107,6 +108,7 @@ export interface WorkerApi {
|
|
|
107
108
|
querySnapshot(spec: QueryReadSpec): QuerySnapshot;
|
|
108
109
|
localRevision(): LocalRevision;
|
|
109
110
|
statusSnapshot(): SyncStatusSnapshot;
|
|
111
|
+
diagnosticsSnapshot(request?: ClientDiagnosticsRequest): ClientDiagnosticsSnapshot;
|
|
110
112
|
conflicts(): readonly ConflictRecord[];
|
|
111
113
|
rejections(): readonly RejectionRecord[];
|
|
112
114
|
commitOutcome(clientCommitId: string): CommitOutcome | undefined;
|
|
@@ -180,6 +182,10 @@ export type SyncWorkerEvent = {
|
|
|
180
182
|
/** Exact revisioned core transaction; Sets and bigint clone directly. */
|
|
181
183
|
readonly kind: 'change';
|
|
182
184
|
readonly batch: ClientChangeBatch;
|
|
185
|
+
} | {
|
|
186
|
+
/** Atomic privacy-safe health/support evidence from the worker core. */
|
|
187
|
+
readonly kind: 'diagnostics';
|
|
188
|
+
readonly snapshot: ClientDiagnosticsSnapshot;
|
|
183
189
|
};
|
|
184
190
|
export type WorkerToMainMessage = {
|
|
185
191
|
readonly t: 'ready';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.17",
|
|
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.17"
|
|
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.17",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
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 {
|
|
@@ -531,6 +545,9 @@ export class SyncClient {
|
|
|
531
545
|
readonly #invalidation = new InvalidationEmitter();
|
|
532
546
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
533
547
|
readonly #presenceListeners = new Set<(scopeKey: string) => void>();
|
|
548
|
+
readonly #diagnostics = new ClientDiagnosticsEmitter();
|
|
549
|
+
#lastRound: DiagnosticLastRound | undefined;
|
|
550
|
+
#lastChange: DiagnosticLastChange | undefined;
|
|
534
551
|
/** The batch accumulator; non-undefined only inside `#applyBatch`. */
|
|
535
552
|
#batch: ChangeAccumulator | undefined;
|
|
536
553
|
/**
|
|
@@ -669,6 +686,7 @@ export class SyncClient {
|
|
|
669
686
|
upgrading: async () => this.upgrading,
|
|
670
687
|
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
671
688
|
});
|
|
689
|
+
this.#emitDiagnostics();
|
|
672
690
|
}
|
|
673
691
|
|
|
674
692
|
/**
|
|
@@ -819,6 +837,7 @@ export class SyncClient {
|
|
|
819
837
|
this.#config.onSyncNeeded?.('startup');
|
|
820
838
|
this.#config.onSyncIntent?.({ kind: 'interactive' });
|
|
821
839
|
}
|
|
840
|
+
this.#emitDiagnostics();
|
|
822
841
|
}
|
|
823
842
|
|
|
824
843
|
// -- accessors ------------------------------------------------------------
|
|
@@ -928,6 +947,222 @@ export class SyncClient {
|
|
|
928
947
|
return this.#changes.on(listener);
|
|
929
948
|
}
|
|
930
949
|
|
|
950
|
+
/** Subscribe to complete, privacy-safe diagnostic snapshots. */
|
|
951
|
+
onDiagnostics(listener: ClientDiagnosticsListener): () => void {
|
|
952
|
+
return this.#diagnostics.on(listener);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* One atomic support/product-health view. It never returns scope values,
|
|
957
|
+
* rows, SQL, paths, auth material, lease ids, keys, or mutation bodies.
|
|
958
|
+
*/
|
|
959
|
+
diagnosticsSnapshot(
|
|
960
|
+
request: ClientDiagnosticsRequest = {},
|
|
961
|
+
): ClientDiagnosticsSnapshot {
|
|
962
|
+
this.#requireActive();
|
|
963
|
+
const expected = request.expectedSubscriptions ?? [];
|
|
964
|
+
if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
|
|
965
|
+
throw new ClientSyncError(
|
|
966
|
+
'sync.invalid_request',
|
|
967
|
+
`diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`,
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
const registered = loadSubscriptions(this.#db);
|
|
971
|
+
const subscriptions = new Map<string, DiagnosticSubscription>();
|
|
972
|
+
for (const sub of registered) {
|
|
973
|
+
const reset = sub.cursor < 0 && sub.reasonCode === 'sync.cursor_expired';
|
|
974
|
+
const complete =
|
|
975
|
+
sub.status === 'active' &&
|
|
976
|
+
sub.cursor >= 0 &&
|
|
977
|
+
sub.bootstrapState === undefined;
|
|
978
|
+
subscriptions.set(sub.id, {
|
|
979
|
+
id: sub.id,
|
|
980
|
+
table: sub.table,
|
|
981
|
+
state:
|
|
982
|
+
sub.status === 'revoked'
|
|
983
|
+
? 'revoked'
|
|
984
|
+
: sub.status === 'failed'
|
|
985
|
+
? 'failed'
|
|
986
|
+
: reset
|
|
987
|
+
? 'reset'
|
|
988
|
+
: complete
|
|
989
|
+
? 'complete'
|
|
990
|
+
: 'bootstrapping',
|
|
991
|
+
complete,
|
|
992
|
+
cursor: sub.cursor,
|
|
993
|
+
...(sub.reasonCode !== undefined
|
|
994
|
+
? { reasonCode: this.#diagnosticCode(sub.reasonCode) }
|
|
995
|
+
: {}),
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
for (const item of expected) {
|
|
999
|
+
if (
|
|
1000
|
+
typeof item.id !== 'string' ||
|
|
1001
|
+
item.id.length === 0 ||
|
|
1002
|
+
typeof item.table !== 'string' ||
|
|
1003
|
+
item.table.length === 0
|
|
1004
|
+
) {
|
|
1005
|
+
throw new ClientSyncError(
|
|
1006
|
+
'sync.invalid_request',
|
|
1007
|
+
'diagnosticsSnapshot expected subscriptions require non-empty id and table strings',
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
const registeredSubscription = subscriptions.get(item.id);
|
|
1011
|
+
if (
|
|
1012
|
+
registeredSubscription !== undefined &&
|
|
1013
|
+
registeredSubscription.table !== item.table
|
|
1014
|
+
) {
|
|
1015
|
+
subscriptions.set(item.id, {
|
|
1016
|
+
id: item.id,
|
|
1017
|
+
table: item.table,
|
|
1018
|
+
state: 'failed',
|
|
1019
|
+
complete: false,
|
|
1020
|
+
reasonCode: 'client.subscription_intent_mismatch',
|
|
1021
|
+
});
|
|
1022
|
+
} else if (registeredSubscription === undefined) {
|
|
1023
|
+
subscriptions.set(item.id, {
|
|
1024
|
+
id: item.id,
|
|
1025
|
+
table: item.table,
|
|
1026
|
+
state: 'unregistered',
|
|
1027
|
+
complete: false,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const capturedAtMs = this.#now();
|
|
1032
|
+
const leaseState = this.#diagnosticLease(capturedAtMs);
|
|
1033
|
+
const connectivity =
|
|
1034
|
+
this.#lastRound?.status === 'succeeded'
|
|
1035
|
+
? 'online'
|
|
1036
|
+
: this.#lastRound?.status === 'failed' &&
|
|
1037
|
+
this.#transportFailureCode(this.#lastRound.errorCode)
|
|
1038
|
+
? 'offline'
|
|
1039
|
+
: 'unknown';
|
|
1040
|
+
const expectedOrder = new Map(
|
|
1041
|
+
expected.map((item, index) => [item.id, index] as const),
|
|
1042
|
+
);
|
|
1043
|
+
const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
|
|
1044
|
+
const aExpected = expectedOrder.get(a.id);
|
|
1045
|
+
const bExpected = expectedOrder.get(b.id);
|
|
1046
|
+
if (aExpected !== undefined || bExpected !== undefined) {
|
|
1047
|
+
return (
|
|
1048
|
+
(aExpected ?? Number.MAX_SAFE_INTEGER) -
|
|
1049
|
+
(bExpected ?? Number.MAX_SAFE_INTEGER)
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
return a.id.localeCompare(b.id);
|
|
1053
|
+
});
|
|
1054
|
+
return {
|
|
1055
|
+
version: CLIENT_DIAGNOSTICS_VERSION,
|
|
1056
|
+
capturedAtMs,
|
|
1057
|
+
host: {
|
|
1058
|
+
kind: 'direct',
|
|
1059
|
+
role: 'single',
|
|
1060
|
+
connectivity,
|
|
1061
|
+
realtime:
|
|
1062
|
+
this.#config.realtime === undefined
|
|
1063
|
+
? 'unsupported'
|
|
1064
|
+
: this.#socket === undefined
|
|
1065
|
+
? 'disconnected'
|
|
1066
|
+
: 'connected',
|
|
1067
|
+
},
|
|
1068
|
+
securityLifecycle: this.#securityLifecycle,
|
|
1069
|
+
schema: {
|
|
1070
|
+
currentVersion: this.#config.schema.version,
|
|
1071
|
+
upgrading: this.#upgrading,
|
|
1072
|
+
...(this.#schemaFloor?.requiredSchemaVersion !== undefined
|
|
1073
|
+
? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
|
|
1074
|
+
: {}),
|
|
1075
|
+
...(this.#schemaFloor?.latestSchemaVersion !== undefined
|
|
1076
|
+
? { latestVersion: this.#schemaFloor.latestSchemaVersion }
|
|
1077
|
+
: {}),
|
|
1078
|
+
},
|
|
1079
|
+
replica: {
|
|
1080
|
+
localRevision: getLocalRevision(this.#db).toString(),
|
|
1081
|
+
syncNeeded: this.#needsPull,
|
|
1082
|
+
pendingOutbox: listOutbox(this.#db).length,
|
|
1083
|
+
},
|
|
1084
|
+
lease: leaseState,
|
|
1085
|
+
subscriptions: allSubscriptions.slice(
|
|
1086
|
+
0,
|
|
1087
|
+
MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
1088
|
+
),
|
|
1089
|
+
subscriptionsTruncated:
|
|
1090
|
+
allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
1091
|
+
...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
|
|
1092
|
+
...(this.#lastChange !== undefined
|
|
1093
|
+
? { lastChange: this.#lastChange }
|
|
1094
|
+
: {}),
|
|
1095
|
+
storage: this.#diagnosticStorage(),
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
#diagnosticLease(nowMs: number): ClientDiagnosticsSnapshot['lease'] {
|
|
1100
|
+
const lease = this.#leaseState;
|
|
1101
|
+
if (lease?.errorCode !== undefined) {
|
|
1102
|
+
return {
|
|
1103
|
+
state: 'stopped',
|
|
1104
|
+
errorCode: this.#diagnosticCode(lease.errorCode),
|
|
1105
|
+
...(lease.expiresAtMs !== undefined
|
|
1106
|
+
? { expiresAtMs: lease.expiresAtMs }
|
|
1107
|
+
: {}),
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
if (lease?.expiresAtMs === undefined) return { state: 'none' };
|
|
1111
|
+
return {
|
|
1112
|
+
state: lease.expiresAtMs <= nowMs ? 'expired' : 'active',
|
|
1113
|
+
expiresAtMs: lease.expiresAtMs,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
#diagnosticStorage(): ClientDiagnosticsStorage {
|
|
1118
|
+
try {
|
|
1119
|
+
const pageCount = Number(
|
|
1120
|
+
this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0,
|
|
1121
|
+
);
|
|
1122
|
+
const pageSize = Number(
|
|
1123
|
+
this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0,
|
|
1124
|
+
);
|
|
1125
|
+
const outboxBytes = Number(
|
|
1126
|
+
this.#db.query(
|
|
1127
|
+
'SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox',
|
|
1128
|
+
)[0]?.bytes ?? 0,
|
|
1129
|
+
);
|
|
1130
|
+
const outcome = this.#db.query(
|
|
1131
|
+
`SELECT COUNT(*) AS entries,
|
|
1132
|
+
COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
|
|
1133
|
+
FROM _syncular_commit_outcomes`,
|
|
1134
|
+
)[0];
|
|
1135
|
+
const blobBytes = this.#hasBlobs
|
|
1136
|
+
? Number(
|
|
1137
|
+
this.#db.query(
|
|
1138
|
+
'SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs',
|
|
1139
|
+
)[0]?.bytes ?? 0,
|
|
1140
|
+
)
|
|
1141
|
+
: 0;
|
|
1142
|
+
const pressure =
|
|
1143
|
+
this.#config.blobCacheMaxBytes !== undefined &&
|
|
1144
|
+
blobBytes > this.#config.blobCacheMaxBytes;
|
|
1145
|
+
return {
|
|
1146
|
+
status: pressure ? 'pressure' : 'healthy',
|
|
1147
|
+
databaseBytesApprox: Math.max(0, pageCount * pageSize),
|
|
1148
|
+
pendingOutboxBytesApprox: Math.max(0, outboxBytes),
|
|
1149
|
+
retainedOutcomeBytesApprox: Math.max(0, Number(outcome?.bytes ?? 0)),
|
|
1150
|
+
retainedOutcomeEntries: Math.max(0, Number(outcome?.entries ?? 0)),
|
|
1151
|
+
blobCacheBytesApprox: Math.max(0, blobBytes),
|
|
1152
|
+
...(pressure
|
|
1153
|
+
? { pressureReasonCode: 'client.blob_cache_over_limit' as const }
|
|
1154
|
+
: {}),
|
|
1155
|
+
};
|
|
1156
|
+
} catch {
|
|
1157
|
+
return { status: 'unreadable' };
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
#emitDiagnostics(): void {
|
|
1162
|
+
if (!this.#started || this.#securityLifecycle !== 'active') return;
|
|
1163
|
+
this.#diagnostics.emit(this.diagnosticsSnapshot());
|
|
1164
|
+
}
|
|
1165
|
+
|
|
931
1166
|
/** One call for the complete status domain used by reactive hosts. */
|
|
932
1167
|
statusSnapshot(): SyncStatusSnapshot {
|
|
933
1168
|
this.#requireStarted();
|
|
@@ -976,9 +1211,25 @@ export class SyncClient {
|
|
|
976
1211
|
}
|
|
977
1212
|
if (revision !== undefined) {
|
|
978
1213
|
const event = batch.finish(revision, status);
|
|
1214
|
+
const tables = [...new Set(event.tables.map((entry) => entry.table))];
|
|
1215
|
+
const windows = [...new Set(event.windows.map((entry) => entry.table))];
|
|
1216
|
+
this.#lastChange = {
|
|
1217
|
+
revision: revision.toString(),
|
|
1218
|
+
recordedAtMs: this.#now(),
|
|
1219
|
+
tables: tables.slice(0, MAX_DIAGNOSTIC_DOMAINS),
|
|
1220
|
+
windows: windows.slice(0, MAX_DIAGNOSTIC_DOMAINS),
|
|
1221
|
+
domainsTruncated:
|
|
1222
|
+
tables.length > MAX_DIAGNOSTIC_DOMAINS ||
|
|
1223
|
+
windows.length > MAX_DIAGNOSTIC_DOMAINS,
|
|
1224
|
+
statusChanged: event.status !== undefined,
|
|
1225
|
+
conflictsChanged: event.conflictsChanged,
|
|
1226
|
+
rejectionsChanged: event.rejectionsChanged,
|
|
1227
|
+
outcomesChanged: event.outcomesChanged,
|
|
1228
|
+
};
|
|
979
1229
|
this.#changes.emit(event);
|
|
980
1230
|
const legacy = invalidationFromChange(event);
|
|
981
1231
|
if (legacy !== undefined) this.#invalidation.emit(legacy);
|
|
1232
|
+
this.#emitDiagnostics();
|
|
982
1233
|
}
|
|
983
1234
|
return result;
|
|
984
1235
|
}
|
|
@@ -1452,6 +1703,7 @@ export class SyncClient {
|
|
|
1452
1703
|
scopes: input.scopes,
|
|
1453
1704
|
...(input.params !== undefined ? { params: input.params } : {}),
|
|
1454
1705
|
});
|
|
1706
|
+
this.#emitDiagnostics();
|
|
1455
1707
|
return;
|
|
1456
1708
|
}
|
|
1457
1709
|
saveSubscription(this.#db, {
|
|
@@ -1462,11 +1714,13 @@ export class SyncClient {
|
|
|
1462
1714
|
cursor: -1,
|
|
1463
1715
|
status: 'active',
|
|
1464
1716
|
});
|
|
1717
|
+
this.#emitDiagnostics();
|
|
1465
1718
|
}
|
|
1466
1719
|
|
|
1467
1720
|
unsubscribe(id: string): void {
|
|
1468
1721
|
this.#requireActive();
|
|
1469
1722
|
deleteSubscription(this.#db, id);
|
|
1723
|
+
this.#emitDiagnostics();
|
|
1470
1724
|
}
|
|
1471
1725
|
|
|
1472
1726
|
// -- windowed subscriptions (§4.8) ------------------------------------------
|
|
@@ -2092,9 +2346,73 @@ export class SyncClient {
|
|
|
2092
2346
|
);
|
|
2093
2347
|
}
|
|
2094
2348
|
this.#syncOutstanding = true;
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2349
|
+
const startedAtMs = this.#now();
|
|
2350
|
+
return this.#serialize(() => this.#runSync())
|
|
2351
|
+
.then(
|
|
2352
|
+
(summary) => {
|
|
2353
|
+
const completedAtMs = this.#now();
|
|
2354
|
+
this.#lastRound = {
|
|
2355
|
+
status: 'succeeded',
|
|
2356
|
+
startedAtMs,
|
|
2357
|
+
completedAtMs,
|
|
2358
|
+
durationMs: Math.max(0, completedAtMs - startedAtMs),
|
|
2359
|
+
counters: this.#diagnosticRoundCounters(summary),
|
|
2360
|
+
};
|
|
2361
|
+
this.#emitDiagnostics();
|
|
2362
|
+
return summary;
|
|
2363
|
+
},
|
|
2364
|
+
(error: unknown) => {
|
|
2365
|
+
const completedAtMs = this.#now();
|
|
2366
|
+
const code = (error as { code?: unknown }).code;
|
|
2367
|
+
this.#lastRound = {
|
|
2368
|
+
status: 'failed',
|
|
2369
|
+
startedAtMs,
|
|
2370
|
+
completedAtMs,
|
|
2371
|
+
durationMs: Math.max(0, completedAtMs - startedAtMs),
|
|
2372
|
+
errorCode:
|
|
2373
|
+
typeof code === 'string'
|
|
2374
|
+
? this.#diagnosticCode(code)
|
|
2375
|
+
: 'client.unknown_failure',
|
|
2376
|
+
};
|
|
2377
|
+
this.#emitDiagnostics();
|
|
2378
|
+
throw error;
|
|
2379
|
+
},
|
|
2380
|
+
)
|
|
2381
|
+
.finally(() => {
|
|
2382
|
+
this.#syncOutstanding = false;
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
#diagnosticRoundCounters(summary: SyncSummary): DiagnosticRoundCounters {
|
|
2387
|
+
return {
|
|
2388
|
+
pushed: summary.pushed,
|
|
2389
|
+
applied: summary.applied.length,
|
|
2390
|
+
rejected: summary.rejected.length,
|
|
2391
|
+
retryable: summary.retryable.length,
|
|
2392
|
+
conflicts: summary.conflicts.length,
|
|
2393
|
+
commitsApplied: summary.commitsApplied,
|
|
2394
|
+
segmentRowsApplied: summary.segmentRowsApplied,
|
|
2395
|
+
bootstrapping: summary.bootstrapping.length,
|
|
2396
|
+
resets: summary.resets.length,
|
|
2397
|
+
revoked: summary.revoked.length,
|
|
2398
|
+
failed: summary.failed.length,
|
|
2399
|
+
deferredCommits: summary.deferredCommits ?? 0,
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
#transportFailureCode(code: string): boolean {
|
|
2404
|
+
return (
|
|
2405
|
+
code === 'transport.failed' ||
|
|
2406
|
+
code === 'transport.unavailable' ||
|
|
2407
|
+
code === 'sync.transport_failed' ||
|
|
2408
|
+
code === 'client.worker_failed'
|
|
2409
|
+
);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
#diagnosticCode(code: string): string {
|
|
2413
|
+
return code.length <= 96 && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/.test(code)
|
|
2414
|
+
? code
|
|
2415
|
+
: 'client.unknown_failure';
|
|
2098
2416
|
}
|
|
2099
2417
|
|
|
2100
2418
|
async #runSync(): Promise<SyncSummary> {
|
|
@@ -2254,7 +2572,23 @@ export class SyncClient {
|
|
|
2254
2572
|
*/
|
|
2255
2573
|
#roundTrip(request: Uint8Array): Promise<Uint8Array> {
|
|
2256
2574
|
const socket = this.#socket;
|
|
2257
|
-
if (socket === undefined)
|
|
2575
|
+
if (socket === undefined) {
|
|
2576
|
+
return Promise.resolve()
|
|
2577
|
+
.then(() => this.#config.transport(request))
|
|
2578
|
+
.catch((error: unknown) => {
|
|
2579
|
+
if (
|
|
2580
|
+
error instanceof ClientSyncError ||
|
|
2581
|
+
typeof (error as { code?: unknown })?.code === 'string'
|
|
2582
|
+
) {
|
|
2583
|
+
throw error;
|
|
2584
|
+
}
|
|
2585
|
+
throw new ClientSyncError(
|
|
2586
|
+
'sync.transport_failed',
|
|
2587
|
+
`transport round failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2588
|
+
true,
|
|
2589
|
+
);
|
|
2590
|
+
});
|
|
2591
|
+
}
|
|
2258
2592
|
return new Promise<Uint8Array>((resolve, reject) => {
|
|
2259
2593
|
// sync() already enforces one round in flight (§8.7).
|
|
2260
2594
|
this.#pendingRound = {
|
|
@@ -2309,6 +2643,7 @@ export class SyncClient {
|
|
|
2309
2643
|
this.#socket = undefined;
|
|
2310
2644
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
2311
2645
|
this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
|
|
2646
|
+
this.#emitDiagnostics();
|
|
2312
2647
|
},
|
|
2313
2648
|
});
|
|
2314
2649
|
if (this.#securityLifecycle === 'preflight') {
|
|
@@ -2319,6 +2654,7 @@ export class SyncClient {
|
|
|
2319
2654
|
);
|
|
2320
2655
|
}
|
|
2321
2656
|
this.#socket = socket;
|
|
2657
|
+
this.#emitDiagnostics();
|
|
2322
2658
|
}
|
|
2323
2659
|
|
|
2324
2660
|
disconnectRealtime(): void {
|
|
@@ -2326,6 +2662,7 @@ export class SyncClient {
|
|
|
2326
2662
|
this.#socket = undefined;
|
|
2327
2663
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
2328
2664
|
this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
|
|
2665
|
+
this.#emitDiagnostics();
|
|
2329
2666
|
}
|
|
2330
2667
|
|
|
2331
2668
|
/**
|