@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/README.md
CHANGED
|
@@ -179,6 +179,35 @@ The state is a discriminated union covering startup, migration,
|
|
|
179
179
|
children; a blocked live query has `phase === 'blocked'`, never an indefinite
|
|
180
180
|
loading state.
|
|
181
181
|
|
|
182
|
+
## Privacy-safe support diagnostics
|
|
183
|
+
|
|
184
|
+
Every direct and Worker/multi-tab client exposes the same versioned snapshot:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const snapshot = await client.diagnosticsSnapshot({
|
|
188
|
+
expectedSubscriptions: [
|
|
189
|
+
{ id: 'membership-security', table: 'facility_memberships' },
|
|
190
|
+
{ id: 'scheduler-window', table: 'surgeries' },
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const off = client.onDiagnostics(() => refreshSupportView());
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`expectedSubscriptions` contains application intent only—stable PHI-free ids
|
|
198
|
+
and generated table names, never scopes. It lets a support screen distinguish
|
|
199
|
+
an absent registration from a legitimate zero-row completed bootstrap. The
|
|
200
|
+
snapshot also distinguishes reset, revocation, failure, schema floor, lease
|
|
201
|
+
stop, pending outbox, offline transport, and storage pressure/unreadability.
|
|
202
|
+
Worker leaders and followers return identical evidence with their honest role.
|
|
203
|
+
|
|
204
|
+
The contract intentionally excludes scope values, rows and clinical row
|
|
205
|
+
counts, SQL, paths, client/actor/lease ids, auth, keys, mutation bodies, stack
|
|
206
|
+
traces, and arbitrary prose. It is safe to copy the JSON snapshot into a
|
|
207
|
+
redacted support ticket as long as the application also keeps subscription ids
|
|
208
|
+
free of patient/user data. Do not attach database files, console dumps, query
|
|
209
|
+
results, or app state alongside it. See SPEC §7.6.
|
|
210
|
+
|
|
182
211
|
## Durable commit outcomes
|
|
183
212
|
|
|
184
213
|
`SyncClient` and every host bridge expose `commitOutcome(id)`,
|
package/dist/client.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { type ScopeMap, type WakeReason } from '@syncular/core';
|
|
11
11
|
import { type BlobRef, type BlobTransport, type CachedBlob } from './blob.js';
|
|
12
12
|
import type { ClientDatabase, SqlRow, SqlValue } from './database.js';
|
|
13
|
+
import { type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot } from './diagnostics.js';
|
|
13
14
|
import type { EncryptionConfig } from './encryption.js';
|
|
14
15
|
import { type ClientChangeListener, type CommandResult, type InvalidationListener, type LocalRevision, type SyncIntent, type SyncStatusSnapshot } from './invalidation.js';
|
|
15
16
|
import { type LeaderLock } from './leader-lock.js';
|
|
@@ -283,6 +284,13 @@ export declare class SyncClient {
|
|
|
283
284
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
284
285
|
/** Subscribe to exact revisioned observer transactions (SPEC §7.5). */
|
|
285
286
|
onChange(listener: ClientChangeListener): () => void;
|
|
287
|
+
/** Subscribe to complete, privacy-safe diagnostic snapshots. */
|
|
288
|
+
onDiagnostics(listener: ClientDiagnosticsListener): () => void;
|
|
289
|
+
/**
|
|
290
|
+
* One atomic support/product-health view. It never returns scope values,
|
|
291
|
+
* rows, SQL, paths, auth material, lease ids, keys, or mutation bodies.
|
|
292
|
+
*/
|
|
293
|
+
diagnosticsSnapshot(request?: ClientDiagnosticsRequest): ClientDiagnosticsSnapshot;
|
|
286
294
|
/** One call for the complete status domain used by reactive hosts. */
|
|
287
295
|
statusSnapshot(): SyncStatusSnapshot;
|
|
288
296
|
/**
|
package/dist/client.js
CHANGED
|
@@ -11,6 +11,7 @@ import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encode
|
|
|
11
11
|
import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, evictScopedRows, upsertLocalRow, } from './apply.js';
|
|
12
12
|
import { clearPendingUpload, computeBlobId, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, reconcileBlobRefcounts, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
13
13
|
import { registerDevtools } from './devtools.js';
|
|
14
|
+
import { CLIENT_DIAGNOSTICS_VERSION, ClientDiagnosticsEmitter, MAX_DIAGNOSTIC_DOMAINS, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS, } from './diagnostics.js';
|
|
14
15
|
import { ClientSyncError } from './errors.js';
|
|
15
16
|
import { ChangeAccumulator, ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './invalidation.js';
|
|
16
17
|
import { singleOwnerLock, } from './leader-lock.js';
|
|
@@ -110,6 +111,9 @@ export class SyncClient {
|
|
|
110
111
|
#invalidation = new InvalidationEmitter();
|
|
111
112
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
112
113
|
#presenceListeners = new Set();
|
|
114
|
+
#diagnostics = new ClientDiagnosticsEmitter();
|
|
115
|
+
#lastRound;
|
|
116
|
+
#lastChange;
|
|
113
117
|
/** The batch accumulator; non-undefined only inside `#applyBatch`. */
|
|
114
118
|
#batch;
|
|
115
119
|
/**
|
|
@@ -224,6 +228,7 @@ export class SyncClient {
|
|
|
224
228
|
upgrading: async () => this.upgrading,
|
|
225
229
|
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
226
230
|
});
|
|
231
|
+
this.#emitDiagnostics();
|
|
227
232
|
}
|
|
228
233
|
/**
|
|
229
234
|
* §7.4.1/§7.4.2: compare the generated schema version to the persisted
|
|
@@ -359,6 +364,7 @@ export class SyncClient {
|
|
|
359
364
|
this.#config.onSyncNeeded?.('startup');
|
|
360
365
|
this.#config.onSyncIntent?.({ kind: 'interactive' });
|
|
361
366
|
}
|
|
367
|
+
this.#emitDiagnostics();
|
|
362
368
|
}
|
|
363
369
|
// -- accessors ------------------------------------------------------------
|
|
364
370
|
get clientId() {
|
|
@@ -452,6 +458,182 @@ export class SyncClient {
|
|
|
452
458
|
onChange(listener) {
|
|
453
459
|
return this.#changes.on(listener);
|
|
454
460
|
}
|
|
461
|
+
/** Subscribe to complete, privacy-safe diagnostic snapshots. */
|
|
462
|
+
onDiagnostics(listener) {
|
|
463
|
+
return this.#diagnostics.on(listener);
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* One atomic support/product-health view. It never returns scope values,
|
|
467
|
+
* rows, SQL, paths, auth material, lease ids, keys, or mutation bodies.
|
|
468
|
+
*/
|
|
469
|
+
diagnosticsSnapshot(request = {}) {
|
|
470
|
+
this.#requireActive();
|
|
471
|
+
const expected = request.expectedSubscriptions ?? [];
|
|
472
|
+
if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
|
|
473
|
+
throw new ClientSyncError('sync.invalid_request', `diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`);
|
|
474
|
+
}
|
|
475
|
+
const registered = loadSubscriptions(this.#db);
|
|
476
|
+
const subscriptions = new Map();
|
|
477
|
+
for (const sub of registered) {
|
|
478
|
+
const reset = sub.cursor < 0 && sub.reasonCode === 'sync.cursor_expired';
|
|
479
|
+
const complete = sub.status === 'active' &&
|
|
480
|
+
sub.cursor >= 0 &&
|
|
481
|
+
sub.bootstrapState === undefined;
|
|
482
|
+
subscriptions.set(sub.id, {
|
|
483
|
+
id: sub.id,
|
|
484
|
+
table: sub.table,
|
|
485
|
+
state: sub.status === 'revoked'
|
|
486
|
+
? 'revoked'
|
|
487
|
+
: sub.status === 'failed'
|
|
488
|
+
? 'failed'
|
|
489
|
+
: reset
|
|
490
|
+
? 'reset'
|
|
491
|
+
: complete
|
|
492
|
+
? 'complete'
|
|
493
|
+
: 'bootstrapping',
|
|
494
|
+
complete,
|
|
495
|
+
cursor: sub.cursor,
|
|
496
|
+
...(sub.reasonCode !== undefined
|
|
497
|
+
? { reasonCode: this.#diagnosticCode(sub.reasonCode) }
|
|
498
|
+
: {}),
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
for (const item of expected) {
|
|
502
|
+
if (typeof item.id !== 'string' ||
|
|
503
|
+
item.id.length === 0 ||
|
|
504
|
+
typeof item.table !== 'string' ||
|
|
505
|
+
item.table.length === 0) {
|
|
506
|
+
throw new ClientSyncError('sync.invalid_request', 'diagnosticsSnapshot expected subscriptions require non-empty id and table strings');
|
|
507
|
+
}
|
|
508
|
+
const registeredSubscription = subscriptions.get(item.id);
|
|
509
|
+
if (registeredSubscription !== undefined &&
|
|
510
|
+
registeredSubscription.table !== item.table) {
|
|
511
|
+
subscriptions.set(item.id, {
|
|
512
|
+
id: item.id,
|
|
513
|
+
table: item.table,
|
|
514
|
+
state: 'failed',
|
|
515
|
+
complete: false,
|
|
516
|
+
reasonCode: 'client.subscription_intent_mismatch',
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
else if (registeredSubscription === undefined) {
|
|
520
|
+
subscriptions.set(item.id, {
|
|
521
|
+
id: item.id,
|
|
522
|
+
table: item.table,
|
|
523
|
+
state: 'unregistered',
|
|
524
|
+
complete: false,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const capturedAtMs = this.#now();
|
|
529
|
+
const leaseState = this.#diagnosticLease(capturedAtMs);
|
|
530
|
+
const connectivity = this.#lastRound?.status === 'succeeded'
|
|
531
|
+
? 'online'
|
|
532
|
+
: this.#lastRound?.status === 'failed' &&
|
|
533
|
+
this.#transportFailureCode(this.#lastRound.errorCode)
|
|
534
|
+
? 'offline'
|
|
535
|
+
: 'unknown';
|
|
536
|
+
const expectedOrder = new Map(expected.map((item, index) => [item.id, index]));
|
|
537
|
+
const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
|
|
538
|
+
const aExpected = expectedOrder.get(a.id);
|
|
539
|
+
const bExpected = expectedOrder.get(b.id);
|
|
540
|
+
if (aExpected !== undefined || bExpected !== undefined) {
|
|
541
|
+
return ((aExpected ?? Number.MAX_SAFE_INTEGER) -
|
|
542
|
+
(bExpected ?? Number.MAX_SAFE_INTEGER));
|
|
543
|
+
}
|
|
544
|
+
return a.id.localeCompare(b.id);
|
|
545
|
+
});
|
|
546
|
+
return {
|
|
547
|
+
version: CLIENT_DIAGNOSTICS_VERSION,
|
|
548
|
+
capturedAtMs,
|
|
549
|
+
host: {
|
|
550
|
+
kind: 'direct',
|
|
551
|
+
role: 'single',
|
|
552
|
+
connectivity,
|
|
553
|
+
realtime: this.#config.realtime === undefined
|
|
554
|
+
? 'unsupported'
|
|
555
|
+
: this.#socket === undefined
|
|
556
|
+
? 'disconnected'
|
|
557
|
+
: 'connected',
|
|
558
|
+
},
|
|
559
|
+
securityLifecycle: this.#securityLifecycle,
|
|
560
|
+
schema: {
|
|
561
|
+
currentVersion: this.#config.schema.version,
|
|
562
|
+
upgrading: this.#upgrading,
|
|
563
|
+
...(this.#schemaFloor?.requiredSchemaVersion !== undefined
|
|
564
|
+
? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
|
|
565
|
+
: {}),
|
|
566
|
+
...(this.#schemaFloor?.latestSchemaVersion !== undefined
|
|
567
|
+
? { latestVersion: this.#schemaFloor.latestSchemaVersion }
|
|
568
|
+
: {}),
|
|
569
|
+
},
|
|
570
|
+
replica: {
|
|
571
|
+
localRevision: getLocalRevision(this.#db).toString(),
|
|
572
|
+
syncNeeded: this.#needsPull,
|
|
573
|
+
pendingOutbox: listOutbox(this.#db).length,
|
|
574
|
+
},
|
|
575
|
+
lease: leaseState,
|
|
576
|
+
subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
|
|
577
|
+
subscriptionsTruncated: allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
578
|
+
...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
|
|
579
|
+
...(this.#lastChange !== undefined
|
|
580
|
+
? { lastChange: this.#lastChange }
|
|
581
|
+
: {}),
|
|
582
|
+
storage: this.#diagnosticStorage(),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
#diagnosticLease(nowMs) {
|
|
586
|
+
const lease = this.#leaseState;
|
|
587
|
+
if (lease?.errorCode !== undefined) {
|
|
588
|
+
return {
|
|
589
|
+
state: 'stopped',
|
|
590
|
+
errorCode: this.#diagnosticCode(lease.errorCode),
|
|
591
|
+
...(lease.expiresAtMs !== undefined
|
|
592
|
+
? { expiresAtMs: lease.expiresAtMs }
|
|
593
|
+
: {}),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (lease?.expiresAtMs === undefined)
|
|
597
|
+
return { state: 'none' };
|
|
598
|
+
return {
|
|
599
|
+
state: lease.expiresAtMs <= nowMs ? 'expired' : 'active',
|
|
600
|
+
expiresAtMs: lease.expiresAtMs,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
#diagnosticStorage() {
|
|
604
|
+
try {
|
|
605
|
+
const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
|
|
606
|
+
const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
|
|
607
|
+
const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
|
|
608
|
+
const outcome = this.#db.query(`SELECT COUNT(*) AS entries,
|
|
609
|
+
COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
|
|
610
|
+
FROM _syncular_commit_outcomes`)[0];
|
|
611
|
+
const blobBytes = this.#hasBlobs
|
|
612
|
+
? Number(this.#db.query('SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
|
|
613
|
+
: 0;
|
|
614
|
+
const pressure = this.#config.blobCacheMaxBytes !== undefined &&
|
|
615
|
+
blobBytes > this.#config.blobCacheMaxBytes;
|
|
616
|
+
return {
|
|
617
|
+
status: pressure ? 'pressure' : 'healthy',
|
|
618
|
+
databaseBytesApprox: Math.max(0, pageCount * pageSize),
|
|
619
|
+
pendingOutboxBytesApprox: Math.max(0, outboxBytes),
|
|
620
|
+
retainedOutcomeBytesApprox: Math.max(0, Number(outcome?.bytes ?? 0)),
|
|
621
|
+
retainedOutcomeEntries: Math.max(0, Number(outcome?.entries ?? 0)),
|
|
622
|
+
blobCacheBytesApprox: Math.max(0, blobBytes),
|
|
623
|
+
...(pressure
|
|
624
|
+
? { pressureReasonCode: 'client.blob_cache_over_limit' }
|
|
625
|
+
: {}),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
return { status: 'unreadable' };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
#emitDiagnostics() {
|
|
633
|
+
if (!this.#started || this.#securityLifecycle !== 'active')
|
|
634
|
+
return;
|
|
635
|
+
this.#diagnostics.emit(this.diagnosticsSnapshot());
|
|
636
|
+
}
|
|
455
637
|
/** One call for the complete status domain used by reactive hosts. */
|
|
456
638
|
statusSnapshot() {
|
|
457
639
|
this.#requireStarted();
|
|
@@ -502,10 +684,25 @@ export class SyncClient {
|
|
|
502
684
|
}
|
|
503
685
|
if (revision !== undefined) {
|
|
504
686
|
const event = batch.finish(revision, status);
|
|
687
|
+
const tables = [...new Set(event.tables.map((entry) => entry.table))];
|
|
688
|
+
const windows = [...new Set(event.windows.map((entry) => entry.table))];
|
|
689
|
+
this.#lastChange = {
|
|
690
|
+
revision: revision.toString(),
|
|
691
|
+
recordedAtMs: this.#now(),
|
|
692
|
+
tables: tables.slice(0, MAX_DIAGNOSTIC_DOMAINS),
|
|
693
|
+
windows: windows.slice(0, MAX_DIAGNOSTIC_DOMAINS),
|
|
694
|
+
domainsTruncated: tables.length > MAX_DIAGNOSTIC_DOMAINS ||
|
|
695
|
+
windows.length > MAX_DIAGNOSTIC_DOMAINS,
|
|
696
|
+
statusChanged: event.status !== undefined,
|
|
697
|
+
conflictsChanged: event.conflictsChanged,
|
|
698
|
+
rejectionsChanged: event.rejectionsChanged,
|
|
699
|
+
outcomesChanged: event.outcomesChanged,
|
|
700
|
+
};
|
|
505
701
|
this.#changes.emit(event);
|
|
506
702
|
const legacy = invalidationFromChange(event);
|
|
507
703
|
if (legacy !== undefined)
|
|
508
704
|
this.#invalidation.emit(legacy);
|
|
705
|
+
this.#emitDiagnostics();
|
|
509
706
|
}
|
|
510
707
|
return result;
|
|
511
708
|
}
|
|
@@ -870,6 +1067,7 @@ export class SyncClient {
|
|
|
870
1067
|
scopes: input.scopes,
|
|
871
1068
|
...(input.params !== undefined ? { params: input.params } : {}),
|
|
872
1069
|
});
|
|
1070
|
+
this.#emitDiagnostics();
|
|
873
1071
|
return;
|
|
874
1072
|
}
|
|
875
1073
|
saveSubscription(this.#db, {
|
|
@@ -880,10 +1078,12 @@ export class SyncClient {
|
|
|
880
1078
|
cursor: -1,
|
|
881
1079
|
status: 'active',
|
|
882
1080
|
});
|
|
1081
|
+
this.#emitDiagnostics();
|
|
883
1082
|
}
|
|
884
1083
|
unsubscribe(id) {
|
|
885
1084
|
this.#requireActive();
|
|
886
1085
|
deleteSubscription(this.#db, id);
|
|
1086
|
+
this.#emitDiagnostics();
|
|
887
1087
|
}
|
|
888
1088
|
// -- windowed subscriptions (§4.8) ------------------------------------------
|
|
889
1089
|
/**
|
|
@@ -1403,10 +1603,65 @@ export class SyncClient {
|
|
|
1403
1603
|
return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
|
|
1404
1604
|
}
|
|
1405
1605
|
this.#syncOutstanding = true;
|
|
1406
|
-
|
|
1606
|
+
const startedAtMs = this.#now();
|
|
1607
|
+
return this.#serialize(() => this.#runSync())
|
|
1608
|
+
.then((summary) => {
|
|
1609
|
+
const completedAtMs = this.#now();
|
|
1610
|
+
this.#lastRound = {
|
|
1611
|
+
status: 'succeeded',
|
|
1612
|
+
startedAtMs,
|
|
1613
|
+
completedAtMs,
|
|
1614
|
+
durationMs: Math.max(0, completedAtMs - startedAtMs),
|
|
1615
|
+
counters: this.#diagnosticRoundCounters(summary),
|
|
1616
|
+
};
|
|
1617
|
+
this.#emitDiagnostics();
|
|
1618
|
+
return summary;
|
|
1619
|
+
}, (error) => {
|
|
1620
|
+
const completedAtMs = this.#now();
|
|
1621
|
+
const code = error.code;
|
|
1622
|
+
this.#lastRound = {
|
|
1623
|
+
status: 'failed',
|
|
1624
|
+
startedAtMs,
|
|
1625
|
+
completedAtMs,
|
|
1626
|
+
durationMs: Math.max(0, completedAtMs - startedAtMs),
|
|
1627
|
+
errorCode: typeof code === 'string'
|
|
1628
|
+
? this.#diagnosticCode(code)
|
|
1629
|
+
: 'client.unknown_failure',
|
|
1630
|
+
};
|
|
1631
|
+
this.#emitDiagnostics();
|
|
1632
|
+
throw error;
|
|
1633
|
+
})
|
|
1634
|
+
.finally(() => {
|
|
1407
1635
|
this.#syncOutstanding = false;
|
|
1408
1636
|
});
|
|
1409
1637
|
}
|
|
1638
|
+
#diagnosticRoundCounters(summary) {
|
|
1639
|
+
return {
|
|
1640
|
+
pushed: summary.pushed,
|
|
1641
|
+
applied: summary.applied.length,
|
|
1642
|
+
rejected: summary.rejected.length,
|
|
1643
|
+
retryable: summary.retryable.length,
|
|
1644
|
+
conflicts: summary.conflicts.length,
|
|
1645
|
+
commitsApplied: summary.commitsApplied,
|
|
1646
|
+
segmentRowsApplied: summary.segmentRowsApplied,
|
|
1647
|
+
bootstrapping: summary.bootstrapping.length,
|
|
1648
|
+
resets: summary.resets.length,
|
|
1649
|
+
revoked: summary.revoked.length,
|
|
1650
|
+
failed: summary.failed.length,
|
|
1651
|
+
deferredCommits: summary.deferredCommits ?? 0,
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
#transportFailureCode(code) {
|
|
1655
|
+
return (code === 'transport.failed' ||
|
|
1656
|
+
code === 'transport.unavailable' ||
|
|
1657
|
+
code === 'sync.transport_failed' ||
|
|
1658
|
+
code === 'client.worker_failed');
|
|
1659
|
+
}
|
|
1660
|
+
#diagnosticCode(code) {
|
|
1661
|
+
return code.length <= 96 && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/.test(code)
|
|
1662
|
+
? code
|
|
1663
|
+
: 'client.unknown_failure';
|
|
1664
|
+
}
|
|
1410
1665
|
async #runSync() {
|
|
1411
1666
|
if (this.#schemaFloor !== undefined) {
|
|
1412
1667
|
return {
|
|
@@ -1547,8 +1802,17 @@ export class SyncClient {
|
|
|
1547
1802
|
*/
|
|
1548
1803
|
#roundTrip(request) {
|
|
1549
1804
|
const socket = this.#socket;
|
|
1550
|
-
if (socket === undefined)
|
|
1551
|
-
return
|
|
1805
|
+
if (socket === undefined) {
|
|
1806
|
+
return Promise.resolve()
|
|
1807
|
+
.then(() => this.#config.transport(request))
|
|
1808
|
+
.catch((error) => {
|
|
1809
|
+
if (error instanceof ClientSyncError ||
|
|
1810
|
+
typeof error?.code === 'string') {
|
|
1811
|
+
throw error;
|
|
1812
|
+
}
|
|
1813
|
+
throw new ClientSyncError('sync.transport_failed', `transport round failed: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1552
1816
|
return new Promise((resolve, reject) => {
|
|
1553
1817
|
// sync() already enforces one round in flight (§8.7).
|
|
1554
1818
|
this.#pendingRound = {
|
|
@@ -1592,6 +1856,7 @@ export class SyncClient {
|
|
|
1592
1856
|
this.#socket = undefined;
|
|
1593
1857
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
1594
1858
|
this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
|
|
1859
|
+
this.#emitDiagnostics();
|
|
1595
1860
|
},
|
|
1596
1861
|
});
|
|
1597
1862
|
if (this.#securityLifecycle === 'preflight') {
|
|
@@ -1599,12 +1864,14 @@ export class SyncClient {
|
|
|
1599
1864
|
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
|
|
1600
1865
|
}
|
|
1601
1866
|
this.#socket = socket;
|
|
1867
|
+
this.#emitDiagnostics();
|
|
1602
1868
|
}
|
|
1603
1869
|
disconnectRealtime() {
|
|
1604
1870
|
this.#socket?.close();
|
|
1605
1871
|
this.#socket = undefined;
|
|
1606
1872
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
1607
1873
|
this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
|
|
1874
|
+
this.#emitDiagnostics();
|
|
1608
1875
|
}
|
|
1609
1876
|
/**
|
|
1610
1877
|
* §8.7 channel-tag routing (synchronous, so chunk order is preserved):
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Privacy-safe client diagnostics shared by direct, Worker, Tauri, React
|
|
3
|
+
* Native, and normalized React hosts. The snapshot deliberately excludes
|
|
4
|
+
* requested/effective scope values, row/cardinality data, SQL, database paths,
|
|
5
|
+
* auth material, lease ids, encryption keys, mutation bodies, and arbitrary
|
|
6
|
+
* diagnostic prose.
|
|
7
|
+
*/
|
|
8
|
+
import type { SecurityLifecycle } from './client.js';
|
|
9
|
+
export declare const CLIENT_DIAGNOSTICS_VERSION: 1;
|
|
10
|
+
export declare const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS = 256;
|
|
11
|
+
export declare const MAX_DIAGNOSTIC_DOMAINS = 256;
|
|
12
|
+
export type ClientDiagnosticsHostKind = 'direct' | 'worker' | 'tauri' | 'react-native';
|
|
13
|
+
export type ClientDiagnosticsHostRole = 'single' | 'leader' | 'follower' | 'unknown';
|
|
14
|
+
export type ClientDiagnosticsConnectivity = 'online' | 'offline' | 'unknown';
|
|
15
|
+
export type ClientDiagnosticsRealtime = 'connected' | 'disconnected' | 'unsupported' | 'unknown';
|
|
16
|
+
export interface ClientDiagnosticsHost {
|
|
17
|
+
readonly kind: ClientDiagnosticsHostKind;
|
|
18
|
+
readonly role: ClientDiagnosticsHostRole;
|
|
19
|
+
readonly connectivity: ClientDiagnosticsConnectivity;
|
|
20
|
+
readonly realtime: ClientDiagnosticsRealtime;
|
|
21
|
+
}
|
|
22
|
+
export interface ExpectedDiagnosticSubscription {
|
|
23
|
+
/** Application-owned stable identifier. It must never contain PHI. */
|
|
24
|
+
readonly id: string;
|
|
25
|
+
/** Generated schema table name. */
|
|
26
|
+
readonly table: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ClientDiagnosticsRequest {
|
|
29
|
+
/**
|
|
30
|
+
* Optional bounded intent list. Missing registrations are returned as
|
|
31
|
+
* `unregistered`, allowing a zero-row security scope to fail closed without
|
|
32
|
+
* reading private Syncular tables. Values/scopes are intentionally absent.
|
|
33
|
+
*/
|
|
34
|
+
readonly expectedSubscriptions?: readonly ExpectedDiagnosticSubscription[];
|
|
35
|
+
}
|
|
36
|
+
export type DiagnosticSubscriptionState = 'unregistered' | 'bootstrapping' | 'complete' | 'reset' | 'revoked' | 'failed';
|
|
37
|
+
export interface DiagnosticSubscription {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
readonly table: string;
|
|
40
|
+
readonly state: DiagnosticSubscriptionState;
|
|
41
|
+
readonly complete: boolean;
|
|
42
|
+
/** Last fully applied local commit sequence; absent when unregistered. */
|
|
43
|
+
readonly cursor?: number;
|
|
44
|
+
readonly reasonCode?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface DiagnosticRoundCounters {
|
|
47
|
+
readonly pushed: number;
|
|
48
|
+
readonly applied: number;
|
|
49
|
+
readonly rejected: number;
|
|
50
|
+
readonly retryable: number;
|
|
51
|
+
readonly conflicts: number;
|
|
52
|
+
readonly commitsApplied: number;
|
|
53
|
+
readonly segmentRowsApplied: number;
|
|
54
|
+
readonly bootstrapping: number;
|
|
55
|
+
readonly resets: number;
|
|
56
|
+
readonly revoked: number;
|
|
57
|
+
readonly failed: number;
|
|
58
|
+
readonly deferredCommits: number;
|
|
59
|
+
}
|
|
60
|
+
export type DiagnosticLastRound = {
|
|
61
|
+
readonly status: 'succeeded';
|
|
62
|
+
readonly startedAtMs: number;
|
|
63
|
+
readonly completedAtMs: number;
|
|
64
|
+
readonly durationMs: number;
|
|
65
|
+
readonly counters: DiagnosticRoundCounters;
|
|
66
|
+
} | {
|
|
67
|
+
readonly status: 'failed';
|
|
68
|
+
readonly startedAtMs: number;
|
|
69
|
+
readonly completedAtMs: number;
|
|
70
|
+
readonly durationMs: number;
|
|
71
|
+
/** Stable code only; never arbitrary transport/server prose. */
|
|
72
|
+
readonly errorCode: string;
|
|
73
|
+
};
|
|
74
|
+
export interface DiagnosticLastChange {
|
|
75
|
+
/** Decimal u64 for JSON/IPC parity. */
|
|
76
|
+
readonly revision: string;
|
|
77
|
+
readonly recordedAtMs: number;
|
|
78
|
+
/** Generated table names only; no scope keys or row ids. */
|
|
79
|
+
readonly tables: readonly string[];
|
|
80
|
+
/** Generated table names for changed window registrations/completeness. */
|
|
81
|
+
readonly windows: readonly string[];
|
|
82
|
+
readonly domainsTruncated: boolean;
|
|
83
|
+
readonly statusChanged: boolean;
|
|
84
|
+
readonly conflictsChanged: boolean;
|
|
85
|
+
readonly rejectionsChanged: boolean;
|
|
86
|
+
readonly outcomesChanged: boolean;
|
|
87
|
+
}
|
|
88
|
+
export interface ClientDiagnosticsStorage {
|
|
89
|
+
readonly status: 'healthy' | 'pressure' | 'unreadable';
|
|
90
|
+
/** SQLite page estimate. No path, filename, or per-domain row counts. */
|
|
91
|
+
readonly databaseBytesApprox?: number;
|
|
92
|
+
readonly pendingOutboxBytesApprox?: number;
|
|
93
|
+
readonly retainedOutcomeBytesApprox?: number;
|
|
94
|
+
readonly retainedOutcomeEntries?: number;
|
|
95
|
+
readonly blobCacheBytesApprox?: number;
|
|
96
|
+
readonly pressureReasonCode?: 'client.blob_cache_over_limit';
|
|
97
|
+
}
|
|
98
|
+
export interface ClientDiagnosticsSnapshot {
|
|
99
|
+
readonly version: typeof CLIENT_DIAGNOSTICS_VERSION;
|
|
100
|
+
readonly capturedAtMs: number;
|
|
101
|
+
readonly host: ClientDiagnosticsHost;
|
|
102
|
+
readonly securityLifecycle: SecurityLifecycle;
|
|
103
|
+
readonly schema: {
|
|
104
|
+
readonly currentVersion: number;
|
|
105
|
+
readonly upgrading: boolean;
|
|
106
|
+
readonly requiredVersion?: number;
|
|
107
|
+
readonly latestVersion?: number;
|
|
108
|
+
};
|
|
109
|
+
readonly replica: {
|
|
110
|
+
/** Decimal u64 for JSON/IPC parity. */
|
|
111
|
+
readonly localRevision: string;
|
|
112
|
+
readonly syncNeeded: boolean;
|
|
113
|
+
readonly pendingOutbox: number;
|
|
114
|
+
};
|
|
115
|
+
readonly lease: {
|
|
116
|
+
readonly state: 'none' | 'active' | 'expired' | 'stopped';
|
|
117
|
+
readonly expiresAtMs?: number;
|
|
118
|
+
readonly errorCode?: string;
|
|
119
|
+
};
|
|
120
|
+
readonly subscriptions: readonly DiagnosticSubscription[];
|
|
121
|
+
readonly subscriptionsTruncated: boolean;
|
|
122
|
+
readonly lastRound?: DiagnosticLastRound;
|
|
123
|
+
readonly lastChange?: DiagnosticLastChange;
|
|
124
|
+
readonly storage: ClientDiagnosticsStorage;
|
|
125
|
+
}
|
|
126
|
+
export type ClientDiagnosticsListener = (snapshot: ClientDiagnosticsSnapshot) => void;
|
|
127
|
+
export declare class ClientDiagnosticsEmitter {
|
|
128
|
+
#private;
|
|
129
|
+
on(listener: ClientDiagnosticsListener): () => void;
|
|
130
|
+
emit(snapshot: ClientDiagnosticsSnapshot): void;
|
|
131
|
+
}
|
|
132
|
+
/** Host wrappers replace topology facts without changing core evidence. */
|
|
133
|
+
export declare function withClientDiagnosticsHost(snapshot: ClientDiagnosticsSnapshot, host: ClientDiagnosticsHost): ClientDiagnosticsSnapshot;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const CLIENT_DIAGNOSTICS_VERSION = 1;
|
|
2
|
+
export const MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS = 256;
|
|
3
|
+
export const MAX_DIAGNOSTIC_DOMAINS = 256;
|
|
4
|
+
export class ClientDiagnosticsEmitter {
|
|
5
|
+
#listeners = new Set();
|
|
6
|
+
on(listener) {
|
|
7
|
+
this.#listeners.add(listener);
|
|
8
|
+
return () => this.#listeners.delete(listener);
|
|
9
|
+
}
|
|
10
|
+
emit(snapshot) {
|
|
11
|
+
for (const listener of this.#listeners) {
|
|
12
|
+
try {
|
|
13
|
+
listener(snapshot);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Diagnostics observers cannot alter sync correctness.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Host wrappers replace topology facts without changing core evidence. */
|
|
22
|
+
export function withClientDiagnosticsHost(snapshot, host) {
|
|
23
|
+
return { ...snapshot, host };
|
|
24
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * from './client.js';
|
|
|
15
15
|
export * from './content-type.js';
|
|
16
16
|
export * from './database.js';
|
|
17
17
|
export * from './devtools.js';
|
|
18
|
+
export * from './diagnostics.js';
|
|
18
19
|
export * from './encryption.js';
|
|
19
20
|
export * from './errors.js';
|
|
20
21
|
export * from './http.js';
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export * from './client.js';
|
|
|
15
15
|
export * from './content-type.js';
|
|
16
16
|
export * from './database.js';
|
|
17
17
|
export * from './devtools.js';
|
|
18
|
+
export * from './diagnostics.js';
|
|
18
19
|
export * from './encryption.js';
|
|
19
20
|
export * from './errors.js';
|
|
20
21
|
export * from './http.js';
|
package/dist/worker-entry.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* the core owns exactly one loop.
|
|
15
15
|
*/
|
|
16
16
|
import { SyncClient } from './client.js';
|
|
17
|
+
import { withClientDiagnosticsHost, } from './diagnostics.js';
|
|
17
18
|
import { encryptionConfigFromKeyring } from './encryption.js';
|
|
18
19
|
import { ClientSyncError } from './errors.js';
|
|
19
20
|
import { httpBlobTransport, httpSegmentDownloader, httpSyncTransport, webSocketRealtimeConnector, } from './http.js';
|
|
@@ -251,6 +252,23 @@ export function startSyncWorker(overrides = {}) {
|
|
|
251
252
|
if (!closed)
|
|
252
253
|
post({ t: 'event', event: { kind: 'change', batch } });
|
|
253
254
|
});
|
|
255
|
+
const postDiagnostics = (snapshot) => {
|
|
256
|
+
if (closed)
|
|
257
|
+
return;
|
|
258
|
+
post({
|
|
259
|
+
t: 'event',
|
|
260
|
+
event: {
|
|
261
|
+
kind: 'diagnostics',
|
|
262
|
+
snapshot: withClientDiagnosticsHost(snapshot, {
|
|
263
|
+
kind: 'worker',
|
|
264
|
+
role: 'leader',
|
|
265
|
+
connectivity: offline ? 'offline' : snapshot.host.connectivity,
|
|
266
|
+
realtime: snapshot.host.realtime,
|
|
267
|
+
}),
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
started.onDiagnostics(postDiagnostics);
|
|
254
272
|
await started.start();
|
|
255
273
|
realtimeConnector =
|
|
256
274
|
overrides.createRealtime !== undefined
|
|
@@ -315,6 +333,15 @@ export function startSyncWorker(overrides = {}) {
|
|
|
315
333
|
querySnapshot: (spec) => requireClient().querySnapshot(spec),
|
|
316
334
|
localRevision: () => requireClient().localRevision,
|
|
317
335
|
statusSnapshot: () => requireClient().statusSnapshot(),
|
|
336
|
+
diagnosticsSnapshot: (request) => {
|
|
337
|
+
const snapshot = requireClient().diagnosticsSnapshot(request);
|
|
338
|
+
return withClientDiagnosticsHost(snapshot, {
|
|
339
|
+
kind: 'worker',
|
|
340
|
+
role: 'leader',
|
|
341
|
+
connectivity: offline ? 'offline' : snapshot.host.connectivity,
|
|
342
|
+
realtime: snapshot.host.realtime,
|
|
343
|
+
});
|
|
344
|
+
},
|
|
318
345
|
conflicts: () => requireClient().conflicts,
|
|
319
346
|
rejections: () => requireClient().rejections,
|
|
320
347
|
commitOutcome: (clientCommitId) => requireClient().commitOutcome(clientCommitId),
|
|
@@ -337,6 +364,21 @@ export function startSyncWorker(overrides = {}) {
|
|
|
337
364
|
offline = value;
|
|
338
365
|
if (offline)
|
|
339
366
|
client?.disconnectRealtime();
|
|
367
|
+
else if (client !== undefined) {
|
|
368
|
+
const snapshot = client.diagnosticsSnapshot();
|
|
369
|
+
post({
|
|
370
|
+
t: 'event',
|
|
371
|
+
event: {
|
|
372
|
+
kind: 'diagnostics',
|
|
373
|
+
snapshot: withClientDiagnosticsHost(snapshot, {
|
|
374
|
+
kind: 'worker',
|
|
375
|
+
role: 'leader',
|
|
376
|
+
connectivity: snapshot.host.connectivity,
|
|
377
|
+
realtime: snapshot.host.realtime,
|
|
378
|
+
}),
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
340
382
|
},
|
|
341
383
|
close: async () => {
|
|
342
384
|
closed = true;
|