@syncular/client 0.25.0 → 0.25.2
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/dist/reactive-store.js +45 -19
- package/package.json +3 -3
- package/src/reactive-store.ts +51 -19
package/dist/reactive-store.js
CHANGED
|
@@ -136,6 +136,17 @@ function valuesEqual(left, right) {
|
|
|
136
136
|
typeof right !== 'object') {
|
|
137
137
|
return false;
|
|
138
138
|
}
|
|
139
|
+
if (left instanceof Date || right instanceof Date) {
|
|
140
|
+
return (left instanceof Date &&
|
|
141
|
+
right instanceof Date &&
|
|
142
|
+
Object.is(left.getTime(), right.getTime()));
|
|
143
|
+
}
|
|
144
|
+
// Errors and class instances keep state outside their enumerable keys.
|
|
145
|
+
for (const value of [left, right]) {
|
|
146
|
+
const prototype = Object.getPrototypeOf(value);
|
|
147
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
139
150
|
const leftRecord = left;
|
|
140
151
|
const rightRecord = right;
|
|
141
152
|
const keys = Object.keys(leftRecord);
|
|
@@ -146,7 +157,7 @@ function valuesEqual(left, right) {
|
|
|
146
157
|
}
|
|
147
158
|
function reconcileRows(previous, fresh, rowKey) {
|
|
148
159
|
if (previous.length === 0)
|
|
149
|
-
return fresh;
|
|
160
|
+
return fresh.length === 0 ? previous : fresh;
|
|
150
161
|
if (rowKey === undefined) {
|
|
151
162
|
let changed = previous.length !== fresh.length;
|
|
152
163
|
const next = fresh.map((row, index) => {
|
|
@@ -229,6 +240,27 @@ class ObservationCache {
|
|
|
229
240
|
this.#active.clear();
|
|
230
241
|
}
|
|
231
242
|
}
|
|
243
|
+
const EMPTY_ROWS = Object.freeze([]);
|
|
244
|
+
const unreadQuerySnapshots = new Map();
|
|
245
|
+
/** One frozen snapshot per phase and availability for entries that have no
|
|
246
|
+
* successful read, so switching between unread entries keeps the snapshot and
|
|
247
|
+
* `rows` identity. */
|
|
248
|
+
function unreadQuerySnapshot(phase, availability) {
|
|
249
|
+
const key = canonicalValue([phase, availability]);
|
|
250
|
+
let snapshot = unreadQuerySnapshots.get(key);
|
|
251
|
+
if (snapshot === undefined) {
|
|
252
|
+
snapshot = Object.freeze({
|
|
253
|
+
rows: EMPTY_ROWS,
|
|
254
|
+
phase,
|
|
255
|
+
revision: undefined,
|
|
256
|
+
error: undefined,
|
|
257
|
+
isRefreshing: false,
|
|
258
|
+
availability,
|
|
259
|
+
});
|
|
260
|
+
unreadQuerySnapshots.set(key, snapshot);
|
|
261
|
+
}
|
|
262
|
+
return snapshot;
|
|
263
|
+
}
|
|
232
264
|
class QueryEntry {
|
|
233
265
|
store;
|
|
234
266
|
spec;
|
|
@@ -236,14 +268,9 @@ class QueryEntry {
|
|
|
236
268
|
cache;
|
|
237
269
|
#owner = Symbol('query-window-claim');
|
|
238
270
|
#listeners = new Set();
|
|
239
|
-
#state = {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
revision: undefined,
|
|
243
|
-
error: undefined,
|
|
244
|
-
isRefreshing: false,
|
|
245
|
-
availability: { state: 'ready' },
|
|
246
|
-
};
|
|
271
|
+
#state = unreadQuerySnapshot('loading', {
|
|
272
|
+
state: 'ready',
|
|
273
|
+
});
|
|
247
274
|
#delegate;
|
|
248
275
|
#generation = 0;
|
|
249
276
|
#scheduled = false;
|
|
@@ -340,14 +367,7 @@ class QueryEntry {
|
|
|
340
367
|
this.#desiredRevision = 0n;
|
|
341
368
|
this.#claimError = undefined;
|
|
342
369
|
this.#claimPending = false;
|
|
343
|
-
this.#state = {
|
|
344
|
-
rows: [],
|
|
345
|
-
phase: 'loading',
|
|
346
|
-
revision: undefined,
|
|
347
|
-
error: undefined,
|
|
348
|
-
isRefreshing: false,
|
|
349
|
-
availability: { state: 'ready' },
|
|
350
|
-
};
|
|
370
|
+
this.#state = unreadQuerySnapshot('loading', { state: 'ready' });
|
|
351
371
|
}
|
|
352
372
|
dispose() {
|
|
353
373
|
this.#listeners.clear();
|
|
@@ -374,7 +394,13 @@ class QueryEntry {
|
|
|
374
394
|
canonicalValue(this.#state.availability)) {
|
|
375
395
|
return;
|
|
376
396
|
}
|
|
377
|
-
this.#state =
|
|
397
|
+
this.#state =
|
|
398
|
+
next.revision === undefined &&
|
|
399
|
+
next.rows.length === 0 &&
|
|
400
|
+
next.error === undefined &&
|
|
401
|
+
!next.isRefreshing
|
|
402
|
+
? unreadQuerySnapshot(next.phase, next.availability)
|
|
403
|
+
: next;
|
|
378
404
|
for (const listener of this.#listeners)
|
|
379
405
|
listener();
|
|
380
406
|
}
|
|
@@ -539,7 +565,7 @@ class ValueEntry {
|
|
|
539
565
|
}
|
|
540
566
|
set(next) {
|
|
541
567
|
this.invalidate();
|
|
542
|
-
if (next
|
|
568
|
+
if (valuesEqual(next, this.value))
|
|
543
569
|
return;
|
|
544
570
|
this.value = next;
|
|
545
571
|
for (const listener of this.#listeners)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.2",
|
|
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",
|
|
@@ -89,10 +89,10 @@
|
|
|
89
89
|
},
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.25.
|
|
92
|
+
"@syncular/core": "0.25.2"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.25.
|
|
95
|
+
"@syncular/server": "0.25.2",
|
|
96
96
|
"playwright": "1.63.0"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/reactive-store.ts
CHANGED
|
@@ -249,6 +249,18 @@ function valuesEqual(left: unknown, right: unknown): boolean {
|
|
|
249
249
|
) {
|
|
250
250
|
return false;
|
|
251
251
|
}
|
|
252
|
+
if (left instanceof Date || right instanceof Date) {
|
|
253
|
+
return (
|
|
254
|
+
left instanceof Date &&
|
|
255
|
+
right instanceof Date &&
|
|
256
|
+
Object.is(left.getTime(), right.getTime())
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
// Errors and class instances keep state outside their enumerable keys.
|
|
260
|
+
for (const value of [left, right]) {
|
|
261
|
+
const prototype = Object.getPrototypeOf(value);
|
|
262
|
+
if (prototype !== Object.prototype && prototype !== null) return false;
|
|
263
|
+
}
|
|
252
264
|
const leftRecord = left as Record<string, unknown>;
|
|
253
265
|
const rightRecord = right as Record<string, unknown>;
|
|
254
266
|
const keys = Object.keys(leftRecord);
|
|
@@ -265,7 +277,7 @@ function reconcileRows<Row>(
|
|
|
265
277
|
fresh: readonly Row[],
|
|
266
278
|
rowKey: ((row: Row) => readonly SqlValue[]) | undefined,
|
|
267
279
|
): readonly Row[] {
|
|
268
|
-
if (previous.length === 0) return fresh;
|
|
280
|
+
if (previous.length === 0) return fresh.length === 0 ? previous : fresh;
|
|
269
281
|
if (rowKey === undefined) {
|
|
270
282
|
let changed = previous.length !== fresh.length;
|
|
271
283
|
const next = fresh.map((row, index) => {
|
|
@@ -348,17 +360,38 @@ class ObservationCache {
|
|
|
348
360
|
}
|
|
349
361
|
}
|
|
350
362
|
|
|
363
|
+
const EMPTY_ROWS: readonly never[] = Object.freeze([]);
|
|
364
|
+
const unreadQuerySnapshots = new Map<string, LiveQueryResult<never>>();
|
|
365
|
+
|
|
366
|
+
/** One frozen snapshot per phase and availability for entries that have no
|
|
367
|
+
* successful read, so switching between unread entries keeps the snapshot and
|
|
368
|
+
* `rows` identity. */
|
|
369
|
+
function unreadQuerySnapshot(
|
|
370
|
+
phase: LiveQueryPhase,
|
|
371
|
+
availability: SyncAvailability,
|
|
372
|
+
): LiveQueryResult<never> {
|
|
373
|
+
const key = canonicalValue([phase, availability]);
|
|
374
|
+
let snapshot = unreadQuerySnapshots.get(key);
|
|
375
|
+
if (snapshot === undefined) {
|
|
376
|
+
snapshot = Object.freeze({
|
|
377
|
+
rows: EMPTY_ROWS,
|
|
378
|
+
phase,
|
|
379
|
+
revision: undefined,
|
|
380
|
+
error: undefined,
|
|
381
|
+
isRefreshing: false,
|
|
382
|
+
availability,
|
|
383
|
+
});
|
|
384
|
+
unreadQuerySnapshots.set(key, snapshot);
|
|
385
|
+
}
|
|
386
|
+
return snapshot;
|
|
387
|
+
}
|
|
388
|
+
|
|
351
389
|
class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
352
390
|
readonly #owner = Symbol('query-window-claim');
|
|
353
391
|
readonly #listeners = new Set<() => void>();
|
|
354
|
-
#state: LiveQueryResult<Row> = {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
revision: undefined,
|
|
358
|
-
error: undefined,
|
|
359
|
-
isRefreshing: false,
|
|
360
|
-
availability: { state: 'ready' },
|
|
361
|
-
};
|
|
392
|
+
#state: LiveQueryResult<Row> = unreadQuerySnapshot('loading', {
|
|
393
|
+
state: 'ready',
|
|
394
|
+
});
|
|
362
395
|
#delegate: QueryEntry<Row> | undefined;
|
|
363
396
|
#generation = 0;
|
|
364
397
|
#scheduled = false;
|
|
@@ -464,14 +497,7 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
464
497
|
this.#desiredRevision = 0n;
|
|
465
498
|
this.#claimError = undefined;
|
|
466
499
|
this.#claimPending = false;
|
|
467
|
-
this.#state = {
|
|
468
|
-
rows: [],
|
|
469
|
-
phase: 'loading',
|
|
470
|
-
revision: undefined,
|
|
471
|
-
error: undefined,
|
|
472
|
-
isRefreshing: false,
|
|
473
|
-
availability: { state: 'ready' },
|
|
474
|
-
};
|
|
500
|
+
this.#state = unreadQuerySnapshot('loading', { state: 'ready' });
|
|
475
501
|
}
|
|
476
502
|
|
|
477
503
|
dispose(): void {
|
|
@@ -501,7 +527,13 @@ class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
|
501
527
|
) {
|
|
502
528
|
return;
|
|
503
529
|
}
|
|
504
|
-
this.#state =
|
|
530
|
+
this.#state =
|
|
531
|
+
next.revision === undefined &&
|
|
532
|
+
next.rows.length === 0 &&
|
|
533
|
+
next.error === undefined &&
|
|
534
|
+
!next.isRefreshing
|
|
535
|
+
? unreadQuerySnapshot(next.phase, next.availability)
|
|
536
|
+
: next;
|
|
505
537
|
for (const listener of this.#listeners) listener();
|
|
506
538
|
}
|
|
507
539
|
|
|
@@ -668,7 +700,7 @@ class ValueEntry<T> implements ExternalStoreEntry<T> {
|
|
|
668
700
|
}
|
|
669
701
|
set(next: T): void {
|
|
670
702
|
this.invalidate();
|
|
671
|
-
if (next
|
|
703
|
+
if (valuesEqual(next, this.value)) return;
|
|
672
704
|
this.value = next;
|
|
673
705
|
for (const listener of this.#listeners) listener();
|
|
674
706
|
}
|