@syncular/client 0.15.47 → 0.16.1
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 +59 -0
- package/dist/bun-database.d.ts +5 -0
- package/dist/bun-database.js +5 -0
- package/dist/client.d.ts +17 -20
- package/dist/client.js +152 -60
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/outbox.d.ts +5 -1
- package/dist/outbox.js +26 -3
- package/dist/reactive-store.d.ts +12 -6
- package/dist/reactive-store.js +219 -45
- package/dist/realtime-supervisor.d.ts +1 -1
- package/dist/realtime-supervisor.js +2 -8
- package/dist/remote.d.ts +2 -0
- package/dist/remote.js +10 -2
- package/dist/sync-scheduler.d.ts +23 -0
- package/dist/sync-scheduler.js +122 -0
- package/dist/window.d.ts +5 -0
- package/dist/window.js +39 -0
- package/dist/worker-entry.js +8 -19
- package/dist/worker-host.d.ts +4 -7
- package/dist/worker-host.js +3 -15
- package/dist/worker-protocol.d.ts +4 -18
- package/package.json +3 -11
- package/src/bun-database.ts +11 -0
- package/src/client.ts +203 -63
- package/src/index.ts +1 -0
- package/src/outbox.ts +39 -8
- package/src/reactive-store.ts +226 -56
- package/src/realtime-supervisor.ts +2 -11
- package/src/remote.ts +11 -2
- package/src/sync-scheduler.ts +154 -0
- package/src/window.ts +65 -0
- package/src/worker-entry.ts +10 -21
- package/src/worker-host.ts +5 -22
- package/src/worker-protocol.ts +7 -33
- package/dist/realtime-supervisor-observation.d.ts +0 -8
- package/dist/realtime-supervisor-observation.js +0 -15
- package/src/realtime-supervisor-observation.ts +0 -21
package/dist/outbox.js
CHANGED
|
@@ -26,17 +26,40 @@ export function appendOutboxCommit(db, clientCommitId, operations, nowMs, before
|
|
|
26
26
|
]);
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
/** Pending commits in FIFO creation order (§7.1). */
|
|
29
|
+
/** Pending commits in FIFO creation order (§7.1). Full reads serve replay and the public listing. */
|
|
30
30
|
export function listOutbox(db) {
|
|
31
31
|
return db
|
|
32
32
|
.query(`SELECT seq, client_commit_id, created_at_ms, operations
|
|
33
33
|
FROM _syncular_outbox ORDER BY seq ASC`)
|
|
34
|
-
.map(
|
|
34
|
+
.map(decodeOutboxRow);
|
|
35
|
+
}
|
|
36
|
+
function decodeOutboxRow(row) {
|
|
37
|
+
return {
|
|
35
38
|
seq: row.seq,
|
|
36
39
|
clientCommitId: row.client_commit_id,
|
|
37
40
|
createdAtMs: row.created_at_ms,
|
|
38
41
|
operations: JSON.parse(row.operations),
|
|
39
|
-
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Keyset pages bound staging; laziness decodes only commits consumed by the encoder. */
|
|
45
|
+
export function* iterateOutbox(db, throughSeq) {
|
|
46
|
+
let afterSeq = 0;
|
|
47
|
+
while (afterSeq < throughSeq) {
|
|
48
|
+
const rows = db.query(`SELECT seq, client_commit_id, created_at_ms, operations FROM _syncular_outbox
|
|
49
|
+
WHERE seq > ? AND seq <= ? ORDER BY seq ASC LIMIT 32`, [afterSeq, throughSeq]);
|
|
50
|
+
if (rows.length === 0)
|
|
51
|
+
return;
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const commit = decodeOutboxRow(row);
|
|
54
|
+
afterSeq = commit.seq;
|
|
55
|
+
yield commit;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Routine status reads never load operation bodies. */
|
|
60
|
+
export function countOutbox(db) {
|
|
61
|
+
return db.query('SELECT COUNT(*) AS count FROM _syncular_outbox')[0]
|
|
62
|
+
.count;
|
|
40
63
|
}
|
|
41
64
|
export function deleteOutboxCommit(db, clientCommitId) {
|
|
42
65
|
db.exec('DELETE FROM _syncular_outbox_before_images WHERE client_commit_id = ?', [clientCommitId]);
|
package/dist/reactive-store.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type SyncAvailability } from './availability.js';
|
|
2
|
-
import type { CommitOutcome, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
2
|
+
import type { CommitOutcome, ClientSnapshotReader, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
3
3
|
import type { SqlValue } from './database.js';
|
|
4
4
|
import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
|
|
5
5
|
import type { LeadershipState } from './multi-tab.js';
|
|
@@ -27,16 +27,14 @@ export interface LiveQueryResult<Row> {
|
|
|
27
27
|
readonly isRefreshing: boolean;
|
|
28
28
|
readonly availability: SyncAvailability;
|
|
29
29
|
}
|
|
30
|
-
export interface ReactiveQueryClient {
|
|
30
|
+
export interface ReactiveQueryClient extends Pick<ClientSnapshotReader, 'statusSnapshot' | 'commitOutcomes'> {
|
|
31
31
|
readonly currentSchemaVersion?: number;
|
|
32
32
|
onChange(listener: ClientChangeListener): () => void;
|
|
33
33
|
querySnapshot<Row = Record<string, SqlValue>>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
34
|
-
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
35
34
|
leadershipSnapshot?(): LeadershipState | undefined;
|
|
36
35
|
onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
commitOutcomes(): readonly CommitOutcome[] | Promise<readonly CommitOutcome[]>;
|
|
36
|
+
conflicts(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
37
|
+
rejections(): readonly unknown[] | Promise<readonly unknown[]>;
|
|
40
38
|
setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
|
|
41
39
|
windowState(base: WindowBase): WindowState | Promise<WindowState>;
|
|
42
40
|
}
|
|
@@ -85,6 +83,14 @@ export declare class ReactiveClientStore {
|
|
|
85
83
|
window(base: WindowBase): ExternalStoreEntry<WindowState>;
|
|
86
84
|
setWindowClaim(owner: symbol, base: WindowBase, units: readonly string[]): Promise<void>;
|
|
87
85
|
releaseWindowClaims(owner: symbol): void;
|
|
86
|
+
/** Retained observation counts for diagnostics and resource benchmarks. */
|
|
87
|
+
cacheStats(): {
|
|
88
|
+
queries: number;
|
|
89
|
+
activeQueries: number;
|
|
90
|
+
windows: number;
|
|
91
|
+
activeWindows: number;
|
|
92
|
+
windowClaims: number;
|
|
93
|
+
};
|
|
88
94
|
start(): void;
|
|
89
95
|
dispose(): void;
|
|
90
96
|
}
|
package/dist/reactive-store.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { classifySyncAvailability, } from './availability.js';
|
|
2
|
+
import { ClientSyncError } from './errors.js';
|
|
2
3
|
import { windowBaseKey } from './window.js';
|
|
3
4
|
function errorOf(value) {
|
|
4
5
|
return value instanceof Error ? value : new Error(String(value));
|
|
5
6
|
}
|
|
6
|
-
function readCollection(value) {
|
|
7
|
-
return typeof value === 'function' ? value() : value;
|
|
8
|
-
}
|
|
9
7
|
function unsupportedCanonicalValue(value) {
|
|
10
8
|
const description = Object.prototype.toString.call(value);
|
|
11
9
|
throw new TypeError(`unsupported reactive cache-key value ${description}; use null, string, finite number, bigint, boolean, bytes, arrays, or plain objects`);
|
|
@@ -178,9 +176,58 @@ function reconcileRows(previous, fresh, rowKey) {
|
|
|
178
176
|
? previous
|
|
179
177
|
: next;
|
|
180
178
|
}
|
|
179
|
+
/** Shared ownership for query and window observations, including abandoned renders. */
|
|
180
|
+
class ObservationCache {
|
|
181
|
+
#entries = new Map();
|
|
182
|
+
#active = new Set();
|
|
183
|
+
get size() {
|
|
184
|
+
return this.#entries.size;
|
|
185
|
+
}
|
|
186
|
+
get activeSize() {
|
|
187
|
+
return this.#active.size;
|
|
188
|
+
}
|
|
189
|
+
get(key) {
|
|
190
|
+
return this.#entries.get(key);
|
|
191
|
+
}
|
|
192
|
+
add(key, entry) {
|
|
193
|
+
this.#entries.set(key, entry);
|
|
194
|
+
this.#cleanup(key, entry);
|
|
195
|
+
}
|
|
196
|
+
activate(key, candidate) {
|
|
197
|
+
const entry = this.#entries.get(key) ?? candidate;
|
|
198
|
+
this.#entries.set(key, entry);
|
|
199
|
+
this.#active.add(entry);
|
|
200
|
+
return entry;
|
|
201
|
+
}
|
|
202
|
+
deactivate(key, entry) {
|
|
203
|
+
this.#active.delete(entry);
|
|
204
|
+
this.#cleanup(key, entry);
|
|
205
|
+
}
|
|
206
|
+
#cleanup(key, entry) {
|
|
207
|
+
scheduleMicrotask(() => {
|
|
208
|
+
if (this.#active.has(entry))
|
|
209
|
+
return;
|
|
210
|
+
if (this.#entries.get(key) === entry)
|
|
211
|
+
this.#entries.delete(key);
|
|
212
|
+
entry.reset();
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
onChange(batch) {
|
|
216
|
+
for (const entry of this.#active)
|
|
217
|
+
entry.onChange(batch);
|
|
218
|
+
}
|
|
219
|
+
clear() {
|
|
220
|
+
for (const entry of this.#entries.values())
|
|
221
|
+
entry.dispose();
|
|
222
|
+
this.#entries.clear();
|
|
223
|
+
this.#active.clear();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
181
226
|
class QueryEntry {
|
|
182
227
|
store;
|
|
183
228
|
spec;
|
|
229
|
+
key;
|
|
230
|
+
cache;
|
|
184
231
|
#owner = Symbol('query-window-claim');
|
|
185
232
|
#listeners = new Set();
|
|
186
233
|
#state = {
|
|
@@ -191,22 +238,31 @@ class QueryEntry {
|
|
|
191
238
|
isRefreshing: false,
|
|
192
239
|
availability: { state: 'ready' },
|
|
193
240
|
};
|
|
194
|
-
#
|
|
241
|
+
#delegate;
|
|
242
|
+
#generation = 0;
|
|
195
243
|
#scheduled = false;
|
|
196
244
|
#running = false;
|
|
197
245
|
#requested = false;
|
|
198
246
|
#desiredRevision = 0n;
|
|
199
247
|
#claimReady = Promise.resolve();
|
|
200
248
|
#offStatus;
|
|
201
|
-
constructor(store, spec) {
|
|
249
|
+
constructor(store, spec, key, cache) {
|
|
202
250
|
this.store = store;
|
|
203
251
|
this.spec = spec;
|
|
252
|
+
this.key = key;
|
|
253
|
+
this.cache = cache;
|
|
204
254
|
}
|
|
205
|
-
getSnapshot = () => this.#state;
|
|
255
|
+
getSnapshot = () => this.#delegate?.getSnapshot() ?? this.#state;
|
|
206
256
|
subscribe = (listener) => {
|
|
207
|
-
this
|
|
208
|
-
this
|
|
209
|
-
|
|
257
|
+
const current = this.cache.activate(this.key, this);
|
|
258
|
+
if (current !== this) {
|
|
259
|
+
this.#delegate = current;
|
|
260
|
+
return this.#delegate.subscribe(listener);
|
|
261
|
+
}
|
|
262
|
+
this.#delegate = undefined;
|
|
263
|
+
const notify = () => listener();
|
|
264
|
+
this.#listeners.add(notify);
|
|
265
|
+
if (this.#listeners.size === 1) {
|
|
210
266
|
this.#offStatus = this.store.status.subscribe(() => this.#onAvailabilityChange());
|
|
211
267
|
this.#onAvailabilityChange();
|
|
212
268
|
if (this.spec.claimCoverage !== false) {
|
|
@@ -215,28 +271,59 @@ class QueryEntry {
|
|
|
215
271
|
claims.push(this.store.setWindowClaim(this.#owner, coverage.base, coverage.units));
|
|
216
272
|
}
|
|
217
273
|
this.#claimReady = Promise.all(claims).then(() => undefined);
|
|
274
|
+
// A render can lose its last subscriber before the read loop starts.
|
|
275
|
+
// The loop still observes the original rejection while it owns the claim.
|
|
276
|
+
void this.#claimReady.catch(() => undefined);
|
|
218
277
|
}
|
|
219
278
|
this.#requestRead();
|
|
220
279
|
}
|
|
221
280
|
return () => {
|
|
222
|
-
if (!this.#listeners.delete(
|
|
281
|
+
if (!this.#listeners.delete(notify))
|
|
223
282
|
return;
|
|
224
|
-
this.#
|
|
225
|
-
|
|
283
|
+
if (this.#listeners.size === 0) {
|
|
284
|
+
this.cache.deactivate(this.key, this);
|
|
226
285
|
this.#offStatus?.();
|
|
227
286
|
this.#offStatus = undefined;
|
|
228
287
|
this.store.releaseWindowClaims(this.#owner);
|
|
229
288
|
}
|
|
230
289
|
};
|
|
231
290
|
};
|
|
232
|
-
refresh = () =>
|
|
291
|
+
refresh = () => {
|
|
292
|
+
if (this.#delegate !== undefined)
|
|
293
|
+
this.#delegate.refresh();
|
|
294
|
+
else
|
|
295
|
+
this.#requestRead(true);
|
|
296
|
+
};
|
|
297
|
+
reset() {
|
|
298
|
+
this.#generation += 1;
|
|
299
|
+
this.#scheduled = false;
|
|
300
|
+
this.#running = false;
|
|
301
|
+
this.#requested = false;
|
|
302
|
+
this.#desiredRevision = 0n;
|
|
303
|
+
this.#claimReady = Promise.resolve();
|
|
304
|
+
this.#state = {
|
|
305
|
+
rows: [],
|
|
306
|
+
phase: 'loading',
|
|
307
|
+
revision: undefined,
|
|
308
|
+
error: undefined,
|
|
309
|
+
isRefreshing: false,
|
|
310
|
+
availability: { state: 'ready' },
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
dispose() {
|
|
314
|
+
this.#listeners.clear();
|
|
315
|
+
this.#offStatus?.();
|
|
316
|
+
this.#offStatus = undefined;
|
|
317
|
+
this.store.releaseWindowClaims(this.#owner);
|
|
318
|
+
this.reset();
|
|
319
|
+
}
|
|
233
320
|
onChange(batch) {
|
|
234
321
|
if (!batchMatches(batch, this.spec))
|
|
235
322
|
return;
|
|
236
323
|
if (batch.revision > this.#desiredRevision) {
|
|
237
324
|
this.#desiredRevision = batch.revision;
|
|
238
325
|
}
|
|
239
|
-
if (this.#
|
|
326
|
+
if (this.#listeners.size > 0)
|
|
240
327
|
this.#requestRead();
|
|
241
328
|
}
|
|
242
329
|
#publish(next) {
|
|
@@ -253,6 +340,8 @@ class QueryEntry {
|
|
|
253
340
|
listener();
|
|
254
341
|
}
|
|
255
342
|
#requestRead(refreshing = false) {
|
|
343
|
+
if (this.#listeners.size === 0)
|
|
344
|
+
return;
|
|
256
345
|
this.#requested = true;
|
|
257
346
|
if (refreshing &&
|
|
258
347
|
this.#state.revision !== undefined &&
|
|
@@ -262,7 +351,10 @@ class QueryEntry {
|
|
|
262
351
|
if (this.#scheduled || this.#running)
|
|
263
352
|
return;
|
|
264
353
|
this.#scheduled = true;
|
|
354
|
+
const generation = this.#generation;
|
|
265
355
|
scheduleMicrotask(() => {
|
|
356
|
+
if (generation !== this.#generation)
|
|
357
|
+
return;
|
|
266
358
|
this.#scheduled = false;
|
|
267
359
|
void this.#readLoop();
|
|
268
360
|
});
|
|
@@ -292,8 +384,9 @@ class QueryEntry {
|
|
|
292
384
|
this.#requestRead();
|
|
293
385
|
}
|
|
294
386
|
async #readLoop() {
|
|
295
|
-
if (this.#running || this.#
|
|
387
|
+
if (this.#running || this.#listeners.size === 0)
|
|
296
388
|
return;
|
|
389
|
+
const generation = this.#generation;
|
|
297
390
|
this.#running = true;
|
|
298
391
|
try {
|
|
299
392
|
do {
|
|
@@ -301,6 +394,8 @@ class QueryEntry {
|
|
|
301
394
|
if (this.store.availabilitySnapshot().state === 'blocked')
|
|
302
395
|
break;
|
|
303
396
|
await this.#claimReady;
|
|
397
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
398
|
+
return;
|
|
304
399
|
const snapshot = await this.store.client.querySnapshot({
|
|
305
400
|
sql: this.spec.sql,
|
|
306
401
|
...(this.spec.params !== undefined
|
|
@@ -310,6 +405,8 @@ class QueryEntry {
|
|
|
310
405
|
? { coverage: this.spec.coverage }
|
|
311
406
|
: {}),
|
|
312
407
|
});
|
|
408
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
409
|
+
return;
|
|
313
410
|
const availability = this.store.availabilitySnapshot();
|
|
314
411
|
if (availability.state === 'blocked') {
|
|
315
412
|
this.#publish({
|
|
@@ -341,9 +438,11 @@ class QueryEntry {
|
|
|
341
438
|
isRefreshing: false,
|
|
342
439
|
availability,
|
|
343
440
|
});
|
|
344
|
-
} while (this.#requested && this.#
|
|
441
|
+
} while (this.#requested && this.#listeners.size > 0);
|
|
345
442
|
}
|
|
346
443
|
catch (error) {
|
|
444
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
445
|
+
return;
|
|
347
446
|
const wrapped = errorOf(error);
|
|
348
447
|
this.#publish({
|
|
349
448
|
...this.#state,
|
|
@@ -354,15 +453,18 @@ class QueryEntry {
|
|
|
354
453
|
});
|
|
355
454
|
}
|
|
356
455
|
finally {
|
|
357
|
-
this.#
|
|
358
|
-
|
|
359
|
-
this.#
|
|
456
|
+
if (generation === this.#generation) {
|
|
457
|
+
this.#running = false;
|
|
458
|
+
if (this.#requested && this.#listeners.size > 0)
|
|
459
|
+
this.#requestRead();
|
|
460
|
+
}
|
|
360
461
|
}
|
|
361
462
|
}
|
|
362
463
|
}
|
|
363
464
|
class ValueEntry {
|
|
364
465
|
value;
|
|
365
466
|
read;
|
|
467
|
+
#generation = 0;
|
|
366
468
|
#listeners = new Set();
|
|
367
469
|
constructor(value, read) {
|
|
368
470
|
this.value = value;
|
|
@@ -374,9 +476,17 @@ class ValueEntry {
|
|
|
374
476
|
return () => this.#listeners.delete(listener);
|
|
375
477
|
};
|
|
376
478
|
refresh = () => {
|
|
377
|
-
|
|
479
|
+
const generation = ++this.#generation;
|
|
480
|
+
void this.read().then((next) => {
|
|
481
|
+
if (generation === this.#generation)
|
|
482
|
+
this.set(next);
|
|
483
|
+
});
|
|
378
484
|
};
|
|
485
|
+
invalidate() {
|
|
486
|
+
this.#generation += 1;
|
|
487
|
+
}
|
|
379
488
|
set(next) {
|
|
489
|
+
this.invalidate();
|
|
380
490
|
if (next === this.value)
|
|
381
491
|
return;
|
|
382
492
|
this.value = next;
|
|
@@ -388,28 +498,59 @@ class WindowEntry {
|
|
|
388
498
|
store;
|
|
389
499
|
base;
|
|
390
500
|
baseKey;
|
|
501
|
+
cache;
|
|
391
502
|
#listeners = new Set();
|
|
392
503
|
#state = { units: [], pending: [] };
|
|
504
|
+
#generation = 0;
|
|
505
|
+
#delegate;
|
|
393
506
|
#running = false;
|
|
394
507
|
#requested = false;
|
|
395
|
-
constructor(store, base, baseKey) {
|
|
508
|
+
constructor(store, base, baseKey, cache) {
|
|
396
509
|
this.store = store;
|
|
397
510
|
this.base = base;
|
|
398
511
|
this.baseKey = baseKey;
|
|
512
|
+
this.cache = cache;
|
|
399
513
|
}
|
|
400
|
-
getSnapshot = () => this.#state;
|
|
514
|
+
getSnapshot = () => this.#delegate?.getSnapshot() ?? this.#state;
|
|
401
515
|
subscribe = (listener) => {
|
|
402
|
-
this
|
|
516
|
+
const current = this.cache.activate(this.baseKey, this);
|
|
517
|
+
if (current !== this) {
|
|
518
|
+
this.#delegate = current;
|
|
519
|
+
return this.#delegate.subscribe(listener);
|
|
520
|
+
}
|
|
521
|
+
this.#delegate = undefined;
|
|
522
|
+
const notify = () => listener();
|
|
523
|
+
this.#listeners.add(notify);
|
|
403
524
|
if (this.#listeners.size === 1)
|
|
404
525
|
this.refresh();
|
|
405
|
-
return () =>
|
|
526
|
+
return () => {
|
|
527
|
+
if (this.#listeners.delete(notify) && this.#listeners.size === 0) {
|
|
528
|
+
this.cache.deactivate(this.baseKey, this);
|
|
529
|
+
}
|
|
530
|
+
};
|
|
406
531
|
};
|
|
407
532
|
refresh = () => {
|
|
533
|
+
if (this.#delegate !== undefined) {
|
|
534
|
+
this.#delegate.refresh();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (this.#listeners.size === 0)
|
|
538
|
+
return;
|
|
408
539
|
this.#requested = true;
|
|
409
540
|
if (this.#running)
|
|
410
541
|
return;
|
|
411
542
|
void this.#readLoop();
|
|
412
543
|
};
|
|
544
|
+
reset() {
|
|
545
|
+
this.#generation += 1;
|
|
546
|
+
this.#running = false;
|
|
547
|
+
this.#requested = false;
|
|
548
|
+
this.#state = { units: [], pending: [] };
|
|
549
|
+
}
|
|
550
|
+
dispose() {
|
|
551
|
+
this.#listeners.clear();
|
|
552
|
+
this.reset();
|
|
553
|
+
}
|
|
413
554
|
onChange(batch) {
|
|
414
555
|
if (batch.windows.some((change) => change.baseKey === this.baseKey) &&
|
|
415
556
|
this.#listeners.size > 0) {
|
|
@@ -417,34 +558,39 @@ class WindowEntry {
|
|
|
417
558
|
}
|
|
418
559
|
}
|
|
419
560
|
async #readLoop() {
|
|
561
|
+
const generation = this.#generation;
|
|
420
562
|
this.#running = true;
|
|
421
563
|
try {
|
|
422
564
|
do {
|
|
423
565
|
this.#requested = false;
|
|
424
566
|
const next = await this.store.client.windowState(this.base);
|
|
567
|
+
if (generation !== this.#generation || this.#listeners.size === 0)
|
|
568
|
+
return;
|
|
425
569
|
if (canonicalValue(next.units) !== canonicalValue(this.#state.units) ||
|
|
426
570
|
canonicalValue(next.pending) !== canonicalValue(this.#state.pending)) {
|
|
427
571
|
this.#state = next;
|
|
428
572
|
for (const listener of this.#listeners)
|
|
429
573
|
listener();
|
|
430
574
|
}
|
|
431
|
-
} while (this.#requested);
|
|
575
|
+
} while (this.#requested && this.#listeners.size > 0);
|
|
432
576
|
}
|
|
433
577
|
catch {
|
|
434
578
|
// WindowState predates the error-bearing query result. Keep the last
|
|
435
579
|
// coherent snapshot; a later exact window event or refresh retries.
|
|
436
580
|
}
|
|
437
581
|
finally {
|
|
438
|
-
this.#
|
|
439
|
-
|
|
440
|
-
this
|
|
582
|
+
if (generation === this.#generation) {
|
|
583
|
+
this.#running = false;
|
|
584
|
+
if (this.#requested)
|
|
585
|
+
this.refresh();
|
|
586
|
+
}
|
|
441
587
|
}
|
|
442
588
|
}
|
|
443
589
|
}
|
|
444
590
|
export class ReactiveClientStore {
|
|
445
591
|
client;
|
|
446
|
-
#queries = new
|
|
447
|
-
#windows = new
|
|
592
|
+
#queries = new ObservationCache();
|
|
593
|
+
#windows = new ObservationCache();
|
|
448
594
|
#windowClaims = new Map();
|
|
449
595
|
#offChange;
|
|
450
596
|
#offLeadership;
|
|
@@ -479,8 +625,8 @@ export class ReactiveClientStore {
|
|
|
479
625
|
const conflicts = new ValueEntry({ conflicts: [], rejections: [], error: undefined, isLoading: true }, async () => {
|
|
480
626
|
try {
|
|
481
627
|
const [found, rejected] = await Promise.all([
|
|
482
|
-
|
|
483
|
-
|
|
628
|
+
client.conflicts(),
|
|
629
|
+
client.rejections(),
|
|
484
630
|
]);
|
|
485
631
|
return {
|
|
486
632
|
conflicts: found,
|
|
@@ -513,9 +659,6 @@ export class ReactiveClientStore {
|
|
|
513
659
|
this.status = status;
|
|
514
660
|
this.conflicts = conflicts;
|
|
515
661
|
this.outcomes = outcomes;
|
|
516
|
-
status.refresh();
|
|
517
|
-
conflicts.refresh();
|
|
518
|
-
outcomes.refresh();
|
|
519
662
|
this.start();
|
|
520
663
|
}
|
|
521
664
|
query(spec) {
|
|
@@ -541,8 +684,8 @@ export class ReactiveClientStore {
|
|
|
541
684
|
});
|
|
542
685
|
let entry = this.#queries.get(key);
|
|
543
686
|
if (entry === undefined) {
|
|
544
|
-
entry = new QueryEntry(this, spec);
|
|
545
|
-
this.#queries.
|
|
687
|
+
entry = new QueryEntry(this, spec, key, this.#queries);
|
|
688
|
+
this.#queries.add(key, entry);
|
|
546
689
|
}
|
|
547
690
|
return entry;
|
|
548
691
|
}
|
|
@@ -580,8 +723,8 @@ export class ReactiveClientStore {
|
|
|
580
723
|
const key = windowBaseKey(base);
|
|
581
724
|
let entry = this.#windows.get(key);
|
|
582
725
|
if (entry === undefined) {
|
|
583
|
-
entry = new WindowEntry(this, base, key);
|
|
584
|
-
this.#windows.
|
|
726
|
+
entry = new WindowEntry(this, base, key, this.#windows);
|
|
727
|
+
this.#windows.add(key, entry);
|
|
585
728
|
}
|
|
586
729
|
return entry;
|
|
587
730
|
}
|
|
@@ -624,7 +767,8 @@ export class ReactiveClientStore {
|
|
|
624
767
|
});
|
|
625
768
|
}
|
|
626
769
|
async #flushWindow(group) {
|
|
627
|
-
|
|
770
|
+
const baseKey = windowBaseKey(group.base);
|
|
771
|
+
if (group.running || this.#windowClaims.get(baseKey) !== group)
|
|
628
772
|
return;
|
|
629
773
|
group.running = true;
|
|
630
774
|
try {
|
|
@@ -636,6 +780,8 @@ export class ReactiveClientStore {
|
|
|
636
780
|
const key = canonicalValue(units);
|
|
637
781
|
if (key !== group.appliedKey) {
|
|
638
782
|
await this.client.setWindow(group.base, units);
|
|
783
|
+
if (this.#windowClaims.get(baseKey) !== group)
|
|
784
|
+
return;
|
|
639
785
|
group.appliedKey = key;
|
|
640
786
|
}
|
|
641
787
|
}
|
|
@@ -648,18 +794,33 @@ export class ReactiveClientStore {
|
|
|
648
794
|
}
|
|
649
795
|
finally {
|
|
650
796
|
group.running = false;
|
|
651
|
-
if (group.requested)
|
|
797
|
+
if (group.requested && this.#windowClaims.get(baseKey) === group)
|
|
652
798
|
this.#scheduleWindow(group);
|
|
799
|
+
else if (group.claims.size === 0 &&
|
|
800
|
+
group.waiters.length === 0 &&
|
|
801
|
+
group.appliedKey === canonicalValue([])) {
|
|
802
|
+
const key = windowBaseKey(group.base);
|
|
803
|
+
if (this.#windowClaims.get(key) === group)
|
|
804
|
+
this.#windowClaims.delete(key);
|
|
805
|
+
}
|
|
653
806
|
}
|
|
654
807
|
}
|
|
808
|
+
/** Retained observation counts for diagnostics and resource benchmarks. */
|
|
809
|
+
cacheStats() {
|
|
810
|
+
return {
|
|
811
|
+
queries: this.#queries.size,
|
|
812
|
+
activeQueries: this.#queries.activeSize,
|
|
813
|
+
windows: this.#windows.size,
|
|
814
|
+
activeWindows: this.#windows.activeSize,
|
|
815
|
+
windowClaims: this.#windowClaims.size,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
655
818
|
start() {
|
|
656
819
|
if (this.#offChange !== undefined)
|
|
657
820
|
return;
|
|
658
821
|
this.#offChange = this.client.onChange((batch) => {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
for (const entry of this.#windows.values())
|
|
662
|
-
entry.onChange(batch);
|
|
822
|
+
this.#queries.onChange(batch);
|
|
823
|
+
this.#windows.onChange(batch);
|
|
663
824
|
if (batch.status !== undefined) {
|
|
664
825
|
this.status.set({
|
|
665
826
|
status: batch.status,
|
|
@@ -680,14 +841,27 @@ export class ReactiveClientStore {
|
|
|
680
841
|
...previous,
|
|
681
842
|
leadership,
|
|
682
843
|
});
|
|
844
|
+
if (previous.isLoading)
|
|
845
|
+
this.status.refresh();
|
|
683
846
|
});
|
|
847
|
+
this.status.refresh();
|
|
848
|
+
this.conflicts.refresh();
|
|
849
|
+
this.outcomes.refresh();
|
|
684
850
|
}
|
|
685
851
|
dispose() {
|
|
852
|
+
this.#queries.clear();
|
|
853
|
+
this.#windows.clear();
|
|
854
|
+
for (const entry of [this.status, this.conflicts, this.outcomes]) {
|
|
855
|
+
entry.invalidate();
|
|
856
|
+
}
|
|
686
857
|
this.#offChange?.();
|
|
687
858
|
this.#offChange = undefined;
|
|
688
859
|
this.#offLeadership?.();
|
|
689
860
|
this.#offLeadership = undefined;
|
|
690
861
|
for (const group of this.#windowClaims.values()) {
|
|
862
|
+
for (const waiter of group.waiters.splice(0)) {
|
|
863
|
+
waiter.reject(new ClientSyncError('client.reactive_store_disposed', 'reactive store disposed'));
|
|
864
|
+
}
|
|
691
865
|
// Releasing a window is best-effort teardown. A resource owner may have
|
|
692
866
|
// already closed the underlying worker/native handle before React effect
|
|
693
867
|
// cleanup runs (notably during schema-changing HMR). Do not let that
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { SecurityLifecycle } from './client.js';
|
|
2
2
|
import type { ClientDiagnosticsConnectivity, ClientDiagnosticsListener, ClientDiagnosticsSnapshot } from './diagnostics.js';
|
|
3
|
-
export { linkRealtimeSupervisorObservation } from './realtime-supervisor-observation.js';
|
|
4
3
|
type CancelTimer = () => void;
|
|
5
4
|
export interface RealtimeSupervisorClient {
|
|
6
5
|
connectRealtime(): Promise<void>;
|
|
@@ -91,3 +90,4 @@ export declare class RealtimeSupervisor {
|
|
|
91
90
|
export declare function installRealtimeSupervisor<T extends RealtimeSupervisorClient>(client: T, options?: RealtimeSupervisorOptions): T;
|
|
92
91
|
export declare function realtimeSupervisorSnapshot(client: object): RealtimeSupervisorSnapshot;
|
|
93
92
|
export declare function subscribeRealtimeSupervisor(client: object, listener: () => void): () => void;
|
|
93
|
+
export {};
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { realtimeSupervisorObservationSource } from './realtime-supervisor-observation.js';
|
|
2
|
-
export { linkRealtimeSupervisorObservation } from './realtime-supervisor-observation.js';
|
|
3
1
|
const REALTIME_SUPERVISOR_KEY = Symbol.for('syncular.realtime-supervisor.v1');
|
|
4
2
|
const DEFAULT_INITIAL_DELAY_MS = 1_000;
|
|
5
3
|
const DEFAULT_MAXIMUM_DELAY_MS = 30_000;
|
|
@@ -8,16 +6,12 @@ const UNSUPPORTED_SNAPSHOT = Object.freeze({
|
|
|
8
6
|
phase: 'unsupported',
|
|
9
7
|
attempt: 0,
|
|
10
8
|
});
|
|
11
|
-
function attachment(client
|
|
12
|
-
if (visited.has(client))
|
|
13
|
-
return undefined;
|
|
14
|
-
visited.add(client);
|
|
9
|
+
function attachment(client) {
|
|
15
10
|
const candidate = Reflect.get(client, REALTIME_SUPERVISOR_KEY);
|
|
16
11
|
if (candidate?.version === 1 && candidate.supervisor) {
|
|
17
12
|
return candidate;
|
|
18
13
|
}
|
|
19
|
-
|
|
20
|
-
return source === undefined ? undefined : attachment(source, visited);
|
|
14
|
+
return undefined;
|
|
21
15
|
}
|
|
22
16
|
function scheduleTimer(callback, delayMs) {
|
|
23
17
|
const timer = globalThis.setTimeout(callback, delayMs);
|
package/dist/remote.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface SyncRemoteClientConfig {
|
|
|
18
18
|
readonly operations?: RemoteOperationTransport;
|
|
19
19
|
readonly operationRealtime?: RemoteOperationRealtimeConnector;
|
|
20
20
|
readonly encryption?: EncryptionConfig;
|
|
21
|
+
/** Acquired partition log epoch for restore-safe ordinary commits (§2.1). */
|
|
22
|
+
readonly logEpoch?: string;
|
|
21
23
|
}
|
|
22
24
|
export interface RemoteCommitInput {
|
|
23
25
|
/** Stable caller-owned idempotency identity for this logical commit. */
|