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.
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/dist/src/ai/adapters/anthropic.d.ts +31 -0
- package/dist/src/ai/adapters/anthropic.d.ts.map +1 -0
- package/dist/src/ai/adapters/anthropic.js +75 -0
- package/dist/src/ai/adapters/openai.d.ts +33 -0
- package/dist/src/ai/adapters/openai.d.ts.map +1 -0
- package/dist/src/ai/adapters/openai.js +120 -0
- package/dist/src/ai/adapters/vercel.d.ts +434 -0
- package/dist/src/ai/adapters/vercel.d.ts.map +1 -0
- package/dist/src/ai/adapters/vercel.js +162 -0
- package/dist/src/ai/index.d.ts +492 -0
- package/dist/src/ai/index.d.ts.map +1 -0
- package/dist/src/ai/index.js +71 -0
- package/dist/src/ai/state.d.ts +13 -0
- package/dist/src/ai/state.d.ts.map +1 -0
- package/dist/src/ai/state.js +215 -0
- package/dist/src/ai/tools.d.ts +13 -0
- package/dist/src/ai/tools.d.ts.map +1 -0
- package/dist/src/ai/tools.js +257 -0
- package/dist/src/ai/types.d.ts +196 -0
- package/dist/src/ai/types.d.ts.map +1 -0
- package/dist/src/ai/types.js +9 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +540 -0
- package/dist/src/core/utils.d.ts +27 -0
- package/dist/src/core/utils.d.ts.map +1 -0
- package/dist/src/core/utils.js +56 -0
- package/dist/src/entity.d.ts +165 -0
- package/dist/src/entity.d.ts.map +1 -0
- package/dist/src/entity.js +108 -0
- package/dist/src/fields.d.ts +207 -0
- package/dist/src/fields.d.ts.map +1 -0
- package/dist/src/fields.js +291 -0
- package/dist/src/generators/erd-ir.d.ts +10 -0
- package/dist/src/generators/erd-ir.d.ts.map +1 -0
- package/dist/src/generators/erd-ir.js +119 -0
- package/dist/src/index.d.ts +51 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +101 -0
- package/dist/src/init/dependencies.d.ts +31 -0
- package/dist/src/init/dependencies.d.ts.map +1 -0
- package/dist/src/init/dependencies.js +101 -0
- package/dist/src/init/entity-templates.d.ts +42 -0
- package/dist/src/init/entity-templates.d.ts.map +1 -0
- package/dist/src/init/entity-templates.js +367 -0
- package/dist/src/init/index.d.ts +10 -0
- package/dist/src/init/index.d.ts.map +1 -0
- package/dist/src/init/index.js +250 -0
- package/dist/src/init/prompts.d.ts +11 -0
- package/dist/src/init/prompts.d.ts.map +1 -0
- package/dist/src/init/prompts.js +275 -0
- package/dist/src/init/templates.d.ts +24 -0
- package/dist/src/init/templates.d.ts.map +1 -0
- package/dist/src/init/templates.js +587 -0
- package/dist/src/json/index.d.ts +11 -0
- package/dist/src/json/index.d.ts.map +1 -0
- package/dist/src/json/index.js +26 -0
- package/dist/src/json/parser.d.ts +61 -0
- package/dist/src/json/parser.d.ts.map +1 -0
- package/dist/src/json/parser.js +309 -0
- package/dist/src/json/types.d.ts +275 -0
- package/dist/src/json/types.d.ts.map +1 -0
- package/dist/src/json/types.js +10 -0
- package/dist/src/manifest.d.ts +147 -0
- package/dist/src/manifest.d.ts.map +1 -0
- package/dist/src/manifest.js +104 -0
- package/dist/src/relations.d.ts +96 -0
- package/dist/src/relations.d.ts.map +1 -0
- package/dist/src/relations.js +108 -0
- package/dist/src/source.d.ts +93 -0
- package/dist/src/source.d.ts.map +1 -0
- package/dist/src/source.js +89 -0
- package/dist/src/template/context.d.ts +34 -0
- package/dist/src/template/context.d.ts.map +1 -0
- package/dist/src/template/context.js +31 -0
- package/dist/src/template/index.d.ts +6 -0
- package/dist/src/template/index.d.ts.map +1 -0
- package/dist/src/template/index.js +12 -0
- package/dist/src/template/registry.d.ts +18 -0
- package/dist/src/template/registry.d.ts.map +1 -0
- package/dist/src/template/registry.js +89 -0
- package/dist/src/template/runner.d.ts +9 -0
- package/dist/src/template/runner.d.ts.map +1 -0
- package/dist/src/template/runner.js +125 -0
- package/dist/src/template/types.d.ts +73 -0
- package/dist/src/template/types.d.ts.map +1 -0
- package/dist/src/template/types.js +3 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/api.d.ts +22 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/api.d.ts.map +1 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/api.js +866 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/auth.d.ts +20 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/auth.d.ts.map +1 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/auth.js +273 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/crud-hooks.d.ts +22 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/crud-hooks.d.ts.map +1 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/crud-hooks.js +237 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/hooks.d.ts +30 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/hooks.d.ts.map +1 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/hooks.js +345 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/i18n.d.ts +25 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/i18n.d.ts.map +1 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/i18n.js +199 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/index.d.ts +8 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/index.d.ts.map +1 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/index.js +18 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/schema.d.ts +22 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/schema.d.ts.map +1 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/schema.js +270 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/service.d.ts +23 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/service.d.ts.map +1 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/service.js +304 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/validation.d.ts +21 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/validation.d.ts.map +1 -0
- package/dist/src/templates/nextjs-drizzle-trpc/generators/validation.js +248 -0
- package/dist/src/templates/nextjs-drizzle-trpc/index.d.ts +30 -0
- package/dist/src/templates/nextjs-drizzle-trpc/index.d.ts.map +1 -0
- package/dist/src/templates/nextjs-drizzle-trpc/index.js +71 -0
- package/dist/src/validation/index.d.ts +71 -0
- package/dist/src/validation/index.d.ts.map +1 -0
- package/dist/src/validation/index.js +314 -0
- 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
|
+
};
|