svelte-ag 1.0.59 → 1.0.60

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.
@@ -1,8 +1,16 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createApiRequest } from 'ts-ag';
3
+ import * as v from 'valibot';
4
+ const API_URL = 'https://api.example.test';
5
+ const schemas = {
6
+ '/users': {
7
+ POST: v.union([v.object({ id: v.number(), group: v.optional(v.string()) }), v.object({ ids: v.array(v.number()) })])
8
+ }
9
+ };
2
10
  function getUserId(input) {
3
11
  return 'id' in input ? input.id : input.ids[0];
4
12
  }
5
- function jsonResponse(body, status = 200) {
13
+ function jsonFetchResponse(body, status = 200) {
6
14
  return new Response(JSON.stringify(body), {
7
15
  status,
8
16
  headers: { 'content-type': 'application/json' }
@@ -17,40 +25,50 @@ describe('createQueryFunction', () => {
17
25
  afterEach(() => {
18
26
  vi.useRealTimers();
19
27
  vi.restoreAllMocks();
28
+ vi.unstubAllGlobals();
20
29
  });
21
30
  it('returns the same query instance for the same path, method, and input', async () => {
22
31
  const { createQueryFunction } = await import('./entrypoint.svelte.js');
23
- const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
24
- const request = requestMock;
32
+ const request = createApiRequest(schemas, API_URL, 'test');
25
33
  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 });
34
+ const query1 = createQuery('/users', 'POST', { id: 1 });
35
+ const query2 = createQuery('/users', 'POST', { id: 1 });
36
+ const query3 = createQuery('/users', 'POST', { id: 2 });
29
37
  expect(query1).toBe(query2);
30
38
  expect(query3).not.toBe(query1);
31
39
  });
32
40
  it('reuses requestors so separate queries can batch together', async () => {
33
41
  const { createQueryFunction } = await import('./entrypoint.svelte.js');
34
- const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
35
- const request = requestMock;
42
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ 1: 'one', 2: 'two' }));
43
+ vi.stubGlobal('fetch', fetchMock);
44
+ vi.stubGlobal('fetch', fetchMock);
45
+ const request = createApiRequest(schemas, API_URL, 'test');
36
46
  const createQuery = createQueryFunction(request, {
37
47
  '/users': {
38
- GET: {
48
+ POST: {
39
49
  canBatch: () => 'users',
40
50
  batchInput: (inputs) => ({ ids: inputs.map(getUserId) }),
41
- unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getUserId(input) }))
51
+ unBatchOutput: async (inputs, outputs) => {
52
+ return inputs.map(() => {
53
+ return outputs;
54
+ });
55
+ }
42
56
  }
43
57
  }
44
58
  });
45
- const query1 = createQuery('/users', 'GET', { id: 1 });
46
- const query2 = createQuery('/users', 'GET', { id: 2 });
59
+ const query1 = createQuery('/users', 'POST', { id: 1 });
60
+ const query2 = createQuery('/users', 'POST', { id: 2 });
47
61
  const p1 = query1.request();
48
62
  const p2 = query2.request();
49
63
  await vi.advanceTimersByTimeAsync(100);
50
- expect(requestMock).toHaveBeenCalledTimes(1);
51
- expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
64
+ expect(fetchMock).toHaveBeenCalledTimes(1);
65
+ expect(fetchMock).toHaveBeenCalledWith(`${API_URL}//users`, expect.objectContaining({
66
+ method: 'POST',
67
+ body: JSON.stringify({ ids: [1, 2] }),
68
+ credentials: 'include'
69
+ }));
52
70
  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 });
71
+ await expect(response1.json()).resolves.toEqual({ 1: 'one', 2: 'two' });
72
+ await expect(response2.json()).resolves.toEqual({ 1: 'one', 2: 'two' });
55
73
  });
56
74
  });
@@ -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;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
+ {"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;IA6BvB,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,25 +1,6 @@
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
- }
23
4
  export class Query {
24
5
  // -------- Constants --------
25
6
  #TIMEOUT = 1000 * 60 * 5; // 5 minutes
@@ -53,7 +34,7 @@ export class Query {
53
34
  async request() {
54
35
  const cachedValue = this.#cache.get(this.#cacheKey);
55
36
  if (cachedValue !== null) {
56
- return cloneResponse(cachedValue);
37
+ return cachedValue;
57
38
  }
58
39
  this.#status = 'loading';
59
40
  if (this.#pendingRequest === null) {
@@ -70,9 +51,9 @@ export class Query {
70
51
  finally {
71
52
  this.#pendingRequest = null;
72
53
  }
73
- const responseForState = cloneResponse(res);
74
- const responseForCaller = cloneResponse(res);
75
- this.#cache.set(this.#cacheKey, cloneResponse(res));
54
+ const responseForState = res;
55
+ const responseForCaller = res;
56
+ this.#cache.set(this.#cacheKey, res);
76
57
  if (responseForState.ok === false) {
77
58
  const body = await responseForState.json();
78
59
  this.#status = 'error';
@@ -147,6 +128,8 @@ export class Requestor {
147
128
  */
148
129
  async flushBatchQueue(batchId) {
149
130
  const queue = this.#batchQueue[batchId].splice(0);
131
+ // TODO maybe remove the unBatchOutput function and just always return the
132
+ // same response and then its on each consumer of each query to find the relevant records
150
133
  try {
151
134
  const batchedInput = this.#batchInput(queue.map((q) => q.input));
152
135
  const res = await this.fetch(batchedInput);
@@ -4,15 +4,29 @@ import { Cache } from './cache.svelte.js';
4
4
  import { Query, Requestor } from './query.svelte.js';
5
5
  import { stringify } from 'devalue';
6
6
  import * as v from 'valibot';
7
+ const API_URL = 'https://api.example.test';
8
+ const plainSchemas = {
9
+ '/users': {
10
+ GET: v.object({ id: v.number() })
11
+ }
12
+ };
13
+ const batchedSchemas = {
14
+ '/users': {
15
+ POST: v.union([v.object({ id: v.number(), group: v.optional(v.string()) }), v.object({ ids: v.array(v.number()) })])
16
+ }
17
+ };
7
18
  function getSingleId(input) {
8
19
  return 'id' in input ? input.id : input.ids[0];
9
20
  }
10
- function jsonResponse(body, status = 200) {
21
+ function jsonFetchResponse(body, status = 200) {
11
22
  return new Response(JSON.stringify(body), {
12
23
  status,
13
24
  headers: { 'content-type': 'application/json' }
14
25
  });
15
26
  }
27
+ function jsonResponse(body, status = 200) {
28
+ return jsonFetchResponse(body, status);
29
+ }
16
30
  function devalueFetchResponse(body, status = 200) {
17
31
  return new Response(stringify(body), {
18
32
  status,
@@ -39,6 +53,18 @@ function deferred() {
39
53
  });
40
54
  return { promise, resolve, reject };
41
55
  }
56
+ function createPlainRequest() {
57
+ return createApiRequest(plainSchemas, API_URL, 'test');
58
+ }
59
+ function createBatchedRequest() {
60
+ return createApiRequest(batchedSchemas, API_URL, 'test');
61
+ }
62
+ function createPlainRequestor() {
63
+ return new Requestor('/users', 'GET', createPlainRequest(), new Cache());
64
+ }
65
+ function createBatchedRequestor(batchDetails) {
66
+ return new Requestor('/users', 'POST', createBatchedRequest(), new Cache(), batchDetails);
67
+ }
42
68
  describe('Requestor', () => {
43
69
  beforeEach(() => {
44
70
  vi.useFakeTimers();
@@ -50,35 +76,33 @@ describe('Requestor', () => {
50
76
  vi.unstubAllGlobals();
51
77
  });
52
78
  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());
79
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1 }));
80
+ vi.stubGlobal('fetch', fetchMock);
81
+ const requestor = createPlainRequestor();
56
82
  const response = await requestor.request({ id: 1 });
57
- expect(requestMock).toHaveBeenCalledTimes(1);
58
- expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { id: 1 });
83
+ expect(fetchMock).toHaveBeenCalledTimes(1);
84
+ expect(fetchMock).toHaveBeenCalledWith(`${API_URL}//users?id=1`, expect.objectContaining({
85
+ method: 'GET',
86
+ credentials: 'include'
87
+ }));
59
88
  await expect(response.json()).resolves.toEqual({ id: 1 });
60
89
  });
61
90
  it('devalue response', async () => {
62
91
  const fetchMock = vi.fn(async () => devalueFetchResponse({ id: 1 }));
63
92
  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());
93
+ const requestor = createPlainRequestor();
70
94
  const response = await requestor.request({ id: 1 });
71
95
  expect(fetchMock).toHaveBeenCalledTimes(1);
72
- expect(fetchMock).toHaveBeenCalledWith('https://api.example.test//users?id=1', expect.objectContaining({
96
+ expect(fetchMock).toHaveBeenCalledWith(`${API_URL}//users?id=1`, expect.objectContaining({
73
97
  method: 'GET',
74
98
  credentials: 'include'
75
99
  }));
76
100
  await expect(response.json()).resolves.toEqual({ id: 1 });
77
101
  });
78
102
  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(), {
103
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ ok: true }));
104
+ vi.stubGlobal('fetch', fetchMock);
105
+ const requestor = createBatchedRequestor({
82
106
  canBatch: (input) => ('group' in input && input.group) || false,
83
107
  batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
84
108
  unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getSingleId(input) }))
@@ -86,25 +110,29 @@ describe('Requestor', () => {
86
110
  const p1 = requestor.request({ id: 1, group: 'team' });
87
111
  const p2 = requestor.request({ id: 2, group: 'team' });
88
112
  await vi.advanceTimersByTimeAsync(99);
89
- expect(requestMock).not.toHaveBeenCalled();
113
+ expect(fetchMock).not.toHaveBeenCalled();
90
114
  await vi.advanceTimersByTimeAsync(1);
91
- expect(requestMock).toHaveBeenCalledTimes(1);
92
- expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
115
+ expect(fetchMock).toHaveBeenCalledTimes(1);
116
+ expect(fetchMock).toHaveBeenCalledWith(`${API_URL}//users`, expect.objectContaining({
117
+ method: 'POST',
118
+ body: JSON.stringify({ ids: [1, 2] }),
119
+ credentials: 'include'
120
+ }));
93
121
  const [response1, response2] = await Promise.all([p1, p2]);
94
122
  await expect(response1.json()).resolves.toEqual({ id: 1 });
95
123
  await expect(response2.json()).resolves.toEqual({ id: 2 });
96
124
  });
97
125
  it('rate limits separate batches by start time rather than completion time', async () => {
98
126
  const starts = [];
99
- const requestMock = vi.fn(async (_path, _method, input) => {
127
+ const fetchMock = vi.fn(async (_url, init) => {
100
128
  starts.push(Date.now());
101
- if ('ids' in input && input.ids[0] === 1) {
129
+ if (init?.body === JSON.stringify({ ids: [1] })) {
102
130
  await new Promise((resolve) => setTimeout(resolve, 200));
103
131
  }
104
- return jsonResponse({ ids: 'ids' in input ? input.ids : [input.id] });
132
+ return jsonFetchResponse({ ok: true });
105
133
  });
106
- const request = requestMock;
107
- const requestor = new Requestor('/users', 'GET', request, new Cache(), {
134
+ vi.stubGlobal('fetch', fetchMock);
135
+ const requestor = createBatchedRequestor({
108
136
  canBatch: (input) => ('group' in input && input.group) || false,
109
137
  batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
110
138
  unBatchOutput: (_inputs, output) => [output]
@@ -117,13 +145,13 @@ describe('Requestor', () => {
117
145
  expect(starts).toEqual([100, 200]);
118
146
  await vi.runAllTimersAsync();
119
147
  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] });
148
+ await expect(response1.json()).resolves.toEqual({ ok: true });
149
+ await expect(response2.json()).resolves.toEqual({ ok: true });
122
150
  });
123
151
  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(), {
152
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ ok: false }, 207));
153
+ vi.stubGlobal('fetch', fetchMock);
154
+ const requestor = createBatchedRequestor({
127
155
  canBatch: () => 'team',
128
156
  batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
129
157
  unBatchOutput: () => [jsonResponse({ message: 'bad request' }, 400)]
@@ -136,11 +164,11 @@ describe('Requestor', () => {
136
164
  await expect(response.json()).resolves.toEqual({ message: 'bad request' });
137
165
  });
138
166
  it('rejects all queued callers when a batched fetch throws', async () => {
139
- const requestMock = vi.fn(async () => {
167
+ const fetchMock = vi.fn(async () => {
140
168
  throw new Error('network down');
141
169
  });
142
- const request = requestMock;
143
- const requestor = new Requestor('/users', 'GET', request, new Cache(), {
170
+ vi.stubGlobal('fetch', fetchMock);
171
+ const requestor = createBatchedRequestor({
144
172
  canBatch: () => 'team',
145
173
  batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
146
174
  unBatchOutput: (_inputs, output) => [output]
@@ -161,43 +189,40 @@ describe('Query', () => {
161
189
  afterEach(() => {
162
190
  vi.useRealTimers();
163
191
  vi.restoreAllMocks();
192
+ vi.unstubAllGlobals();
164
193
  });
165
194
  it('deduplicates concurrent requests and returns readable responses to each caller', async () => {
166
195
  const pending = deferred();
167
- const requestMock = vi.fn().mockReturnValue(pending.promise);
168
- const requestor = {
169
- request: requestMock
170
- };
196
+ const fetchMock = vi.fn().mockReturnValue(pending.promise);
197
+ vi.stubGlobal('fetch', fetchMock);
171
198
  const query = new Query({
172
199
  path: '/users',
173
200
  method: 'GET',
174
201
  input: { id: 1 },
175
- requestor,
202
+ requestor: createPlainRequestor(),
176
203
  cache: new Cache()
177
204
  });
178
205
  const p1 = query.request();
179
206
  const p2 = query.request();
180
- expect(requestMock).toHaveBeenCalledTimes(1);
181
- pending.resolve(jsonResponse({ id: 1 }));
207
+ expect(fetchMock).toHaveBeenCalledTimes(1);
208
+ pending.resolve(jsonFetchResponse({ id: 1 }));
182
209
  const [response1, response2] = await Promise.all([p1, p2]);
183
210
  await expect(response1.json()).resolves.toEqual({ id: 1 });
184
211
  await expect(response2.json()).resolves.toEqual({ id: 1 });
185
212
  });
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
- };
213
+ it('caches responses and returns readable responses on cache hits', async () => {
214
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1, name: 'Ada' }));
215
+ vi.stubGlobal('fetch', fetchMock);
191
216
  const query = new Query({
192
217
  path: '/users',
193
218
  method: 'GET',
194
219
  input: { id: 1 },
195
- requestor,
220
+ requestor: createPlainRequestor(),
196
221
  cache: new Cache()
197
222
  });
198
223
  const first = await query.request();
199
224
  const second = await query.request();
200
- expect(requestMock).toHaveBeenCalledTimes(1);
225
+ expect(fetchMock).toHaveBeenCalledTimes(1);
201
226
  expect(query.isCached).toBe(true);
202
227
  await expect(first.json()).resolves.toEqual({ id: 1, name: 'Ada' });
203
228
  await expect(second.json()).resolves.toEqual({ id: 1, name: 'Ada' });
@@ -205,17 +230,11 @@ describe('Query', () => {
205
230
  it('preserves devalue parsing for query state, returned responses, and cache hits', async () => {
206
231
  const fetchMock = vi.fn(async () => devalueFetchResponse({ id: 1, createdAt: new Date('2024-01-01T00:00:00.000Z') }));
207
232
  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
233
  const query = new Query({
215
234
  path: '/users',
216
235
  method: 'GET',
217
236
  input: { id: 1 },
218
- requestor,
237
+ requestor: createPlainRequestor(),
219
238
  cache: new Cache()
220
239
  });
221
240
  const firstResponse = await query.request();
@@ -233,37 +252,32 @@ describe('Query', () => {
233
252
  createdAt: new Date('2024-01-01T00:00:00.000Z')
234
253
  });
235
254
  });
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
- };
255
+ it('preserves arbitrary response overrides across query responses and cache hits', async () => {
256
+ const fetchMock = vi.fn(async () => withResponseOverrides(jsonFetchResponse({ id: 1 })));
257
+ vi.stubGlobal('fetch', fetchMock);
242
258
  const query = new Query({
243
259
  path: '/users',
244
260
  method: 'GET',
245
261
  input: { id: 1 },
246
- requestor,
262
+ requestor: createPlainRequestor(),
247
263
  cache: new Cache()
248
264
  });
249
265
  const first = (await query.request());
250
266
  const second = (await query.request());
251
- expect(requestMock).toHaveBeenCalledTimes(1);
267
+ expect(fetchMock).toHaveBeenCalledTimes(1);
252
268
  expect(first.extra()).toBe('copied');
253
269
  expect(first.meta).toEqual({ source: 'custom' });
254
270
  expect(second.extra()).toBe('copied');
255
271
  expect(second.meta).toEqual({ source: 'custom' });
256
272
  });
257
273
  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
- };
274
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1, active: true }));
275
+ vi.stubGlobal('fetch', fetchMock);
262
276
  const query = new Query({
263
277
  path: '/users',
264
278
  method: 'GET',
265
279
  input: { id: 1 },
266
- requestor,
280
+ requestor: createPlainRequestor(),
267
281
  cache: new Cache()
268
282
  });
269
283
  const response = await query.request();
@@ -273,15 +287,13 @@ describe('Query', () => {
273
287
  await expect(response.json()).resolves.toEqual({ id: 1, active: true });
274
288
  });
275
289
  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
- };
290
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ message: 'missing' }, 404));
291
+ vi.stubGlobal('fetch', fetchMock);
280
292
  const query = new Query({
281
293
  path: '/users',
282
294
  method: 'GET',
283
295
  input: { id: 99 },
284
- requestor,
296
+ requestor: createPlainRequestor(),
285
297
  cache: new Cache()
286
298
  });
287
299
  const response = await query.request();
@@ -292,62 +304,66 @@ describe('Query', () => {
292
304
  await expect(response.json()).resolves.toEqual({ message: 'missing' });
293
305
  });
294
306
  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
- };
307
+ let callCount = 0;
308
+ const fetchMock = vi.fn(async () => {
309
+ callCount += 1;
310
+ if (callCount === 1) {
311
+ throw new Error('network down');
312
+ }
313
+ return jsonFetchResponse({ id: 1, recovered: true });
314
+ });
315
+ vi.stubGlobal('fetch', fetchMock);
302
316
  const query = new Query({
303
317
  path: '/users',
304
318
  method: 'GET',
305
319
  input: { id: 1 },
306
- requestor,
320
+ requestor: createPlainRequestor(),
307
321
  cache: new Cache()
308
322
  });
309
323
  await expect(query.request()).rejects.toThrow('network down');
310
324
  expect(query.status).toBe('error');
311
- const response = await query.request();
312
- expect(requestMock).toHaveBeenCalledTimes(2);
325
+ const responsePromise = query.request();
326
+ await vi.advanceTimersByTimeAsync(100);
327
+ const response = await responsePromise;
328
+ expect(fetchMock).toHaveBeenCalledTimes(2);
313
329
  expect(query.status).toBe('success');
314
330
  await expect(response.json()).resolves.toEqual({ id: 1, recovered: true });
315
331
  });
316
332
  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
- };
333
+ let callCount = 0;
334
+ const fetchMock = vi.fn(async () => {
335
+ callCount += 1;
336
+ return jsonFetchResponse({ call: callCount });
337
+ });
338
+ vi.stubGlobal('fetch', fetchMock);
324
339
  const query = new Query({
325
340
  path: '/users',
326
341
  method: 'GET',
327
342
  input: { id: 1 },
328
- requestor,
343
+ requestor: createPlainRequestor(),
329
344
  cache: new Cache()
330
345
  });
331
346
  const first = await query.request();
332
347
  query.resetCache();
333
- const second = await query.request();
334
- expect(requestMock).toHaveBeenCalledTimes(2);
348
+ const secondPromise = query.request();
349
+ await vi.advanceTimersByTimeAsync(100);
350
+ const second = await secondPromise;
351
+ expect(fetchMock).toHaveBeenCalledTimes(2);
335
352
  await expect(first.json()).resolves.toEqual({ call: 1 });
336
353
  await expect(second.json()).resolves.toEqual({ call: 2 });
337
354
  });
338
355
  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
- };
356
+ let callCount = 0;
357
+ const fetchMock = vi.fn(async () => {
358
+ callCount += 1;
359
+ return jsonFetchResponse({ call: callCount });
360
+ });
361
+ vi.stubGlobal('fetch', fetchMock);
346
362
  const query = new Query({
347
363
  path: '/users',
348
364
  method: 'GET',
349
365
  input: { id: 1 },
350
- requestor,
366
+ requestor: createPlainRequestor(),
351
367
  cache: new Cache(),
352
368
  opts: {
353
369
  cache: { timeout: 50 }
@@ -357,8 +373,10 @@ describe('Query', () => {
357
373
  vi.advanceTimersByTime(49);
358
374
  const cached = await query.request();
359
375
  vi.advanceTimersByTime(1);
360
- const refreshed = await query.request();
361
- expect(requestMock).toHaveBeenCalledTimes(2);
376
+ const refreshedPromise = query.request();
377
+ await vi.advanceTimersByTimeAsync(50);
378
+ const refreshed = await refreshedPromise;
379
+ expect(fetchMock).toHaveBeenCalledTimes(2);
362
380
  await expect(first.json()).resolves.toEqual({ call: 1 });
363
381
  await expect(cached.json()).resolves.toEqual({ call: 1 });
364
382
  await expect(refreshed.json()).resolves.toEqual({ call: 2 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-ag",
3
- "version": "1.0.59",
3
+ "version": "1.0.60",
4
4
  "description": "Useful svelte components",
5
5
  "bugs": "https://github.com/ageorgeh/svelte-ag/issues",
6
6
  "repository": {
@@ -41,8 +41,7 @@
41
41
  "svelte:sync": "svelte-kit sync",
42
42
  "test": "vitest --run",
43
43
  "test:e2e": "playwright test",
44
- "test:e2e:ui": "playwright test --ui",
45
- "test:e2e:update": "playwright test --update-snapshots",
44
+ "test:e2e-local": "PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:3005/ pnpm test:e2e",
46
45
  "watch": "svelte-package -i src -w"
47
46
  },
48
47
  "dependencies": {
@@ -71,7 +70,7 @@
71
70
  "@iconify/types": "^2.0.0",
72
71
  "@internationalized/date": "^3.12.0",
73
72
  "@lucide/svelte": "^1.7.0",
74
- "@playwright/test": "1.57.0",
73
+ "@playwright/test": "1.58.2",
75
74
  "@sveltejs/kit": "^2.55.0",
76
75
  "@sveltejs/package": "^2.5.7",
77
76
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
@@ -1,5 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import type { ApiEndpoints, ApiRequestFunction } from 'ts-ag';
2
+ import { createApiRequest, type ApiEndpoints } from 'ts-ag';
3
+ import * as v from 'valibot';
3
4
 
4
5
  type TestResponse = ApiEndpoints['response'];
5
6
 
@@ -8,23 +9,29 @@ type BatchedInput = { ids: number[] };
8
9
 
9
10
  type UsersApi = {
10
11
  path: '/users';
11
- method: 'GET';
12
+ method: 'POST';
12
13
  requestInput: UserInput | BatchedInput;
13
14
  requestOutput: null;
14
15
  response: TestResponse;
15
16
  };
16
17
 
17
- type UsersRequest = ApiRequestFunction<UsersApi>;
18
+ const API_URL = 'https://api.example.test';
19
+
20
+ const schemas = {
21
+ '/users': {
22
+ POST: v.union([v.object({ id: v.number(), group: v.optional(v.string()) }), v.object({ ids: v.array(v.number()) })])
23
+ }
24
+ };
18
25
 
19
26
  function getUserId(input: UsersApi['requestInput']): number {
20
27
  return 'id' in input ? input.id : input.ids[0]!;
21
28
  }
22
29
 
23
- function jsonResponse(body: unknown, status = 200): TestResponse {
30
+ function jsonFetchResponse(body: unknown, status = 200): Response {
24
31
  return new Response(JSON.stringify(body), {
25
32
  status,
26
33
  headers: { 'content-type': 'application/json' }
27
- }) as TestResponse;
34
+ });
28
35
  }
29
36
 
30
37
  describe('createQueryFunction', () => {
@@ -37,17 +44,17 @@ describe('createQueryFunction', () => {
37
44
  afterEach(() => {
38
45
  vi.useRealTimers();
39
46
  vi.restoreAllMocks();
47
+ vi.unstubAllGlobals();
40
48
  });
41
49
 
42
50
  it('returns the same query instance for the same path, method, and input', async () => {
43
51
  const { createQueryFunction } = await import('./entrypoint.svelte.js');
44
- const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
45
- const request = requestMock as unknown as UsersRequest;
52
+ const request = createApiRequest<UsersApi>(schemas, API_URL, 'test');
46
53
  const createQuery = createQueryFunction<UsersApi>(request, {});
47
54
 
48
- const query1 = createQuery('/users', 'GET', { id: 1 });
49
- const query2 = createQuery('/users', 'GET', { id: 1 });
50
- const query3 = createQuery('/users', 'GET', { id: 2 });
55
+ const query1 = createQuery('/users', 'POST', { id: 1 });
56
+ const query2 = createQuery('/users', 'POST', { id: 1 });
57
+ const query3 = createQuery('/users', 'POST', { id: 2 });
51
58
 
52
59
  expect(query1).toBe(query2);
53
60
  expect(query3).not.toBe(query1);
@@ -55,31 +62,44 @@ describe('createQueryFunction', () => {
55
62
 
56
63
  it('reuses requestors so separate queries can batch together', async () => {
57
64
  const { createQueryFunction } = await import('./entrypoint.svelte.js');
58
- const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
59
- const request = requestMock as unknown as UsersRequest;
65
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ 1: 'one', 2: 'two' }));
66
+ vi.stubGlobal('fetch', fetchMock);
67
+ vi.stubGlobal('fetch', fetchMock);
68
+ const request = createApiRequest<UsersApi>(schemas, API_URL, 'test');
60
69
  const createQuery = createQueryFunction<UsersApi>(request, {
61
70
  '/users': {
62
- GET: {
71
+ POST: {
63
72
  canBatch: () => 'users',
64
73
  batchInput: (inputs) => ({ ids: inputs.map(getUserId) }),
65
- unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getUserId(input) }))
74
+ unBatchOutput: async (inputs, outputs) => {
75
+ return inputs.map(() => {
76
+ return outputs;
77
+ });
78
+ }
66
79
  }
67
80
  }
68
81
  });
69
82
 
70
- const query1 = createQuery('/users', 'GET', { id: 1 });
71
- const query2 = createQuery('/users', 'GET', { id: 2 });
83
+ const query1 = createQuery('/users', 'POST', { id: 1 });
84
+ const query2 = createQuery('/users', 'POST', { id: 2 });
72
85
 
73
86
  const p1 = query1.request();
74
87
  const p2 = query2.request();
75
88
 
76
89
  await vi.advanceTimersByTimeAsync(100);
77
90
 
78
- expect(requestMock).toHaveBeenCalledTimes(1);
79
- expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
91
+ expect(fetchMock).toHaveBeenCalledTimes(1);
92
+ expect(fetchMock).toHaveBeenCalledWith(
93
+ `${API_URL}//users`,
94
+ expect.objectContaining({
95
+ method: 'POST',
96
+ body: JSON.stringify({ ids: [1, 2] }),
97
+ credentials: 'include'
98
+ })
99
+ );
80
100
 
81
101
  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 });
102
+ await expect(response1.json()).resolves.toEqual({ 1: 'one', 2: 'two' });
103
+ await expect(response2.json()).resolves.toEqual({ 1: 'one', 2: 'two' });
84
104
  });
85
105
  });
@@ -9,31 +9,6 @@ import { RateLimiter } from './rate.svelte';
9
9
 
10
10
  export type QueryStatus = 'idle' | 'loading' | 'success' | 'error';
11
11
 
12
- function copyOwnOverrides<T extends object>(source: T, target: T): T {
13
- for (const key of Reflect.ownKeys(source)) {
14
- const descriptor = Object.getOwnPropertyDescriptor(source, key);
15
- if (!descriptor) continue;
16
-
17
- Object.defineProperty(target, key, descriptor);
18
- }
19
-
20
- return target;
21
- }
22
-
23
- function cloneResponse<T>(response: T): T {
24
- if (
25
- response !== null &&
26
- typeof response === 'object' &&
27
- 'clone' in response &&
28
- typeof response.clone === 'function'
29
- ) {
30
- const source = response as T & object;
31
- return copyOwnOverrides(source, response.clone() as T & object) as T;
32
- }
33
-
34
- return response;
35
- }
36
-
37
12
  export class Query<
38
13
  API extends ApiEndpoints,
39
14
  Path extends API['path'],
@@ -97,7 +72,7 @@ export class Query<
97
72
  async request(): Promise<ApiResponse<API, Path, Method>> {
98
73
  const cachedValue = this.#cache.get(this.#cacheKey);
99
74
  if (cachedValue !== null) {
100
- return cloneResponse(cachedValue);
75
+ return cachedValue;
101
76
  }
102
77
 
103
78
  this.#status = 'loading';
@@ -116,9 +91,9 @@ export class Query<
116
91
  this.#pendingRequest = null;
117
92
  }
118
93
 
119
- const responseForState = cloneResponse(res);
120
- const responseForCaller = cloneResponse(res);
121
- this.#cache.set(this.#cacheKey, cloneResponse(res));
94
+ const responseForState = res;
95
+ const responseForCaller = res;
96
+ this.#cache.set(this.#cacheKey, res);
122
97
 
123
98
  if (responseForState.ok === false) {
124
99
  const body = await responseForState.json();
@@ -223,6 +198,8 @@ export class Requestor<
223
198
  private async flushBatchQueue(batchId: string): Promise<void> {
224
199
  const queue = this.#batchQueue[batchId].splice(0);
225
200
 
201
+ // TODO maybe remove the unBatchOutput function and just always return the
202
+ // same response and then its on each consumer of each query to find the relevant records
226
203
  try {
227
204
  const batchedInput = this.#batchInput(queue.map((q) => q.input));
228
205
 
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { createApiRequest, type ApiEndpoints, type ApiRequestFunction } from 'ts-ag';
2
+ import { createApiRequest, type ApiEndpoints } from 'ts-ag';
3
3
  import { Cache } from './cache.svelte.js';
4
4
  import { Query, Requestor } from './query.svelte.js';
5
5
  import { stringify } from 'devalue';
@@ -21,25 +21,39 @@ type PlainUsersApi = {
21
21
 
22
22
  type BatchedUsersApi = {
23
23
  path: '/users';
24
- method: 'GET';
24
+ method: 'POST';
25
25
  requestInput: BatchedUserInput | BatchedRequestInput;
26
26
  requestOutput: null;
27
27
  response: TestResponse;
28
28
  };
29
29
 
30
- type PlainUsersRequestor = Requestor<PlainUsersApi, '/users', 'GET'>;
31
- type PlainUsersRequest = ApiRequestFunction<PlainUsersApi>;
32
- type BatchedUsersRequest = ApiRequestFunction<BatchedUsersApi>;
30
+ const API_URL = 'https://api.example.test';
31
+
32
+ const plainSchemas = {
33
+ '/users': {
34
+ GET: v.object({ id: v.number() })
35
+ }
36
+ };
37
+
38
+ const batchedSchemas = {
39
+ '/users': {
40
+ POST: v.union([v.object({ id: v.number(), group: v.optional(v.string()) }), v.object({ ids: v.array(v.number()) })])
41
+ }
42
+ };
33
43
 
34
44
  function getSingleId(input: BatchedUsersApi['requestInput']): number {
35
45
  return 'id' in input ? input.id : input.ids[0]!;
36
46
  }
37
47
 
38
- function jsonResponse(body: unknown, status = 200): TestResponse {
48
+ function jsonFetchResponse(body: unknown, status = 200): Response {
39
49
  return new Response(JSON.stringify(body), {
40
50
  status,
41
51
  headers: { 'content-type': 'application/json' }
42
- }) as TestResponse;
52
+ });
53
+ }
54
+
55
+ function jsonResponse(body: unknown, status = 200): TestResponse {
56
+ return jsonFetchResponse(body, status) as TestResponse;
43
57
  }
44
58
 
45
59
  function devalueFetchResponse(body: unknown, status = 200): Response {
@@ -81,6 +95,30 @@ function deferred<T>() {
81
95
  return { promise, resolve, reject };
82
96
  }
83
97
 
98
+ function createPlainRequest() {
99
+ return createApiRequest<PlainUsersApi>(plainSchemas, API_URL, 'test');
100
+ }
101
+
102
+ function createBatchedRequest() {
103
+ return createApiRequest<BatchedUsersApi>(batchedSchemas, API_URL, 'test');
104
+ }
105
+
106
+ function createPlainRequestor() {
107
+ return new Requestor<PlainUsersApi, '/users', 'GET'>('/users', 'GET', createPlainRequest(), new Cache());
108
+ }
109
+
110
+ function createBatchedRequestor(
111
+ batchDetails?: ConstructorParameters<typeof Requestor<BatchedUsersApi, '/users', 'POST'>>[4]
112
+ ) {
113
+ return new Requestor<BatchedUsersApi, '/users', 'POST'>(
114
+ '/users',
115
+ 'POST',
116
+ createBatchedRequest(),
117
+ new Cache(),
118
+ batchDetails
119
+ );
120
+ }
121
+
84
122
  describe('Requestor', () => {
85
123
  beforeEach(() => {
86
124
  vi.useFakeTimers();
@@ -94,37 +132,33 @@ describe('Requestor', () => {
94
132
  });
95
133
 
96
134
  it('passes through non-batched requests', async () => {
97
- const requestMock = vi.fn(async () => jsonResponse({ id: 1 }));
98
- const request = requestMock as unknown as PlainUsersRequest;
99
- const requestor = new Requestor<PlainUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache());
135
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1 }));
136
+ vi.stubGlobal('fetch', fetchMock);
137
+ const requestor = createPlainRequestor();
100
138
 
101
139
  const response = await requestor.request({ id: 1 });
102
140
 
103
- expect(requestMock).toHaveBeenCalledTimes(1);
104
- expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { id: 1 });
141
+ expect(fetchMock).toHaveBeenCalledTimes(1);
142
+ expect(fetchMock).toHaveBeenCalledWith(
143
+ `${API_URL}//users?id=1`,
144
+ expect.objectContaining({
145
+ method: 'GET',
146
+ credentials: 'include'
147
+ })
148
+ );
105
149
  await expect(response.json()).resolves.toEqual({ id: 1 });
106
150
  });
107
151
 
108
152
  it('devalue response', async () => {
109
153
  const fetchMock = vi.fn(async () => devalueFetchResponse({ id: 1 }));
110
154
  vi.stubGlobal('fetch', fetchMock);
111
-
112
- const request = createApiRequest<PlainUsersApi>(
113
- {
114
- '/users': {
115
- GET: v.object({ id: v.number() })
116
- }
117
- },
118
- 'https://api.example.test',
119
- 'test'
120
- );
121
- const requestor = new Requestor<PlainUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache());
155
+ const requestor = createPlainRequestor();
122
156
 
123
157
  const response = await requestor.request({ id: 1 });
124
158
 
125
159
  expect(fetchMock).toHaveBeenCalledTimes(1);
126
160
  expect(fetchMock).toHaveBeenCalledWith(
127
- 'https://api.example.test//users?id=1',
161
+ `${API_URL}//users?id=1`,
128
162
  expect.objectContaining({
129
163
  method: 'GET',
130
164
  credentials: 'include'
@@ -134,9 +168,9 @@ describe('Requestor', () => {
134
168
  });
135
169
 
136
170
  it('batches requests with the same batch id and preserves response order', async () => {
137
- const requestMock = vi.fn(async () => jsonResponse({ ok: true }));
138
- const request = requestMock as unknown as BatchedUsersRequest;
139
- const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
171
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ ok: true }));
172
+ vi.stubGlobal('fetch', fetchMock);
173
+ const requestor = createBatchedRequestor({
140
174
  canBatch: (input) => ('group' in input && input.group) || false,
141
175
  batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
142
176
  unBatchOutput: (inputs) => inputs.map((input) => jsonResponse({ id: getSingleId(input) }))
@@ -146,11 +180,18 @@ describe('Requestor', () => {
146
180
  const p2 = requestor.request({ id: 2, group: 'team' });
147
181
 
148
182
  await vi.advanceTimersByTimeAsync(99);
149
- expect(requestMock).not.toHaveBeenCalled();
183
+ expect(fetchMock).not.toHaveBeenCalled();
150
184
 
151
185
  await vi.advanceTimersByTimeAsync(1);
152
- expect(requestMock).toHaveBeenCalledTimes(1);
153
- expect(requestMock).toHaveBeenCalledWith('/users', 'GET', { ids: [1, 2] });
186
+ expect(fetchMock).toHaveBeenCalledTimes(1);
187
+ expect(fetchMock).toHaveBeenCalledWith(
188
+ `${API_URL}//users`,
189
+ expect.objectContaining({
190
+ method: 'POST',
191
+ body: JSON.stringify({ ids: [1, 2] }),
192
+ credentials: 'include'
193
+ })
194
+ );
154
195
 
155
196
  const [response1, response2] = await Promise.all([p1, p2]);
156
197
  await expect(response1.json()).resolves.toEqual({ id: 1 });
@@ -159,18 +200,17 @@ describe('Requestor', () => {
159
200
 
160
201
  it('rate limits separate batches by start time rather than completion time', async () => {
161
202
  const starts: number[] = [];
162
- const requestMock = vi.fn(async (_path: '/users', _method: 'GET', input: BatchedUsersApi['requestInput']) => {
203
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
163
204
  starts.push(Date.now());
164
205
 
165
- if ('ids' in input && input.ids[0] === 1) {
206
+ if (init?.body === JSON.stringify({ ids: [1] })) {
166
207
  await new Promise((resolve) => setTimeout(resolve, 200));
167
208
  }
168
209
 
169
- return jsonResponse({ ids: 'ids' in input ? input.ids : [input.id] });
210
+ return jsonFetchResponse({ ok: true });
170
211
  });
171
- const request = requestMock as unknown as BatchedUsersRequest;
172
-
173
- const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
212
+ vi.stubGlobal('fetch', fetchMock);
213
+ const requestor = createBatchedRequestor({
174
214
  canBatch: (input) => ('group' in input && input.group) || false,
175
215
  batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
176
216
  unBatchOutput: (_inputs, output) => [output]
@@ -188,14 +228,14 @@ describe('Requestor', () => {
188
228
  await vi.runAllTimersAsync();
189
229
 
190
230
  const [response1, response2] = await Promise.all([p1, p2]);
191
- await expect(response1.json()).resolves.toEqual({ ids: [1] });
192
- await expect(response2.json()).resolves.toEqual({ ids: [2] });
231
+ await expect(response1.json()).resolves.toEqual({ ok: true });
232
+ await expect(response2.json()).resolves.toEqual({ ok: true });
193
233
  });
194
234
 
195
235
  it('returns batched error responses without rejecting them', async () => {
196
- const requestMock = vi.fn(async () => jsonResponse({ ok: false }, 207));
197
- const request = requestMock as unknown as BatchedUsersRequest;
198
- const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
236
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ ok: false }, 207));
237
+ vi.stubGlobal('fetch', fetchMock);
238
+ const requestor = createBatchedRequestor({
199
239
  canBatch: () => 'team',
200
240
  batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
201
241
  unBatchOutput: () => [jsonResponse({ message: 'bad request' }, 400)]
@@ -212,11 +252,11 @@ describe('Requestor', () => {
212
252
  });
213
253
 
214
254
  it('rejects all queued callers when a batched fetch throws', async () => {
215
- const requestMock = vi.fn(async () => {
255
+ const fetchMock = vi.fn(async () => {
216
256
  throw new Error('network down');
217
257
  });
218
- const request = requestMock as unknown as BatchedUsersRequest;
219
- const requestor = new Requestor<BatchedUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache(), {
258
+ vi.stubGlobal('fetch', fetchMock);
259
+ const requestor = createBatchedRequestor({
220
260
  canBatch: () => 'team',
221
261
  batchInput: (inputs) => ({ ids: inputs.map(getSingleId) }),
222
262
  unBatchOutput: (_inputs, output) => [output]
@@ -242,53 +282,48 @@ describe('Query', () => {
242
282
  afterEach(() => {
243
283
  vi.useRealTimers();
244
284
  vi.restoreAllMocks();
285
+ vi.unstubAllGlobals();
245
286
  });
246
287
 
247
288
  it('deduplicates concurrent requests and returns readable responses to each caller', async () => {
248
289
  const pending = deferred<Response>();
249
- const requestMock = vi.fn().mockReturnValue(pending.promise);
250
- const requestor = {
251
- request: requestMock as PlainUsersRequest
252
- } as unknown as PlainUsersRequestor;
253
-
290
+ const fetchMock = vi.fn().mockReturnValue(pending.promise);
291
+ vi.stubGlobal('fetch', fetchMock);
254
292
  const query = new Query<PlainUsersApi, '/users', 'GET'>({
255
293
  path: '/users',
256
294
  method: 'GET',
257
295
  input: { id: 1 },
258
- requestor,
296
+ requestor: createPlainRequestor(),
259
297
  cache: new Cache()
260
298
  });
261
299
 
262
300
  const p1 = query.request();
263
301
  const p2 = query.request();
264
302
 
265
- expect(requestMock).toHaveBeenCalledTimes(1);
303
+ expect(fetchMock).toHaveBeenCalledTimes(1);
266
304
 
267
- pending.resolve(jsonResponse({ id: 1 }));
305
+ pending.resolve(jsonFetchResponse({ id: 1 }));
268
306
 
269
307
  const [response1, response2] = await Promise.all([p1, p2]);
270
308
  await expect(response1.json()).resolves.toEqual({ id: 1 });
271
309
  await expect(response2.json()).resolves.toEqual({ id: 1 });
272
310
  });
273
311
 
274
- it('caches responses and returns a fresh readable clone on cache hits', async () => {
275
- const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, name: 'Ada' }));
276
- const requestor = {
277
- request: requestMock as PlainUsersRequest
278
- } as unknown as PlainUsersRequestor;
279
-
312
+ it('caches responses and returns readable responses on cache hits', async () => {
313
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1, name: 'Ada' }));
314
+ vi.stubGlobal('fetch', fetchMock);
280
315
  const query = new Query<PlainUsersApi, '/users', 'GET'>({
281
316
  path: '/users',
282
317
  method: 'GET',
283
318
  input: { id: 1 },
284
- requestor,
319
+ requestor: createPlainRequestor(),
285
320
  cache: new Cache()
286
321
  });
287
322
 
288
323
  const first = await query.request();
289
324
  const second = await query.request();
290
325
 
291
- expect(requestMock).toHaveBeenCalledTimes(1);
326
+ expect(fetchMock).toHaveBeenCalledTimes(1);
292
327
  expect(query.isCached).toBe(true);
293
328
  await expect(first.json()).resolves.toEqual({ id: 1, name: 'Ada' });
294
329
  await expect(second.json()).resolves.toEqual({ id: 1, name: 'Ada' });
@@ -299,22 +334,11 @@ describe('Query', () => {
299
334
  devalueFetchResponse({ id: 1, createdAt: new Date('2024-01-01T00:00:00.000Z') })
300
335
  );
301
336
  vi.stubGlobal('fetch', fetchMock);
302
-
303
- const request = createApiRequest<PlainUsersApi>(
304
- {
305
- '/users': {
306
- GET: v.object({ id: v.number() })
307
- }
308
- },
309
- 'https://api.example.test',
310
- 'test'
311
- );
312
- const requestor = new Requestor<PlainUsersApi, '/users', 'GET'>('/users', 'GET', request, new Cache());
313
337
  const query = new Query<PlainUsersApi, '/users', 'GET'>({
314
338
  path: '/users',
315
339
  method: 'GET',
316
340
  input: { id: 1 },
317
- requestor,
341
+ requestor: createPlainRequestor(),
318
342
  cache: new Cache()
319
343
  });
320
344
 
@@ -337,18 +361,14 @@ describe('Query', () => {
337
361
  });
338
362
  });
339
363
 
340
- it('preserves arbitrary response overrides across query clones and cache hits', async () => {
341
- const customResponse = withResponseOverrides(jsonResponse({ id: 1 }) as Response);
342
- const requestMock = vi.fn().mockResolvedValue(customResponse);
343
- const requestor = {
344
- request: requestMock as PlainUsersRequest
345
- } as unknown as PlainUsersRequestor;
346
-
364
+ it('preserves arbitrary response overrides across query responses and cache hits', async () => {
365
+ const fetchMock = vi.fn(async () => withResponseOverrides(jsonFetchResponse({ id: 1 })));
366
+ vi.stubGlobal('fetch', fetchMock);
347
367
  const query = new Query<PlainUsersApi, '/users', 'GET'>({
348
368
  path: '/users',
349
369
  method: 'GET',
350
370
  input: { id: 1 },
351
- requestor,
371
+ requestor: createPlainRequestor(),
352
372
  cache: new Cache()
353
373
  });
354
374
 
@@ -361,7 +381,7 @@ describe('Query', () => {
361
381
  meta: { source: string };
362
382
  };
363
383
 
364
- expect(requestMock).toHaveBeenCalledTimes(1);
384
+ expect(fetchMock).toHaveBeenCalledTimes(1);
365
385
  expect(first.extra()).toBe('copied');
366
386
  expect(first.meta).toEqual({ source: 'custom' });
367
387
  expect(second.extra()).toBe('copied');
@@ -369,16 +389,13 @@ describe('Query', () => {
369
389
  });
370
390
 
371
391
  it('updates success state from successful responses', async () => {
372
- const requestMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1, active: true }));
373
- const requestor = {
374
- request: requestMock as PlainUsersRequest
375
- } as unknown as PlainUsersRequestor;
376
-
392
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ id: 1, active: true }));
393
+ vi.stubGlobal('fetch', fetchMock);
377
394
  const query = new Query<PlainUsersApi, '/users', 'GET'>({
378
395
  path: '/users',
379
396
  method: 'GET',
380
397
  input: { id: 1 },
381
- requestor,
398
+ requestor: createPlainRequestor(),
382
399
  cache: new Cache()
383
400
  });
384
401
 
@@ -391,16 +408,13 @@ describe('Query', () => {
391
408
  });
392
409
 
393
410
  it('updates error state from error responses without throwing', async () => {
394
- const requestMock = vi.fn().mockResolvedValue(jsonResponse({ message: 'missing' }, 404));
395
- const requestor = {
396
- request: requestMock as PlainUsersRequest
397
- } as unknown as PlainUsersRequestor;
398
-
411
+ const fetchMock = vi.fn(async () => jsonFetchResponse({ message: 'missing' }, 404));
412
+ vi.stubGlobal('fetch', fetchMock);
399
413
  const query = new Query<PlainUsersApi, '/users', 'GET'>({
400
414
  path: '/users',
401
415
  method: 'GET',
402
416
  input: { id: 99 },
403
- requestor,
417
+ requestor: createPlainRequestor(),
404
418
  cache: new Cache()
405
419
  });
406
420
 
@@ -414,72 +428,75 @@ describe('Query', () => {
414
428
  });
415
429
 
416
430
  it('clears the pending request when the request throws so later retries can succeed', async () => {
417
- const requestMock = vi
418
- .fn()
419
- .mockRejectedValueOnce(new Error('network down'))
420
- .mockResolvedValueOnce(jsonResponse({ id: 1, recovered: true }));
421
- const requestor = {
422
- request: requestMock as PlainUsersRequest
423
- } as unknown as PlainUsersRequestor;
431
+ let callCount = 0;
432
+ const fetchMock = vi.fn(async () => {
433
+ callCount += 1;
424
434
 
435
+ if (callCount === 1) {
436
+ throw new Error('network down');
437
+ }
438
+
439
+ return jsonFetchResponse({ id: 1, recovered: true });
440
+ });
441
+ vi.stubGlobal('fetch', fetchMock);
425
442
  const query = new Query<PlainUsersApi, '/users', 'GET'>({
426
443
  path: '/users',
427
444
  method: 'GET',
428
445
  input: { id: 1 },
429
- requestor,
446
+ requestor: createPlainRequestor(),
430
447
  cache: new Cache()
431
448
  });
432
449
 
433
450
  await expect(query.request()).rejects.toThrow('network down');
434
451
  expect(query.status).toBe('error');
435
452
 
436
- const response = await query.request();
453
+ const responsePromise = query.request();
454
+ await vi.advanceTimersByTimeAsync(100);
455
+ const response = await responsePromise;
437
456
 
438
- expect(requestMock).toHaveBeenCalledTimes(2);
457
+ expect(fetchMock).toHaveBeenCalledTimes(2);
439
458
  expect(query.status).toBe('success');
440
459
  await expect(response.json()).resolves.toEqual({ id: 1, recovered: true });
441
460
  });
442
461
 
443
462
  it('resetCache forces the next request to fetch again', async () => {
444
- const requestMock = vi
445
- .fn()
446
- .mockResolvedValueOnce(jsonResponse({ call: 1 }))
447
- .mockResolvedValueOnce(jsonResponse({ call: 2 }));
448
- const requestor = {
449
- request: requestMock as PlainUsersRequest
450
- } as unknown as PlainUsersRequestor;
451
-
463
+ let callCount = 0;
464
+ const fetchMock = vi.fn(async () => {
465
+ callCount += 1;
466
+ return jsonFetchResponse({ call: callCount });
467
+ });
468
+ vi.stubGlobal('fetch', fetchMock);
452
469
  const query = new Query<PlainUsersApi, '/users', 'GET'>({
453
470
  path: '/users',
454
471
  method: 'GET',
455
472
  input: { id: 1 },
456
- requestor,
473
+ requestor: createPlainRequestor(),
457
474
  cache: new Cache()
458
475
  });
459
476
 
460
477
  const first = await query.request();
461
478
  query.resetCache();
462
- const second = await query.request();
479
+ const secondPromise = query.request();
480
+ await vi.advanceTimersByTimeAsync(100);
481
+ const second = await secondPromise;
463
482
 
464
- expect(requestMock).toHaveBeenCalledTimes(2);
483
+ expect(fetchMock).toHaveBeenCalledTimes(2);
465
484
  await expect(first.json()).resolves.toEqual({ call: 1 });
466
485
  await expect(second.json()).resolves.toEqual({ call: 2 });
467
486
  });
468
487
 
469
488
  it('honors custom cache timeout options', async () => {
470
- const requestMock = vi
471
- .fn()
472
- .mockResolvedValueOnce(jsonResponse({ call: 1 }))
473
- .mockResolvedValueOnce(jsonResponse({ call: 2 }));
474
- const requestor = {
475
- request: requestMock as PlainUsersRequest
476
- } as unknown as PlainUsersRequestor;
477
-
489
+ let callCount = 0;
490
+ const fetchMock = vi.fn(async () => {
491
+ callCount += 1;
492
+ return jsonFetchResponse({ call: callCount });
493
+ });
494
+ vi.stubGlobal('fetch', fetchMock);
478
495
  const query = new Query<PlainUsersApi, '/users', 'GET'>({
479
496
  path: '/users',
480
497
  method: 'GET',
481
498
  input: { id: 1 },
482
- requestor,
499
+ requestor: createPlainRequestor(),
483
500
  cache: new Cache(),
484
501
  opts: {
485
502
  cache: { timeout: 50 }
@@ -490,9 +507,11 @@ describe('Query', () => {
490
507
  vi.advanceTimersByTime(49);
491
508
  const cached = await query.request();
492
509
  vi.advanceTimersByTime(1);
493
- const refreshed = await query.request();
510
+ const refreshedPromise = query.request();
511
+ await vi.advanceTimersByTimeAsync(50);
512
+ const refreshed = await refreshedPromise;
494
513
 
495
- expect(requestMock).toHaveBeenCalledTimes(2);
514
+ expect(fetchMock).toHaveBeenCalledTimes(2);
496
515
  await expect(first.json()).resolves.toEqual({ call: 1 });
497
516
  await expect(cached.json()).resolves.toEqual({ call: 1 });
498
517
  await expect(refreshed.json()).resolves.toEqual({ call: 2 });