@fragno-dev/core 0.0.1
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.
- package/.turbo/turbo-build.log +61 -0
- package/.turbo/turbo-types$colon$check.log +2 -0
- package/dist/api/api.d.ts +2 -0
- package/dist/api/api.js +3 -0
- package/dist/api-CBDGZiLC.d.ts +278 -0
- package/dist/api-CBDGZiLC.d.ts.map +1 -0
- package/dist/api-DgHfYjq2.js +54 -0
- package/dist/api-DgHfYjq2.js.map +1 -0
- package/dist/client/client.d.ts +3 -0
- package/dist/client/client.js +6 -0
- package/dist/client/client.svelte.d.ts +33 -0
- package/dist/client/client.svelte.d.ts.map +1 -0
- package/dist/client/client.svelte.js +123 -0
- package/dist/client/client.svelte.js.map +1 -0
- package/dist/client/react.d.ts +58 -0
- package/dist/client/react.d.ts.map +1 -0
- package/dist/client/react.js +80 -0
- package/dist/client/react.js.map +1 -0
- package/dist/client/vanilla.d.ts +61 -0
- package/dist/client/vanilla.d.ts.map +1 -0
- package/dist/client/vanilla.js +136 -0
- package/dist/client/vanilla.js.map +1 -0
- package/dist/client/vue.d.ts +39 -0
- package/dist/client/vue.d.ts.map +1 -0
- package/dist/client/vue.js +108 -0
- package/dist/client/vue.js.map +1 -0
- package/dist/client-DWjxKDnE.js +703 -0
- package/dist/client-DWjxKDnE.js.map +1 -0
- package/dist/client-XFdAy-IQ.d.ts +287 -0
- package/dist/client-XFdAy-IQ.d.ts.map +1 -0
- package/dist/integrations/astro.d.ts +18 -0
- package/dist/integrations/astro.d.ts.map +1 -0
- package/dist/integrations/astro.js +16 -0
- package/dist/integrations/astro.js.map +1 -0
- package/dist/integrations/next-js.d.ts +15 -0
- package/dist/integrations/next-js.d.ts.map +1 -0
- package/dist/integrations/next-js.js +17 -0
- package/dist/integrations/next-js.js.map +1 -0
- package/dist/integrations/react-ssr.d.ts +19 -0
- package/dist/integrations/react-ssr.d.ts.map +1 -0
- package/dist/integrations/react-ssr.js +38 -0
- package/dist/integrations/react-ssr.js.map +1 -0
- package/dist/integrations/svelte-kit.d.ts +21 -0
- package/dist/integrations/svelte-kit.d.ts.map +1 -0
- package/dist/integrations/svelte-kit.js +18 -0
- package/dist/integrations/svelte-kit.js.map +1 -0
- package/dist/mod.d.ts +3 -0
- package/dist/mod.js +177 -0
- package/dist/mod.js.map +1 -0
- package/dist/route-Bp6eByhz.js +331 -0
- package/dist/route-Bp6eByhz.js.map +1 -0
- package/dist/ssr-tJHqcNSw.js +48 -0
- package/dist/ssr-tJHqcNSw.js.map +1 -0
- package/package.json +127 -0
- package/src/api/api.test.ts +140 -0
- package/src/api/api.ts +106 -0
- package/src/api/error.ts +47 -0
- package/src/api/fragment.test.ts +509 -0
- package/src/api/fragment.ts +277 -0
- package/src/api/internal/path-runtime.test.ts +121 -0
- package/src/api/internal/path-type.test.ts +602 -0
- package/src/api/internal/path.ts +322 -0
- package/src/api/internal/response-stream.ts +118 -0
- package/src/api/internal/route.test.ts +56 -0
- package/src/api/internal/route.ts +9 -0
- package/src/api/request-input-context.test.ts +437 -0
- package/src/api/request-input-context.ts +201 -0
- package/src/api/request-middleware.test.ts +544 -0
- package/src/api/request-middleware.ts +126 -0
- package/src/api/request-output-context.test.ts +626 -0
- package/src/api/request-output-context.ts +175 -0
- package/src/api/route.test.ts +176 -0
- package/src/api/route.ts +152 -0
- package/src/client/client-builder.test.ts +264 -0
- package/src/client/client-error.test.ts +15 -0
- package/src/client/client-error.ts +141 -0
- package/src/client/client-types.test.ts +493 -0
- package/src/client/client.ssr.test.ts +173 -0
- package/src/client/client.svelte.test.ts +837 -0
- package/src/client/client.svelte.ts +278 -0
- package/src/client/client.test.ts +1690 -0
- package/src/client/client.ts +1035 -0
- package/src/client/component.test.svelte +21 -0
- package/src/client/internal/ndjson-streaming.test.ts +457 -0
- package/src/client/internal/ndjson-streaming.ts +248 -0
- package/src/client/react.test.ts +947 -0
- package/src/client/react.ts +241 -0
- package/src/client/vanilla.test.ts +867 -0
- package/src/client/vanilla.ts +265 -0
- package/src/client/vue.test.ts +754 -0
- package/src/client/vue.ts +242 -0
- package/src/http/http-status.ts +60 -0
- package/src/integrations/astro.ts +17 -0
- package/src/integrations/next-js.ts +31 -0
- package/src/integrations/react-ssr.ts +40 -0
- package/src/integrations/svelte-kit.ts +41 -0
- package/src/mod.ts +20 -0
- package/src/util/async.test.ts +85 -0
- package/src/util/async.ts +96 -0
- package/src/util/content-type.test.ts +136 -0
- package/src/util/content-type.ts +84 -0
- package/src/util/nanostores.test.ts +28 -0
- package/src/util/nanostores.ts +65 -0
- package/src/util/ssr.ts +75 -0
- package/src/util/types-util.ts +16 -0
- package/tsconfig.json +10 -0
- package/tsdown.config.ts +21 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,1690 @@
|
|
|
1
|
+
import { afterEach, assert, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineRoute } from "../api/route";
|
|
4
|
+
import { buildUrl, createClientBuilder, getCacheKey, isGetHook, isMutatorHook } from "./client";
|
|
5
|
+
import { useFragno } from "./vanilla";
|
|
6
|
+
import { createAsyncIteratorFromCallback, waitForAsyncIterator } from "../util/async";
|
|
7
|
+
import type { FragnoPublicClientConfig } from "../mod";
|
|
8
|
+
import { atom, computed, effect } from "nanostores";
|
|
9
|
+
import { defineFragment } from "../api/fragment";
|
|
10
|
+
import { RequestOutputContext } from "../api/request-output-context";
|
|
11
|
+
import { FragnoClientUnknownApiError } from "./client-error";
|
|
12
|
+
|
|
13
|
+
// Mock fetch globally
|
|
14
|
+
global.fetch = vi.fn();
|
|
15
|
+
|
|
16
|
+
describe("buildUrl", () => {
|
|
17
|
+
test("should build URL with no parameters", () => {
|
|
18
|
+
const result = buildUrl(
|
|
19
|
+
{ baseUrl: "http://localhost:3000", mountRoute: "/api", path: "/users" },
|
|
20
|
+
{},
|
|
21
|
+
);
|
|
22
|
+
expect(result).toBe("http://localhost:3000/api/users");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("should build URL with path parameters", () => {
|
|
26
|
+
const result = buildUrl(
|
|
27
|
+
{ baseUrl: "http://localhost:3000", mountRoute: "/api", path: "/users/:id" },
|
|
28
|
+
{ pathParams: { id: "123" } },
|
|
29
|
+
);
|
|
30
|
+
expect(result).toBe("http://localhost:3000/api/users/123");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("should build URL with query parameters", () => {
|
|
34
|
+
const result = buildUrl(
|
|
35
|
+
{ baseUrl: "http://localhost:3000", mountRoute: "/api", path: "/users" },
|
|
36
|
+
{ queryParams: { sort: "name", order: "asc" } },
|
|
37
|
+
);
|
|
38
|
+
expect(result).toBe("http://localhost:3000/api/users?sort=name&order=asc");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("should build URL with both path and query parameters", () => {
|
|
42
|
+
const result = buildUrl(
|
|
43
|
+
{ baseUrl: "http://localhost:3000", mountRoute: "/api", path: "/users/:id/posts" },
|
|
44
|
+
{ pathParams: { id: "123" }, queryParams: { limit: "10" } },
|
|
45
|
+
);
|
|
46
|
+
expect(result).toBe("http://localhost:3000/api/users/123/posts?limit=10");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("should handle empty baseUrl", () => {
|
|
50
|
+
const result = buildUrl({ baseUrl: "", mountRoute: "/api", path: "/users" }, {});
|
|
51
|
+
expect(result).toBe("/api/users");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("should handle empty mountRoute", () => {
|
|
55
|
+
const result = buildUrl(
|
|
56
|
+
{ baseUrl: "http://localhost:3000", mountRoute: "", path: "/users" },
|
|
57
|
+
{},
|
|
58
|
+
);
|
|
59
|
+
expect(result).toBe("http://localhost:3000/users");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("should handle undefined baseUrl", () => {
|
|
63
|
+
const result = buildUrl({ mountRoute: "/api", path: "/users" }, {});
|
|
64
|
+
expect(result).toBe("/api/users");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("getCacheKey", () => {
|
|
69
|
+
test("should return path only when no parameters", () => {
|
|
70
|
+
const result = getCacheKey("GET", "/users");
|
|
71
|
+
expect(result).toEqual(["GET", "/users"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("should include path parameters in order", () => {
|
|
75
|
+
const result = getCacheKey("GET", "/users/:id/posts/:postId", {
|
|
76
|
+
pathParams: { id: "123", postId: "456" },
|
|
77
|
+
});
|
|
78
|
+
expect(result).toEqual(["GET", "/users/:id/posts/:postId", "123", "456"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("should include query parameters in alphabetical order", () => {
|
|
82
|
+
const result = getCacheKey("GET", "/users", {
|
|
83
|
+
queryParams: { sort: "name", order: "asc" },
|
|
84
|
+
});
|
|
85
|
+
expect(result).toEqual(["GET", "/users", "asc", "name"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("should handle missing path parameters", () => {
|
|
89
|
+
const result = getCacheKey("GET", "/users/:id/posts/:postId", {
|
|
90
|
+
pathParams: { id: "123" },
|
|
91
|
+
});
|
|
92
|
+
expect(result).toEqual(["GET", "/users/:id/posts/:postId", "123", "<missing>"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("should handle both path and query parameters", () => {
|
|
96
|
+
const result = getCacheKey("GET", "/users/:id", {
|
|
97
|
+
pathParams: { id: "123" },
|
|
98
|
+
queryParams: { sort: "name" },
|
|
99
|
+
});
|
|
100
|
+
expect(result).toEqual(["GET", "/users/:id", "123", "name"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("should handle empty params object", () => {
|
|
104
|
+
const result = getCacheKey("GET", "/users", {});
|
|
105
|
+
expect(result).toEqual(["GET", "/users"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("should handle undefined params", () => {
|
|
109
|
+
const result = getCacheKey("GET", "/users");
|
|
110
|
+
expect(result).toEqual(["GET", "/users"]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("invalidation", () => {
|
|
115
|
+
const testFragment = defineFragment("test-fragment");
|
|
116
|
+
const testRoutes = [
|
|
117
|
+
defineRoute({
|
|
118
|
+
method: "GET",
|
|
119
|
+
path: "/users",
|
|
120
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
121
|
+
handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
|
|
122
|
+
}),
|
|
123
|
+
defineRoute({
|
|
124
|
+
method: "POST",
|
|
125
|
+
path: "/users",
|
|
126
|
+
inputSchema: z.object({ name: z.string() }),
|
|
127
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
128
|
+
handler: async (_ctx, { json }) => json({ id: 2, name: "Jane" }),
|
|
129
|
+
}),
|
|
130
|
+
defineRoute({
|
|
131
|
+
method: "GET",
|
|
132
|
+
path: "/users/:id",
|
|
133
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
134
|
+
handler: async ({ pathParams }, { json }) =>
|
|
135
|
+
json({ id: Number(pathParams["id"]), name: "John" }),
|
|
136
|
+
}),
|
|
137
|
+
] as const;
|
|
138
|
+
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
vi.clearAllMocks();
|
|
141
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
vi.restoreAllMocks();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("should automatically refetch when an item is invalidated", async () => {
|
|
149
|
+
let callCount = 0;
|
|
150
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
151
|
+
callCount++;
|
|
152
|
+
return {
|
|
153
|
+
headers: new Headers(),
|
|
154
|
+
ok: true,
|
|
155
|
+
json: async () => [{ id: callCount, name: `John${callCount}` }],
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const client = createClientBuilder(
|
|
160
|
+
testFragment,
|
|
161
|
+
{ baseUrl: "http://localhost:3000" },
|
|
162
|
+
testRoutes,
|
|
163
|
+
);
|
|
164
|
+
const clientObj = {
|
|
165
|
+
useUsers: client.createHook("/users"),
|
|
166
|
+
useMutateUsers: client.createMutator("POST", "/users"),
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const { useUsers, useMutateUsers } = useFragno(clientObj);
|
|
170
|
+
const userStore = useUsers();
|
|
171
|
+
|
|
172
|
+
const stateAfterInitialLoad = await waitForAsyncIterator(
|
|
173
|
+
userStore,
|
|
174
|
+
(state) => state.loading === false,
|
|
175
|
+
);
|
|
176
|
+
expect(stateAfterInitialLoad).toEqual({
|
|
177
|
+
loading: false,
|
|
178
|
+
data: [{ id: 1, name: "John1" }],
|
|
179
|
+
});
|
|
180
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
181
|
+
|
|
182
|
+
// The second fetch call is the mutation.
|
|
183
|
+
await useMutateUsers().mutate({
|
|
184
|
+
body: { name: "John" },
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const stateAfterRefetch = await waitForAsyncIterator(
|
|
188
|
+
userStore,
|
|
189
|
+
(state) => state.loading === false,
|
|
190
|
+
);
|
|
191
|
+
expect(stateAfterRefetch).toEqual({
|
|
192
|
+
loading: false,
|
|
193
|
+
data: [{ id: 3, name: "John3" }],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("should refetch when an item is invalidated", async () => {
|
|
200
|
+
let callCount = 0;
|
|
201
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
|
202
|
+
callCount++;
|
|
203
|
+
return {
|
|
204
|
+
headers: new Headers(),
|
|
205
|
+
ok: true,
|
|
206
|
+
json: async () => [{ id: callCount, name: `John${callCount}` }],
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const client = createClientBuilder(
|
|
211
|
+
testFragment,
|
|
212
|
+
{ baseUrl: "http://localhost:3000" },
|
|
213
|
+
testRoutes,
|
|
214
|
+
);
|
|
215
|
+
let invalidateCalled = false;
|
|
216
|
+
const clientObj = {
|
|
217
|
+
useUsers: client.createHook("/users"),
|
|
218
|
+
modifyUsersManual: client.createMutator("POST", "/users", (invalidate) => {
|
|
219
|
+
invalidateCalled = true;
|
|
220
|
+
return invalidate("GET", "/users", {});
|
|
221
|
+
}),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const { useUsers, modifyUsersManual } = useFragno(clientObj);
|
|
225
|
+
const userStore = useUsers();
|
|
226
|
+
|
|
227
|
+
const stateAfterInitialLoad = await waitForAsyncIterator(
|
|
228
|
+
userStore,
|
|
229
|
+
(state) => state.loading === false,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(stateAfterInitialLoad).toEqual({
|
|
233
|
+
loading: false,
|
|
234
|
+
data: [{ id: 1, name: "John1" }],
|
|
235
|
+
});
|
|
236
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
237
|
+
|
|
238
|
+
// The second fetch call is the mutation.
|
|
239
|
+
await modifyUsersManual().mutate({
|
|
240
|
+
body: { name: "John" },
|
|
241
|
+
});
|
|
242
|
+
expect(invalidateCalled).toBe(true);
|
|
243
|
+
|
|
244
|
+
const stateAfterRefetch = await waitForAsyncIterator(
|
|
245
|
+
userStore,
|
|
246
|
+
(state) => state.loading === false,
|
|
247
|
+
);
|
|
248
|
+
expect(stateAfterRefetch).toEqual({
|
|
249
|
+
loading: false,
|
|
250
|
+
data: [{ id: 3, name: "John3" }],
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("hook parameter reactivity", () => {
|
|
258
|
+
const clientConfig: FragnoPublicClientConfig = {
|
|
259
|
+
baseUrl: "http://localhost:3000",
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
vi.clearAllMocks();
|
|
264
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
afterEach(() => {
|
|
268
|
+
vi.restoreAllMocks();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("should react to path parameters", async () => {
|
|
272
|
+
const testFragment = defineFragment("test-fragment");
|
|
273
|
+
const testRoutes = [
|
|
274
|
+
defineRoute({
|
|
275
|
+
method: "GET",
|
|
276
|
+
path: "/users/:id",
|
|
277
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
278
|
+
handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
|
|
279
|
+
}),
|
|
280
|
+
] as const;
|
|
281
|
+
|
|
282
|
+
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
|
283
|
+
assert(typeof input === "string");
|
|
284
|
+
|
|
285
|
+
// Regex to extract id value from a URL string, matching only on /users/:id
|
|
286
|
+
const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
|
|
287
|
+
|
|
288
|
+
expect(id).toBeDefined();
|
|
289
|
+
expect(+id).not.toBeNaN();
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
headers: new Headers(),
|
|
293
|
+
ok: true,
|
|
294
|
+
json: async () => ({ id: Number(id), name: "John" }),
|
|
295
|
+
} as Response;
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
299
|
+
const useUsers = cb.createHook("/users/:id");
|
|
300
|
+
|
|
301
|
+
const idAtom = atom("123");
|
|
302
|
+
const store = useUsers.store({ path: { id: idAtom } });
|
|
303
|
+
|
|
304
|
+
const itt = createAsyncIteratorFromCallback(store.listen);
|
|
305
|
+
|
|
306
|
+
{
|
|
307
|
+
const { value } = await itt.next();
|
|
308
|
+
expect(value).toEqual({
|
|
309
|
+
loading: true,
|
|
310
|
+
promise: expect.any(Promise),
|
|
311
|
+
data: undefined,
|
|
312
|
+
error: undefined,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
{
|
|
317
|
+
const { value } = await itt.next();
|
|
318
|
+
expect(value).toEqual({
|
|
319
|
+
loading: false,
|
|
320
|
+
data: { id: 123, name: "John" },
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
idAtom.set("456");
|
|
325
|
+
|
|
326
|
+
{
|
|
327
|
+
const { value } = await itt.next();
|
|
328
|
+
expect(value).toEqual({
|
|
329
|
+
loading: true,
|
|
330
|
+
promise: expect.any(Promise),
|
|
331
|
+
data: undefined,
|
|
332
|
+
error: undefined,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
{
|
|
337
|
+
const { value } = await itt.next();
|
|
338
|
+
expect(value).toEqual({
|
|
339
|
+
loading: false,
|
|
340
|
+
data: { id: 456, name: "John" },
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("should react to query parameters", async () => {
|
|
346
|
+
const testFragment = defineFragment("test-fragment");
|
|
347
|
+
const testRoutes = [
|
|
348
|
+
defineRoute({
|
|
349
|
+
method: "GET",
|
|
350
|
+
path: "/users",
|
|
351
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string(), role: z.string() })),
|
|
352
|
+
handler: async (_ctx, { json }) => json([{ id: 1, name: "John", role: "admin" }]),
|
|
353
|
+
}),
|
|
354
|
+
] as const;
|
|
355
|
+
|
|
356
|
+
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
|
357
|
+
assert(typeof input === "string");
|
|
358
|
+
|
|
359
|
+
const url = new URL(input);
|
|
360
|
+
const role = url.searchParams.get("role");
|
|
361
|
+
const limit = url.searchParams.get("limit");
|
|
362
|
+
|
|
363
|
+
expect(role).toBeDefined();
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
headers: new Headers(),
|
|
367
|
+
ok: true,
|
|
368
|
+
json: async () => [
|
|
369
|
+
{ id: 1, name: "John", role: role! },
|
|
370
|
+
...(limit === "2" ? [{ id: 2, name: "Jane", role: role! }] : []),
|
|
371
|
+
],
|
|
372
|
+
} as Response;
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
376
|
+
const useUsers = cb.createHook("/users");
|
|
377
|
+
|
|
378
|
+
const roleAtom = atom("admin");
|
|
379
|
+
const limitAtom = atom("1");
|
|
380
|
+
const store = useUsers.store({ query: { role: roleAtom, limit: limitAtom } });
|
|
381
|
+
|
|
382
|
+
const itt = createAsyncIteratorFromCallback(store.listen);
|
|
383
|
+
|
|
384
|
+
{
|
|
385
|
+
const { value } = await itt.next();
|
|
386
|
+
expect(value).toEqual({
|
|
387
|
+
loading: true,
|
|
388
|
+
promise: expect.any(Promise),
|
|
389
|
+
data: undefined,
|
|
390
|
+
error: undefined,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
{
|
|
395
|
+
const { value } = await itt.next();
|
|
396
|
+
expect(value).toEqual({
|
|
397
|
+
loading: false,
|
|
398
|
+
data: [{ id: 1, name: "John", role: "admin" }],
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Change role
|
|
403
|
+
roleAtom.set("user");
|
|
404
|
+
|
|
405
|
+
{
|
|
406
|
+
const { value } = await itt.next();
|
|
407
|
+
expect(value).toEqual({
|
|
408
|
+
loading: true,
|
|
409
|
+
promise: expect.any(Promise),
|
|
410
|
+
data: undefined,
|
|
411
|
+
error: undefined,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
{
|
|
416
|
+
const { value } = await itt.next();
|
|
417
|
+
expect(value).toEqual({
|
|
418
|
+
loading: false,
|
|
419
|
+
data: [{ id: 1, name: "John", role: "user" }],
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Change limit
|
|
424
|
+
limitAtom.set("2");
|
|
425
|
+
|
|
426
|
+
{
|
|
427
|
+
const { value } = await itt.next();
|
|
428
|
+
expect(value).toEqual({
|
|
429
|
+
loading: true,
|
|
430
|
+
promise: expect.any(Promise),
|
|
431
|
+
data: undefined,
|
|
432
|
+
error: undefined,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
{
|
|
437
|
+
const { value } = await itt.next();
|
|
438
|
+
expect(value).toEqual({
|
|
439
|
+
loading: false,
|
|
440
|
+
data: [
|
|
441
|
+
{ id: 1, name: "John", role: "user" },
|
|
442
|
+
{ id: 2, name: "Jane", role: "user" },
|
|
443
|
+
],
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("should react to combined path and query parameters", async () => {
|
|
449
|
+
const testFragment = defineFragment("test-fragment");
|
|
450
|
+
const testRoutes = [
|
|
451
|
+
defineRoute({
|
|
452
|
+
method: "GET",
|
|
453
|
+
path: "/users/:id/posts",
|
|
454
|
+
outputSchema: z.array(z.object({ id: z.number(), title: z.string(), userId: z.number() })),
|
|
455
|
+
handler: async (_ctx, { json }) => json([{ id: 1, title: "Post", userId: 1 }]),
|
|
456
|
+
}),
|
|
457
|
+
] as const;
|
|
458
|
+
|
|
459
|
+
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
|
460
|
+
assert(typeof input === "string");
|
|
461
|
+
|
|
462
|
+
const url = new URL(input);
|
|
463
|
+
const [, userId] = url.pathname.match(/\/users\/([^/]+)\/posts/) ?? [];
|
|
464
|
+
const status = url.searchParams.get("status");
|
|
465
|
+
const limit = url.searchParams.get("limit");
|
|
466
|
+
|
|
467
|
+
expect(userId).toBeDefined();
|
|
468
|
+
expect(status).toBeDefined();
|
|
469
|
+
|
|
470
|
+
const numPosts = limit === "2" ? 2 : 1;
|
|
471
|
+
const posts = Array.from({ length: numPosts }, (_, i) => ({
|
|
472
|
+
id: i + 1,
|
|
473
|
+
title: `${status} Post ${i + 1}`,
|
|
474
|
+
userId: Number(userId),
|
|
475
|
+
}));
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
headers: new Headers(),
|
|
479
|
+
ok: true,
|
|
480
|
+
json: async () => posts,
|
|
481
|
+
} as Response;
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
485
|
+
const usePosts = cb.createHook("/users/:id/posts");
|
|
486
|
+
|
|
487
|
+
const userIdAtom = atom("123");
|
|
488
|
+
const statusAtom = atom("published");
|
|
489
|
+
const store = usePosts.store({
|
|
490
|
+
path: { id: userIdAtom },
|
|
491
|
+
query: { status: statusAtom, limit: "1" },
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const itt = createAsyncIteratorFromCallback(store.listen);
|
|
495
|
+
|
|
496
|
+
{
|
|
497
|
+
const { value } = await itt.next();
|
|
498
|
+
expect(value).toEqual({
|
|
499
|
+
loading: true,
|
|
500
|
+
promise: expect.any(Promise),
|
|
501
|
+
data: undefined,
|
|
502
|
+
error: undefined,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
{
|
|
507
|
+
const { value } = await itt.next();
|
|
508
|
+
expect(value).toEqual({
|
|
509
|
+
loading: false,
|
|
510
|
+
data: [{ id: 1, title: "published Post 1", userId: 123 }],
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Change path parameter
|
|
515
|
+
userIdAtom.set("456");
|
|
516
|
+
|
|
517
|
+
{
|
|
518
|
+
const { value } = await itt.next();
|
|
519
|
+
expect(value).toEqual({
|
|
520
|
+
loading: true,
|
|
521
|
+
promise: expect.any(Promise),
|
|
522
|
+
data: undefined,
|
|
523
|
+
error: undefined,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
{
|
|
528
|
+
const { value } = await itt.next();
|
|
529
|
+
expect(value).toEqual({
|
|
530
|
+
loading: false,
|
|
531
|
+
data: [{ id: 1, title: "published Post 1", userId: 456 }],
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Change query parameter
|
|
536
|
+
statusAtom.set("draft");
|
|
537
|
+
|
|
538
|
+
{
|
|
539
|
+
const { value } = await itt.next();
|
|
540
|
+
expect(value).toEqual({
|
|
541
|
+
loading: true,
|
|
542
|
+
promise: expect.any(Promise),
|
|
543
|
+
data: undefined,
|
|
544
|
+
error: undefined,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
{
|
|
549
|
+
const { value } = await itt.next();
|
|
550
|
+
expect(value).toEqual({
|
|
551
|
+
loading: false,
|
|
552
|
+
data: [{ id: 1, title: "draft Post 1", userId: 456 }],
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("should handle mixed atoms and non-atoms in parameters", async () => {
|
|
558
|
+
const testFragment = defineFragment("test-fragment");
|
|
559
|
+
const testRoutes = [
|
|
560
|
+
defineRoute({
|
|
561
|
+
method: "GET",
|
|
562
|
+
path: "/users/:id/posts",
|
|
563
|
+
outputSchema: z.array(
|
|
564
|
+
z.object({ id: z.number(), title: z.string(), category: z.string() }),
|
|
565
|
+
),
|
|
566
|
+
handler: async (_ctx, { json }) => json([{ id: 1, title: "Post", category: "tech" }]),
|
|
567
|
+
}),
|
|
568
|
+
] as const;
|
|
569
|
+
|
|
570
|
+
let fetchCallCount = 0;
|
|
571
|
+
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
|
572
|
+
fetchCallCount++;
|
|
573
|
+
assert(typeof input === "string");
|
|
574
|
+
|
|
575
|
+
const url = new URL(input);
|
|
576
|
+
const [, userId] = url.pathname.match(/\/users\/([^/]+)\/posts/) ?? [];
|
|
577
|
+
const category = url.searchParams.get("category");
|
|
578
|
+
const sort = url.searchParams.get("sort");
|
|
579
|
+
|
|
580
|
+
expect(userId).toBeDefined();
|
|
581
|
+
expect(category).toBeDefined();
|
|
582
|
+
expect(sort).toBe("desc");
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
headers: new Headers(),
|
|
586
|
+
ok: true,
|
|
587
|
+
json: async () => [
|
|
588
|
+
{
|
|
589
|
+
id: fetchCallCount,
|
|
590
|
+
title: `Post ${fetchCallCount}`,
|
|
591
|
+
category: category!,
|
|
592
|
+
},
|
|
593
|
+
],
|
|
594
|
+
} as Response;
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
598
|
+
const usePosts = cb.createHook("/users/:id/posts");
|
|
599
|
+
|
|
600
|
+
const userIdAtom = atom("123");
|
|
601
|
+
const categoryAtom = atom("tech");
|
|
602
|
+
// sort is a non-atom (static value)
|
|
603
|
+
const store = usePosts.store({
|
|
604
|
+
path: { id: userIdAtom },
|
|
605
|
+
query: { category: categoryAtom, sort: "desc" },
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const itt = createAsyncIteratorFromCallback(store.listen);
|
|
609
|
+
|
|
610
|
+
{
|
|
611
|
+
const { value } = await itt.next();
|
|
612
|
+
expect(value).toEqual({
|
|
613
|
+
loading: true,
|
|
614
|
+
promise: expect.any(Promise),
|
|
615
|
+
data: undefined,
|
|
616
|
+
error: undefined,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
{
|
|
621
|
+
const { value } = await itt.next();
|
|
622
|
+
expect(value).toEqual({
|
|
623
|
+
loading: false,
|
|
624
|
+
data: [{ id: 1, title: "Post 1", category: "tech" }],
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
expect(fetchCallCount).toBe(1);
|
|
629
|
+
|
|
630
|
+
// Change atom parameter - should trigger refetch
|
|
631
|
+
categoryAtom.set("science");
|
|
632
|
+
|
|
633
|
+
{
|
|
634
|
+
const { value } = await itt.next();
|
|
635
|
+
expect(value).toEqual({
|
|
636
|
+
loading: true,
|
|
637
|
+
promise: expect.any(Promise),
|
|
638
|
+
data: undefined,
|
|
639
|
+
error: undefined,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
{
|
|
644
|
+
const { value } = await itt.next();
|
|
645
|
+
expect(value).toEqual({
|
|
646
|
+
loading: false,
|
|
647
|
+
data: [{ id: 2, title: "Post 2", category: "science" }],
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
expect(fetchCallCount).toBe(2);
|
|
652
|
+
|
|
653
|
+
// Change path atom parameter - should trigger refetch
|
|
654
|
+
userIdAtom.set("456");
|
|
655
|
+
|
|
656
|
+
{
|
|
657
|
+
const { value } = await itt.next();
|
|
658
|
+
expect(value).toEqual({
|
|
659
|
+
loading: true,
|
|
660
|
+
promise: expect.any(Promise),
|
|
661
|
+
data: undefined,
|
|
662
|
+
error: undefined,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
{
|
|
667
|
+
const { value } = await itt.next();
|
|
668
|
+
expect(value).toEqual({
|
|
669
|
+
loading: false,
|
|
670
|
+
data: [{ id: 3, title: "Post 3", category: "science" }],
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
expect(fetchCallCount).toBe(3);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("should not refetch when non-atom parameters remain unchanged", async () => {
|
|
678
|
+
const testFragment = defineFragment("test-fragment");
|
|
679
|
+
const testRoutes = [
|
|
680
|
+
defineRoute({
|
|
681
|
+
method: "GET",
|
|
682
|
+
path: "/users/:id",
|
|
683
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
684
|
+
handler: async (_ctx, { json }) => json({ id: 1, name: "John" }),
|
|
685
|
+
}),
|
|
686
|
+
] as const;
|
|
687
|
+
|
|
688
|
+
let fetchCallCount = 0;
|
|
689
|
+
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
|
690
|
+
fetchCallCount++;
|
|
691
|
+
assert(typeof input === "string");
|
|
692
|
+
|
|
693
|
+
const [, id] = String(input).match(/\/users\/([^/]+)/) ?? [];
|
|
694
|
+
expect(id).toBeDefined();
|
|
695
|
+
|
|
696
|
+
return {
|
|
697
|
+
headers: new Headers(),
|
|
698
|
+
ok: true,
|
|
699
|
+
json: async () => ({ id: Number(id), name: `John${fetchCallCount}` }),
|
|
700
|
+
} as Response;
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
704
|
+
const useUser = cb.createHook("/users/:id");
|
|
705
|
+
|
|
706
|
+
const reactiveIdAtom = atom("123");
|
|
707
|
+
// Create store with mixed params: one atom, one static
|
|
708
|
+
const store = useUser.store({ path: { id: reactiveIdAtom } });
|
|
709
|
+
|
|
710
|
+
const itt = createAsyncIteratorFromCallback(store.listen);
|
|
711
|
+
|
|
712
|
+
// Initial load
|
|
713
|
+
{
|
|
714
|
+
const { value } = await itt.next();
|
|
715
|
+
expect(value).toEqual({
|
|
716
|
+
loading: true,
|
|
717
|
+
promise: expect.any(Promise),
|
|
718
|
+
data: undefined,
|
|
719
|
+
error: undefined,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
{
|
|
724
|
+
const { value } = await itt.next();
|
|
725
|
+
expect(value).toEqual({
|
|
726
|
+
loading: false,
|
|
727
|
+
data: { id: 123, name: "John1" },
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
expect(fetchCallCount).toBe(1);
|
|
732
|
+
|
|
733
|
+
// Create a second store with the same static parameter values
|
|
734
|
+
// This should not trigger additional fetches since the cache key is the same
|
|
735
|
+
const store2 = useUser.store({ path: { id: "123" } });
|
|
736
|
+
const itt2 = createAsyncIteratorFromCallback(store2.listen);
|
|
737
|
+
|
|
738
|
+
// Should get cached result immediately
|
|
739
|
+
{
|
|
740
|
+
const { value } = await itt2.next();
|
|
741
|
+
expect(value).toEqual({
|
|
742
|
+
loading: false,
|
|
743
|
+
data: { id: 123, name: "John1" },
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// No additional fetch should have occurred
|
|
748
|
+
expect(fetchCallCount).toBe(1);
|
|
749
|
+
|
|
750
|
+
// Now change the reactive atom - should trigger new fetch
|
|
751
|
+
reactiveIdAtom.set("456");
|
|
752
|
+
|
|
753
|
+
// Note: the behaviour in the next two steps is pretty weird, as first we keep the old data
|
|
754
|
+
// but then we go to loading anyway.
|
|
755
|
+
{
|
|
756
|
+
const { value } = await itt.next();
|
|
757
|
+
expect(value).toEqual({
|
|
758
|
+
loading: false,
|
|
759
|
+
data: { id: 123, name: "John1" },
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
{
|
|
764
|
+
const { value } = await itt.next();
|
|
765
|
+
expect(value).toEqual({
|
|
766
|
+
loading: true,
|
|
767
|
+
promise: expect.any(Promise),
|
|
768
|
+
data: undefined,
|
|
769
|
+
error: undefined,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
{
|
|
774
|
+
const { value } = await itt.next();
|
|
775
|
+
expect(value).toEqual({
|
|
776
|
+
loading: false,
|
|
777
|
+
data: { id: 456, name: "John2" },
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
expect(fetchCallCount).toBe(2);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test("should handle multiple reactive query parameters independently", async () => {
|
|
785
|
+
const testFragment = defineFragment("test-fragment");
|
|
786
|
+
const testRoutes = [
|
|
787
|
+
defineRoute({
|
|
788
|
+
method: "GET",
|
|
789
|
+
path: "/posts",
|
|
790
|
+
outputSchema: z.array(
|
|
791
|
+
z.object({
|
|
792
|
+
id: z.number(),
|
|
793
|
+
title: z.string(),
|
|
794
|
+
category: z.string(),
|
|
795
|
+
status: z.string(),
|
|
796
|
+
}),
|
|
797
|
+
),
|
|
798
|
+
queryParameters: ["category", "status", "author"],
|
|
799
|
+
handler: async (_ctx, { json }) =>
|
|
800
|
+
json([{ id: 1, title: "Post", category: "tech", status: "published" }]),
|
|
801
|
+
}),
|
|
802
|
+
] as const;
|
|
803
|
+
|
|
804
|
+
let fetchCallCount = 0;
|
|
805
|
+
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
|
806
|
+
fetchCallCount++;
|
|
807
|
+
assert(typeof input === "string");
|
|
808
|
+
|
|
809
|
+
const url = new URL(input);
|
|
810
|
+
const category = url.searchParams.get("category") || "tech";
|
|
811
|
+
const status = url.searchParams.get("status") || "published";
|
|
812
|
+
const author = url.searchParams.get("author") || "john";
|
|
813
|
+
|
|
814
|
+
return {
|
|
815
|
+
headers: new Headers(),
|
|
816
|
+
ok: true,
|
|
817
|
+
json: async () => [
|
|
818
|
+
{
|
|
819
|
+
id: fetchCallCount,
|
|
820
|
+
title: `${category} ${status} Post by ${author}`,
|
|
821
|
+
category,
|
|
822
|
+
status,
|
|
823
|
+
},
|
|
824
|
+
],
|
|
825
|
+
} as Response;
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
829
|
+
const usePosts = cb.createHook("/posts");
|
|
830
|
+
|
|
831
|
+
const categoryAtom = atom("tech");
|
|
832
|
+
const statusAtom = atom("published");
|
|
833
|
+
const authorAtom = atom("john");
|
|
834
|
+
|
|
835
|
+
const store = usePosts.store({
|
|
836
|
+
query: {
|
|
837
|
+
category: categoryAtom,
|
|
838
|
+
status: statusAtom,
|
|
839
|
+
author: authorAtom,
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
const itt = createAsyncIteratorFromCallback(store.listen);
|
|
844
|
+
|
|
845
|
+
// Initial load
|
|
846
|
+
{
|
|
847
|
+
const { value } = await itt.next();
|
|
848
|
+
expect(value).toEqual({
|
|
849
|
+
loading: true,
|
|
850
|
+
promise: expect.any(Promise),
|
|
851
|
+
data: undefined,
|
|
852
|
+
error: undefined,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
{
|
|
857
|
+
const { value } = await itt.next();
|
|
858
|
+
expect(value).toEqual({
|
|
859
|
+
loading: false,
|
|
860
|
+
data: [
|
|
861
|
+
{ id: 1, title: "tech published Post by john", category: "tech", status: "published" },
|
|
862
|
+
],
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
expect(fetchCallCount).toBe(1);
|
|
867
|
+
|
|
868
|
+
// Change first atom
|
|
869
|
+
categoryAtom.set("science");
|
|
870
|
+
|
|
871
|
+
{
|
|
872
|
+
const { value } = await itt.next();
|
|
873
|
+
expect(value).toEqual({
|
|
874
|
+
loading: true,
|
|
875
|
+
promise: expect.any(Promise),
|
|
876
|
+
data: undefined,
|
|
877
|
+
error: undefined,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
{
|
|
882
|
+
const { value } = await itt.next();
|
|
883
|
+
expect(value).toEqual({
|
|
884
|
+
loading: false,
|
|
885
|
+
data: [
|
|
886
|
+
{
|
|
887
|
+
id: 2,
|
|
888
|
+
title: "science published Post by john",
|
|
889
|
+
category: "science",
|
|
890
|
+
status: "published",
|
|
891
|
+
},
|
|
892
|
+
],
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
expect(fetchCallCount).toBe(2);
|
|
897
|
+
|
|
898
|
+
// Change second atom
|
|
899
|
+
statusAtom.set("draft");
|
|
900
|
+
|
|
901
|
+
{
|
|
902
|
+
const { value } = await itt.next();
|
|
903
|
+
expect(value).toEqual({
|
|
904
|
+
loading: true,
|
|
905
|
+
promise: expect.any(Promise),
|
|
906
|
+
data: undefined,
|
|
907
|
+
error: undefined,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
{
|
|
912
|
+
const { value } = await itt.next();
|
|
913
|
+
expect(value).toEqual({
|
|
914
|
+
loading: false,
|
|
915
|
+
data: [
|
|
916
|
+
{ id: 3, title: "science draft Post by john", category: "science", status: "draft" },
|
|
917
|
+
],
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
expect(fetchCallCount).toBe(3);
|
|
922
|
+
|
|
923
|
+
// Change third atom
|
|
924
|
+
authorAtom.set("jane");
|
|
925
|
+
|
|
926
|
+
{
|
|
927
|
+
const { value } = await itt.next();
|
|
928
|
+
expect(value).toEqual({
|
|
929
|
+
loading: true,
|
|
930
|
+
promise: expect.any(Promise),
|
|
931
|
+
data: undefined,
|
|
932
|
+
error: undefined,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
{
|
|
937
|
+
const { value } = await itt.next();
|
|
938
|
+
expect(value).toEqual({
|
|
939
|
+
loading: false,
|
|
940
|
+
data: [
|
|
941
|
+
{ id: 4, title: "science draft Post by jane", category: "science", status: "draft" },
|
|
942
|
+
],
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
expect(fetchCallCount).toBe(4);
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
describe("createHook - streaming", () => {
|
|
951
|
+
const clientConfig: FragnoPublicClientConfig = {
|
|
952
|
+
baseUrl: "http://localhost:3000",
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
beforeEach(() => {
|
|
956
|
+
vi.clearAllMocks();
|
|
957
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
afterEach(() => {
|
|
961
|
+
vi.restoreAllMocks();
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test("Should be able to stream data and receive updates in store (store.listen)", async () => {
|
|
965
|
+
const streamFragmentDefinition = defineFragment("stream-fragment");
|
|
966
|
+
const streamRoutes = [
|
|
967
|
+
defineRoute({
|
|
968
|
+
method: "GET",
|
|
969
|
+
path: "/users-stream",
|
|
970
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
971
|
+
handler: async () => {
|
|
972
|
+
throw new Error("Not implemented");
|
|
973
|
+
},
|
|
974
|
+
}),
|
|
975
|
+
] as const;
|
|
976
|
+
const client = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
|
|
977
|
+
const clientObj = {
|
|
978
|
+
useUsersStream: client.createHook("/users-stream"),
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
982
|
+
const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
|
|
983
|
+
return ctx.jsonStream(async (stream) => {
|
|
984
|
+
await stream.write({ id: 1, name: "John" });
|
|
985
|
+
await stream.sleep(1);
|
|
986
|
+
await stream.write({ id: 2, name: "Jane" });
|
|
987
|
+
await stream.sleep(1);
|
|
988
|
+
await stream.write({ id: 3, name: "Jim" });
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
const { useUsersStream } = clientObj;
|
|
993
|
+
const userStore = useUsersStream.store({});
|
|
994
|
+
|
|
995
|
+
const itt = createAsyncIteratorFromCallback(userStore.listen);
|
|
996
|
+
|
|
997
|
+
{
|
|
998
|
+
const { value } = await itt.next();
|
|
999
|
+
assert(value);
|
|
1000
|
+
expect(value.loading).toBe(true);
|
|
1001
|
+
expect(value.data).toBeUndefined();
|
|
1002
|
+
expect(value.error).toBeUndefined();
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
{
|
|
1006
|
+
const { value } = await itt.next();
|
|
1007
|
+
assert(value);
|
|
1008
|
+
expect(value.loading).toBe(false);
|
|
1009
|
+
expect(value.data).toEqual([{ id: 1, name: "John" }]);
|
|
1010
|
+
expect(value.error).toBeUndefined();
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
{
|
|
1014
|
+
const { value } = await itt.next();
|
|
1015
|
+
assert(value);
|
|
1016
|
+
expect(value.loading).toBe(false);
|
|
1017
|
+
expect(value.data).toEqual([
|
|
1018
|
+
{ id: 1, name: "John" },
|
|
1019
|
+
{ id: 2, name: "Jane" },
|
|
1020
|
+
]);
|
|
1021
|
+
expect(value.error).toBeUndefined();
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
{
|
|
1025
|
+
const { value } = await itt.next();
|
|
1026
|
+
assert(value);
|
|
1027
|
+
expect(value.loading).toBe(false);
|
|
1028
|
+
expect(value.data).toEqual([
|
|
1029
|
+
{ id: 1, name: "John" },
|
|
1030
|
+
{ id: 2, name: "Jane" },
|
|
1031
|
+
{ id: 3, name: "Jim" },
|
|
1032
|
+
]);
|
|
1033
|
+
expect(value.error).toBeUndefined();
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
test("throws FragnoClientUnknownApiError when the stream is not valid JSON", async () => {
|
|
1038
|
+
const streamErrorFragmentDefinition = defineFragment("stream-error-fragment");
|
|
1039
|
+
const streamErrorRoutes = [
|
|
1040
|
+
defineRoute({
|
|
1041
|
+
method: "GET",
|
|
1042
|
+
path: "/users-stream-error",
|
|
1043
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
1044
|
+
handler: async () => {
|
|
1045
|
+
throw new Error("Not implemented");
|
|
1046
|
+
},
|
|
1047
|
+
}),
|
|
1048
|
+
] as const;
|
|
1049
|
+
|
|
1050
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
1051
|
+
const ctx = new RequestOutputContext(streamErrorRoutes[0].outputSchema);
|
|
1052
|
+
return ctx.jsonStream(async (stream) => {
|
|
1053
|
+
await stream.writeRaw("this is not json lol");
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
const client = createClientBuilder(
|
|
1058
|
+
streamErrorFragmentDefinition,
|
|
1059
|
+
clientConfig,
|
|
1060
|
+
streamErrorRoutes,
|
|
1061
|
+
);
|
|
1062
|
+
const clientObj = {
|
|
1063
|
+
useUsersStreamError: client.createHook("/users-stream-error"),
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
const { useUsersStreamError } = clientObj;
|
|
1067
|
+
const userStore = useUsersStreamError.store({});
|
|
1068
|
+
|
|
1069
|
+
const { error } = await waitForAsyncIterator(
|
|
1070
|
+
createAsyncIteratorFromCallback(userStore.listen),
|
|
1071
|
+
(value) => value.loading === false && value.error !== undefined,
|
|
1072
|
+
);
|
|
1073
|
+
|
|
1074
|
+
expect(error).toBeInstanceOf(FragnoClientUnknownApiError);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
test("throws FragnoClientUnknownApiError when the stream is new lines only", async () => {
|
|
1078
|
+
const streamErrorFragmentDefinition = defineFragment("stream-error-fragment");
|
|
1079
|
+
const streamErrorRoutes = [
|
|
1080
|
+
defineRoute({
|
|
1081
|
+
method: "GET",
|
|
1082
|
+
path: "/users-stream-error",
|
|
1083
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
1084
|
+
handler: async () => {
|
|
1085
|
+
throw new Error("Not implemented");
|
|
1086
|
+
},
|
|
1087
|
+
}),
|
|
1088
|
+
] as const;
|
|
1089
|
+
|
|
1090
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
1091
|
+
const ctx = new RequestOutputContext(streamErrorRoutes[0].outputSchema);
|
|
1092
|
+
return ctx.jsonStream(async (stream) => {
|
|
1093
|
+
await stream.writeRaw("\n\n");
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
const client = createClientBuilder(
|
|
1098
|
+
streamErrorFragmentDefinition,
|
|
1099
|
+
clientConfig,
|
|
1100
|
+
streamErrorRoutes,
|
|
1101
|
+
);
|
|
1102
|
+
const clientObj = {
|
|
1103
|
+
useUsersStreamError: client.createHook("/users-stream-error"),
|
|
1104
|
+
};
|
|
1105
|
+
|
|
1106
|
+
const { useUsersStreamError } = clientObj;
|
|
1107
|
+
const userStore = useUsersStreamError.store({});
|
|
1108
|
+
|
|
1109
|
+
const { error } = await waitForAsyncIterator(
|
|
1110
|
+
createAsyncIteratorFromCallback(userStore.listen),
|
|
1111
|
+
(value) => value.loading === false && value.error !== undefined,
|
|
1112
|
+
);
|
|
1113
|
+
|
|
1114
|
+
expect(error).toBeInstanceOf(FragnoClientUnknownApiError);
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
test("throws FragnoClientUnknownApiError with cause SyntaxError when the stream is not valid JSON (multiple empty lines)", async () => {
|
|
1118
|
+
const streamErrorFragmentDefinition = defineFragment("stream-error-fragment");
|
|
1119
|
+
const streamErrorRoutes = [
|
|
1120
|
+
defineRoute({
|
|
1121
|
+
method: "GET",
|
|
1122
|
+
path: "/users-stream-error",
|
|
1123
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
1124
|
+
handler: async () => {
|
|
1125
|
+
throw new Error("Not implemented");
|
|
1126
|
+
},
|
|
1127
|
+
}),
|
|
1128
|
+
] as const;
|
|
1129
|
+
|
|
1130
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
1131
|
+
const ctx = new RequestOutputContext(streamErrorRoutes[0].outputSchema);
|
|
1132
|
+
return ctx.jsonStream(async (stream) => {
|
|
1133
|
+
await stream.writeRaw("this is not json lol\n\n");
|
|
1134
|
+
});
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
const client = createClientBuilder(
|
|
1138
|
+
streamErrorFragmentDefinition,
|
|
1139
|
+
clientConfig,
|
|
1140
|
+
streamErrorRoutes,
|
|
1141
|
+
);
|
|
1142
|
+
const clientObj = {
|
|
1143
|
+
useUsersStreamError: client.createHook("/users-stream-error"),
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
const { useUsersStreamError } = clientObj;
|
|
1147
|
+
const userStore = useUsersStreamError.store({});
|
|
1148
|
+
|
|
1149
|
+
const { error } = await waitForAsyncIterator(
|
|
1150
|
+
createAsyncIteratorFromCallback(userStore.listen),
|
|
1151
|
+
(value) => value.loading === false && value.error !== undefined,
|
|
1152
|
+
);
|
|
1153
|
+
|
|
1154
|
+
expect(error).toBeInstanceOf(FragnoClientUnknownApiError);
|
|
1155
|
+
assert(error!.cause instanceof SyntaxError); // JSON parse failure gives a SyntaxError
|
|
1156
|
+
expect(error!.cause.message).toMatch(/Unexpected (token|identifier)/);
|
|
1157
|
+
});
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
describe("createMutator", () => {
|
|
1161
|
+
const clientConfig: FragnoPublicClientConfig = {
|
|
1162
|
+
baseUrl: "http://localhost:3000",
|
|
1163
|
+
};
|
|
1164
|
+
|
|
1165
|
+
beforeEach(() => {
|
|
1166
|
+
vi.clearAllMocks();
|
|
1167
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
afterEach(() => {
|
|
1171
|
+
vi.restoreAllMocks();
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
test("body is optional when no inputSchema in route", async () => {
|
|
1175
|
+
const testFragment = defineFragment("test-fragment");
|
|
1176
|
+
const testRoutes = [
|
|
1177
|
+
defineRoute({
|
|
1178
|
+
method: "DELETE",
|
|
1179
|
+
path: "/users/:id",
|
|
1180
|
+
handler: async (_ctx, { empty }) => empty(),
|
|
1181
|
+
}),
|
|
1182
|
+
] as const;
|
|
1183
|
+
|
|
1184
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
1185
|
+
return new Response(null, { status: 201 });
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
1189
|
+
const deleteUser = cb.createMutator("DELETE", "/users/:id");
|
|
1190
|
+
|
|
1191
|
+
const result = await deleteUser.mutateQuery({
|
|
1192
|
+
path: { id: "123" },
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
expect(result).toBeUndefined();
|
|
1196
|
+
});
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
describe("createMutator - streaming", () => {
|
|
1200
|
+
const clientConfig: FragnoPublicClientConfig = {
|
|
1201
|
+
baseUrl: "http://localhost:3000",
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
beforeEach(() => {
|
|
1205
|
+
vi.clearAllMocks();
|
|
1206
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
afterEach(() => {
|
|
1210
|
+
vi.restoreAllMocks();
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
test("should support streaming responses for mutations", async () => {
|
|
1214
|
+
const mutationStreamFragmentDefinition = defineFragment("mutation-stream-fragment");
|
|
1215
|
+
const mutationStreamRoutes = [
|
|
1216
|
+
defineRoute({
|
|
1217
|
+
method: "POST",
|
|
1218
|
+
path: "/process-items",
|
|
1219
|
+
inputSchema: z.object({ items: z.array(z.string()) }),
|
|
1220
|
+
outputSchema: z.array(z.object({ item: z.string(), status: z.string() })),
|
|
1221
|
+
handler: async ({ input }, { jsonStream }) => {
|
|
1222
|
+
const data = await input.valid();
|
|
1223
|
+
const { items } = data!;
|
|
1224
|
+
return jsonStream(async (stream) => {
|
|
1225
|
+
for (const item of items) {
|
|
1226
|
+
await stream.write({ item, status: "processed" });
|
|
1227
|
+
await stream.sleep(1);
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
},
|
|
1231
|
+
}),
|
|
1232
|
+
] as const;
|
|
1233
|
+
|
|
1234
|
+
// Mock the fetch response for streaming with proper ReadableStream
|
|
1235
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
1236
|
+
const encoder = new TextEncoder();
|
|
1237
|
+
|
|
1238
|
+
const stream = new ReadableStream({
|
|
1239
|
+
start(controller) {
|
|
1240
|
+
// Enqueue all chunks at once
|
|
1241
|
+
controller.enqueue(encoder.encode('{"item":"item1","status":"processed"}\n'));
|
|
1242
|
+
controller.enqueue(encoder.encode('{"item":"item2","status":"processed"}\n'));
|
|
1243
|
+
controller.enqueue(encoder.encode('{"item":"item3","status":"processed"}\n'));
|
|
1244
|
+
controller.close();
|
|
1245
|
+
},
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
// Create a proper Response object with the stream
|
|
1249
|
+
const response = new Response(stream, {
|
|
1250
|
+
status: 200,
|
|
1251
|
+
headers: new Headers({
|
|
1252
|
+
"transfer-encoding": "chunked",
|
|
1253
|
+
"content-type": "application/x-ndjson",
|
|
1254
|
+
}),
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
return response;
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
const client = createClientBuilder(
|
|
1261
|
+
mutationStreamFragmentDefinition,
|
|
1262
|
+
clientConfig,
|
|
1263
|
+
mutationStreamRoutes,
|
|
1264
|
+
);
|
|
1265
|
+
const mutator = client.createMutator("POST", "/process-items");
|
|
1266
|
+
|
|
1267
|
+
const result = await mutator.mutateQuery({
|
|
1268
|
+
body: { items: ["item1", "item2", "item3"] },
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
// Streaming mutations return all items
|
|
1272
|
+
expect(result).toEqual([
|
|
1273
|
+
{ item: "item1", status: "processed" },
|
|
1274
|
+
{ item: "item2", status: "processed" },
|
|
1275
|
+
{ item: "item3", status: "processed" },
|
|
1276
|
+
]);
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
test("Should be able to mutate data and receive updates in store (store.subscribe)", async () => {
|
|
1280
|
+
const streamFragmentDefinition = defineFragment("stream-fragment");
|
|
1281
|
+
const streamRoutes = [
|
|
1282
|
+
defineRoute({
|
|
1283
|
+
method: "POST",
|
|
1284
|
+
path: "/users-stream",
|
|
1285
|
+
inputSchema: z.object({ items: z.array(z.string()) }),
|
|
1286
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
1287
|
+
handler: async () => {
|
|
1288
|
+
throw new Error("Not implemented");
|
|
1289
|
+
},
|
|
1290
|
+
}),
|
|
1291
|
+
] as const;
|
|
1292
|
+
const client = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
|
|
1293
|
+
const useUsersMutateStream = client.createMutator("POST", "/users-stream");
|
|
1294
|
+
|
|
1295
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
1296
|
+
const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
|
|
1297
|
+
return ctx.jsonStream(async (stream) => {
|
|
1298
|
+
await stream.write({ id: 1, name: "John" });
|
|
1299
|
+
await stream.sleep(0);
|
|
1300
|
+
await stream.write({ id: 2, name: "Jane" });
|
|
1301
|
+
await stream.sleep(0);
|
|
1302
|
+
await stream.write({ id: 3, name: "Jim" });
|
|
1303
|
+
});
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
const { mutatorStore } = useUsersMutateStream;
|
|
1307
|
+
const itt = createAsyncIteratorFromCallback(mutatorStore.subscribe);
|
|
1308
|
+
|
|
1309
|
+
{
|
|
1310
|
+
const { value } = await itt.next();
|
|
1311
|
+
expect(value).toEqual({
|
|
1312
|
+
loading: false,
|
|
1313
|
+
data: undefined,
|
|
1314
|
+
error: undefined,
|
|
1315
|
+
mutate: expect.any(Function),
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const firstItem = await mutatorStore.mutate({ body: { items: ["item1", "item2", "item3"] } });
|
|
1320
|
+
expect(firstItem).toEqual([{ id: 1, name: "John" }]);
|
|
1321
|
+
|
|
1322
|
+
{
|
|
1323
|
+
const { value } = await itt.next();
|
|
1324
|
+
expect(value).toEqual({
|
|
1325
|
+
loading: true,
|
|
1326
|
+
data: undefined,
|
|
1327
|
+
error: undefined,
|
|
1328
|
+
mutate: expect.any(Function),
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
{
|
|
1333
|
+
const { value } = await itt.next();
|
|
1334
|
+
assert(value);
|
|
1335
|
+
expect(value).toEqual({
|
|
1336
|
+
loading: true,
|
|
1337
|
+
data: [{ id: 1, name: "John" }],
|
|
1338
|
+
error: undefined,
|
|
1339
|
+
mutate: expect.any(Function),
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
{
|
|
1344
|
+
const { value } = await itt.next();
|
|
1345
|
+
assert(value);
|
|
1346
|
+
expect(value).toEqual({
|
|
1347
|
+
loading: false,
|
|
1348
|
+
data: [{ id: 1, name: "John" }],
|
|
1349
|
+
error: undefined,
|
|
1350
|
+
mutate: expect.any(Function),
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
{
|
|
1355
|
+
const { value } = await itt.next();
|
|
1356
|
+
assert(value);
|
|
1357
|
+
expect(value).toEqual({
|
|
1358
|
+
loading: false,
|
|
1359
|
+
data: [
|
|
1360
|
+
{ id: 1, name: "John" },
|
|
1361
|
+
{ id: 2, name: "Jane" },
|
|
1362
|
+
],
|
|
1363
|
+
error: undefined,
|
|
1364
|
+
mutate: expect.any(Function),
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
{
|
|
1369
|
+
const { value } = await itt.next();
|
|
1370
|
+
assert(value);
|
|
1371
|
+
expect(value).toEqual({
|
|
1372
|
+
loading: false,
|
|
1373
|
+
data: [
|
|
1374
|
+
{ id: 1, name: "John" },
|
|
1375
|
+
{ id: 2, name: "Jane" },
|
|
1376
|
+
{ id: 3, name: "Jim" },
|
|
1377
|
+
],
|
|
1378
|
+
error: undefined,
|
|
1379
|
+
mutate: expect.any(Function),
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
test("Should be able to mutate data and receive updates in store (store.listen)", async () => {
|
|
1385
|
+
const streamFragmentDefinition = defineFragment("stream-fragment");
|
|
1386
|
+
const streamRoutes = [
|
|
1387
|
+
defineRoute({
|
|
1388
|
+
method: "POST",
|
|
1389
|
+
path: "/users-stream",
|
|
1390
|
+
inputSchema: z.object({ items: z.array(z.string()) }),
|
|
1391
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
1392
|
+
handler: async () => {
|
|
1393
|
+
throw new Error("Not implemented");
|
|
1394
|
+
},
|
|
1395
|
+
}),
|
|
1396
|
+
] as const;
|
|
1397
|
+
const client = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
|
|
1398
|
+
const useUsersMutateStream = client.createMutator("POST", "/users-stream");
|
|
1399
|
+
|
|
1400
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
1401
|
+
const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
|
|
1402
|
+
return ctx.jsonStream(async (stream) => {
|
|
1403
|
+
await stream.write({ id: 1, name: "John" });
|
|
1404
|
+
await stream.sleep(0);
|
|
1405
|
+
await stream.write({ id: 2, name: "Jane" });
|
|
1406
|
+
await stream.sleep(0);
|
|
1407
|
+
await stream.write({ id: 3, name: "Jim" });
|
|
1408
|
+
});
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
const { mutatorStore } = useUsersMutateStream;
|
|
1412
|
+
const itt = createAsyncIteratorFromCallback(mutatorStore.listen);
|
|
1413
|
+
|
|
1414
|
+
const firstItem = await mutatorStore.mutate({ body: { items: ["item1", "item2", "item3"] } });
|
|
1415
|
+
expect(firstItem).toEqual([{ id: 1, name: "John" }]);
|
|
1416
|
+
|
|
1417
|
+
{
|
|
1418
|
+
const { value } = await itt.next();
|
|
1419
|
+
assert(value);
|
|
1420
|
+
expect(value).toEqual({
|
|
1421
|
+
loading: true,
|
|
1422
|
+
data: undefined,
|
|
1423
|
+
error: undefined,
|
|
1424
|
+
mutate: expect.any(Function),
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
{
|
|
1429
|
+
const { value } = await itt.next();
|
|
1430
|
+
assert(value);
|
|
1431
|
+
expect(value).toEqual({
|
|
1432
|
+
loading: true,
|
|
1433
|
+
data: [{ id: 1, name: "John" }],
|
|
1434
|
+
error: undefined,
|
|
1435
|
+
mutate: expect.any(Function),
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
{
|
|
1440
|
+
const { value } = await itt.next();
|
|
1441
|
+
assert(value);
|
|
1442
|
+
expect(value).toEqual({
|
|
1443
|
+
loading: false,
|
|
1444
|
+
data: [{ id: 1, name: "John" }],
|
|
1445
|
+
error: undefined,
|
|
1446
|
+
mutate: expect.any(Function),
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
{
|
|
1451
|
+
const { value } = await itt.next();
|
|
1452
|
+
assert(value);
|
|
1453
|
+
expect(value).toEqual({
|
|
1454
|
+
loading: false,
|
|
1455
|
+
data: [
|
|
1456
|
+
{ id: 1, name: "John" },
|
|
1457
|
+
{ id: 2, name: "Jane" },
|
|
1458
|
+
],
|
|
1459
|
+
error: undefined,
|
|
1460
|
+
mutate: expect.any(Function),
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
{
|
|
1465
|
+
const { value } = await itt.next();
|
|
1466
|
+
assert(value);
|
|
1467
|
+
expect(value).toEqual({
|
|
1468
|
+
loading: false,
|
|
1469
|
+
data: [
|
|
1470
|
+
{ id: 1, name: "John" },
|
|
1471
|
+
{ id: 2, name: "Jane" },
|
|
1472
|
+
{ id: 3, name: "Jim" },
|
|
1473
|
+
],
|
|
1474
|
+
error: undefined,
|
|
1475
|
+
mutate: expect.any(Function),
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
describe("computed", () => {
|
|
1482
|
+
const clientConfig: FragnoPublicClientConfig = {
|
|
1483
|
+
baseUrl: "http://localhost:3000",
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
beforeEach(() => {
|
|
1487
|
+
vi.clearAllMocks();
|
|
1488
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
afterEach(() => {
|
|
1492
|
+
vi.restoreAllMocks();
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
test("Derived from streaming route", async () => {
|
|
1496
|
+
const streamFragmentDefinition = defineFragment("stream-fragment");
|
|
1497
|
+
const streamRoutes = [
|
|
1498
|
+
defineRoute({
|
|
1499
|
+
method: "GET",
|
|
1500
|
+
path: "/users-stream",
|
|
1501
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
1502
|
+
handler: async () => {
|
|
1503
|
+
throw new Error("Not implemented");
|
|
1504
|
+
},
|
|
1505
|
+
}),
|
|
1506
|
+
] as const;
|
|
1507
|
+
const client = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
|
|
1508
|
+
const useUsersStream = client.createHook("/users-stream");
|
|
1509
|
+
|
|
1510
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
1511
|
+
const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
|
|
1512
|
+
return ctx.jsonStream(async (stream) => {
|
|
1513
|
+
await stream.write({ id: 1, name: "John" });
|
|
1514
|
+
await stream.sleep(1);
|
|
1515
|
+
await stream.write({ id: 2, name: "Jane" });
|
|
1516
|
+
await stream.sleep(1);
|
|
1517
|
+
await stream.write({ id: 3, name: "Jim" });
|
|
1518
|
+
});
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
const userStore = useUsersStream.store({});
|
|
1522
|
+
|
|
1523
|
+
const names = computed(userStore, ({ data }) => data?.map((user) => user.name).join(", "));
|
|
1524
|
+
const itt = createAsyncIteratorFromCallback(names.listen);
|
|
1525
|
+
|
|
1526
|
+
{
|
|
1527
|
+
const { value } = await itt.next();
|
|
1528
|
+
expect(value).toBe("John");
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
{
|
|
1532
|
+
const { value } = await itt.next();
|
|
1533
|
+
expect(value).toBe("John, Jane");
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
{
|
|
1537
|
+
const { value } = await itt.next();
|
|
1538
|
+
expect(value).toBe("John, Jane, Jim");
|
|
1539
|
+
}
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
test("Derived from streaming route with atom usage", async () => {
|
|
1543
|
+
const streamFragmentDefinition = defineFragment("stream-fragment");
|
|
1544
|
+
const streamRoutes = [
|
|
1545
|
+
defineRoute({
|
|
1546
|
+
method: "GET",
|
|
1547
|
+
path: "/users-stream",
|
|
1548
|
+
outputSchema: z.array(
|
|
1549
|
+
z.object({ num: z.number(), status: z.enum(["continue", "half-way", "done"]) }),
|
|
1550
|
+
),
|
|
1551
|
+
handler: async () => {
|
|
1552
|
+
throw new Error("Not implemented");
|
|
1553
|
+
},
|
|
1554
|
+
}),
|
|
1555
|
+
] as const;
|
|
1556
|
+
const client = createClientBuilder(streamFragmentDefinition, clientConfig, streamRoutes);
|
|
1557
|
+
const useUsersStream = client.createHook("/users-stream");
|
|
1558
|
+
|
|
1559
|
+
vi.mocked(global.fetch).mockImplementation(async () => {
|
|
1560
|
+
const ctx = new RequestOutputContext(streamRoutes[0].outputSchema);
|
|
1561
|
+
return ctx.jsonStream(async (stream) => {
|
|
1562
|
+
await stream.write({ num: 8, status: "continue" });
|
|
1563
|
+
await stream.sleep(1);
|
|
1564
|
+
await stream.write({ num: 17, status: "half-way" });
|
|
1565
|
+
await stream.sleep(1);
|
|
1566
|
+
await stream.write({ num: 3, status: "done" });
|
|
1567
|
+
});
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
const userStore = useUsersStream.store({});
|
|
1571
|
+
|
|
1572
|
+
const product = computed(
|
|
1573
|
+
userStore,
|
|
1574
|
+
({ data }) => data?.map((user) => user.num).reduce((acc, num) => acc * num, 1) ?? 1,
|
|
1575
|
+
);
|
|
1576
|
+
const highestNum = atom(0);
|
|
1577
|
+
effect([userStore], ({ data }) => {
|
|
1578
|
+
if (!Array.isArray(data) || data.length === 0) {
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const latest = data[data.length - 1];
|
|
1583
|
+
highestNum.set(Math.max(highestNum.get(), latest.num));
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
const productItt = createAsyncIteratorFromCallback(product.listen);
|
|
1587
|
+
const highestNumItt = createAsyncIteratorFromCallback(highestNum.listen);
|
|
1588
|
+
|
|
1589
|
+
{
|
|
1590
|
+
const { value } = await productItt.next();
|
|
1591
|
+
expect(value).toBe(8);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
{
|
|
1595
|
+
const { value } = await productItt.next();
|
|
1596
|
+
expect(value).toBe(136);
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
{
|
|
1600
|
+
const { value } = await productItt.next();
|
|
1601
|
+
expect(value).toBe(408);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
{
|
|
1605
|
+
const { value } = await highestNumItt.next();
|
|
1606
|
+
expect(value).toBe(8);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
{
|
|
1610
|
+
const { value } = await highestNumItt.next();
|
|
1611
|
+
expect(value).toBe(17);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// No last value on the highestNum iterator as it will stay '17' and thus no store update is
|
|
1615
|
+
// pushed.
|
|
1616
|
+
});
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
describe("type guards", () => {
|
|
1620
|
+
const testFragment = defineFragment("test-fragment");
|
|
1621
|
+
const testRoutes = [
|
|
1622
|
+
defineRoute({
|
|
1623
|
+
method: "GET",
|
|
1624
|
+
path: "/users",
|
|
1625
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
1626
|
+
handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
|
|
1627
|
+
}),
|
|
1628
|
+
defineRoute({
|
|
1629
|
+
method: "POST",
|
|
1630
|
+
path: "/users",
|
|
1631
|
+
inputSchema: z.object({ name: z.string() }),
|
|
1632
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
1633
|
+
handler: async (_ctx, { json }) => json({ id: 2, name: "Jane" }),
|
|
1634
|
+
}),
|
|
1635
|
+
] as const;
|
|
1636
|
+
|
|
1637
|
+
test("isGetHook should correctly identify GET hooks using symbols", () => {
|
|
1638
|
+
const client = createClientBuilder(testFragment, {}, testRoutes);
|
|
1639
|
+
const getHook = client.createHook("/users");
|
|
1640
|
+
const mutatorHook = client.createMutator("POST", "/users");
|
|
1641
|
+
|
|
1642
|
+
expect(isGetHook(getHook)).toBe(true);
|
|
1643
|
+
// Test that it correctly identifies non-GET hooks
|
|
1644
|
+
expect(isGetHook(mutatorHook)).toBe(false);
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
test("isMutatorHook should correctly identify mutator hooks using symbols", () => {
|
|
1648
|
+
const client = createClientBuilder(testFragment, {}, testRoutes);
|
|
1649
|
+
const getHook = client.createHook("/users");
|
|
1650
|
+
const mutatorHook = client.createMutator("POST", "/users");
|
|
1651
|
+
|
|
1652
|
+
expect(isMutatorHook(mutatorHook)).toBe(true);
|
|
1653
|
+
// Test that it correctly identifies non-mutator hooks
|
|
1654
|
+
expect(isMutatorHook(getHook)).toBe(false);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
test("type guards should work correctly with symbol checking", () => {
|
|
1658
|
+
const client = createClientBuilder(testFragment, {}, testRoutes);
|
|
1659
|
+
const getHook = client.createHook("/users");
|
|
1660
|
+
const mutatorHook = client.createMutator("POST", "/users");
|
|
1661
|
+
|
|
1662
|
+
// Test that the hooks have the expected methods/properties
|
|
1663
|
+
expect("store" in getHook).toBe(true);
|
|
1664
|
+
expect("query" in getHook).toBe(true);
|
|
1665
|
+
expect("mutateQuery" in mutatorHook).toBe(true);
|
|
1666
|
+
expect("mutatorStore" in mutatorHook).toBe(true);
|
|
1667
|
+
|
|
1668
|
+
// The type guards should work based on symbols
|
|
1669
|
+
expect(isGetHook(getHook)).toBe(true);
|
|
1670
|
+
expect(isMutatorHook(mutatorHook)).toBe(true);
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
test("type guards should work correctly with object checking", () => {
|
|
1674
|
+
expect(isGetHook(1)).toBe(false);
|
|
1675
|
+
expect(isMutatorHook("absence of hook")).toBe(false);
|
|
1676
|
+
expect(isGetHook({})).toBe(false);
|
|
1677
|
+
expect(isMutatorHook({})).toBe(false);
|
|
1678
|
+
expect(isGetHook(null)).toBe(false);
|
|
1679
|
+
expect(isMutatorHook(null)).toBe(false);
|
|
1680
|
+
expect(isGetHook(undefined)).toBe(false);
|
|
1681
|
+
expect(isMutatorHook(undefined)).toBe(false);
|
|
1682
|
+
expect(isGetHook(true)).toBe(false);
|
|
1683
|
+
expect(isMutatorHook(true)).toBe(false);
|
|
1684
|
+
expect(isGetHook(false)).toBe(false);
|
|
1685
|
+
expect(isMutatorHook(false)).toBe(false);
|
|
1686
|
+
expect(isGetHook(Symbol("fragno-get-hook"))).toBe(false);
|
|
1687
|
+
expect(isMutatorHook(Symbol("fragno-mutator-hook"))).toBe(false);
|
|
1688
|
+
expect(isGetHook(Symbol("fragno-get-hook"))).toBe(false);
|
|
1689
|
+
});
|
|
1690
|
+
});
|