rip-lang 3.16.0 → 3.16.2

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 (57) hide show
  1. package/README.md +3 -4
  2. package/bin/rip +201 -18
  3. package/bin/rip-schema +175 -0
  4. package/docs/AGENTS.md +1 -1
  5. package/docs/RIP-APP.md +200 -19
  6. package/docs/RIP-DUCKDB.md +64 -1
  7. package/docs/RIP-INTRO.md +4 -4
  8. package/docs/RIP-LANG.md +36 -38
  9. package/docs/RIP-SCHEMA.md +1204 -364
  10. package/docs/RIP-TYPES.md +74 -103
  11. package/docs/demo/README.md +4 -3
  12. package/docs/dist/rip.js +4195 -966
  13. package/docs/dist/rip.min.js +1161 -284
  14. package/docs/dist/rip.min.js.br +0 -0
  15. package/docs/example/index.json +7 -7
  16. package/docs/example/index.json.br +0 -0
  17. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  18. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  19. package/docs/extensions/vscode/rip/index.html +2 -1
  20. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  21. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  22. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  23. package/docs/index.html +1 -1
  24. package/docs/ui/bundle.json +55 -55
  25. package/docs/ui/bundle.json.br +0 -0
  26. package/docs/ui/hljs-rip.js +1 -1
  27. package/docs/ui/index.html +1 -1
  28. package/package.json +15 -7
  29. package/rip-loader.js +59 -2
  30. package/src/AGENTS.md +43 -12
  31. package/src/browser.js +52 -11
  32. package/src/compiler.js +538 -80
  33. package/src/components.js +488 -48
  34. package/src/dts.js +80 -50
  35. package/src/grammar/README.md +29 -170
  36. package/src/grammar/grammar.rip +17 -12
  37. package/src/grammar/solar.rip +4 -17
  38. package/src/lexer.js +82 -32
  39. package/src/parser.js +229 -229
  40. package/src/schema/dts.js +328 -54
  41. package/src/schema/loader-server.js +2 -1
  42. package/src/schema/runtime-browser-stubs.js +20 -9
  43. package/src/schema/runtime-ddl.js +161 -44
  44. package/src/schema/runtime-migrate.js +681 -0
  45. package/src/schema/runtime-orm.js +698 -54
  46. package/src/schema/runtime-validate.js +808 -24
  47. package/src/schema/runtime.generated.js +2395 -135
  48. package/src/schema/schema.js +1054 -94
  49. package/src/typecheck.js +1610 -127
  50. package/src/types.js +90 -6
  51. package/src/grammar/lunar.rip +0 -2412
  52. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  53. /package/docs/demo/{components → routes}/about.rip +0 -0
  54. /package/docs/demo/{components → routes}/card.rip +0 -0
  55. /package/docs/demo/{components → routes}/counter.rip +0 -0
  56. /package/docs/demo/{components → routes}/index.rip +0 -0
  57. /package/docs/demo/{components → routes}/todos.rip +0 -0
@@ -4,33 +4,44 @@
4
4
 
5
5
  # Rip Schema
6
6
 
7
- > **One keyword. A validator, a class, an ORM, a migration tool, and a TypeScript type — from a single declaration.**
7
+ > **One keyword. A validator, a class, an ORM, migrations, and a live API contract — from a single declaration.**
8
8
 
9
- In a typical TypeScript application the shape of a `User` is described four
9
+ In a typical TypeScript application the shape of a `User` is described five
10
10
  times. Once as a Zod schema for input validation. Once as a Prisma model for
11
11
  the database. Once as a generated TypeScript type for the editor. Once as a
12
- DTO class for API projections. Every change has to be propagated across all
13
- four. Every divergence becomes a bug.
12
+ DTO class for API projections. Once more as an OpenAPI document for clients.
13
+ Every change has to be propagated across all five. Every divergence becomes
14
+ a bug.
14
15
 
15
- Rip Schema collapses all four into one declaration:
16
+ Rip Schema collapses all of them into one declaration:
16
17
 
17
18
  ```coffee
18
19
  User = schema :model
19
20
  name! string, 1..100
20
- email!# email
21
+ email! email @unique
22
+ phone? ~:phone
21
23
  @timestamps
22
24
  @has_many Order
23
25
  identifier: ~> "#{@name} <#{@email}>"
24
26
  beforeValidation: -> @email = @email.toLowerCase()
25
27
  ```
26
28
 
27
- From that single line of source, the language gives you:
29
+ From that single declaration, the language gives you:
28
30
 
29
- - a **runtime validator** — `User.parse(data)` / `.safe()` / `.ok()`
31
+ - a **runtime validator** — `User.parse(data)` / `.safe()` / `.ok()` (plus
32
+ async variants), with strict wire coercion (`~integer`, `~:phone`),
33
+ cross-field refinements, and discriminated unions
30
34
  - a **generated class** with your methods and `~>` computed getters bound as prototype getters
31
- - a **TypeScript type** — `ModelSchema<UserInstance, UserData>`, automatic, no codegen step
35
+ - a **TypeScript type** — `ModelSchema<User, UserData>`, automatic, no codegen step
32
36
  - an **async ORM** — `User.find! 1`, `User.where(active: true).all!`, `user.save!`
33
- - **migration-grade DDL** — `User.toSQL()` emits `CREATE TABLE`, indexes, foreign keys
37
+ - **transactions** — `schema.transaction! ->` with ambient propagation,
38
+ rollback, and `afterCommit` hooks
39
+ - **query economics** — `User.includes(:orders)` eager loading, composable
40
+ `@scope`s, batch writes; no N+1 by default
41
+ - **migrations** — `rip schema make/migrate` diffs the declared models
42
+ against the live database and writes plain-SQL migration files
43
+ - **a wire contract** — `User.toJSONSchema()`, and rip-server routes that
44
+ validate with a schema contribute to a generated `GET /openapi.json`
34
45
  - **schema algebra** — `User.omit("password")` produces a correctly-typed derived shape
35
46
 
36
47
  Schemas are runtime values. You pass them around, export them, derive from
@@ -49,32 +60,37 @@ architecture for contributors.
49
60
  1. [What Rip Schema is](#1-what-rip-schema-is)
50
61
  2. [A quick tour](#2-a-quick-tour)
51
62
  3. [Schemas vs types](#3-schemas-vs-types)
52
- 4. [The five kinds](#4-the-five-kinds)
63
+ 4. [The six kinds](#4-the-six-kinds)
53
64
  5. [Body syntax](#5-body-syntax)
54
65
  6. [The runtime API](#6-the-runtime-api)
55
66
  7. [What `.parse()` returns by kind](#7-what-parse-returns-by-kind)
56
- 8. [`:model` — ORM, DDL, hooks, relations](#8-model--orm-ddl-hooks-relations)
57
- 9. [Mixins](#9-mixins)
58
- 10. [Schema algebra](#10-schema-algebra)
59
- 11. [Shadow TypeScript](#11-shadow-typescript)
60
- 12. [SchemaError and diagnostics](#12-schemaerror-and-diagnostics)
61
- 13. [Common mistakes](#13-common-mistakes)
62
- 14. [Recipes](#14-recipes)
63
- 15. [What's not here yet](#15-whats-not-here-yet)
67
+ 8. [`:model` — the ORM](#8-model--the-orm)
68
+ 9. [Transactions & data integrity](#9-transactions--data-integrity)
69
+ 10. [Query economics](#10-query-economics)
70
+ 11. [Adapters](#11-adapters)
71
+ 12. [DDL & schema evolution](#12-ddl--schema-evolution)
72
+ 13. [Wire contracts — JSON Schema & OpenAPI](#13-wire-contracts--json-schema--openapi)
73
+ 14. [Mixins](#14-mixins)
74
+ 15. [Schema algebra](#15-schema-algebra)
75
+ 16. [Shadow TypeScript](#16-shadow-typescript)
76
+ 17. [SchemaError and diagnostics](#17-schemaerror-and-diagnostics)
77
+ 18. [Common mistakes](#18-common-mistakes)
78
+ 19. [Recipes](#19-recipes)
79
+ 20. [What's not here yet](#20-whats-not-here-yet)
64
80
 
65
81
  ## Part II — Reference
66
- 16. [Capability matrix](#16-capability-matrix)
67
- 17. [Field types](#17-field-types)
68
- 18. [Directives](#18-directives)
69
- 19. [Hook reference](#19-hook-reference)
70
- 20. [Constraints](#20-constraints)
71
- 21. [Relations](#21-relations)
72
- 22. [Design invariants](#22-design-invariants)
82
+ 21. [Capability matrix](#21-capability-matrix)
83
+ 22. [Field types](#22-field-types)
84
+ 23. [Directives](#23-directives)
85
+ 24. [Hook reference](#24-hook-reference)
86
+ 25. [Constraints](#25-constraints)
87
+ 26. [Relations](#26-relations)
88
+ 27. [Design invariants](#27-design-invariants)
73
89
 
74
90
  ## Part III — Architecture
75
- 23. [Runtime architecture](#23-runtime-architecture)
76
- 24. [Compiler integration](#24-compiler-integration)
77
- 25. [FAQ](#25-faq)
91
+ 28. [Runtime architecture](#28-runtime-architecture)
92
+ 29. [Compiler integration](#29-compiler-integration)
93
+ 30. [FAQ](#30-faq)
78
94
 
79
95
  ---
80
96
 
@@ -86,9 +102,9 @@ A *schema* in Rip is a runtime value that describes data. You create one with
86
102
  the `schema` keyword and an optional `:kind` symbol:
87
103
 
88
104
  ```coffee
89
- SignupInput = schema # default :input
90
- Role = schema :enum
91
- User = schema :model
105
+ SignupInput = schema; email! # default :input
106
+ Role = schema :enum; :admin; :user
107
+ User = schema :model; name!
92
108
  ```
93
109
 
94
110
  Every schema is a real JavaScript object at runtime. It has methods
@@ -122,7 +138,7 @@ schema dialect, its own types, its own runtime, its own failure modes:
122
138
  | Input validation | Zod, Yup, Joi, io-ts, Valibot | `schema :input` + `.parse/.safe` |
123
139
  | Domain objects with logic | hand-written classes + `zod.infer` | `schema :shape` |
124
140
  | Database models | Prisma, Drizzle, TypeORM, Sequelize | `schema :model` |
125
- | Migrations / DDL | Prisma migrate, Drizzle Kit, knex | `Model.toSQL()` |
141
+ | Migrations / DDL | Prisma migrate, Drizzle Kit, knex | `Model.toSQL()` + `rip schema make/migrate` |
126
142
  | API projections / DTOs | `.pick` / `.omit` on Zod + class | `Model.pick/.omit/.partial/.extend` |
127
143
  | Static types for the editor | Inferred from every library above | Automatic shadow TS — no codegen |
128
144
  | Fixed value sets | TS `enum` or string unions | `schema :enum` (runtime + static) |
@@ -190,6 +206,27 @@ result = SignupInput.safe rawJson
190
206
  valid = SignupInput.ok rawJson
191
207
  ```
192
208
 
209
+ ### Validating a list
210
+
211
+ `.array` turns any schema into a *list-of-that* schema, exposing the same
212
+ validation family — the common shape for an API response (one request, many
213
+ records):
214
+
215
+ ```coffee
216
+ Product = schema :shape
217
+ id! integer
218
+ name! string
219
+
220
+ items = Product.array.parse rawJson # → Product[]; throws on any bad item
221
+ result = Product.array.safe rawJson # → {ok, value: Product[], errors}
222
+ allGood = Product.array.ok rawJson # → boolean
223
+ ```
224
+
225
+ A non-array input fails fast with an error naming what it got — so an enveloped
226
+ `{ items: [...] }` passed whole, or a renamed key, is obvious — and each item
227
+ failure carries its `[index]` so a bad record is locatable. `parseAsync` /
228
+ `safeAsync` / `okAsync` mirror the async variants.
229
+
193
230
  ### A shape with behavior
194
231
 
195
232
  ```coffee
@@ -231,7 +268,7 @@ Status.ok "unknown" # false
231
268
  ```coffee
232
269
  User = schema :model
233
270
  name! string, 1..100
234
- email!# email
271
+ email! email @unique
235
272
  @timestamps
236
273
  @has_many Order
237
274
 
@@ -246,21 +283,51 @@ Order = schema :model
246
283
  # DDL for migration (works with or without the ORM adapter configured)
247
284
  sql = User.toSQL()
248
285
 
249
- # ORM operations (async, use `!` or `await`)
286
+ # ORM operations (async `!` is the dammit operator, Rip's await)
250
287
  user = User.create! name: "Alice", email: "ALICE@EXAMPLE.COM"
251
288
  found = User.find! user.id
252
289
  orders = user.orders! # has_many relation → Order[]
253
290
  owner = orders[0]?.user! # belongs_to relation → User
254
291
  ```
255
292
 
293
+ ### The production layer
294
+
295
+ The same declaration carries the whole data layer — atomic writes,
296
+ constant-query-count reads, schema evolution, and a live API contract:
297
+
298
+ ```coffee
299
+ # Transactions — ambient propagation; model code inside is unchanged
300
+ order = schema.transaction! ->
301
+ o = Order.create! userId: user.id, total: 100
302
+ user.name = "Alice Q."
303
+ user.save!
304
+ o # block value = transaction value
305
+
306
+ # Eager loading + scopes — a list view in three queries, any row count
307
+ users = User.includes(orders: :user).where(active: true).all!
308
+
309
+ # Migrations — diff the declared models against the live database
310
+ # rip schema status applied / pending / drift + the classified plan
311
+ # rip schema make NAME write migrations/NNNN_NAME.sql from the diff
312
+ # rip schema migrate apply pending files, checksummed history
313
+
314
+ # Wire contract — schema-validated routes feed a generated OpenAPI doc
315
+ post '/signup', input: SignupInput, ->
316
+ { welcome: @input.email } # 400 + issues automatic
317
+ ```
318
+
319
+ Each of these has its own section: [§9](#9-transactions--data-integrity),
320
+ [§10](#10-query-economics), [§12](#12-ddl--schema-evolution),
321
+ [§13](#13-wire-contracts--json-schema--openapi).
322
+
256
323
  ### Schema algebra — derive new shapes
257
324
 
258
325
  ```coffee
259
326
  UserPublic = User.omit "email" # → Schema<Omit<UserData, 'email'>>
260
327
  UserCreate = User.pick "name", "email" # → Schema<Pick<UserData, 'name' | 'email'>>
261
328
  UserUpdate = User.partial() # → Schema<Partial<UserData>>
262
- AdminUser = User.extend schema :shape
263
- permissions! string[]
329
+ AdminUser = User.extend (schema :shape
330
+ permissions! string[])
264
331
  ```
265
332
 
266
333
  Derived schemas are always `:shape`. **Field semantics survive** —
@@ -285,9 +352,9 @@ User = schema
285
352
  schema.defaultMaxString = 500
286
353
 
287
354
  Profile = schema :model
288
- name! # → {min: 1, max: 500}
289
- email!# email # → {max: 500}
290
- bio? text # → uncapped (text opts out)
355
+ name! # → {min: 1, max: 500}
356
+ email! email @unique # → {max: 500}
357
+ bio? text # → uncapped (text opts out)
291
358
 
292
359
  # One-line small shapes, plus a registered schema used as a field type.
293
360
  Address = schema :shape; street? ..200; city? ..100; zip? ..10
@@ -296,8 +363,8 @@ Order = schema :shape
296
363
  address! Address # validation recurses; errors like "address.street"
297
364
  ```
298
365
 
299
- See §5 for body-syntax details, §17 for nested type references, and
300
- §20 for constraint and pragma rules.
366
+ See §5 for body-syntax details, §22 for nested type references, and
367
+ §25 for constraint and pragma rules.
301
368
 
302
369
  ---
303
370
 
@@ -342,17 +409,19 @@ plus the runtime dimension.
342
409
 
343
410
  ---
344
411
 
345
- ## 4. The five kinds
412
+ ## 4. The six kinds
346
413
 
347
- Every schema has one of five kinds, selected by a `:symbol` after the
414
+ Every schema has one of six kinds, selected by a `:symbol` after the
348
415
  `schema` keyword:
349
416
 
417
+ <!-- doctest: skip -->
350
418
  ```coffee
351
419
  input = schema # default — :input
352
420
  shape = schema :shape
353
421
  enum = schema :enum
354
422
  mixin = schema :mixin
355
423
  model = schema :model
424
+ union = schema :union
356
425
  ```
357
426
 
358
427
  The kind determines which body forms are legal, what `.parse()` returns,
@@ -430,6 +499,48 @@ User = schema :model
430
499
  Mixins are fields-only. Methods, computed, hooks, and non-`@mixin`
431
500
  directives inside a mixin body are compile errors.
432
501
 
502
+ ### `:union`
503
+
504
+ A discriminated union over registered schemas. The body names the
505
+ discriminator field (`@on :field`, required — untagged unions are a
506
+ non-goal: they make dispatch O(n) and error messages incoherent) and
507
+ two or more constituent schemas, one per line:
508
+
509
+ ```coffee
510
+ ClickEvent = schema :shape
511
+ kind! "click" # single-literal constant — the tag
512
+ x! integer
513
+ y! integer
514
+
515
+ ScrollEvent = schema :shape
516
+ kind! "scroll"
517
+ delta! integer
518
+
519
+ Event = schema :union
520
+ @on :kind
521
+ ClickEvent
522
+ ScrollEvent
523
+
524
+ Event.parse(kind: "click", x: 1, y: 2) # → ClickEvent instance
525
+ Event.safe(kind: "hover") # → {field: "kind", error: "union",
526
+ # message: "expected one of click | scroll"}
527
+ ```
528
+
529
+ - Each constituent must declare the discriminator as a string-literal
530
+ type, all values distinct across the union — checked at first parse
531
+ (lazy, consistent with registry resolution), with the colliding
532
+ constituents named in the error.
533
+ - `.parse(data)` reads the discriminator and dispatches O(1) to the
534
+ matching constituent's validator; the result is that constituent's
535
+ instance (shapes keep their behavior).
536
+ - Usable as a field type like any registered schema: `events! Event[]`.
537
+ - If any constituent is async-validating (`@ensure!`), the union is too
538
+ — use `parseAsync!` / `safeAsync!` / `okAsync!`.
539
+ - Schema algebra on a union throws — distribute-vs-intersect has no
540
+ obviously-right answer, so v1 defers; derive from a constituent.
541
+ - Shadow TS: `type Event = ClickEvent | ScrollEvent;` — narrowing via
542
+ the discriminator works natively.
543
+
433
544
  ### `:model`
434
545
 
435
546
  A DB-backed entity. Everything `:shape` offers (all field forms,
@@ -443,7 +554,7 @@ instances.
443
554
  ```coffee
444
555
  User = schema :model
445
556
  name! string
446
- email!# email
557
+ email! email @unique
447
558
  @timestamps
448
559
  @has_many Order
449
560
 
@@ -461,6 +572,7 @@ a schema-specific diagnostic.
461
572
 
462
573
  ### Field
463
574
 
575
+ <!-- doctest: skip -->
464
576
  ```coffee
465
577
  name[!|?|#]* [type] [range] [default] [regex] [attrs] [, -> transform]
466
578
  ```
@@ -470,32 +582,111 @@ Modifiers:
470
582
  | Modifier | Meaning |
471
583
  | -------- | -------- |
472
584
  | `!` | required |
473
- | `#` | unique (emits `UNIQUE` in DDL; also creates a unique index) |
474
585
  | `?` | optional |
475
586
 
476
- Any combination works (`email!#` means required + unique). Order among
477
- modifiers doesn't matter. No modifier means "present but not required" —
587
+ `!` and `?` are *shape* modifiers they apply to every schema kind, and
588
+ order doesn't matter. No modifier means "present but not required" —
478
589
  equivalent to `?` for validation purposes.
479
590
 
591
+ Uniqueness is **not** a modifier — it's a *storage* constraint (only
592
+ meaningful on `:model`), spelled `@unique`: inline as `email! email @unique`,
593
+ or as a directive `@unique :email` (composite: `@unique [:partnerId, :mrn]`).
594
+
480
595
  **Type is optional** — when omitted, the field defaults to `string`. Type
481
596
  expressions accept:
482
597
 
483
598
  - a type identifier (`string`, `email`, `integer`, …)
484
599
  - an array suffix (`string[]`)
485
600
  - a string-literal union (`"M" | "F" | "U"`) — value must be one of the
486
- listed members; 2+ members required, no mixing with base types
487
-
488
- ```coffee
489
- name! # required string (default type)
490
- tags! string[] # required array of strings
491
- email!# email # required, unique, email-format-validated
492
- bio? text, 0..1000 # optional text, 0-1000 chars
493
- role? string, ["user"] # optional, default "user"
494
- status string, [:draft] # default :draft same as ["draft"]
495
- zip! string, /^\d{5}$/ # regex-validated
496
- sex? "M" | "F" | "U" # literal union
497
- priority "low" | "med" | "high", [:med] # literal union + default
498
- ```
601
+ listed members; no mixing with base types. A single literal
602
+ (`kind! "click"`) is a constant field — the building block of
603
+ [discriminated unions](#union)
604
+ - a `~`-prefixed coercible type (`~integer`, `~number`, `~boolean`,
605
+ `~date`) "coerce, then validate" (see below)
606
+
607
+ ```coffee
608
+ Example = schema
609
+ name! # required string (default type)
610
+ tags! string[] # required array of strings
611
+ email! email @unique # required, unique, email-format-validated
612
+ bio? text, 0..1000 # optional text, 0-1000 chars
613
+ role? string, ["user"] # optional, default "user"
614
+ status string, [:draft] # default :draft — same as ["draft"]
615
+ zip! string, /^\d{5}$/ # regex-validated
616
+ sex? "M" | "F" | "U" # literal union
617
+ priority "low" | "med" | "high", [:med] # literal union + default
618
+ ```
619
+
620
+ ### Coercion types (`~type`)
621
+
622
+ Wire data arrives as strings; a `~` prefix on a coercible built-in
623
+ means the value converts through a strict table before validation:
624
+
625
+ ```coffee
626
+ SearchParams = schema
627
+ page? ~integer # "42" → 42; "abc" → {error: 'coerce'}
628
+ minPrice? ~number # "19.95" → 19.95
629
+ active? ~boolean # "true"/"1"/1 → true; "false"/"0"/0 → false
630
+ since? ~date # ISO-8601 string or epoch ms → Date
631
+ ```
632
+
633
+ - Coercion tables are strict and documented: `~integer` accepts
634
+ integral strings and integral numbers, rejects `NaN` and `"12.5"`;
635
+ `~boolean` accepts exactly the six tokens above; `~date` accepts
636
+ ISO-8601 strings and finite epoch numbers. Failed coercion is
637
+ `{error: 'coerce'}` — distinct from `{error: 'type'}`, because "looked
638
+ like wire data but didn't convert" is a different mistake than "wrong
639
+ shape entirely".
640
+ - Constraints apply **after** coercion — `age? ~integer, 18..120` range-
641
+ checks the coerced number.
642
+ - Coercion is field semantics, so it **survives algebra** (`.pick`,
643
+ `.omit`, …) like transforms do, and is **skipped on DB hydrate**
644
+ (rows arrive canonical).
645
+ - `~` doesn't combine with a `->` transform (the transform IS manual
646
+ control of the same step), doesn't apply to arrays, and only covers
647
+ the four wire-friendly built-ins — everything else wants a named
648
+ coercer or an explicit transform.
649
+
650
+ ### Named coercers (`~:name`)
651
+
652
+ A `~:symbol` in the type slot coerces through the **named-coercer
653
+ registry** — and `@rip-lang/validate` (the zero-dependency,
654
+ browser-safe package behind `@rip-lang/server`'s `read()` vocabulary)
655
+ registers every battle-tested wire normalizer (`id`, `money`, `ssn`,
656
+ `phone`, `name`, `date`, `state`, `zipplus4`, `slug`, `ids`, …) there
657
+ at load, so they all work in a schema field:
658
+
659
+ ```coffee
660
+ Patient = schema :model
661
+ chart! ~:id, 1..99999 # "42" → 42 (integer; constraint after coercion)
662
+ ssn? ~:ssn # "123-45-6789" → "123456789"
663
+ phone? ~:phone # "8016542000" → "(801) 654-2000"
664
+ state? ~:state # "ut" → "UT"
665
+ dob? ~:date # normalized "YYYY-MM-DD" string
666
+ amount? ~:money # "$1,234.50" → 1234.5
667
+ kids? ~:ids # "3, 1, 2, 2" → [1, 2, 3]
668
+ ```
669
+
670
+ - The normalizer behind `read 'dob', 'date'` is the *same function*
671
+ behind `dob? ~:date` — one vocabulary, two call sites.
672
+ `registerValidator` registers both; schema-only code can use
673
+ `schema.registerCoercer name, fn` directly.
674
+ - The vocabulary is **isomorphic**: importing `@rip-lang/server` loads
675
+ it on the server, and a side-effect `import '@rip-lang/validate'` in
676
+ any component file loads it in the browser bundle — so a schema with
677
+ `~:ssn` parses identically on both sides of the wire.
678
+ - A coercer returning `null`/`undefined`/`false` fails the field with
679
+ `{error: 'coerce', message: "<field> is not a valid <name>"}`. A
680
+ coercer that isn't registered at parse time is a **config error**
681
+ (fail loud), not a validation failure.
682
+ - Output types: the shipped names carry static output types for shadow
683
+ TS and DDL (`~:id` → `number`/INTEGER, `~:money` → `number`,
684
+ `~:ids` → `number[]`, `~:ssn` → `string`, …). Custom-registered
685
+ names type as `any` — use an explicit transform when you need a
686
+ precise static type.
687
+ - Note the namespaces differ: `~date` (built-in) coerces to a `Date`
688
+ instance; `~:date` (named) normalizes to a `"YYYY-MM-DD"` string —
689
+ exactly what `read 'x', 'date'` returns.
499
690
 
500
691
  ### Inline field transform
501
692
 
@@ -505,11 +696,12 @@ raw input. `it` inside the body refers to the **whole raw input object**
505
696
  differently-named key, compose across multiple inputs, or coerce types:
506
697
 
507
698
  ```coffee
508
- id! -> it.Id # remap PascalCase input
509
- displayName! -> it.DisplayName
510
- shippedAt? date, -> new Date(it.shippedAt) # wire string → Date
511
- slug! -> "#{it.FirstName}-#{it.LastName}".toLowerCase()
512
- email!# email, -> it.email.toLowerCase().trim() # normalize + validate
699
+ Imported = schema
700
+ id! -> it.Id # remap PascalCase input
701
+ displayName! -> it.DisplayName
702
+ shippedAt? date, -> new Date(it.shippedAt) # wire string → Date
703
+ slug! -> "#{it.FirstName}-#{it.LastName}".toLowerCase()
704
+ email! email @unique, -> it.email.toLowerCase().trim() # normalize + validate
513
705
  ```
514
706
 
515
707
  Rules:
@@ -521,7 +713,7 @@ Rules:
521
713
  the line (type, range, regex, default, attrs). The comma is a
522
714
  structural boundary between the field declaration and the
523
715
  transform, not an argument separator — without it, lines like
524
- `email!# email -> fn` misleadingly suggest `email` is an input to
716
+ `email! email -> fn` misleadingly suggest `email` is an input to
525
717
  the arrow. The bare form `name! -> fn` (nothing before the arrow
526
718
  except the name and modifiers) parses comma-less because there's
527
719
  nothing to elide. This is unlike Rip's general `get '/path' ->`
@@ -541,7 +733,7 @@ Rules:
541
733
  ```
542
734
 
543
735
  Directives attach behavior that isn't a field. The set depends on the
544
- kind (see [§18](#18-directives)). Examples:
736
+ kind (see [§23](#23-directives)). Examples:
545
737
 
546
738
  ```coffee
547
739
  @timestamps # adds createdAt/updatedAt columns (:model only)
@@ -557,21 +749,33 @@ kind (see [§18](#18-directives)). Examples:
557
749
 
558
750
  ```coffee
559
751
  name: -> body
752
+ name: (params) -> body
560
753
  ```
561
754
 
562
755
  Thin-arrow method bound on the generated class prototype. `this` is the
563
- instance. For `:model`, method names matching known [hook
564
- names](#19-hook-reference) bind to the lifecycle; on other kinds those
565
- names are just methods.
756
+ instance. Parameters are optional and may carry Rip type annotations,
757
+ which flow into shadow TS a fully-annotated method gets its complete
758
+ signature (typed params, `this`, and the inferred return) instead of
759
+ `(...args: any[]) => unknown`:
566
760
 
567
761
  ```coffee
568
762
  greet: -> "Hello, #{@name}!"
569
763
 
764
+ add: (other:: Money) ->
765
+ Money.parse amount: @amount + other.amount, currency: @currency
766
+ # shadow TS: add(this: Money, other: Money): Money
767
+
570
768
  beforeSave: ->
571
769
  @email = @email.toLowerCase()
572
770
  @slug = @name.toLowerCase().replace(/\s+/g, '-')
573
771
  ```
574
772
 
773
+ Parameters are method-only — lifecycle hooks, computed getters (`~>`),
774
+ and eager-derived fields (`!>`) are accessor-shaped and reject them.
775
+ For `:model`, method names matching known [hook
776
+ names](#24-hook-reference) bind to the lifecycle; on other kinds those
777
+ names are just methods.
778
+
575
779
  ### Computed getter (lazy)
576
780
 
577
781
  ```coffee
@@ -591,6 +795,7 @@ isAdmin: ~> @role is 'admin'
591
795
 
592
796
  ### Eager-derived field
593
797
 
798
+ <!-- doctest: skip -->
594
799
  ```coffee
595
800
  name: !> body
596
801
  ```
@@ -602,9 +807,11 @@ Excluded from DDL and persistence — re-computed on hydrate from the
602
807
  declared fields.
603
808
 
604
809
  ```coffee
605
- fullName: !> "#{@firstName} #{@lastName}".trim()
606
- orderNumber: !> "ORD-#{String(@id).padStart(6, '0')}"
607
- slug: !> @fullName.toLowerCase().replace(/\s+/g, '-')
810
+ Person = schema :shape
811
+ firstName! string
812
+ lastName! string
813
+ fullName: !> "#{@firstName} #{@lastName}".trim()
814
+ slug: !> @fullName.toLowerCase().replace(/\s+/g, '-')
608
815
  ```
609
816
 
610
817
  Declaration order matters — an `!>` can read earlier declared fields
@@ -652,6 +859,36 @@ Two forms, same semantics:
652
859
  ]
653
860
  ```
654
861
 
862
+ An optional `:field` symbol between the message and the predicate
863
+ **attributes the failure to a specific field**, so form libraries can
864
+ attach the error to the right input (without it, the issue stays
865
+ schema-wide with `field: ''`):
866
+
867
+ ```coffee
868
+ @ensure "passwords must match", :password2, (u) -> u.password is u.password2
869
+ # fails as {field: "password2", error: "ensure", message: "passwords must match"}
870
+ ```
871
+
872
+ **Async refinements** use the dammit operator — `@ensure!` — for
873
+ predicates that await a database or network check:
874
+
875
+ ```coffee
876
+ Signup = schema
877
+ email! email
878
+ @ensure! "email already registered", :email, (u) ->
879
+ not await User.where(email: u.email).first()
880
+ ```
881
+
882
+ A schema with ≥1 `@ensure!` is **async-validating**: sync `.parse` /
883
+ `.safe` / `.ok` throw immediately ("use parseAsync!/safeAsync!/okAsync!")
884
+ — no silent promise-leak, no sometimes-sync API. Sync refinements run
885
+ first (cheap before expensive); async refinements then run
886
+ **concurrently** (`Promise.all`) with all results collected in
887
+ declaration order, preserving the no-short-circuit rule. A rejected
888
+ async predicate counts as failed with the declared message. Async
889
+ refinements are skipped on hydrate and dropped by algebra, same as
890
+ sync ones.
891
+
655
892
  Both forms compile to the same internal representation; use whichever
656
893
  reads cleanest for the case at hand. The inline form is nicer for
657
894
  one-offs; the array form keeps related invariants visually grouped.
@@ -685,7 +922,7 @@ Rules:
685
922
  would be wasted work.
686
923
  - **Refinements drop on algebra.** Any derivation (`.pick`, `.omit`,
687
924
  `.partial`, `.required`, `.extend`) returns a `:shape` without any
688
- refinements from the source. See [§10](#10-schema-algebra).
925
+ refinements from the source. See [§15](#15-schema-algebra).
689
926
 
690
927
  **Scope**: `:input`, `:shape`, and `:model` accept `@ensure`. `:enum`
691
928
  and `:mixin` reject it at compile time with a diagnostic pointing at
@@ -737,6 +974,7 @@ hook / transform), `~>` (computed getter), `!>` (eager-derived) — is
737
974
  rejected on the inline form with a message pointing to the indented
738
975
  form:
739
976
 
977
+ <!-- doctest: fail -->
740
978
  ```coffee
741
979
  # compile error — point at the indented form:
742
980
  X = schema :shape; name!; greet: -> @name # ✗ '->' not allowed inline
@@ -800,14 +1038,25 @@ Validates `data`. Returns a boolean. Allocates no error arrays — this is
800
1038
  the fast path for filter-style checks.
801
1039
 
802
1040
  ```coffee
803
- if User.ok raw
804
- # ...
1041
+ process raw if User.ok raw
805
1042
  ```
806
1043
 
807
- ### Async variants (`parse!`, `safe!`, `ok!`)
1044
+ ### `.parseAsync(data)` / `.safeAsync(data)` / `.okAsync(data)`
1045
+
1046
+ Async validation entry points. They exist on **every** schema (sync-only
1047
+ schemas just resolve immediately) and are **required** when the schema
1048
+ has `@ensure!` async refinements — the sync trio throws on those schemas
1049
+ rather than sometimes-returning a promise. The dammit operator gives the
1050
+ idiomatic call:
1051
+
1052
+ ```coffee
1053
+ user = Signup.parseAsync! raw
1054
+ r = Signup.safeAsync! raw
1055
+ ok = Signup.okAsync! raw
1056
+ ```
1057
+
1058
+ ### The dammit operator and the ORM
808
1059
 
809
- Every method has a dammit-operator variant that awaits the result. For
810
- `:input`/`:shape`/`:enum` these are sync, so `!` is a no-op (harmless).
811
1060
  For `:model`, the ORM methods are all genuinely async and `!` is the
812
1061
  canonical form:
813
1062
 
@@ -827,6 +1076,7 @@ users = User.where(active: true).all!
827
1076
  | `:shape` | Instance of a generated class — fields as enumerable own properties, methods and getters on the prototype | same |
828
1077
  | `:enum` | The member value (or the name string, for bare enums) | same |
829
1078
  | `:model` | **Unpersisted** instance — same structure as `:shape`, but the class also has `save()`, `destroy()`, relation methods, and `_persisted` state | same |
1079
+ | `:union` | The matching constituent's `.parse()` result — dispatched O(1) on the discriminator | same |
830
1080
  | `:mixin` | **Not instantiable** — `.parse()` throws | N/A |
831
1081
 
832
1082
  For `:shape` and `:model`:
@@ -841,32 +1091,42 @@ For `:shape` and `:model`:
841
1091
 
842
1092
  ---
843
1093
 
844
- ## 8. `:model` — ORM, DDL, hooks, relations
1094
+ ## 8. `:model` — the ORM
845
1095
 
846
1096
  `:model` is where everything comes together. A model declaration gives
847
1097
  you:
848
1098
 
849
1099
  - field validation (from `:shape`)
850
1100
  - class instances with methods and computed getters (from `:shape`)
851
- - lifecycle hooks bound by name
1101
+ - lifecycle hooks bound by name (12 of them, including transaction-aware
1102
+ `afterCommit` / `afterRollback`)
852
1103
  - an async ORM — `find`, `where`, `create`, `save`, `destroy`
853
- - `.toSQL()` for DDL (works without ever touching the ORM)
854
1104
  - relation accessors driven by `@belongs_to` / `@has_many` / `@has_one`
855
1105
  - automatic registration in a process-global registry for cross-module
856
1106
  relation resolution
857
1107
 
1108
+ This section covers the core read/write surface. The rest of the data
1109
+ layer has its own sections: transactions and constraint translation
1110
+ ([§9](#9-transactions--data-integrity)), eager loading / scopes / batch
1111
+ writes / soft deletes ([§10](#10-query-economics)), adapters
1112
+ ([§11](#11-adapters)), DDL and migrations
1113
+ ([§12](#12-ddl--schema-evolution)).
1114
+
858
1115
  ### Static ORM methods
859
1116
 
860
1117
  ```coffee
861
- User.find! id # → UserInstance | null
862
- User.findMany! [1, 2, 3] # → UserInstance[]
863
- User.where(active: true).all! # → UserInstance[]
864
- User.where(active: true).first! # → UserInstance | null
1118
+ User.find! id # → User | null
1119
+ User.findMany! [1, 2, 3] # → User[] (one IN query)
1120
+ User.where(active: true).all! # → User[]
1121
+ User.where(active: true).first! # → User | null
865
1122
  User.where(active: true).count! # → number
866
- User.all! # → UserInstance[]
867
- User.first! # → UserInstance | null
1123
+ User.includes(:orders).all! # → eager-loaded (see below)
1124
+ User.all! # → User[]
1125
+ User.first! # → User | null
868
1126
  User.count! # → number
869
1127
  User.create! name: "Alice", email: "a@b.c"
1128
+ User.upsert! {email: "a@b.c", name: "Al"}, on: :email # INSERT … ON CONFLICT
1129
+ User.insertMany! rows # validate all, one multi-VALUES INSERT
870
1130
  User.toSQL() # → DDL string (no DB call)
871
1131
  ```
872
1132
 
@@ -875,16 +1135,19 @@ User.toSQL() # → DDL string (no DB call)
875
1135
  ```coffee
876
1136
  User
877
1137
  .where(active: true) # object → AND equalities
1138
+ .where(id: [1, 2, 3]) # array value → IN (…)
878
1139
  .where("created_at > ?", since) # raw SQL + params
1140
+ .includes(:orders) # eager-load relations (see below)
879
1141
  .order("last_name, first_name") # or .orderBy — same thing
880
1142
  .limit(10)
881
1143
  .offset(20)
882
1144
  .all!
883
1145
  ```
884
1146
 
885
- - `.where`, `.limit`, `.offset`, `.order` / `.orderBy` return the query
886
- builder (sync).
887
- - `.all`, `.first`, `.count` terminate with a promise.
1147
+ - `.where`, `.includes`, `.limit`, `.offset`, `.order` / `.orderBy`,
1148
+ `.withDeleted`, `.onlyDeleted` return the query builder (sync).
1149
+ - `.all`, `.first`, `.count`, `.updateAll`, `.deleteAll` terminate with
1150
+ a promise.
888
1151
 
889
1152
  ### Instance methods
890
1153
 
@@ -893,6 +1156,8 @@ Every `:model` instance carries:
893
1156
  ```coffee
894
1157
  user.save! # validate, run hooks, INSERT or UPDATE
895
1158
  user.destroy! # run hooks, DELETE (or UPDATE deleted_at for @softDelete)
1159
+ user.destroy! hard: true # force a real DELETE on a @softDelete model
1160
+ user.restore! # @softDelete only — UPDATE deleted_at = NULL
896
1161
  user.ok() # boolean — current fields validate
897
1162
  user.errors() # SchemaIssue[] — current fields' errors
898
1163
  user.toJSON() # plain object of own enumerable properties
@@ -1022,8 +1287,8 @@ the same instance work fine.
1022
1287
 
1023
1288
  ### Lifecycle hooks
1024
1289
 
1025
- Hooks are methods whose name matches one of the [ten recognized hook
1026
- names](#19-hook-reference). On `:model` they bind into the lifecycle; on
1290
+ Hooks are methods whose name matches one of the [twelve recognized hook
1291
+ names](#24-hook-reference). On `:model` they bind into the lifecycle; on
1027
1292
  other kinds they're just regular methods.
1028
1293
 
1029
1294
  **Save flow:**
@@ -1061,6 +1326,12 @@ Validation happens **after** `beforeValidation` (so that hook is the
1061
1326
  right place to normalize input) and **before** `beforeSave` (so `beforeSave`
1062
1327
  only runs on already-valid data).
1063
1328
 
1329
+ `afterCommit` and `afterRollback` sit outside both flows: when a
1330
+ `schema.transaction!` is open they queue on it and fire after the
1331
+ outermost COMMIT / ROLLBACK; with no transaction open, `afterCommit`
1332
+ fires immediately after a successful save/destroy and `afterRollback`
1333
+ never fires. See the Transactions section above.
1334
+
1064
1335
  ### Relations
1065
1336
 
1066
1337
  ```coffee
@@ -1079,11 +1350,11 @@ Relation accessors are **async methods** on the instance prototype:
1079
1350
 
1080
1351
  ```coffee
1081
1352
  user = User.find! 1
1082
- orders = user.orders! # → OrderInstance[]
1083
- profile = user.profile! # → ProfileInstance | null
1353
+ orders = user.orders! # → Order[]
1354
+ profile = user.profile! # → Profile | null
1084
1355
 
1085
1356
  order = Order.find! 42
1086
- owner = order.user! # → UserInstance | null
1357
+ owner = order.user! # → User | null
1087
1358
  ```
1088
1359
 
1089
1360
  Accessor names:
@@ -1096,81 +1367,9 @@ Targets resolve lazily through a process-global registry keyed by name.
1096
1367
  Circular and cross-module references work — import the file that defines
1097
1368
  the target, and relation calls succeed.
1098
1369
 
1099
- See [§21 Relations](#21-relations) for the full table of directive →
1370
+ See [§26 Relations](#26-relations) for the full table of directive →
1100
1371
  accessor → return type.
1101
1372
 
1102
- ### DDL (`.toSQL()`)
1103
-
1104
- `.toSQL()` returns `CREATE SEQUENCE` + `CREATE TABLE` + index `CREATE`
1105
- statements for a model. It does not touch the database — you run the
1106
- output through whatever migration plumbing you prefer.
1107
-
1108
- ```coffee
1109
- User.toSQL()
1110
- # CREATE SEQUENCE users_seq START 1;
1111
- #
1112
- # CREATE TABLE users (
1113
- # id INTEGER PRIMARY KEY DEFAULT nextval('users_seq'),
1114
- # name VARCHAR(100) NOT NULL,
1115
- # email VARCHAR NOT NULL UNIQUE,
1116
- # created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1117
- # updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
1118
- # );
1119
- #
1120
- # CREATE UNIQUE INDEX idx_users_email ON users ("email");
1121
- ```
1122
-
1123
- `.toSQL()` works independently of the ORM. A migration script that never
1124
- calls `.find()` or `.create()` can still emit full DDL.
1125
-
1126
- #### Sequence start value
1127
-
1128
- The auto-id sequence seeds at `1` by default. Override per-model with the
1129
- `@idStart N` directive, or per-call with the `idStart` option (the option
1130
- wins):
1131
-
1132
- ```coffee
1133
- User = schema :model
1134
- name! string
1135
- @idStart 10001 # customer-facing IDs start at 10001
1136
-
1137
- User.toSQL() # → CREATE SEQUENCE users_seq START 10001;
1138
- User.toSQL(idStart: 50000) # → CREATE SEQUENCE users_seq START 50000;
1139
- ```
1140
-
1141
- Required because DuckDB (as of 1.5.2) does not implement
1142
- `ALTER SEQUENCE … RESTART WITH N` — so the seed has to be baked into the
1143
- initial `CREATE SEQUENCE` rather than bumped in a follow-up migration.
1144
-
1145
- To emit a whole application's schema, call `.toSQL()` per model and join.
1146
- Order by FK dependency (models referenced via `@belongs_to` come first):
1147
-
1148
- ```coffee
1149
- ddl = [
1150
- User.toSQL()
1151
- Category.toSQL()
1152
- Order.toSQL() # references User
1153
- OrderItem.toSQL() # references Order
1154
- ].join('\n\n')
1155
- ```
1156
-
1157
- ### The adapter seam
1158
-
1159
- All ORM methods route through a single adapter interface: `adapter.query(sql, params)`.
1160
- The default adapter uses `fetch` against a rip-db instance at `$DB_URL`.
1161
- Install a custom adapter (for tests, or for a different backend) with
1162
- `__schemaSetAdapter`:
1163
-
1164
- ```coffee
1165
- globalThis.__ripSchema.__schemaSetAdapter
1166
- query: (sql, params) ->
1167
- # return {columns: [{name, type}, ...], data: [[row values], ...], rows: N}
1168
- ...
1169
- ```
1170
-
1171
- The adapter contract is minimal — one method, one result shape. Any DB
1172
- client that can execute parameterized SQL and return row data fits.
1173
-
1174
1373
  ### Snake / camel dual access on instances
1175
1374
 
1176
1375
  Database columns are typically snake_case (`user_id`, `created_at`) while
@@ -1268,7 +1467,426 @@ always quoted, and raw SQL is always the user's responsibility.
1268
1467
 
1269
1468
  ---
1270
1469
 
1271
- ## 9. Mixins
1470
+ ## 9. Transactions & data integrity
1471
+
1472
+ Atomic multi-statement writes, transaction-aware lifecycle hooks, and
1473
+ DB constraint violations that fail the same structured way validation
1474
+ does.
1475
+
1476
+ ### Transactions (`schema.transaction!`)
1477
+
1478
+ Atomic multi-statement writes. The block's value becomes the
1479
+ transaction's value; a throw rolls everything back and propagates:
1480
+
1481
+ ```coffee
1482
+ result = schema.transaction! ->
1483
+ user = User.create! name: "Alice", email: "a@b.c"
1484
+ Order.create! userId: user.id, total: 100
1485
+ user # block value = transaction!'s value
1486
+ ```
1487
+
1488
+ - **Propagation is ambient** (AsyncLocalStorage): every ORM call inside
1489
+ the block — `create!`, `save!`, `destroy!`, relation accessors,
1490
+ queries — automatically routes through the transaction's pinned
1491
+ connection. Model code is unchanged inside the block.
1492
+ - Block throws → `ROLLBACK`, the exception propagates. Block returns →
1493
+ `COMMIT`, the value is returned.
1494
+ - **Nested `transaction!` joins the outer transaction** (Active
1495
+ Record's default). DuckDB has no `SAVEPOINT`, so independent nested
1496
+ units aren't expressible on the primary backend; joining is the
1497
+ honest semantics.
1498
+ - Don't parallelize ORM calls *inside* one transaction — they share one
1499
+ pinned DB connection, exactly like one connection in any ORM.
1500
+ Parallel `transaction!` blocks are fine; each gets its own connection
1501
+ and its own ambient context.
1502
+ - Two transaction-aware hooks: `afterCommit` fires after the outermost
1503
+ transaction commits (or immediately after save/destroy when no
1504
+ transaction is open) — this is where emails, webhooks, and cache
1505
+ invalidation belong. `afterRollback` fires after a rollback for each
1506
+ instance saved/destroyed inside the rolled-back transaction. A row
1507
+ saved twice in one transaction gets one callback. Exceptions in
1508
+ `afterCommit` propagate but cannot roll back — the COMMIT already
1509
+ happened.
1510
+ - Against rip-db / duckdb-harbor, the transaction rides harbor's
1511
+ session protocol (`POST /sql/sessions/new` pins a connection; the
1512
+ session is destroyed after COMMIT/ROLLBACK; harbor's idle TTL
1513
+ auto-rolls-back abandoned transactions server-side). Note: harbor
1514
+ gates session creation behind the `__HARBOR_ADMIN__:sessions:create`
1515
+ authz policy — transactions need an authz rule allowing it (or
1516
+ `harbor_allow_admin_without_authz = true` on trusted deployments),
1517
+ and an authenticated principal (harbor's unauthenticated `token :=
1518
+ NULL` mode cannot create owned sessions).
1519
+ - Adapters without `begin()` throw a clear "does not support
1520
+ transactions" error — never a silent non-transactional fallback.
1521
+
1522
+ ### Constraint violations are SchemaErrors
1523
+
1524
+ The ORM wraps every adapter call. DB errors recognized as constraint
1525
+ violations are translated into `SchemaError` so a `save!` that trips a
1526
+ UNIQUE index fails the same way a `save!` that trips a validator does:
1527
+
1528
+ | DB condition | Issue emitted |
1529
+ | --- | --- |
1530
+ | UNIQUE violation | `{field: "email", error: "unique", message: "email already taken"}` |
1531
+ | NOT NULL violation | `{field, error: "required", …}` |
1532
+ | FK violation | `{field, error: "reference", …}` |
1533
+ | CHECK violation | `{field: "", error: "check", …}` |
1534
+
1535
+ The original adapter error is preserved as `err.cause`. Unrecognized
1536
+ errors propagate untouched. Uniqueness pre-checks
1537
+ (`validates_uniqueness_of`-style) are deliberately **not** offered —
1538
+ they race; the DB constraint is the check, translation makes it
1539
+ ergonomic.
1540
+
1541
+ ## 10. Query economics
1542
+
1543
+ The features that keep list views at a constant query count and bulk
1544
+ operations at one statement: eager loading, composable scopes, batch
1545
+ writes, and soft deletes.
1546
+
1547
+ ### Eager loading (`.includes`)
1548
+
1549
+ Relations are lazy by default — `user.orders!` issues a query on demand,
1550
+ which makes N+1 the default behavior of every list view. `.includes`
1551
+ fixes the economics with **batched second queries** (`WHERE fk IN (…)`),
1552
+ never JOINs — no row duplication, uniform across `belongs_to` /
1553
+ `has_one` / `has_many`:
1554
+
1555
+ ```coffee
1556
+ users = User.includes(:orders).where(active: true).all!
1557
+ posts = Post.includes(:author, comments: :author).limit(20).all!
1558
+ ```
1559
+
1560
+ - Accepts `:symbols`, strings, and nested `{relation: nested}` maps to
1561
+ any depth. One query per relation per nesting level, regardless of
1562
+ row count.
1563
+ - Preloaded relations fill the accessor's **memo**: `user.orders!`
1564
+ resolves from cache with no query. The accessor API is unchanged
1565
+ (uniform async) — preloading is purely a performance fact, invisible
1566
+ to call sites.
1567
+ - `.includes` never changes the root result set — same rows with or
1568
+ without it.
1569
+
1570
+ Relation accessors memoize independently of `.includes`: the second
1571
+ `user.orders!` call on the same instance is free. Pass
1572
+ `user.orders! reload: true` to bust the memo and re-query.
1573
+
1574
+ ### Query scopes (`@scope`, `@defaultScope`)
1575
+
1576
+ Named, composable query fragments declared on the model:
1577
+
1578
+ ```coffee
1579
+ User = schema :model
1580
+ name! string
1581
+ active? boolean
1582
+ role? string
1583
+ @scope :active, -> @where(active: true)
1584
+ @scope :since, (d) -> @where("created_at > ?", d)
1585
+ @defaultScope -> @where(banned: false)
1586
+
1587
+ User.active().since(monday).order("name").all!
1588
+ User.where(role: "admin").active().all! # chains in any order
1589
+ User.unscoped().all! # skip the @defaultScope
1590
+ ```
1591
+
1592
+ - `this` inside a scope body is the query builder; scopes return the
1593
+ builder, so they compose with each other and with `.where` /
1594
+ `.order` / `.limit` in any order. Parameterized scopes declare their
1595
+ args: `(d) -> @where("created_at > ?", d)`.
1596
+ - Scopes live in the **static** namespace (model + builder), so a field
1597
+ `active` and a scope `:active` coexist. Scope names may not collide
1598
+ with the query API (`where`, `find`, `order`, …) or with each other —
1599
+ checked at first use with a `collision` SchemaError.
1600
+ - `@defaultScope` (at most one per model) applies to every read and
1601
+ bulk write — `where`/`all`/`first`/`count`/`find`/`findMany`/
1602
+ `updateAll`/`deleteAll` — unless `.unscoped()` appears anywhere in
1603
+ the chain. It composes with `@softDelete`'s implicit filter; both
1604
+ apply. (Use sparingly — Active Record's caveats about default
1605
+ scopes apply verbatim.)
1606
+ - Scopes appear in shadow TS: typed statics on the model const plus a
1607
+ per-model `UserQuery` alias so scope-first chains typecheck.
1608
+
1609
+ ### Batch writes
1610
+
1611
+ ```coffee
1612
+ User.upsert! {email: "a@b.c", name: "Alice"}, on: :email
1613
+ # INSERT … ON CONFLICT (email) DO UPDATE SET …; validates;
1614
+ # beforeSave/afterSave fire; beforeCreate/beforeUpdate do NOT
1615
+ # (the runtime can't know which branch the DB took).
1616
+ # DuckDB caveat: ON CONFLICT updates on rows referenced by another
1617
+ # table's FK trip DuckDB's indexed-column restriction — see
1618
+ # docs/RIP-DUCKDB.md.
1619
+
1620
+ User.insertMany! rows
1621
+ # validates every row first (ALL failures collected into one
1622
+ # SchemaError with `[i].field` issue paths, before any SQL), then one
1623
+ # multi-VALUES INSERT … RETURNING *. Per-instance hooks deliberately
1624
+ # skipped — this is the bulk path; use create! in a loop for hooks.
1625
+
1626
+ User.where(active: false).deleteAll! # one statement (soft-delete aware)
1627
+ User.where(plan: "trial").updateAll! expired: true
1628
+ # one UPDATE; bypasses validation and hooks — the name says "all",
1629
+ # the docs say "raw". Bumps updated_at on @timestamps models.
1630
+ ```
1631
+
1632
+ ### Soft deletes
1633
+
1634
+ `@softDelete` adds a `deleted_at` column and turns `.destroy()` into an
1635
+ UPDATE. Every read (`find`, `where`, `all`, `first`, `count`) and bulk
1636
+ write implicitly filters `deleted_at IS NULL`. The escape hatches:
1637
+
1638
+ ```coffee
1639
+ User.withDeleted().all! # no filter — live and deleted rows
1640
+ User.onlyDeleted().all! # inverted filter — deleted rows only
1641
+ user.restore! # UPDATE … SET deleted_at = NULL
1642
+ user.destroy! hard: true # real DELETE; hooks still fire
1643
+ ```
1644
+
1645
+ Relation accessors on other models respect the target's soft-delete
1646
+ filter — an `order.user!` of a soft-deleted user resolves `null`,
1647
+ consistent with `find`.
1648
+
1649
+ ## 11. Adapters
1650
+
1651
+ ### The adapter seam (Contract v2)
1652
+
1653
+ All ORM methods route through a single adapter funnel.
1654
+ `query(sql, params)` is the only **required** method; v2 adds optional
1655
+ capabilities the runtime feature-detects:
1656
+
1657
+ ```coffee
1658
+ globalThis.__ripSchema.__schemaSetAdapter
1659
+ # required — returns {columns: [{name, type}, …], data: [[…]], rowCount: N}
1660
+ query: (sql, params) ->
1661
+ db.run sql, params
1662
+
1663
+ # optional — transactions (schema.transaction!). Returns a TxHandle.
1664
+ begin: (options) ->
1665
+ conn = db.pin()
1666
+ conn.run 'BEGIN'
1667
+ {
1668
+ query: (sql, params) -> conn.run sql, params
1669
+ commit: -> conn.run 'COMMIT' and conn.release()
1670
+ rollback: -> conn.run 'ROLLBACK' and conn.release()
1671
+ }
1672
+
1673
+ capabilities: { tx: true } # truthful self-report
1674
+ ```
1675
+
1676
+ Calling a feature whose method is absent throws a clear error
1677
+ (`schema.transaction()` on an adapter without `begin()` says so by
1678
+ name) — never a silent fallback.
1679
+
1680
+ The default adapter talks to a duckdb-harbor instance: `RIP_DB_URL`
1681
+ (default `http://127.0.0.1:9494`; legacy `DB_URL` honored) and
1682
+ `RIP_DB_TOKEN` for bearer auth. Its `begin()` rides harbor's session
1683
+ protocol — `POST /sql/sessions/new` pins a connection, statements carry
1684
+ the `sessionId`, and the session is destroyed after COMMIT/ROLLBACK.
1685
+ `@rip-lang/db`'s `connect(url)` installs the same contract (query +
1686
+ begin) with its richer error handling and timeouts.
1687
+
1688
+ ### Per-schema adapters (`on:`)
1689
+
1690
+ Multi-database setups pin individual models to their own adapter:
1691
+
1692
+ ```coffee
1693
+ analytics = schema.connect url: env.ANALYTICS_URL, token: env.ANALYTICS_TOKEN
1694
+
1695
+ Event = schema :model, on: analytics
1696
+ name! string
1697
+ @timestamps
1698
+ ```
1699
+
1700
+ - `schema.connect {url, token?}` builds a NEW harbor adapter value
1701
+ without installing it globally; any Contract-v2 adapter object works
1702
+ in the `on:` slot. The default remains the global adapter.
1703
+ - Every ORM call resolves the model's adapter; `schema.transaction!`
1704
+ takes `on: analytics` to pin the ambient transaction to one adapter.
1705
+ ORM calls against a *different* adapter inside that block run
1706
+ **outside** the transaction — each adapter has its own ambient slot,
1707
+ and cross-adapter atomicity is impossible, so the runtime never
1708
+ pretends otherwise.
1709
+ - `@belongs_to` / `@has_many` across adapters: the accessor works (it's
1710
+ just a second query), but FK DDL emission is suppressed with a note —
1711
+ the constraint can't exist cross-database.
1712
+
1713
+ ## 12. DDL & schema evolution
1714
+
1715
+ Greenfield CREATE comes from `toSQL()`; everything after the first
1716
+ deploy comes from the migration system — diffing the declared models
1717
+ against the live database.
1718
+
1719
+ ### DDL (`.toSQL()`)
1720
+
1721
+ `.toSQL()` returns `CREATE SEQUENCE` + `CREATE TABLE` + index `CREATE`
1722
+ statements for a model. It does not touch the database — you run the
1723
+ output through whatever migration plumbing you prefer.
1724
+
1725
+ ```coffee
1726
+ User.toSQL()
1727
+ # CREATE SEQUENCE users_seq START 1;
1728
+ #
1729
+ # CREATE TABLE users (
1730
+ # id INTEGER PRIMARY KEY DEFAULT nextval('users_seq'),
1731
+ # name VARCHAR(100) NOT NULL,
1732
+ # email VARCHAR NOT NULL,
1733
+ # created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1734
+ # updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
1735
+ # );
1736
+ #
1737
+ # CREATE UNIQUE INDEX idx_users_email ON users ("email");
1738
+ #
1739
+ # CREATE UNIQUE INDEX idx_users_email ON users ("email");
1740
+ ```
1741
+
1742
+ `.toSQL()` works independently of the ORM. A migration script that never
1743
+ calls `.find()` or `.create()` can still emit full DDL.
1744
+
1745
+ #### Sequence start value
1746
+
1747
+ The auto-id sequence seeds at `1` by default. Override per-model with the
1748
+ `@idStart N` directive, or per-call with the `idStart` option (the option
1749
+ wins):
1750
+
1751
+ ```coffee
1752
+ User = schema :model
1753
+ name! string
1754
+ @idStart 10001 # customer-facing IDs start at 10001
1755
+
1756
+ User.toSQL() # → CREATE SEQUENCE users_seq START 10001;
1757
+ User.toSQL(idStart: 50000) # → CREATE SEQUENCE users_seq START 50000;
1758
+ ```
1759
+
1760
+ Required because DuckDB (as of 1.5.2) does not implement
1761
+ `ALTER SEQUENCE … RESTART WITH N` — so the seed has to be baked into the
1762
+ initial `CREATE SEQUENCE` rather than bumped in a follow-up migration.
1763
+
1764
+ To emit a whole application's schema, call `.toSQL()` per model and join.
1765
+ Order by FK dependency (models referenced via `@belongs_to` come first):
1766
+
1767
+ ```coffee
1768
+ ddl = [
1769
+ User.toSQL()
1770
+ Category.toSQL()
1771
+ Order.toSQL() # references User
1772
+ OrderItem.toSQL() # references Order
1773
+ ].join('\n\n')
1774
+ ```
1775
+
1776
+ ### Schema evolution (`rip schema status / make / migrate`)
1777
+
1778
+ `.toSQL()` covers greenfield CREATE. Evolution — diffing the declared
1779
+ models against the deployed database and emitting ALTER migrations —
1780
+ is built in:
1781
+
1782
+ ```text
1783
+ rip schema status [models.rip] # applied / pending / drift + the current plan
1784
+ rip schema plan [models.rip] # just the classified diff
1785
+ rip schema make <name> [models.rip] # write migrations/NNNN_<name>.sql from the diff
1786
+ rip schema migrate [models.rip] # apply pending migration files in order
1787
+ ```
1788
+
1789
+ The same verbs are callable from code: `schema.plan!`, `schema.status!`,
1790
+ `schema.make! "name", opts`, `schema.migrate! opts`, and
1791
+ `schema.introspect!` (the deployed schema as canonical table specs).
1792
+
1793
+ **How it works.** The DDL emitter's internal model is exposed as a
1794
+ canonical table spec (`Model._tableSpec()`); introspection builds the
1795
+ same structure from the live database (`information_schema` +
1796
+ `duckdb_*()` catalogs, or the adapter's own `introspect()` capability).
1797
+ The differ operates on two values of the same type and emits classified
1798
+ steps:
1799
+
1800
+ | Class | Examples | Gate |
1801
+ | --- | --- | --- |
1802
+ | `safe` | ADD COLUMN (nullable / defaulted), CREATE TABLE, CREATE INDEX, RENAME | none |
1803
+ | `lossy` | type change, SET NOT NULL, new UNIQUE on existing data | `--allow-lossy` |
1804
+ | `destructive` | DROP COLUMN, DROP TABLE | `--allow-destructive` |
1805
+ | `blocked` | any ALTER DuckDB refuses on an FK-referenced table | no flag — manual rebuild |
1806
+
1807
+ **Migration files are plain SQL** — numbered, hand-editable, checked
1808
+ into git (default `./migrations`). `make` writes them; humans may amend
1809
+ them; `migrate` applies pending files in order (each inside a
1810
+ transaction when the adapter supports one) and records
1811
+ `(version, name, checksum, applied_at)` in `_rip_migrations`. A
1812
+ checksum mismatch on an applied file aborts — someone edited history —
1813
+ unless `--repair` re-records.
1814
+
1815
+ **Renames are resolved in the declaration**, since a diff cannot
1816
+ distinguish rename from drop + add:
1817
+
1818
+ ```coffee
1819
+ User = schema :model
1820
+ givenName! ..50, {was: "first_name"} # column rename
1821
+ @tableWas legacy_users # table rename
1822
+ ```
1823
+
1824
+ The differ consumes the annotation and emits `RENAME` instead of
1825
+ `DROP + ADD`; once the migration lands, the annotation is dead weight
1826
+ and can be removed.
1827
+
1828
+ **DuckDB caveats the differ understands:**
1829
+
1830
+ - `ADD COLUMN` cannot carry `NOT NULL` / `UNIQUE` / `REFERENCES` —
1831
+ required adds become add → (backfill TODO when no default) →
1832
+ `SET NOT NULL`; unique adds get a separate `CREATE UNIQUE INDEX`;
1833
+ FK constraints cannot be added to an existing table at all (a note is
1834
+ emitted).
1835
+ - A table referenced by another table's FOREIGN KEY is frozen for
1836
+ everything except `ADD COLUMN` and index DDL ("Dependency Error") —
1837
+ such steps classify `blocked`. The plan orders ALTERs before
1838
+ CREATEs so a new child table never freezes its parent mid-migration.
1839
+ - `VARCHAR(n)` length hints are not persisted by DuckDB, so they never
1840
+ produce drift; sequence start values cannot be altered after
1841
+ creation, so `@idStart` drift is reported as a note, not a step.
1842
+
1843
+ ## 13. Wire contracts — JSON Schema & OpenAPI
1844
+
1845
+ ### JSON Schema & OpenAPI (`toJSONSchema`)
1846
+
1847
+ Every schema exports a JSON Schema (draft 2020-12):
1848
+
1849
+ ```coffee
1850
+ SignupInput.toJSONSchema()
1851
+ # { $schema: "…/2020-12/schema", title: "SignupInput", type: "object",
1852
+ # properties: { email: {type: "string", format: "email"}, … },
1853
+ # required: ["email", "password"] }
1854
+ ```
1855
+
1856
+ - Field types map per [§22](#22-field-types); ranges become
1857
+ `minLength`/`maxLength`/`minimum`/`maximum`/`minItems`/`maxItems`,
1858
+ regexes become `pattern`, defaults become `default`, literal unions
1859
+ become `enum` (single literal → `const`).
1860
+ - Nested registry schemas become `$ref`s collected under `$defs`
1861
+ (cycle-safe — recursive shapes work); `:enum` maps to `enum`,
1862
+ `:union` to `oneOf` + a `discriminator`.
1863
+ - `:model` shapes include the DB-managed columns `toJSON()` carries
1864
+ (`id`, FKs, `@timestamps`, `@softDelete`).
1865
+ - Transforms and refinements have no executable JSON Schema equivalent
1866
+ — they export as `description` annotations rather than being
1867
+ silently dropped or approximated.
1868
+
1869
+ **The payoff is rip-server integration.** A route that validates with
1870
+ a schema contributes to a generated `GET /openapi.json` automatically:
1871
+
1872
+ ```coffee
1873
+ import { post, openapi } from '@rip-lang/server'
1874
+
1875
+ post '/signup', input: SignupInput, ->
1876
+ # @input is the parsed (defaulted, coerced) value;
1877
+ # 400 with structured {field, error, message} issues is automatic
1878
+ createUser @input
1879
+
1880
+ openapi title: 'Trust Health API', version: '1.4.0' # optional info block
1881
+ ```
1882
+
1883
+ The `input:` option validates through `safeAsync` (so `@ensure!`
1884
+ schemas work), never re-reads the body stream, and registers
1885
+ `/openapi.json` on first use — declaration → DB → server contract →
1886
+ client codegen (any OpenAPI generator), with zero additional
1887
+ authoring.
1888
+
1889
+ ## 14. Mixins
1272
1890
 
1273
1891
  `:mixin` schemas exist to share field groups across multiple models or
1274
1892
  shapes. They're non-instantiable — you declare them, then other schemas
@@ -1331,7 +1949,7 @@ orthogonal to the capability axis that distinguishes the kinds.
1331
1949
 
1332
1950
  ---
1333
1951
 
1334
- ## 10. Schema algebra
1952
+ ## 15. Schema algebra
1335
1953
 
1336
1954
  Algebra operators derive new schemas from existing ones:
1337
1955
 
@@ -1370,8 +1988,8 @@ Algebra operators derive new schemas from existing ones:
1370
1988
 
1371
1989
  ```coffee
1372
1990
  User = schema :model
1373
- name! string
1374
- email!# email, -> it.email.toLowerCase()
1991
+ name! string
1992
+ email! email @unique, -> it.email.toLowerCase()
1375
1993
  password! string
1376
1994
  full: ~> "#{@name} <#{@email}>"
1377
1995
  tagline: !> "#{@name} (active)"
@@ -1392,8 +2010,8 @@ typeof u.tagline # 'undefined' — !> dropped
1392
2010
  fields from another schema. Collisions still throw.
1393
2011
 
1394
2012
  ```coffee
1395
- AdminUser = User.extend schema :shape
1396
- permissions! string[]
2013
+ AdminUser = User.extend (schema :shape
2014
+ permissions! string[])
1397
2015
  ```
1398
2016
 
1399
2017
  `.sourceModel` is preserved through chained algebra, so tooling can trace
@@ -1414,7 +2032,7 @@ generators, query projectors) can walk this back to the originating
1414
2032
 
1415
2033
  ---
1416
2034
 
1417
- ## 11. Shadow TypeScript
2035
+ ## 16. Shadow TypeScript
1418
2036
 
1419
2037
  Every named schema emits virtual TypeScript declarations that the
1420
2038
  language service picks up. The VS Code extension and `rip check` both
@@ -1423,42 +2041,58 @@ the box.
1423
2041
 
1424
2042
  ### What gets emitted
1425
2043
 
1426
- For `:input`:
2044
+ The schema's **bare name** is the type you reference everywhere — the parsed
2045
+ value (for `:input`/`:shape`) or the hydrated instance (for `:model`) — exactly
2046
+ the way a class names both its value and its instance type. A separate
2047
+ `<Name>Data` (fields-only) type is emitted only when it differs from the bare
2048
+ name: behavior-bearing `:shape`s and every `:model`.
2049
+
2050
+ For `:input` (no behavior — the bare name is the whole shape):
1427
2051
 
1428
2052
  ```ts
1429
- type SignupInputValue = { email: string; password: string };
1430
- declare const SignupInput: Schema<SignupInputValue, SignupInputValue>;
2053
+ type SignupInput = { email: string; password: string };
2054
+ declare const SignupInput: Schema<SignupInput, SignupInput>;
1431
2055
  ```
1432
2056
 
1433
- For `:shape` (with behavior):
2057
+ For `:shape` (with behavior — `<Name>Data` = fields, bare `<Name>` = instance):
1434
2058
 
1435
2059
  ```ts
1436
2060
  type AddressData = { street: string; city: string };
1437
- type AddressInstance = AddressData & {
2061
+ type Address = AddressData & {
1438
2062
  readonly full: unknown;
1439
2063
  normalize: (...args: any[]) => unknown;
1440
2064
  };
1441
- declare const Address: Schema<AddressInstance, AddressData>;
2065
+ declare const Address: Schema<Address, AddressData>;
1442
2066
  ```
1443
2067
 
1444
2068
  For `:model`:
1445
2069
 
1446
2070
  ```ts
1447
2071
  type UserData = { name: string; email: string };
1448
- type UserInstance = UserData & {
2072
+ type UserCreate = { name: string; email: string }; // create()'s input — required-no-default fields
2073
+ type User = UserData & {
1449
2074
  readonly identifier: unknown;
1450
2075
  greet: (...args: any[]) => unknown;
1451
- save(): Promise<UserInstance>;
1452
- destroy(): Promise<UserInstance>;
2076
+ save(): Promise<User>;
2077
+ destroy(): Promise<User>;
1453
2078
  ok(): boolean;
1454
2079
  errors(): SchemaIssue[];
1455
2080
  toJSON(): UserData;
1456
- organization(): Promise<OrganizationInstance | null>;
1457
- orders(): Promise<OrderInstance[]>;
2081
+ organization(): Promise<Organization | null>;
2082
+ orders(): Promise<Order[]>;
1458
2083
  };
1459
- declare const User: ModelSchema<UserInstance, UserData>;
2084
+ declare const User: ModelSchema<User, UserData, number, UserCreate>;
1460
2085
  ```
1461
2086
 
2087
+ > **Computed/derived inference.** The `unknown` on a `~>`/`!>` member above is
2088
+ > the *published* `.d.ts` form. In the editor and `rip check`, the type emitter
2089
+ > infers each computed/derived member from its body — `full` above resolves to
2090
+ > `string`, and a `status: ~> if @done then 'Completed' else 'Pending'` resolves
2091
+ > to `"Completed" | "Pending"` — so they're usable as their real types (e.g.
2092
+ > `order.name.toUpperCase()`). A plain `.d.ts` has no runtime body to infer
2093
+ > from, so it keeps `unknown`. Methods stay `(...args: any[]) => unknown` either
2094
+ > way (their params aren't typed).
2095
+
1462
2096
  For `:enum`:
1463
2097
 
1464
2098
  ```ts
@@ -1490,6 +2124,17 @@ Schema<Omit<UserData, "email">, Omit<UserData, "email">>
1490
2124
  Schema<Partial<UserData>, Partial<UserData>>
1491
2125
  ```
1492
2126
 
2127
+ A **named** derived schema additionally gets a bare type of its own, so a
2128
+ projection can be annotated or re-exported under a clean name:
2129
+
2130
+ ```coffee
2131
+ UserPublic = User.pick("id", "name") # also emits a bare `type UserPublic`
2132
+ ```
2133
+
2134
+ It's emitted as `type UserPublic = ReturnType<(typeof UserPublic)['parse']>`,
2135
+ which resolves to the exact projection (`Pick<UserData, "id" | "name">`) —
2136
+ covering every operator and chain for free.
2137
+
1493
2138
  ### Same-file targets type relation accessors
1494
2139
 
1495
2140
  Relation accessors get precise return types when the target is declared
@@ -1499,7 +2144,7 @@ in the same file:
1499
2144
  User = schema :model
1500
2145
  name! string
1501
2146
  Order = schema :model
1502
- @belongs_to User # → order.user(): Promise<UserInstance | null>
2147
+ @belongs_to User # → order.user(): Promise<User | null>
1503
2148
  ```
1504
2149
 
1505
2150
  Cross-file relation targets degrade to `unknown` rather than emit
@@ -1508,7 +2153,7 @@ requiring virtual-module imports.
1508
2153
 
1509
2154
  ### Intrinsic declarations
1510
2155
 
1511
- Three base interfaces get injected into every schema-using file's type
2156
+ Five base interfaces get injected into every schema-using file's type
1512
2157
  view:
1513
2158
 
1514
2159
  ```ts
@@ -1518,8 +2163,8 @@ type SchemaSafeResult<T> =
1518
2163
  | { ok: false; value: null; errors: SchemaIssue[] };
1519
2164
 
1520
2165
  interface Schema<Out, In = unknown> {
1521
- parse(data: In): Out;
1522
- safe(data: In): SchemaSafeResult<Out>;
2166
+ parse(data: unknown): Out;
2167
+ safe(data: unknown): SchemaSafeResult<Out>;
1523
2168
  ok(data: unknown): boolean;
1524
2169
  pick<K extends keyof In>(...keys: K[]): Schema<Pick<In, K>, Pick<In, K>>;
1525
2170
  omit<K extends keyof In>(...keys: K[]): Schema<Omit<In, K>, Omit<In, K>>;
@@ -1531,16 +2176,36 @@ interface Schema<Out, In = unknown> {
1531
2176
  extend<U>(other: Schema<U>): Schema<In & U, In & U>;
1532
2177
  }
1533
2178
 
1534
- interface ModelSchema<Instance, Data = unknown> extends Schema<Instance, Data> {
1535
- find(id: unknown): Promise<Instance | null>;
1536
- findMany(ids: unknown[]): Promise<Instance[]>;
2179
+ interface SchemaQuery<T> {
2180
+ all(): Promise<T[]>;
2181
+ first(): Promise<T | null>;
2182
+ count(): Promise<number>;
2183
+ limit(n: number): SchemaQuery<T>;
2184
+ offset(n: number): SchemaQuery<T>;
2185
+ order(spec: string): SchemaQuery<T>;
2186
+ }
2187
+
2188
+ interface ModelSchema<Instance, Data = unknown, Id = number, Create = Partial<Data>> extends Schema<Instance, Data> {
2189
+ find(id: Id): Promise<Instance | null>;
2190
+ findMany(ids: Id[]): Promise<Instance[]>;
1537
2191
  where(cond: Record<string, unknown> | string, ...params: unknown[]): SchemaQuery<Instance>;
2192
+ includes(...specs: unknown[]): SchemaQuery<Instance>;
2193
+ withDeleted(): SchemaQuery<Instance>;
2194
+ onlyDeleted(): SchemaQuery<Instance>;
2195
+ unscoped(): SchemaQuery<Instance>;
1538
2196
  all(limit?: number): Promise<Instance[]>;
1539
2197
  first(): Promise<Instance | null>;
1540
2198
  count(cond?: Record<string, unknown>): Promise<number>;
1541
- create(data: Partial<Data>): Promise<Instance>;
1542
- toSQL(options?: { dropFirst?: boolean; header?: string }): string;
2199
+ create(data: Create): Promise<Instance>;
2200
+ upsert(data: Create, opts: { on: unknown }): Promise<Instance>;
2201
+ insertMany(rows: Create[]): Promise<Instance[]>;
2202
+ toSQL(options?: { dropFirst?: boolean; header?: string; idStart?: number }): string;
1543
2203
  }
2204
+
2205
+ declare const schema: {
2206
+ transaction<T>(fn: () => T | Promise<T>): Promise<T>;
2207
+ transaction<T>(opts: Record<string, unknown>, fn: () => T | Promise<T>): Promise<T>;
2208
+ };
1544
2209
  ```
1545
2210
 
1546
2211
  You don't import these — they're injected automatically when the file
@@ -1548,7 +2213,7 @@ contains any schema declaration.
1548
2213
 
1549
2214
  ---
1550
2215
 
1551
- ## 12. SchemaError and diagnostics
2216
+ ## 17. SchemaError and diagnostics
1552
2217
 
1553
2218
  ### `SchemaError`
1554
2219
 
@@ -1612,13 +2277,14 @@ range or drop the conflicting pragma.
1612
2277
 
1613
2278
  ---
1614
2279
 
1615
- ## 13. Common mistakes
2280
+ ## 18. Common mistakes
1616
2281
 
1617
2282
  These forms look right but don't work — the parser catches all of them
1618
2283
  with specific diagnostics.
1619
2284
 
1620
2285
  ### `name: type` instead of `name type`
1621
2286
 
2287
+ <!-- doctest: fail -->
1622
2288
  ```coffee
1623
2289
  # wrong — fields use a space, not a colon, between name and type
1624
2290
  X = schema
@@ -1631,6 +2297,7 @@ X = schema
1631
2297
 
1632
2298
  ### Bare identifier enum members
1633
2299
 
2300
+ <!-- doctest: fail -->
1634
2301
  ```coffee
1635
2302
  # wrong — enum members are :symbol
1636
2303
  R = schema :enum
@@ -1645,6 +2312,7 @@ R = schema
1645
2312
 
1646
2313
  ### `name: value` as an enum member
1647
2314
 
2315
+ <!-- doctest: fail -->
1648
2316
  ```coffee
1649
2317
  # wrong — use :name value
1650
2318
  R = schema :enum
@@ -1657,6 +2325,7 @@ R = schema
1657
2325
 
1658
2326
  ### Methods in `:input` or `:mixin`
1659
2327
 
2328
+ <!-- doctest: fail -->
1660
2329
  ```coffee
1661
2330
  # wrong — :input is fields-only
1662
2331
  X = schema :input
@@ -1671,6 +2340,7 @@ X = schema :shape
1671
2340
 
1672
2341
  ### ORM directives on `:shape`
1673
2342
 
2343
+ <!-- doctest: fail -->
1674
2344
  ```coffee
1675
2345
  # wrong — @timestamps is :model-only
1676
2346
  A = schema :shape
@@ -1686,14 +2356,14 @@ A = schema :model
1686
2356
  ### Calling ORM methods on a derived shape
1687
2357
 
1688
2358
  ```coffee
1689
- UserPublic = User.omit "password"
2359
+ UserPublic = User.pick "id", "name", "email"
1690
2360
 
1691
2361
  # wrong — algebra returns :shape; :shape has no .find()
1692
2362
  user = UserPublic.find! 1
1693
2363
 
1694
2364
  # right — query the source model and project
1695
2365
  user = User.find! 1
1696
- publicView = UserPublic.parse user.toJSON()
2366
+ view = UserPublic.parse user.toJSON()
1697
2367
  ```
1698
2368
 
1699
2369
  ### Treating `.ok()` as a type predicate for shapes/models
@@ -1717,34 +2387,92 @@ predicate.
1717
2387
 
1718
2388
  ---
1719
2389
 
1720
- ## 14. Recipes
2390
+ ## 19. Recipes
1721
2391
 
1722
2392
  ### Validating HTTP input
1723
2393
 
2394
+ The `input:` route option validates the JSON body before the handler
2395
+ runs — a 400 with structured issues goes out automatically, the parsed
2396
+ (defaulted, coerced) value is `@input`, and the route contributes to
2397
+ the generated `GET /openapi.json`:
2398
+
1724
2399
  ```coffee
1725
- import { post, read } from '@rip-lang/server'
2400
+ import { post } from '@rip-lang/server'
1726
2401
 
1727
2402
  SignupInput = schema
1728
2403
  email! email
1729
2404
  password! string, 8..100
1730
- age? integer, 18..120
2405
+ age? ~integer, 18..120 # "21" on the wire → 21
1731
2406
 
1732
- post '/signup' ->
1733
- raw = @json() # whatever shape the client sent
1734
- result = SignupInput.safe raw
1735
- unless result.ok
1736
- return error! 400, errors: result.errors
1737
- # result.value is the cleaned, typed payload
1738
- db.users.insert result.value
2407
+ post '/signup', input: SignupInput, ->
2408
+ db.users.insert @input
1739
2409
  { ok: true }
1740
2410
  ```
1741
2411
 
2412
+ Validating by hand works too — `SignupInput.safe raw` returns
2413
+ `{ok, value, errors}` for cases where you need custom failure handling.
2414
+
2415
+ ### An atomic checkout
2416
+
2417
+ `schema.transaction!` makes the multi-write atomic; the UNIQUE
2418
+ constraint is the duplicate check (no racy pre-query — the DB error
2419
+ arrives as a structured SchemaError); `afterCommit` is where effects
2420
+ that must never observe uncommitted state belong:
2421
+
2422
+ ```coffee
2423
+ Order = schema :model
2424
+ total! integer
2425
+ reference! # string
2426
+ @belongs_to User
2427
+ @timestamps
2428
+ afterCommit: -> queueEmail 'receipt', @id # fires after COMMIT only
2429
+
2430
+ placeOrder = (user, items, reference) ->
2431
+ try
2432
+ schema.transaction! ->
2433
+ order = Order.create! userId: user.id, total: totalOf(items), reference: reference
2434
+ Invoice.create! orderId: order.id, amount: order.total
2435
+ order
2436
+ catch err
2437
+ if err.issues?[0]?.error is 'unique'
2438
+ error! 'That order reference already exists', 409
2439
+ throw err
2440
+ ```
2441
+
2442
+ A throw anywhere in the block rolls everything back — the order and
2443
+ invoice land together or not at all.
2444
+
2445
+ ### A list view without N+1
2446
+
2447
+ `.includes` batches relations (`WHERE fk IN (…)` — one query per
2448
+ relation per level), scopes name the filters, and `@defaultScope`
2449
+ keeps cancelled rows out of every query unless `.unscoped()` opts in:
2450
+
2451
+ ```coffee
2452
+ Patient = schema :model
2453
+ name! string
2454
+ active? boolean
2455
+ @has_many Visit
2456
+ @scope :active, -> @where(active: true)
2457
+ @defaultScope -> @where(archived: false)
2458
+
2459
+ Visit = schema :model
2460
+ reason? string
2461
+ @belongs_to Patient
2462
+ @belongs_to Provider
2463
+
2464
+ # 3 queries total, regardless of row count:
2465
+ patients = Patient.active().includes(visits: :provider).all!
2466
+ for patient in patients
2467
+ visits = patient.visits! # memo hit — no query
2468
+ ```
2469
+
1742
2470
  ### A DB-backed model with relations
1743
2471
 
1744
2472
  ```coffee
1745
2473
  User = schema :model
1746
2474
  name! string, 1..100
1747
- email!# email
2475
+ email! email @unique
1748
2476
  @timestamps
1749
2477
  @has_many Order
1750
2478
 
@@ -1803,53 +2531,83 @@ Post = schema :model
1803
2531
  @belongs_to User
1804
2532
  ```
1805
2533
 
1806
- ### Building a public DTO from a model
2534
+ ### Projecting a model to a wire view
1807
2535
 
1808
2536
  ```coffee
1809
2537
  User = schema :model
1810
2538
  name! string
1811
- email!# email
2539
+ email! email @unique
1812
2540
  password! string
1813
2541
  role? string, [:user]
1814
2542
  @timestamps
1815
2543
 
1816
- # Public projection — no password, no ORM methods.
1817
- UserPublic = User.omit "password"
1818
-
1819
- publicJson = (user) -> UserPublic.parse user.toJSON()
2544
+ # Wire projection — the shape clients receive, derived from the one model.
2545
+ # Use `pick` (an allowlist): a field added to the model later can't leak to
2546
+ # clients by default — `password` is simply never selected. Prefer this over
2547
+ # `omit` for anything crossing a trust boundary; `omit` fails open.
2548
+ UserPublic = User.pick "id", "name", "email", "role"
1820
2549
 
1821
2550
  get '/users/:id' ->
1822
2551
  id = read 'id', 'id!'
1823
2552
  user = User.find! id
1824
2553
  return error! 404 unless user
1825
- { user: publicJson user }
2554
+ { user: UserPublic.parse user.toJSON() }
1826
2555
  ```
1827
2556
 
1828
- ### Writing a migration script
2557
+ ### Evolving the schema (week 2 and beyond)
2558
+
2559
+ Change the models, then let the differ write the migration. A rename is
2560
+ declared, not guessed:
1829
2561
 
1830
2562
  ```coffee
1831
- # scripts/migrate.rip
1832
- import { User, Order, OrderItem } from '../api/models.rip'
1833
- import { sql, setup } from '../api/db.rip'
2563
+ # models.rip — week 2: rename a column, add a field, add a table
2564
+ User = schema :model
2565
+ fullName! string, 1..100, {was: "name"} # rename annotation
2566
+ email! email @unique
2567
+ phone? ~:phone # new column
2568
+ @timestamps
1834
2569
 
1835
- setup! # start DB if needed
2570
+ AuditLog = schema :model # new table
2571
+ action! string
2572
+ @belongs_to User
2573
+ @timestamps
2574
+ ```
1836
2575
 
1837
- # Emit DDL in dependency order
1838
- ddl = [
1839
- User.toSQL()
1840
- Order.toSQL() # references User
1841
- OrderItem.toSQL() # references Order
1842
- ].join('\n\n')
2576
+ ```text
2577
+ rip schema status models.rip # review the classified plan
2578
+ rip schema make week2 models.rip # writes migrations/0002_week2.sql
2579
+ rip schema migrate models.rip # applies it, records the checksum
2580
+ ```
2581
+
2582
+ Lossy steps (type changes, SET NOT NULL) need `--allow-lossy`;
2583
+ DROPs need `--allow-destructive`; steps DuckDB refuses on
2584
+ FK-referenced tables are marked `blocked` and never auto-applied.
2585
+ Once the migration lands, delete the `{was: …}` annotation.
2586
+
2587
+ For greenfield scripts, `.toSQL()` still works standalone — it never
2588
+ calls the adapter, so DDL can be emitted before the database exists.
2589
+
2590
+ ### The wire vocabulary (`~:name`)
1843
2591
 
1844
- for stmt in ddl.split ';'
1845
- stmt = stmt.trim()
1846
- sql! stmt + ';' if stmt
2592
+ Every `read()` validator doubles as a schema coercer — one
2593
+ normalization vocabulary for route params and schema fields. It lives
2594
+ in `@rip-lang/validate` (browser-safe), loads with `@rip-lang/server`
2595
+ on the server, and a side-effect `import '@rip-lang/validate'` brings
2596
+ it to client-side parsing:
1847
2597
 
1848
- p "[migrate] schema created"
2598
+ ```coffee
2599
+ Patient = schema :model
2600
+ chart! ~:id, 1..99999 # "42" → 42, range-checked
2601
+ ssn? ~:ssn # "123-45-6789" → "123456789"
2602
+ phone? ~:phone # "8016542000" → "(801) 654-2000"
2603
+ state? ~:state # "ut" → "UT"
2604
+ dob? ~:date # normalized "YYYY-MM-DD"
2605
+ amount? ~:money # "$1,234.50" → 1234.5
1849
2606
  ```
1850
2607
 
1851
- Because `.toSQL()` doesn't call the adapter, migration scripts work
1852
- before the database exists or before the ORM is wired.
2608
+ Custom validators registered with `registerValidator` (from
2609
+ `@rip-lang/validate`) or `schema.registerCoercer` (anywhere) join the
2610
+ vocabulary automatically.
1853
2611
 
1854
2612
  ### Composing nested shapes
1855
2613
 
@@ -1884,11 +2642,11 @@ else
1884
2642
  ```
1885
2643
 
1886
2644
  Registered `:shape` / `:input` / `:model` names can all be referenced
1887
- as field types — see §17 for resolution rules.
2645
+ as field types — see §22 for resolution rules.
1888
2646
 
1889
2647
  ---
1890
2648
 
1891
- ## 15. What's not here yet
2649
+ ## 20. What's not here yet
1892
2650
 
1893
2651
  Rip Schema covers a large surface area with one keyword, but it deliberately
1894
2652
  does not yet cover every feature you might find across the union of Zod,
@@ -1898,55 +2656,32 @@ language.
1898
2656
 
1899
2657
  ### Validator features not yet in
1900
2658
 
1901
- - **Full discriminated-union schemas** — `schema.union(A, B)` with a
1902
- `:discriminator` key that dispatches to the matching constituent.
1903
- String-literal unions in the type slot (`"a" | "b"`) are in;
1904
- schema-constituent unions over arbitrary shapes are not. Today you
1905
- express cross-shape alternation by running multiple `.safe()` calls.
1906
- - **Issue paths** — `@ensure` issues today use `field: ''` (the whole
1907
- object). Per-field attribution (`field: 'email'`) on a refinement
1908
- isn't supported; write the field-specific rule as a constraint or
1909
- inline transform instead.
1910
- - **Coercion built-in types** — `coerce.number`, `coerce.date`, etc.
1911
- as dedicated type names. Today a field transform handles the same
1912
- case (`shippedAt? date, -> new Date(it.shippedAt)`); coerce types
1913
- would just be a stdlib convenience over the transform mechanism.
1914
- - **Async refinements** — `@ensure` predicates are sync. Async
1915
- refinements (that await a database or network check) would need
1916
- either a separate `@ensureAsync` directive or a full async variant
1917
- of the whole validate pipeline.
2659
+ - **Union algebra** — `.pick`/`.omit`/etc. on a `:union` throws; the
2660
+ semantics (distribute? intersect?) have no obviously-right answer.
2661
+ Derive from a constituent instead.
2662
+ - **Untagged unions** deliberate non-goal: O(n) dispatch and
2663
+ incoherent error messages. Use `@on :field`.
1918
2664
 
1919
2665
  ### ORM features not yet in
1920
2666
 
1921
- - **Transactions** — `schema.transaction -> ...` with rollback semantics.
1922
- Today each ORM call is its own statement.
1923
- - **Eager loading** `User.where(...).includes(:orders)`. Today relations
1924
- are lazy (`user.orders!` on demand).
1925
- - **Query scopes** — named, composable `Model.scope(name, ...)` reusable
1926
- across `.where` chains.
1927
- - **Soft deletes** — a built-in `@soft_delete` directive with automatic
1928
- query-filter application. Today you add a `deleted_at` field yourself.
2667
+ - **FK-cluster rebuilds** — DuckDB freezes FK-referenced tables for most
2668
+ ALTERs; the differ classifies those steps `blocked` and leaves the
2669
+ rebuild (recreate referencing tables around the change) to the human.
2670
+ Generating the rebuild automatically is the next migration item.
1929
2671
  - **Polymorphic associations** — `@belongs_to :commentable, polymorphic: true`.
1930
2672
  - **Non-SQL adapters** — Mongo, Redis, Elasticsearch. The adapter contract
1931
2673
  is `query(sql, params)`, which assumes SQL.
2674
+ - **Savepoint-backed nested transactions** — nested `transaction!`
2675
+ joins the outer transaction. DuckDB has no `SAVEPOINT`, so
2676
+ independent nested units aren't expressible on the primary backend.
1932
2677
 
1933
2678
  ### Type features not yet in
1934
2679
 
1935
- - **Recursive schemas** — `Tree = schema :shape` that references itself
1936
- in a nested field. Compiler allows it; shadow TS currently emits
1937
- `unknown` for the recursive branch.
1938
2680
  - **Generic schemas** — `Paginated<T> = schema :shape ...` parameterized
1939
2681
  by another schema. Today you define a concrete `PaginatedUser` per type.
1940
2682
  - **Branded / nominal types** — `UserId = schema :input` whose parsed
1941
2683
  value is nominally distinct from `number`.
1942
2684
 
1943
- ### Deferred by design
1944
-
1945
- - **Per-schema adapters** — every schema currently uses the one global
1946
- adapter. Multi-database setups require swapping before the call.
1947
- - **JSON Schema / OpenAPI export** — `User.toJSONSchema()`. The
1948
- four-layer runtime makes this feasible; no canonical emitter exists yet.
1949
-
1950
2685
  None of these are architectural impossibilities. Each is a conscious pause
1951
2686
  while the core shape of the feature settles. If one of these is blocking
1952
2687
  you, file a proposal — the sidecar design makes most of them additive.
@@ -1955,35 +2690,43 @@ you, file a proposal — the sidecar design makes most of them additive.
1955
2690
 
1956
2691
  # Part II — Reference
1957
2692
 
1958
- ## 16. Capability matrix
2693
+ ## 21. Capability matrix
1959
2694
 
1960
2695
  What each kind's body can contain:
1961
2696
 
1962
2697
  | Feature | `:input` | `:shape` | `:enum` | `:mixin` | `:model` |
1963
2698
  | --------------------------------------- | -------- | -------- | -------- | -------- | -------- |
1964
2699
  | Fields (`name` with optional type) | ✓ | ✓ | — | ✓ | ✓ |
1965
- | Literal-union type (`"a" \| "b"`) | ✓ | ✓ | — | ✓ | ✓ |
2700
+ | Literal-union type (`"a" \| "b"`, single = constant) | ✓ | ✓ | — | ✓ | ✓ |
2701
+ | Coercion (`~integer`, `~:ssn`) | ✓ | ✓ | — | ✓ | ✓ |
1966
2702
  | Range / regex / default / attrs | ✓ | ✓ | — | ✓ | ✓ |
1967
2703
  | Inline transforms (`name, -> fn(it)`) | ✓ | ✓ | — | — | ✓ |
1968
2704
  | `@mixin` directive | ✓ | ✓ | — | ✓ | ✓ |
1969
- | `@ensure` refinement | ✓ | ✓ | — | — | ✓ |
1970
- | Other directives | — | — | — | — | ✓ |
1971
- | Methods (`name: -> body`) | — | ✓ | — | — | ✓ |
2705
+ | `@ensure` / `@ensure!` refinement | ✓ | ✓ | — | — | ✓ |
2706
+ | Other directives (`@timestamps`, `@scope`, …) | — | — | — | — | ✓ |
2707
+ | Methods (`name: (params) -> body`) | — | ✓ | — | — | ✓ |
1972
2708
  | Computed getter (`name: ~> body`) | — | ✓ | — | — | ✓ |
1973
- | Eager-derived field (`name: !> body`) | | ✓ | — | — | ✓ |
1974
- | Hooks (by known name) | — | methods | — | — | ✓ |
2709
+ | Eager-derived field (`name: !> body`) | | ✓ | — | — | ✓ |
2710
+ | Hooks (by known name, 12 total) | — | methods | — | — | ✓ |
1975
2711
  | Enum members (`:symbol`) | — | — | ✓ | — | — |
1976
2712
  | Algebra (`.pick` etc.) | ✓ → shape | ✓ → shape | — | — | ✓ → shape |
1977
- | ORM (`.find`, `.create`) | — | — | — | — | ✓ |
1978
- | `.parse` / `.safe` / `.ok` | ✓ | ✓ | ✓ | — | ✓ |
1979
- | `.toSQL()` | | | | | ✓ |
2713
+ | ORM (`.find`, `.create`, transactions, scopes) | — | — | — | — | ✓ |
2714
+ | `.parse` / `.safe` / `.ok` (+ async trio) | ✓ | ✓ | ✓ | — | ✓ |
2715
+ | `.toJSONSchema()` | | | | | ✓ |
2716
+ | `.toSQL()` / `rip schema make` | — | — | — | — | ✓ |
1980
2717
 
1981
2718
  "methods" in the `:shape` / Hooks row means: hook-named functions are
1982
2719
  accepted, but they're just methods with no lifecycle binding.
1983
2720
 
2721
+ `:union` is body-incompatible with everything above — its body is
2722
+ exactly one `@on :field` plus 2+ bare constituent names. It exposes
2723
+ `.parse` / `.safe` / `.ok` (and the async trio) plus `.toJSONSchema()`
2724
+ (`oneOf` + discriminator) by delegating to the matched constituent;
2725
+ algebra and ORM surface throw.
2726
+
1984
2727
  ---
1985
2728
 
1986
- ## 17. Field types
2729
+ ## 22. Field types
1987
2730
 
1988
2731
  Built-in type names and their runtime / SQL / TypeScript mappings:
1989
2732
 
@@ -2043,25 +2786,36 @@ user-defined enums or shapes compose incrementally.
2043
2786
 
2044
2787
  ---
2045
2788
 
2046
- ## 18. Directives
2789
+ ## 23. Directives
2047
2790
 
2048
2791
  ### For any fielded kind
2049
2792
 
2050
2793
  | Directive | Effect |
2051
2794
  | --------------- | ----------------------------------------------------------------- |
2052
2795
  | `@mixin Name` | Pull in the fields of mixin `Name` at Layer 2 normalization |
2053
- | `@ensure "msg", (x) -> pred` | Cross-field refinement — see [§5](#refinement-ensure). Allowed on `:input` / `:shape` / `:model`; rejected on `:enum` / `:mixin`. |
2796
+ | `@ensure "msg"[, :field], (x) -> pred` | Cross-field refinement, optionally attributed to a field — see [§5](#refinement-ensure). Allowed on `:input` / `:shape` / `:model`; rejected on `:enum` / `:mixin`. |
2797
+ | `@ensure! "msg"[, :field], (x) -> pred` | ASYNC refinement — the schema becomes async-validating (`parseAsync!` / `safeAsync!` / `okAsync!`) |
2798
+
2799
+ ### `:union`-only
2800
+
2801
+ | Directive | Effect |
2802
+ | ----------- | --------------------------------------------------------------- |
2803
+ | `@on :field` | Names the discriminator field (required, exactly once). Constituents follow as bare schema names, one per line |
2054
2804
 
2055
2805
  ### `:model`-only
2056
2806
 
2057
2807
  | Directive | Effect |
2058
2808
  | ----------------------------- | ------------------------------------------------------------------- |
2059
2809
  | `@timestamps` | Adds `created_at` + `updated_at` columns with `CURRENT_TIMESTAMP` defaults |
2060
- | `@softDelete` | Adds `deleted_at` column; `.destroy()` sets `deleted_at = now()` instead of DELETE |
2810
+ | `@softDelete` | Adds `deleted_at` column; `.destroy()` sets `deleted_at = now()` instead of DELETE. Queries (`find`, `where`, `all`, `first`, `count`) implicitly filter `deleted_at IS NULL`; escape hatches: `.withDeleted()`, `.onlyDeleted()`, `inst.restore!`, `inst.destroy! hard: true` |
2061
2811
  | `@index [a, b, c]` | Composite index on the listed columns |
2062
2812
  | `@index column` | Single-column index (same as `@index [column]`) |
2063
- | `@index [...] #` | Unique index |
2813
+ | `@unique [a, b]` | Composite unique constraint on the listed columns |
2814
+ | `@unique :column` | Single-column unique (or inline on the field: `column! type @unique`) |
2064
2815
  | `@idStart N` | Seed value for the auto-id sequence in `.toSQL()` output (default `1`). Overridden per-call by `toSQL(idStart: N)`. |
2816
+ | `@scope :name, -> body` | Named composable query scope — `this` is the builder; also `@scope :name, (args) -> body`. Installed on the model and the builder |
2817
+ | `@defaultScope -> body` | Applied to every query unless `.unscoped()` is called. At most one per model |
2818
+ | `@tableWas old_name` | Table-rename annotation for the schema differ — `rip schema make` emits `RENAME TO` instead of `DROP + CREATE`. Removable once the migration lands |
2065
2819
  | `@belongs_to Target` | FK column `target_id` referencing `targets.id`, NOT NULL |
2066
2820
  | `@belongs_to Target?` | Same, nullable |
2067
2821
  | `@has_one Target` | Accessor `target()` returning one |
@@ -2069,10 +2823,10 @@ user-defined enums or shapes compose incrementally.
2069
2823
 
2070
2824
  ---
2071
2825
 
2072
- ## 19. Hook reference
2826
+ ## 24. Hook reference
2073
2827
 
2074
- Ten recognized hook names. On `:model` they bind into the lifecycle; on
2075
- other kinds they're plain methods.
2828
+ Twelve recognized hook names. On `:model` they bind into the lifecycle;
2829
+ on other kinds they're plain methods.
2076
2830
 
2077
2831
  | Hook name | When it runs |
2078
2832
  | ------------------ | -------------------------------------------------------- |
@@ -2086,17 +2840,20 @@ other kinds they're plain methods.
2086
2840
  | `afterSave` | After INSERT or UPDATE |
2087
2841
  | `beforeDestroy` | Before DELETE (or soft-delete UPDATE) |
2088
2842
  | `afterDestroy` | After DELETE |
2843
+ | `afterCommit` | After the outermost transaction commits — or immediately after save/destroy when no transaction is open |
2844
+ | `afterRollback` | After rollback, for each instance saved/destroyed inside the rolled-back transaction |
2089
2845
 
2090
2846
  Throwing from any hook aborts the operation and the exception propagates
2091
2847
  to the caller.
2092
2848
 
2093
2849
  ---
2094
2850
 
2095
- ## 20. Constraints
2851
+ ## 25. Constraints
2096
2852
 
2097
2853
  Each constraint on a field line is self-identifying by its token
2098
2854
  shape. Multiple constraints combine on one field, separated by commas:
2099
2855
 
2856
+ <!-- doctest: skip -->
2100
2857
  ```coffee
2101
2858
  name[!|?|#] [type] [constraint] [constraint] …
2102
2859
  ```
@@ -2112,16 +2869,17 @@ a string-literal union; the **constraint** forms live after the type:
2112
2869
  | `min..max` | constraint | Size (string/array length) or value range (numeric) |
2113
2870
  | `[value]` | constraint | Default value (single literal in brackets) |
2114
2871
  | `/regex/` | constraint | Pattern constraint (bare regex literal) |
2115
- | `{key: value}` | constraint | Attrs (unique, index, etc.) |
2872
+ | `{key: value}` | constraint | Attrs. Known keys: `{was: "old_name"}` — column-rename annotation for the schema differ |
2116
2873
 
2117
2874
  ```coffee
2118
- password! string, 8..100 # length range
2119
- age? integer, 0..120 # value range
2120
- role? string, ["guest"] # default
2121
- zip! string, /^\d{5}$/ # regex pattern
2122
- status? string, 3..20, ["pending"] # range AND default
2123
- sex? "M" | "F" | "U" # literal union
2124
- status? "draft" | "active" | "done", [:draft] # union + default
2875
+ Example = schema
2876
+ password! string, 8..100 # length range
2877
+ age? integer, 0..120 # value range
2878
+ role? string, ["guest"] # default
2879
+ zip! string, /^\d{5}$/ # regex pattern
2880
+ status? string, 3..20, ["pending"] # range AND default
2881
+ sex? "M" | "F" | "U" # literal union
2882
+ phase? "draft" | "active" | "done", [:draft] # union + default
2125
2883
  ```
2126
2884
 
2127
2885
  ### Range semantics by field type
@@ -2139,9 +2897,10 @@ status? "draft" | "active" | "done", [:draft] # union + default
2139
2897
  Use `n..n` for "exactly N":
2140
2898
 
2141
2899
  ```coffee
2142
- sex? 1..1 # single-character sex code
2143
- npi! 10..10 # NPI is exactly 10 digits
2144
- code! 6..6 # fixed-length code
2900
+ Fixed = schema
2901
+ sex? 1..1 # single-character sex code
2902
+ npi! 10..10 # NPI is exactly 10 digits
2903
+ code! 6..6 # fixed-length code
2145
2904
  ```
2146
2905
 
2147
2906
  Reads as "between N and N" which collapses to "exactly N."
@@ -2165,14 +2924,20 @@ redundant `1` that the `!` modifier already implies:
2165
2924
 
2166
2925
  ```coffee
2167
2926
  # These pairs mean the same thing:
2168
- firstName! 1..50 firstName! ..50
2169
- name! 1..100 name! ..100
2170
- email! 1..320 email! ..320
2171
-
2172
- # But explicit always wins:
2173
- admin! 0..50 # explicit min=0 stays (rare: required but empty allowed)
2174
- age! 0..120 # explicit min=0 stays (newborns are zero)
2175
- score! 0..100 # explicit min=0 stays (test score can be zero)
2927
+ Explicit = schema
2928
+ firstName! 1..50
2929
+ name! 1..100
2930
+ email! 1..320
2931
+ Sugar = schema
2932
+ firstName! ..50
2933
+ name! ..100
2934
+ email! ..320
2935
+
2936
+ # But an explicit min always wins:
2937
+ Zeroes = schema
2938
+ admin! 0..50 # explicit min=0 stays (rare: required but empty allowed)
2939
+ age! 0..120 # explicit min=0 stays (newborns are zero)
2940
+ score! 0..100 # explicit min=0 stays (test score can be zero)
2176
2941
  ```
2177
2942
 
2178
2943
  If the sugar would produce an impossible constraint (`! ..0` →
@@ -2205,9 +2970,10 @@ rejected at parse time with a clear error.
2205
2970
  Trailing comma + indent continues the line:
2206
2971
 
2207
2972
  ```coffee
2208
- password! string,
2209
- 8..100,
2210
- /[A-Z]/
2973
+ Account = schema
2974
+ password! string,
2975
+ 8..100,
2976
+ /[A-Z]/
2211
2977
  ```
2212
2978
 
2213
2979
  This is the same rule Rip applies to any trailing-comma continuation.
@@ -2236,7 +3002,7 @@ schema.defaultMaxString = 500
2236
3002
 
2237
3003
  User = schema :model
2238
3004
  name! # → {min: 1, max: 500} (sugar + pragma)
2239
- email!# email # → {max: 500}
3005
+ email! email @unique # → {max: 500}
2240
3006
  code? # → {max: 500}
2241
3007
  password! 8..200 # → {min: 8, max: 200} (explicit wins)
2242
3008
  bio? text # → no constraint (text opts out)
@@ -2275,16 +3041,16 @@ without changing the scanner shape.
2275
3041
 
2276
3042
  ---
2277
3043
 
2278
- ## 21. Relations
3044
+ ## 26. Relations
2279
3045
 
2280
3046
  ### Directive → accessor → return type
2281
3047
 
2282
3048
  | Directive | Accessor name | Returns |
2283
3049
  | --------------------------- | --------------------- | ---------------------------------------- |
2284
- | `@belongs_to User` | `user()` | `Promise<UserInstance \| null>` |
2285
- | `@belongs_to User?` | `user()` | `Promise<UserInstance \| null>` + nullable FK |
2286
- | `@has_one Profile` | `profile()` | `Promise<ProfileInstance \| null>` |
2287
- | `@has_many Order` | `orders()` | `Promise<OrderInstance[]>` |
3050
+ | `@belongs_to User` | `user()` | `Promise<User \| null>` |
3051
+ | `@belongs_to User?` | `user()` | `Promise<User \| null>` + nullable FK |
3052
+ | `@has_one Profile` | `profile()` | `Promise<Profile \| null>` |
3053
+ | `@has_many Order` | `orders()` | `Promise<Order[]>` |
2288
3054
 
2289
3055
  Accessor names:
2290
3056
 
@@ -2306,11 +3072,18 @@ that declares the target (or the model file itself) are enough to make
2306
3072
  resolution succeed. Unresolved targets throw a runtime error with the
2307
3073
  name and the caller's schema name included.
2308
3074
 
3075
+ ### Memoization
3076
+
3077
+ Accessor results memoize per instance: the second `user.orders!` call
3078
+ resolves from cache with no query. Eager loading (`.includes`) fills
3079
+ the same memo, which is why preloaded relations are free. Pass
3080
+ `{reload: true}` to bust the memo and re-query.
3081
+
2309
3082
  ---
2310
3083
 
2311
- ## 22. Design invariants
3084
+ ## 27. Design invariants
2312
3085
 
2313
- Twelve rules that define how Rip Schema behaves. Worth keeping in mind
3086
+ Sixteen rules that define how Rip Schema behaves. Worth keeping in mind
2314
3087
  when debugging or extending:
2315
3088
 
2316
3089
  1. **Default kind is `:input`.** `schema` with no marker and a
@@ -2333,10 +3106,15 @@ when debugging or extending:
2333
3106
  property of the field, not the instance.
2334
3107
  6. **`:mixin` is non-instantiable.** Mixins declare fields for reuse —
2335
3108
  they don't have a runtime identity of their own.
2336
- 7. **Schema names are global.** Relations and `@mixin` references
2337
- resolve by bare name through a process-global registry. Two models
2338
- with the same name in different modules produce the "last loaded
2339
- wins" behavior avoid it.
3109
+ 7. **Schema names are global, and collisions fail loudly.** Relations
3110
+ and `@mixin` references resolve by bare name through a
3111
+ process-global registry. Registering a name that already exists
3112
+ with a *different* definition throws at registration time;
3113
+ structurally identical re-registration (the same module arriving
3114
+ twice) rebinds silently. `__SchemaRegistry.replace = true` restores
3115
+ last-loaded-wins for dev/HMR reload; `__SchemaRegistry.scope(fn)`
3116
+ runs `fn` against a fresh registry and restores the parent (test
3117
+ isolation).
2340
3118
  8. **Default field type is `string`.** Omitting the type slot is
2341
3119
  legal; `name!` means "required string". Explicit types
2342
3120
  (`integer`, `email`, `"M" | "F"`, etc.) are needed only when
@@ -2356,20 +3134,46 @@ when debugging or extending:
2356
3134
  result as an own enumerable property. Mutating a dependency
2357
3135
  afterward does **not** update the derived value — it stays stale
2358
3136
  by design. Use `~>` for always-current derivations.
2359
- 12. **Refinements are schema-level, not field-level.** `@ensure`
2360
- predicates run after per-field validation succeeds, once per
2361
- parse, against the whole defaulted and typed object. They fail
2362
- with a declared message that ships verbatim to the caller;
2363
- thrown exceptions inside a predicate count as failure, not
2364
- error. Refinements are skipped on DB hydrate (trusted data)
2365
- and dropped by every algebra op (structural derivation never
2366
- carries non-structural invariants).
3137
+ 12. **Refinements are schema-level, not field-level by default.**
3138
+ `@ensure` predicates run after per-field validation succeeds, once
3139
+ per parse, against the whole defaulted and typed object. An
3140
+ optional `:field` symbol attributes the failure to one input;
3141
+ without it the issue is schema-wide (`field: ''`). They fail with
3142
+ a declared message that ships verbatim to the caller; thrown
3143
+ exceptions inside a predicate count as failure, not error.
3144
+ Refinements are skipped on DB hydrate (trusted data) and dropped
3145
+ by every algebra op (structural derivation never carries
3146
+ non-structural invariants).
3147
+ 13. **Coercion is field semantics.** `~type` / `~:name` converts the
3148
+ wire value in pipeline step 1, before defaults and validation —
3149
+ constraints always see the coerced value. Like transforms,
3150
+ coercion survives algebra and is skipped on hydrate. A failed
3151
+ coercion is `{error: 'coerce'}`, distinct from `{error: 'type'}`;
3152
+ an unregistered `~:name` is a loud config error, never a
3153
+ validation failure.
3154
+ 14. **Async-validating schemas refuse the sync API.** One `@ensure!`
3155
+ makes the whole schema async-validating: `.parse`/`.safe`/`.ok`
3156
+ throw immediately ("use parseAsync!/safeAsync!/okAsync!") rather
3157
+ than sometimes-returning a promise. Sync refinements run first;
3158
+ async ones run concurrently; issues collect in declaration order.
3159
+ 15. **Unions dispatch, never merge.** `:union` resolves the
3160
+ discriminator to exactly one constituent and delegates — the
3161
+ result is that constituent's instance. Discriminator values must
3162
+ be disjoint string literals (checked at first parse, lazily).
3163
+ Algebra on a union throws; distribute-vs-intersect has no
3164
+ obviously-right answer, so v1 declines to guess.
3165
+ 16. **The default scope applies at terminal time.** `@defaultScope`
3166
+ composes into the query when a terminal (`all`/`first`/`count`/
3167
+ `updateAll`/`deleteAll`) runs — so `.unscoped()` works anywhere in
3168
+ the chain and the default's clauses never double-apply. `find`
3169
+ routes through the builder, so the default scope and the
3170
+ `@softDelete` filter apply to it uniformly.
2367
3171
 
2368
3172
  ---
2369
3173
 
2370
3174
  # Part III — Architecture
2371
3175
 
2372
- ## 23. Runtime architecture
3176
+ ## 28. Runtime architecture
2373
3177
 
2374
3178
  Each schema goes through four layers. Each layer is built lazily on first
2375
3179
  need, and the caches are independent.
@@ -2465,19 +3269,29 @@ this layer.
2465
3269
  Built on first `.find/.create/.save/.destroy/.where` call on a `:model`.
2466
3270
  Wires:
2467
3271
 
2468
- - the query builder
2469
- - save / destroy flows (including hook lifecycle)
2470
- - relation accessors on the generated class
2471
- - instance methods (`save`, `destroy`, `ok`, `errors`, `toJSON`)
3272
+ - the query builder (with scope methods, default-scope composition,
3273
+ soft-delete filtering, and `.includes` eager-load resolution)
3274
+ - save / destroy / restore flows (including the 12-hook lifecycle and
3275
+ constraint-violation translation)
3276
+ - relation accessors on the generated class (with per-instance
3277
+ memoization)
3278
+ - instance methods (`save`, `destroy`, `restore`, `ok`, `errors`,
3279
+ `toJSON`)
3280
+ - transaction routing — every statement checks the AsyncLocalStorage
3281
+ slot for the schema's adapter and joins an ambient
3282
+ `schema.transaction!` automatically
2472
3283
 
2473
3284
  Requires a configured adapter before first use.
2474
3285
 
2475
3286
  ### Layer 4b — DDL plan
2476
3287
 
2477
- Built on first `.toSQL()` call. Emits the `CREATE SEQUENCE` /
2478
- `CREATE TABLE` / indexes + foreign keys for one model. Independent of
2479
- Layer 4a a migration script that never touches the ORM builds this
2480
- layer only.
3288
+ Built on first `.toSQL()` call. A canonical `_tableSpec` (columns,
3289
+ indexes, FKs, sequence) renders to `CREATE SEQUENCE` / `CREATE TABLE` /
3290
+ indexes + foreign keys for one model. The same spec is what the
3291
+ migration differ (`rip schema status/make/migrate`) compares against
3292
+ the introspected live database — DDL emission and diffing can't drift
3293
+ apart because they share one source. Independent of Layer 4a — a
3294
+ migration script that never touches the ORM builds this layer only.
2481
3295
 
2482
3296
  ### Lazy is the point
2483
3297
 
@@ -2491,20 +3305,26 @@ don't have to.
2491
3305
 
2492
3306
  `__SchemaRegistry` holds every named `:model` and `:mixin` by bare name.
2493
3307
  Registration happens in the `__SchemaDef` constructor — *importing a
2494
- file that declares named schemas activates them*. Tests can call
2495
- `__SchemaRegistry.reset()` between runs to avoid cross-test leakage.
3308
+ file that declares named schemas activates them*. Re-declaring a name
3309
+ with an identical structure is an idempotent no-op (double-import
3310
+ safe); a *different* structure throws unless `replace` mode is on (HMR
3311
+ and test runners opt in). Tests can also use `__SchemaRegistry.scope()`
3312
+ for isolated registries or `.reset()` between runs.
2496
3313
 
2497
3314
  ### The adapter
2498
3315
 
2499
- One function: `adapter.query(sql, params) → {columns, data, rows}`. The
2500
- default adapter talks to rip-db via `fetch`. Custom adapters (for
2501
- tests, in-memory mocks, alternate SQL backends) install with
2502
- `globalThis.__ripSchema.__schemaSetAdapter`. Every ORM method funnels
2503
- through this interface.
3316
+ Contract v2: `query(sql, params) → {columns, data, rowCount}` is the
3317
+ one required method; `begin(options) TxHandle` and a truthful
3318
+ `capabilities` object are optional and feature-detected (§11). The
3319
+ default adapter talks to a duckdb-harbor instance via `fetch`; named
3320
+ adapters from `schema.connect(...)` bind individual models elsewhere
3321
+ (`on:`). Custom adapters (for tests, in-memory mocks, alternate SQL
3322
+ backends) install with `globalThis.__ripSchema.__schemaSetAdapter`.
3323
+ Every ORM method funnels through this interface.
2504
3324
 
2505
3325
  ---
2506
3326
 
2507
- ## 24. Compiler integration
3327
+ ## 29. Compiler integration
2508
3328
 
2509
3329
  The schema keyword is implemented as a compiler sidecar in
2510
3330
  `src/schema/schema.js`, alongside the existing type and component sidecars.
@@ -2570,21 +3390,41 @@ its footprint in the main compiler is small.
2570
3390
 
2571
3391
  ---
2572
3392
 
2573
- ## 25. FAQ
3393
+ ## 30. FAQ
2574
3394
 
2575
3395
  **Why not just use Zod?**
2576
- Zod gives you the validator. It doesn't give you the ORM, the DDL, the
2577
- class, the computed getters, or the derived DTOs. Rip Schema is all of
2578
- that from one declaration. If you only need the validator, `schema :input`
2579
- is the equivalent surface and the derived shadow TS is indistinguishable
2580
- from `z.infer<>`.
3396
+ Zod gives you the validator. It doesn't give you the ORM, the
3397
+ transactions, the migrations, the class, the computed getters, the
3398
+ derived DTOs, or the OpenAPI document. Rip Schema is all of that from
3399
+ one declaration. The validator alone now covers Zod's daily surface
3400
+ too: strict coercion (`~integer`, `~:phone`), discriminated unions
3401
+ (`schema :union`), sync and async refinements (`@ensure` /
3402
+ `@ensure!`), and `safeParse`-style structured results — with shadow TS
3403
+ indistinguishable from `z.infer<>` and no codegen step.
2581
3404
 
2582
3405
  **Is this a full ORM replacement for Prisma / Drizzle?**
2583
- For the common CRUD shape — yes. `find`, `where`, `create`, `save`,
2584
- `destroy`, relations, migrations, hooks, lifecycle callbacks, and
2585
- validations are all present and running in production apps. For
2586
- transactions, eager loading, scopes, and soft deletes not yet; see
2587
- §15.
3406
+ For the production shape — yes. `find`, `where`, `create`, `save`,
3407
+ `destroy`, relations, hooks, validations, transactions
3408
+ (`schema.transaction!`), eager loading (`.includes`), query scopes
3409
+ (`@scope` / `@defaultScope`), soft deletes, upsert/batch writes,
3410
+ structured constraint-violation errors, DDL, **and migration diffing**
3411
+ (`rip schema status / make / migrate` — declared models vs the live
3412
+ database, with checksummed history and rename annotations). The
3413
+ remaining gaps are listed honestly in §20.
3414
+
3415
+ **How do transactions propagate without passing a handle around?**
3416
+ AsyncLocalStorage. `schema.transaction! ->` pins a connection and binds
3417
+ it to the async context; every ORM call inside the block routes through
3418
+ it automatically — model code is unchanged. Nested calls join the outer
3419
+ transaction; parallel transactions get independent contexts; per-schema
3420
+ adapters get independent slots. See §9.
3421
+
3422
+ **What's the difference between `"a" | "b"` and `schema :union`?**
3423
+ The literal union is a FIELD type — one value constrained to a set of
3424
+ strings. `:union` is a SCHEMA kind — whole objects dispatched to
3425
+ different constituent schemas by a discriminator field (`@on :kind`).
3426
+ A single literal (`kind! "click"`) is the constant field that tags a
3427
+ constituent.
2588
3428
 
2589
3429
  **Does the runtime belong to `schema.js` or is it loaded separately?**
2590
3430
  It's inlined. When a file uses `schema`, the compiler injects a small
@@ -2634,7 +3474,7 @@ Yes. They compose: `User.omit("password").pick("name", "email").partial()`
2634
3474
  produces a `:shape` with the intersection of the three operations.
2635
3475
 
2636
3476
  **How do I express cross-field rules — "passwords must match", "end after start"?**
2637
- Use `@ensure`. See [§5](#refinement-ensure) and the summary in [§22](#22-design-invariants)
3477
+ Use `@ensure`. See [§5](#refinement-ensure) and the summary in [§27](#27-design-invariants)
2638
3478
  invariant 12. Messages are required, predicates are plain Rip fns,
2639
3479
  thrown exceptions count as failure, and all refinements run every time
2640
3480
  (no short-circuit between refinements).