gazan-init 0.1.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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +722 -0
  3. package/bin/gazan.js +17 -0
  4. package/package.json +54 -0
  5. package/src/cli/commands/init.js +126 -0
  6. package/src/cli/index.js +22 -0
  7. package/src/config/aliases.js +130 -0
  8. package/src/config/normalize.js +65 -0
  9. package/src/generators/aliasResolver.js +68 -0
  10. package/src/generators/aliasRuntime.js +79 -0
  11. package/src/generators/database/mongoNative.js +45 -0
  12. package/src/generators/database/mongoose.js +32 -0
  13. package/src/generators/database/prisma.js +61 -0
  14. package/src/generators/engine.js +88 -0
  15. package/src/generators/entity/sensitiveFields.js +12 -0
  16. package/src/generators/entity/toApp.js +325 -0
  17. package/src/generators/entity/toMongoose.js +180 -0
  18. package/src/generators/entity/toPrisma.js +233 -0
  19. package/src/generators/entity/toZod.js +70 -0
  20. package/src/generators/env.js +123 -0
  21. package/src/generators/errors.js +67 -0
  22. package/src/generators/features/auth.js +163 -0
  23. package/src/generators/features/bullmq.js +96 -0
  24. package/src/generators/features/redis.js +34 -0
  25. package/src/generators/features/socket.js +45 -0
  26. package/src/generators/gitignore.js +19 -0
  27. package/src/generators/index.js +317 -0
  28. package/src/generators/middlewares.js +167 -0
  29. package/src/generators/packageJson.js +107 -0
  30. package/src/generators/paths.js +45 -0
  31. package/src/generators/project.js +189 -0
  32. package/src/generators/readme.js +177 -0
  33. package/src/generators/shutdown.js +113 -0
  34. package/src/generators/stripSensitiveFieldsHelper.js +50 -0
  35. package/src/generators/syntax.js +65 -0
  36. package/src/generators/tsconfig.js +32 -0
  37. package/src/parser/entity/errors.js +21 -0
  38. package/src/parser/entity/parse.js +417 -0
  39. package/src/parser/entity/schema.js +104 -0
  40. package/src/prompts/index.js +186 -0
  41. package/src/utils/fsSafety.js +17 -0
  42. package/src/utils/strings.js +110 -0
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+
3
+ const { isEsm } = require("../syntax");
4
+ const { generatePrismaSchema } = require("../entity/toPrisma");
5
+
6
+ function defaultPrismaSchema() {
7
+ return `generator client {
8
+ provider = "prisma-client-js"
9
+ }
10
+
11
+ datasource db {
12
+ provider = "postgresql"
13
+ url = env("DATABASE_URL")
14
+ }
15
+
16
+ // Starter model so \`prisma generate\`/\`migrate\` work out of the box.
17
+ // Replace this with your own models, or regenerate with an entity.json.
18
+ model Example {
19
+ id String @id @default(uuid())
20
+ name String
21
+ createdAt DateTime @default(now())
22
+ updatedAt DateTime @updatedAt
23
+
24
+ @@map("examples")
25
+ }
26
+ `;
27
+ }
28
+
29
+ function generateSchemaPrisma(config, entityModel) {
30
+ if (entityModel && entityModel.models.length > 0) {
31
+ return generatePrismaSchema(entityModel, config);
32
+ }
33
+ return defaultPrismaSchema();
34
+ }
35
+
36
+ /** configs/db/index.{js,ts} — centralized PrismaClient lifecycle. No service instantiates its own client. */
37
+ function generatePrismaDbConfig(config) {
38
+ const esm = isEsm(config);
39
+ const isTs = config.language === "ts";
40
+ const imports = esm
41
+ ? `import { PrismaClient } from "@prisma/client";`
42
+ : `const { PrismaClient } = require("@prisma/client");`;
43
+
44
+ return `${imports}
45
+
46
+ const prisma = new PrismaClient();
47
+
48
+ async function connect() {
49
+ await prisma.$connect();
50
+ console.log("[db] prisma connected");
51
+ }
52
+
53
+ async function disconnect() {
54
+ await prisma.$disconnect();
55
+ }
56
+
57
+ ${esm ? "export { prisma, connect, disconnect };" : "module.exports = { prisma, connect, disconnect };"}
58
+ `;
59
+ }
60
+
61
+ module.exports = { generateSchemaPrisma, generatePrismaDbConfig };
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs-extra");
4
+ const path = require("path");
5
+
6
+ /**
7
+ * Thin, composable filesystem primitives used by every generator.
8
+ * Generators never call `fs` directly — everything funnels through here
9
+ * so behaviour (idempotency, logging, dry-run, etc.) stays centralized.
10
+ */
11
+ class GeneratorEngine {
12
+ constructor(rootDir) {
13
+ this.rootDir = rootDir;
14
+ this.createdFiles = [];
15
+ }
16
+
17
+ resolve(...segments) {
18
+ return path.join(this.rootDir, ...segments);
19
+ }
20
+
21
+ createDirectory(relativePath) {
22
+ const full = this.resolve(relativePath);
23
+ fs.ensureDirSync(full);
24
+ return full;
25
+ }
26
+
27
+ createFile(relativePath, content) {
28
+ const full = this.resolve(relativePath);
29
+ fs.ensureDirSync(path.dirname(full));
30
+ fs.writeFileSync(full, content, "utf8");
31
+ this.createdFiles.push(relativePath);
32
+ return full;
33
+ }
34
+
35
+ fileExists(relativePath) {
36
+ return fs.existsSync(this.resolve(relativePath));
37
+ }
38
+
39
+ readJson(relativePath) {
40
+ return fs.readJsonSync(this.resolve(relativePath));
41
+ }
42
+
43
+ writeJson(relativePath, data) {
44
+ const full = this.resolve(relativePath);
45
+ fs.ensureDirSync(path.dirname(full));
46
+ fs.writeJsonSync(full, data, { spaces: 2 });
47
+ this.createdFiles.push(relativePath);
48
+ return full;
49
+ }
50
+
51
+ mergeJson(relativePath, patch) {
52
+ const existing = this.fileExists(relativePath) ? this.readJson(relativePath) : {};
53
+ const merged = deepMerge(existing, patch);
54
+ this.writeJson(relativePath, merged);
55
+ return merged;
56
+ }
57
+
58
+ /**
59
+ * Merge dependency/script/etc. fragments into package.json, preserving
60
+ * key ordering for the well-known top-level fields.
61
+ */
62
+ updatePackageJson(patch) {
63
+ const current = this.fileExists("package.json") ? this.readJson("package.json") : {};
64
+ const merged = deepMerge(current, patch);
65
+ this.writeJson("package.json", merged);
66
+ return merged;
67
+ }
68
+ }
69
+
70
+ function isPlainObject(value) {
71
+ return typeof value === "object" && value !== null && !Array.isArray(value);
72
+ }
73
+
74
+ function deepMerge(target, source) {
75
+ const result = { ...target };
76
+ for (const key of Object.keys(source)) {
77
+ const sourceVal = source[key];
78
+ const targetVal = target[key];
79
+ if (isPlainObject(sourceVal) && isPlainObject(targetVal)) {
80
+ result[key] = deepMerge(targetVal, sourceVal);
81
+ } else {
82
+ result[key] = sourceVal;
83
+ }
84
+ }
85
+ return result;
86
+ }
87
+
88
+ module.exports = { GeneratorEngine, deepMerge };
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+
3
+ // Field names matching this pattern are treated as sensitive and stripped from every API response
4
+ // generated for a model's CRUD endpoints, regardless of database backend. This is a conservative,
5
+ // name-based heuristic — not a replacement for review — documented in the README's limitations.
6
+ const SENSITIVE_FIELD_PATTERN = /password|secret|hash/i;
7
+
8
+ function getSensitiveFields(model) {
9
+ return model.fields.filter((f) => !f.primaryKey && SENSITIVE_FIELD_PATTERN.test(f.name)).map((f) => f.name);
10
+ }
11
+
12
+ module.exports = { getSensitiveFields, SENSITIVE_FIELD_PATTERN };
@@ -0,0 +1,325 @@
1
+ "use strict";
2
+
3
+ const { isEsm } = require("../syntax");
4
+ const { sharedDir } = require("../paths");
5
+ const { resolveImportPath } = require("../aliasResolver");
6
+ const { getSensitiveFields } = require("./sensitiveFields");
7
+
8
+ function serviceBody(model, config, sensitiveFields) {
9
+ const orm = config.database.orm;
10
+ const type = config.database.type;
11
+ const isTs = config.language === "ts";
12
+ const wrap = sensitiveFields.length > 0;
13
+
14
+ const dataParam = isTs ? "data: Record<string, unknown>" : "data";
15
+ const idParam = isTs ? "id: string" : "id";
16
+
17
+ // Password/secret/hash-like fields are stripped from every response a generated CRUD service
18
+ // returns, regardless of backend — see helpers/strip-sensitive-fields.
19
+ const one = (expr) => (wrap ? `stripSensitiveFields(${expr}, SENSITIVE_FIELDS)` : expr);
20
+ const many = (expr) => (wrap ? `stripSensitiveFieldsFromList(${expr}, SENSITIVE_FIELDS)` : expr);
21
+
22
+ if (type === "postgresql" && orm === "prisma") {
23
+ const ctor = isTs ? `constructor(private readonly db: PrismaClient = prisma) {}` : `constructor(db = prisma) {\n this.db = db;\n }`;
24
+ // Data is already shaped by the Zod validator at the route boundary; the cast here just
25
+ // bridges our generic service signature to Prisma's per-model input types.
26
+ const createData = isTs ? `data: data as Prisma.${model.pascalName}CreateInput` : "data";
27
+ const updateData = isTs ? `data: data as Prisma.${model.pascalName}UpdateInput` : "data";
28
+ return `class ${model.pascalName}Service {
29
+ ${ctor}
30
+
31
+ async create(${dataParam}) {
32
+ const record = await this.db.${model.camelName}.create({ ${createData} });
33
+ return ${one("record")};
34
+ }
35
+
36
+ async findAll() {
37
+ const records = await this.db.${model.camelName}.findMany();
38
+ return ${many("records")};
39
+ }
40
+
41
+ async findById(${idParam}) {
42
+ const record = await this.db.${model.camelName}.findUnique({ where: { ${model.primaryKey.camelName}: id } });
43
+ return ${one("record")};
44
+ }
45
+
46
+ async update(${idParam}, ${dataParam}) {
47
+ const record = await this.db.${model.camelName}.update({ where: { ${model.primaryKey.camelName}: id }, ${updateData} });
48
+ return ${one("record")};
49
+ }
50
+
51
+ async delete(${idParam}) {
52
+ return this.db.${model.camelName}.delete({ where: { ${model.primaryKey.camelName}: id } });
53
+ }
54
+ }`;
55
+ }
56
+
57
+ if (type === "mongodb" && orm === "mongoose") {
58
+ const ctor = isTs
59
+ ? `constructor(private readonly model: Model<${model.pascalName}Document> = ${model.pascalName}) {}`
60
+ : `constructor(model = ${model.pascalName}) {\n this.model = model;\n }`;
61
+ return `class ${model.pascalName}Service {
62
+ ${ctor}
63
+
64
+ async create(${dataParam}) {
65
+ const record = await this.model.create(data);
66
+ return ${one("record")};
67
+ }
68
+
69
+ async findAll() {
70
+ const records = await this.model.find();
71
+ return ${many("records")};
72
+ }
73
+
74
+ async findById(${idParam}) {
75
+ const record = await this.model.findById(id);
76
+ return ${one("record")};
77
+ }
78
+
79
+ async update(${idParam}, ${dataParam}) {
80
+ const record = await this.model.findByIdAndUpdate(id, data, { new: true });
81
+ return ${one("record")};
82
+ }
83
+
84
+ async delete(${idParam}) {
85
+ return this.model.findByIdAndDelete(id);
86
+ }
87
+ }`;
88
+ }
89
+
90
+ if (type === "mongodb" && orm === "native") {
91
+ // Mirrors the Mongoose _id strategy: the primary key is always Mongo's native `_id`, generated
92
+ // as a UUID string here (the driver has no schema-level default, so we generate it ourselves).
93
+ // The driver's default Collection<Document> types `_id` as ObjectId, so we type the collection
94
+ // against a document shape with a string `_id` instead of casting at every call site.
95
+ const collectionGeneric = isTs ? `<${model.pascalName}Doc>` : "";
96
+ return `class ${model.pascalName}Service {
97
+ collection() {
98
+ return getDb().collection${collectionGeneric}("${model.tableName}");
99
+ }
100
+
101
+ async create(${dataParam}) {
102
+ const doc = { _id: randomUUID(), ...data };
103
+ await this.collection().insertOne(doc);
104
+ return ${one("doc")};
105
+ }
106
+
107
+ async findAll() {
108
+ const records = await this.collection().find().toArray();
109
+ return ${many("records")};
110
+ }
111
+
112
+ async findById(${idParam}) {
113
+ const record = await this.collection().findOne({ _id: id });
114
+ return ${one("record")};
115
+ }
116
+
117
+ async update(${idParam}, ${dataParam}) {
118
+ await this.collection().updateOne({ _id: id }, { $set: data });
119
+ return this.findById(id);
120
+ }
121
+
122
+ async delete(${idParam}) {
123
+ const result = await this.collection().deleteOne({ _id: id });
124
+ return result.deletedCount > 0;
125
+ }
126
+ }`;
127
+ }
128
+
129
+ // No database configured: in-memory store so entity.json can still be scaffolded end-to-end.
130
+ const storeFields = isTs
131
+ ? `private store = new Map<string, Record<string, unknown>>();\n private seq = 1;`
132
+ : `constructor() {\n this.store = new Map();\n this.seq = 1;\n }`;
133
+
134
+ return `class ${model.pascalName}Service {
135
+ ${storeFields}
136
+
137
+ async create(${dataParam}) {
138
+ const id = String(this.seq++);
139
+ const record = { id, ...data };
140
+ this.store.set(id, record);
141
+ return ${one("record")};
142
+ }
143
+
144
+ async findAll() {
145
+ return ${many("Array.from(this.store.values())")};
146
+ }
147
+
148
+ async findById(${idParam}) {
149
+ const record = this.store.get(id) || null;
150
+ return ${one("record")};
151
+ }
152
+
153
+ async update(${idParam}, ${dataParam}) {
154
+ const existing = this.store.get(id);
155
+ if (!existing) return null;
156
+ const updated = { ...existing, ...data };
157
+ this.store.set(id, updated);
158
+ return ${one("updated")};
159
+ }
160
+
161
+ async delete(${idParam}) {
162
+ return this.store.delete(id);
163
+ }
164
+ }`;
165
+ }
166
+
167
+ function generateService(model, config, aliasConfig, dirs) {
168
+ const esm = isEsm(config);
169
+ const isTs = config.language === "ts";
170
+ const type = config.database.type;
171
+ const orm = config.database.orm;
172
+ const fromDir = dirs.services;
173
+ const r = (target) => resolveImportPath(config, aliasConfig, fromDir, target);
174
+
175
+ const dbFile = `${sharedDir(config, "configs")}/db/index`;
176
+ const dbPath = r(dbFile);
177
+
178
+ const imports = [];
179
+ if (type === "postgresql" && orm === "prisma") {
180
+ imports.push(esm ? `import { prisma } from "${dbPath}";` : `const { prisma } = require("${dbPath}");`);
181
+ if (isTs) imports.push(`import { PrismaClient, Prisma } from "@prisma/client";`);
182
+ } else if (type === "mongodb" && orm === "mongoose") {
183
+ const modelFile = `${sharedDir(config, "models")}/${model.kebabName}.model`;
184
+ const modelPath = r(modelFile);
185
+ const names = isTs ? `${model.pascalName}, ${model.pascalName}Document` : model.pascalName;
186
+ imports.push(esm ? `import { ${names} } from "${modelPath}";` : `const { ${names} } = require("${modelPath}");`);
187
+ if (isTs) imports.push(`import { Model } from "mongoose";`);
188
+ } else if (type === "mongodb" && orm === "native") {
189
+ imports.push(esm ? `import { getDb } from "${dbPath}";` : `const { getDb } = require("${dbPath}");`);
190
+ imports.push(esm ? `import { randomUUID } from "node:crypto";` : `const { randomUUID } = require("node:crypto");`);
191
+ if (isTs) imports.push(`\ninterface ${model.pascalName}Doc {\n _id: string;\n [key: string]: unknown;\n}`);
192
+ }
193
+
194
+ const sensitiveFields = getSensitiveFields(model);
195
+ if (sensitiveFields.length > 0) {
196
+ const helperPath = r(`${sharedDir(config, "helpers")}/strip-sensitive-fields`);
197
+ imports.push(
198
+ esm
199
+ ? `import { stripSensitiveFields, stripSensitiveFieldsFromList } from "${helperPath}";`
200
+ : `const { stripSensitiveFields, stripSensitiveFieldsFromList } = require("${helperPath}");`
201
+ );
202
+ imports.push(`const SENSITIVE_FIELDS = ${JSON.stringify(sensitiveFields)};`);
203
+ }
204
+
205
+ const body = serviceBody(model, config, sensitiveFields);
206
+ const footer = esm ? `\n\nexport { ${model.pascalName}Service };` : `\n\nmodule.exports = ${model.pascalName}Service;`;
207
+
208
+ return `${imports.join("\n")}${imports.length ? "\n\n" : ""}${body}${footer}\n`;
209
+ }
210
+
211
+ function generateController(model, config, aliasConfig, dirs) {
212
+ const esm = isEsm(config);
213
+ const isTs = config.language === "ts";
214
+ const reqType = isTs ? `import { Request, Response, NextFunction } from "express";\n` : "";
215
+
216
+ let serviceImport = "";
217
+ if (isTs) {
218
+ const servicePath = resolveImportPath(config, aliasConfig, dirs.controllers, `${dirs.services}/${model.kebabName}.service`);
219
+ serviceImport = `import { ${model.pascalName}Service } from "${servicePath}";\n`;
220
+ }
221
+
222
+ const ctorParam = isTs ? `private readonly service: ${model.pascalName}Service` : "service";
223
+ const handlerSig = isTs ? "async (req: Request, res: Response, next: NextFunction)" : "async (req, res, next)";
224
+ const serviceRef = "this.service";
225
+
226
+ const classKw = esm ? "export class" : "class";
227
+
228
+ const ctorBody = isTs ? "" : `\n this.service = service;`;
229
+
230
+ return `${reqType}${serviceImport}${reqType || serviceImport ? "\n" : ""}${classKw} ${model.pascalName}Controller {
231
+ constructor(${ctorParam}) {${ctorBody}
232
+ }
233
+
234
+ create = ${handlerSig} => {
235
+ try {
236
+ const item = await ${serviceRef}.create(req.body);
237
+ return res.status(201).json({ success: true, data: item });
238
+ } catch (error) {
239
+ next(error);
240
+ }
241
+ };
242
+
243
+ findAll = ${handlerSig} => {
244
+ try {
245
+ const items = await ${serviceRef}.findAll();
246
+ return res.status(200).json({ success: true, data: items });
247
+ } catch (error) {
248
+ next(error);
249
+ }
250
+ };
251
+
252
+ findOne = ${handlerSig} => {
253
+ try {
254
+ const item = await ${serviceRef}.findById(req.params.id);
255
+ return res.status(200).json({ success: true, data: item });
256
+ } catch (error) {
257
+ next(error);
258
+ }
259
+ };
260
+
261
+ update = ${handlerSig} => {
262
+ try {
263
+ const item = await ${serviceRef}.update(req.params.id, req.body);
264
+ return res.status(200).json({ success: true, data: item });
265
+ } catch (error) {
266
+ next(error);
267
+ }
268
+ };
269
+
270
+ remove = ${handlerSig} => {
271
+ try {
272
+ await ${serviceRef}.delete(req.params.id);
273
+ return res.status(204).send();
274
+ } catch (error) {
275
+ next(error);
276
+ }
277
+ };
278
+ }
279
+ ${esm ? "" : `\nmodule.exports = ${model.pascalName}Controller;\n`}`;
280
+ }
281
+
282
+ function generateRoutes(model, config, aliasConfig, dirs) {
283
+ const esm = isEsm(config);
284
+ const routesDir = dirs.routes;
285
+ const r = (target) => resolveImportPath(config, aliasConfig, routesDir, target);
286
+
287
+ const controllerPath = r(`${dirs.controllers}/${model.kebabName}.controller`);
288
+ const servicePath = r(`${dirs.services}/${model.kebabName}.service`);
289
+ const validatorPath = r(`${dirs.validators}/${model.kebabName}.validator`);
290
+ const validatePath = r(`${sharedDir(config, "middlewares")}/validate`);
291
+
292
+ const imports = esm
293
+ ? [
294
+ `import { Router } from "express";`,
295
+ `import { validate } from "${validatePath}";`,
296
+ `import { ${model.pascalName}Controller } from "${controllerPath}";`,
297
+ `import { ${model.pascalName}Service } from "${servicePath}";`,
298
+ `import { create${model.pascalName}Schema, update${model.pascalName}Schema } from "${validatorPath}";`,
299
+ ]
300
+ : [
301
+ `const { Router } = require("express");`,
302
+ `const { validate } = require("${validatePath}");`,
303
+ `const ${model.pascalName}Controller = require("${controllerPath}");`,
304
+ `const ${model.pascalName}Service = require("${servicePath}");`,
305
+ `const { create${model.pascalName}Schema, update${model.pascalName}Schema } = require("${validatorPath}");`,
306
+ ];
307
+
308
+ const body = `const router = Router();
309
+
310
+ const service = new ${model.pascalName}Service();
311
+ const controller = new ${model.pascalName}Controller(service);
312
+
313
+ router.post("/", validate(create${model.pascalName}Schema), controller.create);
314
+ router.get("/", controller.findAll);
315
+ router.get("/:id", controller.findOne);
316
+ router.patch("/:id", validate(update${model.pascalName}Schema), controller.update);
317
+ router.delete("/:id", controller.remove);
318
+ `;
319
+
320
+ const footer = esm ? "\nexport default router;\n" : "\nmodule.exports = router;\n";
321
+
322
+ return `${imports.join("\n")}\n\n${body}${footer}`;
323
+ }
324
+
325
+ module.exports = { generateService, generateController, generateRoutes };
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+
3
+ const MONGOOSE_TYPE_MAP = {
4
+ string: "String",
5
+ text: "String",
6
+ number: "Number",
7
+ integer: "Number",
8
+ float: "Number",
9
+ boolean: "Boolean",
10
+ date: "Date",
11
+ datetime: "Date",
12
+ uuid: "String",
13
+ json: "mongoose.Schema.Types.Mixed",
14
+ enum: "String",
15
+ decimal: "mongoose.Schema.Types.Decimal128",
16
+ bigint: "mongoose.Schema.Types.Mixed",
17
+ };
18
+
19
+ function buildFieldDefinition(model, field, allModels) {
20
+ const belongsTo = model.relations.find((r) => r.type === "belongsTo" && r.foreignKey === field.name);
21
+ const props = [];
22
+
23
+ if (belongsTo) {
24
+ const target = allModels.find((m) => m.name === belongsTo.model);
25
+ // Every Mongo primary key is a String UUID (see buildIdOverride), so the FK referencing it
26
+ // must be String too — mongoose.Schema.Types.ObjectId would throw a CastError on every write.
27
+ props.push(`type: String`);
28
+ if (target) props.push(`ref: "${target.pascalName}"`);
29
+ } else {
30
+ props.push(`type: ${MONGOOSE_TYPE_MAP[field.type]}`);
31
+ if (field.type === "enum") props.push(`enum: ${JSON.stringify(field.values)}`);
32
+ }
33
+
34
+ if (field.required) props.push(`required: true`);
35
+ if (field.unique) props.push(`unique: true`);
36
+ if (field.index && !field.unique) props.push(`index: true`);
37
+ if (field.default !== undefined && field.default !== null && field.default !== "uuid") {
38
+ props.push(`default: ${JSON.stringify(field.default)}`);
39
+ }
40
+ if (field.min !== undefined) props.push(`min: ${field.min}`);
41
+ if (field.max !== undefined) props.push(`max: ${field.max}`);
42
+ if (field.length !== undefined) props.push(`maxlength: ${field.length}`);
43
+
44
+ return ` ${field.camelName}: { ${props.join(", ")} },`;
45
+ }
46
+
47
+ /**
48
+ * Entity validation guarantees that a MongoDB model's primary key, if declared explicitly, is
49
+ * always type 'uuid' (anything else — including autoIncrement — is rejected at parse time since
50
+ * Mongo has no native equivalent). So rather than emitting a redundant parallel 'id' field next to
51
+ * Mongo's native `_id`, we always route the primary key through `_id` itself: overriding its type
52
+ * to a generated UUID string. This is the one documented strategy GAZAN uses for Mongo primary
53
+ * keys — see README's MongoDB section.
54
+ */
55
+ function buildIdOverride() {
56
+ return ` _id: { type: String, default: () => randomUUID() },`;
57
+ }
58
+
59
+ /**
60
+ * belongsToMany has no scalar FK column to hang off buildFieldDefinition (it only walks
61
+ * model.fields) — Mongoose represents it as its own array-of-refs field instead.
62
+ */
63
+ function buildManyToManyFields(model, allModels) {
64
+ const lines = [];
65
+ for (const relation of model.relations) {
66
+ if (relation.type !== "belongsToMany") continue;
67
+ const target = allModels.find((m) => m.name === relation.model);
68
+ if (!target) continue;
69
+ lines.push(` ${relation.camelName}: { type: [String], ref: "${target.pascalName}", default: [] },`);
70
+ }
71
+ return lines;
72
+ }
73
+
74
+ function buildVirtuals(model, allModels) {
75
+ const lines = [];
76
+ for (const relation of model.relations) {
77
+ if (relation.type === "hasMany" || relation.type === "hasOne") {
78
+ const target = allModels.find((m) => m.name === relation.model);
79
+ if (!target) continue;
80
+ lines.push(`${model.camelName}Schema.virtual("${relation.name}", {`);
81
+ lines.push(` ref: "${target.pascalName}",`);
82
+ lines.push(` localField: "_id",`);
83
+ lines.push(` foreignField: "${relation.foreignKey}",`);
84
+ lines.push(` justOne: ${relation.type === "hasOne"},`);
85
+ lines.push(`});`);
86
+ lines.push("");
87
+ }
88
+ }
89
+ return lines.join("\n");
90
+ }
91
+
92
+ function generateMongooseModel(model, allModels, config) {
93
+ const nonPkFields = model.fields.filter((f) => !f.primaryKey);
94
+ const fieldLines = [
95
+ buildIdOverride(),
96
+ ...nonPkFields.map((f) => buildFieldDefinition(model, f, allModels)),
97
+ ...buildManyToManyFields(model, allModels),
98
+ ].join("\n");
99
+
100
+ const virtuals = buildVirtuals(model, allModels);
101
+ const isTs = config.language === "ts";
102
+ const esm = config.moduleSystem === "mjs" || isTs;
103
+
104
+ const cryptoImport = esm ? `import { randomUUID } from "node:crypto";` : `const { randomUUID } = require("node:crypto");`;
105
+ const mongooseImport = isTs
106
+ ? `import mongoose, { Document } from "mongoose";`
107
+ : config.moduleSystem === "mjs"
108
+ ? `import mongoose from "mongoose";`
109
+ : `const mongoose = require("mongoose");`;
110
+
111
+ const header = `${mongooseImport}\n${cryptoImport}\n`;
112
+
113
+ const interfaceBlock = isTs ? buildTsInterface(model) : "";
114
+
115
+ const schemaBody = [
116
+ `const ${model.camelName}Schema = new mongoose.Schema(`,
117
+ ` {`,
118
+ fieldLines,
119
+ ` },`,
120
+ ` {`,
121
+ ` timestamps: ${model.timestamps},`,
122
+ ` collection: "${model.tableName}",`,
123
+ ` }`,
124
+ `);`,
125
+ ``,
126
+ virtuals ? virtuals : "",
127
+ `${model.camelName}Schema.set("toJSON", { virtuals: true });`,
128
+ ``,
129
+ ]
130
+ .filter((l) => l !== "")
131
+ .join("\n");
132
+
133
+ const exportBlock = isTs
134
+ ? `export const ${model.pascalName} = mongoose.model<${model.pascalName}Document>("${model.pascalName}", ${model.camelName}Schema);\n`
135
+ : config.moduleSystem === "mjs"
136
+ ? `export const ${model.pascalName} = mongoose.model("${model.pascalName}", ${model.camelName}Schema);\n`
137
+ : `const ${model.pascalName} = mongoose.model("${model.pascalName}", ${model.camelName}Schema);\n\nmodule.exports = ${model.pascalName};\n`;
138
+
139
+ return [header, interfaceBlock, schemaBody, exportBlock].filter(Boolean).join("\n");
140
+ }
141
+
142
+ const TS_TYPE_MAP = {
143
+ string: "string",
144
+ text: "string",
145
+ number: "number",
146
+ integer: "number",
147
+ float: "number",
148
+ boolean: "boolean",
149
+ date: "Date",
150
+ datetime: "Date",
151
+ uuid: "string",
152
+ json: "Record<string, unknown>",
153
+ enum: "string",
154
+ decimal: "number",
155
+ bigint: "number",
156
+ };
157
+
158
+ function buildTsInterface(model) {
159
+ // Document<string, ...> types this model's `_id` (and the `.id` virtual) as a string, matching
160
+ // the UUID primary key strategy above — Mongoose's default `Document` types `_id` as an ObjectId.
161
+ const lines = [`export interface ${model.pascalName}Document extends Document<string> {`];
162
+ for (const field of model.fields) {
163
+ if (field.primaryKey) continue;
164
+ const optional = field.required ? "" : "?";
165
+ lines.push(` ${field.camelName}${optional}: ${TS_TYPE_MAP[field.type]};`);
166
+ }
167
+ for (const relation of model.relations) {
168
+ if (relation.type === "belongsToMany") {
169
+ lines.push(` ${relation.camelName}: string[];`);
170
+ }
171
+ }
172
+ if (model.timestamps) {
173
+ lines.push(` createdAt: Date;`);
174
+ lines.push(` updatedAt: Date;`);
175
+ }
176
+ lines.push(`}`);
177
+ return lines.join("\n") + "\n";
178
+ }
179
+
180
+ module.exports = { generateMongooseModel };