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