@syncular/client 0.15.12 → 0.15.14
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 +66 -2
- package/dist/availability.d.ts +17 -0
- package/dist/availability.js +48 -0
- package/dist/client.js +3 -4
- 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/state.d.ts +3 -2
- package/dist/state.js +23 -13
- package/dist/worker-host.d.ts +33 -1
- package/dist/worker-host.js +107 -7
- package/package.json +3 -3
- package/src/availability.ts +70 -0
- package/src/client.ts +7 -4
- 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/state.ts +25 -13
- package/src/worker-host.ts +148 -6
package/README.md
CHANGED
|
@@ -73,7 +73,9 @@ const handle = await createSyncClientHandle({
|
|
|
73
73
|
schema, database: { mode: 'persistent', name: 'app' }, endpoints,
|
|
74
74
|
onRoleChange: (role) => console.log('now', role), // 'follower' → 'leader'
|
|
75
75
|
});
|
|
76
|
-
// handle.role is 'leader' or 'follower'
|
|
76
|
+
// Compatibility: handle.role is 'leader' or 'follower'.
|
|
77
|
+
// Detailed state: handle.leadership / handle.leadershipSnapshot().
|
|
78
|
+
handle.onLeadershipChange((state) => renderConnectionState(state));
|
|
77
79
|
```
|
|
78
80
|
|
|
79
81
|
**Topology.** The tab that wins the Web Locks election is the **leader**:
|
|
@@ -105,6 +107,15 @@ flushed to the new leader on its announce; past the deadline they fail
|
|
|
105
107
|
loudly with `client.follower_timeout` (never a silent hang), and an
|
|
106
108
|
overflowing queue rejects rather than growing unbounded.
|
|
107
109
|
|
|
110
|
+
Leader announcements continue as heartbeats while followers are attached. If
|
|
111
|
+
a tab can acquire neither a response nor a new lock grant before the configured
|
|
112
|
+
`followerCallTimeoutMs`, `handle.leadership` becomes
|
|
113
|
+
`{ state: 'blocked', reason: 'leader-unreachable', code:
|
|
114
|
+
'client.follower_timeout', retryable: true }`. Calls then reject immediately.
|
|
115
|
+
A later announcement rebinds the same handle; a granted Web Lock promotes it.
|
|
116
|
+
An unreachable `BroadcastChannel` is never treated as evidence that the lock
|
|
117
|
+
owner is stale, so it never authorizes a second worker or database owner.
|
|
118
|
+
|
|
108
119
|
**Presence semantics — one device, one peer.** All tabs share the leader's
|
|
109
120
|
single connection, so a device is exactly ONE presence peer collectively:
|
|
110
121
|
identity is `(actorId, leaderClientId)`. A follower's `setPresence`
|
|
@@ -112,8 +123,61 @@ forwards to the leader's single publisher; there is no per-tab presence
|
|
|
112
123
|
peer. This is the honest model — the wire only ever sees one connection per
|
|
113
124
|
device.
|
|
114
125
|
|
|
126
|
+
The default is a **shared replica**: same-origin tabs must use the same
|
|
127
|
+
persistent database name, lock name, and derived channel name. For an embed,
|
|
128
|
+
preview, or history entry that is intentionally independent, derive the whole
|
|
129
|
+
ownership tuple from one stable identity:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
const preview = await createSyncClientHandle({
|
|
133
|
+
worker,
|
|
134
|
+
schema,
|
|
135
|
+
database: { mode: 'persistent', name: 'medical' },
|
|
136
|
+
endpoints,
|
|
137
|
+
replica: { mode: 'isolated', id: 'preview-42' },
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This derives a distinct database name and pool directory, Web Lock name, and
|
|
142
|
+
`BroadcastChannel` name together. `isolatedReplicaNames()` exposes the same
|
|
143
|
+
deterministic tuple for diagnostics and host integration. Replica IDs are
|
|
144
|
+
stable code-like values (`A-Z`, `a-z`, `0-9`, dot, underscore, dash).
|
|
145
|
+
|
|
146
|
+
During Vite development, retain the React client resource only while its
|
|
147
|
+
captured generated schema version matches. The
|
|
148
|
+
[schema-aware Vite guide](https://syncular.dev/guide-vite/) uses
|
|
149
|
+
`retainViteSyncClientResource` to close the old worker before constructing a
|
|
150
|
+
schema-bump replacement; hot-reloading query code alone does not migrate the
|
|
151
|
+
worker-owned database.
|
|
152
|
+
|
|
115
153
|
Set `multiTab: false` to opt out. A losing tab then becomes an
|
|
116
|
-
`isLeader === false` handle whose calls reject with `client.not_leader`.
|
|
154
|
+
`isLeader === false` handle whose calls reject with `client.not_leader`. This
|
|
155
|
+
does not solve a coordination-partition mismatch by itself: an independent
|
|
156
|
+
instance must also use an isolated database and lock identity. Changing only
|
|
157
|
+
the channel, lock, or database name is unsafe or ineffective.
|
|
158
|
+
|
|
159
|
+
## React availability guard
|
|
160
|
+
|
|
161
|
+
The worker handle's schema and leadership snapshots feed the same public React
|
|
162
|
+
boundary as native clients. Guard the application once instead of parsing
|
|
163
|
+
errors or inspecting generated schema modules:
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
<SyncProvider
|
|
167
|
+
client={clientResource}
|
|
168
|
+
renderBoundary={(state, actions) => (
|
|
169
|
+
<SyncBlockedScreen state={state} onRetry={actions.retry} />
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
<App />
|
|
173
|
+
</SyncProvider>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The state is a discriminated union covering startup, migration,
|
|
177
|
+
`client-upgrade-required`, `server-behind`, `incompatible-schema`, and
|
|
178
|
+
`leader-unreachable`. Recovery changes the same handle/provider back to its
|
|
179
|
+
children; a blocked live query has `phase === 'blocked'`, never an indefinite
|
|
180
|
+
loading state.
|
|
117
181
|
|
|
118
182
|
## Durable commit outcomes
|
|
119
183
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SyncStatusSnapshot } from './invalidation.js';
|
|
2
|
+
import type { LeadershipState } from './multi-tab.js';
|
|
3
|
+
export type SyncAvailability = {
|
|
4
|
+
readonly state: 'ready';
|
|
5
|
+
} | {
|
|
6
|
+
readonly state: 'migrating';
|
|
7
|
+
readonly currentSchemaVersion: number;
|
|
8
|
+
} | {
|
|
9
|
+
readonly state: 'blocked';
|
|
10
|
+
readonly reason: 'client-upgrade-required' | 'server-behind' | 'incompatible-schema' | 'leader-unreachable';
|
|
11
|
+
readonly currentSchemaVersion: number;
|
|
12
|
+
readonly requiredSchemaVersion?: number;
|
|
13
|
+
readonly latestServerSchemaVersion?: number;
|
|
14
|
+
readonly retryable: boolean;
|
|
15
|
+
};
|
|
16
|
+
/** Classify schema and browser-ownership state without parsing diagnostics. */
|
|
17
|
+
export declare function classifySyncAvailability(status: SyncStatusSnapshot, leadership?: LeadershipState): SyncAvailability;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Classify schema and browser-ownership state without parsing diagnostics. */
|
|
2
|
+
export function classifySyncAvailability(status, leadership) {
|
|
3
|
+
const currentSchemaVersion = status.currentSchemaVersion;
|
|
4
|
+
if (leadership?.state === 'blocked') {
|
|
5
|
+
return {
|
|
6
|
+
state: 'blocked',
|
|
7
|
+
reason: 'leader-unreachable',
|
|
8
|
+
currentSchemaVersion,
|
|
9
|
+
retryable: true,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const required = status.schemaFloor?.requiredSchemaVersion;
|
|
13
|
+
const latest = status.schemaFloor?.latestSchemaVersion;
|
|
14
|
+
if (required !== undefined && required > currentSchemaVersion) {
|
|
15
|
+
return {
|
|
16
|
+
state: 'blocked',
|
|
17
|
+
reason: 'client-upgrade-required',
|
|
18
|
+
currentSchemaVersion,
|
|
19
|
+
requiredSchemaVersion: required,
|
|
20
|
+
...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
|
|
21
|
+
retryable: false,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (latest !== undefined && latest < currentSchemaVersion) {
|
|
25
|
+
return {
|
|
26
|
+
state: 'blocked',
|
|
27
|
+
reason: 'server-behind',
|
|
28
|
+
currentSchemaVersion,
|
|
29
|
+
...(required !== undefined ? { requiredSchemaVersion: required } : {}),
|
|
30
|
+
latestServerSchemaVersion: latest,
|
|
31
|
+
retryable: false,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (status.schemaFloor !== undefined) {
|
|
35
|
+
return {
|
|
36
|
+
state: 'blocked',
|
|
37
|
+
reason: 'incompatible-schema',
|
|
38
|
+
currentSchemaVersion,
|
|
39
|
+
...(required !== undefined ? { requiredSchemaVersion: required } : {}),
|
|
40
|
+
...(latest !== undefined ? { latestServerSchemaVersion: latest } : {}),
|
|
41
|
+
retryable: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (status.upgrading) {
|
|
45
|
+
return { state: 'migrating', currentSchemaVersion };
|
|
46
|
+
}
|
|
47
|
+
return { state: 'ready' };
|
|
48
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -181,9 +181,7 @@ export class SyncClient {
|
|
|
181
181
|
this.#detectAndResetSchema();
|
|
182
182
|
// A registration remains app intent only while its table exists in the
|
|
183
183
|
// running schema. Removed-table registrations would poison every pull.
|
|
184
|
-
this.#db
|
|
185
|
-
pruneUnknownSubscriptions(this.#db, new Set(this.#schema.tables.keys()));
|
|
186
|
-
});
|
|
184
|
+
const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), new Set(this.#schema.tables.keys()));
|
|
187
185
|
this.#started = true;
|
|
188
186
|
// A persisted active subscription needs one catch-up round on every open:
|
|
189
187
|
// realtime only covers changes after the socket connects, and an
|
|
@@ -193,7 +191,7 @@ export class SyncClient {
|
|
|
193
191
|
// an application-issued sync() call.
|
|
194
192
|
const startupWork = this.#schemaFloor === undefined &&
|
|
195
193
|
(listOutbox(this.#db).length > 0 ||
|
|
196
|
-
|
|
194
|
+
subscriptions.some((sub) => sub.status === 'active'));
|
|
197
195
|
if (startupWork) {
|
|
198
196
|
this.#needsPull = true;
|
|
199
197
|
this.#config.onSyncNeeded?.('startup');
|
|
@@ -393,6 +391,7 @@ export class SyncClient {
|
|
|
393
391
|
}
|
|
394
392
|
#statusSnapshot() {
|
|
395
393
|
return {
|
|
394
|
+
currentSchemaVersion: this.#config.schema.version,
|
|
396
395
|
outbox: listOutbox(this.#db).length,
|
|
397
396
|
upgrading: this.#upgrading,
|
|
398
397
|
leaseState: this.#leaseState,
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/invalidation.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface WindowChange {
|
|
|
20
20
|
readonly units: ReadonlySet<string>;
|
|
21
21
|
}
|
|
22
22
|
export interface SyncStatusSnapshot {
|
|
23
|
+
readonly currentSchemaVersion: number;
|
|
23
24
|
readonly outbox: number;
|
|
24
25
|
readonly upgrading: boolean;
|
|
25
26
|
readonly leaseState: LeaseState | undefined;
|
package/dist/multi-tab.d.ts
CHANGED
|
@@ -5,6 +5,22 @@ export declare const FOLLOWER_TIMEOUT_CODE = "client.follower_timeout";
|
|
|
5
5
|
export declare const DEFAULT_FOLLOWER_CALL_TIMEOUT_MS = 10000;
|
|
6
6
|
/** Max follower calls queued across a handover before we fail loudly. */
|
|
7
7
|
export declare const DEFAULT_FOLLOWER_QUEUE_LIMIT = 256;
|
|
8
|
+
export type LeadershipState = {
|
|
9
|
+
readonly state: 'leader';
|
|
10
|
+
readonly clientId: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly state: 'follower';
|
|
13
|
+
readonly leaderClientId: string;
|
|
14
|
+
readonly epoch: number;
|
|
15
|
+
} | {
|
|
16
|
+
readonly state: 'waiting';
|
|
17
|
+
readonly reason: 'handover' | 'leader-announcement';
|
|
18
|
+
} | {
|
|
19
|
+
readonly state: 'blocked';
|
|
20
|
+
readonly reason: 'leader-unreachable';
|
|
21
|
+
readonly code: typeof FOLLOWER_TIMEOUT_CODE;
|
|
22
|
+
readonly retryable: true;
|
|
23
|
+
};
|
|
8
24
|
interface HelloMessage {
|
|
9
25
|
readonly t: 'hello';
|
|
10
26
|
readonly fromId: string;
|
|
@@ -16,6 +32,10 @@ interface ByeMessage {
|
|
|
16
32
|
readonly t: 'bye';
|
|
17
33
|
readonly fromId: string;
|
|
18
34
|
}
|
|
35
|
+
interface BoundMessage {
|
|
36
|
+
readonly t: 'bound';
|
|
37
|
+
readonly fromId: string;
|
|
38
|
+
}
|
|
19
39
|
interface ReqMessage {
|
|
20
40
|
readonly t: 'req';
|
|
21
41
|
readonly epoch: number;
|
|
@@ -46,7 +66,7 @@ interface EventMessage {
|
|
|
46
66
|
readonly epoch: number;
|
|
47
67
|
readonly event: SyncWorkerEvent;
|
|
48
68
|
}
|
|
49
|
-
export type MultiTabMessage = HelloMessage | ByeMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
|
|
69
|
+
export type MultiTabMessage = HelloMessage | ByeMessage | BoundMessage | ReqMessage | AnnounceMessage | ResMessage | EventMessage;
|
|
50
70
|
/**
|
|
51
71
|
* The tiny cross-tab channel surface we depend on — the DOM `BroadcastChannel`
|
|
52
72
|
* satisfies it. Injectable so bun tests can pair two instances by name.
|
|
@@ -80,6 +100,7 @@ export declare class LeaderBridge {
|
|
|
80
100
|
epoch: number;
|
|
81
101
|
clientId: string;
|
|
82
102
|
invoke: (method: string, args: readonly unknown[]) => Promise<unknown>;
|
|
103
|
+
heartbeatMs?: number;
|
|
83
104
|
});
|
|
84
105
|
/** Re-announce leadership (on promotion and on a follower `hello`). */
|
|
85
106
|
announce(): void;
|
|
@@ -101,12 +122,14 @@ export declare class FollowerLink {
|
|
|
101
122
|
fromId: string;
|
|
102
123
|
onEvent: (event: SyncWorkerEvent) => void;
|
|
103
124
|
onLeaderChange: (clientId: string) => void;
|
|
125
|
+
onStateChange?: (state: LeadershipState) => void;
|
|
104
126
|
callTimeoutMs?: number;
|
|
105
127
|
queueLimit?: number;
|
|
106
128
|
});
|
|
107
129
|
get epoch(): number;
|
|
108
130
|
get maxEpochSeen(): number;
|
|
109
131
|
get leaderClientId(): string;
|
|
132
|
+
get leadershipState(): LeadershipState;
|
|
110
133
|
/** Whether a leader is currently bound (an announce has been heard). */
|
|
111
134
|
get bound(): boolean;
|
|
112
135
|
/**
|
package/dist/multi-tab.js
CHANGED
|
@@ -73,12 +73,18 @@ export class LeaderBridge {
|
|
|
73
73
|
#clientId;
|
|
74
74
|
#invoke;
|
|
75
75
|
#onMessage;
|
|
76
|
+
#heartbeatMs;
|
|
77
|
+
#followers = new Set();
|
|
78
|
+
#heartbeat;
|
|
76
79
|
#closed = false;
|
|
77
80
|
constructor(options) {
|
|
78
81
|
this.#channel = options.channel;
|
|
79
82
|
this.#epoch = options.epoch;
|
|
80
83
|
this.#clientId = options.clientId;
|
|
81
84
|
this.#invoke = options.invoke;
|
|
85
|
+
this.#heartbeatMs =
|
|
86
|
+
options.heartbeatMs ??
|
|
87
|
+
Math.max(50, Math.floor(DEFAULT_FOLLOWER_CALL_TIMEOUT_MS / 3));
|
|
82
88
|
this.#onMessage = (event) => this.#handle(event.data);
|
|
83
89
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
84
90
|
this.announce();
|
|
@@ -104,9 +110,22 @@ export class LeaderBridge {
|
|
|
104
110
|
return;
|
|
105
111
|
if (message.t === 'hello') {
|
|
106
112
|
// A follower joined (or is contesting) — tell it who leads.
|
|
113
|
+
this.#trackFollower(message.fromId);
|
|
107
114
|
this.announce();
|
|
108
115
|
return;
|
|
109
116
|
}
|
|
117
|
+
if (message.t === 'bound') {
|
|
118
|
+
this.#trackFollower(message.fromId);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (message.t === 'bye') {
|
|
122
|
+
this.#followers.delete(message.fromId);
|
|
123
|
+
if (this.#followers.size === 0 && this.#heartbeat !== undefined) {
|
|
124
|
+
clearInterval(this.#heartbeat);
|
|
125
|
+
this.#heartbeat = undefined;
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
110
129
|
if (message.t !== 'req')
|
|
111
130
|
return;
|
|
112
131
|
// Ignore requests stamped for a different (dead/older) leader; that
|
|
@@ -143,11 +162,21 @@ export class LeaderBridge {
|
|
|
143
162
|
});
|
|
144
163
|
});
|
|
145
164
|
}
|
|
165
|
+
#trackFollower(fromId) {
|
|
166
|
+
this.#followers.add(fromId);
|
|
167
|
+
if (this.#heartbeat !== undefined)
|
|
168
|
+
return;
|
|
169
|
+
this.#heartbeat = setInterval(() => this.announce(), this.#heartbeatMs);
|
|
170
|
+
}
|
|
146
171
|
close() {
|
|
147
172
|
if (this.#closed)
|
|
148
173
|
return;
|
|
149
174
|
this.#closed = true;
|
|
175
|
+
if (this.#heartbeat !== undefined)
|
|
176
|
+
clearInterval(this.#heartbeat);
|
|
177
|
+
this.#heartbeat = undefined;
|
|
150
178
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
179
|
+
this.#channel.close();
|
|
151
180
|
}
|
|
152
181
|
}
|
|
153
182
|
/**
|
|
@@ -162,6 +191,7 @@ export class FollowerLink {
|
|
|
162
191
|
#fromId;
|
|
163
192
|
#onEvent;
|
|
164
193
|
#onLeaderChange;
|
|
194
|
+
#onStateChange;
|
|
165
195
|
#callTimeoutMs;
|
|
166
196
|
#queueLimit;
|
|
167
197
|
#onMessage;
|
|
@@ -174,6 +204,12 @@ export class FollowerLink {
|
|
|
174
204
|
#inFlight = new Map();
|
|
175
205
|
#queue = [];
|
|
176
206
|
#closed = false;
|
|
207
|
+
#state = {
|
|
208
|
+
state: 'waiting',
|
|
209
|
+
reason: 'leader-announcement',
|
|
210
|
+
};
|
|
211
|
+
#waitingTimer;
|
|
212
|
+
#blockedTimer;
|
|
177
213
|
/** Resolvers waiting for the first `announce` to bind a leader. */
|
|
178
214
|
#bindWaiters = [];
|
|
179
215
|
constructor(options) {
|
|
@@ -181,6 +217,7 @@ export class FollowerLink {
|
|
|
181
217
|
this.#fromId = options.fromId;
|
|
182
218
|
this.#onEvent = options.onEvent;
|
|
183
219
|
this.#onLeaderChange = options.onLeaderChange;
|
|
220
|
+
this.#onStateChange = options.onStateChange ?? (() => { });
|
|
184
221
|
this.#callTimeoutMs =
|
|
185
222
|
options.callTimeoutMs ?? DEFAULT_FOLLOWER_CALL_TIMEOUT_MS;
|
|
186
223
|
this.#queueLimit = options.queueLimit ?? DEFAULT_FOLLOWER_QUEUE_LIMIT;
|
|
@@ -188,6 +225,7 @@ export class FollowerLink {
|
|
|
188
225
|
this.#channel.addEventListener('message', this.#onMessage);
|
|
189
226
|
// Ask the current leader to announce itself.
|
|
190
227
|
this.#channel.postMessage({ t: 'hello', fromId: this.#fromId });
|
|
228
|
+
this.#armUnboundDeadline();
|
|
191
229
|
}
|
|
192
230
|
get epoch() {
|
|
193
231
|
return this.#epoch;
|
|
@@ -198,6 +236,9 @@ export class FollowerLink {
|
|
|
198
236
|
get leaderClientId() {
|
|
199
237
|
return this.#leaderClientId;
|
|
200
238
|
}
|
|
239
|
+
get leadershipState() {
|
|
240
|
+
return this.#state;
|
|
241
|
+
}
|
|
201
242
|
/** Whether a leader is currently bound (an announce has been heard). */
|
|
202
243
|
get bound() {
|
|
203
244
|
return this.#epoch >= 0;
|
|
@@ -221,7 +262,8 @@ export class FollowerLink {
|
|
|
221
262
|
return new Promise((resolve, reject) => {
|
|
222
263
|
const timer = setTimeout(() => {
|
|
223
264
|
this.#dropBindWaiter(settle);
|
|
224
|
-
|
|
265
|
+
this.#setBlocked();
|
|
266
|
+
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader announced within the follower bind timeout', true));
|
|
225
267
|
}, timeoutMs);
|
|
226
268
|
const settle = () => {
|
|
227
269
|
clearTimeout(timer);
|
|
@@ -248,6 +290,9 @@ export class FollowerLink {
|
|
|
248
290
|
if (this.#closed) {
|
|
249
291
|
return Promise.reject(new ClientSyncError(WORKER_FAILED_CODE, 'the follower link is closed'));
|
|
250
292
|
}
|
|
293
|
+
if (this.#state.state === 'blocked') {
|
|
294
|
+
return Promise.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the follower cannot reach the tab that owns the database', true));
|
|
295
|
+
}
|
|
251
296
|
return new Promise((resolve, reject) => {
|
|
252
297
|
const queued = {
|
|
253
298
|
method,
|
|
@@ -259,12 +304,14 @@ export class FollowerLink {
|
|
|
259
304
|
if (this.#epoch < 0) {
|
|
260
305
|
// No leader bound yet — queue with a deadline so we never hang.
|
|
261
306
|
if (this.#queue.length >= this.#queueLimit) {
|
|
262
|
-
|
|
307
|
+
this.#setBlocked();
|
|
308
|
+
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'follower call queue overflow while awaiting a leader', true));
|
|
263
309
|
return;
|
|
264
310
|
}
|
|
265
311
|
queued.timer = setTimeout(() => {
|
|
266
312
|
this.#dropQueued(queued);
|
|
267
|
-
|
|
313
|
+
this.#setBlocked();
|
|
314
|
+
reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'no leader answered within the follower call timeout', true));
|
|
268
315
|
}, this.#callTimeoutMs);
|
|
269
316
|
this.#queue.push(queued);
|
|
270
317
|
return;
|
|
@@ -279,7 +326,8 @@ export class FollowerLink {
|
|
|
279
326
|
reject: queued.reject,
|
|
280
327
|
timer: setTimeout(() => {
|
|
281
328
|
this.#inFlight.delete(reqId);
|
|
282
|
-
|
|
329
|
+
this.#setBlocked();
|
|
330
|
+
queued.reject(new ClientSyncError(FOLLOWER_TIMEOUT_CODE, 'the leader did not answer within the follower call timeout', true));
|
|
283
331
|
}, this.#callTimeoutMs),
|
|
284
332
|
};
|
|
285
333
|
this.#inFlight.set(reqId, inflight);
|
|
@@ -313,6 +361,15 @@ export class FollowerLink {
|
|
|
313
361
|
this.#leaderClientId = message.clientId;
|
|
314
362
|
if (changed)
|
|
315
363
|
this.#onLeaderChange(message.clientId);
|
|
364
|
+
this.#setState({
|
|
365
|
+
state: 'follower',
|
|
366
|
+
leaderClientId: message.clientId,
|
|
367
|
+
epoch: message.epoch,
|
|
368
|
+
});
|
|
369
|
+
this.#armBoundDeadline();
|
|
370
|
+
if (changed) {
|
|
371
|
+
this.#channel.postMessage({ t: 'bound', fromId: this.#fromId });
|
|
372
|
+
}
|
|
316
373
|
this.#resolveBindWaiters();
|
|
317
374
|
this.#flushQueue();
|
|
318
375
|
return;
|
|
@@ -366,6 +423,8 @@ export class FollowerLink {
|
|
|
366
423
|
return;
|
|
367
424
|
this.#epoch = -1;
|
|
368
425
|
this.#leaderClientId = '';
|
|
426
|
+
this.#setState({ state: 'waiting', reason: 'handover' });
|
|
427
|
+
this.#armUnboundDeadline();
|
|
369
428
|
this.#channel.postMessage({
|
|
370
429
|
t: 'hello',
|
|
371
430
|
fromId: this.#fromId,
|
|
@@ -376,6 +435,7 @@ export class FollowerLink {
|
|
|
376
435
|
if (this.#closed)
|
|
377
436
|
return;
|
|
378
437
|
this.#closed = true;
|
|
438
|
+
this.#clearReachabilityTimers();
|
|
379
439
|
this.#channel.removeEventListener('message', this.#onMessage);
|
|
380
440
|
this.#channel.postMessage({ t: 'bye', fromId: this.#fromId });
|
|
381
441
|
const closedError = new ClientSyncError(WORKER_FAILED_CODE, 'the follower link was closed');
|
|
@@ -396,4 +456,53 @@ export class FollowerLink {
|
|
|
396
456
|
this.#resolveBindWaiters();
|
|
397
457
|
this.#channel.close();
|
|
398
458
|
}
|
|
459
|
+
#setState(state) {
|
|
460
|
+
const previous = this.#state;
|
|
461
|
+
if (previous.state === state.state &&
|
|
462
|
+
(state.state === 'blocked' ||
|
|
463
|
+
(state.state === 'waiting' &&
|
|
464
|
+
previous.state === 'waiting' &&
|
|
465
|
+
previous.reason === state.reason) ||
|
|
466
|
+
(state.state === 'follower' &&
|
|
467
|
+
previous.state === 'follower' &&
|
|
468
|
+
previous.epoch === state.epoch &&
|
|
469
|
+
previous.leaderClientId === state.leaderClientId))) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.#state = state;
|
|
473
|
+
try {
|
|
474
|
+
this.#onStateChange(state);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
// A status listener must never break cross-tab coordination.
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
#setBlocked() {
|
|
481
|
+
this.#clearReachabilityTimers();
|
|
482
|
+
this.#setState({
|
|
483
|
+
state: 'blocked',
|
|
484
|
+
reason: 'leader-unreachable',
|
|
485
|
+
code: FOLLOWER_TIMEOUT_CODE,
|
|
486
|
+
retryable: true,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
#armUnboundDeadline() {
|
|
490
|
+
this.#clearReachabilityTimers();
|
|
491
|
+
this.#blockedTimer = setTimeout(() => this.#setBlocked(), this.#callTimeoutMs);
|
|
492
|
+
}
|
|
493
|
+
#armBoundDeadline() {
|
|
494
|
+
this.#clearReachabilityTimers();
|
|
495
|
+
this.#waitingTimer = setTimeout(() => {
|
|
496
|
+
this.#setState({ state: 'waiting', reason: 'leader-announcement' });
|
|
497
|
+
}, Math.max(1, Math.floor((this.#callTimeoutMs * 2) / 3)));
|
|
498
|
+
this.#blockedTimer = setTimeout(() => this.#setBlocked(), this.#callTimeoutMs);
|
|
499
|
+
}
|
|
500
|
+
#clearReachabilityTimers() {
|
|
501
|
+
if (this.#waitingTimer !== undefined)
|
|
502
|
+
clearTimeout(this.#waitingTimer);
|
|
503
|
+
if (this.#blockedTimer !== undefined)
|
|
504
|
+
clearTimeout(this.#blockedTimer);
|
|
505
|
+
this.#waitingTimer = undefined;
|
|
506
|
+
this.#blockedTimer = undefined;
|
|
507
|
+
}
|
|
399
508
|
}
|
package/dist/reactive-store.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { type SyncAvailability } from './availability.js';
|
|
1
2
|
import type { CommitOutcome, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
2
3
|
import type { SqlValue } from './database.js';
|
|
3
4
|
import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
|
|
5
|
+
import type { LeadershipState } from './multi-tab.js';
|
|
4
6
|
import { type WindowBase } from './window.js';
|
|
5
7
|
export interface QueryDependency {
|
|
6
8
|
readonly table: string;
|
|
@@ -12,21 +14,26 @@ export interface ReactiveQuerySpec<Row> {
|
|
|
12
14
|
readonly params?: readonly SqlValue[];
|
|
13
15
|
readonly dependencies: readonly QueryDependency[];
|
|
14
16
|
readonly coverage?: readonly WindowCoverage[];
|
|
17
|
+
readonly mapRow?: (row: Readonly<Record<string, SqlValue>>) => Row;
|
|
15
18
|
readonly rowKey?: (row: Row) => readonly SqlValue[];
|
|
16
19
|
readonly claimCoverage?: boolean;
|
|
17
20
|
}
|
|
18
|
-
export type LiveQueryPhase = 'loading' | 'partial' | 'ready' | 'error';
|
|
21
|
+
export type LiveQueryPhase = 'loading' | 'partial' | 'ready' | 'blocked' | 'error';
|
|
19
22
|
export interface LiveQueryResult<Row> {
|
|
20
23
|
readonly rows: readonly Row[];
|
|
21
24
|
readonly phase: LiveQueryPhase;
|
|
22
25
|
readonly revision: bigint | undefined;
|
|
23
26
|
readonly error: Error | undefined;
|
|
24
27
|
readonly isRefreshing: boolean;
|
|
28
|
+
readonly availability: SyncAvailability;
|
|
25
29
|
}
|
|
26
30
|
export interface ReactiveQueryClient {
|
|
31
|
+
readonly currentSchemaVersion?: number;
|
|
27
32
|
onChange(listener: ClientChangeListener): () => void;
|
|
28
33
|
querySnapshot<Row = Record<string, SqlValue>>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
29
34
|
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
35
|
+
leadershipSnapshot?(): LeadershipState | undefined;
|
|
36
|
+
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
30
37
|
readonly conflicts: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
31
38
|
readonly rejections: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
32
39
|
commitOutcomes(): readonly CommitOutcome[] | Promise<readonly CommitOutcome[]>;
|
|
@@ -46,6 +53,7 @@ export interface WindowRetention {
|
|
|
46
53
|
}
|
|
47
54
|
export interface StatusStoreSnapshot {
|
|
48
55
|
readonly status: SyncStatusSnapshot | undefined;
|
|
56
|
+
readonly leadership: LeadershipState | undefined;
|
|
49
57
|
readonly error: Error | undefined;
|
|
50
58
|
readonly isLoading: boolean;
|
|
51
59
|
}
|
|
@@ -70,6 +78,7 @@ export declare class ReactiveClientStore {
|
|
|
70
78
|
readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
|
|
71
79
|
constructor(client: ReactiveQueryClient);
|
|
72
80
|
query<Row>(spec: ReactiveQuerySpec<Row>): ExternalStoreEntry<LiveQueryResult<Row>>;
|
|
81
|
+
availabilitySnapshot(): SyncAvailability;
|
|
73
82
|
/** Retain a composable window working set outside React. The returned
|
|
74
83
|
* handle exposes registration completion and releases only this owner. */
|
|
75
84
|
retainWindow(base: WindowBase, units: readonly string[]): WindowRetention;
|