@spooky-sync/client-solid2 0.0.1-canary.200
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/LICENSE +21 -0
- package/QUICK_START.md +126 -0
- package/README.md +19 -0
- package/dist/index.cjs +903 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +498 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +498 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +884 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/skills/sp00ky-solid2/SKILL.md +68 -0
- package/src/index.ts +365 -0
- package/src/lib/Sp00kyProvider.ts +104 -0
- package/src/lib/__tests__/conflate.test.ts +120 -0
- package/src/lib/__tests__/create-query.test.ts +284 -0
- package/src/lib/__tests__/rc-semantics.test.ts +389 -0
- package/src/lib/conflate.ts +74 -0
- package/src/lib/context.ts +28 -0
- package/src/lib/create-preload.ts +115 -0
- package/src/lib/create-query.ts +285 -0
- package/src/lib/create-submission.ts +57 -0
- package/src/lib/from-subscription.ts +32 -0
- package/src/lib/models.ts +8 -0
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-crdt-field.ts +57 -0
- package/src/lib/use-download-file.ts +181 -0
- package/src/lib/use-feature-flag.ts +43 -0
- package/src/lib/use-file-upload.ts +146 -0
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-status.ts +63 -0
- package/src/types/index.ts +83 -0
- package/tsconfig.json +27 -0
- package/tsdown.config.ts +18 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createEffect, createRoot, createSignal, flush } from 'solid-js';
|
|
3
|
+
import { createQuery } from '../create-query';
|
|
4
|
+
import { SyncedDb } from '../../index';
|
|
5
|
+
|
|
6
|
+
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
7
|
+
const settle = async () => {
|
|
8
|
+
await tick();
|
|
9
|
+
flush();
|
|
10
|
+
await tick();
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Emission = Record<string, any>[];
|
|
14
|
+
|
|
15
|
+
function mockEngine() {
|
|
16
|
+
const subs = new Map<string, (e: Emission) => void>();
|
|
17
|
+
const statusSubs = new Map<string, (s: string) => void>();
|
|
18
|
+
const unsubscribed: string[] = [];
|
|
19
|
+
const deregistered: string[] = [];
|
|
20
|
+
const sp00ky = {
|
|
21
|
+
subscribe: vi.fn(async (hash: string, cb: (e: Emission) => void, _o?: unknown) => {
|
|
22
|
+
subs.set(hash, cb);
|
|
23
|
+
return () => {
|
|
24
|
+
subs.delete(hash);
|
|
25
|
+
unsubscribed.push(hash);
|
|
26
|
+
};
|
|
27
|
+
}),
|
|
28
|
+
subscribeQueryStatus: vi.fn(
|
|
29
|
+
(hash: string, cb: (s: string) => void, o?: { immediate?: boolean }) => {
|
|
30
|
+
statusSubs.set(hash, cb);
|
|
31
|
+
if (o?.immediate) cb('idle');
|
|
32
|
+
return () => statusSubs.delete(hash);
|
|
33
|
+
}
|
|
34
|
+
),
|
|
35
|
+
deregisterQuery: vi.fn((hash: string) => deregistered.push(hash)),
|
|
36
|
+
reportFrontendTiming: vi.fn(),
|
|
37
|
+
};
|
|
38
|
+
const db = Object.create(SyncedDb.prototype) as SyncedDb<any>;
|
|
39
|
+
(db as any).getSp00ky = () => sp00ky;
|
|
40
|
+
return {
|
|
41
|
+
db,
|
|
42
|
+
sp00ky,
|
|
43
|
+
emit: (hash: string, e: Emission) => subs.get(hash)?.(e),
|
|
44
|
+
setStatus: (hash: string, s: string) => statusSubs.get(hash)?.(s),
|
|
45
|
+
unsubscribed,
|
|
46
|
+
deregistered,
|
|
47
|
+
hasSub: (hash: string) => subs.has(hash),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function mockQuery(hash: string, isOne = false) {
|
|
52
|
+
return {
|
|
53
|
+
hash,
|
|
54
|
+
isOne,
|
|
55
|
+
run: async () => ({ hash }),
|
|
56
|
+
} as any;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('createQuery', () => {
|
|
60
|
+
it('serves empty data immediately, then live emissions with row identity kept', async () => {
|
|
61
|
+
const eng = mockEngine();
|
|
62
|
+
await createRoot(async (dispose) => {
|
|
63
|
+
const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
|
|
64
|
+
createEffect(
|
|
65
|
+
() => q.data(),
|
|
66
|
+
() => {}
|
|
67
|
+
);
|
|
68
|
+
flush();
|
|
69
|
+
|
|
70
|
+
expect(q.data()).toEqual([]); // committed empty, no suspension
|
|
71
|
+
expect(q.isLoading()).toBe(true);
|
|
72
|
+
|
|
73
|
+
await settle();
|
|
74
|
+
eng.emit('h1', [
|
|
75
|
+
{ id: 'a', n: 1 },
|
|
76
|
+
{ id: 'b', n: 2 },
|
|
77
|
+
]);
|
|
78
|
+
await settle();
|
|
79
|
+
|
|
80
|
+
expect(q.data().map((r: any) => r.id)).toEqual(['a', 'b']);
|
|
81
|
+
expect(q.isLoading()).toBe(false);
|
|
82
|
+
expect(q.isSettled()).toBe(true);
|
|
83
|
+
|
|
84
|
+
const rowA = q.data()[0];
|
|
85
|
+
eng.emit('h1', [
|
|
86
|
+
{ id: 'a', n: 99 },
|
|
87
|
+
{ id: 'b', n: 2 },
|
|
88
|
+
]);
|
|
89
|
+
await settle();
|
|
90
|
+
expect(q.data()[0]).toBe(rowA); // identity preserved via keyed reconcile
|
|
91
|
+
expect(q.data()[0].n).toBe(99);
|
|
92
|
+
|
|
93
|
+
dispose();
|
|
94
|
+
await settle();
|
|
95
|
+
expect(eng.unsubscribed).toContain('h1');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('first empty emission does not mark fetched; second does', async () => {
|
|
100
|
+
const eng = mockEngine();
|
|
101
|
+
await createRoot(async (dispose) => {
|
|
102
|
+
const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
|
|
103
|
+
createEffect(
|
|
104
|
+
() => q.data(),
|
|
105
|
+
() => {}
|
|
106
|
+
);
|
|
107
|
+
flush();
|
|
108
|
+
await settle();
|
|
109
|
+
|
|
110
|
+
eng.emit('h1', []);
|
|
111
|
+
await settle();
|
|
112
|
+
expect(q.isLoading()).toBe(true); // still loading: local DB likely not synced
|
|
113
|
+
|
|
114
|
+
eng.emit('h1', []);
|
|
115
|
+
await settle();
|
|
116
|
+
expect(q.isLoading()).toBe(false); // a later empty emission is authoritative
|
|
117
|
+
dispose();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('one() queries yield the row or null', async () => {
|
|
122
|
+
const eng = mockEngine();
|
|
123
|
+
await createRoot(async (dispose) => {
|
|
124
|
+
const q = createQuery<any, any, any, any, true, any>(eng.db, mockQuery('h1', true));
|
|
125
|
+
createEffect(
|
|
126
|
+
() => q.data(),
|
|
127
|
+
() => {}
|
|
128
|
+
);
|
|
129
|
+
flush();
|
|
130
|
+
await settle();
|
|
131
|
+
|
|
132
|
+
expect(q.data()).toBe(null);
|
|
133
|
+
eng.emit('h1', [{ id: 'a', n: 1 }]);
|
|
134
|
+
await settle();
|
|
135
|
+
expect(q.data()?.n).toBe(1);
|
|
136
|
+
expect(q.isLoading()).toBe(false);
|
|
137
|
+
dispose();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('isFetching mirrors query status; isSettled composes both', async () => {
|
|
142
|
+
const eng = mockEngine();
|
|
143
|
+
await createRoot(async (dispose) => {
|
|
144
|
+
const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
|
|
145
|
+
createEffect(
|
|
146
|
+
() => q.data(),
|
|
147
|
+
() => {}
|
|
148
|
+
);
|
|
149
|
+
flush();
|
|
150
|
+
await settle();
|
|
151
|
+
|
|
152
|
+
eng.setStatus('h1', 'fetching');
|
|
153
|
+
flush();
|
|
154
|
+
expect(q.isFetching()).toBe(true);
|
|
155
|
+
|
|
156
|
+
eng.emit('h1', [{ id: 'a' }]);
|
|
157
|
+
await settle();
|
|
158
|
+
expect(q.isSettled()).toBe(false); // fetched but still fetching
|
|
159
|
+
|
|
160
|
+
eng.setStatus('h1', 'idle');
|
|
161
|
+
flush();
|
|
162
|
+
expect(q.isSettled()).toBe(true);
|
|
163
|
+
dispose();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('registration failure surfaces via error(), resolving isLoading', async () => {
|
|
168
|
+
const eng = mockEngine();
|
|
169
|
+
const failing = {
|
|
170
|
+
hash: 'hx',
|
|
171
|
+
isOne: false,
|
|
172
|
+
run: async () => {
|
|
173
|
+
throw new Error('SSP NOT_READY');
|
|
174
|
+
},
|
|
175
|
+
} as any;
|
|
176
|
+
await createRoot(async (dispose) => {
|
|
177
|
+
const q = createQuery<any, any, any, any, false, any>(eng.db, failing);
|
|
178
|
+
createEffect(
|
|
179
|
+
() => q.data(),
|
|
180
|
+
() => {}
|
|
181
|
+
);
|
|
182
|
+
flush();
|
|
183
|
+
await settle();
|
|
184
|
+
|
|
185
|
+
expect(q.error()?.message).toBe('SSP NOT_READY');
|
|
186
|
+
expect(q.isLoading()).toBe(false); // spinner must resolve
|
|
187
|
+
expect(q.data()).toEqual([]);
|
|
188
|
+
dispose();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('reactive query thunk: identity change re-subscribes and clears state', async () => {
|
|
193
|
+
const eng = mockEngine();
|
|
194
|
+
await createRoot(async (dispose) => {
|
|
195
|
+
const [id, setId] = createSignal('h1');
|
|
196
|
+
const q = createQuery<any, any, any, any, false, any>(eng.db, () => mockQuery(id()));
|
|
197
|
+
createEffect(
|
|
198
|
+
() => q.data(),
|
|
199
|
+
() => {}
|
|
200
|
+
);
|
|
201
|
+
flush();
|
|
202
|
+
await settle();
|
|
203
|
+
|
|
204
|
+
eng.emit('h1', [{ id: 'a' }]);
|
|
205
|
+
await settle();
|
|
206
|
+
expect(q.data().map((r: any) => r.id)).toEqual(['a']);
|
|
207
|
+
|
|
208
|
+
setId('h2');
|
|
209
|
+
flush();
|
|
210
|
+
await settle();
|
|
211
|
+
await settle();
|
|
212
|
+
|
|
213
|
+
expect(eng.unsubscribed).toContain('h1'); // superseded subscription torn down
|
|
214
|
+
expect(eng.hasSub('h2')).toBe(true);
|
|
215
|
+
expect(q.isLoading()).toBe(true); // reset for the new identity
|
|
216
|
+
|
|
217
|
+
eng.emit('h2', [{ id: 'z' }]);
|
|
218
|
+
await settle();
|
|
219
|
+
expect(q.data().map((r: any) => r.id)).toEqual(['z']);
|
|
220
|
+
dispose();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('enabled=false runs no query; flipping true starts it', async () => {
|
|
225
|
+
const eng = mockEngine();
|
|
226
|
+
await createRoot(async (dispose) => {
|
|
227
|
+
const [enabled, setEnabled] = createSignal(false);
|
|
228
|
+
const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'), {
|
|
229
|
+
enabled,
|
|
230
|
+
});
|
|
231
|
+
createEffect(
|
|
232
|
+
() => q.data(),
|
|
233
|
+
() => {}
|
|
234
|
+
);
|
|
235
|
+
flush();
|
|
236
|
+
await settle();
|
|
237
|
+
expect(eng.sp00ky.subscribe).not.toHaveBeenCalled();
|
|
238
|
+
|
|
239
|
+
setEnabled(true);
|
|
240
|
+
flush();
|
|
241
|
+
await settle();
|
|
242
|
+
expect(eng.hasSub('h1')).toBe(true);
|
|
243
|
+
dispose();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('deregisterOnCleanup deregisters the active hash on dispose', async () => {
|
|
248
|
+
const eng = mockEngine();
|
|
249
|
+
await createRoot(async (dispose) => {
|
|
250
|
+
const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'), {
|
|
251
|
+
deregisterOnCleanup: true,
|
|
252
|
+
});
|
|
253
|
+
createEffect(
|
|
254
|
+
() => q.data(),
|
|
255
|
+
() => {}
|
|
256
|
+
);
|
|
257
|
+
flush();
|
|
258
|
+
await settle();
|
|
259
|
+
expect(eng.hasSub('h1')).toBe(true);
|
|
260
|
+
|
|
261
|
+
dispose();
|
|
262
|
+
await settle();
|
|
263
|
+
expect(eng.unsubscribed).toContain('h1');
|
|
264
|
+
expect(eng.deregistered).toEqual(['h1']);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('reports frontend timing per emission', async () => {
|
|
269
|
+
const eng = mockEngine();
|
|
270
|
+
await createRoot(async (dispose) => {
|
|
271
|
+
const q = createQuery<any, any, any, any, false, any>(eng.db, mockQuery('h1'));
|
|
272
|
+
createEffect(
|
|
273
|
+
() => q.data(),
|
|
274
|
+
() => {}
|
|
275
|
+
);
|
|
276
|
+
flush();
|
|
277
|
+
await settle();
|
|
278
|
+
eng.emit('h1', [{ id: 'a' }]);
|
|
279
|
+
await settle();
|
|
280
|
+
expect(eng.sp00ky.reportFrontendTiming).toHaveBeenCalledWith('h1', expect.any(Number));
|
|
281
|
+
dispose();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probes against solid-js 2.0.0-rc.0 semantics this package's design depends
|
|
3
|
+
* on. If any of these fail after a Solid version bump, the binding's
|
|
4
|
+
* assumptions are broken — fix the binding, don't delete the probe.
|
|
5
|
+
*
|
|
6
|
+
* Probed assumptions (see plan):
|
|
7
|
+
* 1. A projection compute given as an async generator restarts when a
|
|
8
|
+
* dependency read before the first `await` changes, and the superseded
|
|
9
|
+
* generator is terminated (its `finally` runs) so external subscriptions
|
|
10
|
+
* can be torn down.
|
|
11
|
+
* 2. Keyed reconcile of yielded arrays notifies coarse readers (whole-array
|
|
12
|
+
* tracking, e.g. `<For>`/mapArray) on add/remove even when the array length
|
|
13
|
+
* stays equal (windowed-list delete case).
|
|
14
|
+
* 3. `seedLoadingValue: true` births the store committed: readable
|
|
15
|
+
* immediately, `isPending` false during the first flight.
|
|
16
|
+
* 4. Signals created with `{ ownedWrite: true }` accept writes from plain
|
|
17
|
+
* callbacks fired outside any tracking scope after setup.
|
|
18
|
+
* 5. Class instances (RecordId-shaped) inside store rows: document whether the
|
|
19
|
+
* proxy wraps them and that instanceof/method access still works through it.
|
|
20
|
+
* 6. Generator teardown on owner dispose runs `finally` blocks.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, expect, it } from 'vitest';
|
|
23
|
+
import {
|
|
24
|
+
createProjection,
|
|
25
|
+
createRoot,
|
|
26
|
+
createSignal,
|
|
27
|
+
createEffect,
|
|
28
|
+
createMemo,
|
|
29
|
+
isPending,
|
|
30
|
+
flush,
|
|
31
|
+
mapArray,
|
|
32
|
+
isWrappable,
|
|
33
|
+
snapshot,
|
|
34
|
+
onCleanup,
|
|
35
|
+
} from 'solid-js';
|
|
36
|
+
import * as signals from '@solidjs/signals';
|
|
37
|
+
|
|
38
|
+
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
|
|
39
|
+
|
|
40
|
+
/** Minimal push-source with subscriber tracking, stand-in for sp00ky.subscribe. */
|
|
41
|
+
function pushSource<T>() {
|
|
42
|
+
let cb: ((v: T) => void) | undefined;
|
|
43
|
+
let subscribes = 0;
|
|
44
|
+
let unsubscribes = 0;
|
|
45
|
+
return {
|
|
46
|
+
subscribe(fn: (v: T) => void) {
|
|
47
|
+
cb = fn;
|
|
48
|
+
subscribes++;
|
|
49
|
+
return () => {
|
|
50
|
+
if (cb === fn) cb = undefined;
|
|
51
|
+
unsubscribes++;
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
emit(v: T) {
|
|
55
|
+
cb?.(v);
|
|
56
|
+
},
|
|
57
|
+
get counts() {
|
|
58
|
+
return { subscribes, unsubscribes };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Push → pull adapter (same shape as ../conflate, inlined to keep the probe
|
|
64
|
+
* self-contained). */
|
|
65
|
+
function iterate<T>(subscribe: (cb: (v: T) => void) => () => void): AsyncIterable<T> {
|
|
66
|
+
return {
|
|
67
|
+
[Symbol.asyncIterator]() {
|
|
68
|
+
let buffered: { v: T } | undefined;
|
|
69
|
+
let resolveNext: ((r: IteratorResult<T>) => void) | undefined;
|
|
70
|
+
let done = false;
|
|
71
|
+
const unsub = subscribe((v) => {
|
|
72
|
+
if (done) return;
|
|
73
|
+
if (resolveNext) {
|
|
74
|
+
const r = resolveNext;
|
|
75
|
+
resolveNext = undefined;
|
|
76
|
+
r({ value: v, done: false });
|
|
77
|
+
} else {
|
|
78
|
+
buffered = { v };
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
const finish = (): IteratorResult<T> => {
|
|
82
|
+
if (!done) {
|
|
83
|
+
done = true;
|
|
84
|
+
unsub();
|
|
85
|
+
resolveNext?.({ value: undefined, done: true });
|
|
86
|
+
resolveNext = undefined;
|
|
87
|
+
}
|
|
88
|
+
return { value: undefined, done: true };
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
next() {
|
|
92
|
+
if (done) return Promise.resolve<IteratorResult<T>>({ value: undefined, done: true });
|
|
93
|
+
if (buffered) {
|
|
94
|
+
const v = buffered.v;
|
|
95
|
+
buffered = undefined;
|
|
96
|
+
return Promise.resolve({ value: v, done: false });
|
|
97
|
+
}
|
|
98
|
+
return new Promise<IteratorResult<T>>((r) => (resolveNext = r));
|
|
99
|
+
},
|
|
100
|
+
return() {
|
|
101
|
+
return Promise.resolve(finish());
|
|
102
|
+
},
|
|
103
|
+
throw(e: unknown) {
|
|
104
|
+
finish();
|
|
105
|
+
return Promise.reject(e);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
type Row = { id: string; n?: number };
|
|
113
|
+
|
|
114
|
+
describe('probe 1: async-generator projection restart on dep change', () => {
|
|
115
|
+
it('abandons the superseded generator: teardown must be manual via onCleanup', async () => {
|
|
116
|
+
// rc.0 semantics: a dep change restarts the compute (a new generator
|
|
117
|
+
// starts) but the superseded generator is NOT terminated — no return(),
|
|
118
|
+
// no finally. Anything it subscribed to leaks unless the compute registers
|
|
119
|
+
// onCleanup (synchronously, before the first await) to tear it down.
|
|
120
|
+
// conflate() + create-query rely on exactly that onCleanup contract.
|
|
121
|
+
const src1 = pushSource<Row[]>();
|
|
122
|
+
const src2 = pushSource<Row[]>();
|
|
123
|
+
const finished: string[] = [];
|
|
124
|
+
|
|
125
|
+
await createRoot(async (dispose) => {
|
|
126
|
+
const [which, setWhich] = createSignal<'a' | 'b'>('a');
|
|
127
|
+
|
|
128
|
+
const rows = createProjection(
|
|
129
|
+
async function* (): AsyncGenerator<Row[]> {
|
|
130
|
+
const w = which(); // tracked read BEFORE first await
|
|
131
|
+
const src = w === 'a' ? src1 : src2;
|
|
132
|
+
const it = iterate<Row[]>((cb) => src.subscribe(cb))[Symbol.asyncIterator]();
|
|
133
|
+
onCleanup(() => void it.return?.()); // manual teardown — Solid won't do it
|
|
134
|
+
try {
|
|
135
|
+
while (true) {
|
|
136
|
+
const r = await it.next();
|
|
137
|
+
if (r.done) break;
|
|
138
|
+
yield r.value;
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
finished.push(w);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
[] as Row[],
|
|
145
|
+
{ key: 'id' }
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// touch the projection so it computes
|
|
149
|
+
createEffect(
|
|
150
|
+
() => rows.length,
|
|
151
|
+
() => {}
|
|
152
|
+
);
|
|
153
|
+
flush();
|
|
154
|
+
await tick();
|
|
155
|
+
expect(src1.counts.subscribes).toBe(1);
|
|
156
|
+
|
|
157
|
+
src1.emit([{ id: 'x', n: 1 }]);
|
|
158
|
+
await tick();
|
|
159
|
+
flush();
|
|
160
|
+
expect(rows.map((r) => r.id)).toEqual(['x']);
|
|
161
|
+
|
|
162
|
+
// dep change → onCleanup fires for generator A, its finally runs, B subscribes
|
|
163
|
+
setWhich('b');
|
|
164
|
+
flush();
|
|
165
|
+
await tick();
|
|
166
|
+
await tick();
|
|
167
|
+
expect(finished).toContain('a');
|
|
168
|
+
expect(src1.counts.unsubscribes).toBe(1);
|
|
169
|
+
expect(src2.counts.subscribes).toBe(1);
|
|
170
|
+
|
|
171
|
+
src2.emit([{ id: 'y', n: 2 }]);
|
|
172
|
+
await tick();
|
|
173
|
+
flush();
|
|
174
|
+
expect(rows.map((r) => r.id)).toEqual(['y']);
|
|
175
|
+
|
|
176
|
+
dispose();
|
|
177
|
+
await tick();
|
|
178
|
+
// dispose runs the live generator's onCleanup too
|
|
179
|
+
expect(finished).toContain('b');
|
|
180
|
+
expect(src2.counts.unsubscribes).toBe(1);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('probe 2: keyed reconcile notifies coarse readers on same-length change', () => {
|
|
186
|
+
it('mapArray over the projection re-runs when a row is swapped', async () => {
|
|
187
|
+
const src = pushSource<Row[]>();
|
|
188
|
+
await createRoot(async (dispose) => {
|
|
189
|
+
const rows = createProjection(
|
|
190
|
+
async function* (): AsyncGenerator<Row[]> {
|
|
191
|
+
for await (const v of iterate<Row[]>((cb) => src.subscribe(cb))) yield v;
|
|
192
|
+
},
|
|
193
|
+
[] as Row[],
|
|
194
|
+
{ key: 'id' }
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const seen: string[][] = [];
|
|
198
|
+
const mapped = mapArray(
|
|
199
|
+
() => rows,
|
|
200
|
+
(r) => r.id
|
|
201
|
+
);
|
|
202
|
+
createEffect(
|
|
203
|
+
() => mapped(),
|
|
204
|
+
(ids) => {
|
|
205
|
+
seen.push([...ids]);
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
flush();
|
|
209
|
+
await tick();
|
|
210
|
+
|
|
211
|
+
src.emit([
|
|
212
|
+
{ id: 'a', n: 1 },
|
|
213
|
+
{ id: 'b', n: 2 },
|
|
214
|
+
]);
|
|
215
|
+
await tick();
|
|
216
|
+
flush();
|
|
217
|
+
|
|
218
|
+
// same length, one row swapped (windowed-list delete: c shifts in for b)
|
|
219
|
+
src.emit([
|
|
220
|
+
{ id: 'a', n: 1 },
|
|
221
|
+
{ id: 'c', n: 3 },
|
|
222
|
+
]);
|
|
223
|
+
await tick();
|
|
224
|
+
flush();
|
|
225
|
+
|
|
226
|
+
expect(seen.at(-1)).toEqual(['a', 'c']);
|
|
227
|
+
|
|
228
|
+
// row identity for the surviving row must be stable across emissions
|
|
229
|
+
const a1 = rows[0];
|
|
230
|
+
src.emit([
|
|
231
|
+
{ id: 'a', n: 99 },
|
|
232
|
+
{ id: 'c', n: 3 },
|
|
233
|
+
]);
|
|
234
|
+
await tick();
|
|
235
|
+
flush();
|
|
236
|
+
expect(rows[0]).toBe(a1);
|
|
237
|
+
expect(rows[0].n).toBe(99);
|
|
238
|
+
|
|
239
|
+
dispose();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('probe 3: seedLoadingValue commits the seed', () => {
|
|
245
|
+
it('store is readable and not pending during first flight', async () => {
|
|
246
|
+
const src = pushSource<Row[]>();
|
|
247
|
+
await createRoot(async (dispose) => {
|
|
248
|
+
const rows = createProjection(
|
|
249
|
+
async function* (): AsyncGenerator<Row[]> {
|
|
250
|
+
for await (const v of iterate<Row[]>((cb) => src.subscribe(cb))) yield v;
|
|
251
|
+
},
|
|
252
|
+
[] as Row[],
|
|
253
|
+
{ key: 'id', seedLoadingValue: true }
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
let pendingDuringFlight: boolean | undefined;
|
|
257
|
+
let lenDuringFlight: number | undefined;
|
|
258
|
+
createEffect(
|
|
259
|
+
() => {
|
|
260
|
+
lenDuringFlight = rows.length;
|
|
261
|
+
pendingDuringFlight = isPending(() => rows.length);
|
|
262
|
+
return undefined;
|
|
263
|
+
},
|
|
264
|
+
() => {}
|
|
265
|
+
);
|
|
266
|
+
flush();
|
|
267
|
+
await tick();
|
|
268
|
+
|
|
269
|
+
expect(lenDuringFlight).toBe(0); // readable, no NotReadyError
|
|
270
|
+
expect(pendingDuringFlight).toBe(false); // commit #0, not pending
|
|
271
|
+
|
|
272
|
+
src.emit([{ id: 'a' }]);
|
|
273
|
+
await tick();
|
|
274
|
+
flush();
|
|
275
|
+
expect(rows.length).toBe(1);
|
|
276
|
+
dispose();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('probe 4: ownedWrite signals accept external-callback writes', () => {
|
|
282
|
+
it('does not throw when written from a later plain callback', async () => {
|
|
283
|
+
let later: (() => void) | undefined;
|
|
284
|
+
await createRoot(async (dispose) => {
|
|
285
|
+
const [v, setV] = createSignal(0, { ownedWrite: true });
|
|
286
|
+
later = () => setV(1);
|
|
287
|
+
createEffect(
|
|
288
|
+
() => v(),
|
|
289
|
+
() => {}
|
|
290
|
+
);
|
|
291
|
+
flush();
|
|
292
|
+
// fire from outside any reactive scope, as a subscription callback would
|
|
293
|
+
await tick();
|
|
294
|
+
expect(() => later!()).not.toThrow();
|
|
295
|
+
flush();
|
|
296
|
+
expect(v()).toBe(1);
|
|
297
|
+
dispose();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('probe 5: class instances inside store rows', () => {
|
|
303
|
+
class FakeRecordId {
|
|
304
|
+
constructor(
|
|
305
|
+
public tb: string,
|
|
306
|
+
public id: string
|
|
307
|
+
) {}
|
|
308
|
+
toString() {
|
|
309
|
+
return `${this.tb}:${this.id}`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
it('documents wrapping behavior and that instanceof/methods survive', async () => {
|
|
314
|
+
const src = pushSource<{ id: string; rid: FakeRecordId }[]>();
|
|
315
|
+
await createRoot(async (dispose) => {
|
|
316
|
+
const rows = createProjection(
|
|
317
|
+
async function* (): AsyncGenerator<{ id: string; rid: FakeRecordId }[]> {
|
|
318
|
+
for await (const v of iterate<{ id: string; rid: FakeRecordId }[]>((cb) =>
|
|
319
|
+
src.subscribe(cb)
|
|
320
|
+
))
|
|
321
|
+
yield v;
|
|
322
|
+
},
|
|
323
|
+
[] as { id: string; rid: FakeRecordId }[],
|
|
324
|
+
{ key: 'id' }
|
|
325
|
+
);
|
|
326
|
+
createEffect(
|
|
327
|
+
() => rows.length,
|
|
328
|
+
() => {}
|
|
329
|
+
);
|
|
330
|
+
flush();
|
|
331
|
+
await tick();
|
|
332
|
+
|
|
333
|
+
src.emit([{ id: 'a', rid: new FakeRecordId('game', 'a') }]);
|
|
334
|
+
await tick();
|
|
335
|
+
flush();
|
|
336
|
+
|
|
337
|
+
const rid = rows[0].rid;
|
|
338
|
+
// Solid 2 wraps class instances (isWrappable true — unlike Solid 1).
|
|
339
|
+
expect(isWrappable(new FakeRecordId('t', 'i'))).toBe(true);
|
|
340
|
+
// Document what survives through the proxy: methods are served BOUND, so
|
|
341
|
+
// `constructor.name` gains a 'bound ' prefix. SyncedDb.delete's
|
|
342
|
+
// cross-package RecordId detection must strip it.
|
|
343
|
+
expect((rid as any).constructor?.name).toBe('bound FakeRecordId');
|
|
344
|
+
expect(rid.toString()).toBe('game:a');
|
|
345
|
+
expect(`${rid.tb}:${rid.id}`).toBe('game:a');
|
|
346
|
+
// `snapshot` must unwrap back to the raw instance so payloads passed to
|
|
347
|
+
// surrealdb (which checks instanceof) can be de-proxied at the boundary.
|
|
348
|
+
const snap = snapshot(rows[0]);
|
|
349
|
+
expect(snap.rid instanceof FakeRecordId).toBe(true);
|
|
350
|
+
dispose();
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('rc.0 packaging: markRaw is typed but absent from the runtime build', () => {
|
|
355
|
+
// store.d.ts declares markRaw, but dist/dev.js does not export it. If this
|
|
356
|
+
// starts passing after a bump, adopt markRaw for RecordId/CrdtField
|
|
357
|
+
// instances at the ingest boundary and drop the snapshot() workaround.
|
|
358
|
+
expect((signals as any).markRaw).toBeUndefined();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe('probe: async-generator memo (fromSubscription shape)', () => {
|
|
363
|
+
it('memo over async generator with loadingValue serves values', async () => {
|
|
364
|
+
const src = pushSource<number>();
|
|
365
|
+
await createRoot(async (dispose) => {
|
|
366
|
+
const v = createMemo(
|
|
367
|
+
async function* (): AsyncGenerator<number> {
|
|
368
|
+
for await (const x of iterate<number>((cb) => src.subscribe(cb))) yield x;
|
|
369
|
+
},
|
|
370
|
+
{ loadingValue: -1 }
|
|
371
|
+
);
|
|
372
|
+
let latest: number | undefined;
|
|
373
|
+
createEffect(
|
|
374
|
+
() => v(),
|
|
375
|
+
(x) => {
|
|
376
|
+
latest = x;
|
|
377
|
+
}
|
|
378
|
+
);
|
|
379
|
+
flush();
|
|
380
|
+
await tick();
|
|
381
|
+
expect(latest).toBe(-1); // loadingValue committed
|
|
382
|
+
src.emit(42);
|
|
383
|
+
await tick();
|
|
384
|
+
flush();
|
|
385
|
+
expect(latest).toBe(42);
|
|
386
|
+
dispose();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
});
|