@syncular/client 0.15.13 → 0.15.15
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 +88 -2
- package/dist/availability.d.ts +17 -0
- package/dist/availability.js +48 -0
- package/dist/client.d.ts +33 -0
- package/dist/client.js +124 -22
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +1 -0
- package/dist/multi-tab.d.ts +24 -1
- package/dist/multi-tab.js +113 -4
- package/dist/reactive-store.d.ts +10 -1
- package/dist/reactive-store.js +107 -5
- package/dist/worker-entry.js +28 -2
- package/dist/worker-host.d.ts +40 -3
- package/dist/worker-host.js +126 -7
- package/dist/worker-protocol.d.ts +10 -1
- package/package.json +3 -3
- package/src/availability.ts +70 -0
- package/src/client.ts +176 -23
- package/src/index.ts +1 -0
- package/src/invalidation.ts +1 -0
- package/src/multi-tab.ts +158 -0
- package/src/reactive-store.ts +138 -7
- package/src/worker-entry.ts +32 -2
- package/src/worker-host.ts +174 -6
- package/src/worker-protocol.ts +11 -0
package/src/client.ts
CHANGED
|
@@ -315,6 +315,29 @@ export interface SyncClientConfig {
|
|
|
315
315
|
* `client.decrypt_failed`, never silent plaintext).
|
|
316
316
|
*/
|
|
317
317
|
readonly encryption?: EncryptionConfig;
|
|
318
|
+
/**
|
|
319
|
+
* Open the local replica in the fail-closed security preflight state.
|
|
320
|
+
*
|
|
321
|
+
* Preflight opens/migrates the database but suppresses every protected read,
|
|
322
|
+
* mutation, subscription, transport, realtime, presence, and blob operation.
|
|
323
|
+
* Only lifecycle/status inspection and `purgeLocalData` remain available.
|
|
324
|
+
* Install the post-authentication keyring and release the gate with
|
|
325
|
+
* `activateSecurity`. This is mutually exclusive with `encryption`: secure
|
|
326
|
+
* hosts must not materialize key bytes before their preflight has passed.
|
|
327
|
+
*/
|
|
328
|
+
readonly securityPreflight?: boolean;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The fail-closed local-replica security lifecycle shared by every host. */
|
|
332
|
+
export type SecurityLifecycle = 'preflight' | 'active';
|
|
333
|
+
|
|
334
|
+
/** Stable client-local error while protected operations are preflight-gated. */
|
|
335
|
+
export const SECURITY_PREFLIGHT_REQUIRED_CODE =
|
|
336
|
+
'client.security_preflight_required';
|
|
337
|
+
|
|
338
|
+
/** Key material installed atomically when a direct client becomes active. */
|
|
339
|
+
export interface SecurityActivation {
|
|
340
|
+
readonly encryption?: EncryptionConfig;
|
|
318
341
|
}
|
|
319
342
|
|
|
320
343
|
/** §8.6 a peer's ephemeral presence document on a scope key. */
|
|
@@ -471,7 +494,8 @@ export class SyncClient {
|
|
|
471
494
|
readonly #db: ClientDatabase;
|
|
472
495
|
readonly #schema: CompiledClientSchema;
|
|
473
496
|
/** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
|
|
474
|
-
|
|
497
|
+
#encryption: EncryptionConfig | undefined;
|
|
498
|
+
#securityLifecycle: SecurityLifecycle;
|
|
475
499
|
readonly #now: () => number;
|
|
476
500
|
readonly #outcomeRetentionMaxEntries: number;
|
|
477
501
|
#started = false;
|
|
@@ -524,12 +548,25 @@ export class SyncClient {
|
|
|
524
548
|
* sections, when the seam is quiescent.
|
|
525
549
|
*/
|
|
526
550
|
#opChain: Promise<unknown> = Promise.resolve();
|
|
551
|
+
/** Async protected operations outside the SQLite serialization chain
|
|
552
|
+
* (blob I/O and realtime connect). Security preflight waits for this set to
|
|
553
|
+
* drain after synchronously closing the gate. */
|
|
554
|
+
readonly #protectedAsync = new Set<Promise<unknown>>();
|
|
555
|
+
#preflightBarrier: Promise<void> | undefined;
|
|
527
556
|
|
|
528
557
|
constructor(config: SyncClientConfig) {
|
|
558
|
+
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
559
|
+
throw new ClientSyncError(
|
|
560
|
+
'sync.invalid_request',
|
|
561
|
+
'securityPreflight and encryption are mutually exclusive; install keys with activateSecurity after preflight',
|
|
562
|
+
);
|
|
563
|
+
}
|
|
529
564
|
this.#config = config;
|
|
530
565
|
this.#db = config.database;
|
|
531
566
|
this.#schema = compileClientSchema(config.schema);
|
|
532
567
|
this.#encryption = config.encryption;
|
|
568
|
+
this.#securityLifecycle =
|
|
569
|
+
config.securityPreflight === true ? 'preflight' : 'active';
|
|
533
570
|
this.#now = config.now ?? Date.now;
|
|
534
571
|
const outcomeRetentionMaxEntries =
|
|
535
572
|
config.limits?.outcomeRetentionMaxEntries ?? 1_000;
|
|
@@ -613,7 +650,7 @@ export class SyncClient {
|
|
|
613
650
|
this.#schemaFloor === undefined &&
|
|
614
651
|
(listOutbox(this.#db).length > 0 ||
|
|
615
652
|
subscriptions.some((sub) => sub.status === 'active'));
|
|
616
|
-
if (startupWork) {
|
|
653
|
+
if (startupWork && this.#securityLifecycle === 'active') {
|
|
617
654
|
this.#needsPull = true;
|
|
618
655
|
this.#config.onSyncNeeded?.('startup');
|
|
619
656
|
this.#config.onSyncIntent?.({ kind: 'interactive' });
|
|
@@ -722,6 +759,68 @@ export class SyncClient {
|
|
|
722
759
|
this.#started = false;
|
|
723
760
|
}
|
|
724
761
|
|
|
762
|
+
/** Current fail-closed local-replica security state. */
|
|
763
|
+
get securityLifecycle(): SecurityLifecycle {
|
|
764
|
+
return this.#securityLifecycle;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Block new protected operations immediately, then wait for every already
|
|
769
|
+
* serialized database/network operation to settle before releasing key
|
|
770
|
+
* references. Hosts await this barrier before applying a quarantine purge.
|
|
771
|
+
*/
|
|
772
|
+
beginSecurityPreflight(): Promise<void> {
|
|
773
|
+
this.#requireStarted();
|
|
774
|
+
if (this.#preflightBarrier !== undefined) return this.#preflightBarrier;
|
|
775
|
+
this.#securityLifecycle = 'preflight';
|
|
776
|
+
this.disconnectRealtime();
|
|
777
|
+
const barrier = (async () => {
|
|
778
|
+
await Promise.allSettled([this.#opChain, ...this.#protectedAsync]);
|
|
779
|
+
this.disconnectRealtime();
|
|
780
|
+
this.#encryption = undefined;
|
|
781
|
+
this.#syncOutstanding = false;
|
|
782
|
+
})();
|
|
783
|
+
this.#preflightBarrier = barrier;
|
|
784
|
+
void barrier.then(
|
|
785
|
+
() => {
|
|
786
|
+
if (this.#preflightBarrier === barrier)
|
|
787
|
+
this.#preflightBarrier = undefined;
|
|
788
|
+
},
|
|
789
|
+
() => {
|
|
790
|
+
if (this.#preflightBarrier === barrier)
|
|
791
|
+
this.#preflightBarrier = undefined;
|
|
792
|
+
},
|
|
793
|
+
);
|
|
794
|
+
return barrier;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Atomically install the post-authentication keyring and release the gate.
|
|
799
|
+
* Persisted subscriptions/outbox work produces one exact startup intent only
|
|
800
|
+
* after activation, never while the local quarantine decision is pending.
|
|
801
|
+
*/
|
|
802
|
+
async activateSecurity(options: SecurityActivation = {}): Promise<void> {
|
|
803
|
+
this.#requireStarted();
|
|
804
|
+
if (this.#securityLifecycle === 'active') {
|
|
805
|
+
throw new ClientSyncError(
|
|
806
|
+
'sync.invalid_request',
|
|
807
|
+
'activateSecurity requires the client to be in security preflight',
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
await (this.#preflightBarrier ?? this.#opChain);
|
|
811
|
+
this.#encryption = options.encryption;
|
|
812
|
+
this.#securityLifecycle = 'active';
|
|
813
|
+
const startupWork =
|
|
814
|
+
this.#schemaFloor === undefined &&
|
|
815
|
+
(listOutbox(this.#db).length > 0 ||
|
|
816
|
+
loadSubscriptions(this.#db).some((sub) => sub.status === 'active'));
|
|
817
|
+
if (startupWork) {
|
|
818
|
+
this.#setSyncNeeded(true);
|
|
819
|
+
this.#config.onSyncNeeded?.('startup');
|
|
820
|
+
this.#config.onSyncIntent?.({ kind: 'interactive' });
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
725
824
|
// -- accessors ------------------------------------------------------------
|
|
726
825
|
|
|
727
826
|
get clientId(): string {
|
|
@@ -730,6 +829,7 @@ export class SyncClient {
|
|
|
730
829
|
|
|
731
830
|
/** The underlying database — raw SQL is the local query API (B3). */
|
|
732
831
|
get database(): ClientDatabase {
|
|
832
|
+
this.#requireActive();
|
|
733
833
|
return this.#db;
|
|
734
834
|
}
|
|
735
835
|
|
|
@@ -742,6 +842,7 @@ export class SyncClient {
|
|
|
742
842
|
* internals read `this.#db` directly and skip this method by design.
|
|
743
843
|
*/
|
|
744
844
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[] {
|
|
845
|
+
this.#requireActive();
|
|
745
846
|
assertReadOnlyQuery(sql);
|
|
746
847
|
return stripSyncColumns(this.#db.query(sql, params));
|
|
747
848
|
}
|
|
@@ -758,7 +859,7 @@ export class SyncClient {
|
|
|
758
859
|
* `windowState()` across separate worker/IPC calls.
|
|
759
860
|
*/
|
|
760
861
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> {
|
|
761
|
-
this.#
|
|
862
|
+
this.#requireActive();
|
|
762
863
|
assertReadOnlyQuery(spec.sql);
|
|
763
864
|
return this.#db.transaction(() => {
|
|
764
865
|
const revision = getLocalRevision(this.#db);
|
|
@@ -835,6 +936,7 @@ export class SyncClient {
|
|
|
835
936
|
|
|
836
937
|
#statusSnapshot(): SyncStatusSnapshot {
|
|
837
938
|
return {
|
|
939
|
+
currentSchemaVersion: this.#config.schema.version,
|
|
838
940
|
outbox: listOutbox(this.#db).length,
|
|
839
941
|
upgrading: this.#upgrading,
|
|
840
942
|
leaseState: this.#leaseState,
|
|
@@ -900,6 +1002,17 @@ export class SyncClient {
|
|
|
900
1002
|
return next;
|
|
901
1003
|
}
|
|
902
1004
|
|
|
1005
|
+
#runProtectedAsync<T>(fn: () => Promise<T>): Promise<T> {
|
|
1006
|
+
this.#requireActive();
|
|
1007
|
+
const task = Promise.resolve().then(fn);
|
|
1008
|
+
this.#protectedAsync.add(task);
|
|
1009
|
+
void task.then(
|
|
1010
|
+
() => this.#protectedAsync.delete(task),
|
|
1011
|
+
() => this.#protectedAsync.delete(task),
|
|
1012
|
+
);
|
|
1013
|
+
return task;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
903
1016
|
// -- blobs (§5.9) ---------------------------------------------------------
|
|
904
1017
|
|
|
905
1018
|
/**
|
|
@@ -909,7 +1022,14 @@ export class SyncClient {
|
|
|
909
1022
|
* a `blob_ref` column of a mutation. The referencing row MUST be written
|
|
910
1023
|
* (via `mutate`) after this call so upload-before-push holds (§5.9.3).
|
|
911
1024
|
*/
|
|
912
|
-
|
|
1025
|
+
uploadBlob(
|
|
1026
|
+
bytes: Uint8Array,
|
|
1027
|
+
options?: { readonly mediaType?: string; readonly name?: string },
|
|
1028
|
+
): Promise<BlobRef> {
|
|
1029
|
+
return this.#runProtectedAsync(() => this.#uploadBlob(bytes, options));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
async #uploadBlob(
|
|
913
1033
|
bytes: Uint8Array,
|
|
914
1034
|
options?: { readonly mediaType?: string; readonly name?: string },
|
|
915
1035
|
): Promise<BlobRef> {
|
|
@@ -949,7 +1069,11 @@ export class SyncClient {
|
|
|
949
1069
|
* transport (§5.9.5), verifies the content address, caches, and returns.
|
|
950
1070
|
* Accepts a raw `blob_ref` column string or a bare `blobId`.
|
|
951
1071
|
*/
|
|
952
|
-
|
|
1072
|
+
fetchBlob(blobIdOrRef: string): Promise<CachedBlob> {
|
|
1073
|
+
return this.#runProtectedAsync(() => this.#fetchBlob(blobIdOrRef));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
async #fetchBlob(blobIdOrRef: string): Promise<CachedBlob> {
|
|
953
1077
|
const blobId = blobIdOrRef.startsWith('sha256:')
|
|
954
1078
|
? blobIdOrRef
|
|
955
1079
|
: parseBlobRef(blobIdOrRef).blobId;
|
|
@@ -1020,7 +1144,11 @@ export class SyncClient {
|
|
|
1020
1144
|
}
|
|
1021
1145
|
|
|
1022
1146
|
/** Flush any queued blob uploads (§5.9.7 B4); safe to call standalone. */
|
|
1023
|
-
|
|
1147
|
+
flushBlobUploads(): Promise<void> {
|
|
1148
|
+
return this.#runProtectedAsync(() => this.#flushBlobUploads());
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async #flushBlobUploads(): Promise<void> {
|
|
1024
1152
|
const transport = this.#config.blobs;
|
|
1025
1153
|
if (transport === undefined || !this.#hasBlobs) return;
|
|
1026
1154
|
for (const pending of listPendingUploads(this.#db)) {
|
|
@@ -1085,22 +1213,24 @@ export class SyncClient {
|
|
|
1085
1213
|
}
|
|
1086
1214
|
|
|
1087
1215
|
get conflicts(): readonly ConflictRecord[] {
|
|
1216
|
+
this.#requireActive();
|
|
1088
1217
|
return this.#conflicts;
|
|
1089
1218
|
}
|
|
1090
1219
|
|
|
1091
1220
|
get rejections(): readonly RejectionRecord[] {
|
|
1221
|
+
this.#requireActive();
|
|
1092
1222
|
return this.#rejections;
|
|
1093
1223
|
}
|
|
1094
1224
|
|
|
1095
1225
|
/** One durable final outcome by the originating client commit id. */
|
|
1096
1226
|
commitOutcome(clientCommitId: string): CommitOutcome | undefined {
|
|
1097
|
-
this.#
|
|
1227
|
+
this.#requireActive();
|
|
1098
1228
|
return readCommitOutcome(this.#db, clientCommitId);
|
|
1099
1229
|
}
|
|
1100
1230
|
|
|
1101
1231
|
/** Newest-first durable outcome journal. */
|
|
1102
1232
|
commitOutcomes(query: CommitOutcomeQuery = {}): readonly CommitOutcome[] {
|
|
1103
|
-
this.#
|
|
1233
|
+
this.#requireActive();
|
|
1104
1234
|
return listCommitOutcomes(this.#db, query);
|
|
1105
1235
|
}
|
|
1106
1236
|
|
|
@@ -1111,7 +1241,7 @@ export class SyncClient {
|
|
|
1111
1241
|
* dismissed. The transition is one-way and survives restart.
|
|
1112
1242
|
*/
|
|
1113
1243
|
resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome {
|
|
1114
|
-
this.#
|
|
1244
|
+
this.#requireActive();
|
|
1115
1245
|
const current = readCommitOutcome(this.#db, input.clientCommitId);
|
|
1116
1246
|
if (current === undefined) {
|
|
1117
1247
|
throw new ClientSyncError(
|
|
@@ -1225,12 +1355,14 @@ export class SyncClient {
|
|
|
1225
1355
|
* Ephemeral — reflects only what the socket has delivered.
|
|
1226
1356
|
*/
|
|
1227
1357
|
presence(scopeKey: string): readonly PresencePeer[] {
|
|
1358
|
+
this.#requireActive();
|
|
1228
1359
|
const peers = this.#presence.get(scopeKey);
|
|
1229
1360
|
return peers === undefined ? [] : [...peers.values()];
|
|
1230
1361
|
}
|
|
1231
1362
|
|
|
1232
1363
|
/** Every scope key this client currently has presence state for. */
|
|
1233
1364
|
presenceKeys(): string[] {
|
|
1365
|
+
this.#requireActive();
|
|
1234
1366
|
return [...this.#presence.keys()];
|
|
1235
1367
|
}
|
|
1236
1368
|
|
|
@@ -1255,7 +1387,7 @@ export class SyncClient {
|
|
|
1255
1387
|
* by the server with `presence.forbidden`.
|
|
1256
1388
|
*/
|
|
1257
1389
|
setPresence(scopeKey: string, doc: Record<string, unknown> | null): void {
|
|
1258
|
-
this.#
|
|
1390
|
+
this.#requireActive();
|
|
1259
1391
|
const socket = this.#socket;
|
|
1260
1392
|
if (socket === undefined) {
|
|
1261
1393
|
throw new ClientSyncError(
|
|
@@ -1288,24 +1420,24 @@ export class SyncClient {
|
|
|
1288
1420
|
}
|
|
1289
1421
|
|
|
1290
1422
|
subscriptions(): SubscriptionRecord[] {
|
|
1291
|
-
this.#
|
|
1423
|
+
this.#requireActive();
|
|
1292
1424
|
return loadSubscriptions(this.#db);
|
|
1293
1425
|
}
|
|
1294
1426
|
|
|
1295
1427
|
subscription(id: string): SubscriptionRecord | undefined {
|
|
1296
|
-
this.#
|
|
1428
|
+
this.#requireActive();
|
|
1297
1429
|
return getSubscription(this.#db, id);
|
|
1298
1430
|
}
|
|
1299
1431
|
|
|
1300
1432
|
pendingCommits(): OutboxCommit[] {
|
|
1301
|
-
this.#
|
|
1433
|
+
this.#requireActive();
|
|
1302
1434
|
return listOutbox(this.#db);
|
|
1303
1435
|
}
|
|
1304
1436
|
|
|
1305
1437
|
// -- subscriptions ----------------------------------------------------------
|
|
1306
1438
|
|
|
1307
1439
|
subscribe(input: SubscribeInput): void {
|
|
1308
|
-
this.#
|
|
1440
|
+
this.#requireActive();
|
|
1309
1441
|
if (!this.#schema.tables.has(input.table)) {
|
|
1310
1442
|
throw new ClientSyncError(
|
|
1311
1443
|
'sync.unknown_table',
|
|
@@ -1333,7 +1465,7 @@ export class SyncClient {
|
|
|
1333
1465
|
}
|
|
1334
1466
|
|
|
1335
1467
|
unsubscribe(id: string): void {
|
|
1336
|
-
this.#
|
|
1468
|
+
this.#requireActive();
|
|
1337
1469
|
deleteSubscription(this.#db, id);
|
|
1338
1470
|
}
|
|
1339
1471
|
|
|
@@ -1361,7 +1493,7 @@ export class SyncClient {
|
|
|
1361
1493
|
base: WindowBase,
|
|
1362
1494
|
units: readonly string[],
|
|
1363
1495
|
): Promise<CommandResult<void>> {
|
|
1364
|
-
this.#
|
|
1496
|
+
this.#requireActive();
|
|
1365
1497
|
const table = this.#table(base.table);
|
|
1366
1498
|
if (!table.scopeColumnByVariable.has(base.variable)) {
|
|
1367
1499
|
throw new ClientSyncError(
|
|
@@ -1428,7 +1560,7 @@ export class SyncClient {
|
|
|
1428
1560
|
* advances past -1 with no resume token held).
|
|
1429
1561
|
*/
|
|
1430
1562
|
windowState(base: WindowBase): WindowState {
|
|
1431
|
-
this.#
|
|
1563
|
+
this.#requireActive();
|
|
1432
1564
|
const baseKey = windowBaseKey(base);
|
|
1433
1565
|
const live = loadWindowUnits(this.#db, baseKey);
|
|
1434
1566
|
const pending: string[] = [];
|
|
@@ -1539,7 +1671,7 @@ export class SyncClient {
|
|
|
1539
1671
|
mutations: readonly MutationInput[],
|
|
1540
1672
|
changedFieldsByIndex: readonly (readonly string[] | undefined)[] = [],
|
|
1541
1673
|
): string {
|
|
1542
|
-
this.#
|
|
1674
|
+
this.#requireActive();
|
|
1543
1675
|
const clientCommitId = crypto.randomUUID();
|
|
1544
1676
|
const operations: OutboxOperation[] = mutations.map((mutation, index) => {
|
|
1545
1677
|
const table = this.#table(mutation.table);
|
|
@@ -1616,7 +1748,7 @@ export class SyncClient {
|
|
|
1616
1748
|
partial: Readonly<Record<string, unknown>>,
|
|
1617
1749
|
options?: { readonly baseVersion?: number },
|
|
1618
1750
|
): string {
|
|
1619
|
-
this.#
|
|
1751
|
+
this.#requireActive();
|
|
1620
1752
|
const compiled = this.#table(table);
|
|
1621
1753
|
const pkColumn = compiled.columns[compiled.primaryKeyIndex] as RowColumn;
|
|
1622
1754
|
const rows = this.#db.query(
|
|
@@ -1950,7 +2082,7 @@ export class SyncClient {
|
|
|
1950
2082
|
* `setWindow` at an await point.
|
|
1951
2083
|
*/
|
|
1952
2084
|
sync(): Promise<SyncSummary> {
|
|
1953
|
-
this.#
|
|
2085
|
+
this.#requireActive();
|
|
1954
2086
|
if (this.#syncOutstanding) {
|
|
1955
2087
|
return Promise.reject(
|
|
1956
2088
|
new ClientSyncError(
|
|
@@ -2158,8 +2290,11 @@ export class SyncClient {
|
|
|
2158
2290
|
|
|
2159
2291
|
// -- realtime (§8 client side) ----------------------------------------------
|
|
2160
2292
|
|
|
2161
|
-
|
|
2162
|
-
this.#
|
|
2293
|
+
connectRealtime(): Promise<void> {
|
|
2294
|
+
return this.#runProtectedAsync(() => this.#connectRealtime());
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
async #connectRealtime(): Promise<void> {
|
|
2163
2298
|
const connector = this.#config.realtime;
|
|
2164
2299
|
if (connector === undefined) {
|
|
2165
2300
|
throw new ClientSyncError(
|
|
@@ -2167,7 +2302,7 @@ export class SyncClient {
|
|
|
2167
2302
|
'no realtime connector configured',
|
|
2168
2303
|
);
|
|
2169
2304
|
}
|
|
2170
|
-
|
|
2305
|
+
const socket = await connector({
|
|
2171
2306
|
onText: (text) => this.#handleRealtimeText(text),
|
|
2172
2307
|
onBinary: (bytes) => this.#routeRealtimeBinary(bytes),
|
|
2173
2308
|
onClose: () => {
|
|
@@ -2176,6 +2311,14 @@ export class SyncClient {
|
|
|
2176
2311
|
this.#abortPendingRound('realtime socket closed mid-round (§8.7)');
|
|
2177
2312
|
},
|
|
2178
2313
|
});
|
|
2314
|
+
if (this.#securityLifecycle === 'preflight') {
|
|
2315
|
+
socket.close();
|
|
2316
|
+
throw new ClientSyncError(
|
|
2317
|
+
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
2318
|
+
'realtime connected after the client entered security preflight',
|
|
2319
|
+
);
|
|
2320
|
+
}
|
|
2321
|
+
this.#socket = socket;
|
|
2179
2322
|
}
|
|
2180
2323
|
|
|
2181
2324
|
disconnectRealtime(): void {
|
|
@@ -3335,4 +3478,14 @@ export class SyncClient {
|
|
|
3335
3478
|
);
|
|
3336
3479
|
}
|
|
3337
3480
|
}
|
|
3481
|
+
|
|
3482
|
+
#requireActive(): void {
|
|
3483
|
+
this.#requireStarted();
|
|
3484
|
+
if (this.#securityLifecycle === 'preflight') {
|
|
3485
|
+
throw new ClientSyncError(
|
|
3486
|
+
SECURITY_PREFLIGHT_REQUIRED_CODE,
|
|
3487
|
+
'the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data',
|
|
3488
|
+
);
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3338
3491
|
}
|
package/src/index.ts
CHANGED
package/src/invalidation.ts
CHANGED