@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/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';
|
|
@@ -68,6 +69,12 @@ function emptySummary(pushed) {
|
|
|
68
69
|
failed: [],
|
|
69
70
|
};
|
|
70
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
|
+
}
|
|
71
78
|
export class SyncClient {
|
|
72
79
|
#config;
|
|
73
80
|
#db;
|
|
@@ -110,6 +117,11 @@ export class SyncClient {
|
|
|
110
117
|
#invalidation = new InvalidationEmitter();
|
|
111
118
|
/** §8.6: subscribable presence-change listeners (twin of onPresence). */
|
|
112
119
|
#presenceListeners = new Set();
|
|
120
|
+
#diagnostics = new ClientDiagnosticsEmitter();
|
|
121
|
+
#diagnosticsDeferralDepth = 0;
|
|
122
|
+
#diagnosticsPending = false;
|
|
123
|
+
#lastRound;
|
|
124
|
+
#lastChange;
|
|
113
125
|
/** The batch accumulator; non-undefined only inside `#applyBatch`. */
|
|
114
126
|
#batch;
|
|
115
127
|
/**
|
|
@@ -224,6 +236,7 @@ export class SyncClient {
|
|
|
224
236
|
upgrading: async () => this.upgrading,
|
|
225
237
|
onInvalidate: (listener) => this.onInvalidate(listener),
|
|
226
238
|
});
|
|
239
|
+
this.#emitDiagnostics();
|
|
227
240
|
}
|
|
228
241
|
/**
|
|
229
242
|
* §7.4.1/§7.4.2: compare the generated schema version to the persisted
|
|
@@ -359,6 +372,7 @@ export class SyncClient {
|
|
|
359
372
|
this.#config.onSyncNeeded?.('startup');
|
|
360
373
|
this.#config.onSyncIntent?.({ kind: 'interactive' });
|
|
361
374
|
}
|
|
375
|
+
this.#emitDiagnostics();
|
|
362
376
|
}
|
|
363
377
|
// -- accessors ------------------------------------------------------------
|
|
364
378
|
get clientId() {
|
|
@@ -452,15 +466,210 @@ export class SyncClient {
|
|
|
452
466
|
onChange(listener) {
|
|
453
467
|
return this.#changes.on(listener);
|
|
454
468
|
}
|
|
469
|
+
/** Subscribe to complete, privacy-safe diagnostic snapshots. */
|
|
470
|
+
onDiagnostics(listener) {
|
|
471
|
+
return this.#diagnostics.on(listener);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* One atomic support/product-health view. It never returns scope values,
|
|
475
|
+
* rows, SQL, paths, auth material, lease ids, keys, or mutation bodies.
|
|
476
|
+
*/
|
|
477
|
+
diagnosticsSnapshot(request = {}) {
|
|
478
|
+
this.#requireActive();
|
|
479
|
+
const expected = request.expectedSubscriptions ?? [];
|
|
480
|
+
if (expected.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS) {
|
|
481
|
+
throw new ClientSyncError('sync.invalid_request', `diagnosticsSnapshot accepts at most ${MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions`);
|
|
482
|
+
}
|
|
483
|
+
const registered = loadSubscriptions(this.#db);
|
|
484
|
+
const subscriptions = new Map();
|
|
485
|
+
for (const sub of registered) {
|
|
486
|
+
const reset = sub.cursor < 0 && sub.reasonCode === 'sync.cursor_expired';
|
|
487
|
+
const complete = sub.status === 'active' &&
|
|
488
|
+
sub.cursor >= 0 &&
|
|
489
|
+
sub.bootstrapState === undefined;
|
|
490
|
+
subscriptions.set(sub.id, {
|
|
491
|
+
id: sub.id,
|
|
492
|
+
table: sub.table,
|
|
493
|
+
state: sub.status === 'revoked'
|
|
494
|
+
? 'revoked'
|
|
495
|
+
: sub.status === 'failed'
|
|
496
|
+
? 'failed'
|
|
497
|
+
: reset
|
|
498
|
+
? 'reset'
|
|
499
|
+
: complete
|
|
500
|
+
? 'complete'
|
|
501
|
+
: 'bootstrapping',
|
|
502
|
+
complete,
|
|
503
|
+
cursor: sub.cursor,
|
|
504
|
+
...(sub.reasonCode !== undefined
|
|
505
|
+
? { reasonCode: this.#diagnosticCode(sub.reasonCode) }
|
|
506
|
+
: {}),
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
for (const item of expected) {
|
|
510
|
+
if (typeof item.id !== 'string' ||
|
|
511
|
+
item.id.length === 0 ||
|
|
512
|
+
typeof item.table !== 'string' ||
|
|
513
|
+
item.table.length === 0) {
|
|
514
|
+
throw new ClientSyncError('sync.invalid_request', 'diagnosticsSnapshot expected subscriptions require non-empty id and table strings');
|
|
515
|
+
}
|
|
516
|
+
const registeredSubscription = subscriptions.get(item.id);
|
|
517
|
+
if (registeredSubscription !== undefined &&
|
|
518
|
+
registeredSubscription.table !== item.table) {
|
|
519
|
+
subscriptions.set(item.id, {
|
|
520
|
+
id: item.id,
|
|
521
|
+
table: item.table,
|
|
522
|
+
state: 'failed',
|
|
523
|
+
complete: false,
|
|
524
|
+
reasonCode: 'client.subscription_intent_mismatch',
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
else if (registeredSubscription === undefined) {
|
|
528
|
+
subscriptions.set(item.id, {
|
|
529
|
+
id: item.id,
|
|
530
|
+
table: item.table,
|
|
531
|
+
state: 'unregistered',
|
|
532
|
+
complete: false,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const capturedAtMs = this.#now();
|
|
537
|
+
const leaseState = this.#diagnosticLease(capturedAtMs);
|
|
538
|
+
const connectivity = this.#lastRound?.status === 'succeeded'
|
|
539
|
+
? 'online'
|
|
540
|
+
: this.#lastRound?.status === 'failed' &&
|
|
541
|
+
this.#transportFailureCode(this.#lastRound.errorCode)
|
|
542
|
+
? 'offline'
|
|
543
|
+
: 'unknown';
|
|
544
|
+
const expectedOrder = new Map(expected.map((item, index) => [item.id, index]));
|
|
545
|
+
const allSubscriptions = [...subscriptions.values()].sort((a, b) => {
|
|
546
|
+
const aExpected = expectedOrder.get(a.id);
|
|
547
|
+
const bExpected = expectedOrder.get(b.id);
|
|
548
|
+
if (aExpected !== undefined || bExpected !== undefined) {
|
|
549
|
+
return ((aExpected ?? Number.MAX_SAFE_INTEGER) -
|
|
550
|
+
(bExpected ?? Number.MAX_SAFE_INTEGER));
|
|
551
|
+
}
|
|
552
|
+
return a.id.localeCompare(b.id);
|
|
553
|
+
});
|
|
554
|
+
return {
|
|
555
|
+
version: CLIENT_DIAGNOSTICS_VERSION,
|
|
556
|
+
capturedAtMs,
|
|
557
|
+
host: {
|
|
558
|
+
kind: 'direct',
|
|
559
|
+
role: 'single',
|
|
560
|
+
connectivity,
|
|
561
|
+
realtime: this.#config.realtime === undefined
|
|
562
|
+
? 'unsupported'
|
|
563
|
+
: this.#socket === undefined
|
|
564
|
+
? 'disconnected'
|
|
565
|
+
: 'connected',
|
|
566
|
+
},
|
|
567
|
+
securityLifecycle: this.#securityLifecycle,
|
|
568
|
+
schema: {
|
|
569
|
+
currentVersion: this.#config.schema.version,
|
|
570
|
+
upgrading: this.#upgrading,
|
|
571
|
+
...(this.#schemaFloor?.requiredSchemaVersion !== undefined
|
|
572
|
+
? { requiredVersion: this.#schemaFloor.requiredSchemaVersion }
|
|
573
|
+
: {}),
|
|
574
|
+
...(this.#schemaFloor?.latestSchemaVersion !== undefined
|
|
575
|
+
? { latestVersion: this.#schemaFloor.latestSchemaVersion }
|
|
576
|
+
: {}),
|
|
577
|
+
},
|
|
578
|
+
replica: {
|
|
579
|
+
localRevision: getLocalRevision(this.#db).toString(),
|
|
580
|
+
syncNeeded: this.#needsPull,
|
|
581
|
+
pendingOutbox: listOutbox(this.#db).length,
|
|
582
|
+
},
|
|
583
|
+
lease: leaseState,
|
|
584
|
+
subscriptions: allSubscriptions.slice(0, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS),
|
|
585
|
+
subscriptionsTruncated: allSubscriptions.length > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
|
|
586
|
+
...(this.#lastRound !== undefined ? { lastRound: this.#lastRound } : {}),
|
|
587
|
+
...(this.#lastChange !== undefined
|
|
588
|
+
? { lastChange: this.#lastChange }
|
|
589
|
+
: {}),
|
|
590
|
+
storage: this.#diagnosticStorage(),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
#diagnosticLease(nowMs) {
|
|
594
|
+
const lease = this.#leaseState;
|
|
595
|
+
if (lease?.errorCode !== undefined) {
|
|
596
|
+
return {
|
|
597
|
+
state: 'stopped',
|
|
598
|
+
errorCode: this.#diagnosticCode(lease.errorCode),
|
|
599
|
+
...(lease.expiresAtMs !== undefined
|
|
600
|
+
? { expiresAtMs: lease.expiresAtMs }
|
|
601
|
+
: {}),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
if (lease?.expiresAtMs === undefined)
|
|
605
|
+
return { state: 'none' };
|
|
606
|
+
return {
|
|
607
|
+
state: lease.expiresAtMs <= nowMs ? 'expired' : 'active',
|
|
608
|
+
expiresAtMs: lease.expiresAtMs,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
#diagnosticStorage() {
|
|
612
|
+
try {
|
|
613
|
+
const pageCount = Number(this.#db.query('PRAGMA page_count')[0]?.page_count ?? 0);
|
|
614
|
+
const pageSize = Number(this.#db.query('PRAGMA page_size')[0]?.page_size ?? 0);
|
|
615
|
+
const outboxBytes = Number(this.#db.query('SELECT COALESCE(SUM(LENGTH(operations)), 0) AS bytes FROM _syncular_outbox')[0]?.bytes ?? 0);
|
|
616
|
+
const outcome = this.#db.query(`SELECT COUNT(*) AS entries,
|
|
617
|
+
COALESCE(SUM(LENGTH(results) + COALESCE(LENGTH(operations), 0)), 0) AS bytes
|
|
618
|
+
FROM _syncular_commit_outcomes`)[0];
|
|
619
|
+
const blobBytes = this.#hasBlobs
|
|
620
|
+
? Number(this.#db.query('SELECT COALESCE(SUM(byte_length), 0) AS bytes FROM _syncular_blobs')[0]?.bytes ?? 0)
|
|
621
|
+
: 0;
|
|
622
|
+
const pressure = this.#config.blobCacheMaxBytes !== undefined &&
|
|
623
|
+
blobBytes > this.#config.blobCacheMaxBytes;
|
|
624
|
+
return {
|
|
625
|
+
status: pressure ? 'pressure' : 'healthy',
|
|
626
|
+
databaseBytesApprox: Math.max(0, pageCount * pageSize),
|
|
627
|
+
pendingOutboxBytesApprox: Math.max(0, outboxBytes),
|
|
628
|
+
retainedOutcomeBytesApprox: Math.max(0, Number(outcome?.bytes ?? 0)),
|
|
629
|
+
retainedOutcomeEntries: Math.max(0, Number(outcome?.entries ?? 0)),
|
|
630
|
+
blobCacheBytesApprox: Math.max(0, blobBytes),
|
|
631
|
+
...(pressure
|
|
632
|
+
? { pressureReasonCode: 'client.blob_cache_over_limit' }
|
|
633
|
+
: {}),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return { status: 'unreadable' };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
#emitDiagnostics() {
|
|
641
|
+
if (!this.#started ||
|
|
642
|
+
this.#securityLifecycle !== 'active' ||
|
|
643
|
+
!this.#diagnostics.observed) {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (this.#diagnosticsDeferralDepth > 0) {
|
|
647
|
+
this.#diagnosticsPending = true;
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
this.#diagnostics.emit(this.diagnosticsSnapshot());
|
|
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
|
+
}
|
|
455
664
|
/** One call for the complete status domain used by reactive hosts. */
|
|
456
665
|
statusSnapshot() {
|
|
457
666
|
this.#requireStarted();
|
|
458
667
|
return this.#statusSnapshot();
|
|
459
668
|
}
|
|
460
|
-
#statusSnapshot() {
|
|
669
|
+
#statusSnapshot(outboxCount) {
|
|
461
670
|
return {
|
|
462
671
|
currentSchemaVersion: this.#config.schema.version,
|
|
463
|
-
outbox: listOutbox(this.#db).length,
|
|
672
|
+
outbox: outboxCount ?? listOutbox(this.#db).length,
|
|
464
673
|
upgrading: this.#upgrading,
|
|
465
674
|
leaseState: this.#leaseState,
|
|
466
675
|
schemaFloor: this.#schemaFloor,
|
|
@@ -473,7 +682,7 @@ export class SyncClient {
|
|
|
473
682
|
* Re-entrant calls share the outer batch so a nested apply never
|
|
474
683
|
* double-emits (e.g. purge → blob reconcile → replay inside one round).
|
|
475
684
|
*/
|
|
476
|
-
#applyBatch(fn) {
|
|
685
|
+
#applyBatch(fn, statusSnapshotOverride) {
|
|
477
686
|
if (this.#batch !== undefined)
|
|
478
687
|
return fn(this.#batch);
|
|
479
688
|
const batch = new ChangeAccumulator();
|
|
@@ -487,8 +696,9 @@ export class SyncClient {
|
|
|
487
696
|
result = fn(batch);
|
|
488
697
|
if (batch.touched) {
|
|
489
698
|
revision = bumpLocalRevision(this.#db);
|
|
490
|
-
if (batch.statusChanged)
|
|
491
|
-
status = this.#statusSnapshot();
|
|
699
|
+
if (batch.statusChanged) {
|
|
700
|
+
status = statusSnapshotOverride?.() ?? this.#statusSnapshot();
|
|
701
|
+
}
|
|
492
702
|
}
|
|
493
703
|
}
|
|
494
704
|
finally {
|
|
@@ -502,10 +712,25 @@ export class SyncClient {
|
|
|
502
712
|
}
|
|
503
713
|
if (revision !== undefined) {
|
|
504
714
|
const event = batch.finish(revision, status);
|
|
715
|
+
const tables = [...new Set(event.tables.map((entry) => entry.table))];
|
|
716
|
+
const windows = [...new Set(event.windows.map((entry) => entry.table))];
|
|
717
|
+
this.#lastChange = {
|
|
718
|
+
revision: revision.toString(),
|
|
719
|
+
recordedAtMs: this.#now(),
|
|
720
|
+
tables: tables.slice(0, MAX_DIAGNOSTIC_DOMAINS),
|
|
721
|
+
windows: windows.slice(0, MAX_DIAGNOSTIC_DOMAINS),
|
|
722
|
+
domainsTruncated: tables.length > MAX_DIAGNOSTIC_DOMAINS ||
|
|
723
|
+
windows.length > MAX_DIAGNOSTIC_DOMAINS,
|
|
724
|
+
statusChanged: event.status !== undefined,
|
|
725
|
+
conflictsChanged: event.conflictsChanged,
|
|
726
|
+
rejectionsChanged: event.rejectionsChanged,
|
|
727
|
+
outcomesChanged: event.outcomesChanged,
|
|
728
|
+
};
|
|
505
729
|
this.#changes.emit(event);
|
|
506
730
|
const legacy = invalidationFromChange(event);
|
|
507
731
|
if (legacy !== undefined)
|
|
508
732
|
this.#invalidation.emit(legacy);
|
|
733
|
+
this.#emitDiagnostics();
|
|
509
734
|
}
|
|
510
735
|
return result;
|
|
511
736
|
}
|
|
@@ -870,6 +1095,7 @@ export class SyncClient {
|
|
|
870
1095
|
scopes: input.scopes,
|
|
871
1096
|
...(input.params !== undefined ? { params: input.params } : {}),
|
|
872
1097
|
});
|
|
1098
|
+
this.#emitDiagnostics();
|
|
873
1099
|
return;
|
|
874
1100
|
}
|
|
875
1101
|
saveSubscription(this.#db, {
|
|
@@ -880,10 +1106,12 @@ export class SyncClient {
|
|
|
880
1106
|
cursor: -1,
|
|
881
1107
|
status: 'active',
|
|
882
1108
|
});
|
|
1109
|
+
this.#emitDiagnostics();
|
|
883
1110
|
}
|
|
884
1111
|
unsubscribe(id) {
|
|
885
1112
|
this.#requireActive();
|
|
886
1113
|
deleteSubscription(this.#db, id);
|
|
1114
|
+
this.#emitDiagnostics();
|
|
887
1115
|
}
|
|
888
1116
|
// -- windowed subscriptions (§4.8) ------------------------------------------
|
|
889
1117
|
/**
|
|
@@ -1403,10 +1631,67 @@ export class SyncClient {
|
|
|
1403
1631
|
return Promise.reject(new ClientSyncError('sync.invalid_request', 'sync() is already running — the core owns one loop (coalesce wake-ups)'));
|
|
1404
1632
|
}
|
|
1405
1633
|
this.#syncOutstanding = true;
|
|
1406
|
-
|
|
1634
|
+
this.#beginDiagnosticsDeferral();
|
|
1635
|
+
const startedAtMs = this.#now();
|
|
1636
|
+
return this.#serialize(() => this.#runSync())
|
|
1637
|
+
.then((summary) => {
|
|
1638
|
+
const completedAtMs = this.#now();
|
|
1639
|
+
this.#lastRound = {
|
|
1640
|
+
status: 'succeeded',
|
|
1641
|
+
startedAtMs,
|
|
1642
|
+
completedAtMs,
|
|
1643
|
+
durationMs: Math.max(0, completedAtMs - startedAtMs),
|
|
1644
|
+
counters: this.#diagnosticRoundCounters(summary),
|
|
1645
|
+
};
|
|
1646
|
+
this.#emitDiagnostics();
|
|
1647
|
+
return summary;
|
|
1648
|
+
}, (error) => {
|
|
1649
|
+
const completedAtMs = this.#now();
|
|
1650
|
+
const code = error.code;
|
|
1651
|
+
this.#lastRound = {
|
|
1652
|
+
status: 'failed',
|
|
1653
|
+
startedAtMs,
|
|
1654
|
+
completedAtMs,
|
|
1655
|
+
durationMs: Math.max(0, completedAtMs - startedAtMs),
|
|
1656
|
+
errorCode: typeof code === 'string'
|
|
1657
|
+
? this.#diagnosticCode(code)
|
|
1658
|
+
: 'client.unknown_failure',
|
|
1659
|
+
};
|
|
1660
|
+
this.#emitDiagnostics();
|
|
1661
|
+
throw error;
|
|
1662
|
+
})
|
|
1663
|
+
.finally(() => {
|
|
1407
1664
|
this.#syncOutstanding = false;
|
|
1665
|
+
this.#endDiagnosticsDeferral();
|
|
1408
1666
|
});
|
|
1409
1667
|
}
|
|
1668
|
+
#diagnosticRoundCounters(summary) {
|
|
1669
|
+
return {
|
|
1670
|
+
pushed: summary.pushed,
|
|
1671
|
+
applied: summary.applied.length,
|
|
1672
|
+
rejected: summary.rejected.length,
|
|
1673
|
+
retryable: summary.retryable.length,
|
|
1674
|
+
conflicts: summary.conflicts.length,
|
|
1675
|
+
commitsApplied: summary.commitsApplied,
|
|
1676
|
+
segmentRowsApplied: summary.segmentRowsApplied,
|
|
1677
|
+
bootstrapping: summary.bootstrapping.length,
|
|
1678
|
+
resets: summary.resets.length,
|
|
1679
|
+
revoked: summary.revoked.length,
|
|
1680
|
+
failed: summary.failed.length,
|
|
1681
|
+
deferredCommits: summary.deferredCommits ?? 0,
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
#transportFailureCode(code) {
|
|
1685
|
+
return (code === 'transport.failed' ||
|
|
1686
|
+
code === 'transport.unavailable' ||
|
|
1687
|
+
code === 'sync.transport_failed' ||
|
|
1688
|
+
code === 'client.worker_failed');
|
|
1689
|
+
}
|
|
1690
|
+
#diagnosticCode(code) {
|
|
1691
|
+
return code.length <= 96 && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/.test(code)
|
|
1692
|
+
? code
|
|
1693
|
+
: 'client.unknown_failure';
|
|
1694
|
+
}
|
|
1410
1695
|
async #runSync() {
|
|
1411
1696
|
if (this.#schemaFloor !== undefined) {
|
|
1412
1697
|
return {
|
|
@@ -1547,8 +1832,17 @@ export class SyncClient {
|
|
|
1547
1832
|
*/
|
|
1548
1833
|
#roundTrip(request) {
|
|
1549
1834
|
const socket = this.#socket;
|
|
1550
|
-
if (socket === undefined)
|
|
1551
|
-
return
|
|
1835
|
+
if (socket === undefined) {
|
|
1836
|
+
return Promise.resolve()
|
|
1837
|
+
.then(() => this.#config.transport(request))
|
|
1838
|
+
.catch((error) => {
|
|
1839
|
+
if (error instanceof ClientSyncError ||
|
|
1840
|
+
typeof error?.code === 'string') {
|
|
1841
|
+
throw error;
|
|
1842
|
+
}
|
|
1843
|
+
throw new ClientSyncError('sync.transport_failed', `transport round failed: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1552
1846
|
return new Promise((resolve, reject) => {
|
|
1553
1847
|
// sync() already enforces one round in flight (§8.7).
|
|
1554
1848
|
this.#pendingRound = {
|
|
@@ -1592,6 +1886,7 @@ export class SyncClient {
|
|
|
1592
1886
|
this.#socket = undefined;
|
|
1593
1887
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
1594
1888
|
this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
|
|
1889
|
+
this.#emitDiagnostics();
|
|
1595
1890
|
},
|
|
1596
1891
|
});
|
|
1597
1892
|
if (this.#securityLifecycle === 'preflight') {
|
|
@@ -1599,12 +1894,14 @@ export class SyncClient {
|
|
|
1599
1894
|
throw new ClientSyncError(SECURITY_PREFLIGHT_REQUIRED_CODE, 'realtime connected after the client entered security preflight');
|
|
1600
1895
|
}
|
|
1601
1896
|
this.#socket = socket;
|
|
1897
|
+
this.#emitDiagnostics();
|
|
1602
1898
|
}
|
|
1603
1899
|
disconnectRealtime() {
|
|
1604
1900
|
this.#socket?.close();
|
|
1605
1901
|
this.#socket = undefined;
|
|
1606
1902
|
this.#presence.clear(); // §8.6.1: presence is per-connection
|
|
1607
1903
|
this.#abortPendingRound('realtime socket disconnected mid-round (§8.7)');
|
|
1904
|
+
this.#emitDiagnostics();
|
|
1608
1905
|
}
|
|
1609
1906
|
/**
|
|
1610
1907
|
* §8.7 channel-tag routing (synchronous, so chunk order is preserved):
|
|
@@ -1760,10 +2057,16 @@ export class SyncClient {
|
|
|
1760
2057
|
const commitsById = new Map(sentCommits.map((commit) => [commit.clientCommitId, commit]));
|
|
1761
2058
|
const subsById = new Map((sentSubs ?? loadSubscriptions(this.#db)).map((sub) => [sub.id, sub]));
|
|
1762
2059
|
const rejectionDetailsByCommit = new Map();
|
|
2060
|
+
let lastFinalPushResult;
|
|
1763
2061
|
for (const frame of message.frames) {
|
|
1764
|
-
if (frame.type
|
|
1765
|
-
|
|
1766
|
-
|
|
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
|
+
}
|
|
1767
2070
|
}
|
|
1768
2071
|
const header = message.frames[0];
|
|
1769
2072
|
if (header?.type !== 'RESP_HEADER') {
|
|
@@ -1793,8 +2096,10 @@ export class SyncClient {
|
|
|
1793
2096
|
let section;
|
|
1794
2097
|
let errorFrame;
|
|
1795
2098
|
let deltaCursor = -1;
|
|
2099
|
+
let responseOutboxCount;
|
|
1796
2100
|
// Each durable observer transaction emits its own revisioned batch.
|
|
1797
2101
|
// Async decrypt/download work happens outside SQLite transactions.
|
|
2102
|
+
this.#beginDiagnosticsDeferral();
|
|
1798
2103
|
try {
|
|
1799
2104
|
for (const frame of message.frames.slice(1)) {
|
|
1800
2105
|
switch (frame.type) {
|
|
@@ -1808,9 +2113,16 @@ export class SyncClient {
|
|
|
1808
2113
|
expiresAtMs: frame.expiresAtMs,
|
|
1809
2114
|
});
|
|
1810
2115
|
break;
|
|
1811
|
-
case 'PUSH_RESULT':
|
|
1812
|
-
|
|
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;
|
|
1813
2124
|
break;
|
|
2125
|
+
}
|
|
1814
2126
|
case 'PUSH_RESULT_DETAILS':
|
|
1815
2127
|
// Pre-indexed above so companion ordering remains wire-additive.
|
|
1816
2128
|
break;
|
|
@@ -1935,10 +2247,15 @@ export class SyncClient {
|
|
|
1935
2247
|
}
|
|
1936
2248
|
}
|
|
1937
2249
|
finally {
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
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
|
+
}
|
|
1942
2259
|
}
|
|
1943
2260
|
if (errorFrame !== undefined)
|
|
1944
2261
|
throw errorFrame;
|
|
@@ -1959,10 +2276,10 @@ export class SyncClient {
|
|
|
1959
2276
|
}
|
|
1960
2277
|
return { ...summary, bootstrapping };
|
|
1961
2278
|
}
|
|
1962
|
-
#handlePushResult(frame, commitsById, summary, batch, rejectionDetails) {
|
|
2279
|
+
#handlePushResult(frame, commitsById, summary, batch, rejectionDetails, pruneOutcomes) {
|
|
1963
2280
|
const commit = commitsById.get(frame.clientCommitId);
|
|
1964
2281
|
if (commit === undefined)
|
|
1965
|
-
return;
|
|
2282
|
+
return false;
|
|
1966
2283
|
if (frame.status === 'applied' || frame.status === 'cached') {
|
|
1967
2284
|
// §6.3: applied and cached both drain the outbox — cached means
|
|
1968
2285
|
// "already applied, you may have missed the ack".
|
|
@@ -1976,11 +2293,13 @@ export class SyncClient {
|
|
|
1976
2293
|
})),
|
|
1977
2294
|
});
|
|
1978
2295
|
deleteOutboxCommit(this.#db, frame.clientCommitId);
|
|
1979
|
-
|
|
2296
|
+
if (pruneOutcomes) {
|
|
2297
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2298
|
+
}
|
|
1980
2299
|
batch.status();
|
|
1981
2300
|
batch.outcomes();
|
|
1982
2301
|
summary.applied.push(frame.clientCommitId);
|
|
1983
|
-
return;
|
|
2302
|
+
return true;
|
|
1984
2303
|
}
|
|
1985
2304
|
// rejected
|
|
1986
2305
|
const cacheMiss = frame.results.some((result) => result.status === 'error' &&
|
|
@@ -1990,7 +2309,7 @@ export class SyncClient {
|
|
|
1990
2309
|
// §6.3: a serving failure, not the commit's outcome — keep the
|
|
1991
2310
|
// commit queued and retry the identical push later.
|
|
1992
2311
|
summary.retryable.push(frame.clientCommitId);
|
|
1993
|
-
return;
|
|
2312
|
+
return false;
|
|
1994
2313
|
}
|
|
1995
2314
|
const outcomeResults = [];
|
|
1996
2315
|
for (const result of frame.results) {
|
|
@@ -2041,7 +2360,9 @@ export class SyncClient {
|
|
|
2041
2360
|
results: outcomeResults,
|
|
2042
2361
|
operations: commit.operations,
|
|
2043
2362
|
});
|
|
2044
|
-
|
|
2363
|
+
if (pruneOutcomes) {
|
|
2364
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2365
|
+
}
|
|
2045
2366
|
batch.outcomes();
|
|
2046
2367
|
// §7.2: remove the rejected optimistic layer. Before-images restore
|
|
2047
2368
|
// validator-rejected updates even when the server emitted no new COMMIT;
|
|
@@ -2051,6 +2372,7 @@ export class SyncClient {
|
|
|
2051
2372
|
});
|
|
2052
2373
|
batch.status();
|
|
2053
2374
|
summary.rejected.push(frame.clientCommitId);
|
|
2375
|
+
return true;
|
|
2054
2376
|
}
|
|
2055
2377
|
#decodeServerRow(tableName, payload) {
|
|
2056
2378
|
if (tableName === undefined)
|