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 +4 -4
- data/CHANGELOG.md +22 -0
- data/docs/03.Querying.md +7 -1
- data/docs/04.Creating_Updating_Deleting.md +1 -1
- data/docs/05.Validation.md +1 -1
- data/docs/08.Scopes.md +1 -1
- data/docs/09.Migrations.md +2 -2
- data/docs/10.Flows_and_Commands.md +58 -36
- data/docs/11.Localization.md +1 -1
- data/docs/12.TheDamiWay.md +8 -15
- data/docs/Manifesto.md +12 -18
- data/docs/site.md +13 -9
- data/lib/dami/actions/command.rb +6 -3
- data/lib/dami/actions/context.rb +4 -5
- data/lib/dami/actions/draft.rb +4 -0
- data/lib/dami/actions/flow.rb +0 -1
- data/lib/dami/adapters/sqlite/connection.rb +12 -0
- data/lib/dami/core.rb +1 -1
- data/lib/dami/inflector.rb +1 -1
- data/lib/dami/migration.rb +33 -26
- data/lib/dami/migrator.rb +1 -3
- data/lib/dami/plugins/validations.rb +1 -2
- data/lib/dami/query/enumerable.rb +4 -1
- data/lib/dami/query/persistence.rb +0 -2
- data/lib/dami/schema/generator.rb +1 -1
- data/lib/dami/schema/loader.rb +1 -1
- data/lib/dami/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5e550fe4dbb3da8996d88b0825c92f2208cb60bdf5c6d389b610de8d65d22091
|
|
4
|
+
data.tar.gz: 35464adbc1ab5319602ef98276c1a6002cba6fbc313ae9c3e2288b970d08f0ab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/docs/05.Validation.md
CHANGED
|
@@ -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
|
|
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:
|
|
99
|
+
* **Chapter 10: Drafts, Flows & Commands** - Business logic: inline drafts, commands, and transactional flows.
|
data/docs/09.Migrations.md
CHANGED
|
@@ -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)`
|
|
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:
|
|
165
|
+
* **Chapter 10: Drafts, Flows & Commands** - Business logic: inline drafts, commands, and transactional flows.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
-
#
|
|
42
|
+
# In a request handler
|
|
43
43
|
def update
|
|
44
44
|
# ...
|
|
45
|
-
# The block yields a `draft` object.
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
117
|
-
Dami.
|
|
118
|
-
# 1. PREPARE the data. `prepare`
|
|
119
|
-
# It halts
|
|
120
|
-
|
|
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.
|
|
123
|
-
|
|
124
|
-
|
|
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",
|
|
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
|
|
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.
|
|
135
|
-
Analytics.track("Team Onboarded", account_id: account
|
|
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 `
|
|
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`
|
|
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
|
|
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 `
|
|
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.
|
data/docs/11.Localization.md
CHANGED
|
@@ -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
|
|
data/docs/12.TheDamiWay.md
CHANGED
|
@@ -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 { ... };
|
|
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.
|
|
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
|
|
159
|
-
db(:
|
|
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.
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
data/lib/dami/actions/command.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
data/lib/dami/actions/context.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
|
30
|
+
retry
|
|
32
31
|
end
|
|
33
32
|
end
|
|
34
33
|
if timeout
|
data/lib/dami/actions/draft.rb
CHANGED
data/lib/dami/actions/flow.rb
CHANGED
|
@@ -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)
|
|
27
|
+
presenter_module.module_eval(&block)
|
|
28
28
|
(@presenter_modules ||= {})[model_name] = presenter_module
|
|
29
29
|
end
|
|
30
30
|
|
data/lib/dami/inflector.rb
CHANGED
|
@@ -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
|
-
#
|
|
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',
|
data/lib/dami/migration.rb
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
# lib/dami/migration.rb
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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')
|
data/lib/dami/schema/loader.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Dami
|
|
|
15
15
|
def load_from_path(path)
|
|
16
16
|
return unless File.exist?(path)
|
|
17
17
|
|
|
18
|
-
#
|
|
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