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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=cache.unit.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=entrypoint.unit.test.d.ts.map
@@ -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;AAEnE,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,EACN,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;IA+BxD,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;IAqBvB,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,GAAG,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;CAkB3F"}
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
- const res = await this.#pendingRequest;
44
- this.#pendingRequest = null;
45
- this.#cache.set(this.#cacheKey, res);
46
- if (res.ok === false) {
47
- const body = await res.json();
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 res;
81
+ return responseForCaller;
52
82
  }
53
83
  else {
54
- const body = await res.json();
84
+ const body = await responseForState.json();
55
85
  this.#status = 'success';
56
86
  this.#data = body;
57
- return res;
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
- const batchedInput = this.#batchInput(queue.map((q) => q.input));
121
- const res = await this.fetch(batchedInput);
122
- const output = await this.#unBatchOutput(queue.map((q) => q.input), res);
123
- queue.forEach(({ resolve, reject }, i) => {
124
- if (output[i].ok === true) {
125
- resolve(output[i]);
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
- delete this.#batchTimers[batchId];
177
+ void this.flushBatchQueue(batchId).finally(() => {
178
+ delete this.#batchTimers[batchId];
179
+ });
144
180
  }, this.#batchDelay);
145
181
  }
146
182
  });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=query.unit.test.d.ts.map
@@ -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":""}
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ if (typeof globalThis.$state !== 'function') {
3
+ globalThis.$state = (value) => value;
4
+ }