svelte-ag 1.0.57 → 1.0.59
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/lib/api/query/cache.unit.test.d.ts +2 -0
- package/dist/lib/api/query/cache.unit.test.d.ts.map +1 -0
- package/dist/lib/api/query/cache.unit.test.js +50 -0
- package/dist/lib/api/query/entrypoint.unit.test.d.ts +2 -0
- package/dist/lib/api/query/entrypoint.unit.test.d.ts.map +1 -0
- package/dist/lib/api/query/entrypoint.unit.test.js +56 -0
- package/dist/lib/api/query/query.svelte.d.ts +1 -1
- package/dist/lib/api/query/query.svelte.d.ts.map +1 -1
- package/dist/lib/api/query/query.svelte.js +59 -23
- package/dist/lib/api/query/query.unit.test.d.ts +2 -0
- package/dist/lib/api/query/query.unit.test.d.ts.map +1 -0
- package/dist/lib/api/query/query.unit.test.js +366 -0
- package/dist/test/vitest.setup.d.ts +1 -0
- package/dist/test/vitest.setup.d.ts.map +1 -0
- package/dist/test/vitest.setup.js +4 -0
- package/package.json +2 -2
- package/src/lib/api/query/cache.unit.test.ts +66 -0
- package/src/lib/api/query/entrypoint.unit.test.ts +85 -0
- package/src/lib/api/query/query.svelte.ts +70 -27
- package/src/lib/api/query/query.unit.test.ts +500 -0
- package/src/test/vitest.setup.ts +3 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache.unit.test.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/cache.unit.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { Cache } from './cache.svelte.js';
|
|
3
|
+
describe('Cache', () => {
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
vi.useFakeTimers();
|
|
6
|
+
vi.setSystemTime(0);
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.useRealTimers();
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
it('stores and expires values based on timeout', () => {
|
|
13
|
+
const cache = new Cache();
|
|
14
|
+
cache.register('user', { timeout: 100 });
|
|
15
|
+
cache.set('user', { id: 1 });
|
|
16
|
+
expect(cache.has('user')).toBe(true);
|
|
17
|
+
expect(cache.get('user')).toEqual({ id: 1 });
|
|
18
|
+
vi.advanceTimersByTime(99);
|
|
19
|
+
expect(cache.has('user')).toBe(true);
|
|
20
|
+
expect(cache.get('user')).toEqual({ id: 1 });
|
|
21
|
+
vi.advanceTimersByTime(1);
|
|
22
|
+
expect(cache.has('user')).toBe(false);
|
|
23
|
+
expect(cache.get('user')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
it('supports infinite timeout and reset', () => {
|
|
26
|
+
const cache = new Cache();
|
|
27
|
+
cache.register('settings', { timeout: 'inf' });
|
|
28
|
+
cache.set('settings', { theme: 'light' });
|
|
29
|
+
vi.advanceTimersByTime(10_000);
|
|
30
|
+
expect(cache.has('settings')).toBe(true);
|
|
31
|
+
expect(cache.get('settings')).toEqual({ theme: 'light' });
|
|
32
|
+
cache.reset('settings');
|
|
33
|
+
expect(cache.has('settings')).toBe(false);
|
|
34
|
+
expect(cache.get('settings')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
it('deregisters keys completely', () => {
|
|
37
|
+
const cache = new Cache();
|
|
38
|
+
cache.register('token', { timeout: 100 });
|
|
39
|
+
cache.set('token', 'abc');
|
|
40
|
+
cache.deregister('token');
|
|
41
|
+
expect(cache.has('token')).toBe(false);
|
|
42
|
+
expect(() => cache.get('token')).toThrow('The key token is not registered in the cache');
|
|
43
|
+
});
|
|
44
|
+
it('throws when mutating an unregistered key', () => {
|
|
45
|
+
const cache = new Cache();
|
|
46
|
+
expect(() => cache.set('missing', 1)).toThrow('The key missing is not registered in the cache');
|
|
47
|
+
expect(() => cache.get('missing')).toThrow('The key missing is not registered in the cache');
|
|
48
|
+
expect(() => cache.reset('missing')).toThrow('The key missing is not registered in the cache');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"entrypoint.unit.test.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/entrypoint.unit.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
function getUserId(input) {
|
|
3
|
+
return 'id' in input ? input.id : input.ids[0];
|
|
4
|
+
}
|
|
5
|
+
function jsonResponse(body, status = 200) {
|
|
6
|
+
return new Response(JSON.stringify(body), {
|
|
7
|
+
status,
|
|
8
|
+
headers: { 'content-type': 'application/json' }
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
describe('createQueryFunction', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.useFakeTimers();
|
|
14
|
+
vi.setSystemTime(0);
|
|
15
|
+
vi.resetModules();
|
|
16
|
+
});
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
it('returns the same query instance for the same path, method, and input', async () => {
|
|
22
|
+
const { createQueryFunction } = await import('./entrypoint.svelte.js');
|
|
23
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
|
|
24
|
+
const request = requestMock;
|
|
25
|
+
const createQuery = createQueryFunction(request, {});
|
|
26
|
+
const query1 = createQuery('/users', 'GET', { id: 1 });
|
|
27
|
+
const query2 = createQuery('/users', 'GET', { id: 1 });
|
|
28
|
+
const query3 = createQuery('/users', 'GET', { id: 2 });
|
|
29
|
+
expect(query1).toBe(query2);
|
|
30
|
+
expect(query3).not.toBe(query1);
|
|
31
|
+
});
|
|
32
|
+
it('reuses requestors so separate queries can batch together', async () => {
|
|
33
|
+
const { createQueryFunction } = await import('./entrypoint.svelte.js');
|
|
34
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
|
|
35
|
+
const request = requestMock;
|
|
36
|
+
const createQuery = createQueryFunction(request, {
|
|
37
|
+
'/users': {
|
|
38
|
+
GET: {
|
|
39
|
+
canBatch: () => 'users',
|
|
40
|
+
batchInput: (inputs) => ({ ids: inputs.map(getUserId) }),
|
|
41
|
+
unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getUserId(input) }))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const query1 = createQuery('/users', 'GET', { id: 1 });
|
|
46
|
+
const query2 = createQuery('/users', 'GET', { id: 2 });
|
|
47
|
+
const p1 = query1.request();
|
|
48
|
+
const p2 = query2.request();
|
|
49
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
50
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
|
|
52
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
53
|
+
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
54
|
+
await expect(response2.json()).resolves.toEqual({ id: 2 });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -6,7 +6,7 @@ export declare class Query<API extends ApiEndpoints, Path extends API['path'], M
|
|
|
6
6
|
path: Path;
|
|
7
7
|
}>['method']> {
|
|
8
8
|
#private;
|
|
9
|
-
constructor({ path, method, input, requestor, cache }: {
|
|
9
|
+
constructor({ path, method, input, requestor, cache, opts }: {
|
|
10
10
|
path: Path;
|
|
11
11
|
method: Method;
|
|
12
12
|
input: ApiInput<API, Path, Method>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/query.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,kBAAkB,EAAE,cAAc,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AAGnH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAG5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"query.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/query.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,kBAAkB,EAAE,cAAc,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AAGnH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAG5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxD,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAC;AA2BnE,qBAAa,KAAK,CAChB,GAAG,SAAS,YAAY,EACxB,IAAI,SAAS,GAAG,CAAC,MAAM,CAAC,EACxB,MAAM,SAAS,OAAO,CAAC,GAAG,EAAE;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,CAAC,CAAC,QAAQ,CAAC;;gBAyBzC,EACV,IAAI,EACJ,MAAM,EACN,KAAK,EACL,SAAS,EACT,KAAK,EACL,IAAI,EACL,EAAE;QACD,IAAI,EAAE,IAAI,CAAC;QACX,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,QAAQ,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;QACnC,SAAS,EAAE,SAAS,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;QACxC,KAAK,EAAE,KAAK,CAAC;QACb,IAAI,CAAC,EAAE;YACL,KAAK,CAAC,EAAE,UAAU,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;SAC1C,CAAC;KACH;IAgBK,OAAO,IAAI,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;IAyCxD,IAAI,QAAQ,WAEX;IACD,IAAI,QAAQ,YAEX;IACD,UAAU;IAMV,IAAI,MAAM,gBAET;IACD,IAAI,IAAI,IAAI,cAAc,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,IAAI,CAEnD;IACD,IAAI,SAAS,IAAI,YAAY,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,IAAI,CAEtD;CACF;AAED,qBAAa,SAAS,CACpB,GAAG,SAAS,YAAY,EACxB,IAAI,SAAS,GAAG,CAAC,MAAM,CAAC,EACxB,MAAM,SAAS,OAAO,CAAC,GAAG,EAAE;IAAE,IAAI,EAAE,IAAI,CAAA;CAAE,CAAC,CAAC,QAAQ,CAAC;;gBA6BnD,IAAI,EAAE,IAAI,EACV,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,kBAAkB,CAAC,GAAG,CAAC,EAChC,MAAM,EAAE,KAAK,EACb,YAAY,CAAC,EAAE,YAAY,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC;YAelC,KAAK;IAOnB;;;OAGG;YACW,eAAe;IA2BvB,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;CAmB3F"}
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { stringify } from 'devalue';
|
|
2
2
|
import { cacheKey } from './utils.svelte.js';
|
|
3
3
|
import { RateLimiter } from './rate.svelte';
|
|
4
|
+
function copyOwnOverrides(source, target) {
|
|
5
|
+
for (const key of Reflect.ownKeys(source)) {
|
|
6
|
+
const descriptor = Object.getOwnPropertyDescriptor(source, key);
|
|
7
|
+
if (!descriptor)
|
|
8
|
+
continue;
|
|
9
|
+
Object.defineProperty(target, key, descriptor);
|
|
10
|
+
}
|
|
11
|
+
return target;
|
|
12
|
+
}
|
|
13
|
+
function cloneResponse(response) {
|
|
14
|
+
if (response !== null &&
|
|
15
|
+
typeof response === 'object' &&
|
|
16
|
+
'clone' in response &&
|
|
17
|
+
typeof response.clone === 'function') {
|
|
18
|
+
const source = response;
|
|
19
|
+
return copyOwnOverrides(source, response.clone());
|
|
20
|
+
}
|
|
21
|
+
return response;
|
|
22
|
+
}
|
|
4
23
|
export class Query {
|
|
5
24
|
// -------- Constants --------
|
|
6
25
|
#TIMEOUT = 1000 * 60 * 5; // 5 minutes
|
|
@@ -20,7 +39,7 @@ export class Query {
|
|
|
20
39
|
#data = $state(null);
|
|
21
40
|
#errorData = $state(null);
|
|
22
41
|
// -------- Functions --------
|
|
23
|
-
constructor({ path, method, input, requestor, cache }) {
|
|
42
|
+
constructor({ path, method, input, requestor, cache, opts }) {
|
|
24
43
|
this.#requestor = requestor;
|
|
25
44
|
this.#cache = cache;
|
|
26
45
|
this.#path = path;
|
|
@@ -29,32 +48,43 @@ export class Query {
|
|
|
29
48
|
this.#input = input;
|
|
30
49
|
this.#inputString = stringify(input);
|
|
31
50
|
this.#cacheKey = cacheKey(path, method, input);
|
|
32
|
-
this.#cache.register(this.#cacheKey, { timeout: this.#TIMEOUT });
|
|
51
|
+
this.#cache.register(this.#cacheKey, opts?.cache ?? { timeout: this.#TIMEOUT });
|
|
33
52
|
}
|
|
34
53
|
async request() {
|
|
35
54
|
const cachedValue = this.#cache.get(this.#cacheKey);
|
|
36
55
|
if (cachedValue !== null) {
|
|
37
|
-
return cachedValue;
|
|
56
|
+
return cloneResponse(cachedValue);
|
|
38
57
|
}
|
|
39
58
|
this.#status = 'loading';
|
|
40
59
|
if (this.#pendingRequest === null) {
|
|
41
60
|
this.#pendingRequest = this.#requestor.request(this.#input);
|
|
42
61
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
62
|
+
let res;
|
|
63
|
+
try {
|
|
64
|
+
res = await this.#pendingRequest;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
this.#status = 'error';
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
this.#pendingRequest = null;
|
|
72
|
+
}
|
|
73
|
+
const responseForState = cloneResponse(res);
|
|
74
|
+
const responseForCaller = cloneResponse(res);
|
|
75
|
+
this.#cache.set(this.#cacheKey, cloneResponse(res));
|
|
76
|
+
if (responseForState.ok === false) {
|
|
77
|
+
const body = await responseForState.json();
|
|
48
78
|
this.#status = 'error';
|
|
49
79
|
// @ts-expect-error Generics not working for some reason
|
|
50
80
|
this.#errorData = body;
|
|
51
|
-
return
|
|
81
|
+
return responseForCaller;
|
|
52
82
|
}
|
|
53
83
|
else {
|
|
54
|
-
const body = await
|
|
84
|
+
const body = await responseForState.json();
|
|
55
85
|
this.#status = 'success';
|
|
56
86
|
this.#data = body;
|
|
57
|
-
return
|
|
87
|
+
return responseForCaller;
|
|
58
88
|
}
|
|
59
89
|
}
|
|
60
90
|
get cacheKey() {
|
|
@@ -117,17 +147,22 @@ export class Requestor {
|
|
|
117
147
|
*/
|
|
118
148
|
async flushBatchQueue(batchId) {
|
|
119
149
|
const queue = this.#batchQueue[batchId].splice(0);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (output
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
128
|
-
reject(output[i]);
|
|
150
|
+
try {
|
|
151
|
+
const batchedInput = this.#batchInput(queue.map((q) => q.input));
|
|
152
|
+
const res = await this.fetch(batchedInput);
|
|
153
|
+
const output = await this.#unBatchOutput(queue.map((q) => q.input), res);
|
|
154
|
+
if (output.length !== queue.length) {
|
|
155
|
+
throw new Error(`Batch output length mismatch for ${batchId}`);
|
|
129
156
|
}
|
|
130
|
-
|
|
157
|
+
queue.forEach(({ resolve }, i) => {
|
|
158
|
+
resolve(output[i]);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
queue.forEach(({ reject }) => {
|
|
163
|
+
reject(err);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
131
166
|
}
|
|
132
167
|
// Performs a request for a given input. Batches it if possible
|
|
133
168
|
async request(input) {
|
|
@@ -139,8 +174,9 @@ export class Requestor {
|
|
|
139
174
|
this.#batchQueue[batchId].push({ input, resolve, reject });
|
|
140
175
|
if (!this.#batchTimers[batchId]) {
|
|
141
176
|
this.#batchTimers[batchId] = setTimeout(() => {
|
|
142
|
-
this.flushBatchQueue(batchId)
|
|
143
|
-
|
|
177
|
+
void this.flushBatchQueue(batchId).finally(() => {
|
|
178
|
+
delete this.#batchTimers[batchId];
|
|
179
|
+
});
|
|
144
180
|
}, this.#batchDelay);
|
|
145
181
|
}
|
|
146
182
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"query.unit.test.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/query.unit.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createApiRequest } from 'ts-ag';
|
|
3
|
+
import { Cache } from './cache.svelte.js';
|
|
4
|
+
import { Query, Requestor } from './query.svelte.js';
|
|
5
|
+
import { stringify } from 'devalue';
|
|
6
|
+
import * as v from 'valibot';
|
|
7
|
+
function getSingleId(input) {
|
|
8
|
+
return 'id' in input ? input.id : input.ids[0];
|
|
9
|
+
}
|
|
10
|
+
function jsonResponse(body, status = 200) {
|
|
11
|
+
return new Response(JSON.stringify(body), {
|
|
12
|
+
status,
|
|
13
|
+
headers: { 'content-type': 'application/json' }
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function devalueFetchResponse(body, status = 200) {
|
|
17
|
+
return new Response(stringify(body), {
|
|
18
|
+
status,
|
|
19
|
+
headers: { 'content-type': 'application/devalue' }
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function withResponseOverrides(response) {
|
|
23
|
+
Object.defineProperty(response, 'extra', {
|
|
24
|
+
configurable: true,
|
|
25
|
+
value: () => 'copied'
|
|
26
|
+
});
|
|
27
|
+
Object.defineProperty(response, 'meta', {
|
|
28
|
+
configurable: true,
|
|
29
|
+
value: { source: 'custom' }
|
|
30
|
+
});
|
|
31
|
+
return response;
|
|
32
|
+
}
|
|
33
|
+
function deferred() {
|
|
34
|
+
let resolve;
|
|
35
|
+
let reject;
|
|
36
|
+
const promise = new Promise((res, rej) => {
|
|
37
|
+
resolve = res;
|
|
38
|
+
reject = rej;
|
|
39
|
+
});
|
|
40
|
+
return { promise, resolve, reject };
|
|
41
|
+
}
|
|
42
|
+
describe('Requestor', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.useFakeTimers();
|
|
45
|
+
vi.setSystemTime(0);
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.useRealTimers();
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
vi.unstubAllGlobals();
|
|
51
|
+
});
|
|
52
|
+
it('passes through non-batched requests', async () => {
|
|
53
|
+
const requestMock = vi.fn(async () => jsonResponse({ id: 1 }));
|
|
54
|
+
const request = requestMock;
|
|
55
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache());
|
|
56
|
+
const response = await requestor.request({ id: 1 });
|
|
57
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
58
|
+
expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { id: 1 });
|
|
59
|
+
await expect(response.json()).resolves.toEqual({ id: 1 });
|
|
60
|
+
});
|
|
61
|
+
it('devalue response', async () => {
|
|
62
|
+
const fetchMock = vi.fn(async () => devalueFetchResponse({ id: 1 }));
|
|
63
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
64
|
+
const request = createApiRequest({
|
|
65
|
+
'/users': {
|
|
66
|
+
GET: v.object({ id: v.number() })
|
|
67
|
+
}
|
|
68
|
+
}, 'https://api.example.test', 'test');
|
|
69
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache());
|
|
70
|
+
const response = await requestor.request({ id: 1 });
|
|
71
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(fetchMock).toHaveBeenCalledWith('https://api.example.test//users?id=1', expect.objectContaining({
|
|
73
|
+
method: 'GET',
|
|
74
|
+
credentials: 'include'
|
|
75
|
+
}));
|
|
76
|
+
await expect(response.json()).resolves.toEqual({ id: 1 });
|
|
77
|
+
});
|
|
78
|
+
it('batches requests with the same batch id and preserves response order', async () => {
|
|
79
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
|
|
80
|
+
const request = requestMock;
|
|
81
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache(), {
|
|
82
|
+
canBatch: (input) => ('group' in input && input.group) || false,
|
|
83
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
84
|
+
unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getSingleId(input) }))
|
|
85
|
+
});
|
|
86
|
+
const p1 = requestor.request({ id: 1, group: 'team' });
|
|
87
|
+
const p2 = requestor.request({ id: 2, group: 'team' });
|
|
88
|
+
await vi.advanceTimersByTimeAsync(99);
|
|
89
|
+
expect(requestMock).not.toHaveBeenCalled();
|
|
90
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
91
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
|
|
93
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
94
|
+
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
95
|
+
await expect(response2.json()).resolves.toEqual({ id: 2 });
|
|
96
|
+
});
|
|
97
|
+
it('rate limits separate batches by start time rather than completion time', async () => {
|
|
98
|
+
const starts = [];
|
|
99
|
+
const requestMock = vi.fn(async (_path, _method, input) => {
|
|
100
|
+
starts.push(Date.now());
|
|
101
|
+
if ('ids' in input && input.ids[0] === 1) {
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
103
|
+
}
|
|
104
|
+
return jsonResponse({ ids: 'ids' in input ? input.ids : [input.id] });
|
|
105
|
+
});
|
|
106
|
+
const request = requestMock;
|
|
107
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache(), {
|
|
108
|
+
canBatch: (input) => ('group' in input && input.group) || false,
|
|
109
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
110
|
+
unBatchOutput: (_inputs, output) => [output]
|
|
111
|
+
});
|
|
112
|
+
const p1 = requestor.request({ id: 1, group: 'a' });
|
|
113
|
+
const p2 = requestor.request({ id: 2, group: 'b' });
|
|
114
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
115
|
+
expect(starts).toEqual([100]);
|
|
116
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
117
|
+
expect(starts).toEqual([100, 200]);
|
|
118
|
+
await vi.runAllTimersAsync();
|
|
119
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
120
|
+
await expect(response1.json()).resolves.toEqual({ ids: [1] });
|
|
121
|
+
await expect(response2.json()).resolves.toEqual({ ids: [2] });
|
|
122
|
+
});
|
|
123
|
+
it('returns batched error responses without rejecting them', async () => {
|
|
124
|
+
const requestMock = vi.fn(async () => jsonResponse({ ok: false }, 207));
|
|
125
|
+
const request = requestMock;
|
|
126
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache(), {
|
|
127
|
+
canBatch: () => 'team',
|
|
128
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
129
|
+
unBatchOutput: () => [jsonResponse({ message: 'bad request' }, 400)]
|
|
130
|
+
});
|
|
131
|
+
const responsePromise = requestor.request({ id: 1 });
|
|
132
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
133
|
+
const response = await responsePromise;
|
|
134
|
+
expect(response.ok).toBe(false);
|
|
135
|
+
expect(response.status).toBe(400);
|
|
136
|
+
await expect(response.json()).resolves.toEqual({ message: 'bad request' });
|
|
137
|
+
});
|
|
138
|
+
it('rejects all queued callers when a batched fetch throws', async () => {
|
|
139
|
+
const requestMock = vi.fn(async () => {
|
|
140
|
+
throw new Error('network down');
|
|
141
|
+
});
|
|
142
|
+
const request = requestMock;
|
|
143
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache(), {
|
|
144
|
+
canBatch: () => 'team',
|
|
145
|
+
batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
|
|
146
|
+
unBatchOutput: (_inputs, output) => [output]
|
|
147
|
+
});
|
|
148
|
+
const p1 = requestor.request({ id: 1 });
|
|
149
|
+
const p2 = requestor.request({ id: 2 });
|
|
150
|
+
const p1Expectation = expect(p1).rejects.toThrow('network down');
|
|
151
|
+
const p2Expectation = expect(p2).rejects.toThrow('network down');
|
|
152
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
153
|
+
await Promise.all([p1Expectation, p2Expectation]);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('Query', () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
vi.useFakeTimers();
|
|
159
|
+
vi.setSystemTime(0);
|
|
160
|
+
});
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
vi.useRealTimers();
|
|
163
|
+
vi.restoreAllMocks();
|
|
164
|
+
});
|
|
165
|
+
it('deduplicates concurrent requests and returns readable responses to each caller', async () => {
|
|
166
|
+
const pending = deferred();
|
|
167
|
+
const requestMock = vi.fn().mockReturnValue(pending.promise);
|
|
168
|
+
const requestor = {
|
|
169
|
+
request: requestMock
|
|
170
|
+
};
|
|
171
|
+
const query = new Query({
|
|
172
|
+
path: '/users',
|
|
173
|
+
method: 'GET',
|
|
174
|
+
input: { id: 1 },
|
|
175
|
+
requestor,
|
|
176
|
+
cache: new Cache()
|
|
177
|
+
});
|
|
178
|
+
const p1 = query.request();
|
|
179
|
+
const p2 = query.request();
|
|
180
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
181
|
+
pending.resolve(jsonResponse({ id: 1 }));
|
|
182
|
+
const [response1, response2] = await Promise.all([p1, p2]);
|
|
183
|
+
await expect(response1.json()).resolves.toEqual({ id: 1 });
|
|
184
|
+
await expect(response2.json()).resolves.toEqual({ id: 1 });
|
|
185
|
+
});
|
|
186
|
+
it('caches responses and returns a fresh readable clone on cache hits', async () => {
|
|
187
|
+
const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, name: 'Ada' }));
|
|
188
|
+
const requestor = {
|
|
189
|
+
request: requestMock
|
|
190
|
+
};
|
|
191
|
+
const query = new Query({
|
|
192
|
+
path: '/users',
|
|
193
|
+
method: 'GET',
|
|
194
|
+
input: { id: 1 },
|
|
195
|
+
requestor,
|
|
196
|
+
cache: new Cache()
|
|
197
|
+
});
|
|
198
|
+
const first = await query.request();
|
|
199
|
+
const second = await query.request();
|
|
200
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
201
|
+
expect(query.isCached).toBe(true);
|
|
202
|
+
await expect(first.json()).resolves.toEqual({ id: 1, name: 'Ada' });
|
|
203
|
+
await expect(second.json()).resolves.toEqual({ id: 1, name: 'Ada' });
|
|
204
|
+
});
|
|
205
|
+
it('preserves devalue parsing for query state, returned responses, and cache hits', async () => {
|
|
206
|
+
const fetchMock = vi.fn(async () => devalueFetchResponse({ id: 1, createdAt: new Date('2024-01-01T00:00:00.000Z') }));
|
|
207
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
208
|
+
const request = createApiRequest({
|
|
209
|
+
'/users': {
|
|
210
|
+
GET: v.object({ id: v.number() })
|
|
211
|
+
}
|
|
212
|
+
}, 'https://api.example.test', 'test');
|
|
213
|
+
const requestor = new Requestor('/users', 'GET', request, new Cache());
|
|
214
|
+
const query = new Query({
|
|
215
|
+
path: '/users',
|
|
216
|
+
method: 'GET',
|
|
217
|
+
input: { id: 1 },
|
|
218
|
+
requestor,
|
|
219
|
+
cache: new Cache()
|
|
220
|
+
});
|
|
221
|
+
const firstResponse = await query.request();
|
|
222
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(query.status).toBe('success');
|
|
224
|
+
expect(query.data).toEqual({ id: 1, createdAt: new Date('2024-01-01T00:00:00.000Z') });
|
|
225
|
+
await expect(firstResponse.json()).resolves.toEqual({
|
|
226
|
+
id: 1,
|
|
227
|
+
createdAt: new Date('2024-01-01T00:00:00.000Z')
|
|
228
|
+
});
|
|
229
|
+
const cachedResponse = await query.request();
|
|
230
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
231
|
+
await expect(cachedResponse.json()).resolves.toEqual({
|
|
232
|
+
id: 1,
|
|
233
|
+
createdAt: new Date('2024-01-01T00:00:00.000Z')
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
it('preserves arbitrary response overrides across query clones and cache hits', async () => {
|
|
237
|
+
const customResponse = withResponseOverrides(jsonResponse({ id: 1 }));
|
|
238
|
+
const requestMock = vi.fn().mockResolvedValue(customResponse);
|
|
239
|
+
const requestor = {
|
|
240
|
+
request: requestMock
|
|
241
|
+
};
|
|
242
|
+
const query = new Query({
|
|
243
|
+
path: '/users',
|
|
244
|
+
method: 'GET',
|
|
245
|
+
input: { id: 1 },
|
|
246
|
+
requestor,
|
|
247
|
+
cache: new Cache()
|
|
248
|
+
});
|
|
249
|
+
const first = (await query.request());
|
|
250
|
+
const second = (await query.request());
|
|
251
|
+
expect(requestMock).toHaveBeenCalledTimes(1);
|
|
252
|
+
expect(first.extra()).toBe('copied');
|
|
253
|
+
expect(first.meta).toEqual({ source: 'custom' });
|
|
254
|
+
expect(second.extra()).toBe('copied');
|
|
255
|
+
expect(second.meta).toEqual({ source: 'custom' });
|
|
256
|
+
});
|
|
257
|
+
it('updates success state from successful responses', async () => {
|
|
258
|
+
const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, active: true }));
|
|
259
|
+
const requestor = {
|
|
260
|
+
request: requestMock
|
|
261
|
+
};
|
|
262
|
+
const query = new Query({
|
|
263
|
+
path: '/users',
|
|
264
|
+
method: 'GET',
|
|
265
|
+
input: { id: 1 },
|
|
266
|
+
requestor,
|
|
267
|
+
cache: new Cache()
|
|
268
|
+
});
|
|
269
|
+
const response = await query.request();
|
|
270
|
+
expect(query.status).toBe('success');
|
|
271
|
+
expect(query.data).toEqual({ id: 1, active: true });
|
|
272
|
+
expect(query.errorData).toBeNull();
|
|
273
|
+
await expect(response.json()).resolves.toEqual({ id: 1, active: true });
|
|
274
|
+
});
|
|
275
|
+
it('updates error state from error responses without throwing', async () => {
|
|
276
|
+
const requestMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'missing' }, 404));
|
|
277
|
+
const requestor = {
|
|
278
|
+
request: requestMock
|
|
279
|
+
};
|
|
280
|
+
const query = new Query({
|
|
281
|
+
path: '/users',
|
|
282
|
+
method: 'GET',
|
|
283
|
+
input: { id: 99 },
|
|
284
|
+
requestor,
|
|
285
|
+
cache: new Cache()
|
|
286
|
+
});
|
|
287
|
+
const response = await query.request();
|
|
288
|
+
expect(query.status).toBe('error');
|
|
289
|
+
expect(query.data).toBeNull();
|
|
290
|
+
expect(query.errorData).toEqual({ message: 'missing' });
|
|
291
|
+
expect(response.ok).toBe(false);
|
|
292
|
+
await expect(response.json()).resolves.toEqual({ message: 'missing' });
|
|
293
|
+
});
|
|
294
|
+
it('clears the pending request when the request throws so later retries can succeed', async () => {
|
|
295
|
+
const requestMock = vi
|
|
296
|
+
.fn()
|
|
297
|
+
.mockRejectedValueOnce(new Error('network down'))
|
|
298
|
+
.mockResolvedValueOnce(jsonResponse({ id: 1, recovered: true }));
|
|
299
|
+
const requestor = {
|
|
300
|
+
request: requestMock
|
|
301
|
+
};
|
|
302
|
+
const query = new Query({
|
|
303
|
+
path: '/users',
|
|
304
|
+
method: 'GET',
|
|
305
|
+
input: { id: 1 },
|
|
306
|
+
requestor,
|
|
307
|
+
cache: new Cache()
|
|
308
|
+
});
|
|
309
|
+
await expect(query.request()).rejects.toThrow('network down');
|
|
310
|
+
expect(query.status).toBe('error');
|
|
311
|
+
const response = await query.request();
|
|
312
|
+
expect(requestMock).toHaveBeenCalledTimes(2);
|
|
313
|
+
expect(query.status).toBe('success');
|
|
314
|
+
await expect(response.json()).resolves.toEqual({ id: 1, recovered: true });
|
|
315
|
+
});
|
|
316
|
+
it('resetCache forces the next request to fetch again', async () => {
|
|
317
|
+
const requestMock = vi
|
|
318
|
+
.fn()
|
|
319
|
+
.mockResolvedValueOnce(jsonResponse({ call: 1 }))
|
|
320
|
+
.mockResolvedValueOnce(jsonResponse({ call: 2 }));
|
|
321
|
+
const requestor = {
|
|
322
|
+
request: requestMock
|
|
323
|
+
};
|
|
324
|
+
const query = new Query({
|
|
325
|
+
path: '/users',
|
|
326
|
+
method: 'GET',
|
|
327
|
+
input: { id: 1 },
|
|
328
|
+
requestor,
|
|
329
|
+
cache: new Cache()
|
|
330
|
+
});
|
|
331
|
+
const first = await query.request();
|
|
332
|
+
query.resetCache();
|
|
333
|
+
const second = await query.request();
|
|
334
|
+
expect(requestMock).toHaveBeenCalledTimes(2);
|
|
335
|
+
await expect(first.json()).resolves.toEqual({ call: 1 });
|
|
336
|
+
await expect(second.json()).resolves.toEqual({ call: 2 });
|
|
337
|
+
});
|
|
338
|
+
it('honors custom cache timeout options', async () => {
|
|
339
|
+
const requestMock = vi
|
|
340
|
+
.fn()
|
|
341
|
+
.mockResolvedValueOnce(jsonResponse({ call: 1 }))
|
|
342
|
+
.mockResolvedValueOnce(jsonResponse({ call: 2 }));
|
|
343
|
+
const requestor = {
|
|
344
|
+
request: requestMock
|
|
345
|
+
};
|
|
346
|
+
const query = new Query({
|
|
347
|
+
path: '/users',
|
|
348
|
+
method: 'GET',
|
|
349
|
+
input: { id: 1 },
|
|
350
|
+
requestor,
|
|
351
|
+
cache: new Cache(),
|
|
352
|
+
opts: {
|
|
353
|
+
cache: { timeout: 50 }
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
const first = await query.request();
|
|
357
|
+
vi.advanceTimersByTime(49);
|
|
358
|
+
const cached = await query.request();
|
|
359
|
+
vi.advanceTimersByTime(1);
|
|
360
|
+
const refreshed = await query.request();
|
|
361
|
+
expect(requestMock).toHaveBeenCalledTimes(2);
|
|
362
|
+
await expect(first.json()).resolves.toEqual({ call: 1 });
|
|
363
|
+
await expect(cached.json()).resolves.toEqual({ call: 1 });
|
|
364
|
+
await expect(refreshed.json()).resolves.toEqual({ call: 2 });
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=vitest.setup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vitest.setup.d.ts","sourceRoot":"","sources":["../../src/test/vitest.setup.ts"],"names":[],"mappings":""}
|