@spooky-sync/client-solid2 0.0.1-canary.207 → 0.0.1-canary.209

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.
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { mergeRows, sameValue } from './merge-rows';
3
+
4
+ // A stand-in for the SDK's RecordId: a class with a value-carrying toString,
5
+ // where two instances of the same record are never `===`.
6
+ class Rid {
7
+ constructor(private tb: string, private id: string) {}
8
+ toString() {
9
+ return `${this.tb}:${this.id}`;
10
+ }
11
+ }
12
+
13
+ // Counts field writes on a row, the way a store proxy would notify.
14
+ function tracked(row: Record<string, unknown>) {
15
+ const writes: string[] = [];
16
+ const proxy = new Proxy(row, {
17
+ set(target, prop, value) {
18
+ writes.push(String(prop));
19
+ (target as any)[prop] = value;
20
+ return true;
21
+ },
22
+ deleteProperty(target, prop) {
23
+ writes.push(`delete:${String(prop)}`);
24
+ delete (target as any)[prop];
25
+ return true;
26
+ },
27
+ });
28
+ return { proxy, writes };
29
+ }
30
+
31
+ describe('sameValue', () => {
32
+ it('treats two RecordId-like wrappers of the same record as equal', () => {
33
+ expect(sameValue(new Rid('user', 'a'), new Rid('user', 'a'))).toBe(true);
34
+ expect(sameValue(new Rid('user', 'a'), new Rid('user', 'b'))).toBe(false);
35
+ });
36
+ it('compares dates by time', () => {
37
+ expect(sameValue(new Date(5), new Date(5))).toBe(true);
38
+ expect(sameValue(new Date(5), new Date(6))).toBe(false);
39
+ });
40
+ it('keeps identity semantics for plain objects and arrays', () => {
41
+ expect(sameValue({ a: 1 }, { a: 1 })).toBe(false);
42
+ expect(sameValue([1], [1])).toBe(false);
43
+ const o = { a: 1 };
44
+ expect(sameValue(o, o)).toBe(true);
45
+ });
46
+ it('never equates a value with null', () => {
47
+ expect(sameValue(null, new Rid('x', 'y'))).toBe(false);
48
+ expect(sameValue(undefined, null)).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe('mergeRows', () => {
53
+ it('writes nothing when a re-emitted row only differs by wrapper identity', () => {
54
+ const { proxy, writes } = tracked({
55
+ id: new Rid('conversation', 'c1'),
56
+ user_a: new Rid('user', 'a'),
57
+ last_message_ms: 10,
58
+ created_at: new Date(1000),
59
+ });
60
+ const draft = [proxy];
61
+ mergeRows(draft, [
62
+ {
63
+ id: new Rid('conversation', 'c1'),
64
+ user_a: new Rid('user', 'a'),
65
+ last_message_ms: 10,
66
+ created_at: new Date(1000),
67
+ },
68
+ ]);
69
+ expect(writes).toEqual([]);
70
+ });
71
+
72
+ it('writes only the field that actually changed', () => {
73
+ const { proxy, writes } = tracked({
74
+ id: new Rid('conversation', 'c1'),
75
+ last_message_ms: 10,
76
+ });
77
+ const draft = [proxy];
78
+ mergeRows(draft, [{ id: new Rid('conversation', 'c1'), last_message_ms: 11 }]);
79
+ expect(writes).toEqual(['last_message_ms']);
80
+ expect((proxy as any).last_message_ms).toBe(11);
81
+ });
82
+
83
+ it('still inserts, drops and reorders rows by key', () => {
84
+ const a = { id: 'a', v: 1 };
85
+ const b = { id: 'b', v: 1 };
86
+ const draft: any[] = [a, b];
87
+ mergeRows(draft, [{ id: 'b', v: 2 }, { id: 'c', v: 1 }]);
88
+ expect(draft.map((r) => r.id)).toEqual(['b', 'c']);
89
+ expect(draft[0]).toBe(b);
90
+ expect(b.v).toBe(2);
91
+ });
92
+ });
@@ -15,6 +15,32 @@ import { snapshot } from 'solid-js';
15
15
  * inserted as-is and trailing rows are dropped, so add / remove / reorder all
16
16
  * reach coarse readers.
17
17
  */
18
+ /**
19
+ * Field-level equality for the merge. `===` is not enough: the decoder hands
20
+ * out a fresh RecordId / Date instance for every record-link and datetime
21
+ * column on every emission, so by identity every one of those fields "changed"
22
+ * on every update - and a store write on an unchanged `id` re-ran everything a
23
+ * page keyed on that row (a thread's anchor, its composer, its whole subtree).
24
+ * Compare those wrappers by value; everything else stays identity (nested
25
+ * objects and arrays are reconciled by their own writes).
26
+ */
27
+ export function sameValue(a: unknown, b: unknown): boolean {
28
+ if (a === b) return true;
29
+ if (a == null || b == null) return false;
30
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
31
+ // RecordId (and the SDK's other value wrappers: Duration, Decimal, Uuid…)
32
+ // all carry a stable `toString`; two of the same class that print alike ARE
33
+ // the same value. Plain objects are excluded: their `toString` is
34
+ // "[object Object]" and would make every object equal to every other.
35
+ if (typeof a === 'object' && typeof b === 'object') {
36
+ const ctor = (a as any).constructor;
37
+ if (ctor && ctor === (b as any).constructor && ctor !== Object && ctor !== Array) {
38
+ return String(a) === String(b);
39
+ }
40
+ }
41
+ return false;
42
+ }
43
+
18
44
  export function mergeRows(draft: any[], next: any[], key = 'id'): void {
19
45
  const byKey = new Map<any, any>();
20
46
  for (const row of draft) {
@@ -29,7 +55,7 @@ export function mergeRows(draft: any[], next: any[], key = 'id'): void {
29
55
 
30
56
  if (reuse) {
31
57
  for (const field of Object.keys(incoming)) {
32
- if (reuse[field] !== incoming[field]) reuse[field] = incoming[field];
58
+ if (!sameValue(reuse[field], incoming[field])) reuse[field] = incoming[field];
33
59
  }
34
60
  // `snapshot` first: iterating the proxy's own keys while deleting from it
35
61
  // is not safe, and the raw object is what carries the stale fields.