@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.
- package/README.md +32 -0
- 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 -774
- 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 -28
- 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 -286
- 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 -103
- 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
|
@@ -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
|
+
}
|
package/src/live-websocket.ts
CHANGED
|
@@ -1,157 +1,124 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
/**
|
|
6
|
+
/** Retry delay in milliseconds; defaults to 3000. */
|
|
14
7
|
reconnectDelay?: number;
|
|
15
|
-
/**
|
|
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
|
-
|
|
26
|
-
const
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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> |
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
85
|
+
socket.onclose = event => {
|
|
86
|
+
if (!current()) return;
|
|
87
|
+
ws = null;
|
|
85
88
|
status = 'disconnected';
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
scheduleReconnect();
|
|
90
|
+
notifyLiveObserver(onClose, event);
|
|
88
91
|
};
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
97
|
+
if (reconnectTimer !== undefined) { clearTimeout(reconnectTimer); reconnectTimer = undefined; }
|
|
105
98
|
reconnectAttempts = 0;
|
|
106
|
-
|
|
107
|
-
|
|
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(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
}
|
package/src/live.svelte.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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,
|
|
19
|
-
unsubscribe?(params: { resource: string; liveParams?: Record<string,
|
|
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
|
|
35
|
-
resource: string
|
|
36
|
-
options?: { liveMode?: LiveMode
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
75
|
-
liveProvider: LiveProvider
|
|
54
|
+
resource: string|(() => string);
|
|
55
|
+
liveProvider: LiveProvider|(() => LiveProvider);
|
|
76
56
|
onLiveEvent: (event: LiveEvent) => void;
|
|
77
|
-
enabled?: boolean
|
|
78
|
-
liveParams?: Record<string,
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
8
|
+
let notificationProvider=$state<NotificationProvider|null>(null);
|
|
8
9
|
|
|
9
10
|
export function setNotificationProvider(provider: NotificationProvider): void {
|
|
10
|
-
notificationProvider
|
|
11
|
+
notificationProvider=provider;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
export function getNotificationProvider(): NotificationProvider
|
|
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'
|
|
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
|
|
31
|
+
provider: NotificationProvider|null|undefined,
|
|
31
32
|
): void {
|
|
32
|
-
if
|
|
33
|
+
if(provider) {
|
|
33
34
|
provider.open(params);
|
|
34
35
|
} else {
|
|
35
|
-
const { type,
|
|
36
|
-
const fullMessage
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
case '
|
|
40
|
-
case '
|
|
41
|
-
case '
|
|
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,
|
|
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
|
|
60
|
+
notificationProvider=null;
|
|
59
61
|
}
|