belt 0.2.4 → 0.2.6

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.
@@ -39,8 +39,9 @@ module Belt
39
39
  lambda/models/<name>.rb Model with validations and fields
40
40
  lambda/controllers/<app>/<names>_controller.rb RESTful controller (index, show, create, update, destroy)
41
41
  config/routes.tf.rb Route entry added
42
- config/schema.tf.rb DynamoDB table schema added
42
+ config/schema.tf.rb API response contract added
43
43
  lambda/lib/routes/<app>_routes.rb Route manifest updated
44
+ infrastructure/modules/app/dynamodb.tf DynamoDB table generated
44
45
  frontend/src/pages/<names>/ React pages (if frontend exists)
45
46
 
46
47
  Alias: `belt g resource` works the same way.
@@ -58,7 +59,9 @@ module Belt
58
59
  ],
59
60
  notes: <<~NOTES
60
61
  Creates:
61
- lambda/models/<name>.rb Model class inheriting from ActiveItem::Base
62
+ lambda/models/<name>.rb Model class inheriting from ApplicationRecord
63
+ config/schema.tf.rb API response contract added
64
+ infrastructure/modules/app/dynamodb.tf DynamoDB table generated
62
65
  NOTES
63
66
  },
64
67
  'controller' => {
@@ -452,11 +455,12 @@ module Belt
452
455
 
453
456
  content = File.read(schema_file)
454
457
 
455
- field_lines = @fields.map { |f| " field :#{f[:name]}, type: :#{f[:type]}" }
456
- field_lines << ' field :created_at, type: :string'
457
- field_lines << ' field :updated_at, type: :string'
458
+ # Generate OpenAPI-style model block for API response contracts
459
+ response_fields = @fields.map { |f| " #{schema_type_for(f[:type])} :#{f[:name]}" }
460
+ response_fields << ' string :created_at'
461
+ response_fields << ' string :updated_at'
458
462
 
459
- schema_block = " model :#{@singular_name} do\n#{field_lines.join("\n")}\n end\n"
463
+ schema_block = " model :#{@singular_name} do\n#{response_fields.join("\n")}\n end\n"
460
464
 
461
465
  # If model already exists (force mode), replace it
462
466
  existing_model_pattern = /^ model :#{Regexp.escape(@singular_name)} do\n.*?^ end\n/m
@@ -474,6 +478,18 @@ module Belt
474
478
  puts " update #{schema_file}"
475
479
  end
476
480
 
481
+ # Map generator field types to OpenAPI schema DSL types
482
+ def schema_type_for(field_type)
483
+ case field_type.to_s
484
+ when 'text' then 'string'
485
+ when 'integer' then 'integer'
486
+ when 'float' then 'number'
487
+ when 'boolean' then 'boolean'
488
+ when 'date', 'datetime' then 'string'
489
+ else 'string'
490
+ end
491
+ end
492
+
477
493
  def sync_tables
478
494
  Belt::CLI::TablesCommand.sync_all_environments
479
495
  end
@@ -7,8 +7,8 @@ require_relative '../inflector'
7
7
  module Belt
8
8
  module CLI
9
9
  class TablesCommand
10
- SCHEMA_FILE_CANDIDATES = ['config/schema.tf.rb', 'infrastructure/schema.tf.rb'].freeze
11
10
  MODULE_DIR = 'infrastructure/modules/app'
11
+ MODELS_DIR = 'lambda/models'
12
12
 
13
13
  def self.run(args)
14
14
  # Environment argument accepted for backwards compatibility but unused —
@@ -19,26 +19,22 @@ module Belt
19
19
  end
20
20
 
21
21
  # Automatically sync dynamodb.tf in the app module.
22
- # Called by generators after updating schema.tf.rb.
22
+ # Called by generators after creating/updating model files.
23
23
  def self.sync_all_environments
24
- return unless schema_file_path
24
+ return unless Dir.exist?(MODELS_DIR)
25
25
 
26
26
  new(quiet: true).run
27
27
  end
28
28
 
29
- def self.schema_file_path
30
- SCHEMA_FILE_CANDIDATES.find { |f| File.exist?(f) }
31
- end
32
-
33
29
  def initialize(quiet: false)
34
30
  @quiet = quiet
35
31
  end
36
32
 
37
33
  def run
38
34
  validate!
39
- models = parse_schema
35
+ models = parse_models
40
36
  if models.empty?
41
- puts "No models found in #{SCHEMA_FILE}" unless @quiet
37
+ puts "No models found in #{MODELS_DIR}/" unless @quiet
42
38
  return
43
39
  end
44
40
 
@@ -48,11 +44,10 @@ module Belt
48
44
  private
49
45
 
50
46
  def validate!
51
- schema_file = self.class.schema_file_path
52
- unless schema_file
47
+ unless Dir.exist?(MODELS_DIR)
53
48
  unless @quiet
54
- abort 'Error: No schema.tf.rb found (checked config/ and infrastructure/). ' \
55
- 'Run `belt generate resource` first.'
49
+ abort "Error: No models directory found at #{MODELS_DIR}/. " \
50
+ 'Run `belt generate model` to create your first model.'
56
51
  end
57
52
  return
58
53
  end
@@ -64,21 +59,53 @@ module Belt
64
59
  'Run `belt new` to create a project with the correct structure.'
65
60
  end
66
61
 
67
- def parse_schema
68
- schema_file = self.class.schema_file_path
69
- return [] unless schema_file && File.exist?(schema_file)
62
+ def parse_models
63
+ model_files = Dir.glob(File.join(MODELS_DIR, '*.rb'))
64
+ .reject { |f| File.basename(f) == 'application_record.rb' }
65
+ .reject { |f| File.basename(f).start_with?('concerns') }
66
+ .sort
67
+
68
+ model_files.filter_map { |f| parse_model_file(f) }
69
+ end
70
+
71
+ # Parse a model file to extract its name and index declarations.
72
+ # Uses lightweight regex parsing — does NOT require loading activeitem or the model.
73
+ def parse_model_file(file_path)
74
+ content = File.read(file_path)
70
75
 
71
- parser = SchemaParser.new
72
- schema_content = File.read(schema_file)
76
+ # Extract class name from `class Foo < ApplicationRecord`
77
+ class_match = content.match(/^class\s+(\w+)\s*<\s*ApplicationRecord/)
78
+ return nil unless class_match
73
79
 
74
- # Replace DSL wrapper with direct parser call
75
- inner = schema_content.sub(/\A(?:Belt\.application)\.schema\.draw do\n?/, '').sub(/\n?end\s*\z/, '')
76
- inner.strip!
80
+ class_name = class_match[1]
81
+ model_name = Belt::Inflector.underscore(class_name)
77
82
 
78
- return [] if inner.empty? || inner.match?(/\A\s*\z/m)
83
+ # Extract indexes() declaration
84
+ indexes = extract_indexes(content)
79
85
 
80
- parser.instance_eval(inner, schema_file)
81
- parser.models
86
+ { name: model_name, indexes: indexes }
87
+ end
88
+
89
+ def extract_indexes(content)
90
+ indexes = []
91
+
92
+ # Match the indexes() DSL: indexes('Name' => { partition_key: 'pk' }, ...)
93
+ # This handles the Ruby hash syntax used by ActiveItem
94
+ index_block = content.match(/^\s*indexes\(\s*\n?(.*?)\n?\s*\)/m)
95
+ return indexes unless index_block
96
+
97
+ index_content = index_block[1]
98
+
99
+ # Parse each index entry: 'IndexName' => { partition_key: 'key', sort_key: 'key' }
100
+ index_content.scan(/'([^']+)'\s*=>\s*\{([^}]+)\}/) do |name, opts|
101
+ partition_key = opts.match(/partition_key:\s*'([^']+)'/)&.captures&.first
102
+ sort_key = opts.match(/sort_key:\s*'([^']+)'/)&.captures&.first
103
+ next unless partition_key
104
+
105
+ indexes << { name: name, partition_key: partition_key, sort_key: sort_key }
106
+ end
107
+
108
+ indexes
82
109
  end
83
110
 
84
111
  def generate_dynamodb_tf(models)
@@ -104,78 +131,94 @@ module Belt
104
131
 
105
132
  def render_dynamodb(models)
106
133
  blocks = models.map { |m| render_table(m) }
107
- "# Auto-generated by Belt from schema.tf.rb\n" \
134
+ "# Auto-generated by Belt from model definitions\n" \
108
135
  "# Do not edit manually — re-run `belt setup tables`\n\n#{blocks.join("\n\n")}\n"
109
136
  end
110
137
 
111
138
  def render_table(model)
112
139
  name = table_name(model[:name])
113
- <<~HCL
114
- resource "aws_dynamodb_table" "#{Belt::Inflector.pluralize(model[:name])}" {
115
- name = "#{name}"
116
- billing_mode = "PAY_PER_REQUEST"
117
- hash_key = "id"
118
-
119
- attribute {
120
- name = "id"
121
- type = "S"
122
- }
123
-
124
- attribute {
125
- name = "_recent_pk"
126
- type = "S"
127
- }
128
-
129
- attribute {
130
- name = "createdAt"
131
- type = "S"
132
- }
133
-
134
- global_secondary_index {
135
- name = "RecentIndex"
136
- hash_key = "_recent_pk"
137
- range_key = "createdAt"
138
- projection_type = "ALL"
139
- }
140
-
141
- tags = {
142
- Name = "#{name}"
143
- Environment = var.environment
144
- ManagedBy = "Terraform"
145
- }
146
- }
147
- HCL
148
- end
149
-
150
- def table_name(model_name)
151
- "${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
152
- end
153
-
154
- # Minimal DSL parser for schema.tf.rb
155
- class SchemaParser
156
- attr_reader :models
157
-
158
- def initialize
159
- @models = []
140
+ custom_indexes = model[:indexes] || []
141
+
142
+ # Collect all custom index attribute names, deduplicating against built-in ones
143
+ builtin_attrs = Set.new(%w[id _recent_pk createdAt])
144
+ extra_attrs = []
145
+ custom_indexes.each do |idx|
146
+ unless builtin_attrs.include?(idx[:partition_key])
147
+ extra_attrs << idx[:partition_key]
148
+ builtin_attrs.add(idx[:partition_key])
149
+ end
150
+ if idx[:sort_key] && !builtin_attrs.include?(idx[:sort_key])
151
+ extra_attrs << idx[:sort_key]
152
+ builtin_attrs.add(idx[:sort_key])
153
+ end
160
154
  end
161
155
 
162
- def model(name, &block)
163
- model_def = ModelParser.new
164
- model_def.instance_eval(&block) if block
165
- @models << { name: name.to_s, fields: model_def.fields }
156
+ lines = []
157
+ lines << "resource \"aws_dynamodb_table\" \"#{Belt::Inflector.pluralize(model[:name])}\" {"
158
+ lines << " name = \"#{name}\""
159
+ lines << ' billing_mode = "PAY_PER_REQUEST"'
160
+ lines << ' hash_key = "id"'
161
+ lines << ''
162
+ lines << ' attribute {'
163
+ lines << ' name = "id"'
164
+ lines << ' type = "S"'
165
+ lines << ' }'
166
+ lines << ''
167
+ lines << ' attribute {'
168
+ lines << ' name = "_recent_pk"'
169
+ lines << ' type = "S"'
170
+ lines << ' }'
171
+ lines << ''
172
+ lines << ' attribute {'
173
+ lines << ' name = "createdAt"'
174
+ lines << ' type = "S"'
175
+ lines << ' }'
176
+
177
+ extra_attrs.each do |attr|
178
+ lines << ''
179
+ lines << ' attribute {'
180
+ lines << " name = \"#{attr}\""
181
+ lines << ' type = "S"'
182
+ lines << ' }'
166
183
  end
167
- end
168
-
169
- class ModelParser
170
- attr_reader :fields
171
184
 
172
- def initialize
173
- @fields = []
185
+ lines << ''
186
+ lines << ' global_secondary_index {'
187
+ lines << ' name = "RecentIndex"'
188
+ lines << ' hash_key = "_recent_pk"'
189
+ lines << ' range_key = "createdAt"'
190
+ lines << ' projection_type = "ALL"'
191
+ lines << ' }'
192
+
193
+ custom_indexes.each do |idx|
194
+ lines << ''
195
+ lines << ' global_secondary_index {'
196
+ lines << " name = \"#{idx[:name]}\""
197
+ lines << " hash_key = \"#{idx[:partition_key]}\""
198
+ lines << " range_key = \"#{idx[:sort_key]}\"" if idx[:sort_key]
199
+ lines << ' projection_type = "ALL"'
200
+ lines << ' }'
174
201
  end
175
202
 
176
- def field(name, **opts)
177
- @fields << { name: name.to_s, type: opts[:type]&.to_s || 'string' }
178
- end
203
+ lines << ''
204
+ lines << ' point_in_time_recovery {'
205
+ lines << ' enabled = var.enable_pitr'
206
+ lines << ' }'
207
+ lines << ''
208
+ lines << ' deletion_protection_enabled = var.deletion_protection'
209
+ lines << ''
210
+ lines << ' tags = {'
211
+ lines << " Name = \"#{name}\""
212
+ lines << ' Environment = var.environment'
213
+ lines << ' ManagedBy = "Terraform"'
214
+ lines << ' }'
215
+ lines << '}'
216
+
217
+ lines.join("\n")
218
+ end
219
+
220
+ def table_name(model_name)
221
+ "${var.app_name}-${var.environment}-#{Belt::Inflector.pluralize(model_name)}"
179
222
  end
180
223
  end
181
224
  end
data/lib/belt/cli.rb CHANGED
@@ -14,6 +14,8 @@ require_relative 'cli/views_command'
14
14
  require_relative 'cli/setup_command'
15
15
  require_relative 'cli/terraform_command'
16
16
  require_relative 'cli/deploy_command'
17
+ require_relative 'cli/backup_config'
18
+ require_relative 'cli/backup_runner'
17
19
  require_relative 'cli/server_command'
18
20
  require_relative 'cli/routes_command'
19
21
  require_relative 'cli/lambda_config_command'
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.2.4'
4
+ VERSION = '0.2.6'
5
5
  end
@@ -39,6 +39,8 @@ module "app" {
39
39
  environment = var.environment
40
40
  aws_region = var.aws_region
41
41
  domain = var.domain
42
+ enable_pitr = var.enable_pitr
43
+ deletion_protection = var.deletion_protection
42
44
  frontend_urls = concat(
43
45
  var.domain != "" ? ["https://${var.environment == "prod" ? var.domain : "${var.environment}.${var.domain}"}"] : [],
44
46
  var.environment == "prod" ? [] : ["http://localhost:3000"]
@@ -4,3 +4,10 @@ domain = "<%= @domain %>"
4
4
  <% else -%>
5
5
  # domain = "myapp.com"
6
6
  <% end -%>
7
+
8
+ # DynamoDB protection (PITR is true by default everywhere)
9
+ <% if @env_name == 'prod' || @env_name == 'production' -%>
10
+ deletion_protection = true
11
+ <% else -%>
12
+ # deletion_protection = true # Enable in production environments
13
+ <% end -%>
@@ -20,3 +20,15 @@ variable "domain" {
20
20
  type = string
21
21
  default = ""
22
22
  }
23
+
24
+ variable "enable_pitr" {
25
+ description = "Enable DynamoDB Point-in-Time Recovery (PITR) for all tables"
26
+ type = bool
27
+ default = true
28
+ }
29
+
30
+ variable "deletion_protection" {
31
+ description = "Enable deletion protection on DynamoDB tables (prevents accidental terraform destroy)"
32
+ type = bool
33
+ default = false
34
+ }
@@ -37,3 +37,15 @@ variable "lambda_config" {
37
37
  type = any
38
38
  default = {}
39
39
  }
40
+
41
+ variable "enable_pitr" {
42
+ description = "Enable DynamoDB Point-in-Time Recovery (PITR) for all tables"
43
+ type = bool
44
+ default = true
45
+ }
46
+
47
+ variable "deletion_protection" {
48
+ description = "Enable deletion protection on DynamoDB tables (prevents accidental terraform destroy)"
49
+ type = bool
50
+ default = false
51
+ }
@@ -1,9 +1,22 @@
1
- Belt.application.schema.draw do
2
- # model :post do
3
- # field :title, type: :string, required: true
4
- # field :slug, type: :string, required: true
5
- # field :content, type: :string
6
- # field :status, type: :string, enum: %w[draft published]
7
- # field :published_at, type: :string
8
- # end
1
+ Belt.application.schema.define do
2
+ # API Schema Definitions — request/response contracts
3
+ #
4
+ # request :create_thing declares what an endpoint accepts
5
+ # model :thing — declares what an endpoint returns
6
+ #
7
+ # Example:
8
+ # request :create_post do
9
+ # string :title, required: true
10
+ # string :body, required: true
11
+ # string :status
12
+ # end
13
+ #
14
+ # model :post do
15
+ # string :id
16
+ # string :title
17
+ # string :body
18
+ # string :status
19
+ # string :created_at
20
+ # string :updated_at
21
+ # end
9
22
  end
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.2.4
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -70,6 +70,8 @@ files:
70
70
  - lib/belt/assets/welcome.css
71
71
  - lib/belt/cli.rb
72
72
  - lib/belt/cli/app_detection.rb
73
+ - lib/belt/cli/backup_config.rb
74
+ - lib/belt/cli/backup_runner.rb
73
75
  - lib/belt/cli/bucket_security.rb
74
76
  - lib/belt/cli/console_command.rb
75
77
  - lib/belt/cli/deploy_command.rb