@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/dist/worker-host.d.ts
CHANGED
|
@@ -23,20 +23,39 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import type { WakeReason } from '@syncular/core';
|
|
25
25
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
26
|
-
import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
26
|
+
import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
27
27
|
import type { SqlRow, SqlValue } from './database.js';
|
|
28
28
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
29
29
|
import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type InvalidationListener, type LocalRevision, type SyncStatusSnapshot } from './invalidation.js';
|
|
30
30
|
import { type LeaderLease, type LeaderLock } from './leader-lock.js';
|
|
31
31
|
import type { LocalDataPurgeInput, LocalDataPurgeResult } from './local-purge.js';
|
|
32
|
-
import { type CrossTabChannel, FollowerLink, LeaderBridge } from './multi-tab.js';
|
|
32
|
+
import { type CrossTabChannel, FollowerLink, LeaderBridge, type LeadershipState } from './multi-tab.js';
|
|
33
33
|
import type { OutboxCommit } from './outbox.js';
|
|
34
34
|
import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
|
|
35
35
|
import type { ClientSchema } from './schema.js';
|
|
36
36
|
import type { SubscriptionRecord } from './state.js';
|
|
37
37
|
import type { WindowBase } from './window.js';
|
|
38
|
-
import { type SyncWorkerEvent, type WorkerDatabaseInit, type WorkerEndpoints, type WorkerErrorShape } from './worker-protocol.js';
|
|
38
|
+
import { type SyncWorkerEvent, type WorkerDatabaseInit, type WorkerEndpoints, type WorkerErrorShape, type WorkerSecurityActivation } from './worker-protocol.js';
|
|
39
39
|
export type HandleRole = 'leader' | 'follower';
|
|
40
|
+
export type BrowserReplicaMode = {
|
|
41
|
+
readonly mode: 'shared';
|
|
42
|
+
} | {
|
|
43
|
+
readonly mode: 'isolated';
|
|
44
|
+
readonly id: string;
|
|
45
|
+
};
|
|
46
|
+
export interface IsolatedReplicaNames {
|
|
47
|
+
readonly databaseName: string;
|
|
48
|
+
readonly databaseDirectory: string;
|
|
49
|
+
readonly lockName: string;
|
|
50
|
+
readonly channelName: string;
|
|
51
|
+
}
|
|
52
|
+
/** Derive the complete ownership tuple for an independently owned replica. */
|
|
53
|
+
export declare function isolatedReplicaNames(options: {
|
|
54
|
+
readonly databaseName: string;
|
|
55
|
+
readonly databaseDirectory?: string;
|
|
56
|
+
readonly lockName?: string;
|
|
57
|
+
readonly replicaId: string;
|
|
58
|
+
}): IsolatedReplicaNames;
|
|
40
59
|
export interface SyncClientHandleConfig {
|
|
41
60
|
/**
|
|
42
61
|
* Spawns the worker running `startSyncWorker()` (a factory so bundlers
|
|
@@ -50,6 +69,8 @@ export interface SyncClientHandleConfig {
|
|
|
50
69
|
readonly endpoints: WorkerEndpoints;
|
|
51
70
|
/** Structured-clone-safe E2EE keyring installed only in the leader worker. */
|
|
52
71
|
readonly encryption?: EncryptionKeyringConfig;
|
|
72
|
+
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
73
|
+
readonly securityPreflight?: boolean;
|
|
53
74
|
readonly clientId?: string;
|
|
54
75
|
readonly limits?: SyncClientLimits;
|
|
55
76
|
/** Worker-side host loop (§8.4); default true. */
|
|
@@ -57,6 +78,8 @@ export interface SyncClientHandleConfig {
|
|
|
57
78
|
/** Default: Web Locks when available, else single-owner. */
|
|
58
79
|
readonly leaderLock?: LeaderLock;
|
|
59
80
|
readonly lockName?: string;
|
|
81
|
+
/** Shared by default; isolated derives the database/lock/channel tuple. */
|
|
82
|
+
readonly replica?: BrowserReplicaMode;
|
|
60
83
|
/**
|
|
61
84
|
* Multi-tab followers (TODO 3.2). On by default: a tab that loses the
|
|
62
85
|
* leader election becomes a FOLLOWER that proxies to the leader over a
|
|
@@ -71,6 +94,8 @@ export interface SyncClientHandleConfig {
|
|
|
71
94
|
readonly followerCallTimeoutMs?: number;
|
|
72
95
|
/** Fires when this handle's role changes (follower → leader on promotion). */
|
|
73
96
|
readonly onRoleChange?: (role: HandleRole) => void;
|
|
97
|
+
/** Fires when reachability or ownership changes without replacing the handle. */
|
|
98
|
+
readonly onLeadershipChange?: (state: LeadershipState) => void;
|
|
74
99
|
readonly onSyncNeeded?: (reason: 'startup' | 'hello' | WakeReason) => void;
|
|
75
100
|
readonly onConflict?: (conflict: ConflictRecord) => void;
|
|
76
101
|
/** A worker-side autoSync round finished (or failed). */
|
|
@@ -107,19 +132,27 @@ export declare class SyncClientHandle {
|
|
|
107
132
|
get role(): HandleRole;
|
|
108
133
|
/** Resolved client id — the leader's; shared by all tabs on this origin. */
|
|
109
134
|
get clientId(): string;
|
|
135
|
+
get currentSchemaVersion(): number;
|
|
136
|
+
get leadership(): LeadershipState;
|
|
137
|
+
leadershipSnapshot(): LeadershipState;
|
|
110
138
|
/** @internal — use {@link createSyncClientHandle}. */
|
|
111
139
|
constructor(internals: {
|
|
112
140
|
role: HandleRole;
|
|
113
141
|
clientId: string;
|
|
142
|
+
currentSchemaVersion: number;
|
|
114
143
|
core?: LeaderCore;
|
|
115
144
|
follower?: FollowerLink;
|
|
116
145
|
invalidation: InvalidationEmitter;
|
|
117
146
|
changes: ChangeEmitter;
|
|
118
147
|
presence: Set<(scopeKey: string) => void>;
|
|
119
148
|
roleListeners?: Set<(role: HandleRole) => void>;
|
|
149
|
+
leadershipListeners?: Set<(state: LeadershipState) => void>;
|
|
150
|
+
leadership?: LeadershipState;
|
|
120
151
|
});
|
|
121
152
|
/** @internal — swap this handle from follower to leader (promotion). */
|
|
122
153
|
__becomeLeader(core: LeaderCore): void;
|
|
154
|
+
/** @internal — apply a follower reachability snapshot in place. */
|
|
155
|
+
__setLeadership(state: LeadershipState): void;
|
|
123
156
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
124
157
|
__dispatchEvent(event: SyncWorkerEvent): void;
|
|
125
158
|
/**
|
|
@@ -137,7 +170,11 @@ export declare class SyncClientHandle {
|
|
|
137
170
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
138
171
|
/** Subscribe to role transitions (follower → leader on promotion). */
|
|
139
172
|
onRoleChange(listener: (role: HandleRole) => void): () => void;
|
|
173
|
+
onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
|
|
140
174
|
subscribe(input: SubscribeInput): Promise<void>;
|
|
175
|
+
securityLifecycle(): Promise<SecurityLifecycle>;
|
|
176
|
+
beginSecurityPreflight(): Promise<void>;
|
|
177
|
+
activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
|
|
141
178
|
unsubscribe(id: string): Promise<void>;
|
|
142
179
|
setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
|
|
143
180
|
windowState(base: WindowBase): Promise<WindowState>;
|
package/dist/worker-host.js
CHANGED
|
@@ -4,6 +4,21 @@ import { ChangeEmitter, InvalidationEmitter, invalidationFromChange, } from './i
|
|
|
4
4
|
import { singleOwnerLock, webLocksLeaderLock, } from './leader-lock.js';
|
|
5
5
|
import { broadcastChannelFactory, FollowerLink, LeaderBridge, multiTabChannelName, newTabId, } from './multi-tab.js';
|
|
6
6
|
import { NOT_LEADER_CODE, WORKER_FAILED_CODE, } from './worker-protocol.js';
|
|
7
|
+
/** Derive the complete ownership tuple for an independently owned replica. */
|
|
8
|
+
export function isolatedReplicaNames(options) {
|
|
9
|
+
if (!/^[A-Za-z0-9._-]+$/.test(options.replicaId)) {
|
|
10
|
+
throw new ClientSyncError('sync.invalid_request', 'an isolated replica id must contain only letters, numbers, dot, underscore, or dash');
|
|
11
|
+
}
|
|
12
|
+
const suffix = `--replica-${options.replicaId}`;
|
|
13
|
+
const databaseName = `${options.databaseName}${suffix}`;
|
|
14
|
+
const lockName = `${options.lockName ?? 'syncular-leader'}${suffix}`;
|
|
15
|
+
return {
|
|
16
|
+
databaseName,
|
|
17
|
+
databaseDirectory: `${options.databaseDirectory ?? `.syncular/${options.databaseName}`}${suffix}`,
|
|
18
|
+
lockName,
|
|
19
|
+
channelName: multiTabChannelName(lockName),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
7
22
|
function defaultLeaderLock() {
|
|
8
23
|
const nav = globalThis.navigator;
|
|
9
24
|
return nav?.locks !== undefined
|
|
@@ -27,26 +42,50 @@ export class SyncClientHandle {
|
|
|
27
42
|
get clientId() {
|
|
28
43
|
return this.#clientId;
|
|
29
44
|
}
|
|
45
|
+
get currentSchemaVersion() {
|
|
46
|
+
return this.#currentSchemaVersion;
|
|
47
|
+
}
|
|
48
|
+
get leadership() {
|
|
49
|
+
return this.#leadership;
|
|
50
|
+
}
|
|
51
|
+
leadershipSnapshot() {
|
|
52
|
+
return this.#leadership;
|
|
53
|
+
}
|
|
30
54
|
#role;
|
|
31
55
|
#clientId;
|
|
56
|
+
#currentSchemaVersion;
|
|
57
|
+
#leadership;
|
|
32
58
|
#core;
|
|
33
59
|
#follower;
|
|
34
60
|
#invalidation;
|
|
35
61
|
#changes;
|
|
36
62
|
#presence;
|
|
37
63
|
#roleListeners;
|
|
64
|
+
#leadershipListeners;
|
|
38
65
|
#devtoolsUnregister;
|
|
39
66
|
#closed = false;
|
|
40
67
|
/** @internal — use {@link createSyncClientHandle}. */
|
|
41
68
|
constructor(internals) {
|
|
42
69
|
this.#role = internals.role;
|
|
43
70
|
this.#clientId = internals.clientId;
|
|
71
|
+
this.#currentSchemaVersion = internals.currentSchemaVersion;
|
|
72
|
+
this.#leadership =
|
|
73
|
+
internals.leadership ??
|
|
74
|
+
(internals.role === 'leader'
|
|
75
|
+
? { state: 'leader', clientId: internals.clientId }
|
|
76
|
+
: (internals.follower?.leadershipState ?? {
|
|
77
|
+
state: 'blocked',
|
|
78
|
+
reason: 'leader-unreachable',
|
|
79
|
+
code: 'client.follower_timeout',
|
|
80
|
+
retryable: true,
|
|
81
|
+
}));
|
|
44
82
|
this.#core = internals.core;
|
|
45
83
|
this.#follower = internals.follower;
|
|
46
84
|
this.#invalidation = internals.invalidation;
|
|
47
85
|
this.#changes = internals.changes;
|
|
48
86
|
this.#presence = internals.presence;
|
|
49
87
|
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
88
|
+
this.#leadershipListeners = internals.leadershipListeners ?? new Set();
|
|
50
89
|
// RFC 0002 §3.2: console introspection — a no-op outside a dev page.
|
|
51
90
|
this.#devtoolsUnregister = registerDevtools({
|
|
52
91
|
kind: 'handle',
|
|
@@ -69,6 +108,7 @@ export class SyncClientHandle {
|
|
|
69
108
|
this.#core = core;
|
|
70
109
|
this.#clientId = core.clientId;
|
|
71
110
|
this.#role = 'leader';
|
|
111
|
+
this.__setLeadership({ state: 'leader', clientId: core.clientId });
|
|
72
112
|
for (const listener of this.#roleListeners) {
|
|
73
113
|
try {
|
|
74
114
|
listener('leader');
|
|
@@ -78,6 +118,20 @@ export class SyncClientHandle {
|
|
|
78
118
|
}
|
|
79
119
|
}
|
|
80
120
|
}
|
|
121
|
+
/** @internal — apply a follower reachability snapshot in place. */
|
|
122
|
+
__setLeadership(state) {
|
|
123
|
+
this.#leadership = state;
|
|
124
|
+
if (state.state === 'follower')
|
|
125
|
+
this.#clientId = state.leaderClientId;
|
|
126
|
+
for (const listener of this.#leadershipListeners) {
|
|
127
|
+
try {
|
|
128
|
+
listener(state);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* a UI listener must never break leadership transitions */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
81
135
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
82
136
|
__dispatchEvent(event) {
|
|
83
137
|
if (event.kind === 'presence') {
|
|
@@ -126,6 +180,12 @@ export class SyncClientHandle {
|
|
|
126
180
|
this.#roleListeners.delete(listener);
|
|
127
181
|
};
|
|
128
182
|
}
|
|
183
|
+
onLeadershipChange(listener) {
|
|
184
|
+
this.#leadershipListeners.add(listener);
|
|
185
|
+
return () => {
|
|
186
|
+
this.#leadershipListeners.delete(listener);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
129
189
|
#call(method, args) {
|
|
130
190
|
if (this.#closed) {
|
|
131
191
|
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the handle is closed'));
|
|
@@ -146,6 +206,15 @@ export class SyncClientHandle {
|
|
|
146
206
|
subscribe(input) {
|
|
147
207
|
return this.#call('subscribe', [input]);
|
|
148
208
|
}
|
|
209
|
+
securityLifecycle() {
|
|
210
|
+
return this.#call('securityLifecycle', []);
|
|
211
|
+
}
|
|
212
|
+
beginSecurityPreflight() {
|
|
213
|
+
return this.#call('beginSecurityPreflight', []);
|
|
214
|
+
}
|
|
215
|
+
activateSecurity(options = {}) {
|
|
216
|
+
return this.#call('activateSecurity', [options]);
|
|
217
|
+
}
|
|
149
218
|
unsubscribe(id) {
|
|
150
219
|
return this.#call('unsubscribe', [id]);
|
|
151
220
|
}
|
|
@@ -373,6 +442,9 @@ function buildInitConfig(config) {
|
|
|
373
442
|
...(config.encryption !== undefined
|
|
374
443
|
? { encryption: config.encryption }
|
|
375
444
|
: {}),
|
|
445
|
+
...(config.securityPreflight !== undefined
|
|
446
|
+
? { securityPreflight: config.securityPreflight }
|
|
447
|
+
: {}),
|
|
376
448
|
...(config.clientId !== undefined ? { clientId: config.clientId } : {}),
|
|
377
449
|
...(config.limits !== undefined ? { limits: config.limits } : {}),
|
|
378
450
|
...(config.autoSync !== undefined ? { autoSync: config.autoSync } : {}),
|
|
@@ -407,14 +479,19 @@ function fireConfigCallbacks(config, event) {
|
|
|
407
479
|
* `multiTab: false`: a losing tab resolves to a dead not-leader handle.
|
|
408
480
|
*/
|
|
409
481
|
export async function createSyncClientHandle(config) {
|
|
410
|
-
const
|
|
411
|
-
const
|
|
482
|
+
const resolvedConfig = resolveReplicaConfig(config);
|
|
483
|
+
const lock = resolvedConfig.leaderLock ?? defaultLeaderLock();
|
|
484
|
+
const lockName = resolvedConfig.lockName ?? 'syncular-leader';
|
|
412
485
|
const invalidation = new InvalidationEmitter();
|
|
413
486
|
const changes = new ChangeEmitter();
|
|
414
487
|
const presence = new Set();
|
|
415
488
|
const roleListeners = new Set();
|
|
416
|
-
if (
|
|
417
|
-
roleListeners.add(
|
|
489
|
+
if (resolvedConfig.onRoleChange !== undefined)
|
|
490
|
+
roleListeners.add(resolvedConfig.onRoleChange);
|
|
491
|
+
const leadershipListeners = new Set();
|
|
492
|
+
if (resolvedConfig.onLeadershipChange !== undefined) {
|
|
493
|
+
leadershipListeners.add(resolvedConfig.onLeadershipChange);
|
|
494
|
+
}
|
|
418
495
|
// Leadership BEFORE the worker exists: one core per origin, and a losing
|
|
419
496
|
// tab never boots a database it must not own.
|
|
420
497
|
const lease = lock.tryAcquire !== undefined
|
|
@@ -425,32 +502,36 @@ export async function createSyncClientHandle(config) {
|
|
|
425
502
|
// Epoch derivation for a fresh boot: epoch 0. A promoter (below) reads
|
|
426
503
|
// the highest epoch it has seen and adds one, so leaders monotonically
|
|
427
504
|
// increase it across handovers.
|
|
428
|
-
return await bootLeader(
|
|
505
|
+
return await bootLeader(resolvedConfig, lockName, lease, {
|
|
429
506
|
epoch: 0,
|
|
430
507
|
invalidation,
|
|
431
508
|
changes,
|
|
432
509
|
presence,
|
|
433
510
|
roleListeners,
|
|
511
|
+
leadershipListeners,
|
|
434
512
|
});
|
|
435
513
|
}
|
|
436
514
|
// ---- Lost the election. ----
|
|
437
|
-
if (
|
|
515
|
+
if (resolvedConfig.multiTab === false) {
|
|
438
516
|
// Opted-out single-tab contract: a dead not-leader handle.
|
|
439
517
|
return new SyncClientHandle({
|
|
440
518
|
role: 'follower',
|
|
441
519
|
clientId: '',
|
|
520
|
+
currentSchemaVersion: resolvedConfig.schema.version,
|
|
442
521
|
invalidation,
|
|
443
522
|
changes,
|
|
444
523
|
presence,
|
|
445
524
|
roleListeners,
|
|
525
|
+
leadershipListeners,
|
|
446
526
|
});
|
|
447
527
|
}
|
|
448
528
|
// ---- Follower: proxy to the leader; contest + promote on its close. ----
|
|
449
|
-
return await bootFollower(
|
|
529
|
+
return await bootFollower(resolvedConfig, lockName, lock, {
|
|
450
530
|
invalidation,
|
|
451
531
|
changes,
|
|
452
532
|
presence,
|
|
453
533
|
roleListeners,
|
|
534
|
+
leadershipListeners,
|
|
454
535
|
});
|
|
455
536
|
}
|
|
456
537
|
/** Boot (or promote to) a leader: spawn the worker, wire the bridge. */
|
|
@@ -471,6 +552,7 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
471
552
|
epoch: parts.epoch ?? 0,
|
|
472
553
|
clientId,
|
|
473
554
|
invoke,
|
|
555
|
+
heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
|
|
474
556
|
});
|
|
475
557
|
}
|
|
476
558
|
: undefined;
|
|
@@ -484,11 +566,13 @@ async function bootLeader(config, lockName, lease, parts) {
|
|
|
484
566
|
const handle = new SyncClientHandle({
|
|
485
567
|
role: 'leader',
|
|
486
568
|
clientId: core.clientId,
|
|
569
|
+
currentSchemaVersion: config.schema.version,
|
|
487
570
|
core,
|
|
488
571
|
invalidation: parts.invalidation,
|
|
489
572
|
changes: parts.changes,
|
|
490
573
|
presence: parts.presence,
|
|
491
574
|
roleListeners: parts.roleListeners,
|
|
575
|
+
leadershipListeners: parts.leadershipListeners,
|
|
492
576
|
});
|
|
493
577
|
handleRef.handle = handle;
|
|
494
578
|
return handle;
|
|
@@ -512,6 +596,7 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
512
596
|
// it after binding). Nothing else to do — calls already flush.
|
|
513
597
|
void clientId;
|
|
514
598
|
},
|
|
599
|
+
onStateChange: (state) => handleRef.handle?.__setLeadership(state),
|
|
515
600
|
...(config.followerCallTimeoutMs !== undefined
|
|
516
601
|
? { callTimeoutMs: config.followerCallTimeoutMs }
|
|
517
602
|
: {}),
|
|
@@ -549,6 +634,7 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
549
634
|
epoch: nextEpoch,
|
|
550
635
|
clientId,
|
|
551
636
|
invoke,
|
|
637
|
+
heartbeatMs: Math.max(10, Math.floor((config.followerCallTimeoutMs ?? 10_000) / 3)),
|
|
552
638
|
});
|
|
553
639
|
},
|
|
554
640
|
}
|
|
@@ -568,11 +654,13 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
568
654
|
// leave '' until promotion (the shared id is the leader's — hooks that
|
|
569
655
|
// need it read it after a round). Followers rarely need clientId directly.
|
|
570
656
|
clientId: '',
|
|
657
|
+
currentSchemaVersion: config.schema.version,
|
|
571
658
|
follower,
|
|
572
659
|
invalidation: parts.invalidation,
|
|
573
660
|
changes: parts.changes,
|
|
574
661
|
presence: parts.presence,
|
|
575
662
|
roleListeners: parts.roleListeners,
|
|
663
|
+
leadershipListeners: parts.leadershipListeners,
|
|
576
664
|
});
|
|
577
665
|
handleRef.handle = handle;
|
|
578
666
|
// Do not hand back a follower until its link has bound to the leader (the
|
|
@@ -589,5 +677,36 @@ async function bootFollower(config, lockName, lock, parts) {
|
|
|
589
677
|
catch {
|
|
590
678
|
/* bind timed out — return the (degraded but functional) handle anyway */
|
|
591
679
|
}
|
|
680
|
+
// Init configuration belongs to the one leader worker. A newly opened
|
|
681
|
+
// follower that explicitly requests security preflight must therefore put
|
|
682
|
+
// the shared origin replica behind the same barrier before it is returned;
|
|
683
|
+
// otherwise an already-running leader would silently ignore the request.
|
|
684
|
+
if (config.securityPreflight === true) {
|
|
685
|
+
await handle.beginSecurityPreflight();
|
|
686
|
+
}
|
|
592
687
|
return handle;
|
|
593
688
|
}
|
|
689
|
+
function resolveReplicaConfig(config) {
|
|
690
|
+
if (config.replica?.mode !== 'isolated')
|
|
691
|
+
return config;
|
|
692
|
+
if (config.database.mode !== 'persistent') {
|
|
693
|
+
throw new ClientSyncError('sync.invalid_request', 'isolated browser replicas require a named persistent database');
|
|
694
|
+
}
|
|
695
|
+
const names = isolatedReplicaNames({
|
|
696
|
+
databaseName: config.database.name,
|
|
697
|
+
...(config.database.directory !== undefined
|
|
698
|
+
? { databaseDirectory: config.database.directory }
|
|
699
|
+
: {}),
|
|
700
|
+
...(config.lockName !== undefined ? { lockName: config.lockName } : {}),
|
|
701
|
+
replicaId: config.replica.id,
|
|
702
|
+
});
|
|
703
|
+
return {
|
|
704
|
+
...config,
|
|
705
|
+
lockName: names.lockName,
|
|
706
|
+
database: {
|
|
707
|
+
...config.database,
|
|
708
|
+
name: names.databaseName,
|
|
709
|
+
directory: names.databaseDirectory,
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import type { WakeReason } from '@syncular/core';
|
|
20
20
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
21
|
-
import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
21
|
+
import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SecurityLifecycle, SubscribeInput, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
22
22
|
import type { SqlRow, SqlValue } from './database.js';
|
|
23
23
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
24
24
|
import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
|
|
@@ -65,6 +65,8 @@ export interface WorkerInitConfig {
|
|
|
65
65
|
readonly endpoints: WorkerEndpoints;
|
|
66
66
|
/** Portable raw keyring installed inside the worker-owned client core. */
|
|
67
67
|
readonly encryption?: EncryptionKeyringConfig;
|
|
68
|
+
/** Open the worker-owned replica behind the fail-closed security gate. */
|
|
69
|
+
readonly securityPreflight?: boolean;
|
|
68
70
|
readonly clientId?: string;
|
|
69
71
|
readonly limits?: SyncClientLimits;
|
|
70
72
|
/**
|
|
@@ -78,7 +80,14 @@ export interface WorkerInitConfig {
|
|
|
78
80
|
export interface WorkerInitResult {
|
|
79
81
|
readonly clientId: string;
|
|
80
82
|
}
|
|
83
|
+
/** Structured-clone-safe key material installed at security activation. */
|
|
84
|
+
export interface WorkerSecurityActivation {
|
|
85
|
+
readonly encryption?: EncryptionKeyringConfig;
|
|
86
|
+
}
|
|
81
87
|
export interface WorkerApi {
|
|
88
|
+
securityLifecycle(): SecurityLifecycle;
|
|
89
|
+
beginSecurityPreflight(): Promise<void>;
|
|
90
|
+
activateSecurity(options?: WorkerSecurityActivation): Promise<void>;
|
|
82
91
|
subscribe(input: SubscribeInput): void;
|
|
83
92
|
unsubscribe(id: string): void;
|
|
84
93
|
/** §4.8 windowed subscriptions: set the live units for a window base. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.15",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
84
|
-
"@syncular/core": "0.15.
|
|
84
|
+
"@syncular/core": "0.15.15"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"better-sqlite3": ">=11"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.15.
|
|
95
|
+
"@syncular/server": "0.15.15",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SyncStatusSnapshot } from './invalidation';
|
|
2
|
+
import type { LeadershipState } from './multi-tab';
|
|
3
|
+
|
|
4
|
+
export type SyncAvailability =
|
|
5
|
+
| { readonly state: 'ready' }
|
|
6
|
+
| { readonly state: 'migrating'; readonly currentSchemaVersion: number }
|
|
7
|
+
| {
|
|
8
|
+
readonly state: 'blocked';
|
|
9
|
+
readonly reason:
|
|
10
|
+
| 'client-upgrade-required'
|
|
11
|
+
| 'server-behind'
|
|
12
|
+
| 'incompatible-schema'
|
|
13
|
+
| 'leader-unreachable';
|
|
14
|
+
readonly currentSchemaVersion: number;
|
|
15
|
+
readonly requiredSchemaVersion?: number;
|
|
16
|
+
readonly latestServerSchemaVersion?: number;
|
|
17
|
+
readonly retryable: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Classify schema and browser-ownership state without parsing diagnostics. */
|
|
21
|
+
export function classifySyncAvailability(
|
|
22
|
+
status: SyncStatusSnapshot,
|
|
23
|
+
leadership?: LeadershipState,
|
|
24
|
+
): SyncAvailability {
|
|
25
|
+
const currentSchemaVersion = status.currentSchemaVersion;
|
|
26
|
+
if (leadership?.state === 'blocked') {
|
|
27
|
+
return {
|
|
28
|
+
state: 'blocked',
|
|
29
|
+
reason: 'leader-unreachable',
|
|
30
|
+
currentSchemaVersion,
|
|
31
|
+
retryable: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const required = status.schemaFloor?.requiredSchemaVersion;
|
|
35
|
+
const latest = status.schemaFloor?.latestSchemaVersion;
|
|
36
|
+
if (required !== undefined && required > currentSchemaVersion) {
|
|
37
|
+
return {
|
|
38
|
+
state: 'blocked',
|
|
39
|
+
reason: 'client-upgrade-required',
|
|
40
|
+
currentSchemaVersion,
|
|
41
|
+
requiredSchemaVersion: required,
|
|
42
|
+
...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
|
|
43
|
+
retryable: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (latest !== undefined && latest < currentSchemaVersion) {
|
|
47
|
+
return {
|
|
48
|
+
state: 'blocked',
|
|
49
|
+
reason: 'server-behind',
|
|
50
|
+
currentSchemaVersion,
|
|
51
|
+
...(required !== undefined ? { requiredSchemaVersion: required } : {}),
|
|
52
|
+
latestServerSchemaVersion: latest,
|
|
53
|
+
retryable: false,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (status.schemaFloor !== undefined) {
|
|
57
|
+
return {
|
|
58
|
+
state: 'blocked',
|
|
59
|
+
reason: 'incompatible-schema',
|
|
60
|
+
currentSchemaVersion,
|
|
61
|
+
...(required !== undefined ? { requiredSchemaVersion: required } : {}),
|
|
62
|
+
...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
|
|
63
|
+
retryable: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (status.upgrading) {
|
|
67
|
+
return { state: 'migrating', currentSchemaVersion };
|
|
68
|
+
}
|
|
69
|
+
return { state: 'ready' };
|
|
70
|
+
}
|