@strav/cli 0.3.33 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strav/cli",
3
- "version": "0.3.33",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "CLI framework and code generators for the Strav framework",
6
6
  "license": "MIT",
@@ -33,11 +33,11 @@
33
33
  "strav": "./src/cli/strav.ts"
34
34
  },
35
35
  "peerDependencies": {
36
- "@strav/kernel": "0.3.33",
37
- "@strav/http": "0.3.33",
38
- "@strav/database": "0.3.33",
39
- "@strav/queue": "0.3.33",
40
- "@strav/signal": "0.3.33"
36
+ "@strav/kernel": "0.4.0",
37
+ "@strav/http": "0.4.0",
38
+ "@strav/database": "0.4.0",
39
+ "@strav/queue": "0.4.0",
40
+ "@strav/signal": "0.4.0"
41
41
  },
42
42
  "dependencies": {
43
43
  "chalk": "^5.6.2",
@@ -4,7 +4,6 @@ import SchemaRegistry from '@strav/database/schema/registry'
4
4
  import DatabaseIntrospector from '@strav/database/database/introspector'
5
5
  import Application from '@strav/kernel/core/application'
6
6
  import type ServiceProvider from '@strav/kernel/core/service_provider'
7
- import { discoverDomains } from '@strav/database'
8
7
  import { getDatabasePaths } from '../config/loader.ts'
9
8
 
10
9
  export interface BootstrapResult {
@@ -19,32 +18,16 @@ export interface BootstrapResult {
19
18
  *
20
19
  * Loads configuration, connects to the database, discovers and validates
21
20
  * schemas, and creates an introspector instance.
22
- *
23
- * @param scope - Optional domain to discover schemas from (e.g., 'public', 'tenant', 'factory')
24
21
  */
25
- export async function bootstrap(scope?: string): Promise<BootstrapResult> {
22
+ export async function bootstrap(): Promise<BootstrapResult> {
26
23
  const config = new Configuration('./config')
27
24
  await config.load()
28
25
 
29
26
  const db = new Database(config)
30
27
 
31
28
  const registry = new SchemaRegistry()
32
-
33
- // Get the configured database paths
34
29
  const dbPaths = await getDatabasePaths()
35
-
36
- if (scope && scope !== 'public') {
37
- // For non-public domains, we need to load public schemas first since they may reference them
38
- await registry.discover(dbPaths.schemas, 'public')
39
- await registry.discover(dbPaths.schemas, scope)
40
- } else if (scope === 'public') {
41
- // For public schemas, only load public
42
- await registry.discover(dbPaths.schemas, 'public')
43
- } else {
44
- // Default: discover all schemas (backward compatibility)
45
- await registry.discover(dbPaths.schemas)
46
- }
47
-
30
+ await registry.discover(dbPaths.schemas)
48
31
  registry.validate()
49
32
 
50
33
  const introspector = new DatabaseIntrospector(db)
@@ -0,0 +1,91 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import Configuration from '@strav/kernel/config/configuration'
4
+ import { SQL } from 'bun'
5
+ import { env } from '@strav/kernel/helpers/env'
6
+
7
+ /**
8
+ * Emit (or apply) the SQL needed to set up the two PostgreSQL roles
9
+ * required by the multi-tenant RLS workflow:
10
+ *
11
+ * - app role: NOBYPASSRLS — used by the application; RLS policies apply.
12
+ * - bypass role: BYPASSRLS — used by migrations, TenantManager, withoutTenant().
13
+ *
14
+ * Reads the desired role names/passwords from the loaded Configuration
15
+ * (`database.username`, `database.password`, `database.tenant.bypass.username`,
16
+ * `database.tenant.bypass.password`).
17
+ *
18
+ * Must be run by a PostgreSQL superuser (only the superuser can grant the
19
+ * BYPASSRLS attribute). Pass --apply to execute against the configured
20
+ * database; otherwise the SQL is printed for manual review.
21
+ */
22
+ export function register(program: Command): void {
23
+ program
24
+ .command('db:setup-roles')
25
+ .description('Print or apply the SQL to create the app + bypass PostgreSQL roles')
26
+ .option('--apply', 'Execute the SQL against the database (requires superuser credentials)')
27
+ .option('--superuser <name>', 'Superuser to connect as when --apply is set', 'postgres')
28
+ .option('--superuser-password <password>', 'Superuser password (or read from $DB_SUPERUSER_PASSWORD)')
29
+ .action(async (opts: { apply?: boolean; superuser: string; superuserPassword?: string }) => {
30
+ try {
31
+ const config = new Configuration('./config')
32
+ await config.load()
33
+
34
+ const dbName = config.get('database.database') ?? env('DB_DATABASE', 'strav')
35
+ const appUser = config.get('database.username') ?? env('DB_USER', 'strav_app')
36
+ const appPassword = config.get('database.password') ?? env('DB_PASSWORD', 'changeme')
37
+ const bypassUser =
38
+ config.get('database.tenant.bypass.username') ?? env('DB_BYPASS_USER', 'strav_admin')
39
+ const bypassPassword =
40
+ config.get('database.tenant.bypass.password') ?? env('DB_BYPASS_PASSWORD', 'changeme')
41
+
42
+ const stmts = [
43
+ `CREATE ROLE "${appUser}" LOGIN PASSWORD '${appPassword}' NOBYPASSRLS;`,
44
+ `CREATE ROLE "${bypassUser}" LOGIN PASSWORD '${bypassPassword}' BYPASSRLS;`,
45
+ `GRANT ALL ON DATABASE "${dbName}" TO "${appUser}", "${bypassUser}";`,
46
+ `GRANT ALL ON SCHEMA public TO "${appUser}", "${bypassUser}";`,
47
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "${appUser}", "${bypassUser}";`,
48
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "${appUser}", "${bypassUser}";`,
49
+ ]
50
+
51
+ if (!opts.apply) {
52
+ console.log(chalk.cyan('\n-- Run as a PostgreSQL superuser:\n'))
53
+ for (const s of stmts) console.log(s)
54
+ console.log(
55
+ chalk.dim('\n(Use --apply to execute these against the configured database.)')
56
+ )
57
+ return
58
+ }
59
+
60
+ const password = opts.superuserPassword ?? env('DB_SUPERUSER_PASSWORD', '')
61
+ const sql = new SQL({
62
+ hostname: config.get('database.host') ?? env('DB_HOST', '127.0.0.1'),
63
+ port: config.get('database.port') ?? env.int('DB_PORT', 5432),
64
+ username: opts.superuser,
65
+ password,
66
+ database: dbName,
67
+ max: 1,
68
+ })
69
+
70
+ console.log(chalk.cyan(`Applying role setup as superuser "${opts.superuser}"...`))
71
+ for (const stmt of stmts) {
72
+ try {
73
+ await sql.unsafe(stmt)
74
+ console.log(chalk.dim(` ok: ${stmt}`))
75
+ } catch (err) {
76
+ const msg = err instanceof Error ? err.message : String(err)
77
+ if (/already exists/i.test(msg)) {
78
+ console.log(chalk.dim(` skip (exists): ${stmt}`))
79
+ } else {
80
+ throw err
81
+ }
82
+ }
83
+ }
84
+ await sql.close()
85
+ console.log(chalk.green('\nRoles set up.'))
86
+ } catch (err) {
87
+ console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
88
+ process.exit(1)
89
+ }
90
+ })
91
+ }
@@ -1,10 +1,7 @@
1
- import { join } from 'node:path'
2
1
  import type { Command } from 'commander'
3
2
  import chalk from 'chalk'
4
3
  import SchemaRegistry from '@strav/database/schema/registry'
5
4
  import ModelGenerator from '../generators/model_generator.ts'
6
- import type { GeneratorConfig } from '../generators/config.ts'
7
- import { discoverDomains } from '@strav/database'
8
5
  import { loadGeneratorConfig, getDatabasePaths } from '../config/loader.ts'
9
6
 
10
7
  export function register(program: Command): void {
@@ -12,101 +9,28 @@ export function register(program: Command): void {
12
9
  .command('generate:models')
13
10
  .alias('g:models')
14
11
  .description('Generate model classes and enums from schema definitions')
15
- .option('--scope <scope>', 'Generate models for specific domain or "all" for all domains', 'all')
16
- .action(async (options) => {
12
+ .action(async () => {
17
13
  try {
18
- const scope = options.scope as string
19
-
20
- // Get configured database paths
21
14
  const dbPaths = await getDatabasePaths()
22
-
23
- // Validate scope against available domains or 'all'
24
- const availableDomains = discoverDomains(dbPaths.schemas)
25
- if (scope !== 'all' && !availableDomains.includes(scope)) {
26
- console.error(chalk.red(`Invalid domain: ${scope}. Available domains: ${availableDomains.join(', ')}, all`))
27
- process.exit(1)
28
- }
29
-
30
- console.log(chalk.cyan(`Generating models from ${scope === 'all' ? 'all' : scope} schemas...`))
31
-
32
- // Load generator config (if available)
33
15
  const config = await loadGeneratorConfig()
34
16
 
35
- const allFiles: any[] = []
36
- const scopesToProcess = scope === 'all' ? availableDomains : [scope]
37
-
38
- // When generating models for specific domains, we need all schemas loaded
39
- // to handle cross-domain references properly
40
- const fullRegistry = new SchemaRegistry()
41
-
42
- // Always load public schemas first (base schemas)
43
- await fullRegistry.discover(dbPaths.schemas, 'public')
44
-
45
- // Load schemas from all other domains if needed for validation or if generating models for those domains
46
- for (const domain of availableDomains) {
47
- if (domain !== 'public' && (scope === 'all' || scope === domain)) {
48
- await fullRegistry.discover(dbPaths.schemas, domain)
49
- }
50
- }
17
+ console.log(chalk.cyan('Generating models from schemas...'))
51
18
 
52
- // Validate all loaded schemas
53
- fullRegistry.validate()
19
+ const registry = new SchemaRegistry()
20
+ await registry.discover(dbPaths.schemas)
21
+ registry.validate()
54
22
 
55
- // Build representation from all loaded schemas
56
- const fullRepresentation = fullRegistry.buildRepresentation()
57
-
58
- // Build a map of schema name -> domain for cross-domain reference resolution
59
- const allSchemasMap = new Map<string, string>()
60
-
61
- // Load public schemas to identify which ones are public
62
- const publicRegistry = new SchemaRegistry()
63
- await publicRegistry.discover(dbPaths.schemas, 'public')
64
- const publicSchemaNames = new Set(publicRegistry.all().map(s => s.name))
65
-
66
- // Map schemas to their respective domains by checking which domain directory they came from
67
- for (const schema of fullRegistry.all()) {
68
- if (publicSchemaNames.has(schema.name)) {
69
- allSchemasMap.set(schema.name, 'public')
70
- } else {
71
- // For non-public schemas, find which domain they belong to
72
- // by checking which domains were loaded
73
- for (const domain of availableDomains) {
74
- if (domain !== 'public') {
75
- const domainRegistry = new SchemaRegistry()
76
- await domainRegistry.discover(dbPaths.schemas, domain)
77
- const domainSchemaNames = new Set(domainRegistry.all().map(s => s.name))
78
- if (domainSchemaNames.has(schema.name)) {
79
- allSchemasMap.set(schema.name, domain)
80
- break
81
- }
82
- }
83
- }
84
- }
85
- }
86
-
87
- for (const currentScope of scopesToProcess) {
88
- // Get just the schemas for the current scope
89
- const scopeRegistry = new SchemaRegistry()
90
- await scopeRegistry.discover(dbPaths.schemas, currentScope)
91
-
92
- if (scopeRegistry.all().length === 0) {
93
- console.log(chalk.yellow(`No schemas found for domain: ${currentScope}`))
94
- continue
95
- }
96
-
97
- const scopeSchemas = scopeRegistry.all()
98
- const generator = new ModelGenerator(scopeSchemas, fullRepresentation, config, currentScope, allSchemasMap)
99
- const files = await generator.writeAll()
100
- allFiles.push(...files)
101
- }
23
+ const representation = registry.buildRepresentation()
24
+ const generator = new ModelGenerator(registry.all(), representation, config)
25
+ const files = await generator.writeAll()
102
26
 
103
- if (allFiles.length === 0) {
27
+ if (files.length === 0) {
104
28
  console.log(chalk.yellow('No models to generate.'))
105
29
  return
106
30
  }
107
31
 
108
- console.log(chalk.green(`\nGenerated ${allFiles.length} file(s):`))
109
- for (const file of allFiles) {
32
+ console.log(chalk.green(`\nGenerated ${files.length} file(s):`))
33
+ for (const file of files) {
110
34
  console.log(chalk.dim(` ${file.path}`))
111
35
  }
112
36
  } catch (err) {
@@ -11,7 +11,6 @@ import SqlGenerator from '@strav/database/database/migration/sql_generator'
11
11
  import MigrationFileGenerator from '@strav/database/database/migration/file_generator'
12
12
  import MigrationTracker from '@strav/database/database/migration/tracker'
13
13
  import MigrationRunner from '@strav/database/database/migration/runner'
14
- import { discoverDomains } from '@strav/database'
15
14
  import { getDatabasePaths } from '../config/loader.ts'
16
15
 
17
16
  /**
@@ -24,37 +23,34 @@ export async function freshDatabase(
24
23
  db: Database,
25
24
  registry: SchemaRegistry,
26
25
  introspector: DatabaseIntrospector,
27
- migrationsPath: string = 'database/migrations',
28
- scope: string = 'public'
26
+ migrationsPath: string = 'database/migrations'
29
27
  ): Promise<number> {
30
- // Drop all tables in public schema
31
28
  console.log(chalk.cyan('\nDropping all tables and types...'))
32
29
 
33
- const tables = await db.sql`
30
+ const conn = db.isMultiTenant ? db.bypass : db.sql
31
+
32
+ const tables = await conn.unsafe(`
34
33
  SELECT table_name FROM information_schema.tables
35
34
  WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
36
- `
37
- for (const row of tables) {
38
- await db.sql.unsafe(`DROP TABLE IF EXISTS "${row.table_name}" CASCADE`)
35
+ `)
36
+ for (const row of tables as Array<{ table_name: string }>) {
37
+ await conn.unsafe(`DROP TABLE IF EXISTS "${row.table_name}" CASCADE`)
39
38
  }
40
39
 
41
- // Drop all enum types in public schema
42
- const types = await db.sql`
40
+ const types = await conn.unsafe(`
43
41
  SELECT t.typname
44
42
  FROM pg_type t
45
43
  JOIN pg_namespace n ON n.oid = t.typnamespace
46
44
  WHERE n.nspname = 'public'
47
45
  AND t.typtype = 'e'
48
- `
49
- for (const row of types) {
50
- await db.sql.unsafe(`DROP TYPE IF EXISTS "${row.typname}" CASCADE`)
46
+ `)
47
+ for (const row of types as Array<{ typname: string }>) {
48
+ await conn.unsafe(`DROP TYPE IF EXISTS "${row.typname}" CASCADE`)
51
49
  }
52
50
 
53
- // Delete existing migration files
54
51
  console.log(chalk.cyan('Clearing migration directory...'))
55
52
  rmSync(migrationsPath, { recursive: true, force: true })
56
53
 
57
- // Generate new migration
58
54
  console.log(chalk.cyan('Generating fresh migration...'))
59
55
 
60
56
  const desired = registry.buildRepresentation()
@@ -68,11 +64,10 @@ export async function freshDatabase(
68
64
  const fileGen = new MigrationFileGenerator(migrationsPath)
69
65
  await fileGen.generate(version, 'fresh', sql, diff, tableOrder)
70
66
 
71
- // Run the migration
72
67
  console.log(chalk.cyan('Running migration...'))
73
68
 
74
- const tracker = new MigrationTracker(db, scope)
75
- const runner = new MigrationRunner(db, tracker, migrationsPath, scope)
69
+ const tracker = new MigrationTracker(db)
70
+ const runner = new MigrationRunner(db, tracker, migrationsPath)
76
71
  const result = await runner.run()
77
72
 
78
73
  return result.applied.length
@@ -101,8 +96,7 @@ export function register(program: Command): void {
101
96
  .command('fresh')
102
97
  .alias('migration:fresh')
103
98
  .description('Reset database and migrations, regenerate and run from scratch')
104
- .option('-s, --scope <scope>', 'Domain (e.g., public, tenant, factory, marketing)', 'public')
105
- .action(async (opts: { scope: string }) => {
99
+ .action(async () => {
106
100
  requireLocalEnv('fresh')
107
101
 
108
102
  // 6-digit challenge
@@ -123,22 +117,11 @@ export function register(program: Command): void {
123
117
 
124
118
  let db
125
119
  try {
126
- // Get configured database paths
127
120
  const dbPaths = await getDatabasePaths()
128
-
129
- // Validate scope against available domains
130
- const availableDomains = discoverDomains(dbPaths.schemas)
131
- if (!availableDomains.includes(opts.scope)) {
132
- throw new Error(`Invalid domain: ${opts.scope}. Available domains: ${availableDomains.join(', ')}`)
133
- }
134
- const scope = opts.scope
135
-
136
- const { db: database, registry, introspector } = await bootstrap(scope)
121
+ const { db: database, registry, introspector } = await bootstrap()
137
122
  db = database
138
123
 
139
- // Use scoped migrations path
140
- const scopedPath = `${dbPaths.migrations}/${scope}`
141
- const applied = await freshDatabase(db, registry, introspector, scopedPath, scope)
124
+ const applied = await freshDatabase(db, registry, introspector, dbPaths.migrations)
142
125
 
143
126
  console.log(chalk.green(`\nFresh migration complete. Applied ${applied} migration(s).`))
144
127
  } catch (err) {
@@ -4,7 +4,6 @@ import { bootstrap, shutdown } from '../cli/bootstrap.ts'
4
4
  import SchemaDiffer from '@strav/database/database/migration/differ'
5
5
  import SqlGenerator from '@strav/database/database/migration/sql_generator'
6
6
  import MigrationFileGenerator from '@strav/database/database/migration/file_generator'
7
- import { discoverDomains } from '@strav/database'
8
7
  import { getDatabasePaths } from '../config/loader.ts'
9
8
 
10
9
  export function register(program: Command): void {
@@ -13,24 +12,14 @@ export function register(program: Command): void {
13
12
  .aliases(['migration:generate', 'g:migration'])
14
13
  .description('Generate migration files from schema changes')
15
14
  .option('-m, --message <message>', 'Migration message', 'migration')
16
- .option('-s, --scope <scope>', 'Domain (e.g., public, tenant, factory, marketing)', 'public')
17
- .action(async (opts: { message: string; scope: string }) => {
15
+ .action(async (opts: { message: string }) => {
18
16
  let db
19
17
  try {
20
- // Get configured database paths
21
18
  const dbPaths = await getDatabasePaths()
22
-
23
- // Validate scope against available domains
24
- const availableDomains = discoverDomains(dbPaths.schemas)
25
- if (!availableDomains.includes(opts.scope)) {
26
- throw new Error(`Invalid domain: ${opts.scope}. Available domains: ${availableDomains.join(', ')}`)
27
- }
28
- const scope = opts.scope
29
-
30
- const { db: database, registry, introspector } = await bootstrap(scope)
19
+ const { db: database, registry, introspector } = await bootstrap()
31
20
  db = database
32
21
 
33
- console.log(chalk.cyan(`Comparing ${scope} schema with database...`))
22
+ console.log(chalk.cyan('Comparing schema with database...'))
34
23
 
35
24
  const desired = registry.buildRepresentation()
36
25
  const actual = await introspector.introspect()
@@ -49,16 +38,11 @@ export function register(program: Command): void {
49
38
 
50
39
  const sql = new SqlGenerator().generate(diff)
51
40
  const version = Date.now().toString()
52
-
53
- // Use the table order from the desired representation (already dependency-ordered)
54
41
  const tableOrder = desired.tables.map(t => t.name)
55
42
 
56
- // Use scoped migrations path from configuration
57
- const scopedPath = `${dbPaths.migrations}/${scope}`
58
- const fileGen = new MigrationFileGenerator(scopedPath)
43
+ const fileGen = new MigrationFileGenerator(dbPaths.migrations)
59
44
  const dir = await fileGen.generate(version, opts.message, sql, diff, tableOrder)
60
45
 
61
- // Print summary
62
46
  console.log(chalk.green(`\nMigration generated: ${version}`))
63
47
  console.log(chalk.dim(` Directory: ${dir}`))
64
48
  console.log(chalk.dim(` Message: ${opts.message}\n`))
@@ -3,7 +3,6 @@ import chalk from 'chalk'
3
3
  import { bootstrap, shutdown } from '../cli/bootstrap.ts'
4
4
  import MigrationTracker from '@strav/database/database/migration/tracker'
5
5
  import MigrationRunner from '@strav/database/database/migration/runner'
6
- import { discoverDomains } from '@strav/database'
7
6
  import { getDatabasePaths } from '../config/loader.ts'
8
7
 
9
8
  export function register(program: Command): void {
@@ -12,26 +11,15 @@ export function register(program: Command): void {
12
11
  .alias('migration:rollback')
13
12
  .description('Rollback migrations by batch')
14
13
  .option('--batch <number>', 'Specific batch number to rollback')
15
- .option('-s, --scope <scope>', 'Domain (e.g., public, tenant, factory, marketing)', 'public')
16
- .action(async (opts: { batch?: string; scope: string }) => {
14
+ .action(async (opts: { batch?: string }) => {
17
15
  let db
18
16
  try {
19
- // Get configured database paths
20
17
  const dbPaths = await getDatabasePaths()
21
-
22
- // Validate scope against available domains
23
- const availableDomains = discoverDomains(dbPaths.schemas)
24
- if (!availableDomains.includes(opts.scope)) {
25
- throw new Error(`Invalid domain: ${opts.scope}. Available domains: ${availableDomains.join(', ')}`)
26
- }
27
- const scope = opts.scope
28
-
29
18
  const { db: database } = await bootstrap()
30
19
  db = database
31
20
 
32
- const scopedPath = `${dbPaths.migrations}/${scope}`
33
- const tracker = new MigrationTracker(db, scope)
34
- const runner = new MigrationRunner(db, tracker, scopedPath, scope)
21
+ const tracker = new MigrationTracker(db)
22
+ const runner = new MigrationRunner(db, tracker, dbPaths.migrations)
35
23
 
36
24
  const batchNum = opts.batch ? parseInt(opts.batch, 10) : undefined
37
25
 
@@ -3,7 +3,6 @@ import chalk from 'chalk'
3
3
  import { bootstrap, shutdown } from '../cli/bootstrap.ts'
4
4
  import MigrationTracker from '@strav/database/database/migration/tracker'
5
5
  import MigrationRunner from '@strav/database/database/migration/runner'
6
- import { discoverDomains } from '@strav/database'
7
6
  import { getDatabasePaths } from '../config/loader.ts'
8
7
 
9
8
  export function register(program: Command): void {
@@ -11,26 +10,15 @@ export function register(program: Command): void {
11
10
  .command('migrate')
12
11
  .alias('migration:run')
13
12
  .description('Run pending migrations')
14
- .option('-s, --scope <scope>', 'Domain (e.g., public, tenant, factory, marketing)', 'public')
15
- .action(async (opts: { scope: string }) => {
13
+ .action(async () => {
16
14
  let db
17
15
  try {
18
- // Get configured database paths
19
16
  const dbPaths = await getDatabasePaths()
20
-
21
- // Validate scope against available domains
22
- const availableDomains = discoverDomains(dbPaths.schemas)
23
- if (!availableDomains.includes(opts.scope)) {
24
- throw new Error(`Invalid domain: ${opts.scope}. Available domains: ${availableDomains.join(', ')}`)
25
- }
26
- const scope = opts.scope
27
-
28
17
  const { db: database } = await bootstrap()
29
18
  db = database
30
19
 
31
- const scopedPath = `${dbPaths.migrations}/${scope}`
32
- const tracker = new MigrationTracker(db, scope)
33
- const runner = new MigrationRunner(db, tracker, scopedPath, scope)
20
+ const tracker = new MigrationTracker(db)
21
+ const runner = new MigrationRunner(db, tracker, dbPaths.migrations)
34
22
 
35
23
  console.log(chalk.cyan('Running pending migrations...'))
36
24
 
@@ -0,0 +1,35 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { bootstrap, shutdown } from '../cli/bootstrap.ts'
4
+ import TenantManager from '@strav/database/database/tenant/manager'
5
+ import { ensureTenantTable } from '@strav/database/database/tenant/seed'
6
+
7
+ export function register(program: Command): void {
8
+ program
9
+ .command('tenant:create')
10
+ .description('Create a new tenant')
11
+ .requiredOption('--slug <slug>', 'Unique tenant slug (used for subdomain/URLs)')
12
+ .requiredOption('--name <name>', 'Tenant display name')
13
+ .action(async (opts: { slug: string; name: string }) => {
14
+ let db
15
+ try {
16
+ const { db: database } = await bootstrap()
17
+ db = database
18
+
19
+ await ensureTenantTable(db.bypass)
20
+ const manager = new TenantManager(db)
21
+
22
+ const tenant = await manager.create({ slug: opts.slug, name: opts.name })
23
+
24
+ console.log(chalk.green('\nTenant created:'))
25
+ console.log(chalk.dim(` id: ${tenant.id}`))
26
+ console.log(chalk.dim(` slug: ${tenant.slug}`))
27
+ console.log(chalk.dim(` name: ${tenant.name}`))
28
+ } catch (err) {
29
+ console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
30
+ process.exit(1)
31
+ } finally {
32
+ if (db) await shutdown(db)
33
+ }
34
+ })
35
+ }
@@ -0,0 +1,64 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { createInterface } from 'node:readline'
4
+ import { bootstrap, shutdown } from '../cli/bootstrap.ts'
5
+ import TenantManager from '@strav/database/database/tenant/manager'
6
+ import { ensureTenantTable } from '@strav/database/database/tenant/seed'
7
+
8
+ export function register(program: Command): void {
9
+ program
10
+ .command('tenant:delete <id>')
11
+ .description('Delete a tenant and cascade-delete all their rows')
12
+ .option('-f, --force', 'Skip the confirmation prompt')
13
+ .action(async (id: string, opts: { force?: boolean }) => {
14
+ let db
15
+ try {
16
+ const { db: database } = await bootstrap()
17
+ db = database
18
+
19
+ await ensureTenantTable(db.bypass)
20
+ const manager = new TenantManager(db)
21
+
22
+ const tenant = await manager.find(id)
23
+ if (!tenant) {
24
+ console.error(chalk.red(`Tenant not found: ${id}`))
25
+ process.exit(1)
26
+ }
27
+
28
+ if (!opts.force) {
29
+ console.log(
30
+ chalk.red('WARNING: ') +
31
+ `This will delete tenant "${tenant.slug}" and ` +
32
+ chalk.red('cascade-delete all their data') +
33
+ ' across every tenant-scoped table.'
34
+ )
35
+ const challenge = tenant.slug
36
+ console.log(`\n Type ${chalk.yellow(challenge)} to confirm:\n`)
37
+
38
+ const answer = await prompt(' > ')
39
+ if (answer.trim() !== challenge) {
40
+ console.error(chalk.red('\nConfirmation does not match. Operation cancelled.'))
41
+ process.exit(1)
42
+ }
43
+ }
44
+
45
+ await manager.delete(id)
46
+ console.log(chalk.green(`\nTenant "${tenant.slug}" deleted.`))
47
+ } catch (err) {
48
+ console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
49
+ process.exit(1)
50
+ } finally {
51
+ if (db) await shutdown(db)
52
+ }
53
+ })
54
+ }
55
+
56
+ function prompt(question: string): Promise<string> {
57
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
58
+ return new Promise(resolve => {
59
+ rl.question(question, answer => {
60
+ rl.close()
61
+ resolve(answer)
62
+ })
63
+ })
64
+ }
@@ -0,0 +1,39 @@
1
+ import type { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { bootstrap, shutdown } from '../cli/bootstrap.ts'
4
+ import TenantManager from '@strav/database/database/tenant/manager'
5
+ import { ensureTenantTable } from '@strav/database/database/tenant/seed'
6
+
7
+ export function register(program: Command): void {
8
+ program
9
+ .command('tenant:list')
10
+ .description('List all tenants')
11
+ .action(async () => {
12
+ let db
13
+ try {
14
+ const { db: database } = await bootstrap()
15
+ db = database
16
+
17
+ await ensureTenantTable(db.bypass)
18
+ const manager = new TenantManager(db)
19
+ const tenants = await manager.list()
20
+
21
+ if (tenants.length === 0) {
22
+ console.log(chalk.yellow('No tenants found.'))
23
+ return
24
+ }
25
+
26
+ console.log(chalk.cyan(`\n${tenants.length} tenant(s):\n`))
27
+ for (const t of tenants) {
28
+ console.log(` ${chalk.green(t.slug)}`)
29
+ console.log(chalk.dim(` id: ${t.id}`))
30
+ console.log(chalk.dim(` name: ${t.name}`))
31
+ }
32
+ } catch (err) {
33
+ console.error(chalk.red(`Error: ${err instanceof Error ? err.message : err}`))
34
+ process.exit(1)
35
+ } finally {
36
+ if (db) await shutdown(db)
37
+ }
38
+ })
39
+ }
@@ -10,30 +10,23 @@ export async function loadGeneratorConfig(): Promise<GeneratorConfig | undefined
10
10
  try {
11
11
  return (await import(join(process.cwd(), 'config/generators.ts'))).default
12
12
  } catch {
13
- // No config/generators.ts — use defaults
14
13
  return undefined
15
14
  }
16
15
  }
17
16
 
18
17
  /**
19
18
  * Get the fully resolved database paths from the configuration.
20
- * This includes the schemas and migrations paths with defaults.
21
19
  */
22
20
  export async function getDatabasePaths(): Promise<{ schemas: string; migrations: string }> {
23
21
  const config = await loadGeneratorConfig()
24
22
  const paths = resolvePaths(config)
25
-
26
- return {
27
- schemas: paths.schemas,
28
- migrations: paths.migrations,
29
- }
23
+ return { schemas: paths.schemas, migrations: paths.migrations }
30
24
  }
31
25
 
32
26
  /**
33
27
  * Get all resolved paths from the configuration.
34
- * Useful for commands that need multiple paths.
35
28
  */
36
- export async function getAllPaths(scope?: string): Promise<GeneratorPaths> {
29
+ export async function getAllPaths(): Promise<GeneratorPaths> {
37
30
  const config = await loadGeneratorConfig()
38
- return resolvePaths(config, scope)
39
- }
31
+ return resolvePaths(config)
32
+ }
@@ -22,13 +22,8 @@ export interface GeneratorPaths {
22
22
  migrations: string
23
23
  }
24
24
 
25
- export interface ModelNaming {
26
- [domain: string]: string | null | undefined
27
- }
28
-
29
25
  export interface GeneratorConfig {
30
26
  paths?: Partial<GeneratorPaths>
31
- modelNaming?: ModelNaming
32
27
  }
33
28
 
34
29
  // ---------------------------------------------------------------------------
@@ -47,7 +42,6 @@ const DEFAULT_PATHS: GeneratorPaths = {
47
42
  routes: 'start',
48
43
  tests: 'tests/api',
49
44
  docs: 'public/_docs',
50
- // Database paths
51
45
  schemas: 'database/schemas',
52
46
  migrations: 'database/migrations',
53
47
  }
@@ -57,19 +51,8 @@ const DEFAULT_PATHS: GeneratorPaths = {
57
51
  // ---------------------------------------------------------------------------
58
52
 
59
53
  /** Merge user config with defaults, returning fully resolved paths. */
60
- export function resolvePaths(config?: GeneratorConfig, scope?: string): GeneratorPaths {
61
- const basePaths = config?.paths ? { ...DEFAULT_PATHS, ...config.paths } : { ...DEFAULT_PATHS }
62
-
63
- // If a domain is provided, append it to models and enums paths
64
- if (scope) {
65
- return {
66
- ...basePaths,
67
- models: `${basePaths.models}/${scope}`,
68
- enums: `${basePaths.enums}/${scope}`
69
- }
70
- }
71
-
72
- return basePaths
54
+ export function resolvePaths(config?: GeneratorConfig): GeneratorPaths {
55
+ return config?.paths ? { ...DEFAULT_PATHS, ...config.paths } : { ...DEFAULT_PATHS }
73
56
  }
74
57
 
75
58
  /**
@@ -79,34 +62,13 @@ export function resolvePaths(config?: GeneratorConfig, scope?: string): Generato
79
62
  */
80
63
  export function relativeImport(fromDir: string, toDir: string): string {
81
64
  let rel = relative(fromDir, toDir)
82
- // Normalize Windows separators for import paths
83
65
  rel = rel.split('\\').join('/')
84
66
  return rel.startsWith('.') ? rel : './' + rel
85
67
  }
86
68
 
87
- /** Get the model name prefix for a given domain. Returns empty string if no prefix. */
88
- export function getModelPrefix(config: GeneratorConfig | undefined, domain: string | undefined): string {
89
- if (!domain) return ''
90
-
91
- // Public domain has no prefix by default
92
- if (domain === 'public') {
93
- return config?.modelNaming?.[domain] ?? ''
94
- }
95
-
96
- // For other domains, use configured prefix or default to PascalCase
97
- if (config?.modelNaming?.[domain] !== undefined) {
98
- return config.modelNaming[domain] || ''
99
- }
100
-
101
- // Default prefix: PascalCase the domain name
102
- return domain.charAt(0).toUpperCase() + domain.slice(1).toLowerCase()
103
- }
104
-
105
69
  /**
106
70
  * Format generated files with Prettier and write them to disk.
107
- * Resolves the Prettier config from each file's location (so the project's
108
- * `.prettierrc` is picked up automatically). Falls back to writing
109
- * unformatted content if Prettier is not installed.
71
+ * Falls back to writing unformatted content if Prettier is not installed.
110
72
  */
111
73
  export async function formatAndWrite(files: GeneratedFile[]): Promise<void> {
112
74
  let prettier: typeof import('prettier') | null = null
@@ -10,7 +10,7 @@ import type {
10
10
  import type { PostgreSQLCustomType } from '@strav/database/schema/postgres'
11
11
  import { toSnakeCase, toCamelCase, toPascalCase } from '@strav/kernel/helpers/strings'
12
12
  import type { GeneratorConfig, GeneratorPaths } from './config.ts'
13
- import { resolvePaths, relativeImport, formatAndWrite, getModelPrefix } from './config.ts'
13
+ import { resolvePaths, relativeImport, formatAndWrite } from './config.ts'
14
14
 
15
15
  export interface GeneratedFile {
16
16
  path: string
@@ -20,19 +20,15 @@ export interface GeneratedFile {
20
20
  export default class ModelGenerator {
21
21
  private schemaMap: Map<string, SchemaDefinition>
22
22
  private paths: GeneratorPaths
23
- private modelPrefix: string
24
23
  private config?: GeneratorConfig
25
24
 
26
25
  constructor(
27
26
  private schemas: SchemaDefinition[],
28
27
  private representation: DatabaseRepresentation,
29
- config?: GeneratorConfig,
30
- private domain?: string,
31
- private allSchemasMap?: Map<string, string>
28
+ config?: GeneratorConfig
32
29
  ) {
33
30
  this.schemaMap = new Map(schemas.map(s => [s.name, s]))
34
- this.paths = resolvePaths(config, domain)
35
- this.modelPrefix = getModelPrefix(config, domain)
31
+ this.paths = resolvePaths(config)
36
32
  this.config = config
37
33
  }
38
34
 
@@ -299,7 +295,7 @@ export default class ModelGenerator {
299
295
  lines.push('// Generated by Strav — DO NOT EDIT')
300
296
  lines.push(...importLines)
301
297
  lines.push('')
302
- lines.push(`export default class ${this.prefixClassName(className)} extends BaseModel {`)
298
+ lines.push(`export default class ${(className)} extends BaseModel {`)
303
299
 
304
300
  if (hasSoftDeletes) {
305
301
  lines.push(' static override softDeletes = true')
@@ -560,7 +556,7 @@ export default class ModelGenerator {
560
556
  if (mode === 'named') {
561
557
  lines.push(`export * from './${basename}'`)
562
558
  } else {
563
- const className = this.prefixClassName(toPascalCase(basename))
559
+ const className = (toPascalCase(basename))
564
560
  lines.push(`export { default as ${className} } from './${basename}'`)
565
561
  }
566
562
  }
@@ -577,29 +573,9 @@ export default class ModelGenerator {
577
573
  // Helpers
578
574
  // ---------------------------------------------------------------------------
579
575
 
580
- /** Apply model name prefix to a class name. */
581
- private prefixClassName(className: string): string {
582
- return `${this.modelPrefix}${className}`
583
- }
584
-
585
- /** Apply appropriate prefix to a model name based on which domain it belongs to. */
576
+ /** Convert a schema name to its generated model class name. */
586
577
  private prefixModelName(modelName: string): string {
587
- // If we don't have domain mapping, use current domain prefix
588
- if (!this.allSchemasMap) {
589
- return this.prefixClassName(toPascalCase(modelName))
590
- }
591
-
592
- // Check which domain this model belongs to
593
- const modelDomain = this.allSchemasMap.get(modelName)
594
-
595
- if (modelDomain) {
596
- // Get the appropriate prefix for that domain
597
- const prefix = getModelPrefix(this.config, modelDomain)
598
- return `${prefix}${toPascalCase(modelName)}`
599
- }
600
-
601
- // Fallback to current domain prefix
602
- return this.prefixClassName(toPascalCase(modelName))
578
+ return toPascalCase(modelName)
603
579
  }
604
580
 
605
581
  private isForeignKey(columnName: string, table: TableDefinition): boolean {