svelte-ag 1.0.57 → 1.0.58

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;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,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,15 @@
1
1
  import { stringify } from 'devalue';
2
2
  import { cacheKey } from './utils.svelte.js';
3
3
  import { RateLimiter } from './rate.svelte';
4
+ function cloneResponse(response) {
5
+ if (response !== null &&
6
+ typeof response === 'object' &&
7
+ 'clone' in response &&
8
+ typeof response.clone === 'function') {
9
+ return response.clone();
10
+ }
11
+ return response;
12
+ }
4
13
  export class Query {
5
14
  // -------- Constants --------
6
15
  #TIMEOUT = 1000 * 60 * 5; // 5 minutes
@@ -20,7 +29,7 @@ export class Query {
20
29
  #data = $state(null);
21
30
  #errorData = $state(null);
22
31
  // -------- Functions --------
23
- constructor({ path, method, input, requestor, cache }) {
32
+ constructor({ path, method, input, requestor, cache, opts }) {
24
33
  this.#requestor = requestor;
25
34
  this.#cache = cache;
26
35
  this.#path = path;
@@ -29,32 +38,43 @@ export class Query {
29
38
  this.#input = input;
30
39
  this.#inputString = stringify(input);
31
40
  this.#cacheKey = cacheKey(path, method, input);
32
- this.#cache.register(this.#cacheKey, { timeout: this.#TIMEOUT });
41
+ this.#cache.register(this.#cacheKey, opts?.cache ?? { timeout: this.#TIMEOUT });
33
42
  }
34
43
  async request() {
35
44
  const cachedValue = this.#cache.get(this.#cacheKey);
36
45
  if (cachedValue !== null) {
37
- return cachedValue;
46
+ return cloneResponse(cachedValue);
38
47
  }
39
48
  this.#status = 'loading';
40
49
  if (this.#pendingRequest === null) {
41
50
  this.#pendingRequest = this.#requestor.request(this.#input);
42
51
  }
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();
52
+ let res;
53
+ try {
54
+ res = await this.#pendingRequest;
55
+ }
56
+ catch (err) {
57
+ this.#status = 'error';
58
+ throw err;
59
+ }
60
+ finally {
61
+ this.#pendingRequest = null;
62
+ }
63
+ const responseForState = cloneResponse(res);
64
+ const responseForCaller = cloneResponse(res);
65
+ this.#cache.set(this.#cacheKey, cloneResponse(res));
66
+ if (responseForState.ok === false) {
67
+ const body = await responseForState.json();
48
68
  this.#status = 'error';
49
69
  // @ts-expect-error Generics not working for some reason
50
70
  this.#errorData = body;
51
- return res;
71
+ return responseForCaller;
52
72
  }
53
73
  else {
54
- const body = await res.json();
74
+ const body = await responseForState.json();
55
75
  this.#status = 'success';
56
76
  this.#data = body;
57
- return res;
77
+ return responseForCaller;
58
78
  }
59
79
  }
60
80
  get cacheKey() {
@@ -117,17 +137,22 @@ export class Requestor {
117
137
  */
118
138
  async flushBatchQueue(batchId) {
119
139
  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]);
140
+ try {
141
+ const batchedInput = this.#batchInput(queue.map((q) => q.input));
142
+ const res = await this.fetch(batchedInput);
143
+ const output = await this.#unBatchOutput(queue.map((q) => q.input), res);
144
+ if (output.length !== queue.length) {
145
+ throw new Error(`Batch output length mismatch for ${batchId}`);
126
146
  }
127
- else {
128
- reject(output[i]);
129
- }
130
- });
147
+ queue.forEach(({ resolve }, i) => {
148
+ resolve(output[i]);
149
+ });
150
+ }
151
+ catch (err) {
152
+ queue.forEach(({ reject }) => {
153
+ reject(err);
154
+ });
155
+ }
131
156
  }
132
157
  // Performs a request for a given input. Batches it if possible
133
158
  async request(input) {
@@ -139,8 +164,9 @@ export class Requestor {
139
164
  this.#batchQueue[batchId].push({ input, resolve, reject });
140
165
  if (!this.#batchTimers[batchId]) {
141
166
  this.#batchTimers[batchId] = setTimeout(() => {
142
- this.flushBatchQueue(batchId);
143
- delete this.#batchTimers[batchId];
167
+ void this.flushBatchQueue(batchId).finally(() => {
168
+ delete this.#batchTimers[batchId];
169
+ });
144
170
  }, this.#batchDelay);
145
171
  }
146
172
  });
@@ -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,276 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { Cache } from './cache.svelte.js';
3
+ import { Query, Requestor } from './query.svelte.js';
4
+ function getSingleId(input) {
5
+ return 'id' in input ? input.id : input.ids[0];
6
+ }
7
+ function jsonResponse(body, status = 200) {
8
+ return new Response(JSON.stringify(body), {
9
+ status,
10
+ headers: { 'content-type': 'application/json' }
11
+ });
12
+ }
13
+ function deferred() {
14
+ let resolve;
15
+ let reject;
16
+ const promise = new Promise((res, rej) => {
17
+ resolve = res;
18
+ reject = rej;
19
+ });
20
+ return { promise, resolve, reject };
21
+ }
22
+ describe('Requestor', () => {
23
+ beforeEach(() => {
24
+ vi.useFakeTimers();
25
+ vi.setSystemTime(0);
26
+ });
27
+ afterEach(() => {
28
+ vi.useRealTimers();
29
+ vi.restoreAllMocks();
30
+ });
31
+ it('passes through non-batched requests', async () => {
32
+ const requestMock = vi.fn(async () => jsonResponse({ id: 1 }));
33
+ const request = requestMock;
34
+ const requestor = new Requestor('/users', 'GET', request, new Cache());
35
+ const response = await requestor.request({ id: 1 });
36
+ expect(requestMock).toHaveBeenCalledTimes(1);
37
+ expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { id: 1 });
38
+ await expect(response.json()).resolves.toEqual({ id: 1 });
39
+ });
40
+ it('batches requests with the same batch id and preserves response order', async () => {
41
+ const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
42
+ const request = requestMock;
43
+ const requestor = new Requestor('/users', 'GET', request, new Cache(), {
44
+ canBatch: (input) => ('group' in input && input.group) || false,
45
+ batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
46
+ unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getSingleId(input) }))
47
+ });
48
+ const p1 = requestor.request({ id: 1, group: 'team' });
49
+ const p2 = requestor.request({ id: 2, group: 'team' });
50
+ await vi.advanceTimersByTimeAsync(99);
51
+ expect(requestMock).not.toHaveBeenCalled();
52
+ await vi.advanceTimersByTimeAsync(1);
53
+ expect(requestMock).toHaveBeenCalledTimes(1);
54
+ expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
55
+ const [response1, response2] = await Promise.all([p1, p2]);
56
+ await expect(response1.json()).resolves.toEqual({ id: 1 });
57
+ await expect(response2.json()).resolves.toEqual({ id: 2 });
58
+ });
59
+ it('rate limits separate batches by start time rather than completion time', async () => {
60
+ const starts = [];
61
+ const requestMock = vi.fn(async (_path, _method, input) => {
62
+ starts.push(Date.now());
63
+ if ('ids' in input && input.ids[0] === 1) {
64
+ await new Promise((resolve) => setTimeout(resolve, 200));
65
+ }
66
+ return jsonResponse({ ids: 'ids' in input ? input.ids : [input.id] });
67
+ });
68
+ const request = requestMock;
69
+ const requestor = new Requestor('/users', 'GET', request, new Cache(), {
70
+ canBatch: (input) => ('group' in input && input.group) || false,
71
+ batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
72
+ unBatchOutput: (_inputs, output) => [output]
73
+ });
74
+ const p1 = requestor.request({ id: 1, group: 'a' });
75
+ const p2 = requestor.request({ id: 2, group: 'b' });
76
+ await vi.advanceTimersByTimeAsync(100);
77
+ expect(starts).toEqual([100]);
78
+ await vi.advanceTimersByTimeAsync(100);
79
+ expect(starts).toEqual([100, 200]);
80
+ await vi.runAllTimersAsync();
81
+ const [response1, response2] = await Promise.all([p1, p2]);
82
+ await expect(response1.json()).resolves.toEqual({ ids: [1] });
83
+ await expect(response2.json()).resolves.toEqual({ ids: [2] });
84
+ });
85
+ it('returns batched error responses without rejecting them', async () => {
86
+ const requestMock = vi.fn(async () => jsonResponse({ ok: false }, 207));
87
+ const request = requestMock;
88
+ const requestor = new Requestor('/users', 'GET', request, new Cache(), {
89
+ canBatch: () => 'team',
90
+ batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
91
+ unBatchOutput: () => [jsonResponse({ message: 'bad request' }, 400)]
92
+ });
93
+ const responsePromise = requestor.request({ id: 1 });
94
+ await vi.advanceTimersByTimeAsync(100);
95
+ const response = await responsePromise;
96
+ expect(response.ok).toBe(false);
97
+ expect(response.status).toBe(400);
98
+ await expect(response.json()).resolves.toEqual({ message: 'bad request' });
99
+ });
100
+ it('rejects all queued callers when a batched fetch throws', async () => {
101
+ const requestMock = vi.fn(async () => {
102
+ throw new Error('network down');
103
+ });
104
+ const request = requestMock;
105
+ const requestor = new Requestor('/users', 'GET', request, new Cache(), {
106
+ canBatch: () => 'team',
107
+ batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
108
+ unBatchOutput: (_inputs, output) => [output]
109
+ });
110
+ const p1 = requestor.request({ id: 1 });
111
+ const p2 = requestor.request({ id: 2 });
112
+ const p1Expectation = expect(p1).rejects.toThrow('network down');
113
+ const p2Expectation = expect(p2).rejects.toThrow('network down');
114
+ await vi.advanceTimersByTimeAsync(100);
115
+ await Promise.all([p1Expectation, p2Expectation]);
116
+ });
117
+ });
118
+ describe('Query', () => {
119
+ beforeEach(() => {
120
+ vi.useFakeTimers();
121
+ vi.setSystemTime(0);
122
+ });
123
+ afterEach(() => {
124
+ vi.useRealTimers();
125
+ vi.restoreAllMocks();
126
+ });
127
+ it('deduplicates concurrent requests and returns readable responses to each caller', async () => {
128
+ const pending = deferred();
129
+ const requestMock = vi.fn().mockReturnValue(pending.promise);
130
+ const requestor = {
131
+ request: requestMock
132
+ };
133
+ const query = new Query({
134
+ path: '/users',
135
+ method: 'GET',
136
+ input: { id: 1 },
137
+ requestor,
138
+ cache: new Cache()
139
+ });
140
+ const p1 = query.request();
141
+ const p2 = query.request();
142
+ expect(requestMock).toHaveBeenCalledTimes(1);
143
+ pending.resolve(jsonResponse({ id: 1 }));
144
+ const [response1, response2] = await Promise.all([p1, p2]);
145
+ await expect(response1.json()).resolves.toEqual({ id: 1 });
146
+ await expect(response2.json()).resolves.toEqual({ id: 1 });
147
+ });
148
+ it('caches responses and returns a fresh readable clone on cache hits', async () => {
149
+ const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, name: 'Ada' }));
150
+ const requestor = {
151
+ request: requestMock
152
+ };
153
+ const query = new Query({
154
+ path: '/users',
155
+ method: 'GET',
156
+ input: { id: 1 },
157
+ requestor,
158
+ cache: new Cache()
159
+ });
160
+ const first = await query.request();
161
+ const second = await query.request();
162
+ expect(requestMock).toHaveBeenCalledTimes(1);
163
+ expect(query.isCached).toBe(true);
164
+ await expect(first.json()).resolves.toEqual({ id: 1, name: 'Ada' });
165
+ await expect(second.json()).resolves.toEqual({ id: 1, name: 'Ada' });
166
+ });
167
+ it('updates success state from successful responses', async () => {
168
+ const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, active: true }));
169
+ const requestor = {
170
+ request: requestMock
171
+ };
172
+ const query = new Query({
173
+ path: '/users',
174
+ method: 'GET',
175
+ input: { id: 1 },
176
+ requestor,
177
+ cache: new Cache()
178
+ });
179
+ const response = await query.request();
180
+ expect(query.status).toBe('success');
181
+ expect(query.data).toEqual({ id: 1, active: true });
182
+ expect(query.errorData).toBeNull();
183
+ await expect(response.json()).resolves.toEqual({ id: 1, active: true });
184
+ });
185
+ it('updates error state from error responses without throwing', async () => {
186
+ const requestMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'missing' }, 404));
187
+ const requestor = {
188
+ request: requestMock
189
+ };
190
+ const query = new Query({
191
+ path: '/users',
192
+ method: 'GET',
193
+ input: { id: 99 },
194
+ requestor,
195
+ cache: new Cache()
196
+ });
197
+ const response = await query.request();
198
+ expect(query.status).toBe('error');
199
+ expect(query.data).toBeNull();
200
+ expect(query.errorData).toEqual({ message: 'missing' });
201
+ expect(response.ok).toBe(false);
202
+ await expect(response.json()).resolves.toEqual({ message: 'missing' });
203
+ });
204
+ it('clears the pending request when the request throws so later retries can succeed', async () => {
205
+ const requestMock = vi
206
+ .fn()
207
+ .mockRejectedValueOnce(new Error('network down'))
208
+ .mockResolvedValueOnce(jsonResponse({ id: 1, recovered: true }));
209
+ const requestor = {
210
+ request: requestMock
211
+ };
212
+ const query = new Query({
213
+ path: '/users',
214
+ method: 'GET',
215
+ input: { id: 1 },
216
+ requestor,
217
+ cache: new Cache()
218
+ });
219
+ await expect(query.request()).rejects.toThrow('network down');
220
+ expect(query.status).toBe('error');
221
+ const response = await query.request();
222
+ expect(requestMock).toHaveBeenCalledTimes(2);
223
+ expect(query.status).toBe('success');
224
+ await expect(response.json()).resolves.toEqual({ id: 1, recovered: true });
225
+ });
226
+ it('resetCache forces the next request to fetch again', async () => {
227
+ const requestMock = vi
228
+ .fn()
229
+ .mockResolvedValueOnce(jsonResponse({ call: 1 }))
230
+ .mockResolvedValueOnce(jsonResponse({ call: 2 }));
231
+ const requestor = {
232
+ request: requestMock
233
+ };
234
+ const query = new Query({
235
+ path: '/users',
236
+ method: 'GET',
237
+ input: { id: 1 },
238
+ requestor,
239
+ cache: new Cache()
240
+ });
241
+ const first = await query.request();
242
+ query.resetCache();
243
+ const second = await query.request();
244
+ expect(requestMock).toHaveBeenCalledTimes(2);
245
+ await expect(first.json()).resolves.toEqual({ call: 1 });
246
+ await expect(second.json()).resolves.toEqual({ call: 2 });
247
+ });
248
+ it('honors custom cache timeout options', async () => {
249
+ const requestMock = vi
250
+ .fn()
251
+ .mockResolvedValueOnce(jsonResponse({ call: 1 }))
252
+ .mockResolvedValueOnce(jsonResponse({ call: 2 }));
253
+ const requestor = {
254
+ request: requestMock
255
+ };
256
+ const query = new Query({
257
+ path: '/users',
258
+ method: 'GET',
259
+ input: { id: 1 },
260
+ requestor,
261
+ cache: new Cache(),
262
+ opts: {
263
+ cache: { timeout: 50 }
264
+ }
265
+ });
266
+ const first = await query.request();
267
+ vi.advanceTimersByTime(49);
268
+ const cached = await query.request();
269
+ vi.advanceTimersByTime(1);
270
+ const refreshed = await query.request();
271
+ expect(requestMock).toHaveBeenCalledTimes(2);
272
+ await expect(first.json()).resolves.toEqual({ call: 1 });
273
+ await expect(cached.json()).resolves.toEqual({ call: 1 });
274
+ await expect(refreshed.json()).resolves.toEqual({ call: 2 });
275
+ });
276
+ });
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-ag",
3
- "version": "1.0.57",
3
+ "version": "1.0.58",
4
4
  "description": "Useful svelte components",
5
5
  "bugs": "https://github.com/ageorgeh/svelte-ag/issues",
6
6
  "repository": {
@@ -70,7 +70,7 @@
70
70
  "@iconify/tailwind4": "^1.2.3",
71
71
  "@iconify/types": "^2.0.0",
72
72
  "@internationalized/date": "^3.12.0",
73
- "@lucide/svelte": "^0.577.0",
73
+ "@lucide/svelte": "^1.7.0",
74
74
  "@playwright/test": "1.57.0",
75
75
  "@sveltejs/kit": "^2.55.0",
76
76
  "@sveltejs/package": "^2.5.7",
@@ -0,0 +1,66 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { Cache } from './cache.svelte.js';
3
+
4
+ describe('Cache', () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers();
7
+ vi.setSystemTime(0);
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ it('stores and expires values based on timeout', () => {
16
+ const cache = new Cache();
17
+
18
+ cache.register('user', { timeout: 100 });
19
+ cache.set('user', { id: 1 });
20
+
21
+ expect(cache.has('user')).toBe(true);
22
+ expect(cache.get('user')).toEqual({ id: 1 });
23
+
24
+ vi.advanceTimersByTime(99);
25
+ expect(cache.has('user')).toBe(true);
26
+ expect(cache.get('user')).toEqual({ id: 1 });
27
+
28
+ vi.advanceTimersByTime(1);
29
+ expect(cache.has('user')).toBe(false);
30
+ expect(cache.get('user')).toBeNull();
31
+ });
32
+
33
+ it('supports infinite timeout and reset', () => {
34
+ const cache = new Cache();
35
+
36
+ cache.register('settings', { timeout: 'inf' });
37
+ cache.set('settings', { theme: 'light' });
38
+
39
+ vi.advanceTimersByTime(10_000);
40
+ expect(cache.has('settings')).toBe(true);
41
+ expect(cache.get('settings')).toEqual({ theme: 'light' });
42
+
43
+ cache.reset('settings');
44
+ expect(cache.has('settings')).toBe(false);
45
+ expect(cache.get('settings')).toBeNull();
46
+ });
47
+
48
+ it('deregisters keys completely', () => {
49
+ const cache = new Cache();
50
+
51
+ cache.register('token', { timeout: 100 });
52
+ cache.set('token', 'abc');
53
+ cache.deregister('token');
54
+
55
+ expect(cache.has('token')).toBe(false);
56
+ expect(() => cache.get('token')).toThrow('The key token is not registered in the cache');
57
+ });
58
+
59
+ it('throws when mutating an unregistered key', () => {
60
+ const cache = new Cache();
61
+
62
+ expect(() => cache.set('missing', 1)).toThrow('The key missing is not registered in the cache');
63
+ expect(() => cache.get('missing')).toThrow('The key missing is not registered in the cache');
64
+ expect(() => cache.reset('missing')).toThrow('The key missing is not registered in the cache');
65
+ });
66
+ });
@@ -0,0 +1,85 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { ApiEndpoints, ApiRequestFunction } from 'ts-ag';
3
+
4
+ type TestResponse = ApiEndpoints['response'];
5
+
6
+ type UserInput = { id: number; group?: string };
7
+ type BatchedInput = { ids: number[] };
8
+
9
+ type UsersApi = {
10
+ path: '/users';
11
+ method: 'GET';
12
+ requestInput: UserInput | BatchedInput;
13
+ requestOutput: null;
14
+ response: TestResponse;
15
+ };
16
+
17
+ type UsersRequest = ApiRequestFunction<UsersApi>;
18
+
19
+ function getUserId(input: UsersApi['requestInput']): number {
20
+ return 'id' in input ? input.id : input.ids[0]!;
21
+ }
22
+
23
+ function jsonResponse(body: unknown, status = 200): TestResponse {
24
+ return new Response(JSON.stringify(body), {
25
+ status,
26
+ headers: { 'content-type': 'application/json' }
27
+ }) as TestResponse;
28
+ }
29
+
30
+ describe('createQueryFunction', () => {
31
+ beforeEach(() => {
32
+ vi.useFakeTimers();
33
+ vi.setSystemTime(0);
34
+ vi.resetModules();
35
+ });
36
+
37
+ afterEach(() => {
38
+ vi.useRealTimers();
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ it('returns the same query instance for the same path, method, and input', async () => {
43
+ const { createQueryFunction } = await import('./entrypoint.svelte.js');
44
+ const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
45
+ const request = requestMock as unknown as UsersRequest;
46
+ const createQuery = createQueryFunction<UsersApi>(request, {});
47
+
48
+ const query1 = createQuery('/users', 'GET', { id: 1 });
49
+ const query2 = createQuery('/users', 'GET', { id: 1 });
50
+ const query3 = createQuery('/users', 'GET', { id: 2 });
51
+
52
+ expect(query1).toBe(query2);
53
+ expect(query3).not.toBe(query1);
54
+ });
55
+
56
+ it('reuses requestors so separate queries can batch together', async () => {
57
+ const { createQueryFunction } = await import('./entrypoint.svelte.js');
58
+ const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
59
+ const request = requestMock as unknown as UsersRequest;
60
+ const createQuery = createQueryFunction<UsersApi>(request, {
61
+ '/users': {
62
+ GET: {
63
+ canBatch: () => 'users',
64
+ batchInput: (inputs) => ({ ids: inputs.map(getUserId) }),
65
+ unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getUserId(input) }))
66
+ }
67
+ }
68
+ });
69
+
70
+ const query1 = createQuery('/users', 'GET', { id: 1 });
71
+ const query2 = createQuery('/users', 'GET', { id: 2 });
72
+
73
+ const p1 = query1.request();
74
+ const p2 = query2.request();
75
+
76
+ await vi.advanceTimersByTimeAsync(100);
77
+
78
+ expect(requestMock).toHaveBeenCalledTimes(1);
79
+ expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
80
+
81
+ const [response1, response2] = await Promise.all([p1, p2]);
82
+ await expect(response1.json()).resolves.toEqual({ id: 1 });
83
+ await expect(response2.json()).resolves.toEqual({ id: 2 });
84
+ });
85
+ });
@@ -9,6 +9,19 @@ import { RateLimiter } from './rate.svelte';
9
9
 
10
10
  export type QueryStatus = 'idle' | 'loading' | 'success' | 'error';
11
11
 
12
+ function cloneResponse<T>(response: T): T {
13
+ if (
14
+ response !== null &&
15
+ typeof response === 'object' &&
16
+ 'clone' in response &&
17
+ typeof response.clone === 'function'
18
+ ) {
19
+ return response.clone();
20
+ }
21
+
22
+ return response;
23
+ }
24
+
12
25
  export class Query<
13
26
  API extends ApiEndpoints,
14
27
  Path extends API['path'],
@@ -42,7 +55,8 @@ export class Query<
42
55
  method,
43
56
  input,
44
57
  requestor,
45
- cache
58
+ cache,
59
+ opts
46
60
  }: {
47
61
  path: Path;
48
62
  method: Method;
@@ -65,13 +79,13 @@ export class Query<
65
79
  this.#inputString = stringify(input);
66
80
  this.#cacheKey = cacheKey(path, method, input);
67
81
 
68
- this.#cache.register(this.#cacheKey, { timeout: this.#TIMEOUT });
82
+ this.#cache.register(this.#cacheKey, opts?.cache ?? { timeout: this.#TIMEOUT });
69
83
  }
70
84
 
71
85
  async request(): Promise<ApiResponse<API, Path, Method>> {
72
86
  const cachedValue = this.#cache.get(this.#cacheKey);
73
87
  if (cachedValue !== null) {
74
- return cachedValue;
88
+ return cloneResponse(cachedValue);
75
89
  }
76
90
 
77
91
  this.#status = 'loading';
@@ -79,23 +93,33 @@ export class Query<
79
93
  if (this.#pendingRequest === null) {
80
94
  this.#pendingRequest = this.#requestor.request(this.#input);
81
95
  }
82
- const res = await this.#pendingRequest;
83
- this.#pendingRequest = null;
84
96
 
85
- this.#cache.set(this.#cacheKey, res);
97
+ let res: ApiResponse<API, Path, Method>;
98
+ try {
99
+ res = await this.#pendingRequest;
100
+ } catch (err) {
101
+ this.#status = 'error';
102
+ throw err;
103
+ } finally {
104
+ this.#pendingRequest = null;
105
+ }
106
+
107
+ const responseForState = cloneResponse(res);
108
+ const responseForCaller = cloneResponse(res);
109
+ this.#cache.set(this.#cacheKey, cloneResponse(res));
86
110
 
87
- if (res.ok === false) {
88
- const body = await res.json();
111
+ if (responseForState.ok === false) {
112
+ const body = await responseForState.json();
89
113
  this.#status = 'error';
90
114
 
91
115
  // @ts-expect-error Generics not working for some reason
92
116
  this.#errorData = body;
93
- return res;
117
+ return responseForCaller;
94
118
  } else {
95
- const body = await res.json();
119
+ const body = await responseForState.json();
96
120
  this.#status = 'success';
97
121
  this.#data = body;
98
- return res;
122
+ return responseForCaller;
99
123
  }
100
124
  }
101
125
 
@@ -147,7 +171,7 @@ export class Requestor<
147
171
  string,
148
172
  {
149
173
  resolve: (value: ApiResponse<API, Path, Method>) => void;
150
- reject: (err: ApiResponse<API, Path, Method>) => void;
174
+ reject: (err: unknown) => void;
151
175
  input: ApiInput<API, Path, Method>;
152
176
  }[]
153
177
  > = {};
@@ -187,21 +211,27 @@ export class Requestor<
187
211
  private async flushBatchQueue(batchId: string): Promise<void> {
188
212
  const queue = this.#batchQueue[batchId].splice(0);
189
213
 
190
- const batchedInput = this.#batchInput(queue.map((q) => q.input));
214
+ try {
215
+ const batchedInput = this.#batchInput(queue.map((q) => q.input));
191
216
 
192
- const res = await this.fetch(batchedInput);
193
- const output = await this.#unBatchOutput(
194
- queue.map((q) => q.input),
195
- res
196
- );
217
+ const res = await this.fetch(batchedInput);
218
+ const output = await this.#unBatchOutput(
219
+ queue.map((q) => q.input),
220
+ res
221
+ );
197
222
 
198
- queue.forEach(({ resolve, reject }, i) => {
199
- if (output[i].ok === true) {
200
- resolve(output[i]);
201
- } else {
202
- reject(output[i]);
223
+ if (output.length !== queue.length) {
224
+ throw new Error(`Batch output length mismatch for ${batchId}`);
203
225
  }
204
- });
226
+
227
+ queue.forEach(({ resolve }, i) => {
228
+ resolve(output[i]!);
229
+ });
230
+ } catch (err) {
231
+ queue.forEach(({ reject }) => {
232
+ reject(err);
233
+ });
234
+ }
205
235
  }
206
236
 
207
237
  // Performs a request for a given input. Batches it if possible
@@ -214,8 +244,9 @@ export class Requestor<
214
244
  this.#batchQueue[batchId].push({ input, resolve, reject });
215
245
  if (!this.#batchTimers[batchId]) {
216
246
  this.#batchTimers[batchId] = setTimeout(() => {
217
- this.flushBatchQueue(batchId);
218
- delete this.#batchTimers[batchId];
247
+ void this.flushBatchQueue(batchId).finally(() => {
248
+ delete this.#batchTimers[batchId];
249
+ });
219
250
  }, this.#batchDelay);
220
251
  }
221
252
  });
@@ -0,0 +1,367 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { ApiEndpoints, ApiRequestFunction } from 'ts-ag';
3
+ import { Cache } from './cache.svelte.js';
4
+ import { Query, Requestor } from './query.svelte.js';
5
+
6
+ type PlainUserInput = { id: number };
7
+ type BatchedUserInput = { id: number; group?: string };
8
+ type BatchedRequestInput = { ids: number[] };
9
+
10
+ type TestResponse = ApiEndpoints['response'];
11
+
12
+ type PlainUsersApi = {
13
+ path: '/users';
14
+ method: 'GET';
15
+ requestInput: PlainUserInput;
16
+ requestOutput: null;
17
+ response: TestResponse;
18
+ };
19
+
20
+ type BatchedUsersApi = {
21
+ path: '/users';
22
+ method: 'GET';
23
+ requestInput: BatchedUserInput | BatchedRequestInput;
24
+ requestOutput: null;
25
+ response: TestResponse;
26
+ };
27
+
28
+ type PlainUsersRequestor = Requestor<PlainUsersApi, '/users', 'GET'>;
29
+ type PlainUsersRequest = ApiRequestFunction<PlainUsersApi>;
30
+ type BatchedUsersRequest = ApiRequestFunction<BatchedUsersApi>;
31
+
32
+ function getSingleId(input: BatchedUsersApi['requestInput']): number {
33
+ return 'id' in input ? input.id : input.ids[0]!;
34
+ }
35
+
36
+ function jsonResponse(body: unknown, status = 200): TestResponse {
37
+ return new Response(JSON.stringify(body), {
38
+ status,
39
+ headers: { 'content-type': 'application/json' }
40
+ }) as TestResponse;
41
+ }
42
+
43
+ function deferred<T>() {
44
+ let resolve!: (value: T | PromiseLike<T>) => void;
45
+ let reject!: (reason?: unknown) => void;
46
+ const promise = new Promise<T>((res, rej) => {
47
+ resolve = res;
48
+ reject = rej;
49
+ });
50
+
51
+ return { promise, resolve, reject };
52
+ }
53
+
54
+ describe('Requestor', () => {
55
+ beforeEach(() => {
56
+ vi.useFakeTimers();
57
+ vi.setSystemTime(0);
58
+ });
59
+
60
+ afterEach(() => {
61
+ vi.useRealTimers();
62
+ vi.restoreAllMocks();
63
+ });
64
+
65
+ it('passes through non-batched requests', async () => {
66
+ const requestMock = vi.fn(async () => jsonResponse({ id: 1 }));
67
+ const request = requestMock as unknown as PlainUsersRequest;
68
+ const requestor = new Requestor<PlainUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache());
69
+
70
+ const response = await requestor.request({ id: 1 });
71
+
72
+ expect(requestMock).toHaveBeenCalledTimes(1);
73
+ expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { id: 1 });
74
+ await expect(response.json()).resolves.toEqual({ id: 1 });
75
+ });
76
+
77
+ it('batches requests with the same batch id and preserves response order', async () => {
78
+ const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
79
+ const request = requestMock as unknown as BatchedUsersRequest;
80
+ const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
81
+ canBatch: (input) => ('group' in input && input.group) || false,
82
+ batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
83
+ unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getSingleId(input) }))
84
+ });
85
+
86
+ const p1 = requestor.request({ id: 1, group: 'team' });
87
+ const p2 = requestor.request({ id: 2, group: 'team' });
88
+
89
+ await vi.advanceTimersByTimeAsync(99);
90
+ expect(requestMock).not.toHaveBeenCalled();
91
+
92
+ await vi.advanceTimersByTimeAsync(1);
93
+ expect(requestMock).toHaveBeenCalledTimes(1);
94
+ expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
95
+
96
+ const [response1, response2] = await Promise.all([p1, p2]);
97
+ await expect(response1.json()).resolves.toEqual({ id: 1 });
98
+ await expect(response2.json()).resolves.toEqual({ id: 2 });
99
+ });
100
+
101
+ it('rate limits separate batches by start time rather than completion time', async () => {
102
+ const starts: number[] = [];
103
+ const requestMock = vi.fn(async (_path: '/users', _method: 'GET', input: BatchedUsersApi['requestInput']) => {
104
+ starts.push(Date.now());
105
+
106
+ if ('ids' in input && input.ids[0] === 1) {
107
+ await new Promise((resolve) => setTimeout(resolve, 200));
108
+ }
109
+
110
+ return jsonResponse({ ids: 'ids' in input ? input.ids : [input.id] });
111
+ });
112
+ const request = requestMock as unknown as BatchedUsersRequest;
113
+
114
+ const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
115
+ canBatch: (input) => ('group' in input && input.group) || false,
116
+ batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
117
+ unBatchOutput: (_inputs, output) => [output]
118
+ });
119
+
120
+ const p1 = requestor.request({ id: 1, group: 'a' });
121
+ const p2 = requestor.request({ id: 2, group: 'b' });
122
+
123
+ await vi.advanceTimersByTimeAsync(100);
124
+ expect(starts).toEqual([100]);
125
+
126
+ await vi.advanceTimersByTimeAsync(100);
127
+ expect(starts).toEqual([100, 200]);
128
+
129
+ await vi.runAllTimersAsync();
130
+
131
+ const [response1, response2] = await Promise.all([p1, p2]);
132
+ await expect(response1.json()).resolves.toEqual({ ids: [1] });
133
+ await expect(response2.json()).resolves.toEqual({ ids: [2] });
134
+ });
135
+
136
+ it('returns batched error responses without rejecting them', async () => {
137
+ const requestMock = vi.fn(async () => jsonResponse({ ok: false }, 207));
138
+ const request = requestMock as unknown as BatchedUsersRequest;
139
+ const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
140
+ canBatch: () => 'team',
141
+ batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
142
+ unBatchOutput: () => [jsonResponse({ message: 'bad request' }, 400)]
143
+ });
144
+
145
+ const responsePromise = requestor.request({ id: 1 });
146
+
147
+ await vi.advanceTimersByTimeAsync(100);
148
+
149
+ const response = await responsePromise;
150
+ expect(response.ok).toBe(false);
151
+ expect(response.status).toBe(400);
152
+ await expect(response.json()).resolves.toEqual({ message: 'bad request' });
153
+ });
154
+
155
+ it('rejects all queued callers when a batched fetch throws', async () => {
156
+ const requestMock = vi.fn(async () => {
157
+ throw new Error('network down');
158
+ });
159
+ const request = requestMock as unknown as BatchedUsersRequest;
160
+ const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
161
+ canBatch: () => 'team',
162
+ batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
163
+ unBatchOutput: (_inputs, output) => [output]
164
+ });
165
+
166
+ const p1 = requestor.request({ id: 1 });
167
+ const p2 = requestor.request({ id: 2 });
168
+ const p1Expectation = expect(p1).rejects.toThrow('network down');
169
+ const p2Expectation = expect(p2).rejects.toThrow('network down');
170
+
171
+ await vi.advanceTimersByTimeAsync(100);
172
+
173
+ await Promise.all([p1Expectation, p2Expectation]);
174
+ });
175
+ });
176
+
177
+ describe('Query', () => {
178
+ beforeEach(() => {
179
+ vi.useFakeTimers();
180
+ vi.setSystemTime(0);
181
+ });
182
+
183
+ afterEach(() => {
184
+ vi.useRealTimers();
185
+ vi.restoreAllMocks();
186
+ });
187
+
188
+ it('deduplicates concurrent requests and returns readable responses to each caller', async () => {
189
+ const pending = deferred<Response>();
190
+ const requestMock = vi.fn().mockReturnValue(pending.promise);
191
+ const requestor = {
192
+ request: requestMock as PlainUsersRequest
193
+ } as unknown as PlainUsersRequestor;
194
+
195
+ const query = new Query<PlainUsersApi, '/users', 'GET'>({
196
+ path: '/users',
197
+ method: 'GET',
198
+ input: { id: 1 },
199
+ requestor,
200
+ cache: new Cache()
201
+ });
202
+
203
+ const p1 = query.request();
204
+ const p2 = query.request();
205
+
206
+ expect(requestMock).toHaveBeenCalledTimes(1);
207
+
208
+ pending.resolve(jsonResponse({ id: 1 }));
209
+
210
+ const [response1, response2] = await Promise.all([p1, p2]);
211
+ await expect(response1.json()).resolves.toEqual({ id: 1 });
212
+ await expect(response2.json()).resolves.toEqual({ id: 1 });
213
+ });
214
+
215
+ it('caches responses and returns a fresh readable clone on cache hits', async () => {
216
+ const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, name: 'Ada' }));
217
+ const requestor = {
218
+ request: requestMock as PlainUsersRequest
219
+ } as unknown as PlainUsersRequestor;
220
+
221
+ const query = new Query<PlainUsersApi, '/users', 'GET'>({
222
+ path: '/users',
223
+ method: 'GET',
224
+ input: { id: 1 },
225
+ requestor,
226
+ cache: new Cache()
227
+ });
228
+
229
+ const first = await query.request();
230
+ const second = await query.request();
231
+
232
+ expect(requestMock).toHaveBeenCalledTimes(1);
233
+ expect(query.isCached).toBe(true);
234
+ await expect(first.json()).resolves.toEqual({ id: 1, name: 'Ada' });
235
+ await expect(second.json()).resolves.toEqual({ id: 1, name: 'Ada' });
236
+ });
237
+
238
+ it('updates success state from successful responses', async () => {
239
+ const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, active: true }));
240
+ const requestor = {
241
+ request: requestMock as PlainUsersRequest
242
+ } as unknown as PlainUsersRequestor;
243
+
244
+ const query = new Query<PlainUsersApi, '/users', 'GET'>({
245
+ path: '/users',
246
+ method: 'GET',
247
+ input: { id: 1 },
248
+ requestor,
249
+ cache: new Cache()
250
+ });
251
+
252
+ const response = await query.request();
253
+
254
+ expect(query.status).toBe('success');
255
+ expect(query.data).toEqual({ id: 1, active: true });
256
+ expect(query.errorData).toBeNull();
257
+ await expect(response.json()).resolves.toEqual({ id: 1, active: true });
258
+ });
259
+
260
+ it('updates error state from error responses without throwing', async () => {
261
+ const requestMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'missing' }, 404));
262
+ const requestor = {
263
+ request: requestMock as PlainUsersRequest
264
+ } as unknown as PlainUsersRequestor;
265
+
266
+ const query = new Query<PlainUsersApi, '/users', 'GET'>({
267
+ path: '/users',
268
+ method: 'GET',
269
+ input: { id: 99 },
270
+ requestor,
271
+ cache: new Cache()
272
+ });
273
+
274
+ const response = await query.request();
275
+
276
+ expect(query.status).toBe('error');
277
+ expect(query.data).toBeNull();
278
+ expect(query.errorData).toEqual({ message: 'missing' });
279
+ expect(response.ok).toBe(false);
280
+ await expect(response.json()).resolves.toEqual({ message: 'missing' });
281
+ });
282
+
283
+ it('clears the pending request when the request throws so later retries can succeed', async () => {
284
+ const requestMock = vi
285
+ .fn()
286
+ .mockRejectedValueOnce(new Error('network down'))
287
+ .mockResolvedValueOnce(jsonResponse({ id: 1, recovered: true }));
288
+ const requestor = {
289
+ request: requestMock as PlainUsersRequest
290
+ } as unknown as PlainUsersRequestor;
291
+
292
+ const query = new Query<PlainUsersApi, '/users', 'GET'>({
293
+ path: '/users',
294
+ method: 'GET',
295
+ input: { id: 1 },
296
+ requestor,
297
+ cache: new Cache()
298
+ });
299
+
300
+ await expect(query.request()).rejects.toThrow('network down');
301
+ expect(query.status).toBe('error');
302
+
303
+ const response = await query.request();
304
+
305
+ expect(requestMock).toHaveBeenCalledTimes(2);
306
+ expect(query.status).toBe('success');
307
+ await expect(response.json()).resolves.toEqual({ id: 1, recovered: true });
308
+ });
309
+
310
+ it('resetCache forces the next request to fetch again', async () => {
311
+ const requestMock = vi
312
+ .fn()
313
+ .mockResolvedValueOnce(jsonResponse({ call: 1 }))
314
+ .mockResolvedValueOnce(jsonResponse({ call: 2 }));
315
+ const requestor = {
316
+ request: requestMock as PlainUsersRequest
317
+ } as unknown as PlainUsersRequestor;
318
+
319
+ const query = new Query<PlainUsersApi, '/users', 'GET'>({
320
+ path: '/users',
321
+ method: 'GET',
322
+ input: { id: 1 },
323
+ requestor,
324
+ cache: new Cache()
325
+ });
326
+
327
+ const first = await query.request();
328
+ query.resetCache();
329
+ const second = await query.request();
330
+
331
+ expect(requestMock).toHaveBeenCalledTimes(2);
332
+ await expect(first.json()).resolves.toEqual({ call: 1 });
333
+ await expect(second.json()).resolves.toEqual({ call: 2 });
334
+ });
335
+
336
+ it('honors custom cache timeout options', async () => {
337
+ const requestMock = vi
338
+ .fn()
339
+ .mockResolvedValueOnce(jsonResponse({ call: 1 }))
340
+ .mockResolvedValueOnce(jsonResponse({ call: 2 }));
341
+ const requestor = {
342
+ request: requestMock as PlainUsersRequest
343
+ } as unknown as PlainUsersRequestor;
344
+
345
+ const query = new Query<PlainUsersApi, '/users', 'GET'>({
346
+ path: '/users',
347
+ method: 'GET',
348
+ input: { id: 1 },
349
+ requestor,
350
+ cache: new Cache(),
351
+ opts: {
352
+ cache: { timeout: 50 }
353
+ }
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
+
362
+ expect(requestMock).toHaveBeenCalledTimes(2);
363
+ await expect(first.json()).resolves.toEqual({ call: 1 });
364
+ await expect(cached.json()).resolves.toEqual({ call: 1 });
365
+ await expect(refreshed.json()).resolves.toEqual({ call: 2 });
366
+ });
367
+ });
@@ -0,0 +1,3 @@
1
+ if (typeof (globalThis as { $state?: unknown }).$state !== 'function') {
2
+ (globalThis as { $state: <T>(value: T) => T }).$state = <T>(value: T): T => value;
3
+ }