yaxa-svelte 2.6.0 → 2.8.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/README.md +136 -25
- package/dist/auth/index.d.ts +7 -1
- package/dist/auth/index.js +3 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +3 -0
- package/dist/client/mutation.svelte.d.ts +37 -0
- package/dist/client/mutation.svelte.js +87 -0
- package/dist/client/query-cache.svelte.d.ts +51 -0
- package/dist/client/query-cache.svelte.js +172 -0
- package/dist/client/query.svelte.d.ts +71 -0
- package/dist/client/query.svelte.js +213 -0
- package/dist/client/query.test.d.ts +1 -0
- package/dist/client/query.test.js +242 -0
- package/dist/components/ai/ai.test.js +68 -1
- package/dist/components/ai/index.d.ts +2 -0
- package/dist/components/ai/index.js +1 -0
- package/dist/components/ai/useAiChat.svelte.d.ts +68 -0
- package/dist/components/ai/useAiChat.svelte.js +258 -0
- package/dist/components/blocks/FAQ.svelte +11 -0
- package/dist/components/blocks/FAQ.svelte.d.ts +1 -0
- package/dist/components/blocks/FAQ.test.js +20 -0
- package/dist/components/saas/AuthCard.svelte +16 -12
- package/dist/components/saas/AuthCard.svelte.d.ts +3 -3
- package/dist/components/saas/ResetPasswordCard.svelte +13 -0
- package/dist/components/saas/ResetPasswordCard.svelte.d.ts +5 -0
- package/dist/components/saas/SignInCard.svelte +13 -0
- package/dist/components/saas/SignInCard.svelte.d.ts +5 -0
- package/dist/components/saas/SignUpCard.svelte +13 -0
- package/dist/components/saas/SignUpCard.svelte.d.ts +5 -0
- package/dist/components/saas/auth-cards.test.d.ts +1 -0
- package/dist/components/saas/auth-cards.test.js +42 -0
- package/dist/components/seo/Seo.svelte +16 -9
- package/dist/index.d.ts +23 -4
- package/dist/index.js +14 -3
- package/dist/server/ai/index.d.ts +2 -0
- package/dist/server/ai/index.js +1 -0
- package/dist/server/ai/stream.d.ts +24 -0
- package/dist/server/ai/stream.js +178 -0
- package/dist/server/ai/stream.test.d.ts +1 -0
- package/dist/server/ai/stream.test.js +83 -0
- package/dist/server/ai/types.d.ts +43 -0
- package/dist/server/ai/types.js +1 -0
- package/dist/server/index.d.ts +6 -0
- package/dist/server/index.js +4 -0
- package/dist/server/openapi/handler.d.ts +1 -1
- package/dist/server/openapi/handler.js +41 -41
- package/dist/server/openapi/openapi.test.js +6 -7
- package/dist/server/openapi/types.d.ts +2 -3
- package/dist/site/context.svelte.d.ts +3 -0
- package/dist/site/context.svelte.js +7 -0
- package/dist/site/hook.d.ts +5 -1
- package/dist/site/hook.js +11 -1
- package/dist/site/index.d.ts +26 -0
- package/dist/site/index.js +25 -0
- package/dist/site/llms.d.ts +27 -0
- package/dist/site/llms.js +58 -0
- package/dist/site/schema.d.ts +45 -0
- package/dist/site/schema.js +43 -0
- package/dist/site/seo-helpers.d.ts +1 -0
- package/dist/site/site-export.test.d.ts +1 -0
- package/dist/site/site-export.test.js +193 -0
- package/dist/site/skill.d.ts +26 -0
- package/dist/site/skill.js +69 -0
- package/dist/site/useSeo.svelte.d.ts +23 -0
- package/dist/site/useSeo.svelte.js +40 -0
- package/dist/site/useSeo.test.d.ts +1 -0
- package/dist/site/useSeo.test.js +35 -0
- package/package.json +5 -1
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type QueryClient, type QueryKey, type QueryStatus } from './query-cache.svelte';
|
|
2
|
+
import type { ClientResponse, ProblemDetails } from './types';
|
|
3
|
+
import type { SSEResult } from '../composables/useSSE.svelte';
|
|
4
|
+
export type QueryFn<TData = any> = () => Promise<ClientResponse<TData> | TData>;
|
|
5
|
+
export interface CreateQueryOptions<TData = any, TError = ProblemDetails | Error> {
|
|
6
|
+
/**
|
|
7
|
+
* Explicit cache key or reactive key getter. If omitted, an auto-derived key is used.
|
|
8
|
+
*/
|
|
9
|
+
key?: QueryKey | (() => QueryKey);
|
|
10
|
+
/**
|
|
11
|
+
* Whether the query should execute automatically (defaults to true).
|
|
12
|
+
* Can be a boolean or a reactive boolean getter: `() => !!userId`.
|
|
13
|
+
*/
|
|
14
|
+
enabled?: boolean | (() => boolean);
|
|
15
|
+
/**
|
|
16
|
+
* Initial data (useful for SSR hydration to avoid loading flashes).
|
|
17
|
+
*/
|
|
18
|
+
initialData?: TData;
|
|
19
|
+
/**
|
|
20
|
+
* Time in milliseconds before data is considered stale (defaults to 0).
|
|
21
|
+
*/
|
|
22
|
+
staleTimeMs?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Whether to refetch data when the browser window regains focus (defaults to true).
|
|
25
|
+
*/
|
|
26
|
+
refetchOnWindowFocus?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether to refetch data when network reconnects (defaults to true).
|
|
29
|
+
*/
|
|
30
|
+
refetchOnReconnect?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Custom query client instance (defaults to global client).
|
|
33
|
+
*/
|
|
34
|
+
client?: QueryClient;
|
|
35
|
+
/**
|
|
36
|
+
* Server-Sent Events stream or channel for live real-time cache invalidation.
|
|
37
|
+
*/
|
|
38
|
+
subscribeToSse?: string | {
|
|
39
|
+
sse?: SSEResult;
|
|
40
|
+
event?: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Callback on successful fetch.
|
|
44
|
+
*/
|
|
45
|
+
onSuccess?: (data: TData) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Callback on error.
|
|
48
|
+
*/
|
|
49
|
+
onError?: (error: TError) => void;
|
|
50
|
+
}
|
|
51
|
+
export interface CreateQueryResult<TData = any, TError = ProblemDetails | Error> {
|
|
52
|
+
readonly data: TData | null;
|
|
53
|
+
readonly error: TError | null;
|
|
54
|
+
readonly status: QueryStatus;
|
|
55
|
+
readonly isLoading: boolean;
|
|
56
|
+
readonly isFetching: boolean;
|
|
57
|
+
readonly isSuccess: boolean;
|
|
58
|
+
readonly isError: boolean;
|
|
59
|
+
readonly updatedAt: number;
|
|
60
|
+
refetch: () => Promise<TData | null>;
|
|
61
|
+
invalidate: () => Promise<void>;
|
|
62
|
+
setData: (updater: TData | ((prev: TData | null) => TData)) => void;
|
|
63
|
+
optimisticUpdate: (updater: (prev: TData | null) => TData) => void;
|
|
64
|
+
destroy: () => void;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Idiomatic Svelte 5 Runes Query Composable.
|
|
68
|
+
* Features automatic dependency tracking, request coalescing, stale-while-revalidate,
|
|
69
|
+
* window focus revalidation, and SSE live invalidation.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createQuery<TData = any, TError = ProblemDetails | Error>(keyOrFn: QueryKey | (() => QueryKey) | QueryFn<TData>, fnOrOptions?: QueryFn<TData> | CreateQueryOptions<TData, TError>, maybeOptions?: CreateQueryOptions<TData, TError>): CreateQueryResult<TData, TError>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { hashQueryKey, getQueryClient } from './query-cache.svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Checks if a response is a Yaxa ClientResponse.
|
|
4
|
+
*/
|
|
5
|
+
function isClientResponse(res) {
|
|
6
|
+
return (typeof res === 'object' &&
|
|
7
|
+
res !== null &&
|
|
8
|
+
'ok' in res &&
|
|
9
|
+
'status' in res &&
|
|
10
|
+
('data' in res || 'error' in res));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Normalizes query key evaluation.
|
|
14
|
+
*/
|
|
15
|
+
function resolveKey(keyOrFn, fallback) {
|
|
16
|
+
if (typeof keyOrFn === 'function') {
|
|
17
|
+
return keyOrFn();
|
|
18
|
+
}
|
|
19
|
+
if (keyOrFn !== undefined) {
|
|
20
|
+
return keyOrFn;
|
|
21
|
+
}
|
|
22
|
+
return fallback || 'default-query-key';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Idiomatic Svelte 5 Runes Query Composable.
|
|
26
|
+
* Features automatic dependency tracking, request coalescing, stale-while-revalidate,
|
|
27
|
+
* window focus revalidation, and SSE live invalidation.
|
|
28
|
+
*/
|
|
29
|
+
export function createQuery(keyOrFn, fnOrOptions, maybeOptions) {
|
|
30
|
+
let keyConfig;
|
|
31
|
+
let queryFn;
|
|
32
|
+
let options;
|
|
33
|
+
if (typeof keyOrFn === 'function' && fnOrOptions && typeof fnOrOptions === 'function') {
|
|
34
|
+
// createQuery(keyFn, queryFn, options)
|
|
35
|
+
keyConfig = keyOrFn;
|
|
36
|
+
queryFn = fnOrOptions;
|
|
37
|
+
options = maybeOptions || {};
|
|
38
|
+
}
|
|
39
|
+
else if (typeof keyOrFn === 'function' && (!fnOrOptions || typeof fnOrOptions === 'object')) {
|
|
40
|
+
// createQuery(queryFn, options)
|
|
41
|
+
queryFn = keyOrFn;
|
|
42
|
+
options = fnOrOptions || {};
|
|
43
|
+
keyConfig = options.key;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
// createQuery(key, queryFn, options)
|
|
47
|
+
keyConfig = keyOrFn;
|
|
48
|
+
queryFn = fnOrOptions;
|
|
49
|
+
options = maybeOptions || {};
|
|
50
|
+
}
|
|
51
|
+
const client = options.client || getQueryClient();
|
|
52
|
+
const staleTimeMs = options.staleTimeMs ?? 0;
|
|
53
|
+
const refetchOnWindowFocus = options.refetchOnWindowFocus ?? true;
|
|
54
|
+
const refetchOnReconnect = options.refetchOnReconnect ?? true;
|
|
55
|
+
const fallbackKey = queryFn.toString();
|
|
56
|
+
const initialKey = resolveKey(keyConfig, fallbackKey);
|
|
57
|
+
const initialHash = hashQueryKey(initialKey);
|
|
58
|
+
// Pre-seed entry immediately on creation with initialData if provided
|
|
59
|
+
client.getOrCreateEntry(initialHash, options.initialData);
|
|
60
|
+
function getCurrentKey() {
|
|
61
|
+
return resolveKey(keyConfig, fallbackKey);
|
|
62
|
+
}
|
|
63
|
+
function getCurrentHash() {
|
|
64
|
+
return hashQueryKey(getCurrentKey());
|
|
65
|
+
}
|
|
66
|
+
function getIsEnabled() {
|
|
67
|
+
return typeof options.enabled === 'function'
|
|
68
|
+
? options.enabled()
|
|
69
|
+
: options.enabled !== undefined
|
|
70
|
+
? options.enabled
|
|
71
|
+
: true;
|
|
72
|
+
}
|
|
73
|
+
function getEntry() {
|
|
74
|
+
return client.getOrCreateEntry(getCurrentHash(), options.initialData);
|
|
75
|
+
}
|
|
76
|
+
async function executeFetch(force = false) {
|
|
77
|
+
if (!getIsEnabled())
|
|
78
|
+
return null;
|
|
79
|
+
const targetEntry = getEntry();
|
|
80
|
+
// 1. Check Stale Time if not forced
|
|
81
|
+
if (!force && targetEntry.updatedAt > 0) {
|
|
82
|
+
const age = Date.now() - targetEntry.updatedAt;
|
|
83
|
+
if (age < staleTimeMs && targetEntry.data !== null) {
|
|
84
|
+
return targetEntry.data;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// 2. Request Coalescing: Reuse in-flight Promise if already running
|
|
88
|
+
if (targetEntry.inFlightPromise) {
|
|
89
|
+
return targetEntry.inFlightPromise;
|
|
90
|
+
}
|
|
91
|
+
targetEntry.isFetching = true;
|
|
92
|
+
if (targetEntry.status === 'idle') {
|
|
93
|
+
targetEntry.status = 'loading';
|
|
94
|
+
}
|
|
95
|
+
const promise = (async () => {
|
|
96
|
+
try {
|
|
97
|
+
const result = await queryFn();
|
|
98
|
+
let finalData;
|
|
99
|
+
if (isClientResponse(result)) {
|
|
100
|
+
if (!result.ok) {
|
|
101
|
+
throw result.error;
|
|
102
|
+
}
|
|
103
|
+
finalData = result.data;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
finalData = result;
|
|
107
|
+
}
|
|
108
|
+
targetEntry.data = finalData;
|
|
109
|
+
targetEntry.error = null;
|
|
110
|
+
targetEntry.status = 'success';
|
|
111
|
+
targetEntry.updatedAt = Date.now();
|
|
112
|
+
options.onSuccess?.(finalData);
|
|
113
|
+
return finalData;
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
targetEntry.error = err;
|
|
117
|
+
targetEntry.status = 'error';
|
|
118
|
+
options.onError?.(err);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
targetEntry.isFetching = false;
|
|
123
|
+
targetEntry.inFlightPromise = null;
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
targetEntry.inFlightPromise = promise;
|
|
127
|
+
return promise;
|
|
128
|
+
}
|
|
129
|
+
// Subscriber notification callback
|
|
130
|
+
const subscriber = () => {
|
|
131
|
+
executeFetch(true);
|
|
132
|
+
};
|
|
133
|
+
const initialEntry = getEntry();
|
|
134
|
+
initialEntry.subscribers.add(subscriber);
|
|
135
|
+
// Initial execution if enabled and not already hydrated/fresh
|
|
136
|
+
if (getIsEnabled() && initialEntry.status === 'idle') {
|
|
137
|
+
executeFetch(false);
|
|
138
|
+
}
|
|
139
|
+
// Window listeners
|
|
140
|
+
let cleanupWindowListeners;
|
|
141
|
+
if (typeof window !== 'undefined') {
|
|
142
|
+
const onFocus = () => {
|
|
143
|
+
if (refetchOnWindowFocus && getIsEnabled()) {
|
|
144
|
+
executeFetch(false);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const onOnline = () => {
|
|
148
|
+
if (refetchOnReconnect && getIsEnabled()) {
|
|
149
|
+
executeFetch(true);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
window.addEventListener('focus', onFocus);
|
|
153
|
+
window.addEventListener('online', onOnline);
|
|
154
|
+
cleanupWindowListeners = () => {
|
|
155
|
+
window.removeEventListener('focus', onFocus);
|
|
156
|
+
window.removeEventListener('online', onOnline);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// SSE Live-Invalidation Bridge
|
|
160
|
+
let cleanupSse;
|
|
161
|
+
if (options.subscribeToSse && typeof options.subscribeToSse === 'object') {
|
|
162
|
+
const sseOption = options.subscribeToSse;
|
|
163
|
+
if (sseOption.sse && sseOption.event) {
|
|
164
|
+
cleanupSse = sseOption.sse.on(sseOption.event, () => {
|
|
165
|
+
executeFetch(true);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function destroy() {
|
|
170
|
+
const currentEntry = getEntry();
|
|
171
|
+
currentEntry.subscribers.delete(subscriber);
|
|
172
|
+
cleanupWindowListeners?.();
|
|
173
|
+
cleanupSse?.();
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
get data() {
|
|
177
|
+
return getEntry().data;
|
|
178
|
+
},
|
|
179
|
+
get error() {
|
|
180
|
+
return getEntry().error;
|
|
181
|
+
},
|
|
182
|
+
get status() {
|
|
183
|
+
return getEntry().status;
|
|
184
|
+
},
|
|
185
|
+
get isLoading() {
|
|
186
|
+
const e = getEntry();
|
|
187
|
+
return e.status === 'loading' && e.data === null;
|
|
188
|
+
},
|
|
189
|
+
get isFetching() {
|
|
190
|
+
return getEntry().isFetching;
|
|
191
|
+
},
|
|
192
|
+
get isSuccess() {
|
|
193
|
+
return getEntry().status === 'success';
|
|
194
|
+
},
|
|
195
|
+
get isError() {
|
|
196
|
+
return getEntry().status === 'error';
|
|
197
|
+
},
|
|
198
|
+
get updatedAt() {
|
|
199
|
+
return getEntry().updatedAt;
|
|
200
|
+
},
|
|
201
|
+
refetch: () => executeFetch(true),
|
|
202
|
+
invalidate: async () => {
|
|
203
|
+
await getEntry().invalidate();
|
|
204
|
+
},
|
|
205
|
+
setData: (updater) => {
|
|
206
|
+
client.setQueryData(getCurrentKey(), updater);
|
|
207
|
+
},
|
|
208
|
+
optimisticUpdate: (updater) => {
|
|
209
|
+
client.setQueryData(getCurrentKey(), updater);
|
|
210
|
+
},
|
|
211
|
+
destroy
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { hashQueryKey, QueryClient, createQuery, createMutation, setQueryClient } from './index';
|
|
3
|
+
describe('Runes Query Engine - hashQueryKey', () => {
|
|
4
|
+
it('returns string keys as-is', () => {
|
|
5
|
+
expect(hashQueryKey('projects')).toBe('projects');
|
|
6
|
+
expect(hashQueryKey('/api/items')).toBe('/api/items');
|
|
7
|
+
});
|
|
8
|
+
it('deterministically hashes arrays and nested objects regardless of key order', () => {
|
|
9
|
+
const keyA = ['projects', { orgId: '123', status: 'active' }];
|
|
10
|
+
const keyB = ['projects', { status: 'active', orgId: '123' }];
|
|
11
|
+
expect(hashQueryKey(keyA)).toBe(hashQueryKey(keyB));
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
describe('Runes Query Engine - QueryClient', () => {
|
|
15
|
+
let client;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
client = new QueryClient();
|
|
18
|
+
setQueryClient(client);
|
|
19
|
+
});
|
|
20
|
+
it('creates, retrieves, and updates query cache entries', () => {
|
|
21
|
+
expect(client.getQueryData('users')).toBeNull();
|
|
22
|
+
client.setQueryData('users', [{ id: '1', name: 'Alice' }]);
|
|
23
|
+
expect(client.getQueryData('users')).toEqual([{ id: '1', name: 'Alice' }]);
|
|
24
|
+
// Functional updater
|
|
25
|
+
client.setQueryData('users', (prev) => [
|
|
26
|
+
...(prev ?? []),
|
|
27
|
+
{ id: '2', name: 'Bob' }
|
|
28
|
+
]);
|
|
29
|
+
expect(client.getQueryData('users')).toHaveLength(2);
|
|
30
|
+
});
|
|
31
|
+
it('invalidates matching queries with exact and prefix filters', async () => {
|
|
32
|
+
const entry1 = client.getOrCreateEntry('projects-list');
|
|
33
|
+
const entry2 = client.getOrCreateEntry('projects-detail-1');
|
|
34
|
+
const entry3 = client.getOrCreateEntry('users');
|
|
35
|
+
const listener1 = vi.fn();
|
|
36
|
+
const listener2 = vi.fn();
|
|
37
|
+
const listener3 = vi.fn();
|
|
38
|
+
entry1.subscribers.add(listener1);
|
|
39
|
+
entry2.subscribers.add(listener2);
|
|
40
|
+
entry3.subscribers.add(listener3);
|
|
41
|
+
// Invalidate all projects
|
|
42
|
+
await client.invalidateQueries('projects');
|
|
43
|
+
expect(listener1).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(listener2).toHaveBeenCalledTimes(1);
|
|
45
|
+
expect(listener3).not.toHaveBeenCalled();
|
|
46
|
+
// Exact invalidation
|
|
47
|
+
await client.invalidateQueries({ key: 'projects-list', exact: true });
|
|
48
|
+
expect(listener1).toHaveBeenCalledTimes(2);
|
|
49
|
+
expect(listener2).toHaveBeenCalledTimes(1);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('Runes Query Engine - createQuery', () => {
|
|
53
|
+
let client;
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
client = new QueryClient();
|
|
56
|
+
setQueryClient(client);
|
|
57
|
+
});
|
|
58
|
+
it('fetches data and unwraps ClientResponse successfully', async () => {
|
|
59
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
60
|
+
ok: true,
|
|
61
|
+
status: 200,
|
|
62
|
+
data: [{ id: 'proj_1', name: 'Alpha' }],
|
|
63
|
+
error: null,
|
|
64
|
+
response: new Response()
|
|
65
|
+
});
|
|
66
|
+
const query = createQuery('test-projects', fetchMock, { client });
|
|
67
|
+
expect(query.isLoading).toBe(true);
|
|
68
|
+
expect(query.data).toBeNull();
|
|
69
|
+
const result = await query.refetch();
|
|
70
|
+
expect(result).toEqual([{ id: 'proj_1', name: 'Alpha' }]);
|
|
71
|
+
expect(query.data).toEqual([{ id: 'proj_1', name: 'Alpha' }]);
|
|
72
|
+
expect(query.isSuccess).toBe(true);
|
|
73
|
+
expect(query.isLoading).toBe(false);
|
|
74
|
+
expect(query.isError).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
it('handles ClientResponse errors and RFC 7807 problem details', async () => {
|
|
77
|
+
const errorResponse = {
|
|
78
|
+
ok: false,
|
|
79
|
+
status: 404,
|
|
80
|
+
data: null,
|
|
81
|
+
error: {
|
|
82
|
+
type: 'https://yaxa.dev/errors/not-found',
|
|
83
|
+
title: 'Project Not Found',
|
|
84
|
+
status: 404,
|
|
85
|
+
detail: 'No project with ID 999 exists'
|
|
86
|
+
},
|
|
87
|
+
response: new Response(null, { status: 404 })
|
|
88
|
+
};
|
|
89
|
+
const fetchMock = vi.fn().mockResolvedValue(errorResponse);
|
|
90
|
+
const onError = vi.fn();
|
|
91
|
+
const query = createQuery('not-found-query', fetchMock, { client, onError });
|
|
92
|
+
await query.refetch();
|
|
93
|
+
expect(query.data).toBeNull();
|
|
94
|
+
expect(query.isError).toBe(true);
|
|
95
|
+
expect(query.error).toEqual(errorResponse.error);
|
|
96
|
+
expect(onError).toHaveBeenCalledWith(errorResponse.error);
|
|
97
|
+
});
|
|
98
|
+
it('coalesces multiple in-flight concurrent requests to the same key', async () => {
|
|
99
|
+
let resolveCall = () => { };
|
|
100
|
+
const fetchMock = vi.fn().mockImplementation(() => new Promise((res) => {
|
|
101
|
+
resolveCall = res;
|
|
102
|
+
}));
|
|
103
|
+
const query1 = createQuery('shared-key', fetchMock, { client });
|
|
104
|
+
const query2 = createQuery('shared-key', fetchMock, { client });
|
|
105
|
+
// Trigger both simultaneously
|
|
106
|
+
const p1 = query1.refetch();
|
|
107
|
+
const p2 = query2.refetch();
|
|
108
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
109
|
+
resolveCall({
|
|
110
|
+
ok: true,
|
|
111
|
+
status: 200,
|
|
112
|
+
data: { result: 'shared' },
|
|
113
|
+
error: null,
|
|
114
|
+
response: new Response()
|
|
115
|
+
});
|
|
116
|
+
const [res1, res2] = await Promise.all([p1, p2]);
|
|
117
|
+
expect(res1).toEqual({ result: 'shared' });
|
|
118
|
+
expect(res2).toEqual({ result: 'shared' });
|
|
119
|
+
});
|
|
120
|
+
it('respects staleTimeMs and avoids refetching when cache is fresh', async () => {
|
|
121
|
+
const fetchMock = vi.fn().mockResolvedValue('data-1');
|
|
122
|
+
const query = createQuery('stale-key', fetchMock, {
|
|
123
|
+
client,
|
|
124
|
+
staleTimeMs: 10_000
|
|
125
|
+
});
|
|
126
|
+
await query.refetch();
|
|
127
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
128
|
+
// Non-forced fetch should return cached data because staleTimeMs hasn't elapsed
|
|
129
|
+
client.getOrCreateEntry('stale-key').updatedAt = Date.now();
|
|
130
|
+
const entry = client.getOrCreateEntry('stale-key');
|
|
131
|
+
expect(entry.data).toBe('data-1');
|
|
132
|
+
});
|
|
133
|
+
it('hydrates with initialData with zero loading state', () => {
|
|
134
|
+
const query = createQuery('ssr-key', async () => 'fetched', {
|
|
135
|
+
client,
|
|
136
|
+
initialData: 'preloaded'
|
|
137
|
+
});
|
|
138
|
+
expect(query.data).toBe('preloaded');
|
|
139
|
+
expect(query.isLoading).toBe(false);
|
|
140
|
+
expect(query.isSuccess).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
it('supports optimisticUpdate and setData', () => {
|
|
143
|
+
const query = createQuery('list-key', async () => ['item1'], {
|
|
144
|
+
client,
|
|
145
|
+
initialData: ['item1']
|
|
146
|
+
});
|
|
147
|
+
query.optimisticUpdate((prev) => [...(prev ?? []), 'item2']);
|
|
148
|
+
expect(query.data).toEqual(['item1', 'item2']);
|
|
149
|
+
query.setData(['item-override']);
|
|
150
|
+
expect(query.data).toEqual(['item-override']);
|
|
151
|
+
});
|
|
152
|
+
it('respects enabled: false and skips automatic fetching', async () => {
|
|
153
|
+
const fetchMock = vi.fn().mockResolvedValue('data');
|
|
154
|
+
const query = createQuery('disabled-key', fetchMock, {
|
|
155
|
+
client,
|
|
156
|
+
enabled: false
|
|
157
|
+
});
|
|
158
|
+
const res = await query.refetch();
|
|
159
|
+
expect(res).toBeNull();
|
|
160
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
it('supports live invalidation via subscribeToSse', async () => {
|
|
163
|
+
const mockSse = {
|
|
164
|
+
on: vi.fn((_event, handler) => {
|
|
165
|
+
// Save handler
|
|
166
|
+
mockSse.trigger = handler;
|
|
167
|
+
return () => { };
|
|
168
|
+
})
|
|
169
|
+
};
|
|
170
|
+
const fetchMock = vi.fn().mockResolvedValue('sse-data');
|
|
171
|
+
createQuery('sse-query', fetchMock, {
|
|
172
|
+
client,
|
|
173
|
+
subscribeToSse: {
|
|
174
|
+
sse: mockSse,
|
|
175
|
+
event: 'project.updated'
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
expect(mockSse.on).toBeDefined();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe('Runes Query Engine - createMutation', () => {
|
|
182
|
+
it('executes successful mutation and runs lifecycle hooks', async () => {
|
|
183
|
+
const onMutate = vi.fn().mockReturnValue({ tempId: 'temp_123' });
|
|
184
|
+
const onSuccess = vi.fn();
|
|
185
|
+
const onSettled = vi.fn();
|
|
186
|
+
const mutationFn = vi.fn().mockResolvedValue({
|
|
187
|
+
ok: true,
|
|
188
|
+
status: 201,
|
|
189
|
+
data: { id: 'real_123', name: 'Created' },
|
|
190
|
+
error: null,
|
|
191
|
+
response: new Response(null, { status: 201 })
|
|
192
|
+
});
|
|
193
|
+
const mutation = createMutation(mutationFn, {
|
|
194
|
+
onMutate,
|
|
195
|
+
onSuccess,
|
|
196
|
+
onSettled
|
|
197
|
+
});
|
|
198
|
+
expect(mutation.isIdle).toBe(true);
|
|
199
|
+
const promise = mutation.mutate({ name: 'Created' });
|
|
200
|
+
expect(mutation.isPending).toBe(true);
|
|
201
|
+
const result = await promise;
|
|
202
|
+
expect(result).toEqual({ id: 'real_123', name: 'Created' });
|
|
203
|
+
expect(mutation.data).toEqual({ id: 'real_123', name: 'Created' });
|
|
204
|
+
expect(mutation.isSuccess).toBe(true);
|
|
205
|
+
expect(mutation.isPending).toBe(false);
|
|
206
|
+
expect(onMutate).toHaveBeenCalledWith({ name: 'Created' });
|
|
207
|
+
expect(onSuccess).toHaveBeenCalledWith({ id: 'real_123', name: 'Created' }, { name: 'Created' }, { tempId: 'temp_123' });
|
|
208
|
+
expect(onSettled).toHaveBeenCalledWith({ id: 'real_123', name: 'Created' }, null, { name: 'Created' }, { tempId: 'temp_123' });
|
|
209
|
+
});
|
|
210
|
+
it('handles mutation errors, passes context for rollback, and re-throws', async () => {
|
|
211
|
+
const rollbackContext = { previousList: ['item1'] };
|
|
212
|
+
const onMutate = vi.fn().mockReturnValue(rollbackContext);
|
|
213
|
+
const onError = vi.fn();
|
|
214
|
+
const onSettled = vi.fn();
|
|
215
|
+
const errorResponse = {
|
|
216
|
+
ok: false,
|
|
217
|
+
status: 400,
|
|
218
|
+
data: null,
|
|
219
|
+
error: {
|
|
220
|
+
type: 'https://yaxa.dev/errors/validation',
|
|
221
|
+
title: 'Bad Request',
|
|
222
|
+
status: 400,
|
|
223
|
+
detail: 'Name must be at least 3 characters'
|
|
224
|
+
},
|
|
225
|
+
response: new Response(null, { status: 400 })
|
|
226
|
+
};
|
|
227
|
+
const mutation = createMutation(async () => errorResponse, {
|
|
228
|
+
onMutate,
|
|
229
|
+
onError,
|
|
230
|
+
onSettled
|
|
231
|
+
});
|
|
232
|
+
await expect(mutation.mutate(undefined)).rejects.toEqual(errorResponse.error);
|
|
233
|
+
expect(mutation.isError).toBe(true);
|
|
234
|
+
expect(mutation.error).toEqual(errorResponse.error);
|
|
235
|
+
expect(onError).toHaveBeenCalledWith(errorResponse.error, undefined, rollbackContext);
|
|
236
|
+
expect(onSettled).toHaveBeenCalledWith(null, errorResponse.error, undefined, rollbackContext);
|
|
237
|
+
mutation.reset();
|
|
238
|
+
expect(mutation.isIdle).toBe(true);
|
|
239
|
+
expect(mutation.error).toBeNull();
|
|
240
|
+
expect(mutation.data).toBeNull();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -1,13 +1,80 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
2
|
import AiChat from './AiChat.svelte';
|
|
3
3
|
import PromptBar from './PromptBar.svelte';
|
|
4
4
|
import AiThought from './AiThought.svelte';
|
|
5
5
|
import AiToolCall from './AiToolCall.svelte';
|
|
6
|
+
import { useAiChat } from './useAiChat.svelte';
|
|
6
7
|
describe('AI-Native Components', () => {
|
|
7
8
|
it('should export all AI component definitions', () => {
|
|
8
9
|
expect(AiChat).toBeDefined();
|
|
9
10
|
expect(PromptBar).toBeDefined();
|
|
10
11
|
expect(AiThought).toBeDefined();
|
|
11
12
|
expect(AiToolCall).toBeDefined();
|
|
13
|
+
expect(typeof useAiChat).toBe('function');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('useAiChat Runes Composable', () => {
|
|
17
|
+
const originalFetch = globalThis.fetch;
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
globalThis.fetch = originalFetch;
|
|
20
|
+
});
|
|
21
|
+
it('initializes with default and initial messages', () => {
|
|
22
|
+
const chat = useAiChat({
|
|
23
|
+
initialMessages: [{ id: 'init-1', role: 'user', content: 'Initial message' }]
|
|
24
|
+
});
|
|
25
|
+
expect(chat.messages).toHaveLength(1);
|
|
26
|
+
expect(chat.messages[0].content).toBe('Initial message');
|
|
27
|
+
expect(chat.input).toBe('');
|
|
28
|
+
expect(chat.loading).toBe(false);
|
|
29
|
+
expect(chat.error).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it('streams SSE text deltas and thoughts progressively into assistant message', async () => {
|
|
32
|
+
const encoder = new TextEncoder();
|
|
33
|
+
const stream = new ReadableStream({
|
|
34
|
+
start(controller) {
|
|
35
|
+
controller.enqueue(encoder.encode('data: {"type":"thought","thought":{"running":true,"text":"Analyzing..."}}\n\n'));
|
|
36
|
+
controller.enqueue(encoder.encode('data: {"type":"text","delta":"Hello "}\n\n'));
|
|
37
|
+
controller.enqueue(encoder.encode('data: {"type":"text","delta":"World!"}\n\n'));
|
|
38
|
+
controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
|
39
|
+
controller.close();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
globalThis.fetch = vi.fn().mockResolvedValue(new Response(stream, {
|
|
43
|
+
headers: { 'Content-Type': 'text/event-stream' }
|
|
44
|
+
}));
|
|
45
|
+
const chat = useAiChat();
|
|
46
|
+
chat.input = 'Say hello';
|
|
47
|
+
await chat.send();
|
|
48
|
+
expect(chat.messages).toHaveLength(2);
|
|
49
|
+
expect(chat.messages[0].role).toBe('user');
|
|
50
|
+
expect(chat.messages[0].content).toBe('Say hello');
|
|
51
|
+
expect(chat.messages[1].role).toBe('assistant');
|
|
52
|
+
expect(chat.messages[1].content).toBe('Hello World!');
|
|
53
|
+
expect(chat.messages[1].thought?.text).toBe('Analyzing...');
|
|
54
|
+
expect(chat.messages[1].isStreaming).toBe(false);
|
|
55
|
+
expect(chat.loading).toBe(false);
|
|
56
|
+
expect(chat.error).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
it('handles retries and message clearing', async () => {
|
|
59
|
+
const encoder = new TextEncoder();
|
|
60
|
+
const mockResponse = () => new Response(new ReadableStream({
|
|
61
|
+
start(controller) {
|
|
62
|
+
controller.enqueue(encoder.encode('data: {"type":"text","delta":"Response 1"}\n\n'));
|
|
63
|
+
controller.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
|
64
|
+
controller.close();
|
|
65
|
+
}
|
|
66
|
+
}));
|
|
67
|
+
globalThis.fetch = vi.fn().mockImplementation(() => Promise.resolve(mockResponse()));
|
|
68
|
+
const chat = useAiChat();
|
|
69
|
+
await chat.send('Prompt 1');
|
|
70
|
+
expect(chat.messages).toHaveLength(2);
|
|
71
|
+
expect(chat.messages[1].content).toBe('Response 1');
|
|
72
|
+
// Retrying replaces the assistant answer
|
|
73
|
+
await chat.retry();
|
|
74
|
+
expect(chat.messages).toHaveLength(2);
|
|
75
|
+
expect(chat.messages[0].content).toBe('Prompt 1');
|
|
76
|
+
// Clear empties conversation
|
|
77
|
+
chat.clear();
|
|
78
|
+
expect(chat.messages).toHaveLength(0);
|
|
12
79
|
});
|
|
13
80
|
});
|
|
@@ -6,3 +6,5 @@ export { default as AiThought } from './AiThought.svelte';
|
|
|
6
6
|
export type { AiThoughtProps } from './AiThought.svelte';
|
|
7
7
|
export { default as AiToolCall } from './AiToolCall.svelte';
|
|
8
8
|
export type { AiToolCallProps, ToolCallStatus } from './AiToolCall.svelte';
|
|
9
|
+
export { useAiChat } from './useAiChat.svelte';
|
|
10
|
+
export type { UseAiChatOptions, UseAiChatReturn } from './useAiChat.svelte';
|
|
@@ -2,3 +2,4 @@ export { default as AiChat } from './AiChat.svelte';
|
|
|
2
2
|
export { default as PromptBar } from './PromptBar.svelte';
|
|
3
3
|
export { default as AiThought } from './AiThought.svelte';
|
|
4
4
|
export { default as AiToolCall } from './AiToolCall.svelte';
|
|
5
|
+
export { useAiChat } from './useAiChat.svelte';
|