yaxa-svelte 2.6.0 → 2.7.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 +84 -18
- 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/index.d.ts +2 -2
- package/dist/index.js +2 -2
- 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/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
**The Intuitive Svelte UI & Solo SaaS Library**
|
|
6
6
|
_Nuxt UI v4 & Nuxt UI Pro Equivalent for SvelteKit 2 & 3 & Svelte 5 with Built-in SEO & SaaS Parity._
|
|
7
7
|
|
|
8
|
+
[](https://yaxa.vercel.app)
|
|
8
9
|
[](https://www.npmjs.com/package/yaxa-svelte)
|
|
9
10
|
[](https://www.npmjs.com/package/yaxa-svelte)
|
|
10
11
|
[](https://svelte.dev)
|
|
@@ -15,7 +16,13 @@ _Nuxt UI v4 & Nuxt UI Pro Equivalent for SvelteKit 2 & 3 & Svelte 5 with Built-i
|
|
|
15
16
|
[](SECURITY.md)
|
|
16
17
|
[](CONTRIBUTING.md)
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
<br />
|
|
20
|
+
|
|
21
|
+
[**🚀 Live Demo**](https://yaxa.vercel.app) •
|
|
22
|
+
[**📚 Documentation**](https://yaxa.vercel.app/docs) •
|
|
23
|
+
[**🎨 Theme Studio**](https://yaxa.vercel.app/theme) •
|
|
24
|
+
[**💼 SaaS Showcase**](https://yaxa.vercel.app/saas) •
|
|
25
|
+
[**⚡ Scalar API Reference**](https://yaxa.vercel.app/api/docs)
|
|
19
26
|
|
|
20
27
|
</div>
|
|
21
28
|
|
|
@@ -276,7 +283,7 @@ export const POST = yaxaEndpoint({
|
|
|
276
283
|
|
|
277
284
|
## 📜 Auto-Generated OpenAPI 3.1 & Interactive Docs (`/api/docs`)
|
|
278
285
|
|
|
279
|
-
Instantly generate an OpenAPI 3.1 specification and host interactive documentation ([Scalar](https://scalar.com/)
|
|
286
|
+
Instantly generate an OpenAPI 3.1 specification and host interactive documentation ([Scalar](https://scalar.com/) or Swagger UI) directly from your `yaxaEndpoint` routes:
|
|
280
287
|
|
|
281
288
|
```ts
|
|
282
289
|
// src/routes/api/docs/+server.ts
|
|
@@ -286,7 +293,7 @@ import { POST as createProject } from '../projects/+server';
|
|
|
286
293
|
export const GET = createApiDocsHandler({
|
|
287
294
|
title: 'Acme SaaS API',
|
|
288
295
|
version: '1.0.0',
|
|
289
|
-
theme: 'scalar', // 'scalar' (default) | 'swagger'
|
|
296
|
+
theme: 'scalar', // 'scalar' (default) | 'swagger'
|
|
290
297
|
endpoints: {
|
|
291
298
|
'/api/projects': {
|
|
292
299
|
POST: createProject
|
|
@@ -295,7 +302,7 @@ export const GET = createApiDocsHandler({
|
|
|
295
302
|
});
|
|
296
303
|
```
|
|
297
304
|
|
|
298
|
-
- **Interactive Reference UI:** Open `/api/docs` in your browser (supports `?theme=scalar` (default)
|
|
305
|
+
- **Interactive Reference UI:** Open `/api/docs` in your browser (supports `?theme=scalar` (default) or `?theme=swagger`).
|
|
299
306
|
- **Live Demo:** Try it live on [yaxa.vercel.app/api/docs](https://yaxa.vercel.app/api/docs).
|
|
300
307
|
- **Raw OpenAPI 3.1 JSON:** Request `/api/docs?format=json` or send `Accept: application/json` for SDK generation.
|
|
301
308
|
|
|
@@ -441,22 +448,81 @@ export const load = async ({ fetch }) => {
|
|
|
441
448
|
|
|
442
449
|
---
|
|
443
450
|
|
|
451
|
+
## ⚡ Runes-Native Reactive Query & Realtime RPC (`createQuery`, `createMutation`)
|
|
452
|
+
|
|
453
|
+
A zero-overhead, runes-native query and mutation cache built specifically for Svelte 5. Features request coalescing, stale-while-revalidate, window focus revalidation, optimistic updates with automatic rollback, and live SSE cache invalidation:
|
|
454
|
+
|
|
455
|
+
```svelte
|
|
456
|
+
<!-- src/routes/projects/+page.svelte -->
|
|
457
|
+
<script lang="ts">
|
|
458
|
+
import { api } from '$lib/api';
|
|
459
|
+
import { createQuery, createMutation } from 'yaxa-svelte';
|
|
460
|
+
|
|
461
|
+
let activeOrg = $state('org_123');
|
|
462
|
+
|
|
463
|
+
// 1. Reactive Query: auto-refetches when activeOrg changes
|
|
464
|
+
const projects = createQuery(() => api['/api/projects'].get({ query: { orgId: activeOrg } }), {
|
|
465
|
+
staleTimeMs: 15_000,
|
|
466
|
+
refetchOnWindowFocus: true,
|
|
467
|
+
subscribeToSse: `org:${activeOrg}/projects` // ⚡ Live SSE invalidation
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// 2. Optimistic Mutation with Automatic Rollback on RFC 7807 Error
|
|
471
|
+
const createProject = createMutation(
|
|
472
|
+
(name: string) => api['/api/projects'].post({ body: { name, orgId: activeOrg } }),
|
|
473
|
+
{
|
|
474
|
+
onMutate: (name) => {
|
|
475
|
+
const previous = projects.data;
|
|
476
|
+
projects.optimisticUpdate((curr) => [
|
|
477
|
+
{ id: 'temp-' + Date.now(), name, status: 'creating' },
|
|
478
|
+
...(curr ?? [])
|
|
479
|
+
]);
|
|
480
|
+
return { previous }; // Snapshot for rollback
|
|
481
|
+
},
|
|
482
|
+
onError: (err, _name, context) => {
|
|
483
|
+
if (context?.previous) projects.setData(context.previous);
|
|
484
|
+
toast.error(err.title, err.detail);
|
|
485
|
+
},
|
|
486
|
+
onSettled: () => projects.invalidate()
|
|
487
|
+
}
|
|
488
|
+
);
|
|
489
|
+
</script>
|
|
490
|
+
|
|
491
|
+
{#if projects.isLoading}
|
|
492
|
+
<Skeleton count={3} class="h-10 w-full" />
|
|
493
|
+
{:else if projects.data}
|
|
494
|
+
<ul>
|
|
495
|
+
{#each projects.data as project}
|
|
496
|
+
<li class={project.id.startsWith('temp-') ? 'opacity-50' : ''}>
|
|
497
|
+
{project.name}
|
|
498
|
+
</li>
|
|
499
|
+
{/each}
|
|
500
|
+
</ul>
|
|
501
|
+
{/if}
|
|
502
|
+
|
|
503
|
+
<Button onclick={() => createProject.mutate('New Project')} loading={createProject.isPending}>
|
|
504
|
+
Create Project
|
|
505
|
+
</Button>
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
444
510
|
## 📦 Subpath Exports Architecture
|
|
445
511
|
|
|
446
|
-
| Subpath | Description
|
|
447
|
-
| :---------------------- |
|
|
448
|
-
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Type-Safe Client RPC (`createApiClient`), Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.)
|
|
449
|
-
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />`
|
|
450
|
-
| `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar Docs, RFC 7807, Real-Time SSE (`yaxaSSE`, `broadcast`), Background Jobs (`enqueueJob`, `createJobRunner`), `eventBus`, Server Hooks, DB, Storage & Email
|
|
451
|
-
| `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`)
|
|
452
|
-
| `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL)
|
|
453
|
-
| `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization
|
|
454
|
-
| `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback
|
|
455
|
-
| `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators
|
|
456
|
-
| `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`)
|
|
457
|
-
| `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`)
|
|
458
|
-
| `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars
|
|
459
|
-
| `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'`
|
|
512
|
+
| Subpath | Description |
|
|
513
|
+
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
514
|
+
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Type-Safe Client RPC (`createApiClient`), Runes Query Engine (`createQuery`, `createMutation`, `QueryClient`), Theme System & Composables (`useGate`, `useUpload`, `useSSE`, `useToast`, `useClipboard`, etc.) |
|
|
515
|
+
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
|
|
516
|
+
| `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar Docs, RFC 7807, Real-Time SSE (`yaxaSSE`, `broadcast`), Background Jobs (`enqueueJob`, `createJobRunner`), `eventBus`, Server Hooks, DB, Storage & Email |
|
|
517
|
+
| `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
|
|
518
|
+
| `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
|
|
519
|
+
| `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
|
|
520
|
+
| `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
|
|
521
|
+
| `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
|
|
522
|
+
| `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
|
|
523
|
+
| `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
|
|
524
|
+
| `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
|
|
525
|
+
| `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
|
|
460
526
|
|
|
461
527
|
---
|
|
462
528
|
|
package/dist/client/index.d.ts
CHANGED
package/dist/client/index.js
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ClientResponse, ProblemDetails } from './types';
|
|
2
|
+
export type MutationFn<TData = any, TVariables = any> = (variables: TVariables) => Promise<ClientResponse<TData> | TData>;
|
|
3
|
+
export interface CreateMutationOptions<TData = any, TVariables = any, TError = ProblemDetails | Error, TContext = unknown> {
|
|
4
|
+
/**
|
|
5
|
+
* Callback fired immediately before the mutation runs.
|
|
6
|
+
* Ideal for optimistic updates; returned context will be passed to onError and onSettled.
|
|
7
|
+
*/
|
|
8
|
+
onMutate?: (variables: TVariables) => Promise<TContext> | TContext;
|
|
9
|
+
/**
|
|
10
|
+
* Callback fired when mutation succeeds.
|
|
11
|
+
*/
|
|
12
|
+
onSuccess?: (data: TData, variables: TVariables, context: TContext | undefined) => Promise<void> | void;
|
|
13
|
+
/**
|
|
14
|
+
* Callback fired when mutation fails.
|
|
15
|
+
*/
|
|
16
|
+
onError?: (error: TError, variables: TVariables, context: TContext | undefined) => Promise<void> | void;
|
|
17
|
+
/**
|
|
18
|
+
* Callback fired when mutation completes (either success or error).
|
|
19
|
+
*/
|
|
20
|
+
onSettled?: (data: TData | null, error: TError | null, variables: TVariables, context: TContext | undefined) => Promise<void> | void;
|
|
21
|
+
}
|
|
22
|
+
export interface CreateMutationResult<TData = any, TVariables = any, TError = ProblemDetails | Error> {
|
|
23
|
+
readonly data: TData | null;
|
|
24
|
+
readonly error: TError | null;
|
|
25
|
+
readonly status: 'idle' | 'pending' | 'success' | 'error';
|
|
26
|
+
readonly isPending: boolean;
|
|
27
|
+
readonly isSuccess: boolean;
|
|
28
|
+
readonly isError: boolean;
|
|
29
|
+
readonly isIdle: boolean;
|
|
30
|
+
mutate: (variables: TVariables) => Promise<TData>;
|
|
31
|
+
reset: () => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Idiomatic Svelte 5 Runes Mutation Composable.
|
|
35
|
+
* Features optimistic rollbacks, lifecycle callbacks, and RFC 7807 problem details unwrapping.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createMutation<TData = any, TVariables = void, TError = ProblemDetails | Error, TContext = unknown>(mutationFn: MutationFn<TData, TVariables>, options?: CreateMutationOptions<TData, TVariables, TError, TContext>): CreateMutationResult<TData, TVariables, TError>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks if a response is a Yaxa ClientResponse.
|
|
3
|
+
*/
|
|
4
|
+
function isClientResponse(res) {
|
|
5
|
+
return (typeof res === 'object' &&
|
|
6
|
+
res !== null &&
|
|
7
|
+
'ok' in res &&
|
|
8
|
+
'status' in res &&
|
|
9
|
+
('data' in res || 'error' in res));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Idiomatic Svelte 5 Runes Mutation Composable.
|
|
13
|
+
* Features optimistic rollbacks, lifecycle callbacks, and RFC 7807 problem details unwrapping.
|
|
14
|
+
*/
|
|
15
|
+
export function createMutation(mutationFn, options = {}) {
|
|
16
|
+
let data = $state(null);
|
|
17
|
+
let error = $state(null);
|
|
18
|
+
let status = $state('idle');
|
|
19
|
+
const isPending = $derived(status === 'pending');
|
|
20
|
+
const isSuccess = $derived(status === 'success');
|
|
21
|
+
const isError = $derived(status === 'error');
|
|
22
|
+
const isIdle = $derived(status === 'idle');
|
|
23
|
+
async function mutate(variables) {
|
|
24
|
+
status = 'pending';
|
|
25
|
+
error = null;
|
|
26
|
+
let context;
|
|
27
|
+
try {
|
|
28
|
+
if (options.onMutate) {
|
|
29
|
+
context = await options.onMutate(variables);
|
|
30
|
+
}
|
|
31
|
+
const result = await mutationFn(variables);
|
|
32
|
+
let finalData;
|
|
33
|
+
if (isClientResponse(result)) {
|
|
34
|
+
if (!result.ok) {
|
|
35
|
+
throw result.error;
|
|
36
|
+
}
|
|
37
|
+
finalData = result.data;
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
finalData = result;
|
|
41
|
+
}
|
|
42
|
+
data = finalData;
|
|
43
|
+
status = 'success';
|
|
44
|
+
await options.onSuccess?.(finalData, variables, context);
|
|
45
|
+
await options.onSettled?.(finalData, null, variables, context);
|
|
46
|
+
return finalData;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const errPayload = err;
|
|
50
|
+
error = errPayload;
|
|
51
|
+
status = 'error';
|
|
52
|
+
await options.onError?.(errPayload, variables, context);
|
|
53
|
+
await options.onSettled?.(null, errPayload, variables, context);
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function reset() {
|
|
58
|
+
data = null;
|
|
59
|
+
error = null;
|
|
60
|
+
status = 'idle';
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
get data() {
|
|
64
|
+
return data;
|
|
65
|
+
},
|
|
66
|
+
get error() {
|
|
67
|
+
return error;
|
|
68
|
+
},
|
|
69
|
+
get status() {
|
|
70
|
+
return status;
|
|
71
|
+
},
|
|
72
|
+
get isPending() {
|
|
73
|
+
return isPending;
|
|
74
|
+
},
|
|
75
|
+
get isSuccess() {
|
|
76
|
+
return isSuccess;
|
|
77
|
+
},
|
|
78
|
+
get isError() {
|
|
79
|
+
return isError;
|
|
80
|
+
},
|
|
81
|
+
get isIdle() {
|
|
82
|
+
return isIdle;
|
|
83
|
+
},
|
|
84
|
+
mutate,
|
|
85
|
+
reset
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
2
|
+
export type QueryKey = string | readonly unknown[];
|
|
3
|
+
/**
|
|
4
|
+
* Deterministically serializes a QueryKey into a cache string.
|
|
5
|
+
*/
|
|
6
|
+
export declare function hashQueryKey(key: QueryKey): string;
|
|
7
|
+
export type QueryStatus = 'idle' | 'loading' | 'success' | 'error';
|
|
8
|
+
export interface QueryCacheEntry<TData = any, TError = any> {
|
|
9
|
+
readonly key: string;
|
|
10
|
+
data: TData | null;
|
|
11
|
+
error: TError | null;
|
|
12
|
+
status: QueryStatus;
|
|
13
|
+
isFetching: boolean;
|
|
14
|
+
updatedAt: number;
|
|
15
|
+
inFlightPromise: Promise<TData | null> | null;
|
|
16
|
+
subscribers: SvelteSet<() => void>;
|
|
17
|
+
invalidate: () => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export interface InvalidateOptions {
|
|
20
|
+
key?: QueryKey;
|
|
21
|
+
exact?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Runes-native Query Client managing cache entries, request coalescing,
|
|
25
|
+
* and real-time invalidation.
|
|
26
|
+
*/
|
|
27
|
+
export declare class QueryClient {
|
|
28
|
+
private entries;
|
|
29
|
+
/**
|
|
30
|
+
* Retrieves or initializes a cache entry for a given key string.
|
|
31
|
+
*/
|
|
32
|
+
getOrCreateEntry<TData = any, TError = any>(keyHash: string, initialData?: TData): QueryCacheEntry<TData, TError>;
|
|
33
|
+
/**
|
|
34
|
+
* Retrieves current data for a query key from the cache.
|
|
35
|
+
*/
|
|
36
|
+
getQueryData<TData = any>(key: QueryKey): TData | null;
|
|
37
|
+
/**
|
|
38
|
+
* Manually sets or optimistically updates data for a query key.
|
|
39
|
+
*/
|
|
40
|
+
setQueryData<TData = any>(key: QueryKey, updater: TData | ((previous: TData | null) => TData)): TData;
|
|
41
|
+
/**
|
|
42
|
+
* Invalidates queries matching a key or filter, triggering active subscribers to refetch.
|
|
43
|
+
*/
|
|
44
|
+
invalidateQueries(filter?: InvalidateOptions | QueryKey): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Clears the cache completely.
|
|
47
|
+
*/
|
|
48
|
+
clear(): void;
|
|
49
|
+
}
|
|
50
|
+
export declare function getQueryClient(): QueryClient;
|
|
51
|
+
export declare function setQueryClient(client: QueryClient): void;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministically serializes a QueryKey into a cache string.
|
|
4
|
+
*/
|
|
5
|
+
export function hashQueryKey(key) {
|
|
6
|
+
if (typeof key === 'string')
|
|
7
|
+
return key;
|
|
8
|
+
try {
|
|
9
|
+
return JSON.stringify(key, (_, val) => {
|
|
10
|
+
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
|
11
|
+
// Sort object keys deterministically
|
|
12
|
+
return Object.keys(val)
|
|
13
|
+
.sort()
|
|
14
|
+
.reduce((acc, k) => {
|
|
15
|
+
acc[k] = val[k];
|
|
16
|
+
return acc;
|
|
17
|
+
}, {});
|
|
18
|
+
}
|
|
19
|
+
return val;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return String(key);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Runes-native Query Client managing cache entries, request coalescing,
|
|
28
|
+
* and real-time invalidation.
|
|
29
|
+
*/
|
|
30
|
+
export class QueryClient {
|
|
31
|
+
entries = new SvelteMap();
|
|
32
|
+
/**
|
|
33
|
+
* Retrieves or initializes a cache entry for a given key string.
|
|
34
|
+
*/
|
|
35
|
+
getOrCreateEntry(keyHash, initialData) {
|
|
36
|
+
const entry = this.entries.get(keyHash);
|
|
37
|
+
if (!entry) {
|
|
38
|
+
let data = $state(initialData !== undefined ? initialData : null);
|
|
39
|
+
let error = $state(null);
|
|
40
|
+
let status = $state(initialData !== undefined ? 'success' : 'idle');
|
|
41
|
+
let isFetching = $state(false);
|
|
42
|
+
let updatedAt = $state(initialData !== undefined ? Date.now() : 0);
|
|
43
|
+
const subscribers = new SvelteSet();
|
|
44
|
+
const newEntry = {
|
|
45
|
+
key: keyHash,
|
|
46
|
+
get data() {
|
|
47
|
+
return data;
|
|
48
|
+
},
|
|
49
|
+
set data(val) {
|
|
50
|
+
data = val;
|
|
51
|
+
},
|
|
52
|
+
get error() {
|
|
53
|
+
return error;
|
|
54
|
+
},
|
|
55
|
+
set error(val) {
|
|
56
|
+
error = val;
|
|
57
|
+
},
|
|
58
|
+
get status() {
|
|
59
|
+
return status;
|
|
60
|
+
},
|
|
61
|
+
set status(val) {
|
|
62
|
+
status = val;
|
|
63
|
+
},
|
|
64
|
+
get isFetching() {
|
|
65
|
+
return isFetching;
|
|
66
|
+
},
|
|
67
|
+
set isFetching(val) {
|
|
68
|
+
isFetching = val;
|
|
69
|
+
},
|
|
70
|
+
get updatedAt() {
|
|
71
|
+
return updatedAt;
|
|
72
|
+
},
|
|
73
|
+
set updatedAt(val) {
|
|
74
|
+
updatedAt = val;
|
|
75
|
+
},
|
|
76
|
+
inFlightPromise: null,
|
|
77
|
+
subscribers,
|
|
78
|
+
invalidate: async () => {
|
|
79
|
+
for (const notify of Array.from(subscribers)) {
|
|
80
|
+
try {
|
|
81
|
+
notify();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Ignored
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
this.entries.set(keyHash, newEntry);
|
|
90
|
+
return newEntry;
|
|
91
|
+
}
|
|
92
|
+
// If entry exists but has no data and initialData was provided, populate it
|
|
93
|
+
if (entry.data === null && initialData !== undefined && entry.status === 'idle') {
|
|
94
|
+
entry.data = initialData;
|
|
95
|
+
entry.status = 'success';
|
|
96
|
+
entry.updatedAt = Date.now();
|
|
97
|
+
}
|
|
98
|
+
return entry;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Retrieves current data for a query key from the cache.
|
|
102
|
+
*/
|
|
103
|
+
getQueryData(key) {
|
|
104
|
+
const hash = hashQueryKey(key);
|
|
105
|
+
const entry = this.entries.get(hash);
|
|
106
|
+
return entry ? entry.data : null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Manually sets or optimistically updates data for a query key.
|
|
110
|
+
*/
|
|
111
|
+
setQueryData(key, updater) {
|
|
112
|
+
const hash = hashQueryKey(key);
|
|
113
|
+
const entry = this.getOrCreateEntry(hash);
|
|
114
|
+
const nextData = typeof updater === 'function'
|
|
115
|
+
? updater(entry.data)
|
|
116
|
+
: updater;
|
|
117
|
+
entry.data = nextData;
|
|
118
|
+
entry.error = null;
|
|
119
|
+
entry.status = 'success';
|
|
120
|
+
entry.updatedAt = Date.now();
|
|
121
|
+
return nextData;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Invalidates queries matching a key or filter, triggering active subscribers to refetch.
|
|
125
|
+
*/
|
|
126
|
+
async invalidateQueries(filter) {
|
|
127
|
+
if (!filter) {
|
|
128
|
+
for (const entry of this.entries.values()) {
|
|
129
|
+
await entry.invalidate();
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const isKey = typeof filter === 'string' || Array.isArray(filter);
|
|
134
|
+
const key = isKey ? filter : filter.key;
|
|
135
|
+
const exact = isKey ? false : Boolean(filter.exact);
|
|
136
|
+
if (!key) {
|
|
137
|
+
for (const entry of this.entries.values()) {
|
|
138
|
+
await entry.invalidate();
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const targetHash = hashQueryKey(key);
|
|
143
|
+
for (const [hash, entry] of this.entries.entries()) {
|
|
144
|
+
if (exact) {
|
|
145
|
+
if (hash === targetHash) {
|
|
146
|
+
await entry.invalidate();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
if (hash.startsWith(targetHash) || hash === targetHash) {
|
|
151
|
+
await entry.invalidate();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Clears the cache completely.
|
|
158
|
+
*/
|
|
159
|
+
clear() {
|
|
160
|
+
this.entries.clear();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Global default query client singleton.
|
|
165
|
+
*/
|
|
166
|
+
let globalClient = new QueryClient();
|
|
167
|
+
export function getQueryClient() {
|
|
168
|
+
return globalClient;
|
|
169
|
+
}
|
|
170
|
+
export function setQueryClient(client) {
|
|
171
|
+
globalClient = client;
|
|
172
|
+
}
|
|
@@ -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
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -207,7 +207,7 @@ export { default as FeatureGrid, featureGridVariants } from './components/blocks
|
|
|
207
207
|
export type { FeatureGridProps, FeatureItem } from './components/blocks/FeatureGrid.svelte';
|
|
208
208
|
export { createMockSiteConfig, createMockUser, createTestWrapperProps } from './testing';
|
|
209
209
|
export type { YaxaTestOptions } from './testing';
|
|
210
|
-
export { createApiClient, interpolatePath, serializeQueryParams } from './client';
|
|
211
|
-
export type { ApiClient, ApiClientConfig, ClientRequestOptions, ClientResponse, InferEndpoint, RouteMethods, ProblemDetails as ClientProblemDetails } from './client';
|
|
210
|
+
export { createApiClient, interpolatePath, serializeQueryParams, createQuery, createMutation, QueryClient, getQueryClient, setQueryClient, hashQueryKey } from './client';
|
|
211
|
+
export type { ApiClient, ApiClientConfig, ClientRequestOptions, ClientResponse, InferEndpoint, RouteMethods, ProblemDetails as ClientProblemDetails, QueryKey, QueryStatus, QueryCacheEntry, CreateQueryOptions, CreateQueryResult, CreateMutationOptions, CreateMutationResult, QueryFn, MutationFn } from './client';
|
|
212
212
|
export { cn, tv } from './utils/cn';
|
|
213
213
|
export type { VariantProps } from './utils/cn';
|
package/dist/index.js
CHANGED
|
@@ -138,7 +138,7 @@ export { default as FAQ, faqVariants } from './components/blocks/FAQ.svelte';
|
|
|
138
138
|
export { default as FeatureGrid, featureGridVariants } from './components/blocks/FeatureGrid.svelte';
|
|
139
139
|
// Testing Utilities
|
|
140
140
|
export { createMockSiteConfig, createMockUser, createTestWrapperProps } from './testing';
|
|
141
|
-
// Type-Safe Client RPC
|
|
142
|
-
export { createApiClient, interpolatePath, serializeQueryParams } from './client';
|
|
141
|
+
// Type-Safe Client RPC & Runes Query Engine
|
|
142
|
+
export { createApiClient, interpolatePath, serializeQueryParams, createQuery, createMutation, QueryClient, getQueryClient, setQueryClient, hashQueryKey } from './client';
|
|
143
143
|
// Utilities
|
|
144
144
|
export { cn, tv } from './utils/cn';
|
|
@@ -2,6 +2,6 @@ import type { RequestHandler } from '@sveltejs/kit';
|
|
|
2
2
|
import type { ApiDocsOptions } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* Creates a turnkey SvelteKit RequestHandler that generates an interactive OpenAPI 3.1 documentation
|
|
5
|
-
* page (Scalar
|
|
5
|
+
* page (Scalar or Swagger UI) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
|
|
6
6
|
*/
|
|
7
7
|
export declare function createApiDocsHandler(options?: ApiDocsOptions): RequestHandler;
|
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
import { generateOpenApiSpec } from './generator';
|
|
2
|
-
function
|
|
2
|
+
function renderHeadMeta({ title, description, canonicalUrl, ogImage }) {
|
|
3
|
+
const descTag = description
|
|
4
|
+
? `<meta name="description" content="${description.replace(/"/g, '"')}" />`
|
|
5
|
+
: '';
|
|
6
|
+
const canonicalTag = canonicalUrl ? `<link rel="canonical" href="${canonicalUrl}" />` : '';
|
|
7
|
+
const ogImageTag = ogImage ? `<meta property="og:image" content="${ogImage}" />` : '';
|
|
8
|
+
const ogDescTag = description
|
|
9
|
+
? `<meta property="og:description" content="${description.replace(/"/g, '"')}" />`
|
|
10
|
+
: '';
|
|
11
|
+
const ogUrlTag = canonicalUrl ? `<meta property="og:url" content="${canonicalUrl}" />` : '';
|
|
12
|
+
return ` <title>${title}</title>
|
|
13
|
+
${descTag}
|
|
14
|
+
<meta name="robots" content="index, follow" />
|
|
15
|
+
${canonicalTag}
|
|
16
|
+
<meta property="og:type" content="website" />
|
|
17
|
+
<meta property="og:title" content="${title.replace(/"/g, '"')}" />
|
|
18
|
+
${ogDescTag}
|
|
19
|
+
${ogUrlTag}
|
|
20
|
+
${ogImageTag}
|
|
21
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
22
|
+
<meta name="twitter:title" content="${title.replace(/"/g, '"')}" />
|
|
23
|
+
${ogDescTag}
|
|
24
|
+
${ogImageTag}`.trim();
|
|
25
|
+
}
|
|
26
|
+
function renderScalarHtml(specJson, meta) {
|
|
3
27
|
return `<!doctype html>
|
|
4
28
|
<html lang="en">
|
|
5
29
|
<head>
|
|
6
30
|
<meta charset="utf-8" />
|
|
7
31
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
-
|
|
32
|
+
${renderHeadMeta(meta)}
|
|
9
33
|
<style>
|
|
10
34
|
body { margin: 0; background-color: #0f172a; }
|
|
11
35
|
</style>
|
|
@@ -18,13 +42,13 @@ function renderScalarHtml(specJson, title) {
|
|
|
18
42
|
</body>
|
|
19
43
|
</html>`;
|
|
20
44
|
}
|
|
21
|
-
function renderSwaggerHtml(specJson,
|
|
45
|
+
function renderSwaggerHtml(specJson, meta) {
|
|
22
46
|
return `<!DOCTYPE html>
|
|
23
47
|
<html lang="en">
|
|
24
48
|
<head>
|
|
25
49
|
<meta charset="utf-8" />
|
|
26
50
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
27
|
-
|
|
51
|
+
${renderHeadMeta(meta)}
|
|
28
52
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
29
53
|
<style>
|
|
30
54
|
body { margin: 0; background: #fafafa; }
|
|
@@ -48,34 +72,9 @@ function renderSwaggerHtml(specJson, title) {
|
|
|
48
72
|
</body>
|
|
49
73
|
</html>`;
|
|
50
74
|
}
|
|
51
|
-
function renderRedocHtml(specJson, title) {
|
|
52
|
-
return `<!DOCTYPE html>
|
|
53
|
-
<html lang="en">
|
|
54
|
-
<head>
|
|
55
|
-
<meta charset="utf-8" />
|
|
56
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
57
|
-
<title>${title}</title>
|
|
58
|
-
<style>
|
|
59
|
-
body { margin: 0; padding: 0; }
|
|
60
|
-
</style>
|
|
61
|
-
</head>
|
|
62
|
-
<body>
|
|
63
|
-
<div id="redoc-container"></div>
|
|
64
|
-
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
|
65
|
-
<script>
|
|
66
|
-
Redoc.init(${specJson}, {
|
|
67
|
-
scrollYOffset: 50,
|
|
68
|
-
theme: {
|
|
69
|
-
colors: { primary: { main: '#3b82f6' } }
|
|
70
|
-
}
|
|
71
|
-
}, document.getElementById('redoc-container'));
|
|
72
|
-
</script>
|
|
73
|
-
</body>
|
|
74
|
-
</html>`;
|
|
75
|
-
}
|
|
76
75
|
/**
|
|
77
76
|
* Creates a turnkey SvelteKit RequestHandler that generates an interactive OpenAPI 3.1 documentation
|
|
78
|
-
* page (Scalar
|
|
77
|
+
* page (Scalar or Swagger UI) or returns raw OpenAPI 3.1 JSON when ?format=json is requested.
|
|
79
78
|
*/
|
|
80
79
|
export function createApiDocsHandler(options = {}) {
|
|
81
80
|
const defaultTheme = options.theme || 'scalar';
|
|
@@ -83,7 +82,7 @@ export function createApiDocsHandler(options = {}) {
|
|
|
83
82
|
const { url, request } = event;
|
|
84
83
|
const format = url.searchParams.get('format');
|
|
85
84
|
const themeQuery = url.searchParams.get('theme');
|
|
86
|
-
const activeTheme = themeQuery && ['scalar', 'swagger'
|
|
85
|
+
const activeTheme = themeQuery && ['scalar', 'swagger'].includes(themeQuery) ? themeQuery : defaultTheme;
|
|
87
86
|
const acceptHeader = request.headers.get('accept') || '';
|
|
88
87
|
const spec = generateOpenApiSpec(options);
|
|
89
88
|
// If caller requests JSON via query param, accept header, or path ending in .json
|
|
@@ -99,16 +98,17 @@ export function createApiDocsHandler(options = {}) {
|
|
|
99
98
|
}
|
|
100
99
|
// Render Interactive HTML Docs
|
|
101
100
|
const specJson = JSON.stringify(spec);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
101
|
+
const canonicalUrl = `${url.origin}${url.pathname}`;
|
|
102
|
+
const ogImage = `${url.origin}/api/og?title=${encodeURIComponent(spec.info.title)}&badge=API+Docs`;
|
|
103
|
+
const meta = {
|
|
104
|
+
title: spec.info.title,
|
|
105
|
+
description: spec.info.description,
|
|
106
|
+
canonicalUrl,
|
|
107
|
+
ogImage
|
|
108
|
+
};
|
|
109
|
+
const html = activeTheme === 'swagger'
|
|
110
|
+
? renderSwaggerHtml(specJson, meta)
|
|
111
|
+
: renderScalarHtml(specJson, meta);
|
|
112
112
|
return new Response(html, {
|
|
113
113
|
headers: {
|
|
114
114
|
'content-type': 'text/html; charset=utf-8',
|
|
@@ -200,21 +200,20 @@ describe('createApiDocsHandler Endpoint', () => {
|
|
|
200
200
|
expect(html).toContain('swagger-ui-dist');
|
|
201
201
|
expect(html).toContain('SwaggerUIBundle');
|
|
202
202
|
});
|
|
203
|
-
it('
|
|
203
|
+
it('falls back to scalar HTML when an unrecognized theme query is passed', async () => {
|
|
204
204
|
const handler = createApiDocsHandler({
|
|
205
|
-
title: '
|
|
206
|
-
theme: '
|
|
205
|
+
title: 'Fallback Docs Test',
|
|
206
|
+
theme: 'scalar'
|
|
207
207
|
});
|
|
208
208
|
const event = createMockRequestEvent({
|
|
209
|
-
url: 'http://localhost:5173/api/docs'
|
|
209
|
+
url: 'http://localhost:5173/api/docs?theme=unknown'
|
|
210
210
|
});
|
|
211
211
|
const response = await handler(event);
|
|
212
212
|
expect(response.status).toBe(200);
|
|
213
213
|
expect(response.headers.get('content-type')).toContain('text/html');
|
|
214
214
|
const html = await response.text();
|
|
215
|
-
expect(html).toContain('<title>
|
|
216
|
-
expect(html).toContain('
|
|
217
|
-
expect(html).toContain('redoc-container');
|
|
215
|
+
expect(html).toContain('<title>Fallback Docs Test</title>');
|
|
216
|
+
expect(html).toContain('@scalar/api-reference');
|
|
218
217
|
});
|
|
219
218
|
it('allows overriding theme dynamically via ?theme query parameter', async () => {
|
|
220
219
|
const handler = createApiDocsHandler({
|
|
@@ -107,13 +107,12 @@ export interface GenerateOpenApiOptions {
|
|
|
107
107
|
endpoints?: EndpointMap;
|
|
108
108
|
registry?: any;
|
|
109
109
|
}
|
|
110
|
-
export type ApiDocsTheme = 'scalar' | 'swagger'
|
|
110
|
+
export type ApiDocsTheme = 'scalar' | 'swagger';
|
|
111
111
|
export interface ApiDocsOptions extends GenerateOpenApiOptions {
|
|
112
112
|
/**
|
|
113
113
|
* Interactive UI theme:
|
|
114
|
-
* - 'scalar' (default): Modern, dark-mode native, high contrast developer reference.
|
|
114
|
+
* - 'scalar' (default): Modern, dark-mode native, high contrast developer reference with interactive runner.
|
|
115
115
|
* - 'swagger': Classic Swagger UI with interactive Try-It-Out console.
|
|
116
|
-
* - 'redoc': Clean 3-panel technical documentation format.
|
|
117
116
|
*/
|
|
118
117
|
theme?: ApiDocsTheme;
|
|
119
118
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yaxa-svelte",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "The Intuitive Svelte UI Library — Nuxt UI v4 equivalent for SvelteKit 2 & 3 & Svelte 5 with automated SEO, dynamic OpenGraph cards, sitemaps, robots.txt, and Tailwind CSS v4.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://yaxa.vercel.app",
|