dami 1.0.0 → 1.0.1

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: 0d27e7d5a72541d63d026ed24e418d0acc195bf33d724f6086afcfece355998b
4
- data.tar.gz: eadb86a3f0a4aae782971785f3c180a532d30561b82fe23f43d8fe236d0724af
3
+ metadata.gz: 5e550fe4dbb3da8996d88b0825c92f2208cb60bdf5c6d389b610de8d65d22091
4
+ data.tar.gz: 35464adbc1ab5319602ef98276c1a6002cba6fbc313ae9c3e2288b970d08f0ab
5
5
  SHA512:
6
- metadata.gz: 4a636d87228fb6e1ad6d8b83d7c101bec962423647cf096650b5026e9242776005c64287ad19817ea9a0474b64227a5282d290ff15aa802ea555c5784ffdafd4
7
- data.tar.gz: a2cbe7c1417947fdc97876875de4aafba02a6cf326ff3a429f11feb7ba48db3908a33208a12c763160fe7d2c61334e22a63aaaf67bdfd85d1b1afe52682b245b
6
+ metadata.gz: ff82a43bd0a072525a74339037cb84525034dd16c6c13279971db061d51556a28e790d3cfe1e0b64c3e79d64a56e300c1f44418c8b741a787c880a140a73502c
7
+ data.tar.gz: 3fad29facc198fea952c6e79f7917af916104c0e7fac41ccb7971225083fb6a96a8cc2574e4349877d76c1e81e5127b80f7234efa2026d7a88a27c69ad761f68
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.1] - 2026-09-13
4
+
5
+ Found while building the first app on 1.0.0 (a finance app on aris + dami, 98 tests), then a full read of the docs against the source.
6
+
7
+ ### 🐛 Correctness
8
+
9
+ * **Text arriving as binary is stored as text.** Rack hands a web app its params as ASCII-8BIT strings, and the sqlite3 gem binds a binary string as a BLOB, which never equals the TEXT it looks like: the row is written, then `where(name: params['name'])` finds nothing. A binary string whose bytes are valid UTF-8 is now bound as text on every path (`execute`, `get_first_row`, prepared statements, inserts). Bytes that are not valid UTF-8 (an image, a PDF) still go in as a BLOB.
10
+ * **`Migration#execute` exists.** The docs listed it; the class did not define it. `execute(sql, params = [])` runs raw SQL inside the migration's transaction: triggers, views, PRAGMAs, data fixes.
11
+ * **`db(:model)` inside `Command` validations and `Draft` blocks.** The docs' `verify :name_is_unique` examples called `db(...)` from inside a block that is `instance_exec`'d on the draft or command, where no `db` existed. Both have it now.
12
+ * **`none?` on a query**, the opposite of `any?`. The docs used it; the builder did not have it.
13
+
14
+ ### 📚 Docs
15
+
16
+ * Chapter 10 (Drafts, Flows & Commands) now shows the real API: `Dami.flow :name do ... end` + `Dami.run(:name, **params)` (the chapter showed a `Dami.run(context: ...) do |run_context|` form that never existed), `draft.transform` / `draft.apply` (not `draft.prepare`), `perform ..., retry_options: { on:, times: }` (not `retry:`), `and_then:` takes callables, results are `Success`/`Failure` with `value` / `error`. Draft blocks are on `create` and `update`, not `delete`. The flow snippets in `Manifesto.md` and `site.md` showed a `step` DSL that does not exist; they use the real one now.
17
+ * Chapter 12 (The Dami Way): an AI reply that had been pasted in above the chapter ("Absolutely. This is the perfect way to end the documentation…") is gone, together with the ````markdown fence that rendered the chapter's opening as a code block. Its examples use `protection` (there is no `before(:save)`) and the real flow API.
18
+ * Chapter 5: `when:` is equality only; use `if:` for anything else. Chapter 9: `execute` signature. Chapter 3: `none?`. Chapter 8/9: the link to chapter 10 names the right chapter.
19
+ * Stray "THE FIX:" / "no changes needed" comments left by AI-assisted editing removed from `lib/` and `test/`. One of them was an empty duplicate `test_create_table_and_reverse` that shadowed the real test; the real one runs again.
20
+
21
+ ### ✅ Tests
22
+
23
+ 284 tests. New: `test/encoding_test.rb` (binary text stored as TEXT and matching in `where`; non-UTF-8 bytes stay a BLOB), `Migration#execute` with params and a trigger, `db` from a draft and from a command validation.
24
+
3
25
  ## [1.0.0] - 2026-09-12 (pre-publish review pass)
4
26
 
5
27
  Fixes applied after a full review of the 1.0.0 working tree, before the first
data/docs/03.Querying.md CHANGED
@@ -197,6 +197,11 @@ if Dami.db(:users).where(status: 'pending').any?
197
197
  puts "There are pending users to review."
198
198
  end
199
199
 
200
+ # `none?` is the opposite
201
+ if Dami.db(:users).where(email: email).none?
202
+ puts "That email is free."
203
+ end
204
+
200
205
  # `exists?` is a convenient alias
201
206
  if Dami.db(:users).exists?(status: 'pending')
202
207
  puts "There are pending users to review."
@@ -229,7 +234,7 @@ Queries are **lazy**. They are only sent to the database when you ask for the da
229
234
  - `.first`
230
235
  - `.last`
231
236
  - `.count`
232
- - `.any?` / `.exists?`
237
+ - `.any?` / `.exists?` / `.none?`
233
238
  - `.each`
234
239
  - `.find_each`
235
240
  - `.find_in_batches`
@@ -372,6 +377,7 @@ A concise summary of the Dami query DSL.
372
377
  | `.each { \|rec\| ... }` | Executes the query and yields each record. |
373
378
  | `.count` | Executes a `COUNT` query for the conditions. |
374
379
  | `.any?`, `.exists?` | Executes a `LIMIT 1` query to check for existence. |
380
+ | `.none?` | The opposite of `.any?`. |
375
381
 
376
382
  ### Batch Processing
377
383
 
@@ -168,7 +168,7 @@ Dami.model :comments do
168
168
  fields { field :post_id, :integer; field :content, :text }
169
169
  relationships { belongs_to :post }
170
170
  end
171
- ````
171
+ ```
172
172
 
173
173
  The `allow_destroy: true` option is required if you want to allow nested records to be deleted.
174
174
 
@@ -190,7 +190,7 @@ end
190
190
 
191
191
  ### Using `when:`
192
192
 
193
- For simple equality checks, `when:` provides a convenient shortcut. The rule only runs if the attributes in the `when` hash match the record's attributes.
193
+ For simple equality checks, `when:` provides a convenient shortcut. The rule only runs if every attribute in the `when` hash equals the value being saved. It is equality only; anything more (a range, a regex, another record) is an `if:` lambda.
194
194
 
195
195
  ```ruby
196
196
  Dami.behavior :orders do
data/docs/08.Scopes.md CHANGED
@@ -96,4 +96,4 @@ Scopes are a fundamental tool for writing clean and maintainable data-access cod
96
96
  ## What's Next?
97
97
 
98
98
  * **Chapter 9: Migrations** - Learn how to manage your database schema over time with versioned changes.
99
- * **Chapter 10: Advanced Topics** - Explore transactions, flows, and other powerful features.
99
+ * **Chapter 10: Drafts, Flows & Commands** - Business logic: inline drafts, commands, and transactional flows.
@@ -99,7 +99,7 @@ The DSL provides simple methods for all common schema operations.
99
99
  | `remove_column` | `remove_column(:users, :status)` |
100
100
  | `add_index` | `add_index(:posts, :user_id)` / `add_index(:users, :email, unique: true)` |
101
101
  | `remove_index` | `remove_index(:posts, :user_id)` |
102
- | `execute(sql)` | Runs a raw SQL command for special cases. |
102
+ | `execute(sql, params = [])` | Raw SQL for what the DSL does not cover: `execute("CREATE TRIGGER ...")`, `execute("UPDATE users SET status = ? WHERE status IS NULL", ['active'])` |
103
103
 
104
104
  ### Column options
105
105
 
@@ -162,4 +162,4 @@ This file is a critical part of the generator. It's an auto-generated snapshot o
162
162
 
163
163
  ## What's Next?
164
164
 
165
- * **Chapter 10: Advanced Topics** - Explore transactions, flows, and other powerful features for building complex application logic.
165
+ * **Chapter 10: Drafts, Flows & Commands** - Business logic: inline drafts, commands, and transactional flows.
@@ -1,4 +1,4 @@
1
- ## Chapter 10: Drafts, Flows & Commands
1
+ # Chapter 10: Drafts, Flows & Commands
2
2
 
3
3
  In most frameworks, handling business logic is an afterthought. It gets mixed into models, controllers, or scattered across service objects. Dami treats business logic as a first-class citizen with a simple, powerful philosophy: the **"Spectrum of Complexity."**
4
4
 
@@ -36,26 +36,27 @@ This is the baseline. It's clean, performant, and the right tool for simple jobs
36
36
 
37
37
  **When to use it:** For the 80% of typical web controller actions. A user updates their profile, submits a form, or changes a setting. The input is untrusted and requires contextual validation and transformation.
38
38
 
39
- For these tasks, Dami provides an **inline `draft` block** on `create`, `update`, and `delete`. This is a massive leap in power with minimal ceremony. It gives you a temporary `draft` object to prepare and verify your data within a single, atomic transaction.
39
+ For these tasks, Dami provides an **inline `draft` block** on `create` and `update`. This is a massive leap in power with minimal ceremony. It gives you a temporary `draft` object to prepare and verify your data before anything is written.
40
40
 
41
41
  ```ruby
42
- # app/controllers/accounts_controller.rb
42
+ # In a request handler
43
43
  def update
44
44
  # ...
45
- # The block yields a `draft` object. The entire block is one transaction.
45
+ # The block yields a `draft` object. It runs first; only if it passes does
46
+ # the write happen, in one transaction.
46
47
  updated_account = db(:accounts).where(id: params[:id]).update(params) do |draft|
47
48
  # --- The Inline Draft DSL ---
48
49
 
49
50
  # 1. Use declarative helpers for common patterns.
50
51
  draft.prevent_changes :subdomain, message: "Subdomain cannot be changed."
51
-
52
- # 2. Apply reusable transformations.
53
- draft.prepare(AccountPolicies::NORMALIZE_NAME)
54
52
 
55
- # 3. Add ad-hoc, contextual validation.
53
+ # 2. Transform the data. The block returns the new data hash.
54
+ draft.transform(:normalize_name) { |data| data.merge(name: data[:name].strip) }
55
+
56
+ # 3. Add ad-hoc, contextual validation. `db`, `get`, `changed?` and
57
+ # `original` are available inside the block.
56
58
  draft.verify :name_is_unique, error: "This name is already taken." do
57
- # `changed?` and `original` are available helpers.
58
- !draft.changed?(:name) || db(:accounts).where(name: draft.get(:name)).none?
59
+ !changed?(:name) || db(:accounts).where(name: get(:name)).none?
59
60
  end
60
61
  end
61
62
 
@@ -70,6 +71,10 @@ def update
70
71
  end
71
72
  ```
72
73
 
74
+ A reusable transformation is a callable that receives the draft: `draft.apply(AccountPolicies::NORMALIZE_NAME)` with `NORMALIZE_NAME = ->(d) { d.transform(:normalize_name) { |data| data.merge(name: data[:name].strip) } }`.
75
+
76
+ `create` with a failing draft raises `Dami::ValidationError` (there is no record to return); `update` returns `nil`.
77
+
73
78
  This is the workhorse of your application. It provides contextual validation and transformation right where it's needed, using a consistent, declarative DSL.
74
79
 
75
80
  -----
@@ -78,7 +83,7 @@ This is the workhorse of your application. It provides contextual validation and
78
83
 
79
84
  **When to use it:** For the 20% of critical, multi-step business processes. A user signup, an e-commerce checkout, a subscription cancellation. These workflows involve multiple database tables, external API calls, and side effects that **cannot be rolled back**.
80
85
 
81
- For these tasks, Dami provides two powerful tools: **Commands** and **`Dami.run`**.
86
+ For these tasks, Dami provides two powerful tools: **Commands** and **Flows**.
82
87
 
83
88
  #### The Command (The Brains 🧠)
84
89
 
@@ -87,10 +92,10 @@ A `Dami::Command` encapsulates complex validation and transformation logic into
87
92
  ```ruby
88
93
  # app/commands/create_account_and_owner_command.rb
89
94
  class CreateAccountAndOwnerCommand < Dami::Command
90
- # Fails early if `signup_source` is not provided in the context.
95
+ # Fails early if `signup_source` is not in the context.
91
96
  requires_context :signup_source
92
97
 
93
- # Declarative validation rules for the incoming data.
98
+ # Declarative validation rules for the incoming data. `db` is available.
94
99
  validate :account_name_is_unique, error: "Account name is taken" do
95
100
  db(:accounts).where(name: data[:account][:name]).none?
96
101
  end
@@ -99,53 +104,70 @@ class CreateAccountAndOwnerCommand < Dami::Command
99
104
  data[:user][:password] == data[:user][:password_confirmation]
100
105
  end
101
106
 
102
- # A data transformation step.
107
+ # A data transformation step. It returns the changes, merged into the data.
103
108
  transform :prepare_records do |data|
104
- data[:user][:password_hash] = BCrypt::Password.create(data[:user][:password])
105
- data[:account][:subdomain] = data[:account][:name].downcase.strip.gsub(/\s+/, '-')
106
- data
109
+ {
110
+ user: data[:user].merge(password_hash: BCrypt::Password.create(data[:user][:password])),
111
+ account: data[:account].merge(subdomain: data[:account][:name].downcase.strip.gsub(/\s+/, '-')),
112
+ }
107
113
  end
108
114
  end
109
115
  ```
110
116
 
111
- #### The Orchestrator (`Dami.run`)
117
+ A command can also be run on its own: `CreateAccountAndOwnerCommand.new(data: attrs, context: { signup_source: 'web' }).call` returns the command, with `valid?`, `errors` and the transformed `data`.
112
118
 
113
- This is the final boss. It's a top-to-bottom, transaction-safe orchestrator for the entire business process. It reads like a clear, imperative script.
119
+ #### The Orchestrator (`Dami.flow` + `Dami.run`)
120
+
121
+ This is the final boss. A flow is a top-to-bottom, transaction-safe orchestrator for the entire business process. It reads like a clear, imperative script.
114
122
 
115
123
  ```ruby
116
- # app/flows/onboarding_flow.rb
117
- Dami.run(context: { signup_source: 'web' }) do |run_context|
118
- # 1. PREPARE the data. `prepare` uses the command to validate and transform.
119
- # It halts automatically if validation fails.
120
- clean_data = prepare CreateAccountAndOwnerCommand, with: params, context: run_context
124
+ # app/flows/onboarding.rb
125
+ Dami.flow :onboard_team do
126
+ # 1. PREPARE the data. `prepare` runs the command on `params`, with everything
127
+ # passed to Dami.run as the command's context. It halts if validation fails.
128
+ clean = prepare CreateAccountAndOwnerCommand, with: params
121
129
 
122
- # 2. Interact with the DB. These commands are now part of the `run` block's
123
- # single, atomic transaction.
124
- account = db(:accounts).create(clean_data[:account])
125
- user = db(:users).create(clean_data[:user].merge(account_id: account[:id]))
130
+ # 2. Interact with the DB. Every write in the flow is part of one atomic transaction.
131
+ account = db(:accounts).create(clean[:account])
132
+ user = db(:users).create(clean[:user].merge(account_id: account[:id]))
126
133
 
127
134
  # 3. Perform an external task with resilience.
128
- perform "Create Billing Trial", retry: { on: [Billing::ApiError], times: 3 } do
135
+ perform "Create Billing Trial", retry_options: { on: [Billing::ApiError], times: 3 } do
129
136
  BillingService.start_trial(account_id: account[:id], user_email: user[:email])
130
137
  end
131
138
 
132
- # 4. SUCCEED. The final, declarative exit point for the entire orchestration.
139
+ # 4. SUCCEED. The final, declarative exit point. `and_then:` takes callables;
140
+ # they run only after the transaction has committed.
133
141
  succeed with: { account: account, user: user }, and_then: [
134
- WelcomeEmailJob.with(user_id: user.id),
135
- Analytics.track("Team Onboarded", account_id: account.id)
142
+ -> { WelcomeEmailJob.perform_later(user_id: user[:id]) },
143
+ -> { Analytics.track("Team Onboarded", account_id: account[:id]) }
136
144
  ]
137
145
  end
138
146
  ```
139
147
 
148
+ Run it from anywhere. Keyword arguments become the flow's params, each readable by name inside the flow (`params`, `signup_source`):
149
+
150
+ ```ruby
151
+ result = Dami.run(:onboard_team, params: form_params, signup_source: 'web')
152
+
153
+ if result.success?
154
+ result.value[:account] # whatever `succeed with:` was given
155
+ else
156
+ result.error # the command's errors, e.g. { account_name_is_unique: ["Account name is taken"] }
157
+ end
158
+ ```
159
+
160
+ A flow can call another flow with `run(:other_flow, extra: 'params')`; the inner flow runs in a savepoint and returns its own `Success` or `Failure`. `halt(value)` leaves a flow early with any value.
161
+
140
162
  -----
141
163
 
142
164
  ### **The Dami Guarantee: Atomic Operations & Safe Side Effects**
143
165
 
144
- You might be asking: "Why do I need `succeed with: and_then:`? Can't I just put the email job in a `call` block?" This is where Dami's most powerful feature comes into play.
166
+ You might be asking: "Why do I need `succeed with: and_then:`? Can't I just put the email job in a `perform` block?" This is where Dami's most powerful feature comes into play.
145
167
 
146
168
  #### The Problem: The "Email Sent, User Never Created" Bug
147
169
 
148
- A `Dami.run` block guarantees that all **database operations** are atomic—they all succeed, or they all roll back. That includes a `Failure` halt from `prepare`: every write the flow made before the halt is rolled back. A flow that calls `run(:other_flow)` runs the inner flow in a savepoint, so an inner failure rolls back only the inner writes and hands the `Failure` back to the outer flow. But what about actions that can't be rolled back, like sending an email or enqueuing a background job?
170
+ A flow run with `Dami.run` guarantees that all **database operations** are atomic—they all succeed, or they all roll back. That includes a `Failure` halt from `prepare`: every write the flow made before the halt is rolled back. A flow that calls `run(:other_flow)` runs the inner flow in a savepoint, so an inner failure rolls back only the inner writes and hands the `Failure` back to the outer flow. But what about actions that can't be rolled back, like sending an email or enqueuing a background job?
149
171
 
150
172
  **Consider this dangerous sequence of events:**
151
173
 
@@ -165,7 +187,7 @@ The `succeed with: and_then:` block is not just another step. It is a **transact
165
187
  1. A user is created in the database transaction.
166
188
  2. The call to the `BillingService` succeeds.
167
189
  3. The `succeed with:` block is reached. The `WelcomeEmailJob` is placed in the holding area.
168
- 4. The `Dami.run` block finishes. Dami commits the database transaction.
190
+ 4. The flow finishes. Dami commits the database transaction.
169
191
  5. **Only now**, after the commit is successful, does Dami execute the actions in the `and_then:` block.
170
192
 
171
193
  If the `BillingService` had failed, the transaction would have rolled back, and the `and_then:` block would have been discarded. No email would ever be sent.
@@ -176,6 +198,6 @@ If the `BillingService` had failed, the transaction would have rolled back, and
176
198
 
177
199
  * **The Problem: Callback Hell & Fat Models.** In Rails, a complex process like this is often a chain of `after_save` hooks. An `Account` model might trigger a user creation, which in turn triggers a welcome email. This chain of hidden actions is brittle and incredibly hard to debug. If an API call to the `BillingService` fails, the `Account` and `User` records are already in the database, leaving your system in an inconsistent, half-finished state.
178
200
 
179
- * **The Dami Solution: An Explicit, Transactional Orchestrator.** `Dami.run` makes the entire business process visible and atomic. The "semantic trio" of `prepare`, `db()`, and `call` makes the intent of each line instantly clear. If the `call` to the `BillingService` fails, the entire transaction is **automatically rolled back**, and the `Account` and `User` records are never saved. The `succeed with: and_then:` block guarantees that side effects like sending an email happen **only after** everything has been successfully committed to the database, eliminating an entire class of common bugs.
201
+ * **The Dami Solution: An Explicit, Transactional Orchestrator.** `Dami.run` makes the entire business process visible and atomic. The "semantic trio" of `prepare`, `db()`, and `perform` makes the intent of each line instantly clear. If the `perform` step calling the `BillingService` fails, the entire transaction is **automatically rolled back**, and the `Account` and `User` records are never saved. The `succeed with: and_then:` block guarantees that side effects like sending an email happen **only after** everything has been successfully committed to the database, eliminating an entire class of common bugs.
180
202
 
181
203
  This is the pinnacle of the Dami philosophy: a system that scales from the simplicity of a one-liner to the robust, transactional, and resilient power needed for mission-critical business logic, all while maintaining a consistent and joyful developer experience.
@@ -28,7 +28,7 @@ Dami.localize :validations do
28
28
  set :min_length, ->(min) { "es demasiado corto (mínimo: #{min} caracteres)" }
29
29
  end
30
30
  end
31
- ````
31
+ ```
32
32
 
33
33
  This approach is powerful because it's pure Ruby. You can load these definitions from anywhere, and it's completely explicit—no "magic" `method_missing`.
34
34
 
@@ -1,12 +1,3 @@
1
- Absolutely. This is the perfect way to end the documentation—a confident, opinionated "manifesto" that clearly articulates Dami's superior design philosophy. Your draft is a great start, and we can definitely crank up the energy to get developers hyped.
2
-
3
- You're right, the draft is missing the knockout punches about validations and other key differentiators. Let's upgrade this from a simple comparison to a powerful final statement.
4
-
5
- Here is the revised, high-energy final chapter.
6
-
7
- -----
8
-
9
- ````markdown
10
1
  # Chapter 12: The Dami Way - A Manifesto
11
2
 
12
3
  You've learned the "what" of Dami's features. This final chapter is about the "why." It's a declaration of the principles that make Dami a fundamentally better way to build modern, maintainable applications.
@@ -43,7 +34,7 @@ class User < ApplicationRecord
43
34
  end
44
35
  # ... 500 more lines of tangled logic ...
45
36
  end
46
- ````
37
+ ```
47
38
 
48
39
  #### **The Dami Way: The Four Pillars of Clarity**
49
40
 
@@ -56,7 +47,7 @@ Dami makes this architectural mess impossible **by design**. It provides four di
56
47
  Dami.model(:users) { fields { ... }; relationships { ... } }
57
48
 
58
49
  # --- 2. Behavior (The Internal Rules) ---
59
- Dami.behavior(:users) { validate { ... }; before(:save) { ... } }
50
+ Dami.behavior(:users) { validate { ... }; protection { ... } }
60
51
 
61
52
  # --- 3. Presentation (The Public Interface) ---
62
53
  Dami.present(:users) { def full_name; ...; end }
@@ -150,13 +141,13 @@ Dami's **Flows** make the entire business process visible, atomic, and safe. It
150
141
 
151
142
  ```ruby
152
143
  # The Dami Way: An Explicit, Readable, and Resilient Script
153
- Dami.run(:user_signup_flow) do
144
+ Dami.flow :user_signup do
154
145
  # 1. Validate and prepare all incoming data
155
146
  clean_data = prepare CreateUserCommand, with: params
156
147
 
157
148
  # 2. Run all database operations inside a single, atomic transaction
158
- user = db(:users).create(clean_data[:user])
159
- db(:teams).default.add_member(user)
149
+ user = db(:users).create(clean_data)
150
+ db(:memberships).create(user_id: user[:id], team_id: default_team_id)
160
151
 
161
152
  # 3. Perform external actions with built-in resilience
162
153
  perform "Sync to CRM", retry_options: { on: [Crm::ApiError], times: 2 } do
@@ -165,9 +156,11 @@ Dami.run(:user_signup_flow) do
165
156
 
166
157
  # 4. Safely enqueue side effects ONLY AFTER the transaction commits
167
158
  succeed with: user, and_then: [
168
- WelcomeEmailJob.with(user_id: user[:id])
159
+ -> { WelcomeEmailJob.perform_later(user_id: user[:id]) }
169
160
  ]
170
161
  end
162
+
163
+ result = Dami.run(:user_signup, params: form_params, default_team_id: 1)
171
164
  ```
172
165
 
173
166
  ### **The Shootout: Why Dami Wins Decisively**
data/docs/Manifesto.md CHANGED
@@ -139,30 +139,24 @@ end
139
139
  Dami replaces this invisible chaos with **explicit, readable, transactional workflows**:
140
140
 
141
141
  ```ruby
142
- Dami.flow :user_onboarding do |params|
143
- step :create_user do
144
- user = db[:users].create(params)
145
- set(:user, user)
146
- end
147
-
148
- step :send_welcome_email do
149
- UserMailer.welcome(get(:user)[:email]).deliver
150
- end
151
-
152
- step :track_analytics do
153
- Analytics.track('signup', user_id: get(:user)[:id])
154
- end
155
-
156
- step :activate_subscription do
157
- Stripe.create_subscription(customer: get(:user)[:email])
142
+ Dami.flow :user_onboarding do
143
+ user = db(:users).create(params)
144
+
145
+ perform "Activate subscription", retry_options: { on: [Stripe::APIError], times: 2 } do
146
+ Stripe.create_subscription(customer: user[:email])
158
147
  end
148
+
149
+ succeed with: user, and_then: [
150
+ -> { UserMailer.welcome(user[:email]).deliver },
151
+ -> { Analytics.track('signup', user_id: user[:id]) }
152
+ ]
159
153
  end
160
154
 
161
155
  # Execute the entire workflow
162
- Dami.run(:user_onboarding, name: 'Alice', email: 'alice@example.com')
156
+ Dami.run(:user_onboarding, params: { name: 'Alice', email: 'alice@example.com' })
163
157
  ```
164
158
 
165
- What happens if step 3 fails? **The entire transaction rolls back.** Your database stays consistent. Your user doesn't get created. No orphaned records. No mystery states.
159
+ What happens if Stripe fails? **The entire transaction rolls back.** Your database stays consistent. Your user doesn't get created. No orphaned records. No mystery states.
166
160
 
167
161
  This is the **Dami Guarantee**: atomic workflows or nothing.
168
162
 
data/docs/site.md CHANGED
@@ -137,11 +137,13 @@ Dami compares model to schema. Writes perfect, reversible migrations. **The tedi
137
137
  Rails callbacks scatter logic across time and space. Dami makes business logic **explicit and atomic:**
138
138
 
139
139
  ```ruby
140
- Dami.flow :user_onboarding do |params|
141
- step :create_user { db[:users].create(params) }
142
- step :send_email { Mailer.welcome(user) }
143
- step :track_event { Analytics.track('signup') }
144
- step :sync_crm { CRM.sync(user) }
140
+ Dami.flow :user_onboarding do
141
+ user = db(:users).create(params)
142
+ perform("Sync CRM") { CRM.sync(user) }
143
+ succeed with: user, and_then: [
144
+ -> { Mailer.welcome(user) },
145
+ -> { Analytics.track('signup') }
146
+ ]
145
147
  end
146
148
  ```
147
149
 
@@ -315,10 +317,12 @@ Dami.behavior :users do
315
317
  end
316
318
 
317
319
  # Flow
318
- Dami.flow :signup do |params|
319
- step(:create) { db[:users].create(params) }
320
- step(:email) { Mailer.welcome(user) }
321
- step(:track) { Analytics.track('signup') }
320
+ Dami.flow :signup do
321
+ user = db(:users).create(params)
322
+ succeed with: user, and_then: [
323
+ -> { Mailer.welcome(user) },
324
+ -> { Analytics.track('signup') }
325
+ ]
322
326
  end
323
327
  ```
324
328
 
@@ -44,6 +44,10 @@ module Dami
44
44
  sym_field = field.to_sym
45
45
  @data.key?(sym_field) && @original[sym_field] != @data[sym_field]
46
46
  end
47
+ # The database, so a validation can check it: db(:accounts).where(name: data[:name]).none?
48
+ def db(model_name)
49
+ Dami.db(model_name)
50
+ end
47
51
  private
48
52
  def validate_required_context!
49
53
  missing = (self.class.instance_variable_get(:@required_context) || []) - @context.keys
@@ -66,11 +70,10 @@ module Dami
66
70
  end
67
71
  end
68
72
  end
69
- def run_transformations
73
+ def run_transformations
70
74
  current_data = @data.dup
71
75
  (self.class.instance_variable_get(:@transformations) || []).each do |t|
72
- # THE FIX: Each transform block returns a hash of changes.
73
- # We must MERGE these changes into the current data hash.
76
+ # Each transform block returns a hash of changes, merged into the data.
74
77
  result = instance_exec(current_data, &t[:block])
75
78
  current_data.merge!(result) if result.is_a?(Hash)
76
79
  end
@@ -15,20 +15,19 @@ module Dami
15
15
  halt(Dami::Failure.new(command.errors)) unless command.valid?
16
16
  command.data
17
17
  end
18
- # THE FIX IS HERE: Renamed 'retry:' to 'retry_options:'
18
+ # A step that talks to the outside world. retry_options: { on: [SomeError], times: 2 }
19
+ # retries that many times on those exceptions; timeout: is in seconds.
19
20
  def perform(name, retry_options: {}, timeout: nil, &block)
20
21
  action = -> do
21
- # THE FIX: Use the new parameter name 'retry_options'
22
22
  retry_exceptions = Array(retry_options[:on])
23
- # Add 1 for the initial attempt.
24
- max_attempts = (retry_options[:times] || 0) + 1
23
+ max_attempts = (retry_options[:times] || 0) + 1 # the first attempt plus the retries
25
24
  attempts = 0
26
25
  begin
27
26
  attempts += 1
28
27
  block.call
29
28
  rescue *retry_exceptions => e
30
29
  raise if attempts >= max_attempts
31
- retry # This 'retry' keyword is correct because it's inside the rescue block
30
+ retry
32
31
  end
33
32
  end
34
33
  if timeout
@@ -32,5 +32,9 @@ module Dami
32
32
  def valid?
33
33
  @errors.empty?
34
34
  end
35
+ # The database, so a verify block can check it: db(:accounts).where(name: get(:name)).none?
36
+ def db(model_name)
37
+ Dami.db(model_name)
38
+ end
35
39
  end
36
40
  end
@@ -17,7 +17,6 @@ module Dami
17
17
  @after_commit_hooks = []
18
18
  end
19
19
 
20
- # Fix: Ensure the method accepts the context parameter
21
20
  def call(context)
22
21
  context.instance_exec(&@block)
23
22
  rescue => e
@@ -87,6 +87,7 @@ module SqliteConnection
87
87
 
88
88
  def convert_value(value)
89
89
  case value
90
+ when String then text_or_blob(value)
90
91
  when Time then value.utc.strftime('%Y-%m-%d %H:%M:%S')
91
92
  when DateTime then value.to_time.utc.strftime('%Y-%m-%d %H:%M:%S')
92
93
  when Date then value.to_s
@@ -96,6 +97,17 @@ module SqliteConnection
96
97
  end
97
98
  end
98
99
 
100
+ # Rack hands a web app its params as ASCII-8BIT strings, and the sqlite3 gem
101
+ # binds a binary string as a BLOB, which never equals the TEXT it looks like:
102
+ # the row is written, then `where(name: params['name'])` finds nothing. A
103
+ # binary string whose bytes are valid UTF-8 is text, so it is bound as text.
104
+ # Bytes that are not valid UTF-8 (an image, a PDF) still go in as a BLOB.
105
+ def text_or_blob(value)
106
+ return value unless value.encoding == Encoding::ASCII_8BIT
107
+ text = value.dup.force_encoding(Encoding::UTF_8)
108
+ text.valid_encoding? ? text : value
109
+ end
110
+
99
111
  def handle_sqlite_error(error, sql: nil)
100
112
  msg = error.message
101
113
  if msg.include?('UNIQUE constraint failed')
data/lib/dami/core.rb CHANGED
@@ -24,7 +24,7 @@ def self.clear_all!
24
24
 
25
25
  def self.present(model_name, &block)
26
26
  presenter_module = Module.new
27
- presenter_module.module_eval(&block) # Changed from instance_eval
27
+ presenter_module.module_eval(&block)
28
28
  (@presenter_modules ||= {})[model_name] = presenter_module
29
29
  end
30
30
 
@@ -32,7 +32,7 @@ module Dami
32
32
  'paralysis' => 'paralyses', 'parenthesis' => 'parentheses', 'synopsis' => 'synopses',
33
33
  'thesis' => 'theses', 'phenomenon' => 'phenomena', 'criterion' => 'criteria',
34
34
  'datum' => 'data',
35
- # Add the common -s words as irregular to handle them explicitly
35
+ # common -s words, listed as irregular so they are handled explicitly
36
36
  'bus' => 'buses', 'gas' => 'gases', 'lens' => 'lenses', 'glass' => 'glasses',
37
37
  'class' => 'classes', 'mass' => 'masses', 'grass' => 'grasses', 'brass' => 'brasses',
38
38
  'canvas' => 'canvases', 'atlas' => 'atlases', 'bias' => 'biases', 'cosmos' => 'cosmoses',
@@ -1,41 +1,41 @@
1
- # lib/dami/migration.rb - Fix create_table to support block parameter syntax
1
+ # lib/dami/migration.rb the schema DSL a migration file's up/down is written in
2
2
 
3
3
  module Dami
4
4
  class Migration
5
5
  def initialize(adapter)
6
6
  @adapter = adapter
7
7
  end
8
-
8
+
9
9
  def up
10
10
  # This should be overridden by the migration file
11
11
  end
12
-
12
+
13
13
  def down
14
- # This should be overridden by the migration file
14
+ # This should be overridden by the migration file
15
15
  end
16
-
16
+
17
17
  # Schema methods
18
- def create_table(name, &block)
19
- table_definition = TableDefinition.new
20
-
21
- # Support both styles: do |t| ... end and do ... end
22
- if block.arity == 0
23
- table_definition.instance_eval(&block)
24
- else
25
- yield table_definition
26
- end
27
-
28
- @adapter.create_table(name, table_definition.columns)
29
- end
30
-
18
+ def create_table(name, &block)
19
+ table_definition = TableDefinition.new
20
+
21
+ # Support both styles: do |t| ... end and do ... end
22
+ if block.arity == 0
23
+ table_definition.instance_eval(&block)
24
+ else
25
+ yield table_definition
26
+ end
27
+
28
+ @adapter.create_table(name, table_definition.columns)
29
+ end
30
+
31
31
  def drop_table(name)
32
32
  @adapter.execute("DROP TABLE IF EXISTS #{name}")
33
33
  end
34
-
34
+
35
35
  def add_column(table, column, type, **options)
36
36
  @adapter.add_column(table, column, type, **options)
37
37
  end
38
-
38
+
39
39
  def remove_column(table, column)
40
40
  # SQLite has limited DROP COLUMN support
41
41
  # This will work in SQLite 3.35.0+ but fail gracefully in older versions
@@ -46,6 +46,7 @@ end
46
46
  puts "Note: DROP COLUMN not supported in this SQLite version: #{e.message}"
47
47
  end
48
48
  end
49
+
49
50
  def add_index(table_name, column_name, **options)
50
51
  @adapter.add_index(table_name, column_name, options)
51
52
  end
@@ -54,20 +55,26 @@ end
54
55
  @adapter.remove_index(table_name, column_name, options)
55
56
  end
56
57
 
57
-
58
+ # Raw SQL for what the DSL does not cover: triggers, views, PRAGMAs, data
59
+ # fixes. Runs inside the migration's transaction like every other step.
60
+ # execute "CREATE TRIGGER ..."
61
+ # execute "UPDATE users SET status = ? WHERE status IS NULL", ['active']
62
+ def execute(sql, params = [])
63
+ @adapter.execute(sql, params)
64
+ end
58
65
  end
59
-
66
+
60
67
  class TableDefinition
61
68
  attr_reader :columns
62
-
69
+
63
70
  def initialize
64
71
  @columns = []
65
72
  end
66
-
73
+
67
74
  def field(name, type, **options)
68
75
  @columns << { name: name, type: type, **options }
69
76
  end
70
-
77
+
71
78
  # created_at / updated_at columns. Dami fills them automatically on
72
79
  # create/update when the model declares the same two fields.
73
80
  def timestamps
@@ -81,4 +88,4 @@ end
81
88
  field(:"#{name}_id", :integer, references: table, **options)
82
89
  end
83
90
  end
84
- end
91
+ end
data/lib/dami/migrator.rb CHANGED
@@ -10,7 +10,7 @@ module Dami
10
10
 
11
11
  def migrate
12
12
  pending_migrations.each { |m| run_migration(m) }
13
- dump_schema # ADD THIS LINE
13
+ dump_schema
14
14
  end
15
15
 
16
16
  def rollback(steps = 1)
@@ -69,7 +69,6 @@ module Dami
69
69
  def run_migration(migration_info)
70
70
  migration = load_migration(migration_info[:file])
71
71
  @adapter.transaction do
72
- # THE FIX: Call the new method name.
73
72
  migration.up
74
73
  @adapter.insert_record(:schema_migrations, { version: migration_info[:version] })
75
74
  end
@@ -78,7 +77,6 @@ module Dami
78
77
  def reverse_migration(migration_info)
79
78
  migration = load_migration(migration_info[:file])
80
79
  @adapter.transaction do
81
- # THE FIX: Call the new method name.
82
80
  migration.down
83
81
 
84
82
  @adapter.execute(
@@ -18,10 +18,9 @@ def validate(value, rule_name, params = [], context = {})
18
18
  rule_config = rule.is_a?(Proc) ? rule.call(*params) : rule
19
19
  check = rule_config[:check]
20
20
 
21
- # --- THE FIX: Use the new Dami.translate system ---
22
21
  # 1. Use an inline message if provided directly in the rule definition.
23
22
  message = rule_config[:message]
24
- # 2. Otherwise, fetch it from the new localization system.
23
+ # 2. Otherwise, fetch it from the localization system.
25
24
  message ||= Dami.translate("validations.#{rule_name}", *params)
26
25
  # 3. Use a generic fallback if no translation is found.
27
26
  message ||= "is invalid"
@@ -47,11 +47,14 @@ module Dami
47
47
  adapter.query_exists?(build_query_structure)
48
48
  end
49
49
 
50
+ def none?
51
+ !any?
52
+ end
53
+
50
54
  def exists?(conds = nil)
51
55
  conds ? where(conds).any? : any?
52
56
  end
53
57
 
54
- # This is the updated method
55
58
  def count
56
59
  adapter.count_records(build_query_structure)
57
60
  end
@@ -69,7 +69,6 @@ def create_many(records, permit: [], protect: true)
69
69
 
70
70
  prepared_records << prepared[:db_parent_attrs]
71
71
  rescue Dami::ValidationError => e
72
- # FIX: Just store the error object, not try to set errors
73
72
  all_errors[index] = e.errors
74
73
  rescue Dami::ProtectionError, Dami::UnknownFieldsError => e
75
74
  raise e
@@ -78,7 +77,6 @@ def create_many(records, permit: [], protect: true)
78
77
 
79
78
  # If ANY validations failed, raise with collected errors
80
79
  unless all_errors.empty?
81
- # FIX: Create a custom message and pass errors in the initializer
82
80
  message = "Validation failed for #{all_errors.size} record(s): " +
83
81
  all_errors.map { |idx, errs| "Record #{idx}: #{errs}" }.join("; ")
84
82
  raise Dami::ValidationError.new(message, all_errors)
@@ -10,7 +10,7 @@ module Dami
10
10
 
11
11
  timestamp = Time.now.utc.strftime('%Y%m%d%H%M%S')
12
12
 
13
- # THE FIX: This converts "AddEmailToUsers" into "add_email_to_users"
13
+ # "AddEmailToUsers" -> "add_email_to_users"
14
14
  snake_case_name = name.gsub(/::/, '/')
15
15
  .gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
16
16
  .gsub(/([a-z\d])([A-Z])/,'\1_\2')
@@ -15,7 +15,7 @@ module Dami
15
15
  def load_from_path(path)
16
16
  return unless File.exist?(path)
17
17
 
18
- # THE FIX: Capture `self` (the loader instance) into a variable.
18
+ # Capture `self` (the loader instance) into a variable.
19
19
  loader_instance = self
20
20
 
21
21
  # Now, the monkey-patched method uses the captured variable, ensuring
data/lib/dami/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Dami
2
- VERSION = "1.0.0"
2
+ VERSION = "1.0.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dami
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steven Garcia