@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/worker-entry.ts
CHANGED
|
@@ -143,7 +143,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
143
143
|
|
|
144
144
|
function runAutoSync(): void {
|
|
145
145
|
autoSyncScheduled = false;
|
|
146
|
-
if (
|
|
146
|
+
if (
|
|
147
|
+
closed ||
|
|
148
|
+
client === undefined ||
|
|
149
|
+
client.securityLifecycle === 'preflight'
|
|
150
|
+
) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
147
153
|
const running = client;
|
|
148
154
|
void serializedSync(() => running.syncUntilIdle())
|
|
149
155
|
.then((summary) => {
|
|
@@ -160,7 +166,13 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
160
166
|
}
|
|
161
167
|
|
|
162
168
|
function consumeSyncIntent(intent: SyncIntent): void {
|
|
163
|
-
if (
|
|
169
|
+
if (
|
|
170
|
+
!autoSync ||
|
|
171
|
+
closed ||
|
|
172
|
+
client === undefined ||
|
|
173
|
+
client.securityLifecycle === 'preflight' ||
|
|
174
|
+
intent.kind === 'none'
|
|
175
|
+
) {
|
|
164
176
|
return;
|
|
165
177
|
}
|
|
166
178
|
if (intent.kind === 'background') {
|
|
@@ -303,6 +315,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
303
315
|
...(config.encryption !== undefined
|
|
304
316
|
? { encryption: encryptionConfigFromKeyring(config.encryption) }
|
|
305
317
|
: {}),
|
|
318
|
+
...(config.securityPreflight !== undefined
|
|
319
|
+
? { securityPreflight: config.securityPreflight }
|
|
320
|
+
: {}),
|
|
306
321
|
onSyncNeeded: (reason) => {
|
|
307
322
|
post({ t: 'event', event: { kind: 'sync-needed', reason } });
|
|
308
323
|
consumeSyncIntent({ kind: 'interactive' });
|
|
@@ -344,6 +359,21 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
344
359
|
}
|
|
345
360
|
|
|
346
361
|
const api: WorkerApi = {
|
|
362
|
+
securityLifecycle: () => requireClient().securityLifecycle,
|
|
363
|
+
beginSecurityPreflight: async () => {
|
|
364
|
+
if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
|
|
365
|
+
backgroundTimer = undefined;
|
|
366
|
+
backgroundDue = Number.POSITIVE_INFINITY;
|
|
367
|
+
autoSyncScheduled = false;
|
|
368
|
+
await requireClient().beginSecurityPreflight();
|
|
369
|
+
},
|
|
370
|
+
activateSecurity: async (options = {}) => {
|
|
371
|
+
await requireClient().activateSecurity({
|
|
372
|
+
...(options.encryption !== undefined
|
|
373
|
+
? { encryption: encryptionConfigFromKeyring(options.encryption) }
|
|
374
|
+
: {}),
|
|
375
|
+
});
|
|
376
|
+
},
|
|
347
377
|
subscribe: (input) => requireClient().subscribe(input),
|
|
348
378
|
unsubscribe: (id) => requireClient().unsubscribe(id),
|
|
349
379
|
setWindow: async (base, units) => {
|
package/src/worker-host.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
QuerySnapshot,
|
|
33
33
|
RejectionRecord,
|
|
34
34
|
SchemaFloor,
|
|
35
|
+
SecurityLifecycle,
|
|
35
36
|
SubscribeInput,
|
|
36
37
|
SyncClientLimits,
|
|
37
38
|
SyncSummary,
|
|
@@ -62,6 +63,7 @@ import {
|
|
|
62
63
|
type CrossTabChannel,
|
|
63
64
|
FollowerLink,
|
|
64
65
|
LeaderBridge,
|
|
66
|
+
type LeadershipState,
|
|
65
67
|
multiTabChannelName,
|
|
66
68
|
newTabId,
|
|
67
69
|
} from './multi-tab';
|
|
@@ -86,11 +88,47 @@ import {
|
|
|
86
88
|
type WorkerInitConfig,
|
|
87
89
|
type WorkerInitResult,
|
|
88
90
|
type WorkerMethod,
|
|
91
|
+
type WorkerSecurityActivation,
|
|
89
92
|
type WorkerToMainMessage,
|
|
90
93
|
} from './worker-protocol';
|
|
91
94
|
|
|
92
95
|
export type HandleRole = 'leader' | 'follower';
|
|
93
96
|
|
|
97
|
+
export type BrowserReplicaMode =
|
|
98
|
+
| { readonly mode: 'shared' }
|
|
99
|
+
| { readonly mode: 'isolated'; readonly id: string };
|
|
100
|
+
|
|
101
|
+
export interface IsolatedReplicaNames {
|
|
102
|
+
readonly databaseName: string;
|
|
103
|
+
readonly databaseDirectory: string;
|
|
104
|
+
readonly lockName: string;
|
|
105
|
+
readonly channelName: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Derive the complete ownership tuple for an independently owned replica. */
|
|
109
|
+
export function isolatedReplicaNames(options: {
|
|
110
|
+
readonly databaseName: string;
|
|
111
|
+
readonly databaseDirectory?: string;
|
|
112
|
+
readonly lockName?: string;
|
|
113
|
+
readonly replicaId: string;
|
|
114
|
+
}): IsolatedReplicaNames {
|
|
115
|
+
if (!/^[A-Za-z0-9._-]+$/.test(options.replicaId)) {
|
|
116
|
+
throw new ClientSyncError(
|
|
117
|
+
'sync.invalid_request',
|
|
118
|
+
'an isolated replica id must contain only letters, numbers, dot, underscore, or dash',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const suffix = `--replica-${options.replicaId}`;
|
|
122
|
+
const databaseName = `${options.databaseName}${suffix}`;
|
|
123
|
+
const lockName = `${options.lockName ?? 'syncular-leader'}${suffix}`;
|
|
124
|
+
return {
|
|
125
|
+
databaseName,
|
|
126
|
+
databaseDirectory: `${options.databaseDirectory ?? `.syncular/${options.databaseName}`}${suffix}`,
|
|
127
|
+
lockName,
|
|
128
|
+
channelName: multiTabChannelName(lockName),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
94
132
|
export interface SyncClientHandleConfig {
|
|
95
133
|
/**
|
|
96
134
|
* Spawns the worker running `startSyncWorker()` (a factory so bundlers
|
|
@@ -104,6 +142,8 @@ export interface SyncClientHandleConfig {
|
|
|
104
142
|
readonly endpoints: WorkerEndpoints;
|
|
105
143
|
/** Structured-clone-safe E2EE keyring installed only in the leader worker. */
|
|
106
144
|
readonly encryption?: EncryptionKeyringConfig;
|
|
145
|
+
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
146
|
+
readonly securityPreflight?: boolean;
|
|
107
147
|
readonly clientId?: string;
|
|
108
148
|
readonly limits?: SyncClientLimits;
|
|
109
149
|
/** Worker-side host loop (§8.4); default true. */
|
|
@@ -111,6 +151,8 @@ export interface SyncClientHandleConfig {
|
|
|
111
151
|
/** Default: Web Locks when available, else single-owner. */
|
|
112
152
|
readonly leaderLock?: LeaderLock;
|
|
113
153
|
readonly lockName?: string;
|
|
154
|
+
/** Shared by default; isolated derives the database/lock/channel tuple. */
|
|
155
|
+
readonly replica?: BrowserReplicaMode;
|
|
114
156
|
/**
|
|
115
157
|
* Multi-tab followers (TODO 3.2). On by default: a tab that loses the
|
|
116
158
|
* leader election becomes a FOLLOWER that proxies to the leader over a
|
|
@@ -125,6 +167,8 @@ export interface SyncClientHandleConfig {
|
|
|
125
167
|
readonly followerCallTimeoutMs?: number;
|
|
126
168
|
/** Fires when this handle's role changes (follower → leader on promotion). */
|
|
127
169
|
readonly onRoleChange?: (role: HandleRole) => void;
|
|
170
|
+
/** Fires when reachability or ownership changes without replacing the handle. */
|
|
171
|
+
readonly onLeadershipChange?: (state: LeadershipState) => void;
|
|
128
172
|
readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
|
|
129
173
|
readonly onConflict?: (conflict: ConflictRecord) => void;
|
|
130
174
|
/** A worker-side autoSync round finished (or failed). */
|
|
@@ -183,15 +227,28 @@ export class SyncClientHandle {
|
|
|
183
227
|
get clientId(): string {
|
|
184
228
|
return this.#clientId;
|
|
185
229
|
}
|
|
230
|
+
get currentSchemaVersion(): number {
|
|
231
|
+
return this.#currentSchemaVersion;
|
|
232
|
+
}
|
|
233
|
+
get leadership(): LeadershipState {
|
|
234
|
+
return this.#leadership;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
leadershipSnapshot(): LeadershipState {
|
|
238
|
+
return this.#leadership;
|
|
239
|
+
}
|
|
186
240
|
|
|
187
241
|
#role: HandleRole;
|
|
188
242
|
#clientId: string;
|
|
243
|
+
readonly #currentSchemaVersion: number;
|
|
244
|
+
#leadership: LeadershipState;
|
|
189
245
|
#core: LeaderCore | undefined;
|
|
190
246
|
#follower: FollowerLink | undefined;
|
|
191
247
|
readonly #invalidation: InvalidationEmitter;
|
|
192
248
|
readonly #changes: ChangeEmitter;
|
|
193
249
|
readonly #presence: Set<(scopeKey: string) => void>;
|
|
194
250
|
readonly #roleListeners: Set<(role: HandleRole) => void>;
|
|
251
|
+
readonly #leadershipListeners: Set<(state: LeadershipState) => void>;
|
|
195
252
|
readonly #devtoolsUnregister: () => void;
|
|
196
253
|
#closed = false;
|
|
197
254
|
|
|
@@ -199,21 +256,36 @@ export class SyncClientHandle {
|
|
|
199
256
|
constructor(internals: {
|
|
200
257
|
role: HandleRole;
|
|
201
258
|
clientId: string;
|
|
259
|
+
currentSchemaVersion: number;
|
|
202
260
|
core?: LeaderCore;
|
|
203
261
|
follower?: FollowerLink;
|
|
204
262
|
invalidation: InvalidationEmitter;
|
|
205
263
|
changes: ChangeEmitter;
|
|
206
264
|
presence: Set<(scopeKey: string) => void>;
|
|
207
265
|
roleListeners?: Set<(role: HandleRole) => void>;
|
|
266
|
+
leadershipListeners?: Set<(state: LeadershipState) => void>;
|
|
267
|
+
leadership?: LeadershipState;
|
|
208
268
|
}) {
|
|
209
269
|
this.#role = internals.role;
|
|
210
270
|
this.#clientId = internals.clientId;
|
|
271
|
+
this.#currentSchemaVersion = internals.currentSchemaVersion;
|
|
272
|
+
this.#leadership =
|
|
273
|
+
internals.leadership ??
|
|
274
|
+
(internals.role === 'leader'
|
|
275
|
+
? { state: 'leader', clientId: internals.clientId }
|
|
276
|
+
: (internals.follower?.leadershipState ?? {
|
|
277
|
+
state: 'blocked',
|
|
278
|
+
reason: 'leader-unreachable',
|
|
279
|
+
code: 'client.follower_timeout',
|
|
280
|
+
retryable: true,
|
|
281
|
+
}));
|
|
211
282
|
this.#core = internals.core;
|
|
212
283
|
this.#follower = internals.follower;
|
|
213
284
|
this.#invalidation = internals.invalidation;
|
|
214
285
|
this.#changes = internals.changes;
|
|
215
286
|
this.#presence = internals.presence;
|
|
216
287
|
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
288
|
+
this.#leadershipListeners = internals.leadershipListeners ?? new Set();
|
|
217
289
|
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
218
290
|
this.#devtoolsUnregister = registerDevtools({
|
|
219
291
|
kind: 'handle',
|
|
@@ -237,6 +309,7 @@ export class SyncClientHandle {
|
|
|
237
309
|
this.#core = core;
|
|
238
310
|
this.#clientId = core.clientId;
|
|
239
311
|
this.#role = 'leader';
|
|
312
|
+
this.__setLeadership({ state: 'leader', clientId: core.clientId });
|
|
240
313
|
for (const listener of this.#roleListeners) {
|
|
241
314
|
try {
|
|
242
315
|
listener('leader');
|
|
@@ -246,6 +319,19 @@ export class SyncClientHandle {
|
|
|
246
319
|
}
|
|
247
320
|
}
|
|
248
321
|
|
|
322
|
+
/** @internal — apply a follower reachability snapshot in place. */
|
|
323
|
+
__setLeadership(state: LeadershipState): void {
|
|
324
|
+
this.#leadership = state;
|
|
325
|
+
if (state.state === 'follower') this.#clientId = state.leaderClientId;
|
|
326
|
+
for (const listener of this.#leadershipListeners) {
|
|
327
|
+
try {
|
|
328
|
+
listener(state);
|
|
329
|
+
} catch {
|
|
330
|
+
/* a UI listener must never break leadership transitions */
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
249
335
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
250
336
|
__dispatchEvent(event: SyncWorkerEvent): void {
|
|
251
337
|
if (event.kind === 'presence') {
|
|
@@ -296,6 +382,13 @@ export class SyncClientHandle {
|
|
|
296
382
|
};
|
|
297
383
|
}
|
|
298
384
|
|
|
385
|
+
onLeadershipChange(listener: (state: LeadershipState) => void): () => void {
|
|
386
|
+
this.#leadershipListeners.add(listener);
|
|
387
|
+
return () => {
|
|
388
|
+
this.#leadershipListeners.delete(listener);
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
299
392
|
#call<M extends WorkerMethod>(
|
|
300
393
|
method: M,
|
|
301
394
|
args: Parameters<WorkerApi[M]>,
|
|
@@ -334,6 +427,18 @@ export class SyncClientHandle {
|
|
|
334
427
|
return this.#call('subscribe', [input]);
|
|
335
428
|
}
|
|
336
429
|
|
|
430
|
+
securityLifecycle(): Promise<SecurityLifecycle> {
|
|
431
|
+
return this.#call('securityLifecycle', []);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
beginSecurityPreflight(): Promise<void> {
|
|
435
|
+
return this.#call('beginSecurityPreflight', []);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
activateSecurity(options: WorkerSecurityActivation = {}): Promise<void> {
|
|
439
|
+
return this.#call('activateSecurity', [options]);
|
|
440
|
+
}
|
|
441
|
+
|
|
337
442
|
unsubscribe(id: string): Promise<void> {
|
|
338
443
|
return this.#call('unsubscribe', [id]);
|
|
339
444
|
}
|
|
@@ -635,6 +740,9 @@ function buildInitConfig(config: SyncClientHandleConfig): WorkerInitConfig {
|
|
|
635
740
|
...(config.encryption !== undefined
|
|
636
741
|
? { encryption: config.encryption }
|
|
637
742
|
: {}),
|
|
743
|
+
...(config.securityPreflight !== undefined
|
|
744
|
+
? { securityPreflight: config.securityPreflight }
|
|
745
|
+
: {}),
|
|
638
746
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
639
747
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
640
748
|
...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
|
|
@@ -672,13 +780,19 @@ function fireConfigCallbacks(
|
|
|
672
780
|
export async function createSyncClientHandle(
|
|
673
781
|
config: SyncClientHandleConfig,
|
|
674
782
|
): Promise<SyncClientHandle> {
|
|
675
|
-
const
|
|
676
|
-
const
|
|
783
|
+
const resolvedConfig = resolveReplicaConfig(config);
|
|
784
|
+
const lock = resolvedConfig.leaderLock ?? defaultLeaderLock();
|
|
785
|
+
const lockName = resolvedConfig.lockName ?? 'syncular-leader';
|
|
677
786
|
const invalidation = new InvalidationEmitter();
|
|
678
787
|
const changes = new ChangeEmitter();
|
|
679
788
|
const presence = new Set<(scopeKey: string) => void>();
|
|
680
789
|
const roleListeners = new Set<(role: HandleRole) => void>();
|
|
681
|
-
if (
|
|
790
|
+
if (resolvedConfig.onRoleChange !== undefined)
|
|
791
|
+
roleListeners.add(resolvedConfig.onRoleChange);
|
|
792
|
+
const leadershipListeners = new Set<(state: LeadershipState) => void>();
|
|
793
|
+
if (resolvedConfig.onLeadershipChange !== undefined) {
|
|
794
|
+
leadershipListeners.add(resolvedConfig.onLeadershipChange);
|
|
795
|
+
}
|
|
682
796
|
|
|
683
797
|
// Leadership BEFORE the worker exists: one core per origin, and a losing
|
|
684
798
|
// tab never boots a database it must not own.
|
|
@@ -692,34 +806,38 @@ export async function createSyncClientHandle(
|
|
|
692
806
|
// Epoch derivation for a fresh boot: epoch 0. A promoter (below) reads
|
|
693
807
|
// the highest epoch it has seen and adds one, so leaders monotonically
|
|
694
808
|
// increase it across handovers.
|
|
695
|
-
return await bootLeader(
|
|
809
|
+
return await bootLeader(resolvedConfig, lockName, lease, {
|
|
696
810
|
epoch: 0,
|
|
697
811
|
invalidation,
|
|
698
812
|
changes,
|
|
699
813
|
presence,
|
|
700
814
|
roleListeners,
|
|
815
|
+
leadershipListeners,
|
|
701
816
|
});
|
|
702
817
|
}
|
|
703
818
|
|
|
704
819
|
// ---- Lost the election. ----
|
|
705
|
-
if (
|
|
820
|
+
if (resolvedConfig.multiTab === false) {
|
|
706
821
|
// Opted-out single-tab contract: a dead not-leader handle.
|
|
707
822
|
return new SyncClientHandle({
|
|
708
823
|
role: 'follower',
|
|
709
824
|
clientId: '',
|
|
825
|
+
currentSchemaVersion: resolvedConfig.schema.version,
|
|
710
826
|
invalidation,
|
|
711
827
|
changes,
|
|
712
828
|
presence,
|
|
713
829
|
roleListeners,
|
|
830
|
+
leadershipListeners,
|
|
714
831
|
});
|
|
715
832
|
}
|
|
716
833
|
|
|
717
834
|
// ---- Follower: proxy to the leader; contest + promote on its close. ----
|
|
718
|
-
return await bootFollower(
|
|
835
|
+
return await bootFollower(resolvedConfig, lockName, lock, {
|
|
719
836
|
invalidation,
|
|
720
837
|
changes,
|
|
721
838
|
presence,
|
|
722
839
|
roleListeners,
|
|
840
|
+
leadershipListeners,
|
|
723
841
|
});
|
|
724
842
|
}
|
|
725
843
|
|
|
@@ -729,6 +847,7 @@ interface HandleParts {
|
|
|
729
847
|
changes: ChangeEmitter;
|
|
730
848
|
presence: Set<(scopeKey: string) => void>;
|
|
731
849
|
roleListeners: Set<(role: HandleRole) => void>;
|
|
850
|
+
leadershipListeners: Set<(state: LeadershipState) => void>;
|
|
732
851
|
}
|
|
733
852
|
|
|
734
853
|
/** Boot (or promote to) a leader: spawn the worker, wire the bridge. */
|
|
@@ -761,6 +880,10 @@ async function bootLeader(
|
|
|
761
880
|
epoch: parts.epoch ?? 0,
|
|
762
881
|
clientId,
|
|
763
882
|
invoke,
|
|
883
|
+
heartbeatMs: Math.max(
|
|
884
|
+
10,
|
|
885
|
+
Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3),
|
|
886
|
+
),
|
|
764
887
|
});
|
|
765
888
|
}
|
|
766
889
|
: undefined;
|
|
@@ -776,11 +899,13 @@ async function bootLeader(
|
|
|
776
899
|
const handle = new SyncClientHandle({
|
|
777
900
|
role: 'leader',
|
|
778
901
|
clientId: core.clientId,
|
|
902
|
+
currentSchemaVersion: config.schema.version,
|
|
779
903
|
core,
|
|
780
904
|
invalidation: parts.invalidation,
|
|
781
905
|
changes: parts.changes,
|
|
782
906
|
presence: parts.presence,
|
|
783
907
|
roleListeners: parts.roleListeners,
|
|
908
|
+
leadershipListeners: parts.leadershipListeners,
|
|
784
909
|
});
|
|
785
910
|
handleRef.handle = handle;
|
|
786
911
|
return handle;
|
|
@@ -811,6 +936,7 @@ async function bootFollower(
|
|
|
811
936
|
// it after binding). Nothing else to do — calls already flush.
|
|
812
937
|
void clientId;
|
|
813
938
|
},
|
|
939
|
+
onStateChange: (state) => handleRef.handle?.__setLeadership(state),
|
|
814
940
|
...(config.followerCallTimeoutMs !== undefined
|
|
815
941
|
? { callTimeoutMs: config.followerCallTimeoutMs }
|
|
816
942
|
: {}),
|
|
@@ -855,6 +981,10 @@ async function bootFollower(
|
|
|
855
981
|
epoch: nextEpoch,
|
|
856
982
|
clientId,
|
|
857
983
|
invoke,
|
|
984
|
+
heartbeatMs: Math.max(
|
|
985
|
+
10,
|
|
986
|
+
Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3),
|
|
987
|
+
),
|
|
858
988
|
});
|
|
859
989
|
},
|
|
860
990
|
}
|
|
@@ -874,11 +1004,13 @@ async function bootFollower(
|
|
|
874
1004
|
// leave '' until promotion (the shared id is the leader's — hooks that
|
|
875
1005
|
// need it read it after a round). Followers rarely need clientId directly.
|
|
876
1006
|
clientId: '',
|
|
1007
|
+
currentSchemaVersion: config.schema.version,
|
|
877
1008
|
follower,
|
|
878
1009
|
invalidation: parts.invalidation,
|
|
879
1010
|
changes: parts.changes,
|
|
880
1011
|
presence: parts.presence,
|
|
881
1012
|
roleListeners: parts.roleListeners,
|
|
1013
|
+
leadershipListeners: parts.leadershipListeners,
|
|
882
1014
|
});
|
|
883
1015
|
handleRef.handle = handle;
|
|
884
1016
|
// Do not hand back a follower until its link has bound to the leader (the
|
|
@@ -894,5 +1026,41 @@ async function bootFollower(
|
|
|
894
1026
|
} catch {
|
|
895
1027
|
/* bind timed out — return the (degraded but functional) handle anyway */
|
|
896
1028
|
}
|
|
1029
|
+
// Init configuration belongs to the one leader worker. A newly opened
|
|
1030
|
+
// follower that explicitly requests security preflight must therefore put
|
|
1031
|
+
// the shared origin replica behind the same barrier before it is returned;
|
|
1032
|
+
// otherwise an already-running leader would silently ignore the request.
|
|
1033
|
+
if (config.securityPreflight === true) {
|
|
1034
|
+
await handle.beginSecurityPreflight();
|
|
1035
|
+
}
|
|
897
1036
|
return handle;
|
|
898
1037
|
}
|
|
1038
|
+
|
|
1039
|
+
function resolveReplicaConfig(
|
|
1040
|
+
config: SyncClientHandleConfig,
|
|
1041
|
+
): SyncClientHandleConfig {
|
|
1042
|
+
if (config.replica?.mode !== 'isolated') return config;
|
|
1043
|
+
if (config.database.mode !== 'persistent') {
|
|
1044
|
+
throw new ClientSyncError(
|
|
1045
|
+
'sync.invalid_request',
|
|
1046
|
+
'isolated browser replicas require a named persistent database',
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
const names = isolatedReplicaNames({
|
|
1050
|
+
databaseName: config.database.name,
|
|
1051
|
+
...(config.database.directory !== undefined
|
|
1052
|
+
? { databaseDirectory: config.database.directory }
|
|
1053
|
+
: {}),
|
|
1054
|
+
...(config.lockName !== undefined ? { lockName: config.lockName } : {}),
|
|
1055
|
+
replicaId: config.replica.id,
|
|
1056
|
+
});
|
|
1057
|
+
return {
|
|
1058
|
+
...config,
|
|
1059
|
+
lockName: names.lockName,
|
|
1060
|
+
database: {
|
|
1061
|
+
...config.database,
|
|
1062
|
+
name: names.databaseName,
|
|
1063
|
+
directory: names.databaseDirectory,
|
|
1064
|
+
},
|
|
1065
|
+
};
|
|
1066
|
+
}
|
package/src/worker-protocol.ts
CHANGED
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
QuerySnapshot,
|
|
28
28
|
RejectionRecord,
|
|
29
29
|
SchemaFloor,
|
|
30
|
+
SecurityLifecycle,
|
|
30
31
|
SubscribeInput,
|
|
31
32
|
SyncClientLimits,
|
|
32
33
|
SyncSummary,
|
|
@@ -100,6 +101,8 @@ export interface WorkerInitConfig {
|
|
|
100
101
|
readonly endpoints: WorkerEndpoints;
|
|
101
102
|
/** Portable raw keyring installed inside the worker-owned client core. */
|
|
102
103
|
readonly encryption?: EncryptionKeyringConfig;
|
|
104
|
+
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
105
|
+
readonly securityPreflight?: boolean;
|
|
103
106
|
readonly clientId?: string;
|
|
104
107
|
readonly limits?: SyncClientLimits;
|
|
105
108
|
/**
|
|
@@ -115,11 +118,19 @@ export interface WorkerInitResult {
|
|
|
115
118
|
readonly clientId: string;
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
/** Structured-clone-safe key material installed at security activation. */
|
|
122
|
+
export interface WorkerSecurityActivation {
|
|
123
|
+
readonly encryption?: EncryptionKeyringConfig;
|
|
124
|
+
}
|
|
125
|
+
|
|
118
126
|
// ---------------------------------------------------------------------------
|
|
119
127
|
// The logical API — the one shared shape (worker implements, handle projects)
|
|
120
128
|
// ---------------------------------------------------------------------------
|
|
121
129
|
|
|
122
130
|
export interface WorkerApi {
|
|
131
|
+
securityLifecycle(): SecurityLifecycle;
|
|
132
|
+
beginSecurityPreflight(): Promise<void>;
|
|
133
|
+
activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
|
|
123
134
|
subscribe(input: SubscribeInput): void;
|
|
124
135
|
unsubscribe(id: string): void;
|
|
125
136
|
/** §4.8 windowed subscriptions: set the live units for a window base. */
|