@svadmin/core 0.0.3 → 0.3.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 (41) hide show
  1. package/package.json +4 -1
  2. package/src/audit.ts +37 -0
  3. package/src/auth-hooks.svelte.ts +293 -0
  4. package/src/auth-hooks.test.ts +205 -0
  5. package/src/context.ts +77 -15
  6. package/src/data-transfer.test.ts +118 -0
  7. package/src/data-transfer.ts +148 -70
  8. package/src/form-hooks.svelte.ts +266 -0
  9. package/src/helpers.ts +157 -0
  10. package/src/hook-utils.svelte.ts +121 -0
  11. package/src/hooks.svelte.ts +278 -496
  12. package/src/http-error.test.ts +85 -0
  13. package/src/i18n.svelte.ts +24 -4
  14. package/src/i18n.test.ts +90 -0
  15. package/src/index.ts +51 -8
  16. package/src/inferencer-openapi.test.ts +192 -0
  17. package/src/inferencer-openapi.ts +290 -0
  18. package/src/inferencer.test.ts +189 -0
  19. package/src/inferencer.ts +247 -0
  20. package/src/live-sse.ts +146 -0
  21. package/src/live-websocket.ts +141 -0
  22. package/src/live.test.ts +134 -0
  23. package/src/live.ts +60 -8
  24. package/src/mutation-hooks.svelte.ts +301 -0
  25. package/src/options.ts +95 -0
  26. package/src/permissions.test.ts +55 -0
  27. package/src/query-hooks.svelte.ts +213 -0
  28. package/src/query-keys.ts +22 -0
  29. package/src/resource-types.test.ts +79 -0
  30. package/src/router-provider.test.ts +71 -0
  31. package/src/router-provider.ts +100 -0
  32. package/src/router.ts +28 -4
  33. package/src/routing-hooks.svelte.ts +99 -0
  34. package/src/table-hooks.svelte.ts +178 -0
  35. package/src/toast.svelte.ts +8 -4
  36. package/src/types.ts +124 -47
  37. package/src/unsaved-changes.svelte.ts +62 -0
  38. package/src/url-sync.ts +15 -0
  39. package/src/useParsed.ts +110 -0
  40. package/src/useStepsForm.svelte.ts +71 -0
  41. package/src/utility-hooks.svelte.ts +161 -0
@@ -0,0 +1,247 @@
1
+ /**
2
+ * @svadmin/inferencer
3
+ *
4
+ * Analyzes API response data and infers ResourceDefinition + FieldDefinition[].
5
+ * Can also generate copy-paste-ready Svelte component code.
6
+ */
7
+ import type { FieldDefinition, ResourceDefinition } from '@svadmin/core';
8
+
9
+ // ─── Field Type Inference ────────────────────────────────────
10
+
11
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
12
+ const URL_RE = /^https?:\/\//;
13
+ const IMAGE_RE = /\.(png|jpe?g|gif|svg|webp|avif|ico)(\?.*)?$/i;
14
+ const PHONE_RE = /^\+?[\d\s\-()]{7,}$/;
15
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})/;
16
+ const COLOR_RE = /^#([0-9a-fA-F]{3,8})$/;
17
+
18
+ type InferredType = FieldDefinition['type'];
19
+
20
+ /**
21
+ * Infer the svadmin field type from a single sample value.
22
+ */
23
+ export function inferFieldType(key: string, value: unknown): InferredType {
24
+ if (value === null || value === undefined) return 'text';
25
+
26
+ if (typeof value === 'boolean') return 'boolean';
27
+ if (typeof value === 'number') return 'number';
28
+
29
+ if (Array.isArray(value)) {
30
+ if (value.length > 0 && typeof value[0] === 'string') {
31
+ if (value.every(v => typeof v === 'string' && IMAGE_RE.test(v))) return 'images';
32
+ return 'tags';
33
+ }
34
+ return 'tags';
35
+ }
36
+
37
+ if (typeof value === 'object') return 'json';
38
+
39
+ if (typeof value === 'string') {
40
+ if (COLOR_RE.test(value)) return 'color';
41
+ if (EMAIL_RE.test(value)) return 'email';
42
+ if (IMAGE_RE.test(value)) return 'image';
43
+ if (URL_RE.test(value)) return 'url';
44
+ if (PHONE_RE.test(value)) return 'phone';
45
+ if (ISO_DATE_RE.test(value)) return 'date';
46
+ if (value.length > 200) return 'textarea';
47
+
48
+ // Heuristic: key name hints
49
+ const lk = key.toLowerCase();
50
+ if (lk.includes('email')) return 'email';
51
+ if (lk.includes('phone') || lk.includes('tel') || lk.includes('mobile')) return 'phone';
52
+ if (lk.includes('url') || lk.includes('link') || lk.includes('website')) return 'url';
53
+ if (lk.includes('avatar') || lk.includes('image') || lk.includes('photo') || lk.includes('thumbnail') || lk.includes('logo')) return 'image';
54
+ if (lk.includes('color') || lk.includes('colour')) return 'color';
55
+ if (lk.includes('description') || lk.includes('content') || lk.includes('body') || lk.includes('bio') || lk.includes('summary')) return 'textarea';
56
+ if (lk === 'created_at' || lk === 'updated_at' || lk.endsWith('_at') || lk.endsWith('_date') || lk.includes('date')) return 'date';
57
+
58
+ return 'text';
59
+ }
60
+
61
+ return 'text';
62
+ }
63
+
64
+ // ─── Relation Detection ──────────────────────────────────────
65
+
66
+ const RELATION_SUFFIXES = ['_id', 'Id', '_ID'];
67
+
68
+ function isLikelyRelation(key: string): string | null {
69
+ for (const suffix of RELATION_SUFFIXES) {
70
+ if (key.endsWith(suffix)) {
71
+ const resource = key.slice(0, -suffix.length);
72
+ // Pluralize naively
73
+ return resource.endsWith('s') ? resource : resource + 's';
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ // ─── Core: Infer from Sample Data ────────────────────────────
80
+
81
+ export interface InferResult {
82
+ fields: FieldDefinition[];
83
+ resource: ResourceDefinition;
84
+ code: string;
85
+ }
86
+
87
+ /**
88
+ * Analyze a sample data array and produce a ResourceDefinition.
89
+ * @param resourceName - Name of the resource (e.g. "posts")
90
+ * @param sampleData - An array of records from the API
91
+ * @param options - Additional configuration
92
+ */
93
+ export function inferResource(
94
+ resourceName: string,
95
+ sampleData: Record<string, unknown>[],
96
+ options: {
97
+ primaryKey?: string;
98
+ label?: string;
99
+ } = {}
100
+ ): InferResult {
101
+ const primaryKey = options.primaryKey ?? 'id';
102
+ const label = options.label ?? capitalize(resourceName);
103
+
104
+ if (!sampleData.length) {
105
+ return {
106
+ fields: [],
107
+ resource: { name: resourceName, label, fields: [], primaryKey },
108
+ code: `// No data available to infer fields for "${resourceName}".`,
109
+ };
110
+ }
111
+
112
+ // Gather all unique keys across all records
113
+ const keySet = new Set<string>();
114
+ for (const row of sampleData) {
115
+ for (const k of Object.keys(row)) keySet.add(k);
116
+ }
117
+
118
+ // For each key, infer from all non-null values
119
+ const fields: FieldDefinition[] = [];
120
+ for (const key of keySet) {
121
+ // Collect non-null values
122
+ const values = sampleData
123
+ .map(r => r[key])
124
+ .filter(v => v !== null && v !== undefined);
125
+
126
+ const sampleValue = values[0];
127
+ let inferredType = inferFieldType(key, sampleValue);
128
+
129
+ // Cross-validate: if most values for this key are of a different type, use majority
130
+ const typeCounts = new Map<InferredType, number>();
131
+ for (const v of values) {
132
+ const t = inferFieldType(key, v);
133
+ typeCounts.set(t, (typeCounts.get(t) ?? 0) + 1);
134
+ }
135
+ let maxCount = 0;
136
+ for (const [t, count] of typeCounts) {
137
+ if (count > maxCount) {
138
+ maxCount = count;
139
+ inferredType = t;
140
+ }
141
+ }
142
+
143
+ // Check for relation
144
+ const relatedResource = isLikelyRelation(key);
145
+
146
+ // Check if it looks like a select (few unique string values)
147
+ const uniqueStrings = new Set(values.filter(v => typeof v === 'string') as string[]);
148
+ const isSelect = inferredType === 'text' && uniqueStrings.size > 1 && uniqueStrings.size <= 10 && values.length >= 5;
149
+
150
+ const field: FieldDefinition = {
151
+ key,
152
+ label: humanize(key),
153
+ type: relatedResource ? 'relation' : isSelect ? 'select' : inferredType,
154
+ sortable: inferredType === 'text' || inferredType === 'number' || inferredType === 'date',
155
+ searchable: inferredType === 'text' || inferredType === 'email',
156
+ showInList: key !== primaryKey && inferredType !== 'textarea' && inferredType !== 'json' && inferredType !== 'richtext',
157
+ showInForm: key !== primaryKey,
158
+ };
159
+
160
+ if (relatedResource) {
161
+ field.resource = relatedResource;
162
+ field.optionLabel = 'name';
163
+ field.optionValue = 'id';
164
+ }
165
+
166
+ if (isSelect) {
167
+ field.options = [...uniqueStrings].map(v => ({ label: capitalize(v), value: v }));
168
+ }
169
+
170
+ fields.push(field);
171
+ }
172
+
173
+ // Sort: primaryKey first, then alphabetically
174
+ fields.sort((a, b) => {
175
+ if (a.key === primaryKey) return -1;
176
+ if (b.key === primaryKey) return 1;
177
+ return a.key.localeCompare(b.key);
178
+ });
179
+
180
+ const resource: ResourceDefinition = {
181
+ name: resourceName,
182
+ label,
183
+ primaryKey,
184
+ fields,
185
+ canCreate: true,
186
+ canEdit: true,
187
+ canDelete: true,
188
+ canShow: true,
189
+ };
190
+
191
+ const code = generateCode(resource);
192
+
193
+ return { fields, resource, code };
194
+ }
195
+
196
+ // ─── Code Generation ─────────────────────────────────────────
197
+
198
+ function generateCode(resource: ResourceDefinition): string {
199
+ const fieldsCode = resource.fields.map(f => {
200
+ const lines = [
201
+ ` { key: '${f.key}', label: '${f.label}', type: '${f.type}'`,
202
+ ];
203
+ if (f.sortable) lines.push(` sortable: true`);
204
+ if (f.searchable) lines.push(` searchable: true`);
205
+ if (f.showInList === false) lines.push(` showInList: false`);
206
+ if (f.showInForm === false) lines.push(` showInForm: false`);
207
+ if (f.resource) {
208
+ lines.push(` resource: '${f.resource}'`);
209
+ lines.push(` optionLabel: '${f.optionLabel}'`);
210
+ lines.push(` optionValue: '${f.optionValue}'`);
211
+ }
212
+ if (f.options) {
213
+ lines.push(` options: ${JSON.stringify(f.options)}`);
214
+ }
215
+
216
+ return lines.join(',\n') + ' }';
217
+ }).join(',\n');
218
+
219
+ return `import type { ResourceDefinition } from '@svadmin/core';
220
+
221
+ export const ${resource.name}Resource: ResourceDefinition = {
222
+ name: '${resource.name}',
223
+ label: '${resource.label}',
224
+ primaryKey: '${resource.primaryKey ?? 'id'}',
225
+ canCreate: true,
226
+ canEdit: true,
227
+ canDelete: true,
228
+ canShow: true,
229
+ fields: [
230
+ ${fieldsCode}
231
+ ],
232
+ };
233
+ `;
234
+ }
235
+
236
+ // ─── Utilities ───────────────────────────────────────────────
237
+
238
+ function capitalize(s: string): string {
239
+ return s.charAt(0).toUpperCase() + s.slice(1);
240
+ }
241
+
242
+ function humanize(key: string): string {
243
+ return key
244
+ .replace(/_/g, ' ')
245
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
246
+ .replace(/\b\w/g, c => c.toUpperCase());
247
+ }
@@ -0,0 +1,146 @@
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';
13
+
14
+ export interface SSELiveProviderOptions {
15
+ /** SSE endpoint URL, e.g. "https://api.example.com/events" */
16
+ url: string;
17
+ /** Optional EventSource init options (e.g. withCredentials) */
18
+ eventSourceInit?: EventSourceInit;
19
+ /** Optional callback when connection opens */
20
+ onOpen?: () => void;
21
+ /** Optional callback for connection errors */
22
+ onError?: (event: Event) => void;
23
+ }
24
+
25
+ export function createSSELiveProvider(options: SSELiveProviderOptions): LiveProvider & { disconnect: () => void; getStatus: () => 'connecting' | 'connected' | 'disconnected' } {
26
+ const { url, eventSourceInit } = options;
27
+
28
+ type Callback = (event: LiveEvent) => void;
29
+ const subscribers = new Map<string, Set<Callback>>();
30
+ let eventSource: EventSource | null = null;
31
+ let status: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
32
+
33
+ function connect() {
34
+ if (eventSource) return;
35
+ if (typeof EventSource === 'undefined') return;
36
+
37
+ status = 'connecting';
38
+ eventSource = new EventSource(url, eventSourceInit);
39
+
40
+ eventSource.onopen = () => {
41
+ status = 'connected';
42
+ options.onOpen?.();
43
+ };
44
+
45
+ // Listen for generic "message" events
46
+ eventSource.onmessage = (msgEvent) => {
47
+ dispatchEvent(msgEvent.data);
48
+ };
49
+
50
+ eventSource.onerror = (event) => {
51
+ // EventSource auto-reconnects by default
52
+ if (eventSource?.readyState === EventSource.CLOSED) {
53
+ status = 'disconnected';
54
+ }
55
+ options.onError?.(event);
56
+ };
57
+
58
+ // Also listen for resource-specific named events
59
+ for (const resource of subscribers.keys()) {
60
+ if (resource !== '*') {
61
+ addNamedListener(resource);
62
+ }
63
+ }
64
+ }
65
+
66
+ function addNamedListener(resource: string) {
67
+ if (!eventSource) return;
68
+ eventSource.addEventListener(resource, ((event: MessageEvent) => {
69
+ try {
70
+ const parsed = JSON.parse(event.data);
71
+ const liveEvent: LiveEvent = {
72
+ type: parsed.type,
73
+ resource: parsed.resource ?? resource,
74
+ payload: parsed.payload ?? parsed,
75
+ };
76
+ notifySubscribers(liveEvent);
77
+ } catch {
78
+ // ignore non-JSON
79
+ }
80
+ }) as EventListener);
81
+ }
82
+
83
+ function dispatchEvent(data: string) {
84
+ try {
85
+ const event: LiveEvent = JSON.parse(data);
86
+ if (event.type && event.resource) {
87
+ notifySubscribers(event);
88
+ }
89
+ } catch {
90
+ // ignore non-JSON
91
+ }
92
+ }
93
+
94
+ function notifySubscribers(event: LiveEvent) {
95
+ const callbacks = subscribers.get(event.resource);
96
+ if (callbacks) {
97
+ for (const cb of callbacks) cb(event);
98
+ }
99
+ // Wildcard subscribers
100
+ const wildcardCallbacks = subscribers.get('*');
101
+ if (wildcardCallbacks) {
102
+ for (const cb of wildcardCallbacks) cb(event);
103
+ }
104
+ }
105
+
106
+ function disconnect() {
107
+ if (eventSource) {
108
+ eventSource.close();
109
+ eventSource = null;
110
+ }
111
+ status = 'disconnected';
112
+ }
113
+
114
+ return {
115
+ subscribe({ resource, callback }) {
116
+ if (!subscribers.has(resource)) {
117
+ subscribers.set(resource, new Set());
118
+ }
119
+ subscribers.get(resource)!.add(callback);
120
+
121
+ // Connect if not already
122
+ if (!eventSource && typeof EventSource !== 'undefined') {
123
+ connect();
124
+ }
125
+ // Add named listener for this resource if connected
126
+ if (eventSource && resource !== '*') {
127
+ addNamedListener(resource);
128
+ }
129
+
130
+ return () => {
131
+ const callbacks = subscribers.get(resource);
132
+ if (callbacks) {
133
+ callbacks.delete(callback);
134
+ if (callbacks.size === 0) subscribers.delete(resource);
135
+ }
136
+ if (subscribers.size === 0) disconnect();
137
+ };
138
+ },
139
+
140
+ disconnect,
141
+
142
+ getStatus() {
143
+ return status;
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,141 @@
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';
9
+
10
+ export interface WebSocketLiveProviderOptions {
11
+ /** WebSocket server URL, e.g. "wss://api.example.com/ws" */
12
+ url: string;
13
+ /** Reconnect delay in ms (default 3000) */
14
+ reconnectDelay?: number;
15
+ /** Max reconnect attempts (default Infinity) */
16
+ maxReconnects?: number;
17
+ /** Optional callback when connection opens */
18
+ onOpen?: () => void;
19
+ /** Optional callback when connection closes */
20
+ onClose?: (event: CloseEvent) => void;
21
+ /** Optional callback for connection errors */
22
+ onError?: (event: Event) => void;
23
+ }
24
+
25
+ export function createWebSocketLiveProvider(options: WebSocketLiveProviderOptions): LiveProvider & { disconnect: () => void; getStatus: () => 'connecting' | 'connected' | 'disconnected' } {
26
+ const { url, reconnectDelay = 3000, maxReconnects = Infinity } = options;
27
+
28
+ type Callback = (event: LiveEvent) => void;
29
+ const subscribers = new Map<string, Set<Callback>>();
30
+ let ws: WebSocket | null = null;
31
+ let reconnectAttempts = 0;
32
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
33
+ let status: 'connecting' | 'connected' | 'disconnected' = 'disconnected';
34
+ let intentionalClose = false;
35
+
36
+ function connect() {
37
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
38
+
39
+ status = 'connecting';
40
+ intentionalClose = false;
41
+
42
+ try {
43
+ ws = new WebSocket(url);
44
+ } catch {
45
+ status = 'disconnected';
46
+ scheduleReconnect();
47
+ return;
48
+ }
49
+
50
+ ws.onopen = () => {
51
+ status = 'connected';
52
+ reconnectAttempts = 0;
53
+ options.onOpen?.();
54
+ };
55
+
56
+ ws.onmessage = (msgEvent) => {
57
+ try {
58
+ const event: LiveEvent = JSON.parse(msgEvent.data);
59
+ if (event.type && event.resource) {
60
+ const callbacks = subscribers.get(event.resource);
61
+ if (callbacks) {
62
+ for (const cb of callbacks) cb(event);
63
+ }
64
+ // Also notify wildcard '*' subscribers
65
+ const wildcardCallbacks = subscribers.get('*');
66
+ if (wildcardCallbacks) {
67
+ for (const cb of wildcardCallbacks) cb(event);
68
+ }
69
+ }
70
+ } catch {
71
+ // ignore non-JSON messages
72
+ }
73
+ };
74
+
75
+ ws.onclose = (event) => {
76
+ status = 'disconnected';
77
+ options.onClose?.(event);
78
+ if (!intentionalClose) scheduleReconnect();
79
+ };
80
+
81
+ ws.onerror = (event) => {
82
+ options.onError?.(event);
83
+ };
84
+ }
85
+
86
+ function scheduleReconnect() {
87
+ if (reconnectAttempts >= maxReconnects) return;
88
+ if (reconnectTimer) clearTimeout(reconnectTimer);
89
+
90
+ reconnectAttempts++;
91
+ reconnectTimer = setTimeout(connect, reconnectDelay);
92
+ }
93
+
94
+ function disconnect() {
95
+ intentionalClose = true;
96
+ if (reconnectTimer) {
97
+ clearTimeout(reconnectTimer);
98
+ reconnectTimer = null;
99
+ }
100
+ if (ws) {
101
+ ws.close();
102
+ ws = null;
103
+ }
104
+ status = 'disconnected';
105
+ }
106
+
107
+ // Auto-connect on creation
108
+ if (typeof WebSocket !== 'undefined') {
109
+ connect();
110
+ }
111
+
112
+ return {
113
+ subscribe({ resource, callback }) {
114
+ if (!subscribers.has(resource)) {
115
+ subscribers.set(resource, new Set());
116
+ }
117
+ subscribers.get(resource)!.add(callback);
118
+
119
+ // Ensure connection is open
120
+ if (status === 'disconnected' && typeof WebSocket !== 'undefined') {
121
+ connect();
122
+ }
123
+
124
+ return () => {
125
+ const callbacks = subscribers.get(resource);
126
+ if (callbacks) {
127
+ callbacks.delete(callback);
128
+ if (callbacks.size === 0) subscribers.delete(resource);
129
+ }
130
+ // Auto-disconnect if no subscribers left
131
+ if (subscribers.size === 0) disconnect();
132
+ };
133
+ },
134
+
135
+ disconnect,
136
+
137
+ getStatus() {
138
+ return status;
139
+ },
140
+ };
141
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * LiveProvider unit tests
3
+ * Tests the subscription management logic without requiring actual WebSocket/SSE connections.
4
+ */
5
+ import { describe, test, expect, mock } from 'bun:test';
6
+ import type { LiveProvider, LiveEvent } from './live';
7
+
8
+ // Helper: create a minimal in-memory LiveProvider for testing subscription logic
9
+ function createMockLiveProvider(): LiveProvider & { emit: (event: LiveEvent) => void } {
10
+ type Callback = (event: LiveEvent) => void;
11
+ const subscribers = new Map<string, Set<Callback>>();
12
+
13
+ return {
14
+ subscribe({ resource, callback }) {
15
+ if (!subscribers.has(resource)) {
16
+ subscribers.set(resource, new Set());
17
+ }
18
+ subscribers.get(resource)!.add(callback);
19
+
20
+ return () => {
21
+ const callbacks = subscribers.get(resource);
22
+ if (callbacks) {
23
+ callbacks.delete(callback);
24
+ if (callbacks.size === 0) subscribers.delete(resource);
25
+ }
26
+ };
27
+ },
28
+
29
+ emit(event: LiveEvent) {
30
+ const callbacks = subscribers.get(event.resource);
31
+ if (callbacks) {
32
+ for (const cb of callbacks) cb(event);
33
+ }
34
+ // Wildcard
35
+ const wildcardCallbacks = subscribers.get('*');
36
+ if (wildcardCallbacks) {
37
+ for (const cb of wildcardCallbacks) cb(event);
38
+ }
39
+ },
40
+ };
41
+ }
42
+
43
+ describe('LiveProvider subscription', () => {
44
+ test('subscribe receives events for correct resource', () => {
45
+ const provider = createMockLiveProvider();
46
+ const received: LiveEvent[] = [];
47
+
48
+ provider.subscribe({
49
+ resource: 'posts',
50
+ callback: (event) => received.push(event),
51
+ });
52
+
53
+ provider.emit({ type: 'INSERT', resource: 'posts', payload: { id: 1 } });
54
+ provider.emit({ type: 'UPDATE', resource: 'comments', payload: { id: 2 } });
55
+
56
+ expect(received.length).toBe(1);
57
+ expect(received[0].type).toBe('INSERT');
58
+ expect(received[0].resource).toBe('posts');
59
+ });
60
+
61
+ test('wildcard subscriber receives all events', () => {
62
+ const provider = createMockLiveProvider();
63
+ const received: LiveEvent[] = [];
64
+
65
+ provider.subscribe({
66
+ resource: '*',
67
+ callback: (event) => received.push(event),
68
+ });
69
+
70
+ provider.emit({ type: 'INSERT', resource: 'posts', payload: {} });
71
+ provider.emit({ type: 'DELETE', resource: 'users', payload: {} });
72
+
73
+ expect(received.length).toBe(2);
74
+ expect(received[0].resource).toBe('posts');
75
+ expect(received[1].resource).toBe('users');
76
+ });
77
+
78
+ test('unsubscribe stops receiving events', () => {
79
+ const provider = createMockLiveProvider();
80
+ const received: LiveEvent[] = [];
81
+
82
+ const unsub = provider.subscribe({
83
+ resource: 'posts',
84
+ callback: (event) => received.push(event),
85
+ });
86
+
87
+ provider.emit({ type: 'INSERT', resource: 'posts', payload: {} });
88
+ expect(received.length).toBe(1);
89
+
90
+ unsub();
91
+
92
+ provider.emit({ type: 'UPDATE', resource: 'posts', payload: {} });
93
+ expect(received.length).toBe(1); // No new events after unsub
94
+ });
95
+
96
+ test('multiple subscribers for same resource', () => {
97
+ const provider = createMockLiveProvider();
98
+ const received1: LiveEvent[] = [];
99
+ const received2: LiveEvent[] = [];
100
+
101
+ provider.subscribe({ resource: 'posts', callback: (e) => received1.push(e) });
102
+ provider.subscribe({ resource: 'posts', callback: (e) => received2.push(e) });
103
+
104
+ provider.emit({ type: 'INSERT', resource: 'posts', payload: { id: 1 } });
105
+
106
+ expect(received1.length).toBe(1);
107
+ expect(received2.length).toBe(1);
108
+ });
109
+
110
+ test('LiveEvent types are correct', () => {
111
+ const insertEvent: LiveEvent = { type: 'INSERT', resource: 'posts', payload: { title: 'New' } };
112
+ const updateEvent: LiveEvent = { type: 'UPDATE', resource: 'posts', payload: { title: 'Updated' } };
113
+ const deleteEvent: LiveEvent = { type: 'DELETE', resource: 'posts', payload: { id: 1 } };
114
+
115
+ expect(insertEvent.type).toBe('INSERT');
116
+ expect(updateEvent.type).toBe('UPDATE');
117
+ expect(deleteEvent.type).toBe('DELETE');
118
+ });
119
+
120
+ test('unsubscribing one does not affect others', () => {
121
+ const provider = createMockLiveProvider();
122
+ const received1: LiveEvent[] = [];
123
+ const received2: LiveEvent[] = [];
124
+
125
+ const unsub1 = provider.subscribe({ resource: 'posts', callback: (e) => received1.push(e) });
126
+ provider.subscribe({ resource: 'posts', callback: (e) => received2.push(e) });
127
+
128
+ unsub1();
129
+ provider.emit({ type: 'INSERT', resource: 'posts', payload: {} });
130
+
131
+ expect(received1.length).toBe(0);
132
+ expect(received2.length).toBe(1);
133
+ });
134
+ });