svelte-ag 1.0.56 → 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.
Files changed (29) hide show
  1. package/dist/lib/api/query/cache.unit.test.d.ts +2 -0
  2. package/dist/lib/api/query/cache.unit.test.d.ts.map +1 -0
  3. package/dist/lib/api/query/cache.unit.test.js +50 -0
  4. package/dist/lib/api/query/entrypoint.unit.test.d.ts +2 -0
  5. package/dist/lib/api/query/entrypoint.unit.test.d.ts.map +1 -0
  6. package/dist/lib/api/query/entrypoint.unit.test.js +56 -0
  7. package/dist/lib/api/query/query.svelte.d.ts +1 -1
  8. package/dist/lib/api/query/query.svelte.d.ts.map +1 -1
  9. package/dist/lib/api/query/query.svelte.js +51 -29
  10. package/dist/lib/api/query/query.unit.test.d.ts +2 -0
  11. package/dist/lib/api/query/query.unit.test.d.ts.map +1 -0
  12. package/dist/lib/api/query/query.unit.test.js +276 -0
  13. package/dist/lib/api/query/rate.svelte.d.ts +5 -0
  14. package/dist/lib/api/query/rate.svelte.d.ts.map +1 -0
  15. package/dist/lib/api/query/rate.svelte.js +16 -0
  16. package/dist/lib/api/query/rate.unit.test.d.ts +2 -0
  17. package/dist/lib/api/query/rate.unit.test.d.ts.map +1 -0
  18. package/dist/lib/api/query/rate.unit.test.js +109 -0
  19. package/dist/test/vitest.setup.d.ts +1 -0
  20. package/dist/test/vitest.setup.d.ts.map +1 -0
  21. package/dist/test/vitest.setup.js +4 -0
  22. package/package.json +3 -3
  23. package/src/lib/api/query/cache.unit.test.ts +66 -0
  24. package/src/lib/api/query/entrypoint.unit.test.ts +85 -0
  25. package/src/lib/api/query/query.svelte.ts +60 -33
  26. package/src/lib/api/query/query.unit.test.ts +367 -0
  27. package/src/lib/api/query/rate.svelte.ts +20 -0
  28. package/src/lib/api/query/rate.unit.test.ts +149 -0
  29. package/src/test/vitest.setup.ts +3 -0
@@ -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;AAInH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAG5C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAExD,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;YAmBlC,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
- import PQueue from 'p-queue';
3
2
  import { cacheKey } from './utils.svelte.js';
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() {
@@ -97,11 +117,7 @@ export class Requestor {
97
117
  this.#path = path;
98
118
  this.#method = method;
99
119
  this.#request = request;
100
- this.#limiter = new PQueue({
101
- concurrency: 5,
102
- interval: 100,
103
- intervalCap: 1
104
- });
120
+ this.#limiter = new RateLimiter();
105
121
  // this.#cache = cache;
106
122
  // TODO
107
123
  this.#canBatch = batchDetails ? batchDetails.canBatch : () => false;
@@ -121,17 +137,22 @@ export class Requestor {
121
137
  */
122
138
  async flushBatchQueue(batchId) {
123
139
  const queue = this.#batchQueue[batchId].splice(0);
124
- const batchedInput = this.#batchInput(queue.map((q) => q.input));
125
- const res = await this.fetch(batchedInput);
126
- const output = await this.#unBatchOutput(queue.map((q) => q.input), res);
127
- queue.forEach(({ resolve, reject }, i) => {
128
- if (output[i].ok === true) {
129
- 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}`);
130
146
  }
131
- else {
132
- reject(output[i]);
133
- }
134
- });
147
+ queue.forEach(({ resolve }, i) => {
148
+ resolve(output[i]);
149
+ });
150
+ }
151
+ catch (err) {
152
+ queue.forEach(({ reject }) => {
153
+ reject(err);
154
+ });
155
+ }
135
156
  }
136
157
  // Performs a request for a given input. Batches it if possible
137
158
  async request(input) {
@@ -143,8 +164,9 @@ export class Requestor {
143
164
  this.#batchQueue[batchId].push({ input, resolve, reject });
144
165
  if (!this.#batchTimers[batchId]) {
145
166
  this.#batchTimers[batchId] = setTimeout(() => {
146
- this.flushBatchQueue(batchId);
147
- delete this.#batchTimers[batchId];
167
+ void this.flushBatchQueue(batchId).finally(() => {
168
+ delete this.#batchTimers[batchId];
169
+ });
148
170
  }, this.#batchDelay);
149
171
  }
150
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,5 @@
1
+ export declare class RateLimiter {
2
+ #private;
3
+ add<T>(fn: () => Promise<T>): Promise<T>;
4
+ }
5
+ //# sourceMappingURL=rate.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/rate.svelte.ts"],"names":[],"mappings":"AAAA,qBAAa,WAAW;;IAItB,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;CAezC"}
@@ -0,0 +1,16 @@
1
+ export class RateLimiter {
2
+ #nextStart = 0;
3
+ #interval = 100;
4
+ add(fn) {
5
+ const now = Date.now();
6
+ const startAt = Math.max(now, this.#nextStart);
7
+ this.#nextStart = startAt + this.#interval;
8
+ return (async () => {
9
+ const wait = Math.max(0, startAt - Date.now());
10
+ if (wait > 0) {
11
+ await new Promise((resolve) => setTimeout(resolve, wait));
12
+ }
13
+ return await fn();
14
+ })();
15
+ }
16
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=rate.unit.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate.unit.test.d.ts","sourceRoot":"","sources":["../../../../src/lib/api/query/rate.unit.test.ts"],"names":[],"mappings":""}