belt 0.6.2 → 0.6.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05d5db875d229ad413bf8af05004a92961776b347db0884ded768f35fcfe245e
4
- data.tar.gz: d6f1590f52f2a04b9457b82cf0c0cad094b96b8e04e112672df37d1e5ccf173b
3
+ metadata.gz: 2fcb629702530cd06ffb9af298dcd259c4fc0503107ef19ab58a58da476617b4
4
+ data.tar.gz: 167a12259478e5079620446f76dad474cde1d456f08b328663fe11f82860fa66
5
5
  SHA512:
6
- metadata.gz: 5b9c29157f8d7b698c8f68c807564299f881b220473ef4a51194352092f5a8967dfce0495fb12bc24a813b4ab2e077358c3800151ccf8737059888560a62ad97
7
- data.tar.gz: 6629a4a02a92e571d1e835ba0f12894f8bad945eb89bf79a2bc4696538378f45dfd7b5f5e3fc4090d9e61120c51d032b2fab5d5e2bdc17cfdfe81d25950061db
6
+ metadata.gz: bb37dd0fe9e0cd392a0824d14b0ab4c51c5d002b36240fc7795ad11ce2313dd0014bea535c020fab6e4ec8988c7cf4d5a90992e2f8cfcdbcea35bc780586592f
7
+ data.tar.gz: d8d7423e5b3cd0c3d1598ff7b9e82446c9de4705b68313ade0ee0479ec26a87b5d4e3acd23a52b664ec2f50fd37e66cb046e1383f18bbf696cd01a13d24b140e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.4
4
+
5
+ ### Added
6
+
7
+ - **Plugins can register top-level commands.** A gem that ships
8
+ `lib/belt/commands/<name>_command.rb` defining `Belt::Commands::<Name>Command.run(args)`
9
+ gets `belt <name> ...` (underscores in the file name become hyphens), listed under
10
+ "Plugin Commands" in `belt --help`. Discovery mirrors `GeneratorRegistry`
11
+ (`Belt::CLI::CommandRegistry`). Optional `.description` for help text and `.standalone?`
12
+ to run outside a Belt project. Built-in commands and terraform actions win on a name
13
+ clash. `belt help` / `belt --help` / `belt -h` now print usage and exit 0.
14
+
15
+ ### Fixed
16
+
17
+ - **The data-migrations step of `belt deploy` no longer fails with "You have already
18
+ activated json X, but your Gemfile requires json Y".** The child `belt db:migrate` now
19
+ starts with the app's bundle loaded (`BUNDLE_GEMFILE` + `-rbundler/setup`), so the belt
20
+ CLI's own `require 'json'` can't activate a newer json than the app's lockfile pins.
21
+ - **A fresh environment's Lambdas missing their `config/lambda/*.yml` `env_vars`** (nested
22
+ envs especially, since they are always created fresh) is fixed in conveyor-belt 0.0.19:
23
+ the provider's create path read only the HCL `lambda_config` map and ignored
24
+ `lambda_config_dir`, so the YAML env vars, `iam_policy_arns` and triggers landed only on
25
+ a second deploy. Existing apps on `version = "~> 0.0.16"` pick it up with
26
+ `terraform init -upgrade` in the env's infrastructure directory.
27
+
28
+ ## 0.6.3
29
+
30
+ ### Added
31
+
32
+ - **Data migrations.** `db/migrate/<timestamp>_<name>.rb` files define `Belt::Migration`
33
+ subclasses whose `up` runs in the booted app context (same as `belt console`).
34
+ `belt deploy <env>` runs pending ones after terraform apply and the nested-env data copy
35
+ (also after `--rebuild`; opt out with `--skip-migrations`). Each version runs once per
36
+ environment, nested envs included, recorded in SSM at
37
+ `/belt/<app>/<env>/schema_migrations/<version>`. A failed migration stays pending, the
38
+ deploy carries on and exits non-zero. New `belt db:migrate [env]` and
39
+ `belt db:migrate:status [env]` run or check them by hand. See `belt explain migrations`.
40
+
3
41
  ## 0.6.2
4
42
 
5
43
  ### Fixed
data/README.md CHANGED
@@ -731,6 +731,29 @@ puts "Created post: #{post.id}"
731
731
 
732
732
  Refuses to run against an environment that already has data (pass `--force` to override). `belt new` scaffolds a starter `config/seeds.rb`. See `belt explain data_seeding` for details.
733
733
 
734
+ ### `belt db:migrate` — data migrations
735
+
736
+ For changes to rows that already exist (backfilling an attribute, populating a new GSI for old records), add a migration to `db/migrate/`:
737
+
738
+ ```ruby
739
+ # db/migrate/20261030120000_index_usernames.rb
740
+ class IndexUsernames < Belt::Migration
741
+ def up
742
+ PoshNosh::MatchStore.new.index_usernames!
743
+ end
744
+ end
745
+ ```
746
+
747
+ `belt deploy <env>` runs pending migrations after terraform apply, once per environment (nested envs included). Applied versions are recorded in SSM under `/belt/<app>/<env>/schema_migrations/`.
748
+
749
+ ```bash
750
+ belt db:migrate dev01 # run pending migrations by hand
751
+ belt db:migrate:status prod # which have run
752
+ belt deploy dev --skip-migrations
753
+ ```
754
+
755
+ Migrations must be idempotent and batch-safe. See `belt explain migrations`.
756
+
734
757
  ## Testing
735
758
 
736
759
  Belt ships an in-process **end-to-end harness** that drives the real router — no AWS, no HTTP server, no browser. It's the tier between controller unit tests (dispatch an action directly, never touch routing) and full cloud e2e (needs a deployed stack).
@@ -807,6 +830,8 @@ No central registry file, no initializer hook. Belt discovers generators by scan
807
830
 
808
831
  That is the whole contract. After `bundle install`, `belt generate <name>` and `belt destroy <name>` just work.
809
832
 
833
+ Plugins can also add a top-level command: a file at `lib/belt/commands/<name>_command.rb` defining `Belt::Commands::<Name>Command.run(args)` becomes `belt <name> ...` and shows up under "Plugin Commands" in `belt --help`. Optional `.description` and `.standalone?` (run outside a project). Built-in commands win on a name clash. See `Belt::CLI::CommandRegistry` and `belt explain plugins`.
834
+
810
835
  See `Belt::CLI::GeneratorRegistry` and the CHANGELOG entry for **0.1.13 Generator Extension API**.
811
836
 
812
837
  ### Plugin layout (canonical)
data/SKILL.md CHANGED
@@ -8,7 +8,7 @@ description: >-
8
8
  Belt routing DSL, writing BeltController actions, modeling DynamoDB data
9
9
  with ActiveItem, wiring Cognito auth, configuring the Lambda handler,
10
10
  running `belt` CLI commands (new, generate, deploy, console, routes, logs,
11
- server, setup, plugin, explain, db:copy/db:seed), Terraform via belt,
11
+ server, setup, plugin, explain, db:copy/db:seed/db:migrate), Terraform via belt,
12
12
  backups, observability, or authoring Belt plugins. Triggers on "belt new",
13
13
  "belt deploy", "belt generate", "belt routes", "BeltController", "ActiveItem",
14
14
  "Belt::LambdaHandler", "belt console", "belt server", "conveyor-belt". Do NOT
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module CLI
5
+ # Discovers top-level CLI commands provided by external gems.
6
+ #
7
+ # Gems register commands by placing a file at:
8
+ # lib/belt/commands/<name>_command.rb
9
+ #
10
+ # Each command class must:
11
+ # - Be named <Name>Command (e.g., ConnectCommand)
12
+ # - Live in the Belt::Commands module
13
+ # - Implement .run(args)
14
+ # - Implement .description for help text (optional)
15
+ # - Implement .standalone? returning true to run outside a Belt project (optional)
16
+ #
17
+ # Example gem structure:
18
+ # belt-connect/
19
+ # lib/
20
+ # belt/
21
+ # commands/
22
+ # connect_command.rb
23
+ #
24
+ # The command is then available as:
25
+ # belt connect ...
26
+ #
27
+ # Underscores in the file name become hyphens in the command name
28
+ # (lib/belt/commands/feature_flags_command.rb → `belt feature-flags`).
29
+ # Built-in commands win on a name clash; see Belt::CLI.plugin_command.
30
+ module CommandRegistry
31
+ COMMAND_PATH = 'lib/belt/commands'
32
+
33
+ class << self
34
+ # Returns a hash of { "name" => command_class } for all discovered commands.
35
+ def discovered_commands
36
+ @discovered_commands ||= discover_all
37
+ end
38
+
39
+ # Returns just the names of discovered commands.
40
+ def command_names
41
+ discovered_commands.keys
42
+ end
43
+
44
+ # Returns the command class for a given name, or nil if not found.
45
+ def find(name)
46
+ discovered_commands[name]
47
+ end
48
+
49
+ # Reset cache (useful in tests)
50
+ def reset!
51
+ @discovered_commands = nil
52
+ end
53
+
54
+ private
55
+
56
+ def discover_all
57
+ commands = {}
58
+
59
+ Gem.loaded_specs.each_value do |spec|
60
+ commands_dir = File.join(spec.gem_dir, COMMAND_PATH)
61
+ next unless File.directory?(commands_dir)
62
+
63
+ Dir.glob(File.join(commands_dir, '*_command.rb')).each do |file|
64
+ basename = File.basename(file, '.rb').sub(/_command\z/, '')
65
+ klass = load_command_class(file, basename)
66
+ commands[basename.tr('_', '-')] = klass if klass
67
+ end
68
+ end
69
+
70
+ commands
71
+ end
72
+
73
+ def load_command_class(file, basename)
74
+ require file
75
+
76
+ class_name = "#{classify(basename)}Command"
77
+ Belt::Commands.const_get(class_name, false)
78
+ rescue LoadError, NameError => e
79
+ warn "[Belt] Warning: Failed to load command '#{basename}' from #{file}: #{e.message}"
80
+ nil
81
+ end
82
+
83
+ def classify(name)
84
+ name.split('_').map(&:capitalize).join
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # Namespace for top-level command classes provided by external gems
91
+ module Commands
92
+ end
93
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'stringio'
5
+ require_relative 'app_detection'
6
+ require_relative 'environment_config'
7
+ require_relative 'migration_store'
8
+ require_relative '../inflector'
9
+ require_relative '../migration'
10
+
11
+ module Belt
12
+ module CLI
13
+ # `belt db:migrate` / `belt db:migrate:status` — data migrations.
14
+ #
15
+ # Each `db/migrate/<timestamp>_<name>.rb` defines a `Belt::Migration`
16
+ # subclass named after the file (`20261030120000_index_usernames.rb` →
17
+ # `IndexUsernames`). Pending ones run oldest-first in the same booted app
18
+ # context `belt console` uses, and each is recorded in SSM (see
19
+ # MigrationStore) only after its `up` returns, so a failure is retried on
20
+ # the next run. `belt deploy` runs this after terraform apply.
21
+ class DbMigrateCommand
22
+ include AppDetection
23
+
24
+ MIGRATIONS_DIR = File.join('db', 'migrate')
25
+ FILENAME_PATTERN = /\A(\d+)_([a-z0-9_]+)\.rb\z/
26
+
27
+ MigrationFile = Struct.new(:version, :name, :path) do
28
+ def class_name
29
+ Belt::Inflector.camelize(name)
30
+ end
31
+
32
+ def label
33
+ name.tr('_', ' ').capitalize
34
+ end
35
+ end
36
+
37
+ def self.run(args, status: false)
38
+ new(args, status: status).run
39
+ end
40
+
41
+ def self.migrations?(root = Dir.pwd)
42
+ Dir.glob(File.join(root, MIGRATIONS_DIR, '*.rb')).any?
43
+ end
44
+
45
+ def initialize(args, status: false)
46
+ @options = { status: status, yes: false }
47
+ parse_options(args)
48
+ end
49
+
50
+ def run
51
+ ENV['BUNDLE_GEMFILE'] ||= File.join(Belt.root, 'Gemfile')
52
+ unless File.exist?(ENV['BUNDLE_GEMFILE'])
53
+ abort "Error: No Gemfile found at #{ENV['BUNDLE_GEMFILE']}. Are you in a Belt project?"
54
+ end
55
+
56
+ @environment = @env_arg || ENV.fetch('BELT_ENV', nil) || 'dev'
57
+ ENV['ENVIRONMENT'] = @environment
58
+ apply_env_config!
59
+
60
+ @store = MigrationStore.new(app_name: detect_app_name, env: @environment)
61
+ @options[:status] ? print_status : migrate
62
+ rescue MigrationStore::Error => e
63
+ abort "Error: #{e.message}"
64
+ end
65
+
66
+ private
67
+
68
+ def parse_options(args)
69
+ OptionParser.new do |opts|
70
+ opts.banner = if @options[:status]
71
+ 'Usage: belt db:migrate:status [environment]'
72
+ else
73
+ 'Usage: belt db:migrate [environment] [options]'
74
+ end
75
+
76
+ unless @options[:status]
77
+ opts.on('-y', '--yes', 'Skip the production confirmation prompt') { @options[:yes] = true }
78
+ end
79
+
80
+ opts.on('-h', '--help', 'Show this help') do
81
+ puts opts
82
+ exit
83
+ end
84
+ end.parse!(args)
85
+
86
+ @env_arg = args.shift
87
+ end
88
+
89
+ def apply_env_config!
90
+ env_config = EnvironmentConfig.load(@environment)
91
+ env_config.apply!
92
+ puts " 🔑 Using AWS profile: #{env_config.aws_profile}" if env_config.aws_profile?
93
+ end
94
+
95
+ def migrate
96
+ pending = pending_migrations
97
+ if pending.empty?
98
+ puts "belt → no pending migrations for #{@environment}"
99
+ return
100
+ end
101
+
102
+ production_guard!
103
+ boot_app
104
+
105
+ puts "belt → running #{pending.size} migration(s) against #{@environment}"
106
+ pending.each { |file| run_migration(file) }
107
+ puts "✅ Migrations complete (#{@environment})"
108
+ end
109
+
110
+ def run_migration(file)
111
+ puts " == #{file.version} #{file.label}"
112
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
113
+ instantiate(file).up
114
+ @store.record!(file.version, file.name)
115
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
116
+ puts format(' == %<version>s migrated (%<elapsed>.1fs)', version: file.version, elapsed: elapsed)
117
+ rescue MigrationStore::Error
118
+ raise
119
+ rescue StandardError, ScriptError => e
120
+ abort "\n❌ Migration #{file.version} (#{file.name}) failed: #{e.class}: #{e.message}\n" \
121
+ "#{Array(e.backtrace).first(5).join("\n")}\n\n" \
122
+ 'Later migrations were not run. Fix it and re-run ' \
123
+ "`belt db:migrate #{@environment}` (migrations must be safe to re-run)."
124
+ end
125
+
126
+ def instantiate(file)
127
+ load File.expand_path(file.path)
128
+ klass = Object.const_get(file.class_name) if Object.const_defined?(file.class_name)
129
+ unless klass.is_a?(Class) && klass < Belt::Migration
130
+ raise NameError, "#{file.path} must define `class #{file.class_name} < Belt::Migration`"
131
+ end
132
+
133
+ klass.new(version: file.version, name: file.name)
134
+ end
135
+
136
+ def print_status
137
+ applied = @store.applied_versions
138
+ files = migration_files
139
+
140
+ puts "\nenvironment: #{@environment}\n\n"
141
+ puts ' Status Migration ID Migration Name'
142
+ puts '-' * 50
143
+ files.each do |file|
144
+ puts status_line(applied.include?(file.version) ? 'up' : 'down', file.version, file.label)
145
+ end
146
+ (applied - files.map(&:version)).sort.each do |version|
147
+ puts status_line('up', version, '** NO FILE **')
148
+ end
149
+ puts ' (no migrations in db/migrate)' if files.empty? && applied.empty?
150
+ end
151
+
152
+ def status_line(state, version, label)
153
+ format(' %<state>-6s %<version>-14s %<label>s', state: state, version: version, label: label)
154
+ end
155
+
156
+ def pending_migrations
157
+ applied = @store.applied_versions
158
+ migration_files.reject { |file| applied.include?(file.version) }
159
+ end
160
+
161
+ def migration_files
162
+ Dir.glob(File.join(MIGRATIONS_DIR, '*.rb')).map do |path|
163
+ match = File.basename(path).match(FILENAME_PATTERN)
164
+ abort "Error: #{path} must be named <timestamp>_<snake_case_name>.rb" unless match
165
+
166
+ MigrationFile.new(match[1], match[2], path)
167
+ end.sort_by(&:version)
168
+ end
169
+
170
+ def production_guard!
171
+ return unless @environment == 'prod' && !@options[:yes]
172
+
173
+ $stdout.write "\n⚠️ WARNING: You are about to migrate PRODUCTION data!\nType 'yes' to continue: "
174
+ response = $stdin.gets&.chomp
175
+ abort "\n❌ Cancelled." unless response&.downcase == 'yes'
176
+ end
177
+
178
+ def boot_app
179
+ suppress_warnings { require 'bundler/setup' }
180
+
181
+ environment_file = File.join(Belt.root, 'lambda', 'config', 'environment.rb')
182
+ if File.exist?(environment_file)
183
+ load environment_file
184
+ else
185
+ require 'belt'
186
+ load_dir('lib')
187
+ load_dir('models')
188
+ end
189
+ end
190
+
191
+ def load_dir(subdir)
192
+ dir = File.join(Belt.root, 'lambda', subdir)
193
+ Dir.glob(File.join(dir, '**', '*.rb')).each { |f| require f } if Dir.exist?(dir)
194
+ end
195
+
196
+ def suppress_warnings
197
+ original_verbose = $VERBOSE
198
+ $VERBOSE = nil
199
+ original_stderr = $stderr
200
+ $stderr = StringIO.new
201
+ yield
202
+ ensure
203
+ $stderr = original_stderr
204
+ $VERBOSE = original_verbose
205
+ end
206
+ end
207
+ end
208
+ end
@@ -16,11 +16,13 @@ require_relative 'cognito_sharer'
16
16
  require_relative 'dynamo_copier'
17
17
  require_relative 'dns_command'
18
18
  require_relative 'apex_dns_sync'
19
+ require_relative 'db_migrate_command'
19
20
 
20
21
  module Belt
21
22
  module CLI
22
23
  class DeployCommand
23
24
  DEFAULT_DOCKER_BUILD_IMAGE = 'public.ecr.aws/sam/build-ruby3.4:latest-x86_64'
25
+ BELT_EXE = File.expand_path('../../../exe/belt', __dir__)
24
26
  def self.run(args)
25
27
  # Handle `belt deploy frontend <env>` as before
26
28
  if args.first == 'frontend'
@@ -33,6 +35,7 @@ module Belt
33
35
  rebuild = false
34
36
  skip_backup = false
35
37
  backup_only = false
38
+ skip_migrations = false
36
39
  filtered_args = []
37
40
 
38
41
  args.each do |arg|
@@ -45,6 +48,8 @@ module Belt
45
48
  skip_backup = true
46
49
  when '--backup-only'
47
50
  backup_only = true
51
+ when '--skip-migrations'
52
+ skip_migrations = true
48
53
  when '-h', '--help'
49
54
  puts help_text
50
55
  exit 0
@@ -79,12 +84,13 @@ module Belt
79
84
  end
80
85
  end
81
86
 
87
+ options = { auto_approve: auto_approve, skip_migrations: skip_migrations, extra_args: filtered_args }
82
88
  if backup_only
83
- new(env, auto_approve: auto_approve, skip_backup: false, extra_args: filtered_args).run_backup_only
89
+ new(env, **options, skip_backup: false).run_backup_only
84
90
  elsif rebuild
85
- new(env, auto_approve: auto_approve, skip_backup: skip_backup, extra_args: filtered_args).run_rebuild
91
+ new(env, **options, skip_backup: skip_backup).run_rebuild
86
92
  else
87
- new(env, auto_approve: auto_approve, skip_backup: skip_backup, extra_args: filtered_args).run
93
+ new(env, **options, skip_backup: skip_backup).run
88
94
  end
89
95
  end
90
96
 
@@ -105,6 +111,7 @@ module Belt
105
111
  7. Prompt for confirmation (unless --auto)
106
112
  8. terraform apply (deploy changes)
107
113
  9. Nested envs: share parent Cognito + copy DynamoDB if empty
114
+ 10. Run pending data migrations in db/migrate (see `belt db:migrate`)
108
115
 
109
116
  Options:
110
117
  --auto, --yes, -y Skip confirmation prompt (auto-approve)
@@ -114,6 +121,7 @@ module Belt
114
121
  --skip-backup Skip pre-deploy backup phase
115
122
  --no-backup Alias for --skip-backup
116
123
  --backup-only Run backups only, do not deploy
124
+ --skip-migrations Don't run pending db/migrate data migrations
117
125
  -h, --help Show this help
118
126
 
119
127
  Environment:
@@ -144,10 +152,11 @@ module Belt
144
152
  HELP
145
153
  end
146
154
 
147
- def initialize(env, auto_approve: false, skip_backup: false, extra_args: [])
155
+ def initialize(env, auto_approve: false, skip_backup: false, skip_migrations: false, extra_args: [])
148
156
  @env = env
149
157
  @auto_approve = auto_approve
150
158
  @skip_backup = skip_backup
159
+ @skip_migrations = skip_migrations
151
160
  @extra_args = extra_args
152
161
  @infra_dir = TerraformCommand.find_infrastructure_dir
153
162
  @project_root = find_project_root
@@ -188,9 +197,12 @@ module Belt
188
197
 
189
198
  run_nested_env_hooks
190
199
 
200
+ migrations_ok = run_migrations
201
+
191
202
  deploy_frontend_if_exists
192
203
 
193
204
  puts "\n Run `belt server` to view your app locally (auto-connects to the deployed API)."
205
+ exit 1 unless migrations_ok
194
206
  end
195
207
 
196
208
  def run_backup_only
@@ -232,6 +244,7 @@ module Belt
232
244
  ensure_lockfile_consistent!
233
245
  generate_routes(lambda_dir)
234
246
  build_and_deploy(lambda_dir, function_name)
247
+ exit 1 unless run_migrations
235
248
  end
236
249
 
237
250
  private
@@ -901,6 +914,36 @@ module Belt
901
914
  [raw, sanitized].uniq
902
915
  end
903
916
 
917
+ # Runs pending db/migrate data migrations in a child `belt db:migrate`
918
+ # so the app's code is booted in its own process, not the deployer's.
919
+ # Runs after the nested-env DynamoDB copy so copied rows get migrated
920
+ # too. Returns false on failure: the infra is already live, so the rest
921
+ # of the deploy continues and the exit status reports the failure.
922
+ def run_migrations # rubocop:disable Naming/PredicateMethod
923
+ return true if @skip_migrations || !DbMigrateCommand.migrations?(@project_root)
924
+
925
+ puts "\n━━━ data migrations ━━━"
926
+ return true if system(migration_child_env, Gem.ruby, BELT_EXE, 'db:migrate', @env, '--yes',
927
+ chdir: @project_root)
928
+
929
+ puts "\n ⚠ Data migrations failed for #{@env}. The deploy itself succeeded."
930
+ puts " Fix the migration and run `belt db:migrate #{@env}` to retry."
931
+ false
932
+ end
933
+
934
+ # Load the app's bundle before the belt CLI does. Otherwise the CLI's own
935
+ # `require 'json'` activates the newest installed json, and db:migrate's
936
+ # `bundler/setup` then fails with "You have already activated json X,
937
+ # but your Gemfile requires json Y" (Fizzy #1734).
938
+ def migration_child_env
939
+ gemfile = File.join(@project_root, 'Gemfile')
940
+ return {} unless File.exist?(gemfile)
941
+
942
+ rubyopt = ENV.fetch('RUBYOPT', '')
943
+ rubyopt = "-rbundler/setup #{rubyopt}".strip unless rubyopt.include?('bundler/setup')
944
+ { 'BUNDLE_GEMFILE' => gemfile, 'RUBYOPT' => rubyopt }
945
+ end
946
+
904
947
  def deploy_frontend_if_exists
905
948
  require_relative 'frontend_registry'
906
949
  require_relative 'frontend_deploy_command'
@@ -52,6 +52,10 @@ module Belt
52
52
  'db:copy' => 'data_seeding',
53
53
  'copy' => 'data_seeding',
54
54
  'db_copy' => 'data_seeding',
55
+ 'migration' => 'migrations',
56
+ 'migrate' => 'migrations',
57
+ 'db:migrate' => 'migrations',
58
+ 'data_migrations' => 'migrations',
55
59
  'e2e' => 'testing',
56
60
  'test' => 'testing',
57
61
  'tests' => 'testing',
@@ -120,6 +124,7 @@ module Belt
120
124
  irb, repl → console
121
125
  seeds, seed, db:seed → data_seeding
122
126
  db:copy, copy → data_seeding
127
+ migrate, db:migrate → migrations
123
128
  e2e, test, harness → testing
124
129
 
125
130
  Examples:
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'time'
6
+
7
+ module Belt
8
+ module CLI
9
+ # Records which data migrations have run against an environment, as one
10
+ # SSM parameter per version:
11
+ #
12
+ # /belt/<app>/<env>/schema_migrations/<version>
13
+ #
14
+ # SSM rather than a DynamoDB table so the record isn't part of the
15
+ # `<app>-<env>-*` tables that `belt db:copy` and the nested-env copy move
16
+ # between environments — each stack keeps its own history.
17
+ class MigrationStore
18
+ class Error < StandardError; end
19
+
20
+ def initialize(app_name:, env:)
21
+ @app_name = app_name
22
+ @env = env
23
+ end
24
+
25
+ def path
26
+ "/belt/#{@app_name}/#{@env}/schema_migrations"
27
+ end
28
+
29
+ # Versions already applied to this environment, as strings.
30
+ def applied_versions
31
+ data = aws_json('ssm', 'get-parameters-by-path', '--path', path, '--output', 'json')
32
+ Array(data['Parameters']).map { |param| File.basename(param['Name']) }
33
+ end
34
+
35
+ def record!(version, name)
36
+ value = JSON.generate('name' => name, 'applied_at' => Time.now.utc.iso8601)
37
+ aws_json('ssm', 'put-parameter', '--name', "#{path}/#{version}", '--type', 'String',
38
+ '--value', value, '--overwrite', '--output', 'json')
39
+ end
40
+
41
+ private
42
+
43
+ def aws_json(*args)
44
+ command = "aws #{args.first(2).join(' ')}"
45
+ output, error, status = Open3.capture3('aws', *args)
46
+ raise Error, "#{command} failed: #{error.strip}" unless status.success?
47
+
48
+ output.strip.empty? ? {} : JSON.parse(output)
49
+ rescue JSON::ParserError => e
50
+ raise Error, "#{command} returned invalid JSON: #{e.message}"
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/belt/cli.rb CHANGED
@@ -26,10 +26,12 @@ require_relative 'cli/tasks_command'
26
26
  require_relative 'cli/console_command'
27
27
  require_relative 'cli/db_copy_command'
28
28
  require_relative 'cli/db_seed_command'
29
+ require_relative 'cli/db_migrate_command'
29
30
  require_relative 'cli/logs_command'
30
31
  require_relative 'cli/doctor_command'
31
32
  require_relative 'cli/plugin_command'
32
33
  require_relative 'cli/explain_command'
34
+ require_relative 'cli/command_registry'
33
35
 
34
36
  module Belt
35
37
  module CLI
@@ -43,6 +45,8 @@ module Belt
43
45
  %w[console c] => Belt::CLI::ConsoleCommand,
44
46
  'db:copy' => Belt::CLI::DbCopyCommand,
45
47
  'db:seed' => Belt::CLI::DbSeedCommand,
48
+ 'db:migrate' => Belt::CLI::DbMigrateCommand,
49
+ 'db:migrate:status' => ->(args) { Belt::CLI::DbMigrateCommand.run(args, status: true) },
46
50
  'logs' => Belt::CLI::LogsCommand,
47
51
  %w[tasks --tasks -T] => Belt::CLI::TasksCommand,
48
52
  'setup' => Belt::CLI::SetupCommand,
@@ -53,7 +57,8 @@ module Belt
53
57
  'dns' => Belt::CLI::DnsCommand,
54
58
  'frontend' => Belt::CLI::FrontendEnvCommand,
55
59
  %w[server s] => Belt::CLI::ServerCommand,
56
- %w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" }
60
+ %w[version --version -v] => ->(_args) { puts "Belt #{Belt::VERSION}" },
61
+ %w[help --help -h] => ->(_args) { puts usage }
57
62
  }.freeze
58
63
 
59
64
  COMMANDS = COMMANDS_DEFINITION.each_with_object({}) do |(keys, handler), hash|
@@ -63,7 +68,7 @@ module Belt
63
68
  TERRAFORM_ACTIONS = Belt::CLI::TerraformCommand::ACTIONS
64
69
 
65
70
  # Commands that can run without being inside a Belt project
66
- STANDALONE_COMMANDS = %w[new version --version -v doctor explain].freeze
71
+ STANDALONE_COMMANDS = %w[new version --version -v help --help -h doctor explain].freeze
67
72
 
68
73
  def self.start(args)
69
74
  command = args.shift
@@ -73,8 +78,10 @@ module Belt
73
78
  exit 1
74
79
  end
75
80
 
81
+ plugin = plugin_command(command)
82
+
76
83
  # For project-level commands, find and chdir to the project root
77
- ensure_project_root!(command) unless STANDALONE_COMMANDS.include?(command)
84
+ ensure_project_root!(command) unless standalone?(command, plugin)
78
85
 
79
86
  # `belt destroy` is ambiguous: could be terraform destroy or belt destroy <generator>.
80
87
  return if command == 'destroy' && route_destroy_command(args)
@@ -82,7 +89,7 @@ module Belt
82
89
  # Terraform shorthand: belt init wups, belt plan wups, belt apply wups, belt destroy wups
83
90
  return Belt::CLI::TerraformCommand.run(command, args) if TERRAFORM_ACTIONS.include?(command)
84
91
 
85
- handler = COMMANDS[command]
92
+ handler = COMMANDS[command] || plugin
86
93
 
87
94
  # If no built-in command matched, try running it as a rake task
88
95
  if handler.nil?
@@ -99,7 +106,43 @@ module Belt
99
106
  end
100
107
  end
101
108
 
109
+ # Returns the gem-provided command class for `name`, or nil. Built-in
110
+ # commands and terraform actions win on a name clash.
111
+ def self.plugin_command(name)
112
+ return nil if name.nil? || builtin_command?(name)
113
+
114
+ CommandRegistry.find(name)
115
+ end
116
+
117
+ def self.builtin_command?(name)
118
+ COMMANDS.key?(name) || TERRAFORM_ACTIONS.include?(name)
119
+ end
120
+
121
+ def self.standalone?(command, plugin)
122
+ return STANDALONE_COMMANDS.include?(command) unless plugin
123
+
124
+ plugin.respond_to?(:standalone?) && plugin.standalone?
125
+ end
126
+
102
127
  def self.usage
128
+ base_usage.sub("\nRake Tasks:\n", "#{plugin_usage}\nRake Tasks:\n")
129
+ end
130
+
131
+ # "Plugin Commands:" help section for gem-provided commands ("" when none).
132
+ def self.plugin_usage
133
+ names = CommandRegistry.command_names.reject { |name| builtin_command?(name) }.sort
134
+ return '' if names.empty?
135
+
136
+ lines = names.map do |name|
137
+ klass = CommandRegistry.find(name)
138
+ description = klass.respond_to?(:description) ? klass.description.to_s : ''
139
+ " #{name.ljust(42)} #{description}".rstrip
140
+ end
141
+
142
+ "\nPlugin Commands:\n#{lines.join("\n")}\n"
143
+ end
144
+
145
+ def self.base_usage
103
146
  <<~USAGE
104
147
  Usage: belt <command> [options]
105
148
 
@@ -131,6 +174,8 @@ module Belt
131
174
  c Alias for console
132
175
  db:copy <from-env> <to-env> [--force] Copy DynamoDB data between environments
133
176
  db:seed [environment] [--force] Run config/seeds.rb against an environment
177
+ db:migrate [environment] [--yes] Run pending db/migrate data migrations
178
+ db:migrate:status [environment] Show which data migrations have run
134
179
  logs [lambda] [-f] [-s 5m] [-e env] View Lambda function logs
135
180
  tasks [-g PATTERN] [-a] List available rake tasks
136
181
  -T [-g PATTERN] [-a] Alias for tasks
@@ -146,6 +191,7 @@ module Belt
146
191
  destroy [environment] <env> terraform destroy for environment
147
192
  output [environment] <env> terraform output for environment
148
193
  --version Show Belt version
194
+ help, --help, -h Show this help
149
195
 
150
196
  Rake Tasks:
151
197
  Any rake task from your Gemfile dependencies can be run directly:
@@ -180,6 +226,8 @@ module Belt
180
226
  belt db:copy prod dev --force # overwrite dev tables even if non-empty
181
227
  belt db:seed # run config/seeds.rb against dev (or BELT_ENV)
182
228
  belt db:seed dev01
229
+ belt db:migrate prod # run pending data migrations against prod
230
+ belt db:migrate:status dev01
183
231
  USAGE
184
232
  end
185
233
 
@@ -126,4 +126,5 @@ file, no generator required.
126
126
 
127
127
  - `belt explain backups` — recovery-point snapshots, not data seeding
128
128
  - `belt explain console` — the app-booting mechanism `db:seed` reuses
129
+ - `belt explain migrations` — changing rows already in a deployed env
129
130
  - `belt explain deployment` — nested environments and the parent → child copy hook
@@ -11,6 +11,7 @@ belt deploy <env> # init → plan → apply (interactive)
11
11
  belt deploy prod --auto # skip confirmation prompt
12
12
  belt deploy prod --skip-backup # skip pre-deploy backup
13
13
  belt deploy --backup-only # create recovery point without deploying
14
+ belt deploy dev --skip-migrations # don't run pending db/migrate migrations
14
15
  ```
15
16
 
16
17
  ## First-Time Setup
@@ -147,6 +148,14 @@ end
147
148
  Backups run automatically before each deploy (DynamoDB snapshots, Cognito
148
149
  exports, S3 syncs). See `belt explain backups` for full documentation.
149
150
 
151
+ ## Data Migrations
152
+
153
+ After terraform apply (and the nested-env data copy), `belt deploy` runs any
154
+ pending `db/migrate/<timestamp>_<name>.rb` data migrations against the env,
155
+ each exactly once per environment. A failing migration doesn't roll back the
156
+ deploy; it's reported and the command exits non-zero. Run them by hand with
157
+ `belt db:migrate <env>`. See `belt explain migrations`.
158
+
150
159
  ## Environment Variables
151
160
 
152
161
  Set `BELT_ENV` to avoid typing the environment every time:
@@ -176,4 +185,5 @@ deploys every configured frontend after terraform apply.
176
185
 
177
186
  - `belt explain routing` — how routes map to infrastructure
178
187
  - `belt explain backups` — pre-deploy backup configuration
188
+ - `belt explain migrations` — data migrations run after deploy
179
189
  - `belt doctor` — check system dependencies before deploying
@@ -0,0 +1,104 @@
1
+ # Data Migrations
2
+
3
+ `belt setup tables` handles the *shape* of a table (keys, GSIs). Data
4
+ migrations handle the *rows already in it*: backfilling a new attribute, or
5
+ populating a new index for records written before it existed.
6
+
7
+ They live in `db/migrate/`, run automatically after `belt deploy <env>`, and
8
+ each one runs **exactly once per environment** — prod, dev and every nested
9
+ (PR-preview) env keep their own history.
10
+
11
+ ## Writing a migration
12
+
13
+ One file per migration, named `<timestamp>_<snake_case_name>.rb`, defining a
14
+ `Belt::Migration` subclass named after the file:
15
+
16
+ ```ruby
17
+ # db/migrate/20261030120000_index_usernames.rb
18
+ class IndexUsernames < Belt::Migration
19
+ def up
20
+ PoshNosh::MatchStore.new.index_usernames!
21
+ say 'usernames indexed'
22
+ end
23
+ end
24
+ ```
25
+
26
+ `up` runs in the same booted app context as `belt console`
27
+ (`lambda/config/environment.rb`, or `lambda/lib` + `lambda/models`), with
28
+ `ENVIRONMENT` set to the target env, so models talk to that env's tables.
29
+ `say` prints an indented progress line.
30
+
31
+ Use `date -u +%Y%m%d%H%M%S` for the timestamp. Migrations run in timestamp
32
+ order.
33
+
34
+ ## Running them
35
+
36
+ ```bash
37
+ belt deploy dev # runs pending migrations after terraform apply
38
+ belt deploy dev --skip-migrations
39
+
40
+ belt db:migrate # dev, or $BELT_ENV if set
41
+ belt db:migrate dev01
42
+ belt db:migrate prod # prompts for confirmation (--yes to skip)
43
+ belt db:migrate:status prod # which versions are up / down
44
+ ```
45
+
46
+ ```
47
+ Status Migration ID Migration Name
48
+ --------------------------------------------------
49
+ up 20261030120000 Index usernames
50
+ down 20261102090000 Backfill display names
51
+ ```
52
+
53
+ During `belt deploy`, migrations run after terraform apply and the
54
+ nested-environment data copy (so rows copied from the parent get migrated
55
+ too), and before the frontend deploy. They also run after
56
+ `belt deploy --rebuild`. Nothing happens if `db/migrate/` is empty.
57
+
58
+ ## Where history is kept
59
+
60
+ Each applied version is an SSM parameter:
61
+
62
+ ```
63
+ /belt/<app>/<env>/schema_migrations/<version> → {"name": "...", "applied_at": "..."}
64
+ ```
65
+
66
+ It lives in SSM rather than a DynamoDB table so `belt db:copy` and the
67
+ nested-env copy (which move every `<app>-<env>-*` table) don't carry one env's
68
+ history into another. The deploying credentials need
69
+ `ssm:GetParametersByPath` and `ssm:PutParameter` on `/belt/<app>/*`.
70
+
71
+ To re-run a migration, delete its parameter:
72
+
73
+ ```bash
74
+ aws ssm delete-parameter --name /belt/myapp/dev/schema_migrations/20261030120000
75
+ ```
76
+
77
+ ## Failures
78
+
79
+ A version is recorded only after `up` returns. If one raises, it and every
80
+ later migration stay pending, and the command exits non-zero. In
81
+ `belt deploy` the infrastructure is already live, so the deploy carries on
82
+ (frontend included), prints a warning and exits 1 at the end. Fix the
83
+ migration and run `belt db:migrate <env>`.
84
+
85
+ ## Write them idempotent and batch-safe
86
+
87
+ Migrations run against live data, often while the previous code is still
88
+ serving requests:
89
+
90
+ - **Idempotent.** A migration that dies halfway is re-run from the start, and
91
+ two overlapping deploys can both run it. Only write rows that still need
92
+ the change, and make writes safe to repeat (set a value, don't increment).
93
+ - **Batch-safe.** A GSI created in the same deploy may still be backfilling,
94
+ so don't query the index you're populating — page through the base table
95
+ and write in small batches. Expect throttling on big tables.
96
+ - **Data only.** Keys, GSIs and table settings belong in Terraform
97
+ (`belt setup tables`). The deployed code should work both before and
98
+ after a migration runs.
99
+
100
+ ## See Also
101
+
102
+ - `belt explain data_seeding` — `db:copy` and `db:seed`
103
+ - `belt explain console` — the app-booting mechanism migrations reuse
104
+ - `belt explain deployment` — the full deploy lifecycle
@@ -45,6 +45,38 @@ Belt discovers plugins automatically when:
45
45
 
46
46
  Optional: `.destroy(args)` and `.description` for destroy path and help text.
47
47
 
48
+ ## Top-level Commands
49
+
50
+ A plugin can also add its own `belt <name> ...` command, the way `belt-connect`
51
+ adds `belt connect`:
52
+
53
+ 1. Gem has a file at `lib/belt/commands/<name>_command.rb`
54
+ 2. Class is `Belt::Commands::<Name>Command`
55
+ 3. Class implements `.run(args)` (required); `args` is everything after `<name>`
56
+
57
+ Optional: `.description` (shown under "Plugin Commands" in `belt --help`) and
58
+ `.standalone?` returning `true` to run outside a Belt project. Otherwise the
59
+ command runs from the project root, like built-ins.
60
+
61
+ Underscores in the file name become hyphens (`feature_flags_command.rb` →
62
+ `belt feature-flags`). Built-in commands and terraform actions win on a name
63
+ clash, so a plugin can't shadow `belt deploy`.
64
+
65
+ ```ruby
66
+ # lib/belt/commands/connect_command.rb
67
+ module Belt
68
+ module Commands
69
+ class ConnectCommand
70
+ def self.description = 'Connect widgets to your app'
71
+
72
+ def self.run(args)
73
+ # belt connect widget orders --env dev → args == ['widget', 'orders', '--env', 'dev']
74
+ end
75
+ end
76
+ end
77
+ end
78
+ ```
79
+
48
80
  ## Generator Checklist
49
81
 
50
82
  A good plugin generator typically installs:
@@ -71,6 +103,8 @@ belt-messaging/
71
103
  │ │ ├── version.rb
72
104
  │ │ ├── controllers/ # Default controllers
73
105
  │ │ └── templates/ # ERB templates for generator
106
+ │ ├── commands/
107
+ │ │ └── messaging_command.rb # ← optional `belt messaging`
74
108
  │ └── generators/
75
109
  │ └── messaging_generator.rb # ← auto-discovered
76
110
  └── spec/
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ # Base class for data migrations in `db/migrate/<timestamp>_<name>.rb`.
5
+ #
6
+ # # db/migrate/20261030120000_index_usernames.rb
7
+ # class IndexUsernames < Belt::Migration
8
+ # def up
9
+ # PoshNosh::MatchStore.new.index_usernames!
10
+ # end
11
+ # end
12
+ #
13
+ # `belt deploy <env>` runs pending migrations after terraform apply, and
14
+ # `belt db:migrate <env>` runs them by hand. Each version runs once per
15
+ # environment (nested envs included); applied versions are recorded in SSM.
16
+ #
17
+ # Migrations touch live data, possibly while a new GSI is still backfilling
18
+ # or the previous code is still serving requests, so `up` must be
19
+ # idempotent (safe to re-run after a partial failure) and batch-safe (page
20
+ # through the base table rather than querying a GSI it is populating).
21
+ class Migration
22
+ attr_reader :version, :name
23
+
24
+ def initialize(version:, name:)
25
+ @version = version
26
+ @name = name
27
+ end
28
+
29
+ def up
30
+ raise NotImplementedError, "#{self.class.name} must define #up"
31
+ end
32
+
33
+ def say(message)
34
+ puts " #{message}"
35
+ end
36
+ end
37
+ end
data/lib/belt/version.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.6.2'
4
+ VERSION = '0.6.4'
5
5
 
6
6
  # Ruby version new apps run on in Lambda. Rendered into the conveyor-belt
7
7
  # provider block (`ruby_version`) by `belt new` / `belt generate environment`.
@@ -137,6 +137,7 @@ belt output <env> # terraform output
137
137
  belt routes [-g PATTERN] [-f json] # Show route definitions
138
138
  belt contracts [-g PATTERN] [-f json] # Show API contracts
139
139
  belt console <env> # Interactive Ruby console
140
+ belt db:migrate <env> # Run pending db/migrate data migrations (deploy does this too)
140
141
  belt logs <lambda> [-f] [-s 5m] [-e env] # View Lambda logs
141
142
  belt doctor # Check dependencies
142
143
  ```
@@ -269,7 +270,8 @@ belt deploy <env> # Deploy everything (init → plan → apply)
269
270
  | `rails routes` | `belt routes` |
270
271
  | `rails console` | `belt console <env>` |
271
272
  | `config/routes.rb` → URL paths | `config/routes.rb` → URL paths + infrastructure |
272
- | Migrations | `belt setup tables <env>` |
273
+ | Schema migrations | `belt setup tables <env>` |
274
+ | Data migrations (`rails db:migrate`) | `db/migrate/*.rb` + `belt db:migrate <env>` (run by `belt deploy`) |
273
275
  | `has_many` / `belongs_to` | Manual (no joins in DynamoDB) |
274
276
  | Template rendering | JSON responses (default) |
275
277
 
data/references/cli.md CHANGED
@@ -21,13 +21,15 @@ default environment so you can omit the `<env>` argument.
21
21
  | `belt doctor` | Check system deps + AWS config. |
22
22
  | `belt plugin new <name>` | Scaffold a Belt plugin gem. |
23
23
  | `belt explain <topic>` | Explain a concept (see topic list below). |
24
- | `belt deploy [env] [--auto] [--skip-backup] [--backup-only]` | Deploy to AWS (init → plan → apply, runs backups first if configured). |
24
+ | `belt deploy [env] [--auto] [--skip-backup] [--backup-only] [--skip-migrations]` | Deploy to AWS (init → plan → apply, runs backups first if configured, data migrations after). |
25
25
  | `belt deploy frontend <env> [--frontend NAME]` | Build + deploy frontend(s). |
26
26
  | `belt dns <deploy\|add <env>\|show>` | Manage the root DNS zone. |
27
27
  | `belt frontend <env <env>\|list>` | Write `<frontend>/.env` from TF outputs, or list frontends. |
28
28
  | `belt server [--frontend NAME]` (alias `s`) | Start local dev server. |
29
29
  | `belt db:copy <from> <to> [--force]` | Copy DynamoDB data between environments. |
30
30
  | `belt db:seed [env] [--force]` | Run `config/seeds.rb` against an environment. |
31
+ | `belt db:migrate [env] [--yes]` | Run pending `db/migrate` data migrations (also runs after `belt deploy`). |
32
+ | `belt db:migrate:status [env]` | Show which data migrations have run against an environment. |
31
33
  | `belt version` | Show Belt version. |
32
34
 
33
35
  ### Terraform shorthand
@@ -64,6 +64,18 @@ belt db:seed [env] [--force] # run config/seeds.rb in the booted console con
64
64
  `db:copy` skips non-empty destination tables by default. `db:seed` refuses to
65
65
  run against an env that already has data unless `--force`.
66
66
 
67
+ ## Data migrations
68
+
69
+ ```bash
70
+ belt db:migrate [env] [--yes] # run pending db/migrate/<timestamp>_<name>.rb
71
+ belt db:migrate:status [env] # up/down per version
72
+ ```
73
+
74
+ `belt deploy` runs pending migrations after apply (`--skip-migrations` to
75
+ opt out). Each version runs once per env, recorded in SSM at
76
+ `/belt/<app>/<env>/schema_migrations/<version>`. Write them idempotent and
77
+ batch-safe: see `belt explain migrations`.
78
+
67
79
  ## Observability
68
80
 
69
81
  `Belt::LambdaHandler` wires these global facades automatically:
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -118,9 +118,11 @@ files:
118
118
  - lib/belt/cli/backup_runner.rb
119
119
  - lib/belt/cli/bucket_security.rb
120
120
  - lib/belt/cli/cognito_sharer.rb
121
+ - lib/belt/cli/command_registry.rb
121
122
  - lib/belt/cli/console_command.rb
122
123
  - lib/belt/cli/contracts_command.rb
123
124
  - lib/belt/cli/db_copy_command.rb
125
+ - lib/belt/cli/db_migrate_command.rb
124
126
  - lib/belt/cli/db_seed_command.rb
125
127
  - lib/belt/cli/deploy_command.rb
126
128
  - lib/belt/cli/destroy_command.rb
@@ -142,6 +144,7 @@ files:
142
144
  - lib/belt/cli/index_command.rb
143
145
  - lib/belt/cli/lambda_config_command.rb
144
146
  - lib/belt/cli/logs_command.rb
147
+ - lib/belt/cli/migration_store.rb
145
148
  - lib/belt/cli/nested_environment.rb
146
149
  - lib/belt/cli/new_command.rb
147
150
  - lib/belt/cli/path_gem_materializer.rb
@@ -168,6 +171,7 @@ files:
168
171
  - lib/belt/docs/frontend.md
169
172
  - lib/belt/docs/generators.md
170
173
  - lib/belt/docs/lambda_handler.md
174
+ - lib/belt/docs/migrations.md
171
175
  - lib/belt/docs/models.md
172
176
  - lib/belt/docs/observability.md
173
177
  - lib/belt/docs/plugins.md
@@ -181,6 +185,7 @@ files:
181
185
  - lib/belt/http_status.rb
182
186
  - lib/belt/inflector.rb
183
187
  - lib/belt/lambda_handler.rb
188
+ - lib/belt/migration.rb
184
189
  - lib/belt/observability.rb
185
190
  - lib/belt/parameters.rb
186
191
  - lib/belt/rendering.rb