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.
Files changed (112) hide show
  1. package/README.md +6 -4
  2. package/bin/rip +167 -12
  3. package/docs/AGENTS.md +1 -1
  4. package/docs/RIP-APP.md +808 -0
  5. package/docs/RIP-DUCKDB.md +477 -0
  6. package/docs/RIP-INTRO.md +396 -0
  7. package/docs/RIP-LANG.md +59 -5
  8. package/docs/RIP-SCHEMA.md +191 -8
  9. package/docs/RIP-TYPES.md +74 -103
  10. package/docs/demo/README.md +4 -3
  11. package/docs/dist/rip.js +3627 -1470
  12. package/docs/dist/rip.min.js +671 -244
  13. package/docs/dist/rip.min.js.br +0 -0
  14. package/docs/example/index.json +7 -7
  15. package/docs/example/index.json.br +0 -0
  16. package/docs/extensions/duckdb/manifest.json +1 -1
  17. package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
  18. package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
  19. package/docs/extensions/vscode/print/index.html +2 -1
  20. package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
  21. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  22. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  23. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  24. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  25. package/docs/ui/bundle.json +61 -0
  26. package/docs/ui/bundle.json.br +0 -0
  27. package/docs/ui/hljs-rip.js +0 -7
  28. package/docs/ui/index.css +66 -23
  29. package/docs/ui/index.html +6 -6
  30. package/package.json +9 -3
  31. package/rip-loader.js +64 -2
  32. package/src/AGENTS.md +63 -36
  33. package/src/browser.js +96 -14
  34. package/src/compiler.js +960 -143
  35. package/src/components.js +794 -88
  36. package/src/{types-emit.js → dts.js} +181 -71
  37. package/src/grammar/README.md +1 -1
  38. package/src/grammar/grammar.rip +111 -97
  39. package/src/lexer.js +132 -18
  40. package/src/parser.js +203 -205
  41. package/src/repl.js +74 -6
  42. package/src/schema/runtime-orm.js +168 -4
  43. package/src/schema/runtime-validate.js +146 -2
  44. package/src/schema/runtime.generated.js +314 -6
  45. package/src/schema/schema.js +5 -5
  46. package/src/sourcemaps.js +277 -1
  47. package/src/stdlib.js +253 -0
  48. package/src/typecheck.js +2023 -106
  49. package/src/types.js +127 -7
  50. package/docs/ui/accordion.rip +0 -103
  51. package/docs/ui/alert-dialog.rip +0 -53
  52. package/docs/ui/autocomplete.rip +0 -115
  53. package/docs/ui/avatar.rip +0 -37
  54. package/docs/ui/badge.rip +0 -15
  55. package/docs/ui/breadcrumb.rip +0 -47
  56. package/docs/ui/button-group.rip +0 -26
  57. package/docs/ui/button.rip +0 -23
  58. package/docs/ui/card.rip +0 -25
  59. package/docs/ui/carousel.rip +0 -110
  60. package/docs/ui/checkbox-group.rip +0 -61
  61. package/docs/ui/checkbox.rip +0 -33
  62. package/docs/ui/collapsible.rip +0 -50
  63. package/docs/ui/combobox.rip +0 -130
  64. package/docs/ui/context-menu.rip +0 -88
  65. package/docs/ui/date-picker.rip +0 -206
  66. package/docs/ui/dialog.rip +0 -60
  67. package/docs/ui/drawer.rip +0 -58
  68. package/docs/ui/editable-value.rip +0 -82
  69. package/docs/ui/field.rip +0 -53
  70. package/docs/ui/fieldset.rip +0 -22
  71. package/docs/ui/form.rip +0 -39
  72. package/docs/ui/grid.rip +0 -901
  73. package/docs/ui/input-group.rip +0 -28
  74. package/docs/ui/input.rip +0 -36
  75. package/docs/ui/label.rip +0 -16
  76. package/docs/ui/menu.rip +0 -134
  77. package/docs/ui/menubar.rip +0 -151
  78. package/docs/ui/meter.rip +0 -36
  79. package/docs/ui/multi-select.rip +0 -203
  80. package/docs/ui/native-select.rip +0 -33
  81. package/docs/ui/nav-menu.rip +0 -126
  82. package/docs/ui/number-field.rip +0 -162
  83. package/docs/ui/otp-field.rip +0 -89
  84. package/docs/ui/pagination.rip +0 -123
  85. package/docs/ui/popover.rip +0 -93
  86. package/docs/ui/preview-card.rip +0 -75
  87. package/docs/ui/progress.rip +0 -25
  88. package/docs/ui/radio-group.rip +0 -57
  89. package/docs/ui/resizable.rip +0 -123
  90. package/docs/ui/scroll-area.rip +0 -145
  91. package/docs/ui/select.rip +0 -151
  92. package/docs/ui/separator.rip +0 -17
  93. package/docs/ui/skeleton.rip +0 -22
  94. package/docs/ui/slider.rip +0 -165
  95. package/docs/ui/spinner.rip +0 -17
  96. package/docs/ui/table.rip +0 -27
  97. package/docs/ui/tabs.rip +0 -113
  98. package/docs/ui/textarea.rip +0 -48
  99. package/docs/ui/toast.rip +0 -87
  100. package/docs/ui/toggle-group.rip +0 -71
  101. package/docs/ui/toggle.rip +0 -24
  102. package/docs/ui/toolbar.rip +0 -38
  103. package/docs/ui/tooltip.rip +0 -85
  104. package/src/app.rip +0 -1571
  105. package/src/sourcemap-merge.js +0 -287
  106. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  107. /package/docs/demo/{components → routes}/about.rip +0 -0
  108. /package/docs/demo/{components → routes}/card.rip +0 -0
  109. /package/docs/demo/{components → routes}/counter.rip +0 -0
  110. /package/docs/demo/{components → routes}/index.rip +0 -0
  111. /package/docs/demo/{components → routes}/todos.rip +0 -0
  112. /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.