@mostajs/orm-cli 0.6.4 → 0.7.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.
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ // fix-mongo-id.mjs — codemod : repair Mongo-era residue left in app code after a
3
+ // project migrates from Mongoose to @mostajs/orm (data-plug). The ORM normalizer
4
+ // maps `_id`→`id` and ids are plain STRINGS for every dialect (mongo included),
5
+ // so two classes of stale code must be repaired :
6
+ //
7
+ // 1. Identifier reads `obj._id` → `obj.id`
8
+ // 2. ObjectId wrapping `new ObjectId(x)` → `x`
9
+ // `mongoose.Types.ObjectId(x)` → `x`
10
+ //
11
+ // @author Dr Hamid MADANI <drmdh@msn.com>
12
+ // License: AGPL-3.0-or-later
13
+ //
14
+ // Usage (invoked by bin/mostajs.sh as `mostajs fix-ids`) :
15
+ //
16
+ // node fix-mongo-id.mjs # dry-run (default, safe — lists sites)
17
+ // node fix-mongo-id.mjs --apply # rewrite files (+ .mongoid.bak backups)
18
+ // node fix-mongo-id.mjs --file X # restrict to a single file
19
+ // node fix-mongo-id.mjs --project P # root to scan (default: cwd)
20
+ // node fix-mongo-id.mjs --map old:new # extra member rename (repeatable)
21
+ // node fix-mongo-id.mjs --no-objectid # skip ObjectId() unwrapping
22
+ // node fix-mongo-id.mjs --include-mongoose # also touch files importing mongoose
23
+ // node fix-mongo-id.mjs --restore # restore from .mongoid.bak backups
24
+ //
25
+ // Conservative by design :
26
+ // - Member rename touches ACCESS only : `.​_id`, `?._id`, `['_id']`, `["_id"]`.
27
+ // A bare `_id:` object *key* (a Mongoose schema declaration) is left intact.
28
+ // - ObjectId unwrap removes the constructor wrapper but KEEPS the inner
29
+ // expression : `new ObjectId(req.params.id)` → `req.params.id`.
30
+ // `ObjectId.isValid(...)` and `import { ObjectId }` lines are NOT rewritten
31
+ // (semantics differ) — they are reported for manual review.
32
+ // - Files importing `mongoose` (likely real Mongo models) are skipped + reported.
33
+ // - Original file is saved as <path>.mongoid.bak. Re-runs are idempotent.
34
+
35
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, renameSync, copyFileSync } from 'node:fs';
36
+ import { join, resolve, relative, extname } from 'node:path';
37
+
38
+ // ---------- CLI ----------
39
+ const argv = process.argv.slice(2);
40
+ const flag = (name) => argv.includes(`--${name}`);
41
+ const val = (name) => { const i = argv.indexOf(`--${name}`); return i >= 0 ? argv[i + 1] : null; };
42
+ const vals = (name) => argv.reduce((acc, a, i) => (a === `--${name}` && argv[i + 1] ? [...acc, argv[i + 1]] : acc), []);
43
+
44
+ const APPLY = flag('apply');
45
+ const RESTORE = flag('restore');
46
+ const ONE_FILE = val('file');
47
+ const ROOT = resolve(val('project') ?? process.cwd());
48
+ const QUIET = flag('quiet');
49
+ const INCLUDE_MONGOOSE = flag('include-mongoose');
50
+ const NO_OBJECTID = flag('no-objectid');
51
+
52
+ const log = (...a) => { if (!QUIET) console.log(...a); };
53
+ const c = { cyan: s => `\x1b[36m${s}\x1b[0m`, yellow: s => `\x1b[33m${s}\x1b[0m`, green: s => `\x1b[32m${s}\x1b[0m`, red: s => `\x1b[31m${s}\x1b[0m`, bold: s => `\x1b[1m${s}\x1b[0m`, dim: s => `\x1b[2m${s}\x1b[0m` };
54
+
55
+ // ---------- Rename map (member access) ----------
56
+ const renameMap = new Map([['_id', 'id']]);
57
+ for (const m of vals('map')) {
58
+ const [from, to] = m.split(':');
59
+ if (!from || !to) { console.error(c.red(`Bad --map "${m}" — expected old:new`)); process.exit(1); }
60
+ renameMap.set(from, to);
61
+ }
62
+ const RISKY = [...renameMap.keys()].filter((k) => /^[a-z][a-zA-Z]*$/.test(k) && k.length <= 6 && k !== 'id');
63
+
64
+ // ---------- Walk ----------
65
+ const SKIP_DIRS = new Set(['node_modules', '.next', '.svelte-kit', 'dist', 'build', '.turbo', '.vercel', '.cache', 'coverage', '.git', '.vscode', '.idea']);
66
+ const EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte']);
67
+ const BAK = '.mongoid.bak';
68
+
69
+ function* walk(dir) {
70
+ let entries;
71
+ try { entries = readdirSync(dir); } catch { return; }
72
+ for (const name of entries) {
73
+ if (SKIP_DIRS.has(name)) continue;
74
+ const p = join(dir, name);
75
+ let st;
76
+ try { st = statSync(p); } catch { continue; }
77
+ if (st.isDirectory()) yield* walk(p);
78
+ else if (EXTENSIONS.has(extname(name)) || name.endsWith(BAK)) yield p;
79
+ }
80
+ }
81
+
82
+ // ---------- Member access rename ( ._id → .id ) ----------
83
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
84
+
85
+ function memberPatterns(from, to) {
86
+ const f = escape(from);
87
+ return [
88
+ { re: new RegExp(`(\\??\\.)${f}\\b`, 'g'), build: (g1) => `${g1}${to}` }, // .foo / ?.foo
89
+ { re: new RegExp(`\\[(['"])${f}\\1\\]`, 'g'), build: (q) => `[${q}${to}${q}]` }, // ['foo'] / ["foo"]
90
+ ];
91
+ }
92
+
93
+ function renameMembers(src) {
94
+ let out = src, count = 0;
95
+ for (const [from, to] of renameMap) {
96
+ for (const { re, build } of memberPatterns(from, to)) {
97
+ out = out.replace(re, (_m, g1) => { count++; return build(g1); });
98
+ }
99
+ }
100
+ return { out, count };
101
+ }
102
+
103
+ // ---------- ObjectId unwrap ( new ObjectId(x) → x ) ----------
104
+ // Matches the constructor head, with or without `new`, and the mongoose paths
105
+ // `mongoose.Types.ObjectId(` / `Types.ObjectId(`. NOT `ObjectId.isValid(` (the
106
+ // `.isValid` sits between `ObjectId` and `(`, so the head regex can't match it).
107
+ const RX_OBJID_CTOR_HEAD = /(?:new\s+)?(?:mongoose\.)?(?:Types\.)?ObjectId\s*\(/g;
108
+
109
+ function unwrapObjectId(src) {
110
+ if (NO_OBJECTID) return { out: src, count: 0 };
111
+ let out = '', last = 0, count = 0;
112
+ const re = new RegExp(RX_OBJID_CTOR_HEAD.source, 'g');
113
+ let m;
114
+ while ((m = re.exec(src)) !== null) {
115
+ const open = m.index + m[0].length - 1; // index of the '('
116
+ let depth = 1, j = open + 1;
117
+ for (; j < src.length && depth > 0; j++) {
118
+ const ch = src[j];
119
+ if (ch === '(') depth++;
120
+ else if (ch === ')') depth--;
121
+ }
122
+ if (depth !== 0) continue; // unbalanced — leave as-is
123
+ const close = j - 1;
124
+ const inner = src.slice(open + 1, close).trim();
125
+ out += src.slice(last, m.index) + inner;
126
+ last = close + 1;
127
+ count++;
128
+ re.lastIndex = last;
129
+ }
130
+ out += src.slice(last);
131
+ return { out, count };
132
+ }
133
+
134
+ // ---------- Per-file analysis ----------
135
+ const RX_MONGOOSE = /\b(?:from\s+['"]mongoose['"]|require\(\s*['"]mongoose['"]\s*\)|new\s+(?:mongoose\.)?Schema\s*\()/;
136
+ const RX_OBJID_ISVALID = /\bObjectId\.isValid\s*\(/;
137
+ const RX_OBJID_IMPORT = /\bimport\b[^;\n]*\bObjectId\b[^;\n]*\bfrom\b[^;\n]*['"](?:mongodb|bson|mongoose)['"]/;
138
+
139
+ function analyze(src, rel) {
140
+ const r1 = renameMembers(src);
141
+ const r2 = unwrapObjectId(r1.out);
142
+ const idCount = r1.count, objCount = r2.count;
143
+ // Manual-review notes (reported, never auto-edited).
144
+ const manual = [];
145
+ src.split('\n').forEach((line, i) => {
146
+ if (RX_OBJID_ISVALID.test(line)) manual.push({ ln: i + 1, text: line.trim().slice(0, 100), why: 'ObjectId.isValid — replace with a string check' });
147
+ if (RX_OBJID_IMPORT.test(line)) manual.push({ ln: i + 1, text: line.trim().slice(0, 100), why: 'ObjectId import — likely unused after unwrap, remove' });
148
+ });
149
+ // Sample hit lines (from the ORIGINAL source) for the report.
150
+ const hitLines = [];
151
+ src.split('\n').forEach((line, i) => {
152
+ let hit = false;
153
+ for (const [from, to] of renameMap) for (const { re } of memberPatterns(from, to)) { re.lastIndex = 0; if (re.test(line)) hit = true; }
154
+ if (!NO_OBJECTID && RX_OBJID_CTOR_HEAD.test(line) && !RX_OBJID_ISVALID.test(line)) hit = true;
155
+ if (hit) hitLines.push({ ln: i + 1, text: line.trim().slice(0, 100) });
156
+ });
157
+ return { rel, rewritten: r2.out, idCount, objCount, manual, hitLines };
158
+ }
159
+
160
+ // ---------- Restore ----------
161
+ function restoreBackups() {
162
+ const restored = [];
163
+ for (const p of walk(ROOT)) {
164
+ if (!p.endsWith(BAK)) continue;
165
+ const original = p.slice(0, -BAK.length);
166
+ const rel = relative(ROOT, original);
167
+ if (APPLY) { renameSync(p, original); log(` ${c.green('✓')} restored ${c.dim(rel)}`); }
168
+ else { log(` ${c.yellow('•')} would restore ${c.dim(rel)}`); }
169
+ restored.push(original);
170
+ }
171
+ return restored;
172
+ }
173
+
174
+ // ---------- Main ----------
175
+ if (RESTORE) {
176
+ log(c.bold('▶ Restoring .mongoid.bak files' + (APPLY ? '' : c.yellow(' (dry-run, use --apply)'))));
177
+ const r = restoreBackups();
178
+ log(`\n${r.length} file(s) ${APPLY ? 'restored' : 'would be restored'}`);
179
+ process.exit(0);
180
+ }
181
+
182
+ log(c.bold(`▶ mostajs fix-ids — scanning ${c.cyan(ROOT)}`));
183
+ log(c.dim(` member renames : ${[...renameMap].map(([f, t]) => `${f}→${t}`).join(' ')}`));
184
+ log(c.dim(` ObjectId unwrap : ${NO_OBJECTID ? 'disabled (--no-objectid)' : 'new ObjectId(x) → x'}`));
185
+ if (RISKY.length) log(c.yellow(` ⚠ blunt member rename(s): ${RISKY.join(', ')} — renames EVERY occurrence; review the dry-run.`));
186
+ log('');
187
+
188
+ const candidates = [];
189
+ const manualOnly = [];
190
+ const skippedMongoose = [];
191
+ const iter = ONE_FILE ? [resolve(ROOT, ONE_FILE)] : walk(ROOT);
192
+ for (const p of iter) {
193
+ if (p.endsWith(BAK)) continue;
194
+ let src;
195
+ try { src = readFileSync(p, 'utf8'); } catch { continue; }
196
+ const rel = relative(ROOT, p);
197
+ const a = analyze(src, rel);
198
+ if (a.idCount === 0 && a.objCount === 0 && a.manual.length === 0) continue;
199
+ if (RX_MONGOOSE.test(src) && !INCLUDE_MONGOOSE) { skippedMongoose.push(rel); continue; }
200
+ if (a.idCount === 0 && a.objCount === 0) { manualOnly.push({ path: p, ...a }); continue; }
201
+ candidates.push({ path: p, src, ...a });
202
+ }
203
+
204
+ const totalId = candidates.reduce((n, ca) => n + ca.idCount, 0);
205
+ const totalObj = candidates.reduce((n, ca) => n + ca.objCount, 0);
206
+
207
+ if (candidates.length === 0 && manualOnly.length === 0) {
208
+ log(c.green(' Nothing to fix.'));
209
+ if (skippedMongoose.length) { log(''); log(c.yellow(` ${skippedMongoose.length} mongoose file(s) skipped (use --include-mongoose):`)); for (const r of skippedMongoose) log(c.dim(` • ${r}`)); }
210
+ process.exit(0);
211
+ }
212
+
213
+ if (candidates.length) {
214
+ log(c.bold(`Found ${totalId} stale id read(s) + ${totalObj} ObjectId wrap(s) in ${candidates.length} file(s):`));
215
+ for (const ca of candidates) {
216
+ log(` ${c.green('→')} ${ca.rel} ${c.dim(`(_id:${ca.idCount} ObjectId:${ca.objCount})`)}`);
217
+ for (const h of ca.hitLines.slice(0, 6)) log(c.dim(` ${h.ln}: ${h.text}`));
218
+ if (ca.hitLines.length > 6) log(c.dim(` … +${ca.hitLines.length - 6} more`));
219
+ }
220
+ log('');
221
+ }
222
+
223
+ // Manual-review notes (across all files that have them).
224
+ const allManual = [...candidates, ...manualOnly].filter((f) => f.manual.length);
225
+ if (allManual.length) {
226
+ log(c.yellow('Manual review (NOT auto-rewritten):'));
227
+ for (const f of allManual) for (const mn of f.manual) log(c.dim(` ${f.rel}:${mn.ln} ${c.yellow(mn.why)}\n ${mn.text}`));
228
+ log('');
229
+ }
230
+
231
+ if (skippedMongoose.length) {
232
+ log(c.yellow(`${skippedMongoose.length} mongoose file(s) skipped (real Mongo models keep _id):`));
233
+ for (const r of skippedMongoose) log(c.dim(` • ${r}`));
234
+ log(c.dim(' Force with --include-mongoose if these are actually data-plug call-sites.'));
235
+ log('');
236
+ }
237
+
238
+ if (!APPLY) {
239
+ log(c.yellow('Dry-run — no files written. Re-run with --apply to execute.'));
240
+ process.exit(0);
241
+ }
242
+
243
+ if (candidates.length === 0) { log(c.green('Nothing to auto-rewrite (manual items only).')); process.exit(0); }
244
+
245
+ // ---------- Apply ----------
246
+ log(c.bold('Applying rewrites :'));
247
+ for (const ca of candidates) {
248
+ const bak = ca.path + BAK;
249
+ if (!existsSync(bak)) copyFileSync(ca.path, bak);
250
+ writeFileSync(ca.path, ca.rewritten);
251
+ log(` ${c.green('✓')} fixed ${ca.rel} ${c.dim(`(backup: ${relative(ROOT, bak)})`)}`);
252
+ }
253
+
254
+ log('');
255
+ log(c.bold(c.green(`✓ Repaired ${totalId} id read(s) + ${totalObj} ObjectId wrap(s) in ${candidates.length} file(s).`)));
256
+ if (allManual.length) log(c.yellow(` ${allManual.reduce((n, f) => n + f.manual.length, 0)} manual item(s) still need review (see above).`));
257
+ log('');
258
+ log(`Review the diff, then rebuild your front/app.`);
259
+ log(`To undo all : ${c.cyan('mostajs fix-ids --restore --apply')}`);
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ // mongo-introspect.mjs — reverse-engineer a LIVE MongoDB into EntitySchema[].
3
+ // The "migrer" half of the Mongo → @mostajs/orm story : connect to the database,
4
+ // sample documents per collection, infer field types, and emit the same
5
+ // `.mostajs/generated/entities.json` that `mostajs convert` produces — so a Mongo
6
+ // project with no Prisma/OpenAPI schema can still adopt data-plug.
7
+ //
8
+ // The ORM normalizes `_id`→`id` (string) for every dialect, so `_id`/`__v` are
9
+ // dropped here and ObjectId-typed fields are emitted as `string`.
10
+ //
11
+ // @author Dr Hamid MADANI <drmdh@msn.com>
12
+ // License: AGPL-3.0-or-later
13
+ //
14
+ // Usage (invoked by bin/mostajs.sh as `mostajs mongo-introspect`) :
15
+ //
16
+ // node mongo-introspect.mjs --uri mongodb://user:pw@host:27017/db?authSource=admin
17
+ // node mongo-introspect.mjs --sample 500 --out .mostajs/generated/entities.json
18
+ // (falls back to $SGBD_URI / $DB_DIALECT from the environment / .mostajs config)
19
+
20
+ import { writeFileSync, mkdirSync } from 'node:fs';
21
+ import { resolve, dirname } from 'node:path';
22
+ import { createRequire } from 'node:module';
23
+ import { pathToFileURL } from 'node:url';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const argv = process.argv.slice(2);
27
+ const val = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : d; };
28
+ const flag = (n) => argv.includes(`--${n}`);
29
+
30
+ const ROOT = resolve(val('project', process.cwd()));
31
+ const URI = val('uri', process.env.SGBD_URI || '');
32
+ const SAMPLE = Math.max(1, Number(val('sample', '200')) || 200);
33
+ const OUT = resolve(val('out', `${ROOT}/.mostajs/generated/entities.json`));
34
+ const QUIET = flag('quiet');
35
+ const VERSION = '0.7.0';
36
+
37
+ const c = { red: s => `\x1b[31m${s}\x1b[0m`, yellow: s => `\x1b[33m${s}\x1b[0m`, green: s => `\x1b[32m${s}\x1b[0m`, cyan: s => `\x1b[36m${s}\x1b[0m`, bold: s => `\x1b[1m${s}\x1b[0m`, dim: s => `\x1b[2m${s}\x1b[0m` };
38
+ const log = (...a) => { if (!QUIET) console.log(...a); };
39
+
40
+ if (!URI) {
41
+ console.error(c.red('✗ No MongoDB URI. Pass --uri or set SGBD_URI (menu 2 → import).'));
42
+ process.exit(2);
43
+ }
44
+
45
+ // ── Resolve mongoose from the project (the mongo driver @mostajs/orm uses) ──
46
+ let mongoose;
47
+ const cliDir = resolve(new URL('..', import.meta.url).pathname);
48
+ for (const base of [ROOT, cliDir, process.cwd()]) {
49
+ try { mongoose = (await import(pathToFileURL(require.resolve('mongoose', { paths: [base] })).href)).default; break; }
50
+ catch { /* next */ }
51
+ }
52
+ if (!mongoose) {
53
+ console.error(c.red('✗ mongoose not installed in the project.'));
54
+ console.error(` Install it : ${c.cyan('npm i mongoose')} (it is the driver @mostajs/orm uses for MongoDB)`);
55
+ process.exit(2);
56
+ }
57
+
58
+ // ── Type inference ─────────────────────────────────────────────────────────
59
+ function isObjectId(v) {
60
+ return v && typeof v === 'object' &&
61
+ (v._bsontype === 'ObjectID' || v._bsontype === 'ObjectId' ||
62
+ (v.constructor && v.constructor.name === 'ObjectId'));
63
+ }
64
+ function scalarType(v) {
65
+ if (v === null || v === undefined) return null;
66
+ if (typeof v === 'string') return 'string';
67
+ if (typeof v === 'number') return 'number';
68
+ if (typeof v === 'boolean') return 'boolean';
69
+ if (v instanceof Date) return 'date';
70
+ if (isObjectId(v)) return 'string'; // normalized to string id
71
+ if (Array.isArray(v)) return 'array';
72
+ if (typeof v === 'object') return 'json';
73
+ return 'string';
74
+ }
75
+ // Reconcile a set of observed types into a single FieldType.
76
+ function reconcile(types) {
77
+ const s = new Set([...types].filter(Boolean));
78
+ if (s.size === 0) return 'string';
79
+ if (s.size === 1) return [...s][0];
80
+ if (s.has('json') || s.has('array')) return 'json';
81
+ if (s.has('string')) return 'string'; // string + number → string
82
+ return 'json';
83
+ }
84
+
85
+ function pascal(s) {
86
+ return s.replace(/[_\-.\s]+/g, ' ').trim()
87
+ .replace(/\s+(.)/g, (_, ch) => ch.toUpperCase())
88
+ .replace(/^(.)/, (_, ch) => ch.toUpperCase());
89
+ }
90
+ function singular(n) {
91
+ // Don't mangle words that merely END in 's' but aren't plurals
92
+ // (Status, Address, Analysis, Series, OS…). Naive de-pluralization otherwise.
93
+ if (/(ss|us|is|os| news|series|species)$/i.test(n)) return n;
94
+ if (/ies$/i.test(n)) return n.slice(0, -3) + 'y';
95
+ if (/(ches|shes|xes|zes|ses)$/i.test(n)) return n.slice(0, -2);
96
+ if (/s$/i.test(n)) return n.slice(0, -1);
97
+ return n;
98
+ }
99
+
100
+ // ── Main ───────────────────────────────────────────────────────────────────
101
+ log(c.bold(`▶ mostajs mongo-introspect — ${c.cyan(URI.replace(/\/\/[^@]*@/, '//***@'))}`));
102
+ log(c.dim(` sampling up to ${SAMPLE} doc(s) per collection`));
103
+ log('');
104
+
105
+ await mongoose.connect(URI, { serverSelectionTimeoutMS: 8000 });
106
+ const db = mongoose.connection.db;
107
+ const collInfos = (await db.listCollections().toArray())
108
+ .filter((ci) => ci.type !== 'view' && !ci.name.startsWith('system.'));
109
+
110
+ const entities = [];
111
+ for (const ci of collInfos) {
112
+ const name = ci.name;
113
+ const docs = await db.collection(name).find({}).limit(SAMPLE).toArray();
114
+ if (docs.length === 0) {
115
+ log(` ${c.yellow('○')} ${name} ${c.dim('(empty — skipped)')}`);
116
+ continue;
117
+ }
118
+ // Aggregate field stats.
119
+ const stat = new Map(); // field → { present, nulls, types:Set, elemTypes:Set }
120
+ for (const doc of docs) {
121
+ for (const [k, v] of Object.entries(doc)) {
122
+ if (k === '_id' || k === '__v') continue;
123
+ const st = stat.get(k) ?? { present: 0, nulls: 0, types: new Set(), elem: new Set() };
124
+ st.present++;
125
+ if (v === null || v === undefined) st.nulls++;
126
+ st.types.add(scalarType(v));
127
+ if (Array.isArray(v) && v.length) st.elem.add(scalarType(v[0]));
128
+ stat.set(k, st);
129
+ }
130
+ }
131
+
132
+ const fields = {};
133
+ let hasCreated = false, hasUpdated = false;
134
+ for (const [k, st] of stat) {
135
+ const type = reconcile(st.types);
136
+ if (k === 'createdAt' && type === 'date') { hasCreated = true; continue; }
137
+ if (k === 'updatedAt' && type === 'date') { hasUpdated = true; continue; }
138
+ const def = { type };
139
+ if (st.present === docs.length && st.nulls === 0) def.required = true;
140
+ if (type === 'array') { const e = reconcile(st.elem); if (e) def.arrayOf = e; }
141
+ fields[k] = def;
142
+ }
143
+
144
+ // Real indexes → IndexDef (skip the default _id_ index).
145
+ const indexes = [];
146
+ try {
147
+ for (const ix of await db.collection(name).indexes()) {
148
+ if (ix.name === '_id_' || !ix.key) continue;
149
+ const f = {};
150
+ for (const [col, dir] of Object.entries(ix.key)) {
151
+ if (col === '_id') continue;
152
+ f[col] = dir === -1 ? 'desc' : (dir === 1 ? 'asc' : 'asc');
153
+ }
154
+ if (Object.keys(f).length) indexes.push({ fields: f, ...(ix.unique ? { unique: true } : {}) });
155
+ }
156
+ } catch { /* indexes() can fail on some deployments — non-fatal */ }
157
+
158
+ entities.push({
159
+ name: pascal(singular(name)),
160
+ collection: name,
161
+ fields,
162
+ relations: {},
163
+ indexes,
164
+ timestamps: hasCreated && hasUpdated,
165
+ });
166
+ log(` ${c.green('✓')} ${name} → ${c.bold(pascal(singular(name)))} ${c.dim(`(${Object.keys(fields).length} fields, ${indexes.length} index, ${docs.length} sampled)`)}`);
167
+ }
168
+
169
+ await mongoose.disconnect();
170
+
171
+ if (entities.length === 0) {
172
+ console.error(c.yellow('\n No non-empty collections found — nothing written.'));
173
+ process.exit(0);
174
+ }
175
+
176
+ // ── Write entities.json (+ .ts, same layout as `mostajs convert`) ──
177
+ mkdirSync(dirname(OUT), { recursive: true });
178
+ const jsonFile = OUT.endsWith('.json') ? OUT : OUT.replace(/\.ts$/, '.json');
179
+ const tsFile = jsonFile.replace(/\.json$/, '.ts');
180
+ writeFileSync(jsonFile, JSON.stringify(entities, null, 2));
181
+ const header = `// Auto-generated by @mostajs/orm-cli mongo-introspect v${VERSION}\n` +
182
+ `// Source : live MongoDB (sampled ${SAMPLE} docs/collection)\n` +
183
+ `// DO NOT EDIT BY HAND — regenerate with: mostajs mongo-introspect\n\n` +
184
+ `import type { EntitySchema } from "@mostajs/orm";\n\n` +
185
+ `export const entities: EntitySchema[] = ${JSON.stringify(entities, null, 2)};\n\n` +
186
+ `export const entityByName: Record<string, EntitySchema> = Object.fromEntries(\n` +
187
+ ` entities.map(e => [e.name, e])\n);\n`;
188
+ writeFileSync(tsFile, header);
189
+
190
+ log('');
191
+ log(c.bold(c.green(`✓ ${entities.length} entit(y/ies) → ${jsonFile}`)));
192
+ log('');
193
+ log('Next :');
194
+ log(` 1. ${c.cyan('mostajs validate')} # lint the inferred schema`);
195
+ log(` 2. ${c.cyan('mostajs fix-ids --apply')} # repair app code (_id / ObjectId)`);
196
+ log(` 3. ${c.cyan('mostajs')} → menu 3 # init dialects / DDL on the target DB`);