@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.
@@ -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
- rows: [],
241
- phase: 'loading',
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 = next;
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 === this.value)
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.0",
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.0"
92
+ "@syncular/core": "0.25.2"
93
93
  },
94
94
  "devDependencies": {
95
- "@syncular/server": "0.25.0",
95
+ "@syncular/server": "0.25.2",
96
96
  "playwright": "1.63.0"
97
97
  }
98
98
  }
@@ -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
- rows: [],
356
- phase: 'loading',
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 = next;
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 === this.value) return;
703
+ if (valuesEqual(next, this.value)) return;
672
704
  this.value = next;
673
705
  for (const listener of this.#listeners) listener();
674
706
  }