rip-lang 3.15.4 → 3.16.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.
- package/README.md +6 -4
- package/bin/rip +167 -12
- package/docs/AGENTS.md +1 -1
- package/docs/RIP-APP.md +808 -0
- package/docs/RIP-DUCKDB.md +477 -0
- package/docs/RIP-INTRO.md +396 -0
- package/docs/RIP-LANG.md +59 -5
- package/docs/RIP-SCHEMA.md +191 -8
- package/docs/RIP-TYPES.md +74 -103
- package/docs/demo/README.md +4 -3
- package/docs/dist/rip.js +3627 -1470
- package/docs/dist/rip.min.js +671 -244
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/example/index.json +7 -7
- package/docs/example/index.json.br +0 -0
- package/docs/extensions/duckdb/manifest.json +1 -1
- package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
- package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
- package/docs/extensions/vscode/print/index.html +2 -1
- package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
- package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/ui/bundle.json +61 -0
- package/docs/ui/bundle.json.br +0 -0
- package/docs/ui/hljs-rip.js +0 -7
- package/docs/ui/index.css +66 -23
- package/docs/ui/index.html +6 -6
- package/package.json +9 -3
- package/rip-loader.js +64 -2
- package/src/AGENTS.md +63 -36
- package/src/browser.js +96 -14
- package/src/compiler.js +960 -143
- package/src/components.js +794 -88
- package/src/{types-emit.js → dts.js} +181 -71
- package/src/grammar/README.md +1 -1
- package/src/grammar/grammar.rip +111 -97
- package/src/lexer.js +132 -18
- package/src/parser.js +203 -205
- package/src/repl.js +74 -6
- package/src/schema/runtime-orm.js +168 -4
- package/src/schema/runtime-validate.js +146 -2
- package/src/schema/runtime.generated.js +314 -6
- package/src/schema/schema.js +5 -5
- package/src/sourcemaps.js +277 -1
- package/src/stdlib.js +253 -0
- package/src/typecheck.js +2023 -106
- package/src/types.js +127 -7
- package/docs/ui/accordion.rip +0 -103
- package/docs/ui/alert-dialog.rip +0 -53
- package/docs/ui/autocomplete.rip +0 -115
- package/docs/ui/avatar.rip +0 -37
- package/docs/ui/badge.rip +0 -15
- package/docs/ui/breadcrumb.rip +0 -47
- package/docs/ui/button-group.rip +0 -26
- package/docs/ui/button.rip +0 -23
- package/docs/ui/card.rip +0 -25
- package/docs/ui/carousel.rip +0 -110
- package/docs/ui/checkbox-group.rip +0 -61
- package/docs/ui/checkbox.rip +0 -33
- package/docs/ui/collapsible.rip +0 -50
- package/docs/ui/combobox.rip +0 -130
- package/docs/ui/context-menu.rip +0 -88
- package/docs/ui/date-picker.rip +0 -206
- package/docs/ui/dialog.rip +0 -60
- package/docs/ui/drawer.rip +0 -58
- package/docs/ui/editable-value.rip +0 -82
- package/docs/ui/field.rip +0 -53
- package/docs/ui/fieldset.rip +0 -22
- package/docs/ui/form.rip +0 -39
- package/docs/ui/grid.rip +0 -901
- package/docs/ui/input-group.rip +0 -28
- package/docs/ui/input.rip +0 -36
- package/docs/ui/label.rip +0 -16
- package/docs/ui/menu.rip +0 -134
- package/docs/ui/menubar.rip +0 -151
- package/docs/ui/meter.rip +0 -36
- package/docs/ui/multi-select.rip +0 -203
- package/docs/ui/native-select.rip +0 -33
- package/docs/ui/nav-menu.rip +0 -126
- package/docs/ui/number-field.rip +0 -162
- package/docs/ui/otp-field.rip +0 -89
- package/docs/ui/pagination.rip +0 -123
- package/docs/ui/popover.rip +0 -93
- package/docs/ui/preview-card.rip +0 -75
- package/docs/ui/progress.rip +0 -25
- package/docs/ui/radio-group.rip +0 -57
- package/docs/ui/resizable.rip +0 -123
- package/docs/ui/scroll-area.rip +0 -145
- package/docs/ui/select.rip +0 -151
- package/docs/ui/separator.rip +0 -17
- package/docs/ui/skeleton.rip +0 -22
- package/docs/ui/slider.rip +0 -165
- package/docs/ui/spinner.rip +0 -17
- package/docs/ui/table.rip +0 -27
- package/docs/ui/tabs.rip +0 -113
- package/docs/ui/textarea.rip +0 -48
- package/docs/ui/toast.rip +0 -87
- package/docs/ui/toggle-group.rip +0 -71
- package/docs/ui/toggle.rip +0 -24
- package/docs/ui/toolbar.rip +0 -38
- package/docs/ui/tooltip.rip +0 -85
- package/src/app.rip +0 -1571
- package/src/sourcemap-merge.js +0 -287
- /package/docs/demo/{components → routes}/_layout.rip +0 -0
- /package/docs/demo/{components → routes}/about.rip +0 -0
- /package/docs/demo/{components → routes}/card.rip +0 -0
- /package/docs/demo/{components → routes}/counter.rip +0 -0
- /package/docs/demo/{components → routes}/index.rip +0 -0
- /package/docs/demo/{components → routes}/todos.rip +0 -0
- /package/src/schema/{dts-emit.js → dts.js} +0 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/assets/rip-schema-social.png" alt="Rip + DuckDB" width="640">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Rip on DuckDB — Foreign Key Constraints
|
|
6
|
+
|
|
7
|
+
> **What works, what doesn't, and how the Rip schema runtime keeps the rough edges off.**
|
|
8
|
+
|
|
9
|
+
DuckDB is Rip's primary backing database — used by `@rip-lang/db`, the
|
|
10
|
+
`schema :model` ORM, the migration tool, and the various sample apps.
|
|
11
|
+
Most of DuckDB's behavior matches what you'd expect from any SQL engine.
|
|
12
|
+
**Foreign-key constraints are the one place where DuckDB diverges
|
|
13
|
+
meaningfully from PostgreSQL/SQLite/MySQL.** This doc is the canonical
|
|
14
|
+
explanation of that divergence — what works, what doesn't, why, and
|
|
15
|
+
the design patterns Rip uses to keep your application code clean.
|
|
16
|
+
|
|
17
|
+
If you're hitting a `Constraint Error: Violates foreign key
|
|
18
|
+
constraint because key "X: N" is still referenced by a foreign key
|
|
19
|
+
in a different table` and trying to figure out what's going on,
|
|
20
|
+
you're in the right place.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
# Contents
|
|
25
|
+
|
|
26
|
+
1. [The exact rule](#1-the-exact-rule)
|
|
27
|
+
2. [What works (probably almost everything you'll write)](#2-what-works)
|
|
28
|
+
3. [What doesn't (the narrow forbidden case)](#3-what-doesnt)
|
|
29
|
+
4. [How Rip's schema runtime keeps you out of trouble](#4-how-rips-schema-runtime-keeps-you-out-of-trouble)
|
|
30
|
+
5. [Worked example — patient records with orders](#5-worked-example)
|
|
31
|
+
6. [When you genuinely need to mutate a referenced indexed column](#6-when-you-genuinely-need-to-mutate)
|
|
32
|
+
7. [Escape hatches](#7-escape-hatches)
|
|
33
|
+
8. [Should I use DuckDB for OLTP at all?](#8-should-i-use-duckdb-for-oltp-at-all)
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 1. The exact rule
|
|
38
|
+
|
|
39
|
+
DuckDB rejects an UPDATE statement when **all three** conditions hold:
|
|
40
|
+
|
|
41
|
+
1. There is at least one row in another table whose foreign key
|
|
42
|
+
references the row being updated.
|
|
43
|
+
2. The UPDATE statement's `SET` clause touches at least one column
|
|
44
|
+
that participates in an **index** — `PRIMARY KEY` or `UNIQUE`.
|
|
45
|
+
3. The new value differs from the old value (DuckDB internally
|
|
46
|
+
re-keys via a delete-then-insert cycle on the index).
|
|
47
|
+
|
|
48
|
+
If any of those three is false, the UPDATE succeeds.
|
|
49
|
+
|
|
50
|
+
The same rule applies to DELETE: DuckDB rejects DELETE on a row that
|
|
51
|
+
is currently referenced by another table's FK.
|
|
52
|
+
|
|
53
|
+
This is documented at <https://duckdb.org/docs/sql/constraints> under
|
|
54
|
+
"Foreign Keys" and is a deliberate design choice: DuckDB's index
|
|
55
|
+
maintenance and FK enforcement are tightly coupled, and supporting
|
|
56
|
+
in-place rewrites of indexed parent rows would require significant
|
|
57
|
+
internal restructuring.
|
|
58
|
+
|
|
59
|
+
The behavior is **stable across DuckDB versions** — at the time of
|
|
60
|
+
writing (DuckDB v1.5.2) and consistent back through several minor
|
|
61
|
+
releases.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 2. What works
|
|
66
|
+
|
|
67
|
+
The narrow forbidden case is so narrow that nearly every operation
|
|
68
|
+
you'll write passes through fine. The decisive table:
|
|
69
|
+
|
|
70
|
+
| Operation | Status | Notes |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| INSERT into the parent table | works | |
|
|
73
|
+
| INSERT into the child table | works | The standard FK existence check on the parent runs as expected. |
|
|
74
|
+
| SELECT, JOIN, aggregate, anything read-only | works | |
|
|
75
|
+
| UPDATE non-indexed columns of an unreferenced parent | works | |
|
|
76
|
+
| UPDATE non-indexed columns of a *referenced* parent | **works** | This is the one that surprises people most. Demographics, status fields, body text — all fine. |
|
|
77
|
+
| UPDATE indexed columns of an *unreferenced* parent | works | |
|
|
78
|
+
| UPDATE child-table columns (any) | works | Only the parent side has the restriction. |
|
|
79
|
+
| DELETE child rows | works | |
|
|
80
|
+
| DELETE parent row that has no current children | works | |
|
|
81
|
+
| Multi-row UPDATE that doesn't change indexed values | works | |
|
|
82
|
+
|
|
83
|
+
The narrow case to watch:
|
|
84
|
+
|
|
85
|
+
| Operation | Status |
|
|
86
|
+
|---|---|
|
|
87
|
+
| UPDATE indexed column on a referenced parent, value actually changes | **rejected** |
|
|
88
|
+
| DELETE referenced parent row | **rejected** |
|
|
89
|
+
|
|
90
|
+
That's it. Two cases. Both rejected. Everything else works.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 3. What doesn't
|
|
95
|
+
|
|
96
|
+
The forbidden operations are:
|
|
97
|
+
|
|
98
|
+
```sql
|
|
99
|
+
-- Suppose orders.patient_id REFERENCES patients(id), and there's
|
|
100
|
+
-- at least one orders row with patient_id = 42.
|
|
101
|
+
|
|
102
|
+
UPDATE patients SET id = 999 WHERE id = 42; -- rejected
|
|
103
|
+
UPDATE patients SET mrn = 'NEW-X' WHERE id = 42; -- rejected (mrn is in UNIQUE)
|
|
104
|
+
UPDATE patients SET partner_id = 7 WHERE id = 42; -- rejected (partner_id is in UNIQUE)
|
|
105
|
+
DELETE FROM patients WHERE id = 42; -- rejected
|
|
106
|
+
|
|
107
|
+
UPDATE patients SET first_name = 'Steve' WHERE id = 42; -- works
|
|
108
|
+
UPDATE patients SET phone = '...' WHERE id = 42; -- works
|
|
109
|
+
UPDATE patients SET mdm_id = 'L00X' WHERE id = 42; -- works
|
|
110
|
+
UPDATE patients SET link_id = 'PID9' WHERE id = 42; -- works
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The error message DuckDB returns names the FK column on the *child*
|
|
114
|
+
side, not the indexed column on the *parent* side. That's confusing
|
|
115
|
+
the first time you see it:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
Constraint Error: Violates foreign key constraint because key
|
|
119
|
+
"patient_id: 42" is still referenced by a foreign key in a different
|
|
120
|
+
table. If this is an unexpected constraint violation, please refer
|
|
121
|
+
to our foreign key limitations in the documentation
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The phrase "patient_id: 42" means: the FK column in the child table
|
|
125
|
+
is `orders.patient_id`, and the value `42` is what's being referenced.
|
|
126
|
+
The actual offending column on the parent (the one that's in the
|
|
127
|
+
index DuckDB is trying to re-key) isn't named in the error.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 4. How Rip's schema runtime keeps you out of trouble
|
|
132
|
+
|
|
133
|
+
The Rip schema runtime turns full-row updates into
|
|
134
|
+
**column-targeted** updates. Most application code that "just calls
|
|
135
|
+
`save!`" never trips the rule.
|
|
136
|
+
|
|
137
|
+
When you do:
|
|
138
|
+
|
|
139
|
+
```coffee
|
|
140
|
+
patient = Patient.find! 42
|
|
141
|
+
patient.phone = '801-555-9999'
|
|
142
|
+
patient.save!
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
an unaware ORM might emit something like:
|
|
146
|
+
|
|
147
|
+
```sql
|
|
148
|
+
UPDATE patients
|
|
149
|
+
SET mrn=?, first_name=?, last_name=?, dob=?, sex=?, phone=?, email=?, mdm_id=?, link_id=?
|
|
150
|
+
WHERE id=?
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
That's a full-row UPDATE that touches `mrn` (an indexed column). On a
|
|
154
|
+
patient with existing orders, DuckDB rejects it — even though the
|
|
155
|
+
*value* of `mrn` isn't changing.
|
|
156
|
+
|
|
157
|
+
Rip's runtime instead emits:
|
|
158
|
+
|
|
159
|
+
```sql
|
|
160
|
+
UPDATE patients SET phone=? WHERE id=?
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
because the snapshot captured at `find!` time tells `save!` exactly
|
|
164
|
+
which columns the application actually mutated. No-op writes to other
|
|
165
|
+
columns are skipped, and the UPDATE never touches an indexed column
|
|
166
|
+
unless you genuinely changed its value. See the
|
|
167
|
+
[`save()` semantics section in RIP-SCHEMA.md](./RIP-SCHEMA.md#what-save-actually-writes)
|
|
168
|
+
for the full story.
|
|
169
|
+
|
|
170
|
+
The practical consequence: **you generally don't have to think about
|
|
171
|
+
this at all when writing Rip code**. The runtime takes care of it for
|
|
172
|
+
the 99% case. You only feel the limitation when your application
|
|
173
|
+
deliberately tries to change `id` / `mrn` / `partner_id` / a
|
|
174
|
+
`@belongs_to` FK on a row that already has children — and that's
|
|
175
|
+
usually a domain-model question, not a tech question.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## 5. Worked example
|
|
180
|
+
|
|
181
|
+
A small healthcare-style schema, exactly the medlabs case the rule
|
|
182
|
+
was originally documented from:
|
|
183
|
+
|
|
184
|
+
```coffee
|
|
185
|
+
Partner = schema :model
|
|
186
|
+
name! string
|
|
187
|
+
slug!# string
|
|
188
|
+
|
|
189
|
+
Patient = schema :model
|
|
190
|
+
mrn? string
|
|
191
|
+
firstName! string
|
|
192
|
+
lastName! string
|
|
193
|
+
dob? date
|
|
194
|
+
phone? phone
|
|
195
|
+
email? email
|
|
196
|
+
mdmId? string
|
|
197
|
+
linkId? string
|
|
198
|
+
@belongs_to Partner
|
|
199
|
+
@timestamps
|
|
200
|
+
@unique [:partnerId, :mrn]
|
|
201
|
+
|
|
202
|
+
Order = schema :model
|
|
203
|
+
status "draft" | "submitted" | "completed" | "cancelled", [:draft]
|
|
204
|
+
totalPrice integer, [0]
|
|
205
|
+
notes? text
|
|
206
|
+
@belongs_to Patient
|
|
207
|
+
@belongs_to Partner?
|
|
208
|
+
@timestamps
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Indexed columns on `Patient`:
|
|
212
|
+
- `id` — surrogate PK, auto-managed
|
|
213
|
+
- `partner_id` — `@belongs_to` FK
|
|
214
|
+
- `(partner_id, mrn)` — composite UNIQUE
|
|
215
|
+
|
|
216
|
+
So the indexed-column set on `Patient` is `{id, partner_id, mrn}`.
|
|
217
|
+
|
|
218
|
+
### Operations that work, no thought required
|
|
219
|
+
|
|
220
|
+
```coffee
|
|
221
|
+
# INSERT — always works
|
|
222
|
+
patient = Patient.create!
|
|
223
|
+
partnerId: ola.id
|
|
224
|
+
mrn: 'MRN-00421'
|
|
225
|
+
firstName: 'Larry'
|
|
226
|
+
lastName: 'Jones'
|
|
227
|
+
|
|
228
|
+
# UPDATE non-indexed — always works
|
|
229
|
+
patient.phone = '(801) 555-0142'
|
|
230
|
+
patient.email = 'mjones@email.com'
|
|
231
|
+
patient.save! # writes only phone, email; Order references stay valid
|
|
232
|
+
|
|
233
|
+
# Sync an external identity-system ID — works (mdm_id non-indexed)
|
|
234
|
+
patient.mdmId = '5801776951206141'
|
|
235
|
+
patient.save! # writes only mdm_id
|
|
236
|
+
|
|
237
|
+
# Update a child row — child-side FK changes are fine
|
|
238
|
+
order = Order.find! 7
|
|
239
|
+
order.status = 'completed'
|
|
240
|
+
order.notes = 'Patient picked up sample'
|
|
241
|
+
order.save! # writes only status, notes
|
|
242
|
+
|
|
243
|
+
# Soft-delete an unreferenced row
|
|
244
|
+
new_patient = Patient.create! partnerId: ola.id, ...
|
|
245
|
+
new_patient.destroy! # works — no children yet
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### The one case that fails
|
|
249
|
+
|
|
250
|
+
```coffee
|
|
251
|
+
# Patient 10023 has at least one Order. We want to change their MRN.
|
|
252
|
+
patient = Patient.find! 10023
|
|
253
|
+
patient.mrn = 'MRN-NEW-99'
|
|
254
|
+
patient.save!
|
|
255
|
+
# => Constraint Error: Violates foreign key constraint because key
|
|
256
|
+
# "patient_id: 10023" is still referenced by a foreign key in a
|
|
257
|
+
# different table.
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
This is the genuine restriction. DuckDB is telling you "you can't
|
|
261
|
+
rotate this patient's MRN while they have orders pointing at them."
|
|
262
|
+
|
|
263
|
+
The runtime *does* try this UPDATE — it correctly emits
|
|
264
|
+
`UPDATE patients SET mrn=? WHERE id=?` (no full-row write), and DuckDB
|
|
265
|
+
rejects it because `mrn` is in the `(partner_id, mrn)` UNIQUE index
|
|
266
|
+
and the row is referenced. The error is correctly Wikipedia-style
|
|
267
|
+
informative — it points at the right concept.
|
|
268
|
+
|
|
269
|
+
### How to handle it at the application layer
|
|
270
|
+
|
|
271
|
+
Three sensible patterns, in roughly increasing complexity:
|
|
272
|
+
|
|
273
|
+
**(a) Treat the indexed column as immutable** after first reference.
|
|
274
|
+
Surface a 409 / 422 from the API:
|
|
275
|
+
|
|
276
|
+
```coffee
|
|
277
|
+
post '/patients/:id/mrn' ->
|
|
278
|
+
user = userScope!
|
|
279
|
+
patient = Patient.find! @params.id
|
|
280
|
+
newMrn = read 'mrn', 'string!'
|
|
281
|
+
if Order.where(patientId: patient.id).count! > 0
|
|
282
|
+
error! 'Cannot change MRN after first order; create new patient instead', 409,
|
|
283
|
+
code: 'mrn_locked'
|
|
284
|
+
patient.mrn = newMrn
|
|
285
|
+
patient.save!
|
|
286
|
+
patient.toPublic()
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
This is what most healthcare systems actually do — MRN is identity,
|
|
290
|
+
and identity rotates only via a dedicated patient-merge workflow.
|
|
291
|
+
Cheap to implement, defensible domain rule.
|
|
292
|
+
|
|
293
|
+
**(b) Migrate the row.** INSERT a new patient with the new MRN,
|
|
294
|
+
repoint child rows, soft-delete the old one:
|
|
295
|
+
|
|
296
|
+
```coffee
|
|
297
|
+
oldPatient = Patient.find! id
|
|
298
|
+
newPatient = Patient.create! { ...oldPatient.toJSON(), id: undefined, mrn: newMrn }
|
|
299
|
+
sql! 'UPDATE orders SET patient_id = ? WHERE patient_id = ?', [newPatient.id, oldPatient.id]
|
|
300
|
+
oldPatient.destroy! # works after the UPDATE — old row is now unreferenced
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The surrogate PK changes. Anything caching `patient.id` (a partner
|
|
304
|
+
holding it via API) breaks. Auditable, but operationally heavier.
|
|
305
|
+
|
|
306
|
+
**(c) Drop the FK constraint.** See [§7 escape hatches](#7-escape-hatches).
|
|
307
|
+
|
|
308
|
+
For most applications, **(a) is the right answer.** Codify the
|
|
309
|
+
"indexed columns are immutable after first reference" rule in your
|
|
310
|
+
domain model, and you stop fighting the database.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## 6. When you genuinely need to mutate
|
|
315
|
+
|
|
316
|
+
If your application's normal write patterns *require* mutating
|
|
317
|
+
indexed parent columns on referenced rows, that's a serious signal
|
|
318
|
+
about either the domain model or the choice of database.
|
|
319
|
+
|
|
320
|
+
Common cases that would hit this regularly:
|
|
321
|
+
|
|
322
|
+
| Pattern | Forbidden ops |
|
|
323
|
+
|---|---|
|
|
324
|
+
| Bulk-rename across joined tables ("lowercase all customer emails") | UPDATE on a UNIQUE column referenced by orders |
|
|
325
|
+
| Periodic merge of duplicate records | DELETE on a referenced row |
|
|
326
|
+
| Reassigning entities ("move all orders from user A to user B") | UPDATE on FK column of child OR DELETE old user |
|
|
327
|
+
| Auto-rotating UNIQUE business identifiers (e.g. account numbers) | UPDATE on UNIQUE column referenced elsewhere |
|
|
328
|
+
|
|
329
|
+
If your app does any of these *routinely*, you have two options:
|
|
330
|
+
|
|
331
|
+
1. Restructure so the indexed values are stable. (Often the right
|
|
332
|
+
call regardless — bulk-mutable identifiers are fragile no matter
|
|
333
|
+
what the database does.)
|
|
334
|
+
2. Use a different database. PostgreSQL and SQLite handle this
|
|
335
|
+
correctly and aren't going anywhere. See [§8](#8-should-i-use-duckdb-for-oltp-at-all).
|
|
336
|
+
|
|
337
|
+
If your app does these *occasionally* — once a quarter, in batch jobs,
|
|
338
|
+
under operator supervision — then [§7](#7-escape-hatches) is for you.
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
## 7. Escape hatches
|
|
343
|
+
|
|
344
|
+
### Drop the FK constraint
|
|
345
|
+
|
|
346
|
+
DuckDB without FKs is a fast columnar engine with no FK surprises.
|
|
347
|
+
Application-level FK enforcement (existence check before write,
|
|
348
|
+
optional cleanup jobs) replaces what the DB was doing.
|
|
349
|
+
|
|
350
|
+
The `@belongs_to` directive in Rip emits a `REFERENCES` clause in
|
|
351
|
+
DDL by default. To skip the constraint while keeping the relation
|
|
352
|
+
accessor and the camelCase/snake_case alias machinery, override the
|
|
353
|
+
DDL emission in your migration:
|
|
354
|
+
|
|
355
|
+
```sql
|
|
356
|
+
-- Generated by Rip, then hand-edit before running:
|
|
357
|
+
CREATE TABLE orders (
|
|
358
|
+
id INTEGER PRIMARY KEY DEFAULT nextval('orders_seq'),
|
|
359
|
+
patient_id INTEGER NOT NULL, -- was: REFERENCES patients(id)
|
|
360
|
+
...
|
|
361
|
+
);
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
You lose:
|
|
365
|
+
- DB-level guarantee that `orders.patient_id` always points at a
|
|
366
|
+
real patient
|
|
367
|
+
- DuckDB's check that you can't DELETE a referenced parent
|
|
368
|
+
|
|
369
|
+
You gain:
|
|
370
|
+
- Freedom to UPDATE / DELETE anything anytime
|
|
371
|
+
- Slightly faster writes (no constraint check)
|
|
372
|
+
|
|
373
|
+
For analytical-flavored apps, this is the right tradeoff. For
|
|
374
|
+
financial or healthcare records that must always be join-able, less
|
|
375
|
+
clearly so — but if your application code is rigorous, app-level
|
|
376
|
+
enforcement is fine. Most ORMs in the JavaScript ecosystem have
|
|
377
|
+
worked this way for years.
|
|
378
|
+
|
|
379
|
+
### Soft-rotate via INSERT + repoint + DELETE
|
|
380
|
+
|
|
381
|
+
Pattern (b) from [§5](#5-worked-example). Wrap it in a helper:
|
|
382
|
+
|
|
383
|
+
```coffee
|
|
384
|
+
export rotateMrn = (oldPatient, newMrn) ->
|
|
385
|
+
newPatient = Patient.create!
|
|
386
|
+
partnerId: oldPatient.partnerId
|
|
387
|
+
mrn: newMrn
|
|
388
|
+
firstName: oldPatient.firstName
|
|
389
|
+
lastName: oldPatient.lastName
|
|
390
|
+
dob: oldPatient.dob
|
|
391
|
+
phone: oldPatient.phone
|
|
392
|
+
email: oldPatient.email
|
|
393
|
+
mdmId: oldPatient.mdmId
|
|
394
|
+
linkId: oldPatient.linkId
|
|
395
|
+
|
|
396
|
+
sql! 'UPDATE orders WHERE patient_id = ? SET patient_id = ?', [oldPatient.id, newPatient.id]
|
|
397
|
+
|
|
398
|
+
oldPatient.destroy! # works — now unreferenced
|
|
399
|
+
newPatient
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Surrogate ID changes; anything that cached the old ID externally
|
|
403
|
+
breaks. Acceptable for internal tools and scheduled migrations,
|
|
404
|
+
not for live API surfaces where partners hold the ID.
|
|
405
|
+
|
|
406
|
+
### Switch to a different database
|
|
407
|
+
|
|
408
|
+
If FK rigidity is repeatedly in the way:
|
|
409
|
+
|
|
410
|
+
- **SQLite** — file-based, no server, full FK semantics, well
|
|
411
|
+
supported, embedded in everything. Excellent for single-machine
|
|
412
|
+
apps. Limited write concurrency.
|
|
413
|
+
- **PostgreSQL** — server-based, full FK semantics including
|
|
414
|
+
`ON DELETE CASCADE` / `ON UPDATE CASCADE`, mature concurrency,
|
|
415
|
+
replication, point-in-time recovery, every feature you might
|
|
416
|
+
ever want. Operational overhead.
|
|
417
|
+
|
|
418
|
+
The Rip schema runtime is mostly DB-agnostic (the
|
|
419
|
+
`__schemaSetAdapter` seam) and the SQL it emits is portable for
|
|
420
|
+
the common operations. Swapping the underlying DB is a real
|
|
421
|
+
project but not a hostile one.
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## 8. Should I use DuckDB for OLTP at all?
|
|
426
|
+
|
|
427
|
+
DuckDB is, by design, an **analytical** database. The marketing
|
|
428
|
+
puts it next to MotherDuck, Apache Arrow, columnar storage, and
|
|
429
|
+
"in-process analytics" benchmarks. Using it for transactional
|
|
430
|
+
workloads — high-volume row-level INSERT/UPDATE/DELETE with FK
|
|
431
|
+
relationships — is going against the canonical use case.
|
|
432
|
+
|
|
433
|
+
That said, DuckDB is *very good* at lots of things that look like
|
|
434
|
+
OLTP:
|
|
435
|
+
|
|
436
|
+
- INSERT-heavy workloads (orders, events, log entries)
|
|
437
|
+
- SELECT-heavy workloads (lookups, aggregations, reports)
|
|
438
|
+
- Single-host applications with moderate concurrency
|
|
439
|
+
- Apps where most rows are append-only and rarely mutated
|
|
440
|
+
|
|
441
|
+
The medlabs healthcare app fits this profile. Patients are mostly
|
|
442
|
+
inserted, occasionally updated (demographics, never identity), and
|
|
443
|
+
never deleted. Orders are inserted and have their `status` /
|
|
444
|
+
`notes` updated — both non-indexed columns. The
|
|
445
|
+
column-targeted UPDATE behavior of the Rip ORM keeps the
|
|
446
|
+
non-indexed-column update path open.
|
|
447
|
+
|
|
448
|
+
DuckDB is a poor fit when:
|
|
449
|
+
|
|
450
|
+
- Multiple writers on the same machine need fine-grained locking
|
|
451
|
+
(DuckDB has a single-writer model under the hood)
|
|
452
|
+
- Distributed write replication is required
|
|
453
|
+
- The workload mutates indexed business identifiers across joined
|
|
454
|
+
tables routinely
|
|
455
|
+
|
|
456
|
+
DuckDB is a fine fit when:
|
|
457
|
+
|
|
458
|
+
- The workload is mostly read, with append-heavy writes
|
|
459
|
+
- Indexed columns are surrogate keys or stable identifiers
|
|
460
|
+
- The schema favors denormalization or has shallow FK relations
|
|
461
|
+
- You want analytics queries to live in the same engine as the
|
|
462
|
+
transactional data
|
|
463
|
+
|
|
464
|
+
For a project where you're not sure: prototype with DuckDB, watch
|
|
465
|
+
the FK behavior, and switch to PostgreSQL or SQLite if you find
|
|
466
|
+
yourself reaching for the escape hatches more than once or twice.
|
|
467
|
+
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## See also
|
|
471
|
+
|
|
472
|
+
- [`docs/RIP-SCHEMA.md`](./RIP-SCHEMA.md) — the schema/ORM documentation,
|
|
473
|
+
including the `save()` semantics, dirty tracking, and `markDirty()`
|
|
474
|
+
escape hatch.
|
|
475
|
+
- [`packages/db/AGENTS.md`](../packages/db/AGENTS.md) — the
|
|
476
|
+
`@rip-lang/db` FFI client.
|
|
477
|
+
- [DuckDB Foreign Key documentation](https://duckdb.org/docs/sql/constraints) — upstream reference for the rule.
|