@signalium/query 0.0.0

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,225 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createPathInterpolator } from '../pathInterpolator.js';
3
+
4
+ describe('createPathInterpolator', () => {
5
+ describe('basic path interpolation', () => {
6
+ it('should interpolate a single parameter', () => {
7
+ const interpolate = createPathInterpolator('/users/[userId]');
8
+ const result = interpolate({ userId: '123' });
9
+ expect(result).toBe('/users/123');
10
+ });
11
+
12
+ it('should interpolate multiple parameters', () => {
13
+ const interpolate = createPathInterpolator('/users/[userId]/posts/[postId]');
14
+ const result = interpolate({ userId: '123', postId: '456' });
15
+ expect(result).toBe('/users/123/posts/456');
16
+ });
17
+
18
+ it('should handle consecutive parameters', () => {
19
+ const interpolate = createPathInterpolator('/items/[category][subcategory]');
20
+ const result = interpolate({ category: 'books', subcategory: 'fiction' });
21
+ expect(result).toBe('/items/booksfiction');
22
+ });
23
+
24
+ it('should handle path with no parameters', () => {
25
+ const interpolate = createPathInterpolator('/static/path');
26
+ const result = interpolate({});
27
+ expect(result).toBe('/static/path');
28
+ });
29
+
30
+ it('should handle path starting with parameter', () => {
31
+ const interpolate = createPathInterpolator('[tenant]/users/[userId]');
32
+ const result = interpolate({ tenant: 'acme', userId: '123' });
33
+ expect(result).toBe('acme/users/123');
34
+ });
35
+
36
+ it('should handle path ending with parameter', () => {
37
+ const interpolate = createPathInterpolator('/users/[userId]');
38
+ const result = interpolate({ userId: '123' });
39
+ expect(result).toBe('/users/123');
40
+ });
41
+ });
42
+
43
+ describe('URL encoding', () => {
44
+ it('should URL-encode special characters in path parameters', () => {
45
+ const interpolate = createPathInterpolator('/users/[userId]');
46
+ const result = interpolate({ userId: 'user@example.com' });
47
+ expect(result).toBe('/users/user%40example.com');
48
+ });
49
+
50
+ it('should URL-encode spaces', () => {
51
+ const interpolate = createPathInterpolator('/search/[query]');
52
+ const result = interpolate({ query: 'hello world' });
53
+ expect(result).toBe('/search/hello%20world');
54
+ });
55
+
56
+ it('should URL-encode forward slashes', () => {
57
+ const interpolate = createPathInterpolator('/files/[path]');
58
+ const result = interpolate({ path: 'folder/subfolder/file.txt' });
59
+ expect(result).toBe('/files/folder%2Fsubfolder%2Ffile.txt');
60
+ });
61
+
62
+ it('should handle unicode characters', () => {
63
+ const interpolate = createPathInterpolator('/items/[name]');
64
+ const result = interpolate({ name: '日本語' });
65
+ expect(result).toBe('/items/%E6%97%A5%E6%9C%AC%E8%AA%9E');
66
+ });
67
+ });
68
+
69
+ describe('query string parameters', () => {
70
+ it('should append extra parameters as query string', () => {
71
+ const interpolate = createPathInterpolator('/users/[userId]');
72
+ const result = interpolate({ userId: '123', page: 2, limit: 10 });
73
+ expect(result).toBe('/users/123?page=2&limit=10');
74
+ });
75
+
76
+ it('should append all non-path parameters as query string', () => {
77
+ const interpolate = createPathInterpolator('/users/[userId]/posts/[postId]');
78
+ const result = interpolate({
79
+ userId: '123',
80
+ postId: '456',
81
+ page: 2,
82
+ limit: 10,
83
+ sort: 'desc',
84
+ });
85
+ expect(result).toBe('/users/123/posts/456?page=2&limit=10&sort=desc');
86
+ });
87
+
88
+ it('should handle only query parameters when path has no params', () => {
89
+ const interpolate = createPathInterpolator('/search');
90
+ const result = interpolate({ q: 'test', page: 1 });
91
+ expect(result).toBe('/search?q=test&page=1');
92
+ });
93
+
94
+ it('should skip undefined query parameters', () => {
95
+ const interpolate = createPathInterpolator('/users/[userId]');
96
+ const result = interpolate({ userId: '123', page: 2, limit: undefined });
97
+ expect(result).toBe('/users/123?page=2');
98
+ });
99
+
100
+ it('should include null and empty string values in query params', () => {
101
+ const interpolate = createPathInterpolator('/users/[userId]');
102
+ const result = interpolate({ userId: '123', filter: null, name: '' });
103
+ expect(result).toBe('/users/123?filter=null&name=');
104
+ });
105
+
106
+ it('should handle boolean query parameters', () => {
107
+ const interpolate = createPathInterpolator('/items');
108
+ const result = interpolate({ active: true, deleted: false });
109
+ expect(result).toBe('/items?active=true&deleted=false');
110
+ });
111
+
112
+ it('should handle numeric query parameters', () => {
113
+ const interpolate = createPathInterpolator('/items');
114
+ const result = interpolate({ id: 0, count: 100 });
115
+ expect(result).toBe('/items?id=0&count=100');
116
+ });
117
+ });
118
+
119
+ describe('type coercion', () => {
120
+ it('should convert numeric path parameters to string', () => {
121
+ const interpolate = createPathInterpolator('/users/[userId]');
122
+ const result = interpolate({ userId: 123 });
123
+ expect(result).toBe('/users/123');
124
+ });
125
+
126
+ it('should convert boolean path parameters to string', () => {
127
+ const interpolate = createPathInterpolator('/settings/[enabled]');
128
+ const result = interpolate({ enabled: true });
129
+ expect(result).toBe('/settings/true');
130
+ });
131
+
132
+ it('should convert null path parameters to string', () => {
133
+ const interpolate = createPathInterpolator('/items/[id]');
134
+ const result = interpolate({ id: null });
135
+ expect(result).toBe('/items/null');
136
+ });
137
+
138
+ it('should handle object conversion to string', () => {
139
+ const interpolate = createPathInterpolator('/items/[id]');
140
+ const result = interpolate({ id: { value: 123 } });
141
+ expect(result).toBe('/items/%5Bobject%20Object%5D');
142
+ });
143
+ });
144
+
145
+ describe('edge cases', () => {
146
+ it('should handle empty path template', () => {
147
+ const interpolate = createPathInterpolator('');
148
+ const result = interpolate({});
149
+ expect(result).toBe('');
150
+ });
151
+
152
+ it('should handle empty params object', () => {
153
+ const interpolate = createPathInterpolator('/users/[userId]/posts/[postId]');
154
+ const result = interpolate({});
155
+ expect(result).toBe('/users/undefined/posts/undefined');
156
+ });
157
+
158
+ it('should handle missing path parameter values', () => {
159
+ const interpolate = createPathInterpolator('/users/[userId]/posts/[postId]');
160
+ const result = interpolate({ userId: '123' });
161
+ expect(result).toBe('/users/123/posts/undefined');
162
+ });
163
+
164
+ it('should handle parameter names with underscores', () => {
165
+ const interpolate = createPathInterpolator('/users/[user_id]');
166
+ const result = interpolate({ user_id: '123' });
167
+ expect(result).toBe('/users/123');
168
+ });
169
+
170
+ it('should handle parameter names with hyphens', () => {
171
+ const interpolate = createPathInterpolator('/users/[user-id]');
172
+ const result = interpolate({ 'user-id': '123' });
173
+ expect(result).toBe('/users/123');
174
+ });
175
+
176
+ it('should handle parameter names with numbers', () => {
177
+ const interpolate = createPathInterpolator('/items/[item1]/[item2]');
178
+ const result = interpolate({ item1: 'first', item2: 'second' });
179
+ expect(result).toBe('/items/first/second');
180
+ });
181
+
182
+ it('should be reusable for multiple interpolations', () => {
183
+ const interpolate = createPathInterpolator('/users/[userId]');
184
+
185
+ const result1 = interpolate({ userId: '123' });
186
+ const result2 = interpolate({ userId: '456' });
187
+ const result3 = interpolate({ userId: '789', page: 1 });
188
+
189
+ expect(result1).toBe('/users/123');
190
+ expect(result2).toBe('/users/456');
191
+ expect(result3).toBe('/users/789?page=1');
192
+ });
193
+
194
+ it('should handle complex real-world example', () => {
195
+ const interpolate = createPathInterpolator('/api/v1/tenants/[tenantId]/users/[userId]/documents/[documentId]');
196
+ const result = interpolate({
197
+ tenantId: 'acme-corp',
198
+ userId: 'user@example.com',
199
+ documentId: '12345',
200
+ version: 2,
201
+ format: 'pdf',
202
+ download: true,
203
+ });
204
+ expect(result).toBe(
205
+ '/api/v1/tenants/acme-corp/users/user%40example.com/documents/12345?version=2&format=pdf&download=true',
206
+ );
207
+ });
208
+ });
209
+
210
+ describe('performance characteristics', () => {
211
+ it('should create the interpolator once and reuse it efficiently', () => {
212
+ const interpolate = createPathInterpolator('/users/[userId]/posts/[postId]');
213
+
214
+ // Simulate multiple calls (as would happen in production)
215
+ const results = [];
216
+ for (let i = 0; i < 1000; i++) {
217
+ results.push(interpolate({ userId: `user${i}`, postId: `post${i}` }));
218
+ }
219
+
220
+ expect(results[0]).toBe('/users/user0/posts/post0');
221
+ expect(results[999]).toBe('/users/user999/posts/post999');
222
+ expect(results.length).toBe(1000);
223
+ });
224
+ });
225
+ });
@@ -0,0 +1,420 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { SyncQueryStore, MemoryPersistentStore } from '../QueryStore.js';
3
+ import { QueryClient, QueryClientContext } from '../QueryClient.js';
4
+ import { entity, t } from '../typeDefs.js';
5
+ import { query } from '../query.js';
6
+ import { watcher, withContexts, reactive } from 'signalium';
7
+ import { createMockFetch, testWithClient } from './utils.js';
8
+
9
+ /**
10
+ * Signalium Reactivity Tests
11
+ *
12
+ * Tests relay lifecycle, reactive computations, watcher behavior,
13
+ * and entity update propagation.
14
+ */
15
+
16
+ describe('Signalium Reactivity', () => {
17
+ let client: QueryClient;
18
+ let mockFetch: ReturnType<typeof createMockFetch>;
19
+ let kv: any;
20
+ let store: any;
21
+
22
+ beforeEach(() => {
23
+ kv = new MemoryPersistentStore();
24
+ store = new SyncQueryStore(kv);
25
+ mockFetch = createMockFetch();
26
+ client = new QueryClient(store, { fetch: mockFetch as any });
27
+ });
28
+
29
+ describe('Relay Lifecycle', () => {
30
+ it('should start relay in pending state', async () => {
31
+ mockFetch.get('/item', { data: 'test' }, { delay: 100 });
32
+
33
+ await testWithClient(client, async () => {
34
+ const getItem = query(t => ({
35
+ path: '/item',
36
+ response: { data: t.string },
37
+ }));
38
+
39
+ const relay = getItem();
40
+
41
+ // Relay should exist and be in pending state
42
+ expect(relay).toBeDefined();
43
+ expect(relay.isPending).toBe(true);
44
+ });
45
+ });
46
+
47
+ it('should transition to resolved state with data', async () => {
48
+ mockFetch.get('/item', { data: 'test' });
49
+
50
+ await testWithClient(client, async () => {
51
+ const getItem = query(t => ({
52
+ path: '/item',
53
+ response: { data: t.string },
54
+ }));
55
+
56
+ const relay = getItem();
57
+ await relay;
58
+
59
+ expect(relay.isResolved).toBe(true);
60
+ expect(relay.isReady).toBe(true);
61
+ expect(relay.isPending).toBe(false);
62
+ expect(relay.value).toEqual({ data: 'test' });
63
+ });
64
+ });
65
+
66
+ it('should transition to error state on failure', async () => {
67
+ const error = new Error('Failed to fetch');
68
+ mockFetch.get('/item', null, { error });
69
+
70
+ await testWithClient(client, async () => {
71
+ const getItem = query(t => ({
72
+ path: '/item',
73
+ response: { data: t.string },
74
+ }));
75
+
76
+ const relay = getItem();
77
+ await expect(relay).rejects.toThrow('Failed to fetch');
78
+
79
+ expect(relay.isRejected).toBe(true);
80
+ expect(relay.error).toBe(error);
81
+ });
82
+ });
83
+ });
84
+
85
+ describe('Reactive Computations', () => {
86
+ it('should support reactive functions depending on query relay', async () => {
87
+ mockFetch.get('/counter', { count: 5 });
88
+
89
+ await testWithClient(client, async () => {
90
+ const getCounter = query(t => ({
91
+ path: '/counter',
92
+ response: { count: t.number },
93
+ }));
94
+
95
+ const relay = getCounter();
96
+
97
+ // Create a reactive function that depends on the relay
98
+ const doubled = reactive(() => {
99
+ if (relay.isReady) {
100
+ return relay.value.count * 2;
101
+ }
102
+ return 0;
103
+ });
104
+
105
+ await relay;
106
+
107
+ // Wait for reactive computation
108
+ const result = doubled();
109
+
110
+ expect(result).toBe(10);
111
+ });
112
+ });
113
+
114
+ it('should support nested reactive functions', async () => {
115
+ mockFetch.get('/value', { value: 10 });
116
+
117
+ await testWithClient(client, async () => {
118
+ const getValue = query(t => ({
119
+ path: '/value',
120
+ response: { value: t.number },
121
+ }));
122
+
123
+ const relay = getValue();
124
+
125
+ const doubled = reactive(() => {
126
+ if (relay.isReady) {
127
+ return relay.value.value * 2;
128
+ }
129
+ return 0;
130
+ });
131
+
132
+ const tripled = reactive(() => {
133
+ return doubled() * 1.5;
134
+ });
135
+
136
+ await relay;
137
+
138
+ expect(doubled()).toBe(20);
139
+ expect(tripled()).toBe(30);
140
+ });
141
+ });
142
+
143
+ it('should support conditional reactivity', async () => {
144
+ mockFetch.get('/config', { value: 5, shouldDouble: true });
145
+
146
+ await testWithClient(client, async () => {
147
+ const getConfig = query(t => ({
148
+ path: '/config',
149
+ response: { value: t.number, shouldDouble: t.boolean },
150
+ }));
151
+
152
+ const relay = getConfig();
153
+
154
+ const computed = reactive(() => {
155
+ if (relay.isReady) {
156
+ const config = relay.value;
157
+ return config.shouldDouble ? config.value * 2 : config.value;
158
+ }
159
+ return 0;
160
+ });
161
+
162
+ await relay;
163
+
164
+ expect(computed()).toBe(10);
165
+ });
166
+ });
167
+ });
168
+
169
+ describe('Promise State Tracking', () => {
170
+ it('should track isPending state', async () => {
171
+ mockFetch.get('/item', { data: 'test' }, { delay: 50 });
172
+
173
+ await testWithClient(client, async () => {
174
+ const getItem = query(t => ({
175
+ path: '/item',
176
+ response: { data: t.string },
177
+ }));
178
+
179
+ const relay = getItem();
180
+
181
+ // Initially should be pending
182
+ expect(relay.isPending).toBe(true);
183
+
184
+ await relay;
185
+
186
+ // After resolution should not be pending
187
+ expect(relay.isPending).toBe(false);
188
+ expect(relay.isSettled).toBe(true);
189
+ });
190
+ });
191
+
192
+ it('should track isReady state correctly', async () => {
193
+ mockFetch.get('/item', { data: 'test' });
194
+
195
+ await testWithClient(client, async () => {
196
+ const getItem = query(t => ({
197
+ path: '/item',
198
+ response: { data: t.string },
199
+ }));
200
+
201
+ const relay = getItem();
202
+
203
+ await relay;
204
+
205
+ // After resolution should be ready
206
+ expect(relay.isReady).toBe(true);
207
+ expect(relay.value).toEqual({ data: 'test' });
208
+ });
209
+ });
210
+
211
+ it('should track isResolved state', async () => {
212
+ mockFetch.get('/item', { success: true });
213
+
214
+ await testWithClient(client, async () => {
215
+ const getItem = query(t => ({
216
+ path: '/item',
217
+ response: { success: t.boolean },
218
+ }));
219
+
220
+ const relay = getItem();
221
+ await relay;
222
+
223
+ expect(relay.isResolved).toBe(true);
224
+ expect(relay.isRejected).toBe(false);
225
+ });
226
+ });
227
+
228
+ it('should track isRejected state', async () => {
229
+ mockFetch.get('/item', null, { error: new Error('Failed') });
230
+
231
+ await testWithClient(client, async () => {
232
+ const getItem = query(t => ({
233
+ path: '/item',
234
+ response: { success: t.boolean },
235
+ }));
236
+
237
+ const relay = getItem();
238
+ await expect(relay).rejects.toThrow('Failed');
239
+
240
+ expect(relay.isRejected).toBe(true);
241
+ expect(relay.isResolved).toBe(false);
242
+ });
243
+ });
244
+ });
245
+
246
+ describe('Reactive Query Patterns', () => {
247
+ it('should support query results in reactive computations', async () => {
248
+ mockFetch.get('/users', {
249
+ users: [
250
+ { id: 1, name: 'Alice' },
251
+ { id: 2, name: 'Bob' },
252
+ ],
253
+ });
254
+
255
+ await testWithClient(client, async () => {
256
+ const getUsers = query(t => ({
257
+ path: '/users',
258
+ response: {
259
+ users: t.array(t.object({ id: t.number, name: t.string })),
260
+ },
261
+ }));
262
+
263
+ const relay = getUsers();
264
+
265
+ const userCount = reactive(() => {
266
+ if (relay.isReady) {
267
+ return relay.value.users.length;
268
+ }
269
+ return 0;
270
+ });
271
+
272
+ const firstUserName = reactive(() => {
273
+ if (relay.isReady && relay.value.users.length > 0) {
274
+ return relay.value.users[0].name;
275
+ }
276
+ return 'Unknown';
277
+ });
278
+
279
+ await relay;
280
+
281
+ expect(userCount()).toBe(2);
282
+ expect(firstUserName()).toBe('Alice');
283
+ });
284
+ });
285
+
286
+ it('should handle conditional query access', async () => {
287
+ mockFetch.get('/config', { enabled: true, data: 'test' });
288
+
289
+ await testWithClient(client, async () => {
290
+ const getConfig = query(t => ({
291
+ path: '/config',
292
+ response: { enabled: t.boolean, data: t.string },
293
+ }));
294
+
295
+ const relay = getConfig();
296
+
297
+ const result = reactive(() => {
298
+ if (relay.isReady) {
299
+ const config = relay.value;
300
+ // Conditional access - only read data if enabled
301
+ return config.enabled ? config.data : 'disabled';
302
+ }
303
+ return 'loading';
304
+ });
305
+
306
+ expect(result()).toBe('loading');
307
+
308
+ await relay;
309
+
310
+ expect(result()).toBe('test');
311
+ });
312
+ });
313
+ });
314
+
315
+ describe('Concurrent Query Handling', () => {
316
+ it('should handle concurrent queries without interference', async () => {
317
+ // Set up mocks for different IDs
318
+ mockFetch.get('/items/1', { url: '/items/1', timestamp: Date.now() });
319
+ mockFetch.get('/items/2', { url: '/items/2', timestamp: Date.now() });
320
+ mockFetch.get('/items/3', { url: '/items/3', timestamp: Date.now() });
321
+
322
+ await testWithClient(client, async () => {
323
+ const getItem = query(t => ({
324
+ path: '/items/[id]',
325
+ response: { url: t.string, timestamp: t.number },
326
+ }));
327
+
328
+ // Start multiple concurrent queries
329
+ const relay1 = getItem({ id: '1' });
330
+ const relay2 = getItem({ id: '2' });
331
+ const relay3 = getItem({ id: '3' });
332
+
333
+ const [result1, result2, result3] = await Promise.all([relay1, relay2, relay3]);
334
+
335
+ expect(result1.url).toContain('/items/1');
336
+ expect(result2.url).toContain('/items/2');
337
+ expect(result3.url).toContain('/items/3');
338
+ });
339
+ });
340
+
341
+ it('should deduplicate concurrent identical requests', async () => {
342
+ // Set up single mock with delay - should only be called once
343
+ mockFetch.get('/item', { count: 1 }, { delay: 50 });
344
+
345
+ await testWithClient(client, async () => {
346
+ const getItem = query(t => ({
347
+ path: '/item',
348
+ response: { count: t.number },
349
+ }));
350
+
351
+ // Start multiple concurrent identical requests
352
+ const relay1 = getItem();
353
+ const relay2 = getItem();
354
+ const relay3 = getItem();
355
+
356
+ // Should be same relay
357
+ expect(relay1).toBe(relay2);
358
+ expect(relay2).toBe(relay3);
359
+
360
+ const [result1, result2, result3] = await Promise.all([relay1, relay2, relay3]);
361
+
362
+ // Should only fetch once
363
+ expect(mockFetch.calls).toHaveLength(1);
364
+
365
+ // All should have same result
366
+ expect(result1).toEqual(result2);
367
+ expect(result2).toEqual(result3);
368
+ });
369
+ });
370
+ });
371
+
372
+ describe('Error State Reactivity', () => {
373
+ it('should notify watchers on error', async () => {
374
+ const error = new Error('Network error');
375
+ mockFetch.get('/item', null, { error });
376
+
377
+ await testWithClient(client, async () => {
378
+ const getItem = query(t => ({
379
+ path: '/item',
380
+ response: { data: t.string },
381
+ }));
382
+
383
+ const relay = getItem();
384
+ let errorCaught = false;
385
+
386
+ const w = watcher(() => {
387
+ if (relay.isRejected) {
388
+ errorCaught = true;
389
+ }
390
+ });
391
+
392
+ const unsub = w.addListener(() => {});
393
+
394
+ await expect(relay).rejects.toThrow('Network error');
395
+ await new Promise(resolve => setTimeout(resolve, 10));
396
+
397
+ expect(errorCaught).toBe(true);
398
+
399
+ unsub();
400
+ });
401
+ });
402
+
403
+ it('should expose error object on relay', async () => {
404
+ const error = new Error('Custom error');
405
+ mockFetch.get('/item', null, { error });
406
+
407
+ await testWithClient(client, async () => {
408
+ const getItem = query(t => ({
409
+ path: '/item',
410
+ response: { data: t.string },
411
+ }));
412
+
413
+ const relay = getItem();
414
+ await expect(relay).rejects.toThrow('Custom error');
415
+
416
+ expect(relay.error).toBe(error);
417
+ });
418
+ });
419
+ });
420
+ });