@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,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,189 @@
1
+ // Unit tests for Inferencer
2
+ import { describe, test, expect } from 'bun:test';
3
+ import { inferFieldType, inferResource } from './inferencer';
4
+
5
+ describe('inferFieldType', () => {
6
+ test('detects boolean', () => {
7
+ expect(inferFieldType('active', true)).toBe('boolean');
8
+ });
9
+
10
+ test('detects number', () => {
11
+ expect(inferFieldType('age', 25)).toBe('number');
12
+ });
13
+
14
+ test('detects email', () => {
15
+ expect(inferFieldType('email', 'user@example.com')).toBe('email');
16
+ });
17
+
18
+ test('detects email from key name', () => {
19
+ expect(inferFieldType('user_email', 'test')).toBe('email');
20
+ });
21
+
22
+ test('detects URL', () => {
23
+ expect(inferFieldType('website', 'https://example.com')).toBe('url');
24
+ });
25
+
26
+ test('detects image URL', () => {
27
+ expect(inferFieldType('avatar', 'https://example.com/photo.jpg')).toBe('image');
28
+ });
29
+
30
+ test('detects image from key name', () => {
31
+ expect(inferFieldType('avatar', '/uploads/user.png')).toBe('image');
32
+ });
33
+
34
+ test('detects date ISO string', () => {
35
+ expect(inferFieldType('created', '2024-01-15T10:30:00Z')).toBe('date');
36
+ });
37
+
38
+ test('detects date from key name', () => {
39
+ expect(inferFieldType('created_at', 'some value')).toBe('date');
40
+ });
41
+
42
+ test('detects phone', () => {
43
+ expect(inferFieldType('phone', '+1 (555) 123-4567')).toBe('phone');
44
+ });
45
+
46
+ test('detects color hex', () => {
47
+ expect(inferFieldType('color', '#ff5533')).toBe('color');
48
+ });
49
+
50
+ test('detects textarea for long strings', () => {
51
+ expect(inferFieldType('content', 'a'.repeat(250))).toBe('textarea');
52
+ });
53
+
54
+ test('detects textarea from key name', () => {
55
+ expect(inferFieldType('description', 'short')).toBe('textarea');
56
+ });
57
+
58
+ test('detects tags array', () => {
59
+ expect(inferFieldType('tags', ['svelte', 'admin'])).toBe('tags');
60
+ });
61
+
62
+ test('detects images array', () => {
63
+ expect(inferFieldType('photos', ['https://x.com/a.png', 'https://x.com/b.jpg'])).toBe('images');
64
+ });
65
+
66
+ test('detects json object', () => {
67
+ expect(inferFieldType('meta', { foo: 'bar' })).toBe('json');
68
+ });
69
+
70
+ test('null defaults to text', () => {
71
+ expect(inferFieldType('unknown', null)).toBe('text');
72
+ });
73
+
74
+ test('plain string defaults to text', () => {
75
+ expect(inferFieldType('title', 'Hello World')).toBe('text');
76
+ });
77
+ });
78
+
79
+ describe('inferResource', () => {
80
+ const sampleData = [
81
+ {
82
+ id: 1,
83
+ title: 'First Post',
84
+ content: 'A very long description that is more than two hundred characters. '.repeat(4),
85
+ author_id: 5,
86
+ status: 'published',
87
+ views: 100,
88
+ is_featured: true,
89
+ created_at: '2024-01-15T10:00:00Z',
90
+ email: 'author@blog.com',
91
+ tags: ['svelte', 'admin'],
92
+ },
93
+ {
94
+ id: 2,
95
+ title: 'Second Post',
96
+ content: 'Another long body text that exceeds the 200 char limit easily. '.repeat(5),
97
+ author_id: 3,
98
+ status: 'draft',
99
+ views: 42,
100
+ is_featured: false,
101
+ created_at: '2024-02-20T14:30:00Z',
102
+ email: 'editor@blog.com',
103
+ tags: ['react'],
104
+ },
105
+ ];
106
+
107
+ test('returns correct resource name and label', () => {
108
+ const result = inferResource('posts', sampleData);
109
+ expect(result.resource.name).toBe('posts');
110
+ expect(result.resource.label).toBe('Posts');
111
+ });
112
+
113
+ test('infers correct number of fields', () => {
114
+ const result = inferResource('posts', sampleData);
115
+ expect(result.fields.length).toBe(10); // id, title, content, author_id, status, views, is_featured, created_at, email, tags
116
+ });
117
+
118
+ test('detects id as primary key', () => {
119
+ const result = inferResource('posts', sampleData);
120
+ const idField = result.fields.find(f => f.key === 'id');
121
+ expect(idField).toBeDefined();
122
+ expect(idField!.showInForm).toBe(false);
123
+ });
124
+
125
+ test('detects number type for views', () => {
126
+ const result = inferResource('posts', sampleData);
127
+ const viewsField = result.fields.find(f => f.key === 'views');
128
+ expect(viewsField!.type).toBe('number');
129
+ });
130
+
131
+ test('detects boolean type for is_featured', () => {
132
+ const result = inferResource('posts', sampleData);
133
+ const field = result.fields.find(f => f.key === 'is_featured');
134
+ expect(field!.type).toBe('boolean');
135
+ });
136
+
137
+ test('detects date for created_at', () => {
138
+ const result = inferResource('posts', sampleData);
139
+ const field = result.fields.find(f => f.key === 'created_at');
140
+ expect(field!.type).toBe('date');
141
+ });
142
+
143
+ test('detects relation for author_id', () => {
144
+ const result = inferResource('posts', sampleData);
145
+ const field = result.fields.find(f => f.key === 'author_id');
146
+ expect(field!.type).toBe('relation');
147
+ expect(field!.resource).toBe('authors');
148
+ });
149
+
150
+ test('detects email type', () => {
151
+ const result = inferResource('posts', sampleData);
152
+ const field = result.fields.find(f => f.key === 'email');
153
+ expect(field!.type).toBe('email');
154
+ });
155
+
156
+ test('detects tags array', () => {
157
+ const result = inferResource('posts', sampleData);
158
+ const field = result.fields.find(f => f.key === 'tags');
159
+ expect(field!.type).toBe('tags');
160
+ });
161
+
162
+ test('detects textarea for content', () => {
163
+ const result = inferResource('posts', sampleData);
164
+ const field = result.fields.find(f => f.key === 'content');
165
+ expect(field!.type).toBe('textarea');
166
+ expect(field!.showInList).toBe(false);
167
+ });
168
+
169
+ test('generates valid TypeScript code', () => {
170
+ const result = inferResource('posts', sampleData);
171
+ expect(result.code).toContain("name: 'posts'");
172
+ expect(result.code).toContain("import type { ResourceDefinition }");
173
+ expect(result.code).toContain("export const postsResource");
174
+ });
175
+
176
+ test('handles empty data gracefully', () => {
177
+ const result = inferResource('empty', []);
178
+ expect(result.fields.length).toBe(0);
179
+ expect(result.code).toContain('No data available');
180
+ });
181
+
182
+ test('custom primaryKey', () => {
183
+ const data = [{ _id: 'abc', name: 'Test' }];
184
+ const result = inferResource('items', data, { primaryKey: '_id' });
185
+ expect(result.resource.primaryKey).toBe('_id');
186
+ const pkField = result.fields.find(f => f.key === '_id');
187
+ expect(pkField!.showInForm).toBe(false);
188
+ });
189
+ });