archetype-engine 2.0.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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -0
  3. package/dist/src/ai/adapters/anthropic.d.ts +31 -0
  4. package/dist/src/ai/adapters/anthropic.d.ts.map +1 -0
  5. package/dist/src/ai/adapters/anthropic.js +75 -0
  6. package/dist/src/ai/adapters/openai.d.ts +33 -0
  7. package/dist/src/ai/adapters/openai.d.ts.map +1 -0
  8. package/dist/src/ai/adapters/openai.js +120 -0
  9. package/dist/src/ai/adapters/vercel.d.ts +434 -0
  10. package/dist/src/ai/adapters/vercel.d.ts.map +1 -0
  11. package/dist/src/ai/adapters/vercel.js +162 -0
  12. package/dist/src/ai/index.d.ts +492 -0
  13. package/dist/src/ai/index.d.ts.map +1 -0
  14. package/dist/src/ai/index.js +71 -0
  15. package/dist/src/ai/state.d.ts +13 -0
  16. package/dist/src/ai/state.d.ts.map +1 -0
  17. package/dist/src/ai/state.js +215 -0
  18. package/dist/src/ai/tools.d.ts +13 -0
  19. package/dist/src/ai/tools.d.ts.map +1 -0
  20. package/dist/src/ai/tools.js +257 -0
  21. package/dist/src/ai/types.d.ts +196 -0
  22. package/dist/src/ai/types.d.ts.map +1 -0
  23. package/dist/src/ai/types.js +9 -0
  24. package/dist/src/cli.d.ts +3 -0
  25. package/dist/src/cli.d.ts.map +1 -0
  26. package/dist/src/cli.js +540 -0
  27. package/dist/src/core/utils.d.ts +27 -0
  28. package/dist/src/core/utils.d.ts.map +1 -0
  29. package/dist/src/core/utils.js +56 -0
  30. package/dist/src/entity.d.ts +165 -0
  31. package/dist/src/entity.d.ts.map +1 -0
  32. package/dist/src/entity.js +108 -0
  33. package/dist/src/fields.d.ts +207 -0
  34. package/dist/src/fields.d.ts.map +1 -0
  35. package/dist/src/fields.js +291 -0
  36. package/dist/src/generators/erd-ir.d.ts +10 -0
  37. package/dist/src/generators/erd-ir.d.ts.map +1 -0
  38. package/dist/src/generators/erd-ir.js +119 -0
  39. package/dist/src/index.d.ts +51 -0
  40. package/dist/src/index.d.ts.map +1 -0
  41. package/dist/src/index.js +101 -0
  42. package/dist/src/init/dependencies.d.ts +31 -0
  43. package/dist/src/init/dependencies.d.ts.map +1 -0
  44. package/dist/src/init/dependencies.js +101 -0
  45. package/dist/src/init/entity-templates.d.ts +42 -0
  46. package/dist/src/init/entity-templates.d.ts.map +1 -0
  47. package/dist/src/init/entity-templates.js +367 -0
  48. package/dist/src/init/index.d.ts +10 -0
  49. package/dist/src/init/index.d.ts.map +1 -0
  50. package/dist/src/init/index.js +250 -0
  51. package/dist/src/init/prompts.d.ts +11 -0
  52. package/dist/src/init/prompts.d.ts.map +1 -0
  53. package/dist/src/init/prompts.js +275 -0
  54. package/dist/src/init/templates.d.ts +24 -0
  55. package/dist/src/init/templates.d.ts.map +1 -0
  56. package/dist/src/init/templates.js +587 -0
  57. package/dist/src/json/index.d.ts +11 -0
  58. package/dist/src/json/index.d.ts.map +1 -0
  59. package/dist/src/json/index.js +26 -0
  60. package/dist/src/json/parser.d.ts +61 -0
  61. package/dist/src/json/parser.d.ts.map +1 -0
  62. package/dist/src/json/parser.js +309 -0
  63. package/dist/src/json/types.d.ts +275 -0
  64. package/dist/src/json/types.d.ts.map +1 -0
  65. package/dist/src/json/types.js +10 -0
  66. package/dist/src/manifest.d.ts +147 -0
  67. package/dist/src/manifest.d.ts.map +1 -0
  68. package/dist/src/manifest.js +104 -0
  69. package/dist/src/relations.d.ts +96 -0
  70. package/dist/src/relations.d.ts.map +1 -0
  71. package/dist/src/relations.js +108 -0
  72. package/dist/src/source.d.ts +93 -0
  73. package/dist/src/source.d.ts.map +1 -0
  74. package/dist/src/source.js +89 -0
  75. package/dist/src/template/context.d.ts +34 -0
  76. package/dist/src/template/context.d.ts.map +1 -0
  77. package/dist/src/template/context.js +31 -0
  78. package/dist/src/template/index.d.ts +6 -0
  79. package/dist/src/template/index.d.ts.map +1 -0
  80. package/dist/src/template/index.js +12 -0
  81. package/dist/src/template/registry.d.ts +18 -0
  82. package/dist/src/template/registry.d.ts.map +1 -0
  83. package/dist/src/template/registry.js +89 -0
  84. package/dist/src/template/runner.d.ts +9 -0
  85. package/dist/src/template/runner.d.ts.map +1 -0
  86. package/dist/src/template/runner.js +125 -0
  87. package/dist/src/template/types.d.ts +73 -0
  88. package/dist/src/template/types.d.ts.map +1 -0
  89. package/dist/src/template/types.js +3 -0
  90. package/dist/src/templates/nextjs-drizzle-trpc/generators/api.d.ts +22 -0
  91. package/dist/src/templates/nextjs-drizzle-trpc/generators/api.d.ts.map +1 -0
  92. package/dist/src/templates/nextjs-drizzle-trpc/generators/api.js +866 -0
  93. package/dist/src/templates/nextjs-drizzle-trpc/generators/auth.d.ts +20 -0
  94. package/dist/src/templates/nextjs-drizzle-trpc/generators/auth.d.ts.map +1 -0
  95. package/dist/src/templates/nextjs-drizzle-trpc/generators/auth.js +273 -0
  96. package/dist/src/templates/nextjs-drizzle-trpc/generators/crud-hooks.d.ts +22 -0
  97. package/dist/src/templates/nextjs-drizzle-trpc/generators/crud-hooks.d.ts.map +1 -0
  98. package/dist/src/templates/nextjs-drizzle-trpc/generators/crud-hooks.js +237 -0
  99. package/dist/src/templates/nextjs-drizzle-trpc/generators/hooks.d.ts +30 -0
  100. package/dist/src/templates/nextjs-drizzle-trpc/generators/hooks.d.ts.map +1 -0
  101. package/dist/src/templates/nextjs-drizzle-trpc/generators/hooks.js +345 -0
  102. package/dist/src/templates/nextjs-drizzle-trpc/generators/i18n.d.ts +25 -0
  103. package/dist/src/templates/nextjs-drizzle-trpc/generators/i18n.d.ts.map +1 -0
  104. package/dist/src/templates/nextjs-drizzle-trpc/generators/i18n.js +199 -0
  105. package/dist/src/templates/nextjs-drizzle-trpc/generators/index.d.ts +8 -0
  106. package/dist/src/templates/nextjs-drizzle-trpc/generators/index.d.ts.map +1 -0
  107. package/dist/src/templates/nextjs-drizzle-trpc/generators/index.js +18 -0
  108. package/dist/src/templates/nextjs-drizzle-trpc/generators/schema.d.ts +22 -0
  109. package/dist/src/templates/nextjs-drizzle-trpc/generators/schema.d.ts.map +1 -0
  110. package/dist/src/templates/nextjs-drizzle-trpc/generators/schema.js +270 -0
  111. package/dist/src/templates/nextjs-drizzle-trpc/generators/service.d.ts +23 -0
  112. package/dist/src/templates/nextjs-drizzle-trpc/generators/service.d.ts.map +1 -0
  113. package/dist/src/templates/nextjs-drizzle-trpc/generators/service.js +304 -0
  114. package/dist/src/templates/nextjs-drizzle-trpc/generators/validation.d.ts +21 -0
  115. package/dist/src/templates/nextjs-drizzle-trpc/generators/validation.d.ts.map +1 -0
  116. package/dist/src/templates/nextjs-drizzle-trpc/generators/validation.js +248 -0
  117. package/dist/src/templates/nextjs-drizzle-trpc/index.d.ts +30 -0
  118. package/dist/src/templates/nextjs-drizzle-trpc/index.d.ts.map +1 -0
  119. package/dist/src/templates/nextjs-drizzle-trpc/index.js +71 -0
  120. package/dist/src/validation/index.d.ts +71 -0
  121. package/dist/src/validation/index.d.ts.map +1 -0
  122. package/dist/src/validation/index.js +314 -0
  123. package/package.json +86 -0
@@ -0,0 +1,866 @@
1
+ "use strict";
2
+ /**
3
+ * tRPC Router Generator
4
+ *
5
+ * Generates type-safe tRPC routers with CRUD operations for each entity.
6
+ * Adapts to database or external API source based on entity/manifest configuration.
7
+ *
8
+ * Generated files:
9
+ * - trpc/routers/{entity}.ts - Individual entity routers with list, get, create, update, remove
10
+ * - trpc/routers/index.ts - App router combining all entity routers
11
+ *
12
+ * Features:
13
+ * - Automatic procedure type selection (publicProcedure vs protectedProcedure)
14
+ * - Multi-tenancy support with automatic tenant filtering
15
+ * - Soft delete support (updates deletedAt instead of hard delete)
16
+ * - External API integration via generated service layer
17
+ * - Input validation using generated Zod schemas
18
+ *
19
+ * @module generators/api
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.apiGenerator = void 0;
23
+ const utils_1 = require("../../../core/utils");
24
+ /**
25
+ * Check if entity has any hooks enabled
26
+ */
27
+ function hasAnyHooks(hooks) {
28
+ return Object.values(hooks).some(v => v);
29
+ }
30
+ function getTableName(entityName) {
31
+ return (0, utils_1.toSnakeCase)((0, utils_1.pluralize)(entityName));
32
+ }
33
+ /**
34
+ * Get the column name for a field (snake_case)
35
+ */
36
+ function getColumnName(fieldName) {
37
+ return (0, utils_1.toSnakeCase)(fieldName);
38
+ }
39
+ /**
40
+ * Generate Zod filter schema for a field based on its type
41
+ */
42
+ function generateFieldFilterSchema(fieldName, field) {
43
+ const baseType = field.type;
44
+ switch (baseType) {
45
+ case 'text':
46
+ return `${fieldName}: z.union([
47
+ z.string(),
48
+ z.object({
49
+ eq: z.string().optional(),
50
+ ne: z.string().optional(),
51
+ contains: z.string().optional(),
52
+ startsWith: z.string().optional(),
53
+ endsWith: z.string().optional(),
54
+ })
55
+ ]).optional()`;
56
+ case 'number':
57
+ return `${fieldName}: z.union([
58
+ z.number(),
59
+ z.object({
60
+ eq: z.number().optional(),
61
+ ne: z.number().optional(),
62
+ gt: z.number().optional(),
63
+ gte: z.number().optional(),
64
+ lt: z.number().optional(),
65
+ lte: z.number().optional(),
66
+ })
67
+ ]).optional()`;
68
+ case 'boolean':
69
+ return `${fieldName}: z.boolean().optional()`;
70
+ case 'date':
71
+ return `${fieldName}: z.union([
72
+ z.string(),
73
+ z.object({
74
+ eq: z.string().optional(),
75
+ ne: z.string().optional(),
76
+ gt: z.string().optional(),
77
+ gte: z.string().optional(),
78
+ lt: z.string().optional(),
79
+ lte: z.string().optional(),
80
+ })
81
+ ]).optional()`;
82
+ default:
83
+ return `${fieldName}: z.string().optional()`;
84
+ }
85
+ }
86
+ /**
87
+ * Generate the complete filter/sort schema for an entity
88
+ */
89
+ function generateListInputSchema(entity) {
90
+ // Skip computed fields - they can't be filtered
91
+ const storedFields = Object.entries(entity.fields).filter(([_, f]) => f.type !== 'computed');
92
+ const fieldNames = storedFields.map(([name]) => name);
93
+ const filterFields = storedFields
94
+ .map(([name, field]) => generateFieldFilterSchema(name, field))
95
+ .join(',\n ');
96
+ // Include system fields in orderBy options
97
+ const orderByFields = [...fieldNames, 'createdAt', 'updatedAt'];
98
+ // Get searchable (text) fields for search - skip computed
99
+ const textFields = storedFields
100
+ .filter(([_, field]) => field.type === 'text')
101
+ .map(([name]) => name);
102
+ return `// List input schema with pagination, filtering, sorting, and search
103
+ const listInput = z.object({
104
+ // Pagination
105
+ page: z.number().int().min(1).default(1),
106
+ limit: z.number().int().min(1).max(100).default(20),
107
+
108
+ // Filtering
109
+ where: z.object({
110
+ ${filterFields}
111
+ }).optional(),
112
+
113
+ // Sorting
114
+ orderBy: z.object({
115
+ field: z.enum([${orderByFields.map(f => `'${f}'`).join(', ')}]),
116
+ direction: z.enum(['asc', 'desc']).default('asc'),
117
+ }).optional(),
118
+
119
+ // Search across text fields${textFields.length > 0 ? ` (${textFields.join(', ')})` : ''}
120
+ search: z.string().optional(),
121
+ }).optional()`;
122
+ }
123
+ /**
124
+ * Check if entity uses external API source
125
+ *
126
+ * Checks entity-level source first, then falls back to manifest-level source.
127
+ *
128
+ * @param entity - Entity IR to check
129
+ * @param manifest - Manifest IR with global source configuration
130
+ * @returns true if entity should use external API, false for database
131
+ */
132
+ function hasExternalSource(entity, manifest) {
133
+ const source = entity.source || manifest.source;
134
+ return source?.type === 'external';
135
+ }
136
+ /**
137
+ * Determine procedure type for a CRUD method based on protection config
138
+ *
139
+ * @param method - CRUD method name (list, get, create, update, remove)
140
+ * @param prot - Normalized protection config from entity
141
+ * @param authEnabled - Whether auth is enabled in manifest
142
+ * @returns 'publicProcedure' or 'protectedProcedure'
143
+ */
144
+ function getProcedureType(method, prot, authEnabled) {
145
+ if (!authEnabled)
146
+ return 'publicProcedure';
147
+ return prot[method] ? 'protectedProcedure' : 'publicProcedure';
148
+ }
149
+ /**
150
+ * Determine which procedure imports are needed for an entity
151
+ *
152
+ * @param prot - Normalized protection config from entity
153
+ * @param authEnabled - Whether auth is enabled in manifest
154
+ * @returns Import string (e.g., "publicProcedure, protectedProcedure")
155
+ */
156
+ function getProcedureImports(prot, authEnabled) {
157
+ if (!authEnabled)
158
+ return 'publicProcedure';
159
+ const needsPublic = Object.values(prot).some(v => !v);
160
+ const needsProtected = Object.values(prot).some(v => v);
161
+ if (needsPublic && needsProtected) {
162
+ return 'publicProcedure, protectedProcedure';
163
+ }
164
+ else if (needsProtected) {
165
+ return 'protectedProcedure';
166
+ }
167
+ return 'publicProcedure';
168
+ }
169
+ /**
170
+ * Generate tRPC router for entity backed by external API
171
+ *
172
+ * Uses the generated service layer to make HTTP requests to external APIs.
173
+ *
174
+ * @param entity - Entity IR with fields and protection config
175
+ * @param manifest - Manifest IR with auth configuration
176
+ * @returns Complete router code as string
177
+ */
178
+ function generateExternalEntityRouter(entity, manifest) {
179
+ const name = entity.name;
180
+ const lowerName = name.toLowerCase();
181
+ const authEnabled = manifest.auth.enabled;
182
+ const prot = entity.protected;
183
+ const hooks = entity.hooks;
184
+ const useHooks = hasAnyHooks(hooks);
185
+ // Get procedure type for each method
186
+ const proc = (method) => getProcedureType(method, prot, authEnabled);
187
+ const procedureImports = getProcedureImports(prot, authEnabled);
188
+ // Generate list input schema (same as database)
189
+ const listInputSchema = generateListInputSchema(entity);
190
+ const computedMapper = generateComputedFieldsMapper(entity);
191
+ const useComputed = hasComputedFields(entity);
192
+ // Hook import (only if hooks enabled)
193
+ const hookImport = useHooks
194
+ ? `import { ${lowerName}Hooks } from '@/generated/hooks/${lowerName}'`
195
+ : '';
196
+ // Hook context builder
197
+ const hookContextBuilder = useHooks
198
+ ? `
199
+ // Build hook context from tRPC context
200
+ interface TRPCContextSession {
201
+ session?: {
202
+ user?: {
203
+ id: string
204
+ email?: string | null
205
+ name?: string | null
206
+ }
207
+ } | null
208
+ headers?: Record<string, string | string[]>
209
+ }
210
+
211
+ function buildHookContext(ctx: TRPCContextSession) {
212
+ return {
213
+ user: ctx.session?.user ? {
214
+ id: ctx.session.user.id,
215
+ email: ctx.session.user.email,
216
+ name: ctx.session.user.name,
217
+ } : undefined,
218
+ headers: ctx.headers,
219
+ }
220
+ }
221
+ `
222
+ : '';
223
+ return `// Auto-generated tRPC router for ${name} (external API)
224
+ // Do not edit manually - regenerate with: npx archetype generate
225
+
226
+ import { z } from 'zod'
227
+ import { router, ${procedureImports} } from '@/server/trpc'
228
+ import { ${lowerName}Service } from '@/generated/services/${lowerName}Service'
229
+ import { ${lowerName}CreateSchema, ${lowerName}UpdateSchema } from '@/generated/schemas/${lowerName}'
230
+ ${hookImport}
231
+
232
+ ${listInputSchema}
233
+ ${computedMapper}${hookContextBuilder}
234
+ export const ${lowerName}Router = router({
235
+ // List ${name}s with pagination, filtering, sorting, and search
236
+ list: ${proc('list')}
237
+ .input(listInput)
238
+ .query(async ({ input }) => {
239
+ const page = input?.page ?? 1
240
+ const limit = input?.limit ?? 20
241
+ const result = await ${lowerName}Service.list({
242
+ page,
243
+ limit,
244
+ where: input?.where,
245
+ orderBy: input?.orderBy,
246
+ search: input?.search,
247
+ })
248
+ return ${useComputed ? '{ ...result, items: result.items.map(withComputedFields) }' : 'result'}
249
+ }),
250
+
251
+ // Get single ${name} by ID
252
+ get: ${proc('get')}
253
+ .input(z.object({ id: z.string() }))
254
+ .query(async ({ input }) => {
255
+ const result = await ${lowerName}Service.get(input.id)
256
+ return ${useComputed ? 'result ? withComputedFields(result) : null' : 'result'}
257
+ }),
258
+
259
+ // Create new ${name}
260
+ create: ${proc('create')}
261
+ .input(${lowerName}CreateSchema)
262
+ .mutation(async ({ ctx, input }) => {${hooks.beforeCreate ? `
263
+ // Run beforeCreate hook
264
+ const hookCtx = buildHookContext(ctx)
265
+ const processedInput = ${lowerName}Hooks.beforeCreate
266
+ ? await ${lowerName}Hooks.beforeCreate(input, hookCtx)
267
+ : input
268
+ ` : ''}
269
+ const result = await ${lowerName}Service.create(${hooks.beforeCreate ? 'processedInput' : 'input'})
270
+ const record = ${useComputed ? 'result ? withComputedFields(result) : result' : 'result'}${hooks.afterCreate ? `
271
+
272
+ // Run afterCreate hook
273
+ if (record && ${lowerName}Hooks.afterCreate) {
274
+ await ${lowerName}Hooks.afterCreate(record, ${hooks.beforeCreate ? 'hookCtx' : 'buildHookContext(ctx)'})
275
+ }
276
+ ` : ''}
277
+ return record
278
+ }),
279
+
280
+ // Update ${name}
281
+ update: ${proc('update')}
282
+ .input(z.object({
283
+ id: z.string(),
284
+ data: ${lowerName}UpdateSchema,
285
+ }))
286
+ .mutation(async ({ ctx, input }) => {${hooks.beforeUpdate ? `
287
+ // Run beforeUpdate hook
288
+ const hookCtx = buildHookContext(ctx)
289
+ const processedData = ${lowerName}Hooks.beforeUpdate
290
+ ? await ${lowerName}Hooks.beforeUpdate(input.id, input.data, hookCtx)
291
+ : input.data
292
+ ` : ''}
293
+ const result = await ${lowerName}Service.update(input.id, ${hooks.beforeUpdate ? 'processedData' : 'input.data'})
294
+ const record = ${useComputed ? 'result ? withComputedFields(result) : result' : 'result'}${hooks.afterUpdate ? `
295
+
296
+ // Run afterUpdate hook
297
+ if (record && ${lowerName}Hooks.afterUpdate) {
298
+ await ${lowerName}Hooks.afterUpdate(record, ${hooks.beforeUpdate ? 'hookCtx' : 'buildHookContext(ctx)'})
299
+ }
300
+ ` : ''}
301
+ return record
302
+ }),
303
+
304
+ // Remove ${name}
305
+ remove: ${proc('remove')}
306
+ .input(z.object({ id: z.string() }))
307
+ .mutation(async ({ ctx, input }) => {${hooks.beforeRemove ? `
308
+ // Run beforeRemove hook
309
+ const hookCtx = buildHookContext(ctx)
310
+ if (${lowerName}Hooks.beforeRemove) {
311
+ await ${lowerName}Hooks.beforeRemove(input.id, hookCtx)
312
+ }
313
+ ` : ''}
314
+ const result = await ${lowerName}Service.delete(input.id)
315
+ const record = ${useComputed ? 'result ? withComputedFields(result) : result' : 'result'}${hooks.afterRemove ? `
316
+
317
+ // Run afterRemove hook
318
+ if (record && ${lowerName}Hooks.afterRemove) {
319
+ await ${lowerName}Hooks.afterRemove(record, ${hooks.beforeRemove ? 'hookCtx' : 'buildHookContext(ctx)'})
320
+ }
321
+ ` : ''}
322
+ return record
323
+ }),
324
+
325
+ // ============ BATCH OPERATIONS ============
326
+
327
+ // Create multiple ${name}s
328
+ createMany: ${proc('create')}
329
+ .input(z.object({
330
+ items: z.array(${lowerName}CreateSchema).min(1).max(100),
331
+ }))
332
+ .mutation(async ({ input }) => {
333
+ const result = await ${lowerName}Service.createMany(input.items)
334
+ return ${useComputed ? '{ ...result, created: result.created.map(withComputedFields) }' : 'result'}
335
+ }),
336
+
337
+ // Update multiple ${name}s
338
+ updateMany: ${proc('update')}
339
+ .input(z.object({
340
+ items: z.array(z.object({
341
+ id: z.string(),
342
+ data: ${lowerName}UpdateSchema,
343
+ })).min(1).max(100),
344
+ }))
345
+ .mutation(async ({ input }) => {
346
+ const result = await ${lowerName}Service.updateMany(input.items)
347
+ return ${useComputed ? '{ ...result, updated: result.updated.map(withComputedFields) }' : 'result'}
348
+ }),
349
+
350
+ // Remove multiple ${name}s
351
+ removeMany: ${proc('remove')}
352
+ .input(z.object({
353
+ ids: z.array(z.string()).min(1).max(100),
354
+ }))
355
+ .mutation(async ({ input }) => {
356
+ const result = await ${lowerName}Service.deleteMany(input.ids)
357
+ return ${useComputed ? '{ ...result, removed: result.removed.map(withComputedFields) }' : 'result'}
358
+ }),
359
+ })
360
+ `;
361
+ }
362
+ /**
363
+ * Generate the filter builder function that converts input.where to Drizzle conditions
364
+ */
365
+ function generateFilterBuilder(entity, tableName) {
366
+ // Skip computed fields - they're not in the database
367
+ const fieldConditions = Object.entries(entity.fields)
368
+ .filter(([_, field]) => field.type !== 'computed')
369
+ .map(([fieldName, field]) => {
370
+ // Use fieldName (camelCase) for JS property access, not snake_case column name
371
+ const column = `${tableName}.${fieldName}`;
372
+ if (field.type === 'text') {
373
+ return ` if (where.${fieldName} !== undefined) {
374
+ if (typeof where.${fieldName} === 'string') {
375
+ conditions.push(eq(${column}, where.${fieldName}))
376
+ } else {
377
+ const f = where.${fieldName}
378
+ if (f.eq !== undefined) conditions.push(eq(${column}, f.eq))
379
+ if (f.ne !== undefined) conditions.push(ne(${column}, f.ne))
380
+ if (f.contains !== undefined) conditions.push(like(${column}, \`%\${f.contains}%\`))
381
+ if (f.startsWith !== undefined) conditions.push(like(${column}, \`\${f.startsWith}%\`))
382
+ if (f.endsWith !== undefined) conditions.push(like(${column}, \`%\${f.endsWith}\`))
383
+ }
384
+ }`;
385
+ }
386
+ else if (field.type === 'number' || field.type === 'date') {
387
+ return ` if (where.${fieldName} !== undefined) {
388
+ if (typeof where.${fieldName} === '${field.type === 'number' ? 'number' : 'string'}') {
389
+ conditions.push(eq(${column}, where.${fieldName}))
390
+ } else {
391
+ const f = where.${fieldName}
392
+ if (f.eq !== undefined) conditions.push(eq(${column}, f.eq))
393
+ if (f.ne !== undefined) conditions.push(ne(${column}, f.ne))
394
+ if (f.gt !== undefined) conditions.push(gt(${column}, f.gt))
395
+ if (f.gte !== undefined) conditions.push(gte(${column}, f.gte))
396
+ if (f.lt !== undefined) conditions.push(lt(${column}, f.lt))
397
+ if (f.lte !== undefined) conditions.push(lte(${column}, f.lte))
398
+ }
399
+ }`;
400
+ }
401
+ else if (field.type === 'boolean') {
402
+ return ` if (where.${fieldName} !== undefined) {
403
+ conditions.push(eq(${column}, where.${fieldName}))
404
+ }`;
405
+ }
406
+ return '';
407
+ }).filter(Boolean).join('\n');
408
+ return `function buildFilters(where: NonNullable<z.infer<typeof listInput>>['where']) {
409
+ const conditions: SQL[] = []
410
+ if (!where) return conditions
411
+
412
+ ${fieldConditions}
413
+
414
+ return conditions
415
+ }`;
416
+ }
417
+ /**
418
+ * Generate the search condition for text fields
419
+ */
420
+ function generateSearchBuilder(entity, tableName) {
421
+ // Skip computed fields - only search stored text fields
422
+ const textFields = Object.entries(entity.fields)
423
+ .filter(([_, field]) => field.type === 'text')
424
+ .map(([name]) => name);
425
+ if (textFields.length === 0) {
426
+ return `function buildSearch(_search: string | undefined) {
427
+ return undefined
428
+ }`;
429
+ }
430
+ const searchConditions = textFields.map(fieldName => {
431
+ // Use fieldName (camelCase) for JS property access, not snake_case column name
432
+ return `like(${tableName}.${fieldName}, \`%\${search}%\`)`;
433
+ }).join(',\n ');
434
+ return `function buildSearch(search: string | undefined) {
435
+ if (!search) return undefined
436
+ return or(
437
+ ${searchConditions}
438
+ )
439
+ }`;
440
+ }
441
+ /**
442
+ * Check if entity has computed fields
443
+ */
444
+ function hasComputedFields(entity) {
445
+ return Object.values(entity.fields).some(f => f.type === 'computed');
446
+ }
447
+ /**
448
+ * Generate computed field mapper function
449
+ * This function adds computed field values to database results at runtime
450
+ */
451
+ function generateComputedFieldsMapper(entity) {
452
+ const computedFields = Object.entries(entity.fields).filter(([_, f]) => f.type === 'computed');
453
+ if (computedFields.length === 0) {
454
+ return '';
455
+ }
456
+ const fieldMappings = computedFields.map(([name, field]) => {
457
+ // The expression is already valid JS (e.g., "`${firstName} ${lastName}`" or "price * quantity")
458
+ return ` ${name}: ${field.expression},`;
459
+ }).join('\n');
460
+ return `
461
+ // Helper: Add computed fields to a record
462
+ function withComputedFields<T extends Record<string, unknown>>(record: T) {
463
+ return {
464
+ ...record,
465
+ ${fieldMappings}
466
+ }
467
+ }
468
+ `;
469
+ }
470
+ /**
471
+ * Generate the orderBy builder
472
+ */
473
+ function generateOrderByBuilder(entity, tableName) {
474
+ // Skip computed fields - can only sort by stored fields
475
+ const fieldNames = Object.entries(entity.fields)
476
+ .filter(([_, f]) => f.type !== 'computed')
477
+ .map(([name]) => name);
478
+ const allFields = [...fieldNames, 'createdAt', 'updatedAt'];
479
+ const fieldCases = allFields.map(fieldName => {
480
+ // Use fieldName (camelCase) for JS property access, not snake_case column name
481
+ return ` case '${fieldName}': column = ${tableName}.${fieldName}; break`;
482
+ }).join('\n');
483
+ return `function buildOrderBy(orderBy: NonNullable<z.infer<typeof listInput>>['orderBy']) {
484
+ if (!orderBy) return undefined
485
+ let column: unknown
486
+ switch (orderBy.field) {
487
+ ${fieldCases}
488
+ }
489
+ return orderBy.direction === 'desc' ? desc(column) : asc(column)
490
+ }`;
491
+ }
492
+ /**
493
+ * Generate tRPC router for entity backed by database
494
+ *
495
+ * Uses Drizzle ORM for database operations with support for:
496
+ * - Multi-tenancy filtering
497
+ * - Soft delete (when enabled)
498
+ * - Automatic timestamps
499
+ * - Pagination (page, limit)
500
+ * - Filtering (where)
501
+ * - Sorting (orderBy)
502
+ * - Search (full-text across text fields)
503
+ *
504
+ * @param entity - Entity IR with fields, relations, and behaviors
505
+ * @param manifest - Manifest IR with database, auth, and tenancy configuration
506
+ * @returns Complete router code as string
507
+ */
508
+ function generateDatabaseEntityRouter(entity, manifest) {
509
+ const name = entity.name;
510
+ const lowerName = name.toLowerCase();
511
+ const tableName = getTableName(name);
512
+ const useTenancy = manifest.tenancy.enabled;
513
+ const useSoftDelete = entity.behaviors.softDelete;
514
+ const authEnabled = manifest.auth.enabled;
515
+ const prot = entity.protected;
516
+ const hooks = entity.hooks;
517
+ const useHooks = hasAnyHooks(hooks);
518
+ // Get procedure type for each method
519
+ const proc = (method) => getProcedureType(method, prot, authEnabled);
520
+ const procedureImports = getProcedureImports(prot, authEnabled);
521
+ // Schema import
522
+ const schemaImport = `import { ${lowerName}CreateSchema, ${lowerName}UpdateSchema } from '@/generated/schemas/${lowerName}'`;
523
+ // Hook import (only if hooks enabled)
524
+ const hookImport = useHooks
525
+ ? `import { ${lowerName}Hooks } from '@/generated/hooks/${lowerName}'`
526
+ : '';
527
+ // Hook context builder
528
+ const hookContextBuilder = useHooks
529
+ ? `
530
+ // Build hook context from tRPC context
531
+ interface TRPCContextSession {
532
+ session?: {
533
+ user?: {
534
+ id: string
535
+ email?: string | null
536
+ name?: string | null
537
+ }
538
+ } | null
539
+ headers?: Record<string, string | string[]>
540
+ }
541
+
542
+ function buildHookContext(ctx: TRPCContextSession) {
543
+ return {
544
+ user: ctx.session?.user ? {
545
+ id: ctx.session.user.id,
546
+ email: ctx.session.user.email,
547
+ name: ctx.session.user.name,
548
+ } : undefined,
549
+ headers: ctx.headers,
550
+ }
551
+ }
552
+ `
553
+ : '';
554
+ // Build base where clauses (tenancy, soft delete)
555
+ const tenantWhere = useTenancy
556
+ ? `eq(${tableName}.${manifest.tenancy.field}, ctx.${manifest.tenancy.field})`
557
+ : '';
558
+ const softDeleteWhere = useSoftDelete
559
+ ? `isNull(${tableName}.deletedAt)`
560
+ : '';
561
+ const baseWheres = [tenantWhere, softDeleteWhere].filter(Boolean);
562
+ const getWhereClause = baseWheres.length > 0
563
+ ? `and(eq(${tableName}.id, input.id), ${baseWheres.join(', ')})`
564
+ : `eq(${tableName}.id, input.id)`;
565
+ // Check if we have text fields for search
566
+ const hasTextFields = Object.values(entity.fields).some(f => f.type === 'text');
567
+ // Drizzle imports - include all needed operators
568
+ const drizzleOperators = ['eq', 'and', 'count', 'ne', 'gt', 'gte', 'lt', 'lte', 'like', 'asc', 'desc', 'inArray'];
569
+ if (hasTextFields)
570
+ drizzleOperators.push('or');
571
+ if (useSoftDelete)
572
+ drizzleOperators.push('isNull');
573
+ const drizzleImports = `import { ${drizzleOperators.join(', ')}, type SQL } from 'drizzle-orm'`;
574
+ // Generate list input schema
575
+ const listInputSchema = generateListInputSchema(entity);
576
+ // Generate helper functions
577
+ const filterBuilder = generateFilterBuilder(entity, tableName);
578
+ const searchBuilder = generateSearchBuilder(entity, tableName);
579
+ const orderByBuilder = generateOrderByBuilder(entity, tableName);
580
+ const computedMapper = generateComputedFieldsMapper(entity);
581
+ const useComputed = hasComputedFields(entity);
582
+ // Build the combined where conditions for list
583
+ const combineConditions = baseWheres.length > 0
584
+ ? ` // Combine base conditions (tenancy, soft delete) with filters and search
585
+ const baseConditions: SQL[] = [${baseWheres.join(', ')}]
586
+ const allConditions = [...baseConditions, ...filterConditions]
587
+ if (searchCondition) allConditions.push(searchCondition)
588
+ const whereClause = allConditions.length > 0 ? and(...allConditions) : undefined`
589
+ : ` // Combine filters and search
590
+ const allConditions = [...filterConditions]
591
+ if (searchCondition) allConditions.push(searchCondition)
592
+ const whereClause = allConditions.length > 0 ? and(...allConditions) : undefined`;
593
+ return `// Auto-generated tRPC router for ${name}
594
+ // Do not edit manually - regenerate with: npx archetype generate
595
+
596
+ import { z } from 'zod'
597
+ import { router, ${procedureImports} } from '@/server/trpc'
598
+ import { ${tableName} } from '@/generated/db/schema'
599
+ ${schemaImport}
600
+ ${drizzleImports}
601
+ ${hookImport}
602
+
603
+ ${listInputSchema}
604
+
605
+ // Helper: Build filter conditions from where input
606
+ ${filterBuilder}
607
+
608
+ // Helper: Build search condition across text fields
609
+ ${searchBuilder}
610
+
611
+ // Helper: Build orderBy clause
612
+ ${orderByBuilder}
613
+ ${computedMapper}${hookContextBuilder}
614
+ export const ${lowerName}Router = router({
615
+ // List ${name}s with pagination, filtering, sorting, and search
616
+ list: ${proc('list')}
617
+ .input(listInput)
618
+ .query(async ({ ctx, input }) => {
619
+ const page = input?.page ?? 1
620
+ const limit = input?.limit ?? 20
621
+ const offset = (page - 1) * limit
622
+
623
+ // Build conditions
624
+ const filterConditions = buildFilters(input?.where)
625
+ const searchCondition = buildSearch(input?.search)
626
+ const orderByClause = buildOrderBy(input?.orderBy)
627
+
628
+ ${combineConditions}
629
+
630
+ // Build query
631
+ let query = ctx.db.select().from(${tableName})
632
+ if (whereClause) query = query.where(whereClause) as typeof query
633
+ if (orderByClause) query = query.orderBy(orderByClause) as typeof query
634
+
635
+ // Get items with pagination
636
+ const items = await query.limit(limit).offset(offset)
637
+
638
+ // Get total count with same filters
639
+ let countQuery = ctx.db.select({ total: count() }).from(${tableName})
640
+ if (whereClause) countQuery = countQuery.where(whereClause) as typeof countQuery
641
+ const [{ total }] = await countQuery
642
+
643
+ return {
644
+ items: ${useComputed ? 'items.map(withComputedFields)' : 'items'},
645
+ total,
646
+ page,
647
+ limit,
648
+ hasMore: offset + items.length < total,
649
+ }
650
+ }),
651
+
652
+ // Get single ${name} by ID
653
+ get: ${proc('get')}
654
+ .input(z.object({ id: z.string() }))
655
+ .query(async ({ ctx, input }) => {
656
+ const result = await ctx.db.select().from(${tableName})
657
+ .where(${getWhereClause})
658
+ .limit(1)
659
+ return ${useComputed ? 'result[0] ? withComputedFields(result[0]) : null' : 'result[0] ?? null'}
660
+ }),
661
+
662
+ // Create new ${name}
663
+ create: ${proc('create')}
664
+ .input(${lowerName}CreateSchema)
665
+ .mutation(async ({ ctx, input }) => {${hooks.beforeCreate ? `
666
+ // Run beforeCreate hook
667
+ const hookCtx = buildHookContext(ctx)
668
+ const processedInput = ${lowerName}Hooks.beforeCreate
669
+ ? await ${lowerName}Hooks.beforeCreate(input, hookCtx)
670
+ : input
671
+ ` : ''}
672
+ const now = new Date().toISOString()
673
+ const result = await ctx.db.insert(${tableName}).values({
674
+ id: crypto.randomUUID(),
675
+ ...${hooks.beforeCreate ? 'processedInput' : 'input'},${useTenancy ? `\n ${manifest.tenancy.field}: ctx.${manifest.tenancy.field},` : ''}
676
+ createdAt: now,
677
+ updatedAt: now,
678
+ }).returning()
679
+ const record = ${useComputed ? 'result[0] ? withComputedFields(result[0]) : result[0]' : 'result[0]'}${hooks.afterCreate ? `
680
+
681
+ // Run afterCreate hook
682
+ if (record && ${lowerName}Hooks.afterCreate) {
683
+ await ${lowerName}Hooks.afterCreate(record, ${hooks.beforeCreate ? 'hookCtx' : 'buildHookContext(ctx)'})
684
+ }
685
+ ` : ''}
686
+ return record
687
+ }),
688
+
689
+ // Update ${name}
690
+ update: ${proc('update')}
691
+ .input(z.object({
692
+ id: z.string(),
693
+ data: ${lowerName}UpdateSchema,
694
+ }))
695
+ .mutation(async ({ ctx, input }) => {${hooks.beforeUpdate ? `
696
+ // Run beforeUpdate hook
697
+ const hookCtx = buildHookContext(ctx)
698
+ const processedData = ${lowerName}Hooks.beforeUpdate
699
+ ? await ${lowerName}Hooks.beforeUpdate(input.id, input.data, hookCtx)
700
+ : input.data
701
+ ` : ''}
702
+ const result = await ctx.db.update(${tableName})
703
+ .set({
704
+ ...${hooks.beforeUpdate ? 'processedData' : 'input.data'},
705
+ updatedAt: new Date().toISOString(),
706
+ })
707
+ .where(${getWhereClause})
708
+ .returning()
709
+ const record = ${useComputed ? 'result[0] ? withComputedFields(result[0]) : result[0]' : 'result[0]'}${hooks.afterUpdate ? `
710
+
711
+ // Run afterUpdate hook
712
+ if (record && ${lowerName}Hooks.afterUpdate) {
713
+ await ${lowerName}Hooks.afterUpdate(record, ${hooks.beforeUpdate ? 'hookCtx' : 'buildHookContext(ctx)'})
714
+ }
715
+ ` : ''}
716
+ return record
717
+ }),
718
+
719
+ // Remove ${name}
720
+ remove: ${proc('remove')}
721
+ .input(z.object({ id: z.string() }))
722
+ .mutation(async ({ ctx, input }) => {${hooks.beforeRemove ? `
723
+ // Run beforeRemove hook
724
+ const hookCtx = buildHookContext(ctx)
725
+ if (${lowerName}Hooks.beforeRemove) {
726
+ await ${lowerName}Hooks.beforeRemove(input.id, hookCtx)
727
+ }
728
+ ` : ''}${useSoftDelete ? `
729
+ // Soft delete
730
+ const result = await ctx.db.update(${tableName})
731
+ .set({ deletedAt: new Date().toISOString() })
732
+ .where(${getWhereClause})
733
+ .returning()
734
+ const record = ${useComputed ? 'result[0] ? withComputedFields(result[0]) : result[0]' : 'result[0]'}` : `
735
+ const result = await ctx.db.delete(${tableName})
736
+ .where(${getWhereClause})
737
+ .returning()
738
+ const record = ${useComputed ? 'result[0] ? withComputedFields(result[0]) : result[0]' : 'result[0]'}`}${hooks.afterRemove ? `
739
+
740
+ // Run afterRemove hook
741
+ if (record && ${lowerName}Hooks.afterRemove) {
742
+ await ${lowerName}Hooks.afterRemove(record, ${hooks.beforeRemove ? 'hookCtx' : 'buildHookContext(ctx)'})
743
+ }
744
+ ` : ''}
745
+ return record
746
+ }),
747
+
748
+ // ============ BATCH OPERATIONS ============
749
+
750
+ // Create multiple ${name}s
751
+ createMany: ${proc('create')}
752
+ .input(z.object({
753
+ items: z.array(${lowerName}CreateSchema).min(1).max(100),
754
+ }))
755
+ .mutation(async ({ ctx, input }) => {
756
+ const now = new Date().toISOString()
757
+ const values = input.items.map(item => ({
758
+ id: crypto.randomUUID(),
759
+ ...item,${useTenancy ? `\n ${manifest.tenancy.field}: ctx.${manifest.tenancy.field},` : ''}
760
+ createdAt: now,
761
+ updatedAt: now,
762
+ }))
763
+ const result = await ctx.db.insert(${tableName}).values(values).returning()
764
+ return { created: ${useComputed ? 'result.map(withComputedFields)' : 'result'}, count: result.length }
765
+ }),
766
+
767
+ // Update multiple ${name}s
768
+ updateMany: ${proc('update')}
769
+ .input(z.object({
770
+ items: z.array(z.object({
771
+ id: z.string(),
772
+ data: ${lowerName}UpdateSchema,
773
+ })).min(1).max(100),
774
+ }))
775
+ .mutation(async ({ ctx, input }) => {
776
+ const now = new Date().toISOString()
777
+ const results: typeof ${tableName}.$inferSelect[] = []
778
+
779
+ // Update each item (Drizzle doesn't support bulk update with different values)
780
+ for (const item of input.items) {
781
+ const result = await ctx.db.update(${tableName})
782
+ .set({
783
+ ...item.data,
784
+ updatedAt: now,
785
+ })
786
+ .where(${useTenancy || useSoftDelete ? `and(eq(${tableName}.id, item.id)${useTenancy ? `, eq(${tableName}.${manifest.tenancy.field}, ctx.${manifest.tenancy.field})` : ''}${useSoftDelete ? `, isNull(${tableName}.deletedAt)` : ''})` : `eq(${tableName}.id, item.id)`})
787
+ .returning()
788
+ if (result[0]) results.push(result[0])
789
+ }
790
+
791
+ return { updated: ${useComputed ? 'results.map(withComputedFields)' : 'results'}, count: results.length }
792
+ }),
793
+
794
+ // Remove multiple ${name}s
795
+ removeMany: ${proc('remove')}
796
+ .input(z.object({
797
+ ids: z.array(z.string()).min(1).max(100),
798
+ }))
799
+ .mutation(async ({ ctx, input }) => {${useSoftDelete ? `
800
+ // Soft delete
801
+ const result = await ctx.db.update(${tableName})
802
+ .set({ deletedAt: new Date().toISOString() })
803
+ .where(${useTenancy ? `and(inArray(${tableName}.id, input.ids), eq(${tableName}.${manifest.tenancy.field}, ctx.${manifest.tenancy.field}), isNull(${tableName}.deletedAt))` : `and(inArray(${tableName}.id, input.ids), isNull(${tableName}.deletedAt))`})
804
+ .returning()
805
+ return { removed: ${useComputed ? 'result.map(withComputedFields)' : 'result'}, count: result.length }` : `
806
+ const result = await ctx.db.delete(${tableName})
807
+ .where(${useTenancy ? `and(inArray(${tableName}.id, input.ids), eq(${tableName}.${manifest.tenancy.field}, ctx.${manifest.tenancy.field}))` : `inArray(${tableName}.id, input.ids)`})
808
+ .returning()
809
+ return { removed: ${useComputed ? 'result.map(withComputedFields)' : 'result'}, count: result.length }`}
810
+ }),
811
+ })
812
+ `;
813
+ }
814
+ /**
815
+ * Generate router for entity, selecting source type automatically
816
+ *
817
+ * @param entity - Entity IR
818
+ * @param manifest - Manifest IR
819
+ * @returns Router code for either external API or database source
820
+ */
821
+ function generateEntityRouter(entity, manifest) {
822
+ if (hasExternalSource(entity, manifest)) {
823
+ return generateExternalEntityRouter(entity, manifest);
824
+ }
825
+ return generateDatabaseEntityRouter(entity, manifest);
826
+ }
827
+ function generateAppRouter(entities) {
828
+ const imports = entities
829
+ .map(e => `import { ${e.name.toLowerCase()}Router } from './${e.name.toLowerCase()}'`)
830
+ .join('\n');
831
+ const routers = entities
832
+ .map(e => ` ${e.name.toLowerCase()}: ${e.name.toLowerCase()}Router,`)
833
+ .join('\n');
834
+ return `// Auto-generated app router
835
+ // Do not edit manually - regenerate with: npx archetype generate
836
+
837
+ import { router } from '@/server/trpc'
838
+ ${imports}
839
+
840
+ export const appRouter = router({
841
+ ${routers}
842
+ })
843
+
844
+ export type AppRouter = typeof appRouter
845
+ `;
846
+ }
847
+ exports.apiGenerator = {
848
+ name: 'trpc-routers',
849
+ description: 'Generate tRPC routers with CRUD operations',
850
+ generate(manifest, ctx) {
851
+ const files = [];
852
+ // Generate router for each entity
853
+ for (const entity of manifest.entities) {
854
+ files.push({
855
+ path: `trpc/routers/${entity.name.toLowerCase()}.ts`,
856
+ content: generateEntityRouter(entity, manifest),
857
+ });
858
+ }
859
+ // Generate app router that combines all entity routers
860
+ files.push({
861
+ path: 'trpc/routers/index.ts',
862
+ content: generateAppRouter(manifest.entities),
863
+ });
864
+ return files;
865
+ },
866
+ };