@zenstackhq/client-helpers 3.1.0 → 3.2.0
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/package.json +12 -9
- package/.turbo/turbo-build.log +0 -24
- package/eslint.config.js +0 -4
- package/src/constants.ts +0 -4
- package/src/fetch.ts +0 -107
- package/src/index.ts +0 -9
- package/src/invalidation.ts +0 -89
- package/src/logging.ts +0 -15
- package/src/mutator.ts +0 -449
- package/src/nested-read-visitor.ts +0 -68
- package/src/nested-write-visitor.ts +0 -359
- package/src/optimistic.ts +0 -139
- package/src/query-analysis.ts +0 -111
- package/src/types.ts +0 -82
- package/test/fetch.test.ts +0 -423
- package/test/invalidation.test.ts +0 -602
- package/test/mutator.test.ts +0 -1533
- package/test/nested-read-visitor.test.ts +0 -949
- package/test/nested-write-visitor.test.ts +0 -1244
- package/test/optimistic.test.ts +0 -743
- package/test/query-analysis.test.ts +0 -1399
- package/test/test-helpers.ts +0 -37
- package/tsconfig.json +0 -4
- package/tsconfig.test.json +0 -7
- package/tsup.config.ts +0 -14
- package/vitest.config.ts +0 -4
package/test/fetch.test.ts
DELETED
|
@@ -1,423 +0,0 @@
|
|
|
1
|
-
import Decimal from 'decimal.js';
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import { deserialize, fetcher, makeUrl, marshal, serialize, unmarshal } from '../src/fetch';
|
|
4
|
-
import type { QueryError } from '../src/types';
|
|
5
|
-
|
|
6
|
-
describe('Fetcher and serialization tests', () => {
|
|
7
|
-
describe('serialize and deserialize', () => {
|
|
8
|
-
it('serializes plain objects', () => {
|
|
9
|
-
const input = { name: 'John', age: 30 };
|
|
10
|
-
const result = serialize(input);
|
|
11
|
-
expect(result.data).toEqual(input);
|
|
12
|
-
expect(result.meta).toBeUndefined();
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('serializes and deserializes Decimal values', () => {
|
|
16
|
-
const input = { price: new Decimal('123.45') };
|
|
17
|
-
const { data, meta } = serialize(input);
|
|
18
|
-
const result = deserialize(data, meta);
|
|
19
|
-
expect(result).toEqual(input);
|
|
20
|
-
expect((result as any).price).toBeInstanceOf(Decimal);
|
|
21
|
-
expect((result as any).price.toString()).toBe('123.45');
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('serializes and deserializes Date values', () => {
|
|
25
|
-
const input = { createdAt: new Date('2023-01-15T12:00:00Z') };
|
|
26
|
-
const { data, meta } = serialize(input);
|
|
27
|
-
const result = deserialize(data, meta);
|
|
28
|
-
expect(result).toEqual(input);
|
|
29
|
-
expect((result as any).createdAt).toBeInstanceOf(Date);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('serializes complex nested objects with special types', () => {
|
|
33
|
-
const input = {
|
|
34
|
-
user: {
|
|
35
|
-
name: 'Alice',
|
|
36
|
-
balance: new Decimal('999.99'),
|
|
37
|
-
createdAt: new Date('2023-01-01T00:00:00Z'),
|
|
38
|
-
},
|
|
39
|
-
items: [{ price: new Decimal('10.50') }, { price: new Decimal('20.75') }],
|
|
40
|
-
};
|
|
41
|
-
const { data, meta } = serialize(input);
|
|
42
|
-
const result = deserialize(data, meta);
|
|
43
|
-
|
|
44
|
-
expect((result as any).user.balance).toBeInstanceOf(Decimal);
|
|
45
|
-
expect((result as any).user.balance.toString()).toBe('999.99');
|
|
46
|
-
expect((result as any).user.createdAt).toBeInstanceOf(Date);
|
|
47
|
-
expect((result as any).items[0].price).toBeInstanceOf(Decimal);
|
|
48
|
-
expect((result as any).items[1].price.toString()).toBe('20.75');
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('handles undefined and null values', () => {
|
|
52
|
-
const input = { foo: undefined, bar: null };
|
|
53
|
-
const { data, meta } = serialize(input);
|
|
54
|
-
const result = deserialize(data, meta);
|
|
55
|
-
expect(result).toEqual({ bar: null });
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('handles arrays with mixed types', () => {
|
|
59
|
-
const input = [new Decimal('1.23'), 'string', 42, new Date('2023-01-01T00:00:00Z')];
|
|
60
|
-
const { data, meta } = serialize(input);
|
|
61
|
-
const result = deserialize(data, meta) as any[];
|
|
62
|
-
|
|
63
|
-
expect(result[0]).toBeInstanceOf(Decimal);
|
|
64
|
-
expect(result[1]).toBe('string');
|
|
65
|
-
expect(result[2]).toBe(42);
|
|
66
|
-
expect(result[3]).toBeInstanceOf(Date);
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
describe('marshal and unmarshal', () => {
|
|
71
|
-
it('marshals and unmarshals plain objects', () => {
|
|
72
|
-
const input = { name: 'John', age: 30 };
|
|
73
|
-
const marshaled = marshal(input);
|
|
74
|
-
const result = unmarshal(marshaled);
|
|
75
|
-
expect(result).toEqual(input);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('marshals objects without metadata when not needed', () => {
|
|
79
|
-
const input = { name: 'John', age: 30 };
|
|
80
|
-
const marshaled = marshal(input);
|
|
81
|
-
const parsed = JSON.parse(marshaled);
|
|
82
|
-
expect(parsed.meta).toBeUndefined();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('marshals and unmarshals objects with Decimal values', () => {
|
|
86
|
-
const input = { price: new Decimal('123.45') };
|
|
87
|
-
const marshaled = marshal(input);
|
|
88
|
-
const parsed = JSON.parse(marshaled);
|
|
89
|
-
|
|
90
|
-
// marshal spreads the data into the root object with meta
|
|
91
|
-
expect(parsed.price).toBeDefined();
|
|
92
|
-
expect(parsed.meta).toBeDefined();
|
|
93
|
-
expect(parsed.meta.serialization).toBeDefined();
|
|
94
|
-
|
|
95
|
-
// unmarshal doesn't automatically deserialize this format
|
|
96
|
-
// It only deserializes objects with explicit 'data' and 'meta.serialization' fields
|
|
97
|
-
const result = unmarshal(marshaled);
|
|
98
|
-
expect(result).toHaveProperty('price');
|
|
99
|
-
expect(result).toHaveProperty('meta');
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('includes metadata when serialization is needed', () => {
|
|
103
|
-
const input = { date: new Date('2023-01-01T00:00:00Z') };
|
|
104
|
-
const marshaled = marshal(input);
|
|
105
|
-
const parsed = JSON.parse(marshaled);
|
|
106
|
-
expect(parsed.meta).toBeDefined();
|
|
107
|
-
expect(parsed.meta.serialization).toBeDefined();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('unmarshals response format with data and meta', () => {
|
|
111
|
-
// Create properly serialized data using serialize/deserialize
|
|
112
|
-
const originalValue = { value: new Decimal('100.00') };
|
|
113
|
-
const { data: serializedData, meta: serializedMeta } = serialize(originalValue);
|
|
114
|
-
|
|
115
|
-
// Create the response format that unmarshal expects
|
|
116
|
-
const responseFormat = {
|
|
117
|
-
data: serializedData,
|
|
118
|
-
meta: { serialization: serializedMeta },
|
|
119
|
-
};
|
|
120
|
-
const marshaled = JSON.stringify(responseFormat);
|
|
121
|
-
|
|
122
|
-
const result = unmarshal(marshaled);
|
|
123
|
-
expect(result.data).toBeDefined();
|
|
124
|
-
expect((result.data as any).value).toBeInstanceOf(Decimal);
|
|
125
|
-
// Decimal normalizes '100.00' to '100'
|
|
126
|
-
expect((result.data as any).value.toString()).toBe('100');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('unmarshals plain values without data wrapper', () => {
|
|
130
|
-
const plainValue = { name: 'test' };
|
|
131
|
-
const marshaled = JSON.stringify(plainValue);
|
|
132
|
-
const result = unmarshal(marshaled);
|
|
133
|
-
expect(result).toEqual(plainValue);
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
describe('makeUrl', () => {
|
|
138
|
-
it('creates URL without args', () => {
|
|
139
|
-
const url = makeUrl('/api', 'User', 'findMany');
|
|
140
|
-
expect(url).toBe('/api/user/findMany');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('creates URL with simple args', () => {
|
|
144
|
-
const args = { where: { id: '1' } };
|
|
145
|
-
const url = makeUrl('/api', 'User', 'findUnique', args);
|
|
146
|
-
expect(url).toContain('/api/user/findUnique?q=');
|
|
147
|
-
expect(url).toContain(encodeURIComponent(JSON.stringify(args)));
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('lowercases first letter of model name', () => {
|
|
151
|
-
const url = makeUrl('/api', 'BlogPost', 'findMany');
|
|
152
|
-
expect(url).toBe('/api/blogPost/findMany');
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it('creates URL with args containing special types', () => {
|
|
156
|
-
const args = {
|
|
157
|
-
where: {
|
|
158
|
-
price: new Decimal('99.99'),
|
|
159
|
-
createdAt: new Date('2023-01-01T00:00:00Z'),
|
|
160
|
-
},
|
|
161
|
-
};
|
|
162
|
-
const url = makeUrl('/api', 'Product', 'findFirst', args);
|
|
163
|
-
|
|
164
|
-
expect(url).toContain('/api/product/findFirst?q=');
|
|
165
|
-
expect(url).toContain('&meta=');
|
|
166
|
-
|
|
167
|
-
// Verify we can reconstruct the args from the URL
|
|
168
|
-
const urlObj = new URL(url, 'http://localhost');
|
|
169
|
-
const qParam = urlObj.searchParams.get('q');
|
|
170
|
-
const metaParam = urlObj.searchParams.get('meta');
|
|
171
|
-
|
|
172
|
-
expect(qParam).toBeDefined();
|
|
173
|
-
expect(metaParam).toBeDefined();
|
|
174
|
-
|
|
175
|
-
const reconstructed = deserialize(JSON.parse(qParam!), JSON.parse(metaParam!).serialization);
|
|
176
|
-
expect((reconstructed as any).where.price).toBeInstanceOf(Decimal);
|
|
177
|
-
expect((reconstructed as any).where.createdAt).toBeInstanceOf(Date);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('handles empty args object', () => {
|
|
181
|
-
const url = makeUrl('/api', 'User', 'findMany', {});
|
|
182
|
-
expect(url).toContain('/api/user/findMany?q=');
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('handles complex nested args', () => {
|
|
186
|
-
const args = {
|
|
187
|
-
include: { posts: true },
|
|
188
|
-
where: { AND: [{ active: true }, { verified: true }] },
|
|
189
|
-
};
|
|
190
|
-
const url = makeUrl('/api', 'User', 'findMany', args);
|
|
191
|
-
expect(url).toContain('/api/user/findMany?q=');
|
|
192
|
-
expect(url).toContain(encodeURIComponent(JSON.stringify(args)));
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
describe('fetcher', () => {
|
|
197
|
-
let mockFetch: ReturnType<typeof vi.fn>;
|
|
198
|
-
const originalFetch = globalThis.fetch;
|
|
199
|
-
|
|
200
|
-
beforeEach(() => {
|
|
201
|
-
mockFetch = vi.fn();
|
|
202
|
-
global.fetch = mockFetch as typeof global.fetch;
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
afterEach(() => {
|
|
206
|
-
globalThis.fetch = originalFetch;
|
|
207
|
-
vi.resetAllMocks();
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
it('successfully fetches and deserializes data', async () => {
|
|
211
|
-
const responseData = { id: '1', name: 'Alice' };
|
|
212
|
-
mockFetch.mockResolvedValue({
|
|
213
|
-
ok: true,
|
|
214
|
-
text: async () => marshal({ data: responseData }),
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
const result = await fetcher('/api/user/findUnique', {});
|
|
218
|
-
|
|
219
|
-
expect(result).toEqual(responseData);
|
|
220
|
-
expect(mockFetch).toHaveBeenCalledWith('/api/user/findUnique', {});
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it('deserializes response with special types', async () => {
|
|
224
|
-
const responseData = {
|
|
225
|
-
id: '1',
|
|
226
|
-
balance: new Decimal('500.50'),
|
|
227
|
-
createdAt: new Date('2023-01-01T00:00:00Z'),
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
// Simulate server response format: { data: {...}, meta: { serialization: {...} } }
|
|
231
|
-
const { data: serializedData, meta: serializedMeta } = serialize(responseData);
|
|
232
|
-
const serverResponse = JSON.stringify({
|
|
233
|
-
data: serializedData,
|
|
234
|
-
meta: { serialization: serializedMeta },
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
mockFetch.mockResolvedValue({
|
|
238
|
-
ok: true,
|
|
239
|
-
text: async () => serverResponse,
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
const result = await fetcher<typeof responseData>('/api/user/findUnique', {});
|
|
243
|
-
|
|
244
|
-
expect(result.balance).toBeInstanceOf(Decimal);
|
|
245
|
-
expect(result.balance.toString()).toBe('500.5');
|
|
246
|
-
expect(result.createdAt).toBeInstanceOf(Date);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('throws QueryError on non-ok response', async () => {
|
|
250
|
-
const errorInfo = { code: 'NOT_FOUND', message: 'User not found' };
|
|
251
|
-
mockFetch.mockResolvedValue({
|
|
252
|
-
ok: false,
|
|
253
|
-
status: 404,
|
|
254
|
-
text: async () => JSON.stringify({ error: errorInfo }),
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
await expect(fetcher('/api/user/findUnique', {})).rejects.toThrow(
|
|
258
|
-
'An error occurred while fetching the data.',
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
try {
|
|
262
|
-
await fetcher('/api/user/findUnique', {});
|
|
263
|
-
} catch (error) {
|
|
264
|
-
const queryError = error as QueryError;
|
|
265
|
-
expect(queryError.status).toBe(404);
|
|
266
|
-
expect(queryError.info).toEqual(errorInfo);
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it('returns undefined for cannot-read-back policy rejection', async () => {
|
|
271
|
-
const errorInfo = {
|
|
272
|
-
rejectedByPolicy: true,
|
|
273
|
-
rejectReason: 'cannot-read-back',
|
|
274
|
-
};
|
|
275
|
-
mockFetch.mockResolvedValue({
|
|
276
|
-
ok: false,
|
|
277
|
-
status: 403,
|
|
278
|
-
text: async () => JSON.stringify({ error: errorInfo }),
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
const result = await fetcher('/api/user/create', {});
|
|
282
|
-
|
|
283
|
-
expect(result).toBeUndefined();
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('throws error for other policy rejections', async () => {
|
|
287
|
-
const errorInfo = {
|
|
288
|
-
rejectedByPolicy: true,
|
|
289
|
-
rejectReason: 'access-denied',
|
|
290
|
-
};
|
|
291
|
-
mockFetch.mockResolvedValue({
|
|
292
|
-
ok: false,
|
|
293
|
-
status: 403,
|
|
294
|
-
text: async () => JSON.stringify({ error: errorInfo }),
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
await expect(fetcher('/api/user/create', {})).rejects.toThrow();
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
it('use custom fetch if provided', async () => {
|
|
301
|
-
const customFetch = vi.fn().mockResolvedValue({
|
|
302
|
-
ok: true,
|
|
303
|
-
text: async () => marshal({ data: { id: '1', name: 'Custom' } }),
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
const result = await fetcher('/api/user/findUnique', {}, customFetch);
|
|
307
|
-
|
|
308
|
-
// Custom fetch should be called instead of global fetch
|
|
309
|
-
expect(customFetch).toHaveBeenCalledWith('/api/user/findUnique', {});
|
|
310
|
-
expect(customFetch).toHaveBeenCalledTimes(1);
|
|
311
|
-
expect(mockFetch).not.toHaveBeenCalled();
|
|
312
|
-
expect(result).toEqual({ id: '1', name: 'Custom' });
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it('passes request options to fetch', async () => {
|
|
316
|
-
const responseData = { id: '1' };
|
|
317
|
-
mockFetch.mockResolvedValue({
|
|
318
|
-
ok: true,
|
|
319
|
-
text: async () => marshal({ data: responseData }),
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
const options: RequestInit = {
|
|
323
|
-
method: 'POST',
|
|
324
|
-
headers: { 'Content-Type': 'application/json' },
|
|
325
|
-
body: JSON.stringify({ name: 'test' }),
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
await fetcher('/api/user/create', options);
|
|
329
|
-
|
|
330
|
-
expect(mockFetch).toHaveBeenCalledWith('/api/user/create', options);
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
it('handles empty response body', async () => {
|
|
334
|
-
mockFetch.mockResolvedValue({
|
|
335
|
-
ok: true,
|
|
336
|
-
text: async () => marshal({ data: null }),
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
const result = await fetcher('/api/user/delete', {});
|
|
340
|
-
expect(result).toBeNull();
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it('handles array responses', async () => {
|
|
344
|
-
const responseData = [
|
|
345
|
-
{ id: '1', name: 'Alice' },
|
|
346
|
-
{ id: '2', name: 'Bob' },
|
|
347
|
-
];
|
|
348
|
-
mockFetch.mockResolvedValue({
|
|
349
|
-
ok: true,
|
|
350
|
-
text: async () => marshal({ data: responseData }),
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
const result = await fetcher<typeof responseData>('/api/user/findMany', {});
|
|
354
|
-
|
|
355
|
-
expect(Array.isArray(result)).toBe(true);
|
|
356
|
-
expect(result).toHaveLength(2);
|
|
357
|
-
expect(result[0]?.name).toBe('Alice');
|
|
358
|
-
expect(result[1]?.name).toBe('Bob');
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
it('preserves response data structure with nested objects', async () => {
|
|
362
|
-
const responseData = {
|
|
363
|
-
id: '1',
|
|
364
|
-
name: 'Alice',
|
|
365
|
-
posts: [
|
|
366
|
-
{ id: 'p1', title: 'Post 1', viewCount: new Decimal('100') },
|
|
367
|
-
{ id: 'p2', title: 'Post 2', viewCount: new Decimal('200') },
|
|
368
|
-
],
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
// Simulate server response format
|
|
372
|
-
const { data: serializedData, meta: serializedMeta } = serialize(responseData);
|
|
373
|
-
const serverResponse = JSON.stringify({
|
|
374
|
-
data: serializedData,
|
|
375
|
-
meta: { serialization: serializedMeta },
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
mockFetch.mockResolvedValue({
|
|
379
|
-
ok: true,
|
|
380
|
-
text: async () => serverResponse,
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
const result = await fetcher<typeof responseData>('/api/user/findUnique', {});
|
|
384
|
-
|
|
385
|
-
expect(result.posts).toHaveLength(2);
|
|
386
|
-
expect(result.posts[0]?.viewCount).toBeInstanceOf(Decimal);
|
|
387
|
-
expect(result.posts[0]?.viewCount.toString()).toBe('100');
|
|
388
|
-
expect(result.posts[1]?.viewCount.toString()).toBe('200');
|
|
389
|
-
});
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
describe('Decimal custom serializer', () => {
|
|
393
|
-
it('handles Decimal instances', () => {
|
|
394
|
-
const value = new Decimal('123.456');
|
|
395
|
-
const { data, meta } = serialize({ value });
|
|
396
|
-
const result = deserialize(data, meta);
|
|
397
|
-
expect((result as any).value).toBeInstanceOf(Decimal);
|
|
398
|
-
expect((result as any).value.toString()).toBe('123.456');
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
it('handles negative Decimal values', () => {
|
|
402
|
-
const value = new Decimal('-99.99');
|
|
403
|
-
const { data, meta } = serialize({ value });
|
|
404
|
-
const result = deserialize(data, meta);
|
|
405
|
-
expect((result as any).value.toString()).toBe('-99.99');
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
it('handles very large Decimal values', () => {
|
|
409
|
-
const value = new Decimal('999999999999999999.999999999999');
|
|
410
|
-
const { data, meta } = serialize({ value });
|
|
411
|
-
const result = deserialize(data, meta);
|
|
412
|
-
expect((result as any).value).toBeInstanceOf(Decimal);
|
|
413
|
-
expect((result as any).value.toString()).toBe(value.toString());
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
it('handles zero Decimal value', () => {
|
|
417
|
-
const value = new Decimal('0');
|
|
418
|
-
const { data, meta } = serialize({ value });
|
|
419
|
-
const result = deserialize(data, meta);
|
|
420
|
-
expect((result as any).value.toString()).toBe('0');
|
|
421
|
-
});
|
|
422
|
-
});
|
|
423
|
-
});
|