@svadmin/core 0.0.6 → 0.4.1

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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * OpenAPI Schema → ResourceDefinition converter
3
+ *
4
+ * Parses an OpenAPI 3.x JSON schema and generates ResourceDefinition[]
5
+ * by analyzing the schema's paths and components.
6
+ */
7
+ import type { FieldDefinition, ResourceDefinition } from './types';
8
+
9
+ // ─── OpenAPI Types (subset) ──────────────────────────────────
10
+
11
+ interface OpenAPISchema {
12
+ openapi?: string;
13
+ info?: { title?: string; version?: string };
14
+ paths?: Record<string, OpenAPIPathItem>;
15
+ components?: {
16
+ schemas?: Record<string, OpenAPISchemaObject>;
17
+ };
18
+ }
19
+
20
+ interface OpenAPIPathItem {
21
+ get?: OpenAPIOperation;
22
+ post?: OpenAPIOperation;
23
+ put?: OpenAPIOperation;
24
+ patch?: OpenAPIOperation;
25
+ delete?: OpenAPIOperation;
26
+ }
27
+
28
+ interface OpenAPIOperation {
29
+ operationId?: string;
30
+ tags?: string[];
31
+ parameters?: OpenAPIParameter[];
32
+ requestBody?: {
33
+ content?: Record<string, { schema?: OpenAPISchemaObject | OpenAPIRef }>;
34
+ };
35
+ responses?: Record<string, {
36
+ content?: Record<string, { schema?: OpenAPISchemaObject | OpenAPIRef }>;
37
+ }>;
38
+ }
39
+
40
+ interface OpenAPIParameter {
41
+ name: string;
42
+ in: string;
43
+ required?: boolean;
44
+ schema?: OpenAPISchemaObject;
45
+ }
46
+
47
+ interface OpenAPISchemaObject {
48
+ type?: string;
49
+ format?: string;
50
+ properties?: Record<string, OpenAPISchemaObject | OpenAPIRef>;
51
+ items?: OpenAPISchemaObject | OpenAPIRef;
52
+ required?: string[];
53
+ enum?: (string | number)[];
54
+ $ref?: string;
55
+ description?: string;
56
+ nullable?: boolean;
57
+ }
58
+
59
+ interface OpenAPIRef {
60
+ $ref: string;
61
+ }
62
+
63
+ function isRef(obj: unknown): obj is OpenAPIRef {
64
+ return obj !== null && typeof obj === 'object' && '$ref' in obj && typeof (obj as OpenAPIRef).$ref === 'string';
65
+ }
66
+
67
+ // ─── Schema Resolution ───────────────────────────────────────
68
+
69
+ function resolveRef(ref: string, root: OpenAPISchema): OpenAPISchemaObject | null {
70
+ // $ref format: "#/components/schemas/Post"
71
+ const parts = ref.replace('#/', '').split('/');
72
+ let current: unknown = root;
73
+ for (const part of parts) {
74
+ if (current === null || typeof current !== 'object') return null;
75
+ current = (current as Record<string, unknown>)[part];
76
+ if (!current) return null;
77
+ }
78
+ return current as OpenAPISchemaObject;
79
+ }
80
+
81
+ function resolveSchema(schemaOrRef: OpenAPISchemaObject | OpenAPIRef, root: OpenAPISchema): OpenAPISchemaObject | null {
82
+ if (isRef(schemaOrRef)) {
83
+ return resolveRef(schemaOrRef.$ref, root);
84
+ }
85
+ return schemaOrRef;
86
+ }
87
+
88
+ // ─── Type Mapping ────────────────────────────────────────────
89
+
90
+ function mapOpenAPITypeToFieldType(
91
+ key: string,
92
+ schema: OpenAPISchemaObject,
93
+ root: OpenAPISchema
94
+ ): FieldDefinition['type'] {
95
+ const { type, format } = schema;
96
+
97
+ // Enum → select
98
+ if (schema.enum && schema.enum.length > 0) return 'select';
99
+
100
+ // Array types
101
+ if (type === 'array') {
102
+ if (schema.items) {
103
+ const itemSchema = resolveSchema(schema.items, root);
104
+ if (itemSchema?.type === 'string') {
105
+ const lk = key.toLowerCase();
106
+ if (lk.includes('image') || lk.includes('photo')) return 'images';
107
+ return 'tags';
108
+ }
109
+ }
110
+ return 'tags';
111
+ }
112
+
113
+ // Object → json
114
+ if (type === 'object') return 'json';
115
+
116
+ // Boolean
117
+ if (type === 'boolean') return 'boolean';
118
+
119
+ // Number/Integer
120
+ if (type === 'number' || type === 'integer') return 'number';
121
+
122
+ // String with format
123
+ if (type === 'string') {
124
+ if (format === 'date' || format === 'date-time') return 'date';
125
+ if (format === 'email') return 'email';
126
+ if (format === 'uri' || format === 'url') return 'url';
127
+ if (format === 'binary' || format === 'byte') return 'image';
128
+
129
+ // Heuristic from key name
130
+ const lk = key.toLowerCase();
131
+ if (lk.includes('email')) return 'email';
132
+ if (lk.includes('phone') || lk.includes('tel')) return 'phone';
133
+ if (lk.includes('url') || lk.includes('link') || lk.includes('website')) return 'url';
134
+ if (lk.includes('avatar') || lk.includes('image') || lk.includes('photo') || lk.includes('logo')) return 'image';
135
+ if (lk.includes('color') || lk.includes('colour')) return 'color';
136
+ if (lk.includes('description') || lk.includes('content') || lk.includes('body') || lk.includes('bio')) return 'textarea';
137
+ if (lk === 'created_at' || lk === 'updated_at' || lk.endsWith('_at') || lk.endsWith('_date')) return 'date';
138
+
139
+ return 'text';
140
+ }
141
+
142
+ return 'text';
143
+ }
144
+
145
+ // ─── Core: Parse OpenAPI Schema ──────────────────────────────
146
+
147
+ export interface InferFromOpenAPIOptions {
148
+ /** Only infer resources matching these schema names */
149
+ include?: string[];
150
+ /** Exclude these schema names */
151
+ exclude?: string[];
152
+ /** Custom primary key (default: 'id') */
153
+ primaryKey?: string;
154
+ }
155
+
156
+ /**
157
+ * Parse an OpenAPI 3.x JSON schema and produce ResourceDefinition[].
158
+ */
159
+ export function inferFromOpenAPI(
160
+ spec: OpenAPISchema,
161
+ options: InferFromOpenAPIOptions = {}
162
+ ): ResourceDefinition[] {
163
+ const { primaryKey = 'id', include, exclude } = options;
164
+ const schemas = spec.components?.schemas ?? {};
165
+ const resources: ResourceDefinition[] = [];
166
+
167
+ // Detect which schemas have CRUD paths
168
+ const resourcePaths = detectResourcePaths(spec);
169
+
170
+ for (const [schemaName, schemaObj] of Object.entries(schemas)) {
171
+ // Apply include/exclude filters
172
+ if (include && !include.includes(schemaName)) continue;
173
+ if (exclude && exclude.includes(schemaName)) continue;
174
+
175
+ const resolved = resolveSchema(schemaObj as OpenAPISchemaObject | OpenAPIRef, spec);
176
+ if (!resolved || resolved.type !== 'object' || !resolved.properties) continue;
177
+
178
+ const fields: FieldDefinition[] = [];
179
+ const requiredFields = new Set(resolved.required ?? []);
180
+
181
+ for (const [propName, propSchemaOrRef] of Object.entries(resolved.properties)) {
182
+ const propSchema = resolveSchema(propSchemaOrRef, spec);
183
+ if (!propSchema) continue;
184
+
185
+ const fieldType = mapOpenAPITypeToFieldType(propName, propSchema, spec);
186
+
187
+ // Detect relation from $ref or _id suffix
188
+ let relatedResource: string | null = null;
189
+ if (isRef(propSchemaOrRef)) {
190
+ const refName = propSchemaOrRef.$ref.split('/').pop() ?? '';
191
+ relatedResource = refName.toLowerCase() + 's';
192
+ } else if (propName.endsWith('_id') || propName.endsWith('Id')) {
193
+ const base = propName.replace(/_id$|Id$/, '');
194
+ relatedResource = base.endsWith('s') ? base : base + 's';
195
+ }
196
+
197
+ const field: FieldDefinition = {
198
+ key: propName,
199
+ label: humanize(propName),
200
+ type: relatedResource ? 'relation' : fieldType,
201
+ required: requiredFields.has(propName),
202
+ sortable: ['text', 'number', 'date'].includes(fieldType),
203
+ searchable: ['text', 'email'].includes(fieldType),
204
+ showInList: propName !== primaryKey && fieldType !== 'textarea' && fieldType !== 'json',
205
+ showInForm: propName !== primaryKey,
206
+ };
207
+
208
+ if (relatedResource) {
209
+ field.resource = relatedResource;
210
+ field.optionLabel = 'name';
211
+ field.optionValue = 'id';
212
+ }
213
+
214
+ if (propSchema.enum) {
215
+ field.options = propSchema.enum.map(v => ({
216
+ label: String(v).charAt(0).toUpperCase() + String(v).slice(1),
217
+ value: v as string | number,
218
+ }));
219
+ }
220
+
221
+ fields.push(field);
222
+ }
223
+
224
+ // Sort: primaryKey first
225
+ fields.sort((a, b) => {
226
+ if (a.key === primaryKey) return -1;
227
+ if (b.key === primaryKey) return 1;
228
+ return 0;
229
+ });
230
+
231
+ // Check path-based CRUD capabilities
232
+ const pathInfo = resourcePaths.get(schemaName.toLowerCase()) ??
233
+ resourcePaths.get(schemaName.toLowerCase() + 's');
234
+
235
+ resources.push({
236
+ name: schemaName.toLowerCase() + 's',
237
+ label: schemaName,
238
+ primaryKey,
239
+ fields,
240
+ canCreate: pathInfo?.hasPost ?? true,
241
+ canEdit: pathInfo?.hasPut ?? true,
242
+ canDelete: pathInfo?.hasDelete ?? true,
243
+ canShow: pathInfo?.hasGet ?? true,
244
+ });
245
+ }
246
+
247
+ return resources;
248
+ }
249
+
250
+ // ─── Path Detection ──────────────────────────────────────────
251
+
252
+ interface PathInfo {
253
+ hasGet: boolean;
254
+ hasPost: boolean;
255
+ hasPut: boolean;
256
+ hasDelete: boolean;
257
+ }
258
+
259
+ function detectResourcePaths(spec: OpenAPISchema): Map<string, PathInfo> {
260
+ const result = new Map<string, PathInfo>();
261
+ if (!spec.paths) return result;
262
+
263
+ for (const [path, methods] of Object.entries(spec.paths)) {
264
+ // Extract resource name from path like /api/posts or /posts/{id}
265
+ const segments = path.split('/').filter(Boolean);
266
+ const resourceSegment = segments.find(s => !s.startsWith('{') && s !== 'api' && s !== 'v1' && s !== 'v2');
267
+ if (!resourceSegment) continue;
268
+
269
+ const name = resourceSegment.toLowerCase();
270
+ const existing = result.get(name) ?? { hasGet: false, hasPost: false, hasPut: false, hasDelete: false };
271
+
272
+ if (methods.get) existing.hasGet = true;
273
+ if (methods.post) existing.hasPost = true;
274
+ if (methods.put || methods.patch) existing.hasPut = true;
275
+ if (methods.delete) existing.hasDelete = true;
276
+
277
+ result.set(name, existing);
278
+ }
279
+
280
+ return result;
281
+ }
282
+
283
+ // ─── Utilities ───────────────────────────────────────────────
284
+
285
+ function humanize(key: string): string {
286
+ return key
287
+ .replace(/_/g, ' ')
288
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
289
+ .replace(/\b\w/g, c => c.toUpperCase());
290
+ }
@@ -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
+ }