@svadmin/core 0.49.1 → 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.
Files changed (97) hide show
  1. package/README.md +32 -0
  2. package/package.json +21 -4
  3. package/src/access-control-contract.ts +250 -0
  4. package/src/adapters/casbin.ts +20 -14
  5. package/src/adapters/casl.ts +22 -19
  6. package/src/admin-tool.ts +7 -7
  7. package/src/audit-contract.ts +105 -0
  8. package/src/audit.ts +88 -128
  9. package/src/auth-hooks.svelte.ts +593 -261
  10. package/src/auth-mutation-contract.ts +143 -0
  11. package/src/auth-query-contract.ts +97 -0
  12. package/src/captured-mutation.svelte.ts +45 -0
  13. package/src/chatProvider.svelte.ts +1 -1
  14. package/src/command-contract.ts +195 -0
  15. package/src/context.svelte.ts +217 -205
  16. package/src/contract-context.svelte.ts +37 -0
  17. package/src/contract-form.svelte.ts +566 -0
  18. package/src/create-contract.ts +56 -0
  19. package/src/create-hooks.svelte.ts +347 -0
  20. package/src/create-many-contract.ts +90 -0
  21. package/src/create-many-hooks.svelte.ts +396 -0
  22. package/src/data-transfer.svelte.ts +1 -215
  23. package/src/defined-options.ts +50 -0
  24. package/src/delete-contract.ts +50 -0
  25. package/src/delete-hooks.svelte.ts +403 -0
  26. package/src/delete-many-contract.ts +60 -0
  27. package/src/delete-many-hooks.svelte.ts +402 -0
  28. package/src/enterprise.ts +4 -4
  29. package/src/export-format.ts +16 -14
  30. package/src/export-hooks.svelte.ts +191 -0
  31. package/src/feature-gate-contract.ts +53 -0
  32. package/src/hook-utils.svelte.ts +53 -105
  33. package/src/hooks.svelte.ts +19 -774
  34. package/src/http.ts +88 -87
  35. package/src/i18n.svelte.ts +56 -2
  36. package/src/import-contract.ts +106 -0
  37. package/src/import-hooks.svelte.ts +310 -0
  38. package/src/index.ts +77 -28
  39. package/src/inferencer-graphql.ts +7 -6
  40. package/src/infinite-query.svelte.ts +228 -0
  41. package/src/invalidation-contract.ts +26 -0
  42. package/src/live-sse.ts +91 -127
  43. package/src/live-subscription.svelte.ts +130 -0
  44. package/src/live-transport.ts +127 -0
  45. package/src/live-websocket.ts +85 -118
  46. package/src/live.svelte.ts +66 -68
  47. package/src/notification.svelte.ts +17 -15
  48. package/src/permission-catalog.ts +121 -0
  49. package/src/permission-hints-contract.ts +48 -0
  50. package/src/permissions.svelte.ts +112 -59
  51. package/src/plain-data.ts +61 -0
  52. package/src/provider-bundle.ts +85 -79
  53. package/src/provider-response.ts +35 -0
  54. package/src/query-hooks.svelte.ts +241 -286
  55. package/src/query-invalidation.ts +172 -0
  56. package/src/query-keys.ts +221 -110
  57. package/src/query-session.svelte.ts +216 -0
  58. package/src/query-snapshot.ts +144 -0
  59. package/src/reactive-projection.ts +53 -0
  60. package/src/record-decoder.ts +63 -0
  61. package/src/resource-binding.svelte.ts +19 -0
  62. package/src/resource-contract.ts +595 -0
  63. package/src/resource-mutation-types.ts +30 -0
  64. package/src/resource-schemas.ts +371 -0
  65. package/src/resource-transport-config.ts +156 -0
  66. package/src/route-parsing.ts +11 -11
  67. package/src/router-provider.ts +64 -59
  68. package/src/router.ts +8 -3
  69. package/src/routing-hooks.svelte.ts +90 -62
  70. package/src/schema-validation.ts +92 -0
  71. package/src/schema.ts +13 -0
  72. package/src/select-options.ts +95 -0
  73. package/src/select-query.svelte.ts +191 -0
  74. package/src/session-query.svelte.ts +88 -0
  75. package/src/strict-command-hooks.svelte.ts +333 -0
  76. package/src/strict-hooks.svelte.ts +198 -0
  77. package/src/table-hooks.svelte.ts +180 -174
  78. package/src/table-state.svelte.ts +117 -117
  79. package/src/task-contract.ts +87 -0
  80. package/src/task-hooks.svelte.ts +193 -149
  81. package/src/task-provider.ts +115 -0
  82. package/src/task-subscription.ts +132 -0
  83. package/src/theme.svelte.ts +155 -154
  84. package/src/toast.svelte.ts +38 -37
  85. package/src/types.ts +55 -103
  86. package/src/unsafe.ts +7 -0
  87. package/src/update-contract.ts +65 -0
  88. package/src/update-hooks.svelte.ts +347 -0
  89. package/src/update-many-contract.ts +79 -0
  90. package/src/update-many-hooks.svelte.ts +373 -0
  91. package/src/url-sync.ts +11 -10
  92. package/src/useCan.ts +96 -31
  93. package/src/useParsed.svelte.ts +18 -14
  94. package/src/useStepsForm.svelte.ts +123 -63
  95. package/src/utility-hooks.svelte.ts +101 -158
  96. package/src/form-hooks.svelte.ts +0 -889
  97. package/src/mutation-hooks.svelte.ts +0 -561
@@ -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
- * SSE (Server-Sent Events) LiveProvider
3
- *
4
- * Connects to an SSE endpoint and dispatches LiveEvents to subscribers.
5
- * The server should emit events as JSON matching the LiveEvent shape:
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
- export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProvider & { disconnect: () => void; getStatus: () => 'connecting' | 'connected' | 'disconnected' } {
22
- const { url, eventSourceInit } = options;
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
- type Callback = (event: LiveEvent) => void;
25
- const subscribers = new Map<string, Set<Callback>>();
26
- let eventSource: EventSource | null = null;
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
- if (!eventSource || namedListeners.has(resource)) return;
62
- const listener = ((event: MessageEvent) => {
63
- try {
64
- const parsed = JSON.parse(event.data);
65
- const liveEvent: LiveEvent = {
66
- type: parsed.type,
67
- resource: parsed.resource ?? resource,
68
- payload: parsed.payload ?? parsed,
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
- eventSource.addEventListener(resource, listener);
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
- if (eventSource) {
112
- eventSource.close();
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({ resource, callback }) {
121
- let isNewResource = false;
122
- if (!subscribers.has(resource)) {
123
- subscribers.set(resource, new Set());
124
- isNewResource = true;
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
- const callbacks = subscribers.get(resource);
137
- if (callbacks) {
138
- callbacks.delete(callback);
139
- if (callbacks.size === 0) {
140
- subscribers.delete(resource);
141
- removeNamedListener(resource);
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.size === 0) disconnect();
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
+ }