@open-mercato/cli 0.4.2-canary-c02407ff85
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/bin/mercato +21 -0
- package/build.mjs +78 -0
- package/dist/bin.js +51 -0
- package/dist/bin.js.map +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/lib/db/commands.js +350 -0
- package/dist/lib/db/commands.js.map +7 -0
- package/dist/lib/db/index.js +7 -0
- package/dist/lib/db/index.js.map +7 -0
- package/dist/lib/generators/entity-ids.js +257 -0
- package/dist/lib/generators/entity-ids.js.map +7 -0
- package/dist/lib/generators/index.js +12 -0
- package/dist/lib/generators/index.js.map +7 -0
- package/dist/lib/generators/module-di.js +73 -0
- package/dist/lib/generators/module-di.js.map +7 -0
- package/dist/lib/generators/module-entities.js +104 -0
- package/dist/lib/generators/module-entities.js.map +7 -0
- package/dist/lib/generators/module-registry.js +1081 -0
- package/dist/lib/generators/module-registry.js.map +7 -0
- package/dist/lib/resolver.js +205 -0
- package/dist/lib/resolver.js.map +7 -0
- package/dist/lib/utils.js +161 -0
- package/dist/lib/utils.js.map +7 -0
- package/dist/mercato.js +1045 -0
- package/dist/mercato.js.map +7 -0
- package/dist/registry.js +7 -0
- package/dist/registry.js.map +7 -0
- package/jest.config.cjs +19 -0
- package/package.json +71 -0
- package/src/__tests__/mercato.test.ts +90 -0
- package/src/bin.ts +74 -0
- package/src/index.ts +2 -0
- package/src/lib/__tests__/resolver.test.ts +101 -0
- package/src/lib/__tests__/utils.test.ts +270 -0
- package/src/lib/db/__tests__/commands.test.ts +131 -0
- package/src/lib/db/commands.ts +431 -0
- package/src/lib/db/index.ts +1 -0
- package/src/lib/generators/__tests__/generators.test.ts +197 -0
- package/src/lib/generators/entity-ids.ts +336 -0
- package/src/lib/generators/index.ts +4 -0
- package/src/lib/generators/module-di.ts +89 -0
- package/src/lib/generators/module-entities.ts +124 -0
- package/src/lib/generators/module-registry.ts +1222 -0
- package/src/lib/resolver.ts +308 -0
- package/src/lib/utils.ts +200 -0
- package/src/mercato.ts +1106 -0
- package/src/registry.ts +2 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +12 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { MikroORM, type Logger } from '@mikro-orm/core'
|
|
4
|
+
import { Migrator } from '@mikro-orm/migrations'
|
|
5
|
+
import { PostgreSqlDriver } from '@mikro-orm/postgresql'
|
|
6
|
+
import type { PackageResolver, ModuleEntry } from '../resolver'
|
|
7
|
+
|
|
8
|
+
const QUIET_MODE = process.env.OM_CLI_QUIET === '1' || process.env.MERCATO_QUIET === '1'
|
|
9
|
+
const PROGRESS_EMOJI = ''
|
|
10
|
+
|
|
11
|
+
function formatResult(modId: string, message: string, emoji = '•') {
|
|
12
|
+
return `${emoji} ${modId}: ${message}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createProgressRenderer(total: number) {
|
|
16
|
+
const width = 20
|
|
17
|
+
const normalizedTotal = total > 0 ? total : 1
|
|
18
|
+
return (current: number) => {
|
|
19
|
+
const clamped = Math.min(Math.max(current, 0), normalizedTotal)
|
|
20
|
+
const filled = Math.round((clamped / normalizedTotal) * width)
|
|
21
|
+
const bar = `${'='.repeat(filled)}${'.'.repeat(Math.max(width - filled, 0))}`
|
|
22
|
+
return `[${bar}] ${clamped}/${normalizedTotal}`
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createMinimalLogger(): Logger {
|
|
27
|
+
return {
|
|
28
|
+
log: () => {},
|
|
29
|
+
error: (_namespace, message) => console.error(message),
|
|
30
|
+
warn: (_namespace, message) => {
|
|
31
|
+
if (!QUIET_MODE) console.warn(message)
|
|
32
|
+
},
|
|
33
|
+
logQuery: () => {},
|
|
34
|
+
setDebugMode: () => {},
|
|
35
|
+
isEnabled: () => false,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getClientUrl(): string {
|
|
40
|
+
const url = process.env.DATABASE_URL
|
|
41
|
+
if (!url) throw new Error('DATABASE_URL is not set')
|
|
42
|
+
return url
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sortModules(mods: ModuleEntry[]): ModuleEntry[] {
|
|
46
|
+
// Sort modules alphabetically since they are now isomorphic
|
|
47
|
+
return mods.slice().sort((a, b) => a.id.localeCompare(b.id))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sanitizes a module ID for use in SQL identifiers (table names).
|
|
52
|
+
* Replaces non-alphanumeric characters with underscores to prevent SQL injection.
|
|
53
|
+
* @public Exported for testing
|
|
54
|
+
*/
|
|
55
|
+
export function sanitizeModuleId(modId: string): string {
|
|
56
|
+
return modId.replace(/[^a-z0-9_]/gi, '_')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validates that a table name is safe for use in SQL queries.
|
|
61
|
+
* @throws Error if the table name contains invalid characters.
|
|
62
|
+
* @public Exported for testing
|
|
63
|
+
*/
|
|
64
|
+
export function validateTableName(tableName: string): void {
|
|
65
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
|
66
|
+
throw new Error(`Invalid table name: ${tableName}. Table names must start with a letter or underscore and contain only alphanumeric characters and underscores.`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function loadModuleEntities(entry: ModuleEntry, resolver: PackageResolver): Promise<any[]> {
|
|
71
|
+
const roots = resolver.getModulePaths(entry)
|
|
72
|
+
const imps = resolver.getModuleImportBase(entry)
|
|
73
|
+
const isAppModule = entry.from === '@app'
|
|
74
|
+
const bases = [
|
|
75
|
+
path.join(roots.appBase, 'data'),
|
|
76
|
+
path.join(roots.pkgBase, 'data'),
|
|
77
|
+
path.join(roots.appBase, 'db'),
|
|
78
|
+
path.join(roots.pkgBase, 'db'),
|
|
79
|
+
]
|
|
80
|
+
const candidates = ['entities.ts', 'schema.ts']
|
|
81
|
+
|
|
82
|
+
for (const base of bases) {
|
|
83
|
+
for (const f of candidates) {
|
|
84
|
+
const p = path.join(base, f)
|
|
85
|
+
if (fs.existsSync(p)) {
|
|
86
|
+
const sub = path.basename(base)
|
|
87
|
+
const fromApp = base.startsWith(roots.appBase)
|
|
88
|
+
// For @app modules, use file:// URL since @/ alias doesn't work in Node.js runtime
|
|
89
|
+
const importPath = (isAppModule && fromApp)
|
|
90
|
+
? `file://${p.replace(/\.ts$/, '.js')}`
|
|
91
|
+
: `${fromApp ? imps.appBase : imps.pkgBase}/${sub}/${f.replace(/\.ts$/, '')}`
|
|
92
|
+
try {
|
|
93
|
+
const mod = await import(importPath)
|
|
94
|
+
const entities = Object.values(mod).filter((v) => typeof v === 'function')
|
|
95
|
+
if (entities.length) return entities as any[]
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// For @app modules with TypeScript files, they can't be directly imported
|
|
98
|
+
// Skip and let MikroORM handle entities through discovery
|
|
99
|
+
if (isAppModule) continue
|
|
100
|
+
throw err
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return []
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getMigrationsPath(entry: ModuleEntry, resolver: PackageResolver): string {
|
|
109
|
+
const from = entry.from || '@open-mercato/core'
|
|
110
|
+
let pkgModRoot: string
|
|
111
|
+
|
|
112
|
+
if (from === '@open-mercato/core') {
|
|
113
|
+
pkgModRoot = path.join(resolver.getRootDir(), 'packages/core/src/modules', entry.id)
|
|
114
|
+
} else if (/^@open-mercato\//.test(from)) {
|
|
115
|
+
const segs = from.split('/')
|
|
116
|
+
if (segs.length > 1 && segs[1]) {
|
|
117
|
+
pkgModRoot = path.join(resolver.getRootDir(), `packages/${segs[1]}/src/modules`, entry.id)
|
|
118
|
+
} else {
|
|
119
|
+
pkgModRoot = path.join(resolver.getRootDir(), 'packages/core/src/modules', entry.id)
|
|
120
|
+
}
|
|
121
|
+
} else if (from === '@app') {
|
|
122
|
+
// For @app modules, use the app directory not the monorepo root
|
|
123
|
+
pkgModRoot = path.join(resolver.getAppDir(), 'src/modules', entry.id)
|
|
124
|
+
} else {
|
|
125
|
+
pkgModRoot = path.join(resolver.getRootDir(), 'packages/core/src/modules', entry.id)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return path.join(pkgModRoot, 'migrations')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface DbOptions {
|
|
132
|
+
quiet?: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface GreenfieldOptions extends DbOptions {
|
|
136
|
+
yes: boolean
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function dbGenerate(resolver: PackageResolver, options: DbOptions = {}): Promise<void> {
|
|
140
|
+
const modules = resolver.loadEnabledModules()
|
|
141
|
+
const ordered = sortModules(modules)
|
|
142
|
+
const results: string[] = []
|
|
143
|
+
|
|
144
|
+
for (const entry of ordered) {
|
|
145
|
+
const modId = entry.id
|
|
146
|
+
const sanitizedModId = sanitizeModuleId(modId)
|
|
147
|
+
const entities = await loadModuleEntities(entry, resolver)
|
|
148
|
+
if (!entities.length) continue
|
|
149
|
+
|
|
150
|
+
const migrationsPath = getMigrationsPath(entry, resolver)
|
|
151
|
+
fs.mkdirSync(migrationsPath, { recursive: true })
|
|
152
|
+
|
|
153
|
+
const tableName = `mikro_orm_migrations_${sanitizedModId}`
|
|
154
|
+
validateTableName(tableName)
|
|
155
|
+
|
|
156
|
+
const orm = await MikroORM.init<PostgreSqlDriver>({
|
|
157
|
+
driver: PostgreSqlDriver,
|
|
158
|
+
clientUrl: getClientUrl(),
|
|
159
|
+
loggerFactory: () => createMinimalLogger(),
|
|
160
|
+
entities,
|
|
161
|
+
migrations: {
|
|
162
|
+
path: migrationsPath,
|
|
163
|
+
glob: '!(*.d).{ts,js}',
|
|
164
|
+
tableName,
|
|
165
|
+
dropTables: false,
|
|
166
|
+
},
|
|
167
|
+
schemaGenerator: {
|
|
168
|
+
disableForeignKeys: true,
|
|
169
|
+
},
|
|
170
|
+
pool: {
|
|
171
|
+
min: 1,
|
|
172
|
+
max: 3,
|
|
173
|
+
idleTimeoutMillis: 30000,
|
|
174
|
+
acquireTimeoutMillis: 60000,
|
|
175
|
+
destroyTimeoutMillis: 30000,
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const migrator = orm.getMigrator() as Migrator
|
|
180
|
+
const diff = await migrator.createMigration()
|
|
181
|
+
if (diff && diff.fileName) {
|
|
182
|
+
try {
|
|
183
|
+
const orig = diff.fileName
|
|
184
|
+
const base = path.basename(orig)
|
|
185
|
+
const dir = path.dirname(orig)
|
|
186
|
+
const ext = path.extname(base)
|
|
187
|
+
const stem = base.replace(ext, '')
|
|
188
|
+
const suffix = `_${modId}`
|
|
189
|
+
const newBase = stem.endsWith(suffix) ? base : `${stem}${suffix}${ext}`
|
|
190
|
+
const newPath = path.join(dir, newBase)
|
|
191
|
+
let content = fs.readFileSync(orig, 'utf8')
|
|
192
|
+
// Rename class to ensure uniqueness as well
|
|
193
|
+
content = content.replace(
|
|
194
|
+
/export class (Migration\d+)/,
|
|
195
|
+
`export class $1_${modId.replace(/[^a-zA-Z0-9]/g, '_')}`
|
|
196
|
+
)
|
|
197
|
+
fs.writeFileSync(newPath, content, 'utf8')
|
|
198
|
+
if (newPath !== orig) fs.unlinkSync(orig)
|
|
199
|
+
results.push(formatResult(modId, `generated ${newBase}`, ''))
|
|
200
|
+
} catch {
|
|
201
|
+
results.push(formatResult(modId, `generated ${path.basename(diff.fileName)} (rename failed)`, ''))
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
results.push(formatResult(modId, 'no changes', ''))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await orm.close(true)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(results.join('\n'))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function dbMigrate(resolver: PackageResolver, options: DbOptions = {}): Promise<void> {
|
|
214
|
+
const modules = resolver.loadEnabledModules()
|
|
215
|
+
const ordered = sortModules(modules)
|
|
216
|
+
const results: string[] = []
|
|
217
|
+
|
|
218
|
+
for (const entry of ordered) {
|
|
219
|
+
const modId = entry.id
|
|
220
|
+
const sanitizedModId = sanitizeModuleId(modId)
|
|
221
|
+
const entities = await loadModuleEntities(entry, resolver)
|
|
222
|
+
|
|
223
|
+
const migrationsPath = getMigrationsPath(entry, resolver)
|
|
224
|
+
|
|
225
|
+
// Skip if no entities AND no migrations directory exists
|
|
226
|
+
// (allows @app modules to run migrations even if entities can't be dynamically imported)
|
|
227
|
+
if (!entities.length && !fs.existsSync(migrationsPath)) continue
|
|
228
|
+
fs.mkdirSync(migrationsPath, { recursive: true })
|
|
229
|
+
|
|
230
|
+
const tableName = `mikro_orm_migrations_${sanitizedModId}`
|
|
231
|
+
validateTableName(tableName)
|
|
232
|
+
|
|
233
|
+
// For @app modules, entities may be empty since TypeScript files can't be imported at runtime
|
|
234
|
+
// Use discovery.warnWhenNoEntities: false to allow running migrations without entities
|
|
235
|
+
const orm = await MikroORM.init<PostgreSqlDriver>({
|
|
236
|
+
driver: PostgreSqlDriver,
|
|
237
|
+
clientUrl: getClientUrl(),
|
|
238
|
+
loggerFactory: () => createMinimalLogger(),
|
|
239
|
+
entities: entities.length ? entities : [],
|
|
240
|
+
discovery: { warnWhenNoEntities: false },
|
|
241
|
+
migrations: {
|
|
242
|
+
path: migrationsPath,
|
|
243
|
+
glob: '!(*.d).{ts,js}',
|
|
244
|
+
tableName,
|
|
245
|
+
dropTables: false,
|
|
246
|
+
},
|
|
247
|
+
schemaGenerator: {
|
|
248
|
+
disableForeignKeys: true,
|
|
249
|
+
},
|
|
250
|
+
pool: {
|
|
251
|
+
min: 1,
|
|
252
|
+
max: 3,
|
|
253
|
+
idleTimeoutMillis: 30000,
|
|
254
|
+
acquireTimeoutMillis: 60000,
|
|
255
|
+
destroyTimeoutMillis: 30000,
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const migrator = orm.getMigrator() as Migrator
|
|
260
|
+
const pending = await migrator.getPendingMigrations()
|
|
261
|
+
if (!pending.length) {
|
|
262
|
+
results.push(formatResult(modId, 'no pending migrations', ''))
|
|
263
|
+
} else {
|
|
264
|
+
const renderProgress = createProgressRenderer(pending.length)
|
|
265
|
+
let applied = 0
|
|
266
|
+
if (!QUIET_MODE) {
|
|
267
|
+
process.stdout.write(` ${PROGRESS_EMOJI} ${modId}: ${renderProgress(applied)}`)
|
|
268
|
+
}
|
|
269
|
+
for (const migration of pending) {
|
|
270
|
+
const migrationName =
|
|
271
|
+
typeof migration === 'string'
|
|
272
|
+
? migration
|
|
273
|
+
: (migration as any).name ?? (migration as any).fileName
|
|
274
|
+
await migrator.up(migrationName ? { migrations: [migrationName] } : undefined)
|
|
275
|
+
applied += 1
|
|
276
|
+
if (!QUIET_MODE) {
|
|
277
|
+
process.stdout.write(`\r ${PROGRESS_EMOJI} ${modId}: ${renderProgress(applied)}`)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!QUIET_MODE) process.stdout.write('\n')
|
|
281
|
+
results.push(
|
|
282
|
+
formatResult(modId, `${pending.length} migration${pending.length === 1 ? '' : 's'} applied`, '')
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await orm.close(true)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
console.log(results.join('\n'))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function dbGreenfield(resolver: PackageResolver, options: GreenfieldOptions): Promise<void> {
|
|
293
|
+
if (!options.yes) {
|
|
294
|
+
console.error('This command will DELETE all data. Use --yes to confirm.')
|
|
295
|
+
process.exit(1)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
console.log('Cleaning up migrations and snapshots for greenfield setup...')
|
|
299
|
+
|
|
300
|
+
const modules = resolver.loadEnabledModules()
|
|
301
|
+
const ordered = sortModules(modules)
|
|
302
|
+
const results: string[] = []
|
|
303
|
+
const outputDir = resolver.getOutputDir()
|
|
304
|
+
|
|
305
|
+
for (const entry of ordered) {
|
|
306
|
+
const modId = entry.id
|
|
307
|
+
const migrationsPath = getMigrationsPath(entry, resolver)
|
|
308
|
+
|
|
309
|
+
if (fs.existsSync(migrationsPath)) {
|
|
310
|
+
// Remove all migration files
|
|
311
|
+
const migrationFiles = fs
|
|
312
|
+
.readdirSync(migrationsPath)
|
|
313
|
+
.filter((file) => file.endsWith('.ts') && file.startsWith('Migration'))
|
|
314
|
+
|
|
315
|
+
for (const file of migrationFiles) {
|
|
316
|
+
fs.unlinkSync(path.join(migrationsPath, file))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Remove snapshot files
|
|
320
|
+
const snapshotFiles = fs
|
|
321
|
+
.readdirSync(migrationsPath)
|
|
322
|
+
.filter((file) => file.endsWith('.json') && file.includes('snapshot'))
|
|
323
|
+
|
|
324
|
+
for (const file of snapshotFiles) {
|
|
325
|
+
fs.unlinkSync(path.join(migrationsPath, file))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (migrationFiles.length > 0 || snapshotFiles.length > 0) {
|
|
329
|
+
results.push(
|
|
330
|
+
formatResult(modId, `cleaned ${migrationFiles.length} migrations, ${snapshotFiles.length} snapshots`, '')
|
|
331
|
+
)
|
|
332
|
+
} else {
|
|
333
|
+
results.push(formatResult(modId, 'already clean', ''))
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
results.push(formatResult(modId, 'no migrations directory', ''))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Clean up checksum files using glob pattern
|
|
340
|
+
if (fs.existsSync(outputDir)) {
|
|
341
|
+
const files = fs.readdirSync(outputDir)
|
|
342
|
+
const checksumFiles = files.filter((file) => file.endsWith('.checksum'))
|
|
343
|
+
|
|
344
|
+
for (const file of checksumFiles) {
|
|
345
|
+
fs.unlinkSync(path.join(outputDir, file))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (checksumFiles.length > 0) {
|
|
349
|
+
results.push(formatResult(modId, `cleaned ${checksumFiles.length} checksum files`, ''))
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
console.log(results.join('\n'))
|
|
355
|
+
|
|
356
|
+
// Drop per-module MikroORM migration tables to ensure clean slate
|
|
357
|
+
console.log('Dropping per-module migration tables...')
|
|
358
|
+
try {
|
|
359
|
+
const { Client } = await import('pg')
|
|
360
|
+
const client = new Client({ connectionString: getClientUrl() })
|
|
361
|
+
await client.connect()
|
|
362
|
+
try {
|
|
363
|
+
await client.query('BEGIN')
|
|
364
|
+
for (const entry of ordered) {
|
|
365
|
+
const modId = entry.id
|
|
366
|
+
const sanitizedModId = sanitizeModuleId(modId)
|
|
367
|
+
const tableName = `mikro_orm_migrations_${sanitizedModId}`
|
|
368
|
+
validateTableName(tableName)
|
|
369
|
+
await client.query(`DROP TABLE IF EXISTS "${tableName}"`)
|
|
370
|
+
console.log(` ${modId}: dropped table ${tableName}`)
|
|
371
|
+
}
|
|
372
|
+
await client.query('COMMIT')
|
|
373
|
+
} catch (e) {
|
|
374
|
+
await client.query('ROLLBACK')
|
|
375
|
+
throw e
|
|
376
|
+
} finally {
|
|
377
|
+
try {
|
|
378
|
+
await client.end()
|
|
379
|
+
} catch {}
|
|
380
|
+
}
|
|
381
|
+
} catch (e) {
|
|
382
|
+
console.error('Failed to drop migration tables:', (e as any)?.message || e)
|
|
383
|
+
throw e
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Drop all existing user tables to ensure fresh CREATE-only migrations
|
|
387
|
+
console.log('Dropping ALL public tables for true greenfield...')
|
|
388
|
+
try {
|
|
389
|
+
const { Client } = await import('pg')
|
|
390
|
+
const client = new Client({ connectionString: getClientUrl() })
|
|
391
|
+
await client.connect()
|
|
392
|
+
try {
|
|
393
|
+
const res = await client.query(`SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
|
|
394
|
+
const tables: string[] = (res.rows || []).map((r: any) => String(r.tablename))
|
|
395
|
+
if (tables.length) {
|
|
396
|
+
await client.query('BEGIN')
|
|
397
|
+
try {
|
|
398
|
+
await client.query("SET session_replication_role = 'replica'")
|
|
399
|
+
for (const t of tables) {
|
|
400
|
+
await client.query(`DROP TABLE IF EXISTS "${t}" CASCADE`)
|
|
401
|
+
}
|
|
402
|
+
await client.query("SET session_replication_role = 'origin'")
|
|
403
|
+
await client.query('COMMIT')
|
|
404
|
+
console.log(` Dropped ${tables.length} tables.`)
|
|
405
|
+
} catch (e) {
|
|
406
|
+
await client.query('ROLLBACK')
|
|
407
|
+
throw e
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
console.log(' No tables found to drop.')
|
|
411
|
+
}
|
|
412
|
+
} finally {
|
|
413
|
+
try {
|
|
414
|
+
await client.end()
|
|
415
|
+
} catch {}
|
|
416
|
+
}
|
|
417
|
+
} catch (e) {
|
|
418
|
+
console.error('Failed to drop public tables:', (e as any)?.message || e)
|
|
419
|
+
throw e
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Generate fresh migrations for all modules
|
|
423
|
+
console.log('Generating fresh migrations for all modules...')
|
|
424
|
+
await dbGenerate(resolver)
|
|
425
|
+
|
|
426
|
+
// Apply migrations
|
|
427
|
+
console.log('Applying migrations...')
|
|
428
|
+
await dbMigrate(resolver)
|
|
429
|
+
|
|
430
|
+
console.log('Greenfield reset complete! Fresh migrations generated and applied.')
|
|
431
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { dbGenerate, dbMigrate, dbGreenfield, type DbOptions, type GreenfieldOptions } from './commands'
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { GeneratorResult } from '../../utils'
|
|
2
|
+
|
|
3
|
+
// Note: Some generators import ESM-only packages (like openapi-typescript)
|
|
4
|
+
// which don't work well with Jest's CommonJS environment.
|
|
5
|
+
// We test the generator interfaces and expected behavior patterns here.
|
|
6
|
+
|
|
7
|
+
describe('generators', () => {
|
|
8
|
+
describe('generator exports', () => {
|
|
9
|
+
it('should export generateEntityIds', async () => {
|
|
10
|
+
const module = await import('../entity-ids')
|
|
11
|
+
expect(typeof module.generateEntityIds).toBe('function')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('should export generateModuleRegistry', async () => {
|
|
15
|
+
const module = await import('../module-registry')
|
|
16
|
+
expect(typeof module.generateModuleRegistry).toBe('function')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should export generateModuleEntities', async () => {
|
|
20
|
+
const module = await import('../module-entities')
|
|
21
|
+
expect(typeof module.generateModuleEntities).toBe('function')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should export generateModuleDi', async () => {
|
|
25
|
+
const module = await import('../module-di')
|
|
26
|
+
expect(typeof module.generateModuleDi).toBe('function')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Note: api-client uses openapi-typescript which is ESM-only
|
|
30
|
+
// and doesn't work with Jest's CommonJS environment
|
|
31
|
+
it.skip('should export generateApiClient', async () => {
|
|
32
|
+
const module = await import('../api-client')
|
|
33
|
+
expect(typeof module.generateApiClient).toBe('function')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('generator options interfaces', () => {
|
|
38
|
+
it('should accept resolver option', () => {
|
|
39
|
+
// All generators should accept a resolver option
|
|
40
|
+
type GeneratorOptions = {
|
|
41
|
+
resolver: unknown
|
|
42
|
+
quiet?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const options: GeneratorOptions = {
|
|
46
|
+
resolver: {},
|
|
47
|
+
quiet: true,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expect(options.resolver).toBeDefined()
|
|
51
|
+
expect(options.quiet).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('should not have force option (removed as per code review)', () => {
|
|
55
|
+
// The force option was removed from all generators
|
|
56
|
+
type GeneratorOptions = {
|
|
57
|
+
resolver: unknown
|
|
58
|
+
quiet?: boolean
|
|
59
|
+
// force?: boolean // This should NOT exist
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const options: GeneratorOptions = {
|
|
63
|
+
resolver: {},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
expect('force' in options).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('GeneratorResult interface', () => {
|
|
71
|
+
it('should track written files', () => {
|
|
72
|
+
const result: GeneratorResult = {
|
|
73
|
+
filesWritten: ['/path/to/file1.ts', '/path/to/file2.ts'],
|
|
74
|
+
filesUnchanged: [],
|
|
75
|
+
errors: [],
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
expect(result.filesWritten).toHaveLength(2)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should track unchanged files', () => {
|
|
82
|
+
const result: GeneratorResult = {
|
|
83
|
+
filesWritten: [],
|
|
84
|
+
filesUnchanged: ['/path/to/unchanged.ts'],
|
|
85
|
+
errors: [],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
expect(result.filesUnchanged).toHaveLength(1)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should track errors', () => {
|
|
92
|
+
const result: GeneratorResult = {
|
|
93
|
+
filesWritten: [],
|
|
94
|
+
filesUnchanged: [],
|
|
95
|
+
errors: ['Failed to import module X', 'Invalid entity definition'],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
expect(result.errors).toHaveLength(2)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should allow mixed results', () => {
|
|
102
|
+
const result: GeneratorResult = {
|
|
103
|
+
filesWritten: ['/path/to/new.ts'],
|
|
104
|
+
filesUnchanged: ['/path/to/existing.ts'],
|
|
105
|
+
errors: ['Warning: something minor'],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
expect(result.filesWritten).toHaveLength(1)
|
|
109
|
+
expect(result.filesUnchanged).toHaveLength(1)
|
|
110
|
+
expect(result.errors).toHaveLength(1)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('generator behavior patterns', () => {
|
|
115
|
+
it('should handle empty module list gracefully', async () => {
|
|
116
|
+
// Generators should not fail when no modules are enabled
|
|
117
|
+
const mockResolver = {
|
|
118
|
+
loadEnabledModules: () => [],
|
|
119
|
+
getOutputDir: () => '/tmp/generated',
|
|
120
|
+
getRootDir: () => '/tmp',
|
|
121
|
+
getModulePaths: () => ({ appBase: '', pkgBase: '' }),
|
|
122
|
+
getModuleImportBase: () => ({ appBase: '', pkgBase: '' }),
|
|
123
|
+
getPackageOutputDir: () => '/tmp/generated',
|
|
124
|
+
isMonorepo: () => true,
|
|
125
|
+
discoverPackages: () => [],
|
|
126
|
+
getModulesConfigPath: () => '/tmp/src/modules.ts',
|
|
127
|
+
getPackageRoot: () => '/tmp',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Just verify the resolver structure is correct
|
|
131
|
+
expect(mockResolver.loadEnabledModules()).toEqual([])
|
|
132
|
+
expect(mockResolver.getOutputDir()).toBe('/tmp/generated')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should support quiet mode', () => {
|
|
136
|
+
// Generators should accept quiet option to suppress console output
|
|
137
|
+
const options = {
|
|
138
|
+
resolver: {},
|
|
139
|
+
quiet: true,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
expect(options.quiet).toBe(true)
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('generator file output patterns', () => {
|
|
148
|
+
describe('entity-ids generator', () => {
|
|
149
|
+
it('should output to entities.ids.generated.ts', () => {
|
|
150
|
+
const outputDir = '/project/generated'
|
|
151
|
+
const expectedPath = `${outputDir}/entities.ids.generated.ts`
|
|
152
|
+
expect(expectedPath).toContain('entities.ids.generated.ts')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('module-registry generator', () => {
|
|
157
|
+
it('should output to modules.generated.ts', () => {
|
|
158
|
+
const outputDir = '/project/generated'
|
|
159
|
+
const expectedPath = `${outputDir}/modules.generated.ts`
|
|
160
|
+
expect(expectedPath).toContain('modules.generated.ts')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should output dashboard widgets', () => {
|
|
164
|
+
const outputDir = '/project/generated'
|
|
165
|
+
const expectedPath = `${outputDir}/dashboard-widgets.generated.ts`
|
|
166
|
+
expect(expectedPath).toContain('dashboard-widgets.generated.ts')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should output injection widgets', () => {
|
|
170
|
+
const outputDir = '/project/generated'
|
|
171
|
+
const expectedPath = `${outputDir}/injection-widgets.generated.ts`
|
|
172
|
+
expect(expectedPath).toContain('injection-widgets.generated.ts')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should output search config', () => {
|
|
176
|
+
const outputDir = '/project/generated'
|
|
177
|
+
const expectedPath = `${outputDir}/search.generated.ts`
|
|
178
|
+
expect(expectedPath).toContain('search.generated.ts')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('module-entities generator', () => {
|
|
183
|
+
it('should output to entities.generated.ts', () => {
|
|
184
|
+
const outputDir = '/project/generated'
|
|
185
|
+
const expectedPath = `${outputDir}/entities.generated.ts`
|
|
186
|
+
expect(expectedPath).toContain('entities.generated.ts')
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('module-di generator', () => {
|
|
191
|
+
it('should output to di.generated.ts', () => {
|
|
192
|
+
const outputDir = '/project/generated'
|
|
193
|
+
const expectedPath = `${outputDir}/di.generated.ts`
|
|
194
|
+
expect(expectedPath).toContain('di.generated.ts')
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
})
|