@svadmin/core 0.50.0 → 0.53.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 (98) hide show
  1. package/package.json +21 -4
  2. package/src/access-control-contract.ts +250 -0
  3. package/src/adapters/casbin.ts +20 -14
  4. package/src/adapters/casl.ts +22 -19
  5. package/src/admin-tool.ts +7 -7
  6. package/src/audit-contract.ts +105 -0
  7. package/src/audit.ts +88 -128
  8. package/src/auth-hooks.svelte.ts +593 -261
  9. package/src/auth-mutation-contract.ts +143 -0
  10. package/src/auth-query-contract.ts +97 -0
  11. package/src/captured-mutation.svelte.ts +45 -0
  12. package/src/chatProvider.svelte.ts +1 -1
  13. package/src/command-contract.ts +195 -0
  14. package/src/context.svelte.ts +217 -205
  15. package/src/contract-context.svelte.ts +37 -0
  16. package/src/contract-form.svelte.ts +566 -0
  17. package/src/create-contract.ts +56 -0
  18. package/src/create-hooks.svelte.ts +347 -0
  19. package/src/create-many-contract.ts +90 -0
  20. package/src/create-many-hooks.svelte.ts +396 -0
  21. package/src/data-transfer.svelte.ts +1 -215
  22. package/src/defined-options.ts +50 -0
  23. package/src/delete-contract.ts +50 -0
  24. package/src/delete-hooks.svelte.ts +403 -0
  25. package/src/delete-many-contract.ts +60 -0
  26. package/src/delete-many-hooks.svelte.ts +402 -0
  27. package/src/enterprise.ts +4 -4
  28. package/src/export-format.ts +16 -14
  29. package/src/export-hooks.svelte.ts +191 -0
  30. package/src/feature-gate-contract.ts +53 -0
  31. package/src/hook-utils.svelte.ts +53 -105
  32. package/src/hooks.svelte.ts +19 -776
  33. package/src/http.ts +88 -87
  34. package/src/i18n.svelte.ts +64 -2
  35. package/src/import-contract.ts +106 -0
  36. package/src/import-hooks.svelte.ts +310 -0
  37. package/src/index.ts +77 -30
  38. package/src/inferencer-graphql.ts +7 -6
  39. package/src/inferencer.ts +9 -0
  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 -288
  55. package/src/query-invalidation.ts +172 -0
  56. package/src/query-keys.ts +221 -110
  57. package/src/query-session.svelte.ts +220 -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 +103 -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 +190 -180
  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 +68 -110
  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
  98. package/src/schema-form.ts +0 -31
@@ -0,0 +1,127 @@
1
+ import { Type, type Static } from '@sinclair/typebox';
2
+ import { checkExact } from './schema-validation';
3
+ import { snapshotPlainData, type JsonValue } from './plain-data';
4
+ import { definedOptions } from './defined-options';
5
+
6
+ const eventFields = {
7
+ type: Type.Union([Type.Literal('INSERT'), Type.Literal('UPDATE'), Type.Literal('DELETE')]),
8
+ resource: Type.String({ minLength: 1 }),
9
+ payload: Type.Record(Type.String(), Type.Unknown()),
10
+ };
11
+ const eventSchema = Type.Object(eventFields, { additionalProperties: false });
12
+ const namedEventSchema = Type.Object({
13
+ ...eventFields, resource: Type.Optional(eventFields.resource),
14
+ }, { additionalProperties: false });
15
+ export type LiveEvent = Static<typeof eventSchema>;
16
+
17
+ export function snapshotLiveEvent(value: unknown): LiveEvent | undefined {
18
+ try {
19
+ const event = snapshotPlainData(value);
20
+ return checkExact(eventSchema, event) ? event : undefined;
21
+ } catch { return undefined; }
22
+ }
23
+
24
+ /** Only a checked event envelope crosses the transport; business payloads stay unknown. */
25
+ export function decodeLiveMessage(data: unknown, channel?: string): LiveEvent | undefined {
26
+ if (typeof data !== 'string') return undefined;
27
+ try {
28
+ const parsed: unknown = JSON.parse(data);
29
+ const value: unknown = snapshotPlainData(parsed);
30
+ if (channel === undefined) return checkExact(eventSchema, value) ? value : undefined;
31
+ if (!checkExact(namedEventSchema, value) || (value.resource !== undefined && value.resource !== channel)) return undefined;
32
+ const event = { ...value, resource: channel };
33
+ return checkExact(eventSchema, event) ? event : undefined;
34
+ } catch { return undefined; }
35
+ }
36
+
37
+ export function notifyLiveObserver<T>(callback: ((value: T) => unknown) | undefined, value: T): void {
38
+ try { void Promise.resolve(callback?.(value)).catch(() => {}); }
39
+ catch { /* Observers do not own transport delivery or cleanup. */ }
40
+ }
41
+
42
+ export function readLiveOptions(value: unknown, allowed: readonly string[]): Record<string, unknown> {
43
+ try {
44
+ if (typeof value !== 'object' || value === null || Object.getOwnPropertySymbols(value).length) throw new TypeError();
45
+ const prototype: unknown = Object.getPrototypeOf(value);
46
+ if (prototype !== Object.prototype && prototype !== null) throw new TypeError();
47
+ const entries: [string, unknown][] = Object.entries(Object.getOwnPropertyDescriptors(value)).map(([key, descriptor]) => {
48
+ if (!allowed.includes(key) || !descriptor.enumerable || !('value' in descriptor)) throw new TypeError();
49
+ const input: unknown = descriptor.value;
50
+ if (input === undefined) throw new TypeError();
51
+ return [key, input];
52
+ });
53
+ return Object.fromEntries(entries);
54
+ } catch { throw new TypeError('Invalid live options'); }
55
+ }
56
+
57
+ export function captureLiveObserver(value: unknown): ((event: unknown) => unknown) | undefined {
58
+ if (value === undefined) return undefined;
59
+ if (typeof value !== 'function') throw new TypeError('Invalid live observer');
60
+ return event => value(event);
61
+ }
62
+
63
+ function subscription(value: unknown) {
64
+ try {
65
+ const fields = readLiveOptions(value, ['resource', 'liveParams', 'callback']);
66
+ const resource = fields['resource'];
67
+ const callback = fields['callback'];
68
+ if (typeof resource !== 'string' || resource.length === 0 || typeof callback !== 'function') throw new TypeError();
69
+ const liveParams = fields['liveParams'] === undefined ? undefined : snapshotPlainData(fields['liveParams']);
70
+ if (liveParams !== undefined && !checkExact(eventFields.payload, liveParams)) throw new TypeError();
71
+ return { resource, ...definedOptions({ liveParams }), callback: (event: LiveEvent): unknown => callback(event) };
72
+ } catch { throw new TypeError('Invalid live subscription'); }
73
+ }
74
+
75
+ function ordered(value: JsonValue): JsonValue {
76
+ if (Array.isArray(value)) return value.map(ordered);
77
+ if (typeof value !== 'object' || value === null) return value;
78
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
79
+ .map(([key, child]) => [key, ordered(child)]));
80
+ }
81
+
82
+ /** Each registration owns its own cleanup, even when two callbacks are identical. */
83
+ export function createLiveSubscribers() {
84
+ type Entry = ReturnType<typeof subscription>;
85
+ const resources = new Map<string, { paramsKey: string; input: Entry; entries: Set<Entry> }>();
86
+ return {
87
+ add(value: unknown, supportsParams = true) {
88
+ const input = subscription(value);
89
+ if (!supportsParams && input.liveParams !== undefined) throw new TypeError('SSE live parameters are unsupported');
90
+ const paramsKey = JSON.stringify(ordered(snapshotPlainData(input.liveParams ?? null)));
91
+ let group = resources.get(input.resource);
92
+ if (group && group.paramsKey !== paramsKey) throw new TypeError('Conflicting live subscription parameters');
93
+ const first = group === undefined;
94
+ if (!group) {
95
+ group = { paramsKey, input, entries: new Set() };
96
+ resources.set(input.resource, group);
97
+ }
98
+ const owner = group;
99
+ group.entries.add(input);
100
+ return {
101
+ resource: input.resource, first,
102
+ remove() {
103
+ if (!owner.entries.delete(input) || owner.entries.size > 0) return false;
104
+ resources.delete(input.resource);
105
+ return true;
106
+ },
107
+ };
108
+ },
109
+ get empty() { return resources.size === 0; },
110
+ channels() {
111
+ return [...resources.values()].map(({ input }) => ({
112
+ resource: input.resource,
113
+ ...definedOptions({ liveParams: input.liveParams === undefined ? undefined : snapshotPlainData(input.liveParams) }),
114
+ }));
115
+ },
116
+ notify(event: LiveEvent, current: () => boolean) {
117
+ const groups = new Set([resources.get(event.resource), resources.get('*')]);
118
+ const recipients = [...groups].flatMap(group => group ? [...group.entries].map(entry => ({ group, entry })) : []);
119
+ for (const { group, entry } of recipients) {
120
+ if (!current()) return;
121
+ if (!group.entries.has(entry)) continue;
122
+ const detached = snapshotPlainData(event);
123
+ if (checkExact(eventSchema, detached)) notifyLiveObserver(entry.callback, detached);
124
+ }
125
+ },
126
+ };
127
+ }
@@ -1,157 +1,124 @@
1
- /**
2
- * WebSocket LiveProvider
3
- *
4
- * Connects to a WebSocket server and dispatches LiveEvents to subscribers.
5
- * The server is expected to send JSON messages matching the LiveEvent shape:
6
- * { type: 'INSERT' | 'UPDATE' | 'DELETE', resource: string, payload: {...} }
7
- */
8
- import type { LiveProvider, LiveEvent } from './live.svelte';
1
+ import type { LiveProvider } from './live.svelte';
2
+ import { createLiveSubscribers, decodeLiveMessage, notifyLiveObserver, readLiveOptions, captureLiveObserver } from './live-transport';
9
3
 
10
4
  export interface WebSocketLiveProviderOptions {
11
- /** WebSocket server URL, e.g. "wss://api.example.com/ws" */
12
5
  url: string;
13
- /** Reconnect delay in ms (default 3000) */
6
+ /** Retry delay in milliseconds; defaults to 3000. */
14
7
  reconnectDelay?: number;
15
- /** Max reconnect attempts (default Infinity) */
8
+ /** Consecutive failed reconnects; defaults to Infinity. */
16
9
  maxReconnects?: number;
17
- /** Optional callback when connection opens */
18
10
  onOpen?: () => void;
19
- /** Optional callback when connection closes */
20
11
  onClose?: (event: CloseEvent) => void;
21
- /** Optional callback for connection errors */
22
12
  onError?: (event: Event) => void;
23
13
  }
24
14
 
25
- export function createWebSocketLiveProvider(options: WebSocketLiveProviderOptions): LiveProvider & { disconnect: () => void; getStatus: () => 'connecting' | 'connected' | 'disconnected' } {
26
- const { url, reconnectDelay = 3000, maxReconnects = Infinity } = options;
15
+ function configuration(options: unknown) {
16
+ const input = readLiveOptions(options, ['url', 'reconnectDelay', 'maxReconnects', 'onOpen', 'onClose', 'onError']);
17
+ const url = input['url'];
18
+ const reconnectDelay = input['reconnectDelay'] === undefined ? 3000 : input['reconnectDelay'];
19
+ const maxReconnects = input['maxReconnects'] === undefined ? Infinity : input['maxReconnects'];
20
+ if (typeof url !== 'string' || !url || typeof reconnectDelay !== 'number' ||
21
+ !Number.isSafeInteger(reconnectDelay) || reconnectDelay < 0 || reconnectDelay > 2_147_483_647 ||
22
+ typeof maxReconnects !== 'number' || (maxReconnects !== Infinity && (!Number.isSafeInteger(maxReconnects) || maxReconnects < 0))) {
23
+ throw new TypeError('Invalid WebSocket live options');
24
+ }
25
+ const onOpen = captureLiveObserver(input['onOpen']);
26
+ const onClose = captureLiveObserver(input['onClose']);
27
+ const onError = captureLiveObserver(input['onError']);
28
+ return { url, reconnectDelay, maxReconnects, onOpen, onClose, onError };
29
+ }
27
30
 
28
- type Callback = (event: LiveEvent) => void;
29
- const subscribers = new Map<string, Set<Callback>>();
30
- const lastParams = new Map<string, Record<string, unknown> | undefined>();
31
+ /** Each resource uses one checked parameter set until its last subscriber leaves. */
32
+ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOptions): LiveProvider & {
33
+ disconnect: () => void; getStatus: () => 'connecting' | 'connected' | 'disconnected';
34
+ } {
35
+ const { url, reconnectDelay, maxReconnects, onOpen, onClose, onError } = configuration(options);
36
+ const subscribers = createLiveSubscribers();
31
37
  let ws: WebSocket | null = null;
32
38
  let reconnectAttempts = 0;
33
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
39
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
34
40
  let status: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
35
- let intentionalClose = false;
36
-
37
- function connect() {
38
- if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
39
-
40
- status = 'connecting';
41
- intentionalClose = false;
42
41
 
43
- try {
44
- ws = new WebSocket(url);
45
- } catch {
42
+ function scheduleReconnect() {
43
+ if (subscribers.empty || ws || reconnectAttempts >= maxReconnects) return;
44
+ if (reconnectTimer !== undefined) clearTimeout(reconnectTimer);
45
+ reconnectAttempts++;
46
+ reconnectTimer = setTimeout(() => { reconnectTimer = undefined; connect(); }, reconnectDelay);
47
+ }
48
+ function close(socket: WebSocket) {
49
+ try { socket.close(); } catch { /* A retired transport cannot own current state. */ }
50
+ }
51
+ function send(socket: WebSocket, message: object) {
52
+ if (ws !== socket) return;
53
+ try { socket.send(JSON.stringify(message)); }
54
+ catch {
55
+ ws = null;
46
56
  status = 'disconnected';
57
+ close(socket);
47
58
  scheduleReconnect();
48
- return;
49
59
  }
50
-
51
- ws.onopen = () => {
60
+ }
61
+ function connect() {
62
+ if (ws || subscribers.empty || typeof WebSocket === 'undefined') return;
63
+ if (reconnectTimer !== undefined) { clearTimeout(reconnectTimer); reconnectTimer = undefined; }
64
+ status = 'connecting';
65
+ let socket: WebSocket;
66
+ try { socket = new WebSocket(url); }
67
+ catch { status = 'disconnected'; scheduleReconnect(); return; }
68
+ ws = socket;
69
+ const current = () => ws === socket;
70
+ socket.onopen = () => {
71
+ if (!current()) return;
52
72
  status = 'connected';
53
- reconnectAttempts = 0;
54
- options.onOpen?.();
55
-
56
- // Resend subscriptions upon reconnects
57
- for (const [res, callbacks] of subscribers.entries()) {
58
- if (callbacks.size > 0) {
59
- const liveParams = lastParams.get(res);
60
- ws?.send(JSON.stringify({ type: 'SUBSCRIBE', resource: res, liveParams }));
61
- }
73
+ for (const channel of subscribers.channels()) send(socket, { type: 'SUBSCRIBE', ...channel });
74
+ if (current()) {
75
+ reconnectAttempts = 0;
76
+ notifyLiveObserver(onOpen, undefined);
62
77
  }
63
78
  };
64
-
65
- ws.onmessage = (msgEvent) => {
66
- try {
67
- const event: LiveEvent = JSON.parse(msgEvent.data);
68
- if (event.type && event.resource) {
69
- const callbacks = subscribers.get(event.resource);
70
- if (callbacks) {
71
- for (const cb of callbacks) cb(event);
72
- }
73
- // Also notify wildcard '*' subscribers
74
- const wildcardCallbacks = subscribers.get('*');
75
- if (wildcardCallbacks) {
76
- for (const cb of wildcardCallbacks) cb(event);
77
- }
78
- }
79
- } catch {
80
- // ignore non-JSON messages
81
- }
79
+ socket.onmessage = message => {
80
+ if (!current()) return;
81
+ const data: unknown = message.data;
82
+ const event = decodeLiveMessage(data);
83
+ if (event) subscribers.notify(event, current);
82
84
  };
83
-
84
- ws.onclose = (event) => {
85
+ socket.onclose = event => {
86
+ if (!current()) return;
87
+ ws = null;
85
88
  status = 'disconnected';
86
- options.onClose?.(event);
87
- if (!intentionalClose) scheduleReconnect();
89
+ scheduleReconnect();
90
+ notifyLiveObserver(onClose, event);
88
91
  };
89
-
90
- ws.onerror = (event) => {
91
- options.onError?.(event);
92
+ socket.onerror = event => {
93
+ if (current()) notifyLiveObserver(onError, event);
92
94
  };
93
95
  }
94
-
95
- function scheduleReconnect() {
96
- if (reconnectAttempts >= maxReconnects) return;
97
- if (reconnectTimer) clearTimeout(reconnectTimer);
98
-
99
- reconnectAttempts++;
100
- reconnectTimer = setTimeout(connect, reconnectDelay);
101
- }
102
-
103
96
  function disconnect() {
104
- intentionalClose = true;
97
+ if (reconnectTimer !== undefined) { clearTimeout(reconnectTimer); reconnectTimer = undefined; }
105
98
  reconnectAttempts = 0;
106
- if (reconnectTimer) {
107
- clearTimeout(reconnectTimer);
108
- reconnectTimer = null;
109
- }
110
- if (ws) {
111
- ws.close();
112
- ws = null;
113
- }
99
+ const previous = ws;
100
+ ws = null;
114
101
  status = 'disconnected';
102
+ if (previous) close(previous);
115
103
  }
116
104
 
117
-
118
-
119
105
  return {
120
- subscribe({ resource, liveParams, callback }) {
121
- if (!subscribers.has(resource)) {
122
- subscribers.set(resource, new Set());
123
- }
124
- subscribers.get(resource)?.add(callback);
125
- lastParams.set(resource, liveParams);
126
-
127
- // Ensure connection is open
128
- if (status === 'disconnected' && typeof WebSocket !== 'undefined') {
129
- connect();
130
- } else if (status === 'connected' && ws && ws.readyState === WebSocket.OPEN) {
131
- // If already connected, dispatch subscription payload dynamically
132
- ws.send(JSON.stringify({ type: 'SUBSCRIBE', resource, liveParams }));
106
+ subscribe(params) {
107
+ const entry = subscribers.add(params);
108
+ if (!ws) connect();
109
+ else if (entry.first && status === 'connected') {
110
+ const channel = subscribers.channels().find(channel => channel.resource === entry.resource);
111
+ if (channel) send(ws, { type: 'SUBSCRIBE', ...channel });
133
112
  }
134
-
113
+ let active = true;
135
114
  return () => {
136
- const callbacks = subscribers.get(resource);
137
- if (callbacks) {
138
- callbacks.delete(callback);
139
- if (callbacks.size === 0) {
140
- subscribers.delete(resource);
141
- if (status === 'connected' && ws && ws.readyState === WebSocket.OPEN) {
142
- ws.send(JSON.stringify({ type: 'UNSUBSCRIBE', resource }));
143
- }
144
- }
145
- }
146
- // Auto-disconnect if no subscribers left
147
- if (subscribers.size === 0) disconnect();
115
+ if (!active) return;
116
+ active = false;
117
+ if (entry.remove() && ws && status === 'connected') send(ws, { type: 'UNSUBSCRIBE', resource: entry.resource });
118
+ if (subscribers.empty) disconnect();
148
119
  };
149
120
  },
150
-
151
121
  disconnect,
152
-
153
- getStatus() {
154
- return status;
155
- },
122
+ getStatus: () => status,
156
123
  };
157
124
  }
@@ -1,22 +1,23 @@
1
+ import { definedOptions } from './defined-options';
1
2
  // LiveProvider — Real-time subscription interface + hooks
2
3
 
3
4
  import { useQueryClient } from '@tanstack/svelte-query';
4
5
  import { captureAdminContext } from './context.svelte';
5
- import { dataQueryMatches } from './query-keys';
6
+ import { createCheckedLiveSubscription } from './live-subscription.svelte';
7
+ import type { ResourceContract } from './resource-contract';
8
+ import { snapshotLiveEvent } from './live-transport';
9
+ import { HttpError } from './types';
10
+ import { captureAuthLiveScope } from './auth-hooks.svelte';
11
+ import type { LiveEvent } from './live-transport';
12
+ export type { LiveEvent } from './live-transport';
6
13
 
7
14
  // ─── Types ──────────────────────────────────────────────────────
8
15
 
9
- export type LiveMode = 'auto' | 'manual' | 'off';
10
-
11
- export interface LiveEvent {
12
- type: 'INSERT' | 'UPDATE' | 'DELETE';
13
- resource: string;
14
- payload: Record<string, unknown>;
15
- }
16
+ export type LiveMode='auto'|'manual'|'off';
16
17
 
17
18
  export interface LiveProvider {
18
- subscribe(params: { resource: string; liveParams?: Record<string, unknown>; callback: (event: LiveEvent) => void }): () => void;
19
- unsubscribe?(params: { resource: string; liveParams?: Record<string, unknown> }): void;
19
+ subscribe(params: { resource: string; liveParams?: Record<string,unknown>; callback: (event: LiveEvent) => void }): () => void;
20
+ unsubscribe?(params: { resource: string; liveParams?: Record<string,unknown> }): void;
20
21
  publish?(event: LiveEvent): void;
21
22
  onConnected?: () => void;
22
23
  onDisconnected?: () => void;
@@ -31,78 +32,75 @@ export interface LiveProviderReconnectOptions {
31
32
  // ─── useLive — auto-invalidate queries on real-time events ──────
32
33
 
33
34
  export function useLive(
34
- liveProvider: LiveProvider | (() => LiveProvider),
35
- resource: string | (() => string),
36
- options?: { liveMode?: LiveMode | (() => LiveMode); onLiveEvent?: (event: LiveEvent) => void; liveParams?: Record<string, unknown> | (() => Record<string, unknown>); dataProviderName?: string }
35
+ liveProvider: LiveProvider|(() => LiveProvider),
36
+ resource: string|(() => string),
37
+ options?: { liveMode?: LiveMode|(() => LiveMode); onLiveEvent?: (event: LiveEvent) => void; liveParams?: Record<string,unknown>|(() => Record<string,unknown>); dataProviderName?: string; contract?: ResourceContract }
37
38
  ): void {
38
- const queryClient = useQueryClient();
39
- const adminContext = captureAdminContext();
40
-
41
- $effect(() => {
42
- const lp = typeof liveProvider === 'function' ? liveProvider() : liveProvider;
43
- const res = typeof resource === 'function' ? resource() : resource;
44
- const liveMode = typeof options?.liveMode === 'function' ? options.liveMode() : (options?.liveMode ?? 'auto');
45
- const liveParams = typeof options?.liveParams === 'function' ? options.liveParams() : options?.liveParams;
46
- if (liveMode === 'off') return;
47
-
48
- let unsubscribe: (() => void) | undefined;
49
- try {
50
- unsubscribe = lp.subscribe({
51
- resource: res,
52
- liveParams,
53
- callback: (event) => {
54
- options?.onLiveEvent?.(event);
55
- if (liveMode === 'auto') {
56
- const matcher = adminContext.queryKeyMatcher(res, options?.dataProviderName);
57
- queryClient.invalidateQueries({
58
- predicate: (q) => dataQueryMatches(q.queryKey, { ...matcher, resource: res }),
59
- });
60
- }
61
- },
62
- });
63
- } catch (e) {
64
- console.warn('[svadmin] LiveProvider.subscribe failed:', e);
65
- return;
66
- }
67
- return unsubscribe;
68
- });
39
+ const queryClient=useQueryClient();
40
+ createCheckedLiveSubscription(() => ({
41
+ resource: typeof resource==='function'? resource():resource,
42
+ liveProvider: typeof liveProvider==='function'? liveProvider():liveProvider,
43
+ liveMode: typeof options?.liveMode==='function'? options.liveMode() : options?.liveMode === undefined ? 'auto' : options.liveMode,
44
+ ...definedOptions({
45
+ liveParams: typeof options?.liveParams==='function'? options.liveParams():options?.liveParams,
46
+ onLiveEvent: options?.onLiveEvent, dataProviderName: options?.dataProviderName, contract: options?.contract,
47
+ }),
48
+ }),queryClient);
69
49
  }
70
50
 
71
51
  // ─── useSubscription — manual channel subscription ──────────────
72
52
 
73
53
  interface UseSubscriptionOptions {
74
- resource: string | (() => string);
75
- liveProvider: LiveProvider | (() => LiveProvider);
54
+ resource: string|(() => string);
55
+ liveProvider: LiveProvider|(() => LiveProvider);
76
56
  onLiveEvent: (event: LiveEvent) => void;
77
- enabled?: boolean | (() => boolean);
78
- liveParams?: Record<string, unknown> | (() => Record<string, unknown>);
57
+ enabled?: boolean|(() => boolean);
58
+ liveParams?: Record<string,unknown>|(() => Record<string,unknown>);
79
59
  }
80
60
 
81
61
  export function useSubscription(options: UseSubscriptionOptions): void {
82
- $effect(() => {
83
- const res = typeof options.resource === 'function' ? options.resource() : options.resource;
84
- const lp = typeof options.liveProvider === 'function' ? options.liveProvider() : options.liveProvider;
85
- const enabled = typeof options.enabled === 'function' ? options.enabled() : (options.enabled ?? true);
86
- const liveParams = typeof options.liveParams === 'function' ? options.liveParams() : options.liveParams;
87
- if (!enabled) return;
88
-
89
- const unsubscribe = lp.subscribe({
90
- resource: res,
91
- liveParams,
92
- callback: options.onLiveEvent,
93
- });
94
- return unsubscribe;
95
- });
62
+ createCheckedLiveSubscription(() => ({
63
+ resource: typeof options.resource==='function'? options.resource():options.resource,
64
+ liveProvider: typeof options.liveProvider==='function'? options.liveProvider():options.liveProvider,
65
+ liveMode: 'manual', onLiveEvent: options.onLiveEvent,
66
+ ...definedOptions({
67
+ enabled: typeof options.enabled==='function'? options.enabled():options.enabled,
68
+ liveParams: typeof options.liveParams==='function'? options.liveParams():options.liveParams,
69
+ }),
70
+ }));
96
71
  }
97
72
 
98
73
  // ─── usePublish — publish custom events ─────────────────────────
99
74
 
100
- export function usePublish(liveProvider: LiveProvider) {
101
- return (event: LiveEvent) => {
102
- if (liveProvider.publish) {
103
- liveProvider.publish(event);
104
- } else {
105
- console.warn('[svadmin] LiveProvider.publish() not implemented');
75
+ /** Await publication failures; successful invocation is not a remote delivery receipt. */
76
+ export function usePublish(liveProvider: LiveProvider | (() => LiveProvider)) {
77
+ const context = captureAdminContext();
78
+ let mounted = true;
79
+ $effect(() => () => { mounted = false; });
80
+ return async (event: LiveEvent): Promise<void> => {
81
+ const checked = snapshotLiveEvent(event);
82
+ if (!checked) throw new HttpError('Invalid live event', 422, undefined, { code: 'INVALID_LIVE_EVENT' });
83
+ let dispatched = false;
84
+ try {
85
+ const provider = typeof liveProvider === 'function' ? liveProvider() : liveProvider;
86
+ const publish = provider.publish;
87
+ const auth = context.authProvider;
88
+ const authScope = captureAuthLiveScope(auth);
89
+ const router = context.routerProvider;
90
+ const tenant = context.tenantCacheKey?.__svadminTenant;
91
+ const current = () => mounted && (typeof liveProvider === 'function' ? liveProvider() : liveProvider) === provider &&
92
+ context.authProvider === auth && authScope.available && authScope.isCurrent() &&
93
+ context.routerProvider === router && context.tenantCacheKey?.__svadminTenant === tenant;
94
+ if (!current()) throw new Error();
95
+ if (typeof publish !== 'function') throw new Error();
96
+ dispatched = true;
97
+ const result: unknown = publish.call(provider, checked);
98
+ await result;
99
+ if (!current()) throw new Error();
100
+ } catch {
101
+ throw new HttpError('Live publication failed', 502, undefined, {
102
+ code: 'LIVE_PUBLISH_FAILED', details: { writeMayHaveSucceeded: dispatched },
103
+ });
106
104
  }
107
105
  };
108
106
  }
@@ -2,15 +2,16 @@
2
2
  // Falls back to built-in toast when no provider is set
3
3
 
4
4
  import { toast } from './toast.svelte';
5
+ import { definedOptions } from './defined-options';
5
6
  import type { NotificationProvider } from './types';
6
7
 
7
- let notificationProvider = $state<NotificationProvider | null>(null);
8
+ let notificationProvider=$state<NotificationProvider|null>(null);
8
9
 
9
10
  export function setNotificationProvider(provider: NotificationProvider): void {
10
- notificationProvider = provider;
11
+ notificationProvider=provider;
11
12
  }
12
13
 
13
- export function getNotificationProvider(): NotificationProvider | null {
14
+ export function getNotificationProvider(): NotificationProvider|null {
14
15
  return notificationProvider;
15
16
  }
16
17
 
@@ -18,7 +19,7 @@ export function getNotificationProvider(): NotificationProvider | null {
18
19
  * Send a notification through the configured provider, or fall back to toast.
19
20
  */
20
21
  export interface NotificationParams {
21
- type: 'success' | 'error' | 'warning' | 'info';
22
+ type: 'success'|'error'|'warning'|'info';
22
23
  message: string;
23
24
  description?: string;
24
25
  key?: string;
@@ -27,24 +28,25 @@ export interface NotificationParams {
27
28
  /** Send through an explicitly scoped provider, with the built-in toast as fallback. */
28
29
  export function notifyWithProvider(
29
30
  params: NotificationParams,
30
- provider: NotificationProvider | null | undefined,
31
+ provider: NotificationProvider|null|undefined,
31
32
  ): void {
32
- if (provider) {
33
+ if(provider) {
33
34
  provider.open(params);
34
35
  } else {
35
- const { type, message, description } = params;
36
- const fullMessage = description ? `${message}: ${description}` : message;
37
- switch (type) {
38
- case 'success': toast.success(fullMessage, undefined, { key: params.key }); break;
39
- case 'error': toast.error(fullMessage, undefined, { key: params.key }); break;
40
- case 'warning': toast.warning(fullMessage, undefined, { key: params.key }); break;
41
- case 'info': toast.info(fullMessage, undefined, { key: params.key }); break;
36
+ const { type,message,description }=params;
37
+ const fullMessage=description? `${message}: ${description}`:message;
38
+ const options=definedOptions({ key: params.key });
39
+ switch(type) {
40
+ case 'success': toast.success(fullMessage,undefined,options); break;
41
+ case 'error': toast.error(fullMessage,undefined,options); break;
42
+ case 'warning': toast.warning(fullMessage,undefined,options); break;
43
+ case 'info': toast.info(fullMessage,undefined,options); break;
42
44
  }
43
45
  }
44
46
  }
45
47
 
46
48
  export function notify(params: NotificationParams): void {
47
- notifyWithProvider(params, notificationProvider);
49
+ notifyWithProvider(params,notificationProvider);
48
50
  }
49
51
 
50
52
  /**
@@ -55,5 +57,5 @@ export function closeNotification(key: string): void {
55
57
  }
56
58
 
57
59
  export function resetNotificationProvider(): void {
58
- notificationProvider = null;
60
+ notificationProvider=null;
59
61
  }