@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.
- package/package.json +4 -1
- package/src/audit.ts +37 -0
- package/src/auth-hooks.svelte.ts +293 -0
- package/src/auth-hooks.test.ts +205 -0
- package/src/context.ts +77 -15
- package/src/data-transfer.test.ts +118 -0
- package/src/data-transfer.ts +148 -70
- package/src/form-hooks.svelte.ts +266 -0
- package/src/helpers.ts +157 -0
- package/src/hook-utils.svelte.ts +121 -0
- package/src/hooks.svelte.ts +278 -496
- package/src/http-error.test.ts +85 -0
- package/src/i18n.svelte.ts +24 -4
- package/src/i18n.test.ts +90 -0
- package/src/index.ts +51 -8
- package/src/inferencer-openapi.test.ts +192 -0
- package/src/inferencer-openapi.ts +290 -0
- package/src/inferencer.test.ts +189 -0
- package/src/inferencer.ts +247 -0
- package/src/live-sse.ts +146 -0
- package/src/live-websocket.ts +141 -0
- package/src/live.test.ts +134 -0
- package/src/live.ts +60 -8
- package/src/mutation-hooks.svelte.ts +301 -0
- package/src/options.ts +95 -0
- package/src/permissions.test.ts +55 -0
- package/src/query-hooks.svelte.ts +213 -0
- package/src/query-keys.ts +22 -0
- package/src/resource-types.test.ts +79 -0
- package/src/router-provider.test.ts +71 -0
- package/src/router-provider.ts +100 -0
- package/src/router.ts +28 -4
- package/src/routing-hooks.svelte.ts +99 -0
- package/src/table-hooks.svelte.ts +178 -0
- package/src/toast.svelte.ts +8 -4
- package/src/types.ts +124 -47
- package/src/unsaved-changes.svelte.ts +62 -0
- package/src/url-sync.ts +15 -0
- package/src/useParsed.ts +110 -0
- package/src/useStepsForm.svelte.ts +71 -0
- 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
|
+
}
|
package/src/live-sse.ts
ADDED
|
@@ -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
|
+
}
|
package/src/live.test.ts
ADDED
|
@@ -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
|
+
});
|