@compilr-dev/factory 0.1.6 → 0.1.7

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.
@@ -4,7 +4,7 @@
4
4
  * Generates: server/index.ts, server/routes/{entity}.ts, server/data/{entity}.ts
5
5
  */
6
6
  import { toCamelCase, toKebabCase } from '../../model/naming.js';
7
- import { tsType, fkFieldName, belongsToRels } from './helpers.js';
7
+ import { tsType, fkFieldName, belongsToRels, hasManyRels } from './helpers.js';
8
8
  import { generateSeedData } from './seed.js';
9
9
  export function generateApiFiles(model) {
10
10
  return [
@@ -71,6 +71,23 @@ function generateDataStore(model, entity) {
71
71
  }
72
72
  typeFields.push(' createdAt: string;');
73
73
  typeFields.push(' updatedAt: string;');
74
+ // Collect searchable fields (string + enum types)
75
+ const searchableFields = entity.fields
76
+ .filter((f) => f.type === 'string' || f.type === 'enum')
77
+ .map((f) => `item.${f.name}`);
78
+ const searchFilter = searchableFields.length > 0
79
+ ? ` const q = options.search.toLowerCase();
80
+ result = result.filter((item) =>
81
+ [${searchableFields.join(', ')}].some((v) =>
82
+ v !== undefined && String(v).toLowerCase().includes(q),
83
+ ),
84
+ );`
85
+ : ` const q = options.search.toLowerCase();
86
+ result = result.filter((item) =>
87
+ Object.values(item).some((v) =>
88
+ v !== undefined && String(v).toLowerCase().includes(q),
89
+ ),
90
+ );`;
74
91
  const seedData = generateSeedData(model, entity);
75
92
  return {
76
93
  path: `server/data/${fileName}.ts`,
@@ -80,6 +97,11 @@ export interface ${typeName}Record {
80
97
  ${typeFields.join('\n')}
81
98
  }
82
99
 
100
+ export interface QueryOptions {
101
+ search?: string;
102
+ filters?: Record<string, string>;
103
+ }
104
+
83
105
  ${seedData}
84
106
 
85
107
  let items: ${typeName}Record[] = [...${varName}SeedData];
@@ -93,6 +115,21 @@ export function getById(id: number): ${typeName}Record | undefined {
93
115
  return items.find((item) => item.id === id);
94
116
  }
95
117
 
118
+ export function query(options: QueryOptions): { data: ${typeName}Record[]; total: number } {
119
+ let result = items;
120
+ if (options.search) {
121
+ ${searchFilter}
122
+ }
123
+ if (options.filters) {
124
+ for (const [field, value] of Object.entries(options.filters)) {
125
+ result = result.filter((item) =>
126
+ String((item as Record<string, unknown>)[field]) === value,
127
+ );
128
+ }
129
+ }
130
+ return { data: result, total: result.length };
131
+ }
132
+
96
133
  export function create(data: Omit<${typeName}Record, 'id' | 'createdAt' | 'updatedAt'>): ${typeName}Record {
97
134
  const now = new Date().toISOString();
98
135
  const item = { ...data, id: nextId++, createdAt: now, updatedAt: now };
@@ -124,6 +161,7 @@ function generateRoutes(model, entity) {
124
161
  const varName = toCamelCase(entity.pluralName) + 'Router';
125
162
  const dataImport = fileName;
126
163
  const btoRels = belongsToRels(entity);
164
+ const hmRels = hasManyRels(entity);
127
165
  // Populate belongsTo entities
128
166
  const populateImports = btoRels
129
167
  .map((rel) => {
@@ -135,6 +173,17 @@ function generateRoutes(model, entity) {
135
173
  })
136
174
  .filter(Boolean)
137
175
  .join('\n');
176
+ // Aliased getAll imports for hasMany targets (used by ?include=)
177
+ const hasManyImports = hmRels
178
+ .map((rel) => {
179
+ const targetEntity = model.entities.find((e) => e.name === rel.target);
180
+ if (!targetEntity)
181
+ return '';
182
+ const targetFile = toKebabCase(targetEntity.pluralName).toLowerCase();
183
+ return `import { getAll as getAll${targetEntity.pluralName} } from '../data/${targetFile}.js';`;
184
+ })
185
+ .filter(Boolean)
186
+ .join('\n');
138
187
  const populateLogic = btoRels.length > 0
139
188
  ? `
140
189
  function populate(item: Record<string, unknown>): Record<string, unknown> {
@@ -151,30 +200,64 @@ ${btoRels
151
200
  `
152
201
  : '';
153
202
  const getListReturn = btoRels.length > 0
154
- ? 'res.json(items.map((item) => populate(item as unknown as Record<string, unknown>)));'
155
- : 'res.json(items);';
156
- const getByIdReturn = btoRels.length > 0
157
- ? 'res.json(populate(item as unknown as Record<string, unknown>));'
158
- : 'res.json(item);';
203
+ ? 'res.json({ data: result.data.map((item) => populate(item as unknown as Record<string, unknown>)), total: result.total });'
204
+ : 'res.json({ data: result.data, total: result.total });';
205
+ // GET by ID with optional ?include= for hasMany
206
+ let getByIdBody;
207
+ if (hmRels.length > 0) {
208
+ const includeChecks = hmRels
209
+ .map((rel) => {
210
+ const targetEntity = model.entities.find((e) => e.name === rel.target);
211
+ if (!targetEntity)
212
+ return '';
213
+ const targetVar = toCamelCase(targetEntity.pluralName);
214
+ const fkRel = targetEntity.relationships.find((r) => r.type === 'belongsTo' && r.target === entity.name) ?? { type: 'belongsTo', target: entity.name };
215
+ const fk = fkFieldName(fkRel);
216
+ return ` if (include.includes('${targetVar}')) {
217
+ result.${targetVar} = getAll${targetEntity.pluralName}().filter((r) => r.${fk} === item.id);
218
+ }`;
219
+ })
220
+ .filter(Boolean)
221
+ .join('\n');
222
+ const populateExpr = btoRels.length > 0 ? 'populate(item as unknown as Record<string, unknown>)' : '{ ...item }';
223
+ getByIdBody = ` const item = getById(Number(req.params.id));
224
+ if (!item) return res.status(404).json({ error: 'Not found' });
225
+ const include = (req.query.include as string ?? '').split(',').filter(Boolean);
226
+ const result: Record<string, unknown> = ${populateExpr};
227
+ ${includeChecks}
228
+ res.json(result);`;
229
+ }
230
+ else {
231
+ const getByIdReturn = btoRels.length > 0
232
+ ? 'res.json(populate(item as unknown as Record<string, unknown>));'
233
+ : 'res.json(item);';
234
+ getByIdBody = ` const item = getById(Number(req.params.id));
235
+ if (!item) return res.status(404).json({ error: 'Not found' });
236
+ ${getByIdReturn}`;
237
+ }
159
238
  return {
160
239
  path: `server/routes/${fileName}.ts`,
161
240
  content: `import { Router } from 'express';
162
- import { getAll, getById, create, update, remove } from '../data/${dataImport}.js';
163
- ${populateImports}
241
+ import { getAll, getById, query, create, update, remove } from '../data/${dataImport}.js';
242
+ ${populateImports}${hasManyImports ? '\n' + hasManyImports : ''}
164
243
 
165
244
  export const ${varName} = Router();
166
245
  ${populateLogic}
167
246
  // GET all
168
- ${varName}.get('/', (_req, res) => {
169
- const items = getAll();
247
+ ${varName}.get('/', (req, res) => {
248
+ const search = req.query.search as string | undefined;
249
+ const filters: Record<string, string> = {};
250
+ for (const [key, value] of Object.entries(req.query)) {
251
+ const match = key.match(/^filter\\[(.+)\\]$/);
252
+ if (match && typeof value === 'string') filters[match[1]] = value;
253
+ }
254
+ const result = query({ search, filters });
170
255
  ${getListReturn}
171
256
  });
172
257
 
173
258
  // GET by ID
174
259
  ${varName}.get('/:id', (req, res) => {
175
- const item = getById(Number(req.params.id));
176
- if (!item) return res.status(404).json({ error: 'Not found' });
177
- ${getByIdReturn}
260
+ ${getByIdBody}
178
261
  });
179
262
 
180
263
  // POST create
@@ -26,7 +26,12 @@ function generateListPage(model, entity) {
26
26
  const hasFilters = enumFields.length > 0;
27
27
  const tableHeaders = entity.fields
28
28
  .slice(0, 5)
29
- .map((f) => ` <th className="px-4 py-2 text-left text-sm font-medium text-gray-500 dark:text-gray-400">${f.label}</th>`)
29
+ .map((f) => ` <th
30
+ className="cursor-pointer select-none px-4 py-2 text-left text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
31
+ onClick={() => handleSort('${f.name}')}
32
+ >
33
+ ${f.label}{sortField === '${f.name}' ? (sortDirection === 'asc' ? ' ↑' : ' ↓') : ''}
34
+ </th>`)
30
35
  .join('\n');
31
36
  const tableCells = entity.fields
32
37
  .slice(0, 5)
@@ -45,16 +50,27 @@ ${hasFilters ? `const filterOptions = [\n${filterDefs}\n];\n` : ''}
45
50
  export default function ${entity.name}List() {
46
51
  const [items, setItems] = useState<${entity.name}[]>([]);
47
52
  const [view, setView] = useState<'card' | 'list'>('card');
48
- const [search, setSearch] = useState('');${hasFilters ? '\n const [filters, setFilters] = useState<Record<string, string>>({});' : ''}
53
+ const [search, setSearch] = useState('');
54
+ const [sortField, setSortField] = useState<string>('');
55
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');${hasFilters ? '\n const [filters, setFilters] = useState<Record<string, string>>({});' : ''}
49
56
 
50
57
  useEffect(() => {
51
58
  fetch('${api}')
52
59
  .then((r) => r.json())
53
- .then((data: ${entity.name}[]) => setItems(data));
60
+ .then((res: { data: ${entity.name}[]; total: number }) => setItems(res.data));
54
61
  }, []);
55
62
 
56
63
  const onSearch = useCallback((q: string) => setSearch(q), []);
57
64
 
65
+ const handleSort = useCallback((field: string) => {
66
+ if (sortField === field) {
67
+ setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
68
+ } else {
69
+ setSortField(field);
70
+ setSortDirection('asc');
71
+ }
72
+ }, [sortField]);
73
+
58
74
  const filtered = items.filter((item) => {
59
75
  if (search) {
60
76
  const q = search.toLowerCase();
@@ -71,6 +87,14 @@ export default function ${entity.name}List() {
71
87
  return true;
72
88
  });
73
89
 
90
+ const sorted = [...filtered].sort((a, b) => {
91
+ if (!sortField) return 0;
92
+ const aVal = (a as Record<string, unknown>)[sortField];
93
+ const bVal = (b as Record<string, unknown>)[sortField];
94
+ const cmp = String(aVal ?? '').localeCompare(String(bVal ?? ''), undefined, { numeric: true });
95
+ return sortDirection === 'asc' ? cmp : -cmp;
96
+ });
97
+
74
98
  return (
75
99
  <div>
76
100
  <div className="mb-6 flex items-center justify-between">
@@ -93,7 +117,7 @@ export default function ${entity.name}List() {
93
117
 
94
118
  {view === 'card' ? (
95
119
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
96
- {filtered.map((item) => (
120
+ {sorted.map((item) => (
97
121
  <${entity.name}Card key={item.id} item={item} />
98
122
  ))}
99
123
  </div>
@@ -105,7 +129,7 @@ ${tableHeaders}
105
129
  </tr>
106
130
  </thead>
107
131
  <tbody>
108
- {filtered.map((item) => (
132
+ {sorted.map((item) => (
109
133
  <tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50 dark:border-gray-800 dark:hover:bg-gray-800/50">
110
134
  ${tableCells}
111
135
  <td className="px-4 py-2">
@@ -160,22 +184,44 @@ function generateDetailPage(model, entity) {
160
184
  const [${stateVar}, set${targetEntity.pluralName}] = useState<${rel.target}[]>([]);`;
161
185
  })
162
186
  .join('');
163
- const hasManyFetches = hmRels
187
+ // Build ?include= param for hasMany targets
188
+ const includeTargets = hmRels
189
+ .map((rel) => {
190
+ const targetEntity = model.entities.find((e) => e.name === rel.target);
191
+ return targetEntity ? toCamelCase(targetEntity.pluralName) : '';
192
+ })
193
+ .filter(Boolean);
194
+ const includeParam = includeTargets.length > 0 ? `?include=${includeTargets.join(',')}` : '';
195
+ const hasManySetters = hmRels
164
196
  .map((rel) => {
165
197
  const targetEntity = model.entities.find((e) => e.name === rel.target);
166
198
  if (!targetEntity)
167
199
  return '';
168
- const fetchApi = apiPath(targetEntity);
169
- const fk = fkFieldName(targetEntity.relationships.find((r) => r.type === 'belongsTo' && r.target === entity.name) ?? {
170
- type: 'belongsTo',
171
- target: entity.name,
172
- });
173
- return `
174
- fetch('${fetchApi}')
175
- .then((r) => r.json())
176
- .then((data: ${rel.target}[]) => set${targetEntity.pluralName}(data.filter((d) => d.${fk} === Number(id))));`;
200
+ const varName = toCamelCase(targetEntity.pluralName);
201
+ return ` set${targetEntity.pluralName}(data.${varName} ?? []);`;
177
202
  })
178
- .join('');
203
+ .join('\n');
204
+ // Build the useEffect block
205
+ let useEffectBlock;
206
+ if (hmRels.length > 0) {
207
+ useEffectBlock =
208
+ ' useEffect(() => {\n' +
209
+ ` fetch(\`${api}/\${id as string}${includeParam}\`)\n` +
210
+ ' .then((r) => r.json())\n' +
211
+ ' .then((data: Record<string, unknown>) => {\n' +
212
+ ` setItem(data as unknown as ${entity.name});\n` +
213
+ hasManySetters +
214
+ '\n });\n' +
215
+ ' }, [id]);';
216
+ }
217
+ else {
218
+ useEffectBlock =
219
+ ' useEffect(() => {\n' +
220
+ ` fetch(\`${api}/\${id as string}\`)\n` +
221
+ ' .then((r) => r.json())\n' +
222
+ ` .then((data: ${entity.name}) => setItem(data));\n` +
223
+ ' }, [id]);';
224
+ }
179
225
  const hasManyImports = hmRels.map((rel) => rel.target).join(', ');
180
226
  const hasManyTypeImport = hmRels.length > 0 ? `, ${hasManyImports}` : '';
181
227
  const hasManyRender = hmRels
@@ -217,11 +263,7 @@ export default function ${entity.name}Detail() {
217
263
  const navigate = useNavigate();
218
264
  const [item, setItem] = useState<${entity.name} | null>(null);${hasManySection}
219
265
 
220
- useEffect(() => {
221
- fetch(\`${api}/\${id as string}\`)
222
- .then((r) => r.json())
223
- .then((data: ${entity.name}) => setItem(data));${hasManyFetches}
224
- }, [id]);
266
+ ${useEffectBlock}
225
267
 
226
268
  if (!item) return <div className="p-6">Loading...</div>;
227
269
 
@@ -330,7 +372,7 @@ function generateForm(model, entity) {
330
372
  if (!targetEntity)
331
373
  return '';
332
374
  const targetApi = apiPath(targetEntity);
333
- return ` fetch('${targetApi}').then((r) => r.json()).then((d: ${rel.target}[]) => set${rel.target}Options(d));`;
375
+ return ` fetch('${targetApi}').then((r) => r.json()).then((res: { data: ${rel.target}[]; total: number }) => set${rel.target}Options(res.data));`;
334
376
  })
335
377
  .join('\n');
336
378
  const relTypeImports = btoRels.map((r) => r.target);
@@ -4,8 +4,34 @@
4
4
  * Generates src/types/index.ts with interfaces for each entity.
5
5
  */
6
6
  import { tsType, fkFieldName, belongsToRels } from './helpers.js';
7
+ const SHARED_TYPES = `// --- Shared API Types ---
8
+
9
+ export interface PaginatedResponse<T> {
10
+ data: T[];
11
+ total: number;
12
+ page: number;
13
+ pageSize: number;
14
+ totalPages: number;
15
+ }
16
+
17
+ export interface ApiResponse<T> {
18
+ data: T;
19
+ error?: string;
20
+ }
21
+
22
+ export interface ApiListResponse<T> {
23
+ data: T[];
24
+ total: number;
25
+ }
26
+
27
+ export interface SortParams {
28
+ field: string;
29
+ direction: 'asc' | 'desc';
30
+ }`;
7
31
  export function generateTypesFile(model) {
8
32
  const lines = ['// Auto-generated TypeScript types from Application Model', ''];
33
+ lines.push(SHARED_TYPES);
34
+ lines.push('');
9
35
  for (const entity of model.entities) {
10
36
  lines.push(generateInterface(entity));
11
37
  lines.push('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/factory",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "AI-driven application scaffolder for the compilr-dev ecosystem",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",