@svadmin/core 0.50.0 → 0.52.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 +21 -4
- package/src/access-control-contract.ts +250 -0
- package/src/adapters/casbin.ts +20 -14
- package/src/adapters/casl.ts +22 -19
- package/src/admin-tool.ts +7 -7
- package/src/audit-contract.ts +105 -0
- package/src/audit.ts +88 -128
- package/src/auth-hooks.svelte.ts +593 -261
- package/src/auth-mutation-contract.ts +143 -0
- package/src/auth-query-contract.ts +97 -0
- package/src/captured-mutation.svelte.ts +45 -0
- package/src/chatProvider.svelte.ts +1 -1
- package/src/command-contract.ts +195 -0
- package/src/context.svelte.ts +217 -205
- package/src/contract-context.svelte.ts +37 -0
- package/src/contract-form.svelte.ts +566 -0
- package/src/create-contract.ts +56 -0
- package/src/create-hooks.svelte.ts +347 -0
- package/src/create-many-contract.ts +90 -0
- package/src/create-many-hooks.svelte.ts +396 -0
- package/src/data-transfer.svelte.ts +1 -215
- package/src/defined-options.ts +50 -0
- package/src/delete-contract.ts +50 -0
- package/src/delete-hooks.svelte.ts +403 -0
- package/src/delete-many-contract.ts +60 -0
- package/src/delete-many-hooks.svelte.ts +402 -0
- package/src/enterprise.ts +4 -4
- package/src/export-format.ts +16 -14
- package/src/export-hooks.svelte.ts +191 -0
- package/src/feature-gate-contract.ts +53 -0
- package/src/hook-utils.svelte.ts +53 -105
- package/src/hooks.svelte.ts +19 -776
- package/src/http.ts +88 -87
- package/src/i18n.svelte.ts +56 -2
- package/src/import-contract.ts +106 -0
- package/src/import-hooks.svelte.ts +310 -0
- package/src/index.ts +77 -30
- package/src/inferencer-graphql.ts +7 -6
- package/src/infinite-query.svelte.ts +228 -0
- package/src/invalidation-contract.ts +26 -0
- package/src/live-sse.ts +91 -127
- package/src/live-subscription.svelte.ts +130 -0
- package/src/live-transport.ts +127 -0
- package/src/live-websocket.ts +85 -118
- package/src/live.svelte.ts +66 -68
- package/src/notification.svelte.ts +17 -15
- package/src/permission-catalog.ts +121 -0
- package/src/permission-hints-contract.ts +48 -0
- package/src/permissions.svelte.ts +112 -59
- package/src/plain-data.ts +61 -0
- package/src/provider-bundle.ts +85 -79
- package/src/provider-response.ts +35 -0
- package/src/query-hooks.svelte.ts +241 -288
- package/src/query-invalidation.ts +172 -0
- package/src/query-keys.ts +221 -110
- package/src/query-session.svelte.ts +216 -0
- package/src/query-snapshot.ts +144 -0
- package/src/reactive-projection.ts +53 -0
- package/src/record-decoder.ts +63 -0
- package/src/resource-binding.svelte.ts +19 -0
- package/src/resource-contract.ts +595 -0
- package/src/resource-mutation-types.ts +30 -0
- package/src/resource-schemas.ts +371 -0
- package/src/resource-transport-config.ts +156 -0
- package/src/route-parsing.ts +11 -11
- package/src/router-provider.ts +64 -59
- package/src/router.ts +8 -3
- package/src/routing-hooks.svelte.ts +90 -62
- package/src/schema-validation.ts +92 -0
- package/src/schema.ts +13 -0
- package/src/select-options.ts +95 -0
- package/src/select-query.svelte.ts +191 -0
- package/src/session-query.svelte.ts +88 -0
- package/src/strict-command-hooks.svelte.ts +333 -0
- package/src/strict-hooks.svelte.ts +198 -0
- package/src/table-hooks.svelte.ts +180 -174
- package/src/table-state.svelte.ts +117 -117
- package/src/task-contract.ts +87 -0
- package/src/task-hooks.svelte.ts +193 -149
- package/src/task-provider.ts +115 -0
- package/src/task-subscription.ts +132 -0
- package/src/theme.svelte.ts +155 -154
- package/src/toast.svelte.ts +38 -37
- package/src/types.ts +55 -108
- package/src/unsafe.ts +7 -0
- package/src/update-contract.ts +65 -0
- package/src/update-hooks.svelte.ts +347 -0
- package/src/update-many-contract.ts +79 -0
- package/src/update-many-hooks.svelte.ts +373 -0
- package/src/url-sync.ts +11 -10
- package/src/useCan.ts +96 -31
- package/src/useParsed.svelte.ts +18 -14
- package/src/useStepsForm.svelte.ts +123 -63
- package/src/utility-hooks.svelte.ts +101 -158
- package/src/form-hooks.svelte.ts +0 -889
- package/src/mutation-hooks.svelte.ts +0 -561
- package/src/schema-form.ts +0 -31
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { createInfiniteQuery, type InfiniteData, type InfiniteQueryObserverBaseResult, type QueryObserverResult,
|
|
2
|
+
type RefetchOptions, type FetchNextPageOptions, type FetchPreviousPageOptions } from '@tanstack/svelte-query';
|
|
3
|
+
import { tick } from 'svelte';
|
|
4
|
+
import { Type } from '@sinclair/typebox';
|
|
5
|
+
import { definedOptions } from './defined-options';
|
|
6
|
+
import { getAdminOptions } from './options.svelte';
|
|
7
|
+
import { useParsed } from './useParsed.svelte';
|
|
8
|
+
import { captureContractContext, type ContractOptions } from './contract-context.svelte';
|
|
9
|
+
import { createOvertimeTracker, createLiveSubscription, type NotificationConfig, type OvertimeOptions,
|
|
10
|
+
type LiveSubscriptionParams } from './hook-utils.svelte';
|
|
11
|
+
import { HttpError, type BaseRecord, type GetListResult, type Pagination, type Sort, type Filter, type KnownResources } from './types';
|
|
12
|
+
import { decodeBaseRecord, decodeListResult, rejectProviderResponse, type RecordDecoder } from './record-decoder';
|
|
13
|
+
import { captureQueryProvider, snapshotListParams } from './query-snapshot';
|
|
14
|
+
import type { LiveMode, LiveEvent } from './live.svelte';
|
|
15
|
+
import type { QueryKey } from './query-keys';
|
|
16
|
+
import { snapshotPlainData } from './plain-data';
|
|
17
|
+
import { checkExact } from './schema-validation';
|
|
18
|
+
import {
|
|
19
|
+
createQuerySession, createQueryResultView, pendingQueryResult, querySessionError, supersededQuerySession,
|
|
20
|
+
} from './query-session.svelte';
|
|
21
|
+
|
|
22
|
+
export interface UseInfiniteListOptions extends ContractOptions {
|
|
23
|
+
resource?: KnownResources;
|
|
24
|
+
pagination?: Pagination;
|
|
25
|
+
sorters?: Sort[];
|
|
26
|
+
filters?: Filter[];
|
|
27
|
+
meta?: Record<string, unknown>;
|
|
28
|
+
dataProviderName?: string;
|
|
29
|
+
queryOptions?: { staleTime?: number; enabled?: boolean };
|
|
30
|
+
successNotification?: NotificationConfig;
|
|
31
|
+
errorNotification?: NotificationConfig;
|
|
32
|
+
liveMode?: LiveMode;
|
|
33
|
+
onLiveEvent?: (event: LiveEvent) => void;
|
|
34
|
+
overtimeOptions?: OvertimeOptions;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const dataSchema = Type.Object({
|
|
38
|
+
pages: Type.Array(Type.Unknown(), { minItems: 1 }),
|
|
39
|
+
pageParams: Type.Array(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), { minItems: 1 }),
|
|
40
|
+
}, { additionalProperties: false });
|
|
41
|
+
const flagsSchema = Type.Object({
|
|
42
|
+
hasNextPage: Type.Boolean(), hasPreviousPage: Type.Boolean(),
|
|
43
|
+
isFetchNextPageError: Type.Boolean(), isFetchingNextPage: Type.Boolean(),
|
|
44
|
+
isFetchPreviousPageError: Type.Boolean(), isFetchingPreviousPage: Type.Boolean(),
|
|
45
|
+
}, { additionalProperties: false });
|
|
46
|
+
|
|
47
|
+
type Pages<T extends BaseRecord> = InfiniteData<GetListResult<T>, number>;
|
|
48
|
+
type InfiniteOutcome<T> =
|
|
49
|
+
| { status: 'pending'; data: undefined; error: null; isPending: true; isError: false; isSuccess: false;
|
|
50
|
+
isLoading: boolean; isLoadingError: false; isRefetchError: false; isPlaceholderData: false;
|
|
51
|
+
isFetchNextPageError: false; isFetchPreviousPageError: false }
|
|
52
|
+
| { status: 'success'; data: T; error: null; isPending: false; isError: false; isSuccess: true;
|
|
53
|
+
isLoading: false; isLoadingError: false; isRefetchError: false; isPlaceholderData: boolean;
|
|
54
|
+
isFetchNextPageError: false; isFetchPreviousPageError: false }
|
|
55
|
+
| { status: 'error'; data: undefined; error: unknown; isPending: false; isError: true; isSuccess: false;
|
|
56
|
+
isLoading: false; isLoadingError: true; isRefetchError: false; isPlaceholderData: false;
|
|
57
|
+
isFetchNextPageError: boolean; isFetchPreviousPageError: boolean }
|
|
58
|
+
| ({ status: 'error'; data: T; error: unknown; isPending: false; isError: true; isSuccess: false;
|
|
59
|
+
isLoading: false; isLoadingError: false; isPlaceholderData: false } & (
|
|
60
|
+
| { isRefetchError: true; isFetchNextPageError: false; isFetchPreviousPageError: false }
|
|
61
|
+
| { isRefetchError: false; isFetchNextPageError: true; isFetchPreviousPageError: false }
|
|
62
|
+
| { isRefetchError: false; isFetchNextPageError: false; isFetchPreviousPageError: true }
|
|
63
|
+
));
|
|
64
|
+
|
|
65
|
+
/** The installed observer retains data on paging errors without setting isRefetchError. */
|
|
66
|
+
export type InfiniteListResult<T extends BaseRecord> =
|
|
67
|
+
Omit<InfiniteQueryObserverBaseResult<Pages<T>, unknown>, keyof InfiniteOutcome<Pages<T>> | 'refetch' | 'fetchNextPage' | 'fetchPreviousPage'>
|
|
68
|
+
& InfiniteOutcome<Pages<T>> & {
|
|
69
|
+
refetch: (settings?: RefetchOptions) => Promise<InfiniteListResult<T>>;
|
|
70
|
+
fetchNextPage: (settings?: FetchNextPageOptions) => Promise<InfiniteListResult<T>>;
|
|
71
|
+
fetchPreviousPage: (settings?: FetchPreviousPageOptions) => Promise<InfiniteListResult<T>>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function decodeInfiniteResult<T extends BaseRecord>(input: unknown, decode: RecordDecoder<T>): InfiniteData<GetListResult<T>, number> {
|
|
75
|
+
try {
|
|
76
|
+
const candidate = snapshotPlainData(input);
|
|
77
|
+
if (checkExact(dataSchema, candidate) && candidate.pages.length === candidate.pageParams.length &&
|
|
78
|
+
candidate.pageParams.every((page, index) => page === index + 1)) {
|
|
79
|
+
return { pages: candidate.pages.map(page => decodeListResult(page, decode)), pageParams: [...candidate.pageParams] };
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Malformed pages and reflection errors cannot become a typed page collection.
|
|
83
|
+
}
|
|
84
|
+
return rejectProviderResponse();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useInfiniteList(options: UseInfiniteListOptions = {}) {
|
|
88
|
+
return createInfiniteListQuery(options, () => decodeBaseRecord);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createInfiniteListQuery<TData extends BaseRecord>(
|
|
92
|
+
options: UseInfiniteListOptions, getDecoder: () => RecordDecoder<TData>,
|
|
93
|
+
) {
|
|
94
|
+
const context = captureContractContext(options);
|
|
95
|
+
const parsed = useParsed();
|
|
96
|
+
const adminOptions = getAdminOptions();
|
|
97
|
+
type Data = Pages<TData>;
|
|
98
|
+
type Result = InfiniteListResult<TData>;
|
|
99
|
+
const session = createQuerySession(context, () => {
|
|
100
|
+
const resource = options.resource ?? parsed.resource ?? '';
|
|
101
|
+
const captured = captureQueryProvider(context, {
|
|
102
|
+
resource, ...definedOptions({ dataProviderName: options.dataProviderName, contract: options.contract, meta: options.meta }),
|
|
103
|
+
});
|
|
104
|
+
const params = snapshotListParams({
|
|
105
|
+
resource, ...definedOptions({
|
|
106
|
+
pagination: { ...options.pagination, current: 1, pageSize: options.pagination?.pageSize ?? 10 },
|
|
107
|
+
sorters: options.sorters, filters: options.filters, meta: captured.meta,
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
resource, captured, params, decode: getDecoder(),
|
|
112
|
+
queryKey: context.queryKeys(resource, options.dataProviderName).data.infiniteList(resource, { ...params, source: captured.source }),
|
|
113
|
+
enabled: options.queryOptions?.enabled ?? true,
|
|
114
|
+
staleTime: options.queryOptions?.staleTime ?? adminOptions.reactQuery?.staleTime,
|
|
115
|
+
successNotification: options.successNotification, errorNotification: options.errorNotification,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
type Origin = typeof session.options;
|
|
119
|
+
const raw = createInfiniteQuery<GetListResult<TData>, unknown, Data, QueryKey, number>(() => {
|
|
120
|
+
const origin = session.options;
|
|
121
|
+
return {
|
|
122
|
+
queryKey: origin.queryKey, initialPageParam: 1,
|
|
123
|
+
queryFn: ({ pageParam }) => session.run(origin, async () => {
|
|
124
|
+
if (typeof pageParam !== 'number' || !Number.isSafeInteger(pageParam) || pageParam < 1) {
|
|
125
|
+
throw new HttpError('Invalid page number', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
|
|
126
|
+
}
|
|
127
|
+
const result = await origin.captured.provider.getList(snapshotListParams({
|
|
128
|
+
...origin.params, pagination: { ...origin.params.pagination, current: pageParam },
|
|
129
|
+
}));
|
|
130
|
+
return decodeListResult(result, origin.decode);
|
|
131
|
+
}),
|
|
132
|
+
getNextPageParam: (_last, pages, _lastParam, pageParams) => {
|
|
133
|
+
if (!session.sessionCurrent(origin)) return undefined;
|
|
134
|
+
try {
|
|
135
|
+
const checked = decodeInfiniteResult({ pages, pageParams }, origin.decode);
|
|
136
|
+
const last = checked.pages.at(-1);
|
|
137
|
+
if (!last || last.data.length === 0 || checked.pages.reduce((total, page) => total + page.data.length, 0) >= last.total) return undefined;
|
|
138
|
+
const next = checked.pages.length + 1;
|
|
139
|
+
return Number.isSafeInteger(next) ? next : undefined;
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
enabled: origin.enabled && origin.auth.available,
|
|
145
|
+
...definedOptions({ staleTime: origin.staleTime }),
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
type Operations = {
|
|
150
|
+
refetch: (settings?: RefetchOptions) => Promise<Result>;
|
|
151
|
+
fetchNextPage: (settings?: FetchNextPageOptions) => Promise<Result>;
|
|
152
|
+
fetchPreviousPage: (settings?: FetchPreviousPageOptions) => Promise<Result>;
|
|
153
|
+
};
|
|
154
|
+
function pending(operations: Operations): Result {
|
|
155
|
+
return {
|
|
156
|
+
...pendingQueryResult<Data, Operations['refetch']>(operations.refetch), ...operations,
|
|
157
|
+
hasNextPage: false, hasPreviousPage: false,
|
|
158
|
+
isFetchNextPageError: false, isFetchingNextPage: false,
|
|
159
|
+
isFetchPreviousPageError: false, isFetchingPreviousPage: false,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function project(origin: Origin, result: QueryObserverResult<Data, unknown>): Result {
|
|
163
|
+
async function run(request: () => Promise<QueryObserverResult<Data, unknown>>): Promise<Result> {
|
|
164
|
+
if (!session.current(origin)) return pending(operations);
|
|
165
|
+
await tick();
|
|
166
|
+
if (!session.current(origin)) return pending(operations);
|
|
167
|
+
try {
|
|
168
|
+
return project(origin, await request());
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw session.current(origin) ? querySessionError(error) : supersededQuerySession();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const operations: Operations = {
|
|
174
|
+
refetch: settings => run(() => raw.refetch(settings)),
|
|
175
|
+
fetchNextPage: settings => run(() => raw.fetchNextPage(settings)),
|
|
176
|
+
fetchPreviousPage: settings => run(() => raw.fetchPreviousPage(settings)),
|
|
177
|
+
};
|
|
178
|
+
if (!session.current(origin)) return pending(operations);
|
|
179
|
+
const state = session.client.getQueryState(origin.queryKey);
|
|
180
|
+
if (!state || state.data !== result.data || state.error !== result.error) return pending(operations);
|
|
181
|
+
try {
|
|
182
|
+
// Refetch's base type omits pagination fields that the installed observer returns.
|
|
183
|
+
const flags: unknown = Object.fromEntries(Object.keys(flagsSchema.properties).map(key => [key, Reflect.get(result, key)]));
|
|
184
|
+
if (!checkExact(flagsSchema, flags)) return rejectProviderResponse();
|
|
185
|
+
const failureReason = result.failureReason === null ? null : querySessionError(result.failureReason);
|
|
186
|
+
const rawData: unknown = result.data;
|
|
187
|
+
if (result.status === 'success') {
|
|
188
|
+
const data = decodeInfiniteResult(rawData, origin.decode);
|
|
189
|
+
if (!session.current(origin)) return pending(operations);
|
|
190
|
+
return { ...result, ...flags, ...operations, data, failureReason, isFetchNextPageError: false, isFetchPreviousPageError: false };
|
|
191
|
+
}
|
|
192
|
+
if (result.status === 'error') {
|
|
193
|
+
const base = { ...result, ...flags, ...operations, error: querySessionError(result.error), failureReason };
|
|
194
|
+
if (rawData === undefined) return { ...base, data: undefined, isLoadingError: true, isRefetchError: false };
|
|
195
|
+
const data = decodeInfiniteResult(rawData, origin.decode);
|
|
196
|
+
if (!session.current(origin)) return pending(operations);
|
|
197
|
+
if (flags.isFetchNextPageError) return {
|
|
198
|
+
...base, data, isLoadingError: false, isRefetchError: false, isFetchNextPageError: true, isFetchPreviousPageError: false,
|
|
199
|
+
};
|
|
200
|
+
if (flags.isFetchPreviousPageError) return {
|
|
201
|
+
...base, data, isLoadingError: false, isRefetchError: false, isFetchNextPageError: false, isFetchPreviousPageError: true,
|
|
202
|
+
};
|
|
203
|
+
return { ...base, data, isLoadingError: false, isRefetchError: true, isFetchNextPageError: false, isFetchPreviousPageError: false };
|
|
204
|
+
}
|
|
205
|
+
return { ...result, ...flags, ...operations, failureReason, isFetchNextPageError: false, isFetchPreviousPageError: false };
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (!session.current(origin)) return pending(operations);
|
|
208
|
+
return {
|
|
209
|
+
...pending(operations), status: 'error', data: undefined, error: querySessionError(error),
|
|
210
|
+
isError: true, isPending: false, isLoading: false, isLoadingError: true, isRefetchError: false,
|
|
211
|
+
isSuccess: false, isPlaceholderData: false, isFetchNextPageError: false, isFetchPreviousPageError: false,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
session.observe(origin => project(origin, raw));
|
|
216
|
+
const query = createQueryResultView(() => project(session.options, raw));
|
|
217
|
+
const overtime = createOvertimeTracker(() => query.isLoading, options.overtimeOptions ?? adminOptions.overtime);
|
|
218
|
+
createLiveSubscription((): LiveSubscriptionParams => ({
|
|
219
|
+
resource: options.resource ?? parsed.resource ?? '',
|
|
220
|
+
...definedOptions({
|
|
221
|
+
liveProvider: context.liveProvider, liveMode: options.liveMode ?? adminOptions.liveMode,
|
|
222
|
+
contract: options.contract, onLiveEvent: options.onLiveEvent, onGlobalLiveEvent: adminOptions.onLiveEvent,
|
|
223
|
+
dataProviderName: options.dataProviderName,
|
|
224
|
+
}),
|
|
225
|
+
enabled: options.queryOptions?.enabled ?? true,
|
|
226
|
+
}));
|
|
227
|
+
return { query, get overtime() { return overtime; } };
|
|
228
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Type } from '@sinclair/typebox';
|
|
2
|
+
import { snapshotPlainData } from './plain-data';
|
|
3
|
+
import { checkExact } from './schema-validation';
|
|
4
|
+
import { HttpError } from './types';
|
|
5
|
+
|
|
6
|
+
const schema = Type.Object({
|
|
7
|
+
id: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
|
8
|
+
invalidates: Type.Optional(Type.Union([
|
|
9
|
+
Type.Literal(false), Type.Literal('all'),
|
|
10
|
+
Type.Array(Type.Union([
|
|
11
|
+
Type.Literal('list'), Type.Literal('many'),
|
|
12
|
+
Type.Literal('detail'), Type.Literal('resourceAll'),
|
|
13
|
+
])),
|
|
14
|
+
])),
|
|
15
|
+
}, { additionalProperties: false });
|
|
16
|
+
|
|
17
|
+
/** Invalid input must not broaden the cache scope or execute submitted accessors. */
|
|
18
|
+
export function snapshotInvalidationParams(value: unknown) {
|
|
19
|
+
try {
|
|
20
|
+
const candidate = snapshotPlainData(value);
|
|
21
|
+
if (checkExact(schema, candidate)) return candidate;
|
|
22
|
+
} catch {
|
|
23
|
+
// Do not expose cache input values or reflection failures.
|
|
24
|
+
}
|
|
25
|
+
throw new HttpError('Invalid refresh input', 422, undefined, { code: 'INVALID_RESOURCE_INPUT' });
|
|
26
|
+
}
|
package/src/live-sse.ts
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
* data: { "type": "INSERT", "resource": "posts", "payload": {...} }
|
|
7
|
-
*
|
|
8
|
-
* Optionally, named SSE events can be used:
|
|
9
|
-
* event: posts
|
|
10
|
-
* data: { "type": "INSERT", "payload": {...} }
|
|
11
|
-
*/
|
|
12
|
-
import type { LiveProvider, LiveEvent } from './live.svelte';
|
|
1
|
+
import type { LiveProvider } from './live.svelte';
|
|
2
|
+
import { createLiveSubscribers, decodeLiveMessage, notifyLiveObserver, readLiveOptions, captureLiveObserver } from './live-transport';
|
|
3
|
+
import { Type } from '@sinclair/typebox';
|
|
4
|
+
import { checkExact } from './schema-validation';
|
|
5
|
+
import { snapshotPlainData } from './plain-data';
|
|
13
6
|
|
|
14
7
|
export interface SSELiveProviderOptions {
|
|
15
8
|
url: string;
|
|
@@ -18,137 +11,108 @@ export interface SSELiveProviderOptions {
|
|
|
18
11
|
onError?: (event: Event) => void;
|
|
19
12
|
}
|
|
20
13
|
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
const initSchema = Type.Object({ withCredentials: Type.Optional(Type.Boolean()) }, { additionalProperties: false });
|
|
15
|
+
function checkedInit(value: unknown) {
|
|
16
|
+
if (value === undefined) return undefined;
|
|
17
|
+
try {
|
|
18
|
+
const init = snapshotPlainData(value);
|
|
19
|
+
if (checkExact(initSchema, init)) return init;
|
|
20
|
+
} catch { /* Configuration reflection cannot leak into connection callbacks. */ }
|
|
21
|
+
throw new TypeError('Invalid SSE initialization');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function configuration(options: unknown) {
|
|
25
|
+
const input = readLiveOptions(options, ['url', 'eventSourceInit', 'onOpen', 'onError']);
|
|
26
|
+
const url = input['url'];
|
|
27
|
+
if (typeof url !== 'string' || !url) throw new TypeError('Invalid SSE live options');
|
|
28
|
+
const eventSourceInit = checkedInit(input['eventSourceInit']);
|
|
29
|
+
const onOpen = captureLiveObserver(input['onOpen']);
|
|
30
|
+
const onError = captureLiveObserver(input['onError']);
|
|
31
|
+
return { url, eventSourceInit, onOpen, onError };
|
|
32
|
+
}
|
|
23
33
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Named events may omit resource, but must include type and payload.
|
|
36
|
+
* Reserved message/open/error resources use default full-envelope messages.
|
|
37
|
+
* Per-subscription liveParams are unsupported and rejected.
|
|
38
|
+
*/
|
|
39
|
+
export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProvider & {
|
|
40
|
+
disconnect: () => void; getStatus: () => 'connecting' | 'connected' | 'disconnected';
|
|
41
|
+
} {
|
|
42
|
+
const { url, eventSourceInit, onOpen, onError } = configuration(options);
|
|
43
|
+
const subscribers = createLiveSubscribers();
|
|
44
|
+
let source: EventSource | null = null;
|
|
27
45
|
let status: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
|
|
28
46
|
const namedListeners = new Map<string, EventListener>();
|
|
29
47
|
|
|
30
|
-
function connect() {
|
|
31
|
-
if (eventSource) return;
|
|
32
|
-
if (typeof EventSource === 'undefined') return;
|
|
33
|
-
|
|
34
|
-
status = 'connecting';
|
|
35
|
-
eventSource = new EventSource(url, eventSourceInit);
|
|
36
|
-
|
|
37
|
-
eventSource.onopen = () => {
|
|
38
|
-
status = 'connected';
|
|
39
|
-
options.onOpen?.();
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
eventSource.onmessage = (msgEvent) => {
|
|
43
|
-
dispatchEvent(msgEvent.data);
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
eventSource.onerror = (event) => {
|
|
47
|
-
if (eventSource?.readyState === EventSource.CLOSED) {
|
|
48
|
-
status = 'disconnected';
|
|
49
|
-
}
|
|
50
|
-
options.onError?.(event);
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
for (const resource of subscribers.keys()) {
|
|
54
|
-
if (resource !== '*') {
|
|
55
|
-
addNamedListener(resource);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
48
|
function addNamedListener(resource: string) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
notifySubscribers(liveEvent);
|
|
71
|
-
} catch {
|
|
72
|
-
// ignore non-JSON
|
|
73
|
-
}
|
|
74
|
-
}) as EventListener;
|
|
49
|
+
const connection = source;
|
|
50
|
+
if (!connection || ['*', 'message', 'open', 'error'].includes(resource) || namedListeners.has(resource)) return;
|
|
51
|
+
const current = () => source === connection;
|
|
52
|
+
const listener: EventListener = event => {
|
|
53
|
+
if (!current() || namedListeners.get(resource) !== listener || !(event instanceof MessageEvent)) return;
|
|
54
|
+
const data: unknown = event.data;
|
|
55
|
+
const decoded = decodeLiveMessage(data, resource);
|
|
56
|
+
if (decoded) subscribers.notify(decoded, current);
|
|
57
|
+
};
|
|
75
58
|
namedListeners.set(resource, listener);
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function removeNamedListener(resource: string) {
|
|
80
|
-
const listener = namedListeners.get(resource);
|
|
81
|
-
if (!listener) return;
|
|
82
|
-
if (eventSource) {
|
|
83
|
-
eventSource.removeEventListener(resource, listener);
|
|
84
|
-
}
|
|
85
|
-
namedListeners.delete(resource);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function dispatchEvent(data: string) {
|
|
89
|
-
try {
|
|
90
|
-
const event: LiveEvent = JSON.parse(data);
|
|
91
|
-
if (event.type && event.resource) {
|
|
92
|
-
notifySubscribers(event);
|
|
93
|
-
}
|
|
94
|
-
} catch {
|
|
95
|
-
// ignore non-JSON
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function notifySubscribers(event: LiveEvent) {
|
|
100
|
-
const callbacks = subscribers.get(event.resource);
|
|
101
|
-
if (callbacks) {
|
|
102
|
-
for (const cb of callbacks) cb(event);
|
|
103
|
-
}
|
|
104
|
-
const wildcardCallbacks = subscribers.get('*');
|
|
105
|
-
if (wildcardCallbacks) {
|
|
106
|
-
for (const cb of wildcardCallbacks) cb(event);
|
|
107
|
-
}
|
|
59
|
+
connection.addEventListener(resource, listener);
|
|
108
60
|
}
|
|
109
|
-
|
|
110
61
|
function disconnect() {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
eventSource = null;
|
|
114
|
-
}
|
|
62
|
+
const previous = source;
|
|
63
|
+
source = null;
|
|
115
64
|
status = 'disconnected';
|
|
65
|
+
if (previous) {
|
|
66
|
+
for (const [resource, listener] of namedListeners) previous.removeEventListener(resource, listener);
|
|
67
|
+
previous.close();
|
|
68
|
+
}
|
|
116
69
|
namedListeners.clear();
|
|
117
70
|
}
|
|
118
|
-
|
|
71
|
+
function connect() {
|
|
72
|
+
if (source || subscribers.empty || typeof EventSource === 'undefined') return;
|
|
73
|
+
status = 'connecting';
|
|
74
|
+
let connection: EventSource;
|
|
75
|
+
try { connection = new EventSource(url, eventSourceInit); }
|
|
76
|
+
catch { status = 'disconnected'; return; }
|
|
77
|
+
source = connection;
|
|
78
|
+
const current = () => source === connection;
|
|
79
|
+
connection.onopen = event => {
|
|
80
|
+
if (!current() || event instanceof MessageEvent) return;
|
|
81
|
+
status = 'connected';
|
|
82
|
+
notifyLiveObserver(onOpen, undefined);
|
|
83
|
+
};
|
|
84
|
+
connection.onmessage = message => {
|
|
85
|
+
if (!current()) return;
|
|
86
|
+
const data: unknown = message.data;
|
|
87
|
+
const event = decodeLiveMessage(data);
|
|
88
|
+
if (event) subscribers.notify(event, current);
|
|
89
|
+
};
|
|
90
|
+
connection.onerror = event => {
|
|
91
|
+
if (!current() || event instanceof MessageEvent) return;
|
|
92
|
+
if (connection.readyState === EventSource.CLOSED) disconnect();
|
|
93
|
+
else status = 'connecting';
|
|
94
|
+
notifyLiveObserver(onError, event);
|
|
95
|
+
};
|
|
96
|
+
for (const channel of subscribers.channels()) addNamedListener(channel.resource);
|
|
97
|
+
}
|
|
119
98
|
return {
|
|
120
|
-
subscribe(
|
|
121
|
-
|
|
122
|
-
if (!
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
subscribers.get(resource)?.add(callback);
|
|
127
|
-
|
|
128
|
-
if (!eventSource && typeof EventSource !== 'undefined') {
|
|
129
|
-
connect();
|
|
130
|
-
}
|
|
131
|
-
if (eventSource && resource !== '*' && isNewResource) {
|
|
132
|
-
addNamedListener(resource);
|
|
133
|
-
}
|
|
134
|
-
|
|
99
|
+
subscribe(params) {
|
|
100
|
+
const entry = subscribers.add(params, false);
|
|
101
|
+
if (!source) connect();
|
|
102
|
+
else if (entry.first) addNamedListener(entry.resource);
|
|
103
|
+
let active = true;
|
|
135
104
|
return () => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
105
|
+
if (!active) return;
|
|
106
|
+
active = false;
|
|
107
|
+
if (entry.remove()) {
|
|
108
|
+
const listener = namedListeners.get(entry.resource);
|
|
109
|
+
if (source && listener) source.removeEventListener(entry.resource, listener);
|
|
110
|
+
namedListeners.delete(entry.resource);
|
|
143
111
|
}
|
|
144
|
-
if (subscribers.
|
|
112
|
+
if (subscribers.empty) disconnect();
|
|
145
113
|
};
|
|
146
114
|
},
|
|
147
|
-
|
|
148
115
|
disconnect,
|
|
149
|
-
|
|
150
|
-
getStatus() {
|
|
151
|
-
return status;
|
|
152
|
-
},
|
|
116
|
+
getStatus: () => status,
|
|
153
117
|
};
|
|
154
118
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { untrack } from 'svelte';
|
|
2
|
+
import type { QueryClient } from '@tanstack/svelte-query';
|
|
3
|
+
import { Type } from '@sinclair/typebox';
|
|
4
|
+
import { captureAdminContext } from './context.svelte';
|
|
5
|
+
import { contractKey, type ResourceContract } from './resource-contract';
|
|
6
|
+
import { captureQueryProvider } from './query-snapshot';
|
|
7
|
+
import { decodeBaseRecord } from './record-decoder';
|
|
8
|
+
import { invalidateOwnedQueries } from './query-invalidation';
|
|
9
|
+
import { snapshotPlainData } from './plain-data';
|
|
10
|
+
import { checkExact } from './schema-validation';
|
|
11
|
+
import { definedOptions } from './defined-options';
|
|
12
|
+
import { captureLiveObserver, notifyLiveObserver, snapshotLiveEvent } from './live-transport';
|
|
13
|
+
import type { LiveEvent, LiveMode, LiveProvider } from './live.svelte';
|
|
14
|
+
import { captureAuthLiveScope } from './auth-hooks.svelte';
|
|
15
|
+
|
|
16
|
+
export interface LiveSubscriptionParams {
|
|
17
|
+
resource: string;
|
|
18
|
+
liveProvider?: LiveProvider;
|
|
19
|
+
liveMode?: LiveMode;
|
|
20
|
+
onLiveEvent?: (event: LiveEvent) => void;
|
|
21
|
+
/** @internal Keeps the application-wide observer independent from the local observer. */
|
|
22
|
+
onGlobalLiveEvent?: (event: LiveEvent) => void;
|
|
23
|
+
liveParams?: Record<string, unknown>;
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
dataProviderName?: string;
|
|
26
|
+
contract?: ResourceContract;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const configSchema = Type.Object({
|
|
30
|
+
resource: Type.String({ minLength: 1 }),
|
|
31
|
+
liveMode: Type.Union([Type.Literal('auto'), Type.Literal('manual'), Type.Literal('off')]),
|
|
32
|
+
enabled: Type.Boolean(),
|
|
33
|
+
dataProviderName: Type.Optional(Type.String({ minLength: 1 })),
|
|
34
|
+
liveParams: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
35
|
+
}, { additionalProperties: false });
|
|
36
|
+
|
|
37
|
+
/** One subscription owns its event copies, cleanup and exact cache source. */
|
|
38
|
+
export function createCheckedLiveSubscription(paramsFn: () => LiveSubscriptionParams, client?: QueryClient): void {
|
|
39
|
+
const context = captureAdminContext();
|
|
40
|
+
const state = $derived.by(() => {
|
|
41
|
+
try {
|
|
42
|
+
const params = paramsFn();
|
|
43
|
+
const candidate: unknown = snapshotPlainData(definedOptions({
|
|
44
|
+
resource: params.resource, liveMode: params.liveMode === undefined ? 'off' : params.liveMode,
|
|
45
|
+
enabled: params.enabled === undefined ? true : params.enabled,
|
|
46
|
+
dataProviderName: params.dataProviderName, liveParams: params.liveParams,
|
|
47
|
+
}));
|
|
48
|
+
if (!checkExact(configSchema, candidate) || !candidate.enabled || candidate.liveMode === 'off') return undefined;
|
|
49
|
+
const authScope = captureAuthLiveScope(context.authProvider);
|
|
50
|
+
if (!authScope.available) return undefined;
|
|
51
|
+
const provider = params.liveProvider;
|
|
52
|
+
const subscribe = provider?.subscribe;
|
|
53
|
+
if (!provider || typeof subscribe !== 'function') return undefined;
|
|
54
|
+
const definition = context.resources.find(resource => resource.name === candidate.resource || resource.identifier === candidate.resource);
|
|
55
|
+
const contract = params.contract ?? definition?.contract;
|
|
56
|
+
const contractId = contract === undefined ? undefined : contractKey(contract);
|
|
57
|
+
if (contract && contract.name !== candidate.resource) return undefined;
|
|
58
|
+
const providerName = candidate.dataProviderName ?? definition?.provider?.dataProviderName ?? definition?.meta?.dataProviderName ?? 'default';
|
|
59
|
+
if (typeof providerName !== 'string' || !providerName) return undefined;
|
|
60
|
+
if (candidate.liveMode === 'auto' && (!client || candidate.resource === '*')) return undefined;
|
|
61
|
+
const captured = candidate.liveMode === 'auto'
|
|
62
|
+
? captureQueryProvider(context, { resource: candidate.resource, dataProviderName: providerName }) : undefined;
|
|
63
|
+
return {
|
|
64
|
+
...candidate, provider, subscribe, contractId, providerName, source: captured?.source, authScope,
|
|
65
|
+
tenant: context.tenantCacheKey?.__svadminTenant, auth: context.authProvider, router: context.routerProvider,
|
|
66
|
+
onEvent: captureLiveObserver(params.onLiveEvent), onGlobal: captureLiveObserver(params.onGlobalLiveEvent),
|
|
67
|
+
};
|
|
68
|
+
} catch { return undefined; }
|
|
69
|
+
});
|
|
70
|
+
$effect(() => {
|
|
71
|
+
const observed = state;
|
|
72
|
+
if (!observed) return;
|
|
73
|
+
const scope = observed;
|
|
74
|
+
let active = true;
|
|
75
|
+
let ready = false;
|
|
76
|
+
let pending: LiveEvent[] = [];
|
|
77
|
+
const current = () => active && state === scope && scope.authScope.isCurrent();
|
|
78
|
+
function deliver(event: LiveEvent) {
|
|
79
|
+
if (!current()) return;
|
|
80
|
+
const local = snapshotLiveEvent(event);
|
|
81
|
+
if (!local) return;
|
|
82
|
+
notifyLiveObserver(scope.onEvent, local);
|
|
83
|
+
if (!current()) return;
|
|
84
|
+
const global = snapshotLiveEvent(event);
|
|
85
|
+
if (!global) return;
|
|
86
|
+
notifyLiveObserver(scope.onGlobal, global);
|
|
87
|
+
if (!current() || scope.liveMode !== 'auto' || !client || scope.source === undefined) return;
|
|
88
|
+
const matcher = { provider: scope.providerName, tenant: scope.tenant, contract: scope.contractId, resource: scope.resource };
|
|
89
|
+
const owner = { source: scope.source, authSession: scope.authScope.cacheKey };
|
|
90
|
+
notifyLiveObserver(() => invalidateOwnedQueries({ client, matcher, owner, scopes: ['resourceAll'], current }), undefined);
|
|
91
|
+
}
|
|
92
|
+
const callback = (value: unknown) => untrack(() => {
|
|
93
|
+
if (!current()) return;
|
|
94
|
+
const event = snapshotLiveEvent(value);
|
|
95
|
+
if (!event || (scope.resource !== '*' && scope.resource !== event.resource)) return;
|
|
96
|
+
if (ready) deliver(event);
|
|
97
|
+
else pending.push(event);
|
|
98
|
+
});
|
|
99
|
+
let cleanup: unknown;
|
|
100
|
+
try {
|
|
101
|
+
cleanup = untrack(() => scope.subscribe.call(scope.provider, {
|
|
102
|
+
resource: scope.resource,
|
|
103
|
+
...definedOptions({ liveParams: scope.liveParams === undefined ? undefined : decodeBaseRecord(snapshotPlainData(scope.liveParams)) }),
|
|
104
|
+
callback,
|
|
105
|
+
}));
|
|
106
|
+
} catch {
|
|
107
|
+
active = false;
|
|
108
|
+
pending = [];
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (typeof cleanup !== 'function') {
|
|
112
|
+
active = false;
|
|
113
|
+
pending = [];
|
|
114
|
+
// A malformed asynchronous cleanup is quarantined, but still released when available.
|
|
115
|
+
notifyLiveObserver(async () => {
|
|
116
|
+
const late: unknown = await cleanup;
|
|
117
|
+
if (typeof late === 'function') await late();
|
|
118
|
+
}, undefined);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const release = cleanup;
|
|
122
|
+
ready = true;
|
|
123
|
+
untrack(() => { for (const event of pending) deliver(event); });
|
|
124
|
+
pending = [];
|
|
125
|
+
return () => {
|
|
126
|
+
active = false;
|
|
127
|
+
notifyLiveObserver(() => release(), undefined);
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
}
|