rip-lang 3.13.136 → 3.14.0

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.
@@ -0,0 +1,2390 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/assets/rip-schema-social.png" alt="Rip Schema" width="640">
3
+ </p>
4
+
5
+ # Rip Schema
6
+
7
+ > **One keyword. A validator, a class, an ORM, a migration tool, and a TypeScript type — from a single declaration.**
8
+
9
+ In a typical TypeScript application the shape of a `User` is described four
10
+ times. Once as a Zod schema for input validation. Once as a Prisma model for
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.
14
+
15
+ Rip Schema collapses all four into one declaration:
16
+
17
+ ```coffee
18
+ User = schema :model
19
+ name! string, 1..100
20
+ email!# email
21
+ @timestamps
22
+ @has_many Order
23
+ identifier: ~> "#{@name} <#{@email}>"
24
+ beforeValidation: -> @email = @email.toLowerCase()
25
+ ```
26
+
27
+ From that single line of source, the language gives you:
28
+
29
+ - a **runtime validator** — `User.parse(data)` / `.safe()` / `.ok()`
30
+ - a **generated class** with your methods and `~>` computed getters bound as prototype getters
31
+ - a **TypeScript type** — `ModelSchema<UserInstance, UserData>`, automatic, no codegen step
32
+ - 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
34
+ - **schema algebra** — `User.omit("password")` produces a correctly-typed derived shape
35
+
36
+ Schemas are runtime values. You pass them around, export them, derive from
37
+ them, reference them anywhere an expression is valid. They're not a separate
38
+ language — they're a vocabulary inside Rip.
39
+
40
+ This guide is the canonical reference. Part I teaches the concepts and
41
+ syntax. Part II is reference tables you'll look up. Part III covers
42
+ architecture for contributors.
43
+
44
+ ---
45
+
46
+ # Contents
47
+
48
+ ## Part I — Using Rip Schema
49
+ 1. [What Rip Schema is](#1-what-rip-schema-is)
50
+ 2. [A quick tour](#2-a-quick-tour)
51
+ 3. [Schemas vs types](#3-schemas-vs-types)
52
+ 4. [The five kinds](#4-the-five-kinds)
53
+ 5. [Body syntax](#5-body-syntax)
54
+ 6. [The runtime API](#6-the-runtime-api)
55
+ 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)
64
+
65
+ ## 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)
73
+
74
+ ## Part III — Architecture
75
+ 23. [Runtime architecture](#23-runtime-architecture)
76
+ 24. [Compiler integration](#24-compiler-integration)
77
+ 25. [FAQ](#25-faq)
78
+
79
+ ---
80
+
81
+ # Part I — Using Rip Schema
82
+
83
+ ## 1. What Rip Schema is
84
+
85
+ A *schema* in Rip is a runtime value that describes data. You create one with
86
+ the `schema` keyword and an optional `:kind` symbol:
87
+
88
+ ```coffee
89
+ SignupInput = schema # default :input
90
+ Role = schema :enum
91
+ User = schema :model
92
+ ```
93
+
94
+ Every schema is a real JavaScript object at runtime. It has methods
95
+ (`.parse`, `.safe`, `.ok`, plus ORM methods on `:model` and algebra methods
96
+ on derived shapes). It carries its own metadata (fields, constraints,
97
+ relations, hooks) and lazily builds the validator plan, ORM plan, and DDL
98
+ plan on first use.
99
+
100
+ Because schemas are values, you pass them around, export them, derive from
101
+ them, and reference them anywhere an expression is valid. They're not a
102
+ separate language — they're a vocabulary inside Rip.
103
+
104
+ **Why schemas exist as a distinct thing:** most applications need one
105
+ coherent description of each data shape for three audiences:
106
+
107
+ 1. **Runtime** — validate external input, produce clean typed values
108
+ 2. **Database** — issue migrations, run queries, hydrate rows
109
+ 3. **Editor / compile time** — autocomplete, typecheck, hover docs
110
+
111
+ Rip Schema gives all three from a single declaration. Write the shape once
112
+ and the language handles the rest.
113
+
114
+ ### What this replaces
115
+
116
+ In the JavaScript and TypeScript ecosystem, covering the same surface area
117
+ requires stitching together several independent libraries — each with its own
118
+ schema dialect, its own types, its own runtime, its own failure modes:
119
+
120
+ | Concern | Typical TypeScript stack | Rip Schema |
121
+ | --------------------------- | ----------------------------------- | ------------------------------------ |
122
+ | Input validation | Zod, Yup, Joi, io-ts, Valibot | `schema :input` + `.parse/.safe` |
123
+ | Domain objects with logic | hand-written classes + `zod.infer` | `schema :shape` |
124
+ | Database models | Prisma, Drizzle, TypeORM, Sequelize | `schema :model` |
125
+ | Migrations / DDL | Prisma migrate, Drizzle Kit, knex | `Model.toSQL()` |
126
+ | API projections / DTOs | `.pick` / `.omit` on Zod + class | `Model.pick/.omit/.partial/.extend` |
127
+ | Static types for the editor | Inferred from every library above | Automatic shadow TS — no codegen |
128
+ | Fixed value sets | TS `enum` or string unions | `schema :enum` (runtime + static) |
129
+ | Shared field groups | Intersection types + manual merge | `schema :mixin` + `@mixin Name` |
130
+
131
+ The equivalent TypeScript stack for a single model is roughly:
132
+
133
+ ```ts
134
+ // validator.ts
135
+ export const UserInput = z.object({
136
+ name: z.string().min(1).max(100),
137
+ email: z.string().email(),
138
+ })
139
+
140
+ // schema.prisma
141
+ model User {
142
+ id Int @id @default(autoincrement())
143
+ name String
144
+ email String @unique
145
+ orders Order[]
146
+ }
147
+
148
+ // user.ts
149
+ export class User {
150
+ constructor(public data: Prisma.User) {}
151
+ get identifier() { return `${this.data.name} <${this.data.email}>` }
152
+ }
153
+
154
+ // dto.ts
155
+ export const UserPublic = UserInput.omit({ email: true })
156
+ export type UserPublic = z.infer<typeof UserPublic>
157
+ ```
158
+
159
+ Four files. Three dialects (Zod, Prisma DSL, TS). Two codegen steps. Drift
160
+ between them is a category of bug that only exists because the description
161
+ lives in more than one place.
162
+
163
+ The Rip Schema equivalent is the five-line `:model` declaration in the
164
+ opening of this document. The validator, the database model, the class with
165
+ its derived property, and the `UserPublic` DTO all fall out of that one
166
+ declaration — as runtime values, with full editor support, without codegen.
167
+
168
+ This is not incremental. One keyword replaces an entire category of tooling.
169
+
170
+ ---
171
+
172
+ ## 2. A quick tour
173
+
174
+ ### Input validation
175
+
176
+ ```coffee
177
+ SignupInput = schema
178
+ email! email
179
+ password! string, 8..100
180
+ age? integer, 18..120
181
+
182
+ # Throws SchemaError on failure, returns a cleaned value on success
183
+ input = SignupInput.parse rawJson
184
+
185
+ # Structured result, no throwing
186
+ result = SignupInput.safe rawJson
187
+ # → {ok: true, value, errors: null} or {ok: false, value: null, errors: [...]}
188
+
189
+ # Fast boolean check
190
+ valid = SignupInput.ok rawJson
191
+ ```
192
+
193
+ ### A shape with behavior
194
+
195
+ ```coffee
196
+ Address = schema :shape
197
+ street! string, 1..200
198
+ city! string
199
+ state! string, 2..2
200
+ zip! string, /^\d{5}$/
201
+
202
+ # Computed getters (~>) read instance fields and return derived values
203
+ full: ~> "#{@street}, #{@city}, #{@state} #{@zip}"
204
+
205
+ # Methods (->) run with `this` bound to the instance
206
+ normalize: ->
207
+ @city = @city.trim()
208
+ @
209
+
210
+ a = Address.parse street: "123 Main", city: " Palo Alto ", state: "CA", zip: "94301"
211
+ a.full # "123 Main, Palo Alto, CA 94301" — using the raw city
212
+ a.normalize()
213
+ a.city # "Palo Alto" — trimmed
214
+ ```
215
+
216
+ ### An enum
217
+
218
+ ```coffee
219
+ Status = schema
220
+ :pending 0
221
+ :active 1
222
+ :done 2
223
+
224
+ Status.parse "pending" # 0 — name resolves to value
225
+ Status.parse 0 # 0 — value resolves to value
226
+ Status.ok "unknown" # false
227
+ ```
228
+
229
+ ### A DB-backed model
230
+
231
+ ```coffee
232
+ User = schema :model
233
+ name! string, 1..100
234
+ email!# email
235
+ @timestamps
236
+ @has_many Order
237
+
238
+ identifier: ~> "#{@name} <#{@email}>"
239
+ beforeSave: -> @email = @email.toLowerCase()
240
+
241
+ Order = schema :model
242
+ total! integer
243
+ @belongs_to User
244
+ @timestamps
245
+
246
+ # DDL for migration (works with or without the ORM adapter configured)
247
+ sql = User.toSQL()
248
+
249
+ # ORM operations (async, use `!` or `await`)
250
+ user = User.create! name: "Alice", email: "ALICE@EXAMPLE.COM"
251
+ found = User.find! user.id
252
+ orders = user.orders! # has_many relation → Order[]
253
+ owner = orders[0]?.user! # belongs_to relation → User
254
+ ```
255
+
256
+ ### Schema algebra — derive new shapes
257
+
258
+ ```coffee
259
+ UserPublic = User.omit "email" # → Schema<Omit<UserData, 'email'>>
260
+ UserCreate = User.pick "name", "email" # → Schema<Pick<UserData, 'name' | 'email'>>
261
+ UserUpdate = User.partial() # → Schema<Partial<UserData>>
262
+ AdminUser = User.extend schema :shape
263
+ permissions! string[]
264
+ ```
265
+
266
+ Derived schemas are always `:shape`. **Field semantics survive** —
267
+ type, constraints, inline transforms all carry through. **Instance
268
+ behavior is dropped** — methods, computed getters (`~>`), eager
269
+ derived fields (`!>`), hooks, and ORM methods don't carry through.
270
+ Algebra is a structural operation on fields, not a behavioral one.
271
+
272
+ ---
273
+
274
+ ## 3. Schemas vs types
275
+
276
+ Rip has two ways to describe data: the `type` / `interface` / `enum`
277
+ compile-time system and the `schema` runtime system. They don't compete —
278
+ they handle different concerns.
279
+
280
+ | Feature | `type` / `interface` / `enum` | `schema` |
281
+ | ------------------------------ | ----------------------------- | ------------------------- |
282
+ | Exists at runtime | No | Yes |
283
+ | Validates data | No | Yes |
284
+ | Produces values / instances | No | Yes |
285
+ | Generates SQL / ORM | No | `:model` only |
286
+ | Used by shadow TS | Yes | Yes |
287
+ | Supports `.parse()` | No | Yes |
288
+ | Erased from JS output | Yes | No |
289
+
290
+ **Rules of thumb:**
291
+
292
+ - Use `type` / `interface` for shapes you want the editor and `rip check` to
293
+ understand, but where the data is already trusted at runtime — internal
294
+ function signatures, intermediate values, return types.
295
+ - Use `schema :input` when data enters your program from outside (HTTP body,
296
+ `JSON.parse`, stdin, query params) and you need runtime guarantees.
297
+ - Use `schema :shape` when you want the same runtime guarantees plus
298
+ behavior (methods, computed getters) — for example a `Point` or `Address`
299
+ value that carries derived computations.
300
+ - Use `schema :model` for DB-backed entities. You get the validator, the
301
+ ORM, the migration DDL, the relation methods, and the shadow TS all from
302
+ one declaration.
303
+ - Use `schema :enum` when the set of values is fixed and runtime membership
304
+ matters. The compile-time `enum` keyword still exists for cases where you
305
+ only need the type and don't need runtime validation.
306
+ - Use `schema :mixin` when two or more schemas share a field group.
307
+
308
+ A schema is never the wrong tool for runtime data. A `type` is never the
309
+ wrong tool for purely compile-time descriptions. When both apply, use the
310
+ schema — it includes everything the type would give you (via shadow TS)
311
+ plus the runtime dimension.
312
+
313
+ ---
314
+
315
+ ## 4. The five kinds
316
+
317
+ Every schema has one of five kinds, selected by a `:symbol` after the
318
+ `schema` keyword:
319
+
320
+ ```coffee
321
+ input = schema # default — :input
322
+ shape = schema :shape
323
+ enum = schema :enum
324
+ mixin = schema :mixin
325
+ model = schema :model
326
+ ```
327
+
328
+ The kind determines which body forms are legal, what `.parse()` returns,
329
+ and whether ORM / DDL surface is active.
330
+
331
+ ### `:input`
332
+
333
+ A field validator. Body allows fields and `@mixin` only. `.parse(data)`
334
+ returns a plain validated object. No behavior, no persistence.
335
+
336
+ ```coffee
337
+ SignupInput = schema
338
+ email! email
339
+ password! string, 8..100
340
+ ```
341
+
342
+ ### `:shape`
343
+
344
+ A validator with behavior. Body accepts every field form — including
345
+ inline transforms (`name! type, -> body`) and the three colon-anchored
346
+ forms: methods (`name: -> body`), computed getters (`name: ~> body`),
347
+ and eager-derived fields (`name: !> body`). `@mixin` is the one
348
+ directive allowed. `.parse(data)` returns a class instance — declared
349
+ fields and eager-derived are own enumerable properties, methods live
350
+ on the prototype, computed getters are non-enumerable prototype
351
+ getters.
352
+
353
+ ```coffee
354
+ Address = schema :shape
355
+ street! string
356
+ city! string
357
+ full: ~> "#{@street}, #{@city}"
358
+ ```
359
+
360
+ `:shape` cannot carry lifecycle hooks (there's no lifecycle) or ORM-bound
361
+ directives (`@timestamps`, `@belongs_to`, `@has_many`, `@has_one`,
362
+ `@softDelete`, `@index`). Known hook names like `beforeSave` used on
363
+ `:shape` are just methods — no lifecycle binding.
364
+
365
+ ### `:enum`
366
+
367
+ A fixed set of values. Members are `:symbol` literals; valued members add
368
+ a space-separated literal.
369
+
370
+ ```coffee
371
+ Role = schema # :enum kind inferred from :symbol body
372
+ :admin
373
+ :user
374
+ :guest
375
+
376
+ Status = schema
377
+ :pending 0
378
+ :active 1
379
+ :done 2
380
+ ```
381
+
382
+ `.parse()` accepts either the member name or its value and returns the
383
+ value. For bare enums (no values), members map to their own name strings.
384
+
385
+ ### `:mixin`
386
+
387
+ A reusable field group. Non-instantiable — you can't `.parse()` or `.ok()`
388
+ a mixin. Other schemas pull the fields in with `@mixin Name`.
389
+
390
+ ```coffee
391
+ Timestamps = schema :mixin
392
+ createdAt! datetime
393
+ updatedAt! datetime
394
+
395
+ User = schema :model
396
+ name! string
397
+ @mixin Timestamps # contributes createdAt + updatedAt
398
+ ```
399
+
400
+ Mixins are fields-only. Methods, computed, hooks, and non-`@mixin`
401
+ directives inside a mixin body are compile errors.
402
+
403
+ ### `:model`
404
+
405
+ A DB-backed entity. Everything `:shape` offers (all field forms,
406
+ methods, computed, eager-derived, inline transforms), plus: relations,
407
+ lifecycle hooks, the full ORM surface (`find`, `where`, `create`,
408
+ `save`, `destroy`, `toSQL`), and a process-global registry entry.
409
+ Eager-derived fields re-run on DB hydrate so they appear on instances
410
+ returned from `.find()` / `.where()` exactly as they do on parsed
411
+ instances.
412
+
413
+ ```coffee
414
+ User = schema :model
415
+ name! string
416
+ email!# email
417
+ @timestamps
418
+ @has_many Order
419
+
420
+ greet: -> "Hello, #{@name}!"
421
+ beforeValidation: -> @email = @email.toLowerCase()
422
+ ```
423
+
424
+ ---
425
+
426
+ ## 5. Body syntax
427
+
428
+ Schema bodies are intentionally not general Rip code — they're declarative.
429
+ Only these line forms are allowed; anything else is a compile error with
430
+ a schema-specific diagnostic.
431
+
432
+ ### Field
433
+
434
+ ```coffee
435
+ name[!|?|#]* [type] [range] [default] [regex] [attrs] [, -> transform]
436
+ ```
437
+
438
+ Modifiers:
439
+
440
+ | Modifier | Meaning |
441
+ | -------- | -------- |
442
+ | `!` | required |
443
+ | `#` | unique (emits `UNIQUE` in DDL; also creates a unique index) |
444
+ | `?` | optional |
445
+
446
+ Any combination works (`email!#` means required + unique). Order among
447
+ modifiers doesn't matter. No modifier means "present but not required" —
448
+ equivalent to `?` for validation purposes.
449
+
450
+ **Type is optional** — when omitted, the field defaults to `string`. Type
451
+ expressions accept:
452
+
453
+ - a type identifier (`string`, `email`, `integer`, …)
454
+ - an array suffix (`string[]`)
455
+ - a string-literal union (`"M" | "F" | "U"`) — value must be one of the
456
+ listed members; 2+ members required, no mixing with base types
457
+
458
+ ```coffee
459
+ name! # required string (default type)
460
+ tags! string[] # required array of strings
461
+ email!# email # required, unique, email-format-validated
462
+ bio? text, 0..1000 # optional text, 0-1000 chars
463
+ role? string, ["user"] # optional, default "user"
464
+ status string, [:draft] # default :draft — same as ["draft"]
465
+ zip! string, /^\d{5}$/ # regex-validated
466
+ sex? "M" | "F" | "U" # literal union
467
+ priority "low" | "med" | "high", [:med] # literal union + default
468
+ ```
469
+
470
+ ### Inline field transform
471
+
472
+ A `-> body` at the end of a field line derives the field's value from the
473
+ raw input. `it` inside the body refers to the **whole raw input object**
474
+ (not just the field's wire value), so transforms can pick from a
475
+ differently-named key, compose across multiple inputs, or coerce types:
476
+
477
+ ```coffee
478
+ id! -> it.Id # remap PascalCase input
479
+ displayName! -> it.DisplayName
480
+ shippedAt? date, -> new Date(it.shippedAt) # wire string → Date
481
+ slug! -> "#{it.FirstName}-#{it.LastName}".toLowerCase()
482
+ email!# email, -> it.email.toLowerCase().trim() # normalize + validate
483
+ ```
484
+
485
+ Rules:
486
+
487
+ - **Declared type is the OUTPUT type** — the validator checks the
488
+ transform's *return value*. The input shape is implicit.
489
+ - **Transform is terminal** on the field line — nothing follows `->`.
490
+ - **Comma before `->` is required** whenever anything precedes it on
491
+ the line (type, range, regex, default, attrs). The comma is a
492
+ structural boundary between the field declaration and the
493
+ transform, not an argument separator — without it, lines like
494
+ `email!# email -> fn` misleadingly suggest `email` is an input to
495
+ the arrow. The bare form `name! -> fn` (nothing before the arrow
496
+ except the name and modifiers) parses comma-less because there's
497
+ nothing to elide. This is unlike Rip's general `get '/path' ->`
498
+ rule: in a function call the arrow is the last argument; in a
499
+ schema field it's a distinct semantic slot.
500
+ - **Runs once at `.parse()`**, never on DB hydrate (rows arrive
501
+ canonical).
502
+ - **Survives algebra** (`.pick`, `.omit`, etc.) — field semantics, not
503
+ instance behavior. A picked schema may still read raw-input keys not
504
+ in its output shape.
505
+ - **Errors** in the transform wrap as `{error: 'transform'}` issues.
506
+
507
+ ### Directive
508
+
509
+ ```coffee
510
+ @name [args]
511
+ ```
512
+
513
+ Directives attach behavior that isn't a field. The set depends on the
514
+ kind (see [§18](#18-directives)). Examples:
515
+
516
+ ```coffee
517
+ @timestamps # adds createdAt/updatedAt columns (:model only)
518
+ @softDelete # adds deletedAt, soft-deletes on .destroy() (:model only)
519
+ @index [role, active] # composite index (:model only)
520
+ @belongs_to Organization? # nullable FK (:model only)
521
+ @has_many Order # has-many relation (:model only)
522
+ @mixin Timestamps # pull in a mixin's fields (any fielded kind)
523
+ ```
524
+
525
+ ### Method
526
+
527
+ ```coffee
528
+ name: -> body
529
+ ```
530
+
531
+ Thin-arrow method bound on the generated class prototype. `this` is the
532
+ instance. For `:model`, method names matching known [hook
533
+ names](#19-hook-reference) bind to the lifecycle; on other kinds those
534
+ names are just methods.
535
+
536
+ ```coffee
537
+ greet: -> "Hello, #{@name}!"
538
+
539
+ beforeSave: ->
540
+ @email = @email.toLowerCase()
541
+ @slug = @name.toLowerCase().replace(/\s+/g, '-')
542
+ ```
543
+
544
+ ### Computed getter (lazy)
545
+
546
+ ```coffee
547
+ name: ~> body
548
+ ```
549
+
550
+ Reactive-style arrow, emitted as a non-enumerable prototype getter via
551
+ `Object.defineProperty(proto, name, {get: fn})`. **Re-evaluates on every
552
+ access** — reflects the current instance state. Excluded from DDL and
553
+ persistence.
554
+
555
+ ```coffee
556
+ full: ~> "#{@street}, #{@city}"
557
+ identifier: ~> "#{@name} <#{@email}>"
558
+ isAdmin: ~> @role is 'admin'
559
+ ```
560
+
561
+ ### Eager-derived field
562
+
563
+ ```coffee
564
+ name: !> body
565
+ ```
566
+
567
+ Materialized-once derivation. Runs during `.parse()` (and on DB hydrate)
568
+ after all declared fields are populated. Stored as an **own enumerable
569
+ property**, so it appears in `Object.keys(inst)` and `JSON.stringify(inst)`.
570
+ Excluded from DDL and persistence — re-computed on hydrate from the
571
+ declared fields.
572
+
573
+ ```coffee
574
+ fullName: !> "#{@firstName} #{@lastName}".trim()
575
+ orderNumber: !> "ORD-#{String(@id).padStart(6, '0')}"
576
+ slug: !> @fullName.toLowerCase().replace(/\s+/g, '-')
577
+ ```
578
+
579
+ Declaration order matters — an `!>` can read earlier declared fields
580
+ and earlier `!>` values, but not later ones.
581
+
582
+ ### `!>` vs `~>` — pick the right one
583
+
584
+ They look similar and come from the same grammar family, but they
585
+ behave very differently after mutation. This is the single most
586
+ important distinction in the schema body:
587
+
588
+ | | `name: !> body` (eager) | `name: ~> body` (lazy) |
589
+ |---|---|---|
590
+ | Fires | once at parse / hydrate | every access |
591
+ | Stored as | own enumerable property | non-enumerable prototype getter |
592
+ | `Object.keys(inst)` | includes it | does not |
593
+ | `JSON.stringify(inst)` | includes it | does not |
594
+ | After `inst.field = x` | **stale** — does not recompute | **live** — reflects the new value |
595
+ | Use for | serialized/materialized derivations, labels that ship over the wire | computed properties that should always reflect current state |
596
+
597
+ > **Important**: an `!>` field will appear *stale* if you mutate a
598
+ > dependency afterwards. That's by design — it's a snapshot, not a
599
+ > reactive binding. When in doubt, pick `~>` for live values and save
600
+ > `!>` for cases where the materialization is itself the goal
601
+ > (JSON payload shape, computed labels at construction time).
602
+
603
+ ### Refinement (`@ensure`)
604
+
605
+ Schema-level cross-field invariants. Where field constraints check one
606
+ value against its own type and range, `@ensure` checks the whole object
607
+ against a predicate — "these fields together must satisfy this rule."
608
+
609
+ Two forms, same semantics:
610
+
611
+ ```coffee
612
+ # Inline — a single invariant
613
+ @ensure "name and email must differ", (u) -> u.name isnt u.email
614
+
615
+ # Array — multiple invariants in one block
616
+ @ensure [
617
+ "end after start", (u) -> u.start < u.end
618
+ "complex rule", (u) ->
619
+ normalized = u.name.toLowerCase()
620
+ not RESERVED_NAMES.includes(normalized)
621
+ ]
622
+ ```
623
+
624
+ Both forms compile to the same internal representation; use whichever
625
+ reads cleanest for the case at hand. The inline form is nicer for
626
+ one-offs; the array form keeps related invariants visually grouped.
627
+
628
+ Rules:
629
+
630
+ - **Message is required** and must be a string literal. It comes first
631
+ (before the fn) and is the only thing reported when the predicate
632
+ fails — write it from the user's perspective, not the developer's.
633
+ - **Predicate takes an explicit parameter.** Refinements declare the
634
+ object parameter by name (`(u) -> ...`) rather than using implicit
635
+ `this`. Makes the contract of "what the predicate sees" visible.
636
+ - **Truthy passes, falsy fails.** The predicate's return is coerced to
637
+ boolean — any truthy value (object, array, non-zero number, non-empty
638
+ string, `true`) passes; any falsy value (`false`, `null`, `undefined`,
639
+ `0`, `''`, `NaN`) fails with the declared message.
640
+ - **Thrown exceptions fail.** If the predicate throws, the refinement
641
+ counts as failed with the declared message — the exception doesn't
642
+ propagate. Write safe predicates; this is a guard, not error
643
+ recovery.
644
+ - **All refinements run.** No short-circuit between refinements —
645
+ every predicate runs even if earlier ones failed. Issues collect in
646
+ declaration order.
647
+ - **Refinements run after field validation.** Predicates can assume
648
+ declared fields are typed and defaulted. If any per-field error fires,
649
+ refinements don't run at all — their input would be malformed.
650
+ - **Refinements run before eager-derived fields.** An `!>` body can
651
+ assume the instance satisfies its invariants.
652
+ - **Refinements are skipped on DB hydrate.** `.find()`, `.where()`,
653
+ `.all()` deliver trusted rows; re-validating predicates on hydrate
654
+ would be wasted work.
655
+ - **Refinements drop on algebra.** Any derivation (`.pick`, `.omit`,
656
+ `.partial`, `.required`, `.extend`) returns a `:shape` without any
657
+ refinements from the source. See [§10](#10-schema-algebra).
658
+
659
+ **Scope**: `:input`, `:shape`, and `:model` accept `@ensure`. `:enum`
660
+ and `:mixin` reject it at compile time with a diagnostic pointing at
661
+ where to put the invariant instead.
662
+
663
+ **Issue shape** when a refinement fails:
664
+
665
+ ```js
666
+ { field: '', error: 'ensure', message: 'your declared message' }
667
+ ```
668
+
669
+ `field: ''` matches the convention for other schema-level errors
670
+ (`enum`, `mixin`, `derived`) — the issue isn't attached to any single
671
+ declared field.
672
+
673
+ ### Rules to remember
674
+
675
+ - Fields use `name type` — **no colon**. `name: type` is a compile error.
676
+ - Methods and computed both use `name:` — the colon before the arrow is how
677
+ you distinguish them from fields.
678
+ - `~>` produces a getter. `->` produces a method.
679
+ - A body cannot contain arbitrary statements — only the four forms above
680
+ (plus enum members in `:enum`).
681
+ - The grammar is whitespace-sensitive: indentation opens the body, dedent
682
+ closes it, trailing comma + indent continues a field line onto the next.
683
+
684
+ ### Inline one-liner body
685
+
686
+ For small sub-shapes — the ones where indented-block ceremony outweighs
687
+ the declaration itself — the body can be written inline, with `;` as
688
+ the entry separator:
689
+
690
+ ```coffee
691
+ Address = schema :shape; street?; line2?; city? ..100; state? ..2; zip? ..10
692
+ Billing = schema :shape; type? "client" | "insurance" | "patient"
693
+ Money = schema :shape; amount! integer, 0..; currency! 3..3
694
+ ```
695
+
696
+ Same grammar as the indented form — every field / directive / enum
697
+ form works inline. The emitted `__schema({...})` descriptor is
698
+ byte-for-byte identical to the equivalent indented block, so runtime
699
+ behavior (parse, safe, ok, algebra) is unchanged.
700
+
701
+ **What's not allowed inline:**
702
+
703
+ Method bodies can themselves contain `;`, which would be ambiguous
704
+ with the entry separator. So anything with an arrow — `->` (method /
705
+ hook / transform), `~>` (computed getter), `!>` (eager-derived) — is
706
+ rejected on the inline form with a message pointing to the indented
707
+ form:
708
+
709
+ ```coffee
710
+ # compile error — point at the indented form:
711
+ X = schema :shape; name!; greet: -> @name # ✗ '->' not allowed inline
712
+ X = schema :shape; name!; full: ~> @name # ✗ '~>' not allowed inline
713
+ X = schema :shape; name!; tag: !> @x # ✗ '!>' not allowed inline
714
+ X = schema :shape; id! -> it.Id # ✗ inline transform not allowed
715
+ ```
716
+
717
+ An **empty inline body** (`X = schema :shape;` with nothing after
718
+ the leading `;`) is also rejected — almost always a typo.
719
+
720
+ ### When to use which form
721
+
722
+ - **Inline** for small sub-shapes that exist to be referenced from
723
+ another schema (`Address`, `Money`, `Coord`, short wire fragments
724
+ for external APIs). The whole declaration fits in one visual
725
+ row and reads more like a type alias than a class.
726
+ - **Indented block** for anything with methods, hooks, computed
727
+ getters, `@ensure` refinements, or more than ~5 fields. Column
728
+ alignment makes large field lists scannable in a way one-liners
729
+ can't.
730
+
731
+ Rip doesn't enforce a choice; it just makes both cheap.
732
+
733
+ ---
734
+
735
+ ## 6. The runtime API
736
+
737
+ Every instantiable kind (`:input`, `:shape`, `:enum`, `:model`) exposes
738
+ the same three entry points. Different signatures, same contract.
739
+
740
+ ### `.parse(data)`
741
+
742
+ Validates `data`. Returns a cleaned value. Throws `SchemaError` on
743
+ failure.
744
+
745
+ ```coffee
746
+ user = SignupInput.parse raw
747
+ # on failure:
748
+ # throw new SchemaError([...issues], schemaName, schemaKind)
749
+ ```
750
+
751
+ ### `.safe(data)`
752
+
753
+ Validates `data`. Returns a structured result — never throws.
754
+
755
+ ```coffee
756
+ result = SignupInput.safe raw
757
+ # Success:
758
+ # {ok: true, value: <parsed>, errors: null}
759
+ # Failure:
760
+ # {ok: false, value: null, errors: [{field, error, message}, ...]}
761
+ ```
762
+
763
+ `value` on success has the same shape as `.parse()` would return. `errors`
764
+ on failure is always a non-empty array.
765
+
766
+ ### `.ok(data)`
767
+
768
+ Validates `data`. Returns a boolean. Allocates no error arrays — this is
769
+ the fast path for filter-style checks.
770
+
771
+ ```coffee
772
+ if User.ok raw
773
+ # ...
774
+ ```
775
+
776
+ ### Async variants (`parse!`, `safe!`, `ok!`)
777
+
778
+ Every method has a dammit-operator variant that awaits the result. For
779
+ `:input`/`:shape`/`:enum` these are sync, so `!` is a no-op (harmless).
780
+ For `:model`, the ORM methods are all genuinely async and `!` is the
781
+ canonical form:
782
+
783
+ ```coffee
784
+ user = User.find! 1
785
+ user.save!
786
+ users = User.where(active: true).all!
787
+ ```
788
+
789
+ ---
790
+
791
+ ## 7. What `.parse()` returns by kind
792
+
793
+ | Kind | `.parse(data)` returns | `.safe(data).value` is |
794
+ | ---------- | ---------------------- | ---------------------- |
795
+ | `:input` | Plain object — validated, defaults applied | same |
796
+ | `:shape` | Instance of a generated class — fields as enumerable own properties, methods and getters on the prototype | same |
797
+ | `:enum` | The member value (or the name string, for bare enums) | same |
798
+ | `:model` | **Unpersisted** instance — same structure as `:shape`, but the class also has `save()`, `destroy()`, relation methods, and `_persisted` state | same |
799
+ | `:mixin` | **Not instantiable** — `.parse()` throws | N/A |
800
+
801
+ For `:shape` and `:model`:
802
+
803
+ - Declared fields are enumerable own properties. `Object.keys(instance)`
804
+ lists them.
805
+ - Methods are non-enumerable on the prototype (so they don't pollute JSON
806
+ serialization or `for…in` iteration).
807
+ - Computed getters (`~>`) are non-enumerable prototype getters. They
808
+ evaluate on read, never persist.
809
+ - For `:model`, internal state (`_dirty`, `_persisted`) is non-enumerable.
810
+
811
+ ---
812
+
813
+ ## 8. `:model` — ORM, DDL, hooks, relations
814
+
815
+ `:model` is where everything comes together. A model declaration gives
816
+ you:
817
+
818
+ - field validation (from `:shape`)
819
+ - class instances with methods and computed getters (from `:shape`)
820
+ - lifecycle hooks bound by name
821
+ - an async ORM — `find`, `where`, `create`, `save`, `destroy`
822
+ - `.toSQL()` for DDL (works without ever touching the ORM)
823
+ - relation accessors driven by `@belongs_to` / `@has_many` / `@has_one`
824
+ - automatic registration in a process-global registry for cross-module
825
+ relation resolution
826
+
827
+ ### Static ORM methods
828
+
829
+ ```coffee
830
+ User.find! id # → UserInstance | null
831
+ User.findMany! [1, 2, 3] # → UserInstance[]
832
+ User.where(active: true).all! # → UserInstance[]
833
+ User.where(active: true).first! # → UserInstance | null
834
+ User.where(active: true).count! # → number
835
+ User.all! # → UserInstance[]
836
+ User.first! # → UserInstance | null
837
+ User.count! # → number
838
+ User.create! name: "Alice", email: "a@b.c"
839
+ User.toSQL() # → DDL string (no DB call)
840
+ ```
841
+
842
+ ### Query builder
843
+
844
+ ```coffee
845
+ User
846
+ .where(active: true) # object → AND equalities
847
+ .where("created_at > ?", since) # raw SQL + params
848
+ .order("last_name, first_name") # or .orderBy — same thing
849
+ .limit(10)
850
+ .offset(20)
851
+ .all!
852
+ ```
853
+
854
+ - `.where`, `.limit`, `.offset`, `.order` / `.orderBy` return the query
855
+ builder (sync).
856
+ - `.all`, `.first`, `.count` terminate with a promise.
857
+
858
+ ### Instance methods
859
+
860
+ Every `:model` instance carries:
861
+
862
+ ```coffee
863
+ user.save! # validate, run hooks, INSERT or UPDATE
864
+ user.destroy! # run hooks, DELETE (or UPDATE deleted_at for @softDelete)
865
+ user.ok() # boolean — current fields validate
866
+ user.errors() # SchemaIssue[] — current fields' errors
867
+ user.toJSON() # plain object of declared fields (no methods/getters)
868
+ ```
869
+
870
+ Plus any methods, computed getters, and relation accessors you declared
871
+ on the schema. Naming tip: methods that produce a fresh projection
872
+ (e.g. `user.toPublic()`, `order.toCard()`) follow Rip's
873
+ `to` / `as` / `from` / `parse` conversion convention — see
874
+ [RIP-LANG.md §15 "Conversion Method Naming"](./RIP-LANG.md#conversion-method-naming).
875
+
876
+ ### Lifecycle hooks
877
+
878
+ Hooks are methods whose name matches one of the [ten recognized hook
879
+ names](#19-hook-reference). On `:model` they bind into the lifecycle; on
880
+ other kinds they're just regular methods.
881
+
882
+ **Save flow:**
883
+
884
+ ```text
885
+ beforeValidation
886
+
887
+ validate
888
+
889
+ afterValidation
890
+
891
+ beforeSave
892
+
893
+ beforeCreate (for inserts) beforeUpdate (for updates)
894
+ ↓ ↓
895
+ INSERT UPDATE
896
+ ↓ ↓
897
+ afterCreate afterUpdate
898
+
899
+ afterSave
900
+ ```
901
+
902
+ **Destroy flow:**
903
+
904
+ ```text
905
+ beforeDestroy
906
+
907
+ DELETE (or UPDATE deleted_at if @softDelete)
908
+
909
+ afterDestroy
910
+ ```
911
+
912
+ Throwing from any hook aborts the operation and propagates the error.
913
+ Validation happens **after** `beforeValidation` (so that hook is the
914
+ right place to normalize input) and **before** `beforeSave` (so `beforeSave`
915
+ only runs on already-valid data).
916
+
917
+ ### Relations
918
+
919
+ ```coffee
920
+ User = schema :model
921
+ name! string
922
+ @has_many Order
923
+ @has_one Profile
924
+
925
+ Order = schema :model
926
+ total! integer
927
+ @belongs_to User
928
+ @belongs_to Organization? # ? = nullable FK
929
+ ```
930
+
931
+ Relation accessors are **async methods** on the instance prototype:
932
+
933
+ ```coffee
934
+ user = User.find! 1
935
+ orders = user.orders! # → OrderInstance[]
936
+ profile = user.profile! # → ProfileInstance | null
937
+
938
+ order = Order.find! 42
939
+ owner = order.user! # → UserInstance | null
940
+ ```
941
+
942
+ Accessor names:
943
+
944
+ - `@belongs_to User` → `user()` (target's name, lower-first-letter)
945
+ - `@has_one Profile` → `profile()`
946
+ - `@has_many Order` → `orders()` (pluralized)
947
+
948
+ Targets resolve lazily through a process-global registry keyed by name.
949
+ Circular and cross-module references work — import the file that defines
950
+ the target, and relation calls succeed.
951
+
952
+ See [§21 Relations](#21-relations) for the full table of directive →
953
+ accessor → return type.
954
+
955
+ ### DDL (`.toSQL()`)
956
+
957
+ `.toSQL()` returns `CREATE SEQUENCE` + `CREATE TABLE` + index `CREATE`
958
+ statements for a model. It does not touch the database — you run the
959
+ output through whatever migration plumbing you prefer.
960
+
961
+ ```coffee
962
+ User.toSQL()
963
+ # CREATE SEQUENCE users_seq START 1;
964
+ #
965
+ # CREATE TABLE users (
966
+ # id INTEGER PRIMARY KEY DEFAULT nextval('users_seq'),
967
+ # name VARCHAR(100) NOT NULL,
968
+ # email VARCHAR NOT NULL UNIQUE,
969
+ # created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
970
+ # updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
971
+ # );
972
+ #
973
+ # CREATE UNIQUE INDEX idx_users_email ON users ("email");
974
+ ```
975
+
976
+ `.toSQL()` works independently of the ORM. A migration script that never
977
+ calls `.find()` or `.create()` can still emit full DDL.
978
+
979
+ To emit a whole application's schema, call `.toSQL()` per model and join.
980
+ Order by FK dependency (models referenced via `@belongs_to` come first):
981
+
982
+ ```coffee
983
+ ddl = [
984
+ User.toSQL()
985
+ Category.toSQL()
986
+ Order.toSQL() # references User
987
+ OrderItem.toSQL() # references Order
988
+ ].join('\n\n')
989
+ ```
990
+
991
+ ### The adapter seam
992
+
993
+ All ORM methods route through a single adapter interface: `adapter.query(sql, params)`.
994
+ The default adapter uses `fetch` against a rip-db instance at `$DB_URL`.
995
+ Install a custom adapter (for tests, or for a different backend) with
996
+ `__schemaSetAdapter`:
997
+
998
+ ```coffee
999
+ globalThis.__ripSchema.__schemaSetAdapter
1000
+ query: (sql, params) ->
1001
+ # return {columns: [{name, type}, ...], data: [[row values], ...], rows: N}
1002
+ ...
1003
+ ```
1004
+
1005
+ The adapter contract is minimal — one method, one result shape. Any DB
1006
+ client that can execute parameterized SQL and return row data fits.
1007
+
1008
+ ### Snake / camel dual access on instances
1009
+
1010
+ Database columns are typically snake_case (`user_id`, `created_at`) while
1011
+ field names are camelCase (`userId`, `createdAt`). A hydrated `:model`
1012
+ instance exposes both — `order.user_id` and `order.userId` read the same
1013
+ slot. The camelCase form is the canonical own property; the snake_case
1014
+ form is a non-enumerable accessor that forwards.
1015
+
1016
+ ```coffee
1017
+ order = Order.find! 42
1018
+ order.userId # 7 (camelCase: canonical)
1019
+ order.user_id # 7 (snake_case: alias)
1020
+ order.createdAt # Date (camelCase: canonical)
1021
+ order.created_at # Date (snake_case: alias)
1022
+ ```
1023
+
1024
+ `.create(data)` also accepts either style:
1025
+
1026
+ ```coffee
1027
+ Order.create! user_id: 7, total: 100
1028
+ Order.create! userId: 7, total: 100 # same result
1029
+ ```
1030
+
1031
+ Use whichever reads better alongside nearby raw SQL or JSON payloads.
1032
+
1033
+ ---
1034
+
1035
+ ## 9. Mixins
1036
+
1037
+ `:mixin` schemas exist to share field groups across multiple models or
1038
+ shapes. They're non-instantiable — you declare them, then other schemas
1039
+ pull them in with `@mixin Name`.
1040
+
1041
+ ```coffee
1042
+ Timestamps = schema :mixin
1043
+ createdAt! datetime
1044
+ updatedAt! datetime
1045
+
1046
+ Auditable = schema :mixin
1047
+ createdBy? string
1048
+ @mixin Timestamps # mixins can chain into mixins
1049
+
1050
+ User = schema :model
1051
+ name! string
1052
+ @mixin Auditable # transitively pulls in Timestamps
1053
+
1054
+ Order = schema :model
1055
+ total! integer
1056
+ @mixin Auditable
1057
+ @belongs_to User
1058
+ ```
1059
+
1060
+ ### Behavior
1061
+
1062
+ - Fields are expanded at Layer 2 normalization, once per host schema, and
1063
+ cached.
1064
+ - Expansion is depth-first. A mixin that `@mixin`s another mixin
1065
+ transitively contributes its base's fields.
1066
+ - Diamond inclusion dedupes: if two mixins both include a common base,
1067
+ the base's fields appear once per host.
1068
+ - Cycles produce a compile error with the full path (`A -> B -> A`).
1069
+ - Duplicate fields across mixins (or between a mixin and the host) are a
1070
+ compile error — no silent overwrite.
1071
+ - Mixins are fields-only. Methods, computed, hooks, and non-`@mixin`
1072
+ directives inside a mixin body are compile errors.
1073
+
1074
+ ### `@mixin` is allowed on any fielded kind
1075
+
1076
+ ```coffee
1077
+ Base = schema :mixin
1078
+ id! uuid
1079
+
1080
+ X = schema :input
1081
+ @mixin Base
1082
+ name! string
1083
+
1084
+ Y = schema :shape
1085
+ @mixin Base
1086
+ full: ~> @name
1087
+
1088
+ Z = schema :model
1089
+ @mixin Base
1090
+ @timestamps
1091
+ ```
1092
+
1093
+ The reason: mixins add *fields*, not *behavior*. Field sharing is
1094
+ orthogonal to the capability axis that distinguishes the kinds.
1095
+
1096
+ ---
1097
+
1098
+ ## 10. Schema algebra
1099
+
1100
+ Algebra operators derive new schemas from existing ones:
1101
+
1102
+ | Operator | Result |
1103
+ | ----------------------- | -------------------------------------------------------------- |
1104
+ | `.pick(...keys)` | new shape with only the listed fields |
1105
+ | `.omit(...keys)` | new shape without the listed fields |
1106
+ | `.partial()` | every field becomes optional |
1107
+ | `.required(...keys)` | the listed fields become required (others unchanged) |
1108
+ | `.extend(other)` | merge another schema's fields; collisions throw |
1109
+
1110
+ ### Three invariants to remember
1111
+
1112
+ > **Algebra always returns `:shape`**, never `:model` or `:input`. On a
1113
+ > model, the ORM surface is stripped — `UserPublic.find()` throws.
1114
+
1115
+ > **Field semantics survive; instance behavior does not.** What carries
1116
+ > through to the derived shape: type (including literal unions),
1117
+ > modifiers, constraints (range, regex, default, attrs), and **inline
1118
+ > transforms** (`name, -> fn(it)`). What gets dropped: methods (`->`),
1119
+ > computed getters (`~>`), eager-derived fields (`!>`), hooks, ORM
1120
+ > methods, and `@ensure` refinements. The transform is "how this
1121
+ > field's value is obtained from raw input" — a property of the field,
1122
+ > not of the instance — so it travels with the field through algebra.
1123
+ > Refinements, by contrast, are schema-level invariants that reference
1124
+ > field names — there's no static guarantee those names survive a
1125
+ > `.pick` or `.omit`, so refinements drop unconditionally.
1126
+
1127
+ > **Transforms-survive has a subtle consequence**: a derived schema may
1128
+ > still read raw-input keys that don't appear in its declared output
1129
+ > shape. `User.pick 'slug'` where `slug` is declared as
1130
+ > `slug! -> "#{it.FirstName}-#{it.LastName}".toLowerCase()` continues
1131
+ > to read `FirstName` and `LastName` from the input even though neither
1132
+ > is in the output. This is deliberate and documented; it makes
1133
+ > PascalCase-remap transforms composable with `.pick`.
1134
+
1135
+ ```coffee
1136
+ User = schema :model
1137
+ name! string
1138
+ email!# email, -> it.email.toLowerCase()
1139
+ password! string
1140
+ full: ~> "#{@name} <#{@email}>"
1141
+ tagline: !> "#{@name} (active)"
1142
+
1143
+ UserPublic = User.omit "password"
1144
+
1145
+ UserPublic.kind # 'shape'
1146
+ typeof UserPublic.find # 'function' — but throws when called
1147
+ UserPublic.find(1) # throws: :model-only
1148
+
1149
+ u = UserPublic.parse {name: "A", email: "X@B.C"}
1150
+ u.email # 'x@b.c' — transform survived
1151
+ typeof u.full # 'undefined' — ~> dropped
1152
+ typeof u.tagline # 'undefined' — !> dropped
1153
+ ```
1154
+
1155
+ `.extend(other)` is the exception to "algebra only drops" — it adds
1156
+ fields from another schema. Collisions still throw.
1157
+
1158
+ ```coffee
1159
+ AdminUser = User.extend schema :shape
1160
+ permissions! string[]
1161
+ ```
1162
+
1163
+ `.sourceModel` is preserved through chained algebra, so tooling can trace
1164
+ derived shapes back to their origin:
1165
+
1166
+ ```coffee
1167
+ A = User.pick "name"
1168
+ B = A.partial()
1169
+ B._sourceModel is User # true
1170
+ ```
1171
+
1172
+ ### The `_sourceModel` metadata
1173
+
1174
+ Algebra operations preserve a non-enumerable `_sourceModel` pointer on
1175
+ the derived schema. Downstream tooling (migration analyzers, form
1176
+ generators, query projectors) can walk this back to the originating
1177
+ `:model` without stringly-typed guesses.
1178
+
1179
+ ---
1180
+
1181
+ ## 11. Shadow TypeScript
1182
+
1183
+ Every named schema emits virtual TypeScript declarations that the
1184
+ language service picks up. The VS Code extension and `rip check` both
1185
+ consume these — autocomplete, hover, and type checking all work out of
1186
+ the box.
1187
+
1188
+ ### What gets emitted
1189
+
1190
+ For `:input`:
1191
+
1192
+ ```ts
1193
+ type SignupInputValue = { email: string; password: string };
1194
+ declare const SignupInput: Schema<SignupInputValue, SignupInputValue>;
1195
+ ```
1196
+
1197
+ For `:shape` (with behavior):
1198
+
1199
+ ```ts
1200
+ type AddressData = { street: string; city: string };
1201
+ type AddressInstance = AddressData & {
1202
+ readonly full: unknown;
1203
+ normalize: (...args: any[]) => unknown;
1204
+ };
1205
+ declare const Address: Schema<AddressInstance, AddressData>;
1206
+ ```
1207
+
1208
+ For `:model`:
1209
+
1210
+ ```ts
1211
+ type UserData = { name: string; email: string };
1212
+ type UserInstance = UserData & {
1213
+ readonly identifier: unknown;
1214
+ greet: (...args: any[]) => unknown;
1215
+ save(): Promise<UserInstance>;
1216
+ destroy(): Promise<UserInstance>;
1217
+ ok(): boolean;
1218
+ errors(): SchemaIssue[];
1219
+ toJSON(): UserData;
1220
+ organization(): Promise<OrganizationInstance | null>;
1221
+ orders(): Promise<OrderInstance[]>;
1222
+ };
1223
+ declare const User: ModelSchema<UserInstance, UserData>;
1224
+ ```
1225
+
1226
+ For `:enum`:
1227
+
1228
+ ```ts
1229
+ type Role = "admin" | "user" | "guest";
1230
+ declare const Role: {
1231
+ parse(data: unknown): Role;
1232
+ safe(data: unknown): SchemaSafeResult<Role>;
1233
+ ok(data: unknown): data is Role; // sound type predicate!
1234
+ };
1235
+ ```
1236
+
1237
+ For `:mixin`: type-only alias, no runtime declaration (mixins aren't
1238
+ user-facing runtime values).
1239
+
1240
+ ```ts
1241
+ type Timestamps = { createdAt: Date; updatedAt: Date };
1242
+ ```
1243
+
1244
+ ### Algebra types follow runtime semantics
1245
+
1246
+ Because algebra operates on `Data` (the plain field shape, not the
1247
+ `Instance`), derived types correctly omit behavior:
1248
+
1249
+ ```ts
1250
+ // User.omit("email") has type:
1251
+ Schema<Omit<UserData, "email">, Omit<UserData, "email">>
1252
+
1253
+ // User.partial() has type:
1254
+ Schema<Partial<UserData>, Partial<UserData>>
1255
+ ```
1256
+
1257
+ ### Same-file targets type relation accessors
1258
+
1259
+ Relation accessors get precise return types when the target is declared
1260
+ in the same file:
1261
+
1262
+ ```coffee
1263
+ User = schema :model
1264
+ name! string
1265
+ Order = schema :model
1266
+ @belongs_to User # → order.user(): Promise<UserInstance | null>
1267
+ ```
1268
+
1269
+ Cross-file relation targets degrade to `unknown` rather than emit
1270
+ unresolved names. This keeps the TypeScript diagnostics clean without
1271
+ requiring virtual-module imports.
1272
+
1273
+ ### Intrinsic declarations
1274
+
1275
+ Three base interfaces get injected into every schema-using file's type
1276
+ view:
1277
+
1278
+ ```ts
1279
+ interface SchemaIssue { field: string; error: string; message: string; }
1280
+ type SchemaSafeResult<T> =
1281
+ | { ok: true; value: T; errors: null }
1282
+ | { ok: false; value: null; errors: SchemaIssue[] };
1283
+
1284
+ interface Schema<Out, In = unknown> {
1285
+ parse(data: In): Out;
1286
+ safe(data: In): SchemaSafeResult<Out>;
1287
+ ok(data: unknown): boolean;
1288
+ pick<K extends keyof In>(...keys: K[]): Schema<Pick<In, K>, Pick<In, K>>;
1289
+ omit<K extends keyof In>(...keys: K[]): Schema<Omit<In, K>, Omit<In, K>>;
1290
+ partial(): Schema<Partial<In>, Partial<In>>;
1291
+ required<K extends keyof In>(...keys: K[]): Schema<
1292
+ Omit<In, K> & Required<Pick<In, K>>,
1293
+ Omit<In, K> & Required<Pick<In, K>>
1294
+ >;
1295
+ extend<U>(other: Schema<U>): Schema<In & U, In & U>;
1296
+ }
1297
+
1298
+ interface ModelSchema<Instance, Data = unknown> extends Schema<Instance, Data> {
1299
+ find(id: unknown): Promise<Instance | null>;
1300
+ findMany(ids: unknown[]): Promise<Instance[]>;
1301
+ where(cond: Record<string, unknown> | string, ...params: unknown[]): SchemaQuery<Instance>;
1302
+ all(limit?: number): Promise<Instance[]>;
1303
+ first(): Promise<Instance | null>;
1304
+ count(cond?: Record<string, unknown>): Promise<number>;
1305
+ create(data: Partial<Data>): Promise<Instance>;
1306
+ toSQL(options?: { dropFirst?: boolean; header?: string }): string;
1307
+ }
1308
+ ```
1309
+
1310
+ You don't import these — they're injected automatically when the file
1311
+ contains any schema declaration.
1312
+
1313
+ ---
1314
+
1315
+ ## 12. SchemaError and diagnostics
1316
+
1317
+ ### `SchemaError`
1318
+
1319
+ Thrown by `.parse()` and `.save()` on validation failure. Carries
1320
+ structured diagnostic information:
1321
+
1322
+ ```coffee
1323
+ try
1324
+ User.parse badInput
1325
+ catch err
1326
+ err.name # 'SchemaError'
1327
+ err.schemaName # 'User'
1328
+ err.schemaKind # 'model'
1329
+ err.issues # [{field, error, message}, ...]
1330
+ err.message # 'User: name is required; email must be email'
1331
+ ```
1332
+
1333
+ Each issue has three fields:
1334
+
1335
+ ```ts
1336
+ {
1337
+ field: string // field name, or '' for schema-wide issues
1338
+ error: string // 'required' | 'type' | 'min' | 'max' | 'pattern' | 'enum' | 'collision' | 'mixin-cycle' | 'mixin-collision' | 'mixin-missing' | ...
1339
+ message: string // human-readable explanation
1340
+ }
1341
+ ```
1342
+
1343
+ ### Schema-mode-aware compile diagnostics
1344
+
1345
+ The schema sub-parser reports errors with context that makes mistakes
1346
+ mechanical to fix:
1347
+
1348
+ ```
1349
+ Schema fields use 'name type' (space, no colon). For methods or computed
1350
+ use 'name: -> body' or 'name: ~> body'.
1351
+
1352
+ Enum member must be a :symbol. Use ':admin' for a bare member or
1353
+ ':admin value' for a valued one.
1354
+
1355
+ :mixin schemas are fields-only. 'greet' is a method; move it to a :shape
1356
+ or :model.
1357
+
1358
+ :shape schemas only accept '@mixin Name'. '@timestamps' is :model-only.
1359
+
1360
+ mixin cycle: A -> B -> A
1361
+
1362
+ Inline schema body does not support '->' (method/hook/transform).
1363
+ Use the indented form.
1364
+
1365
+ Inline schema body is empty. Either add '; field; …' entries after
1366
+ 'schema :shape;' or switch to the indented form.
1367
+
1368
+ Schema pragma 'schema.defaultMaxString' must be declared at file top
1369
+ level. It was found inside a nested block (function / class / if /
1370
+ loop body), where it would leak into later top-level schemas.
1371
+
1372
+ Field 'n' would have impossible constraints min=1 > max=0 after sugar
1373
+ is applied (implicit min=1 from `!` vs range max 0). Write an explicit
1374
+ range or drop the conflicting pragma.
1375
+ ```
1376
+
1377
+ ---
1378
+
1379
+ ## 13. Common mistakes
1380
+
1381
+ These forms look right but don't work — the parser catches all of them
1382
+ with specific diagnostics.
1383
+
1384
+ ### `name: type` instead of `name type`
1385
+
1386
+ ```coffee
1387
+ # wrong — fields use a space, not a colon, between name and type
1388
+ X = schema
1389
+ name: string
1390
+
1391
+ # right
1392
+ X = schema
1393
+ name! string
1394
+ ```
1395
+
1396
+ ### Bare identifier enum members
1397
+
1398
+ ```coffee
1399
+ # wrong — enum members are :symbol
1400
+ R = schema :enum
1401
+ admin
1402
+ user
1403
+
1404
+ # right
1405
+ R = schema
1406
+ :admin
1407
+ :user
1408
+ ```
1409
+
1410
+ ### `name: value` as an enum member
1411
+
1412
+ ```coffee
1413
+ # wrong — use :name value
1414
+ R = schema :enum
1415
+ pending: 0
1416
+
1417
+ # right
1418
+ R = schema
1419
+ :pending 0
1420
+ ```
1421
+
1422
+ ### Methods in `:input` or `:mixin`
1423
+
1424
+ ```coffee
1425
+ # wrong — :input is fields-only
1426
+ X = schema :input
1427
+ name! string
1428
+ greet: -> "hi"
1429
+
1430
+ # right — use :shape (or :model) for behavior
1431
+ X = schema :shape
1432
+ name! string
1433
+ greet: -> "hi"
1434
+ ```
1435
+
1436
+ ### ORM directives on `:shape`
1437
+
1438
+ ```coffee
1439
+ # wrong — @timestamps is :model-only
1440
+ A = schema :shape
1441
+ street! string
1442
+ @timestamps
1443
+
1444
+ # right
1445
+ A = schema :model
1446
+ street! string
1447
+ @timestamps
1448
+ ```
1449
+
1450
+ ### Calling ORM methods on a derived shape
1451
+
1452
+ ```coffee
1453
+ UserPublic = User.omit "password"
1454
+
1455
+ # wrong — algebra returns :shape; :shape has no .find()
1456
+ user = UserPublic.find! 1
1457
+
1458
+ # right — query the source model and project
1459
+ user = User.find! 1
1460
+ publicView = UserPublic.parse user.toJSON()
1461
+ ```
1462
+
1463
+ ### Treating `.ok()` as a type predicate for shapes/models
1464
+
1465
+ ```coffee
1466
+ # wrong — .ok() doesn't produce a parsed value
1467
+ if User.ok raw
1468
+ raw.name # raw is still untyped — .ok is boolean only
1469
+
1470
+ # right
1471
+ result = User.safe raw
1472
+ if result.ok
1473
+ result.value.name # typed
1474
+
1475
+ # or
1476
+ user = User.parse raw # throws on failure, returns typed value
1477
+ ```
1478
+
1479
+ Only `:enum` exposes `.ok(data): data is EnumType` as a sound type
1480
+ predicate.
1481
+
1482
+ ---
1483
+
1484
+ ## 14. Recipes
1485
+
1486
+ ### Validating HTTP input
1487
+
1488
+ ```coffee
1489
+ import { post, read } from '@rip-lang/server'
1490
+
1491
+ SignupInput = schema
1492
+ email! email
1493
+ password! string, 8..100
1494
+ age? integer, 18..120
1495
+
1496
+ post '/signup' ->
1497
+ raw = @json() # whatever shape the client sent
1498
+ result = SignupInput.safe raw
1499
+ unless result.ok
1500
+ return error! 400, errors: result.errors
1501
+ # result.value is the cleaned, typed payload
1502
+ db.users.insert result.value
1503
+ { ok: true }
1504
+ ```
1505
+
1506
+ ### A DB-backed model with relations
1507
+
1508
+ ```coffee
1509
+ User = schema :model
1510
+ name! string, 1..100
1511
+ email!# email
1512
+ @timestamps
1513
+ @has_many Order
1514
+
1515
+ beforeValidation: -> @email = @email.toLowerCase()
1516
+
1517
+ Order = schema :model
1518
+ total! integer
1519
+ status string, [:pending]
1520
+ @belongs_to User
1521
+ @timestamps
1522
+
1523
+ # Use:
1524
+ user = User.create! name: "Alice", email: "ALICE@EXAMPLE.COM"
1525
+ Order.create! user_id: user.id, total: 100
1526
+ orders = user.orders! # [{total: 100, ...}]
1527
+ owner = orders[0].user! # the same user
1528
+ ```
1529
+
1530
+ ### A shape with computed values
1531
+
1532
+ ```coffee
1533
+ Money = schema :shape
1534
+ amount! integer
1535
+ currency! string, 3..3
1536
+
1537
+ formatted: ~>
1538
+ symbol = {USD: "$", EUR: "€", JPY: "¥"}[@currency] ?? @currency
1539
+ "#{symbol}#{(@amount / 100).toFixed(2)}"
1540
+
1541
+ add: (other) ->
1542
+ throw new Error "currency mismatch" unless @currency is other.currency
1543
+ Money.parse amount: @amount + other.amount, currency: @currency
1544
+
1545
+ a = Money.parse amount: 12345, currency: "USD"
1546
+ a.formatted # "$123.45"
1547
+ b = a.add Money.parse amount: 99, currency: "USD"
1548
+ b.formatted # "$124.44"
1549
+ ```
1550
+
1551
+ ### Sharing fields with a mixin
1552
+
1553
+ ```coffee
1554
+ Timestamps = schema :mixin
1555
+ createdAt! datetime
1556
+ updatedAt! datetime
1557
+
1558
+ User = schema :model
1559
+ name! string
1560
+ email! email
1561
+ @mixin Timestamps
1562
+
1563
+ Post = schema :model
1564
+ title! string
1565
+ body! text
1566
+ @mixin Timestamps
1567
+ @belongs_to User
1568
+ ```
1569
+
1570
+ ### Building a public DTO from a model
1571
+
1572
+ ```coffee
1573
+ User = schema :model
1574
+ name! string
1575
+ email!# email
1576
+ password! string
1577
+ role? string, [:user]
1578
+ @timestamps
1579
+
1580
+ # Public projection — no password, no ORM methods.
1581
+ UserPublic = User.omit "password"
1582
+
1583
+ publicJson = (user) -> UserPublic.parse user.toJSON()
1584
+
1585
+ get '/users/:id' ->
1586
+ id = read 'id', 'id!'
1587
+ user = User.find! id
1588
+ return error! 404 unless user
1589
+ { user: publicJson user }
1590
+ ```
1591
+
1592
+ ### Writing a migration script
1593
+
1594
+ ```coffee
1595
+ # scripts/migrate.rip
1596
+ import { User, Order, OrderItem } from '../api/models.rip'
1597
+ import { sql, setup } from '../api/db.rip'
1598
+
1599
+ setup! # start DB if needed
1600
+
1601
+ # Emit DDL in dependency order
1602
+ ddl = [
1603
+ User.toSQL()
1604
+ Order.toSQL() # references User
1605
+ OrderItem.toSQL() # references Order
1606
+ ].join('\n\n')
1607
+
1608
+ for stmt in ddl.split ';'
1609
+ stmt = stmt.trim()
1610
+ sql! stmt + ';' if stmt
1611
+
1612
+ p "[migrate] schema created"
1613
+ ```
1614
+
1615
+ Because `.toSQL()` doesn't call the adapter, migration scripts work
1616
+ before the database exists or before the ORM is wired.
1617
+
1618
+ ---
1619
+
1620
+ ## 15. What's not here yet
1621
+
1622
+ Rip Schema covers a large surface area with one keyword, but it deliberately
1623
+ does not yet cover every feature you might find across the union of Zod,
1624
+ Prisma, Drizzle, and the rest. These are intentional omissions — each one
1625
+ has an open design question that hasn't been resolved in a way that fits the
1626
+ language.
1627
+
1628
+ ### Validator features not yet in
1629
+
1630
+ - **Full discriminated-union schemas** — `schema.union(A, B)` with a
1631
+ `:discriminator` key that dispatches to the matching constituent.
1632
+ String-literal unions in the type slot (`"a" | "b"`) are in;
1633
+ schema-constituent unions over arbitrary shapes are not. Today you
1634
+ express cross-shape alternation by running multiple `.safe()` calls.
1635
+ - **Issue paths** — `@ensure` issues today use `field: ''` (the whole
1636
+ object). Per-field attribution (`field: 'email'`) on a refinement
1637
+ isn't supported; write the field-specific rule as a constraint or
1638
+ inline transform instead.
1639
+ - **Coercion built-in types** — `coerce.number`, `coerce.date`, etc.
1640
+ as dedicated type names. Today a field transform handles the same
1641
+ case (`shippedAt? date, -> new Date(it.shippedAt)`); coerce types
1642
+ would just be a stdlib convenience over the transform mechanism.
1643
+ - **Async refinements** — `@ensure` predicates are sync. Async
1644
+ refinements (that await a database or network check) would need
1645
+ either a separate `@ensureAsync` directive or a full async variant
1646
+ of the whole validate pipeline.
1647
+
1648
+ ### ORM features not yet in
1649
+
1650
+ - **Transactions** — `schema.transaction -> ...` with rollback semantics.
1651
+ Today each ORM call is its own statement.
1652
+ - **Eager loading** — `User.where(...).includes(:orders)`. Today relations
1653
+ are lazy (`user.orders!` on demand).
1654
+ - **Query scopes** — named, composable `Model.scope(name, ...)` reusable
1655
+ across `.where` chains.
1656
+ - **Soft deletes** — a built-in `@soft_delete` directive with automatic
1657
+ query-filter application. Today you add a `deleted_at` field yourself.
1658
+ - **Polymorphic associations** — `@belongs_to :commentable, polymorphic: true`.
1659
+ - **Non-SQL adapters** — Mongo, Redis, Elasticsearch. The adapter contract
1660
+ is `query(sql, params)`, which assumes SQL.
1661
+
1662
+ ### Type features not yet in
1663
+
1664
+ - **Recursive schemas** — `Tree = schema :shape` that references itself
1665
+ in a nested field. Compiler allows it; shadow TS currently emits
1666
+ `unknown` for the recursive branch.
1667
+ - **Generic schemas** — `Paginated<T> = schema :shape ...` parameterized
1668
+ by another schema. Today you define a concrete `PaginatedUser` per type.
1669
+ - **Branded / nominal types** — `UserId = schema :input` whose parsed
1670
+ value is nominally distinct from `number`.
1671
+
1672
+ ### Deferred by design
1673
+
1674
+ - **Per-schema adapters** — every schema currently uses the one global
1675
+ adapter. Multi-database setups require swapping before the call.
1676
+ - **JSON Schema / OpenAPI export** — `User.toJSONSchema()`. The
1677
+ four-layer runtime makes this feasible; no canonical emitter exists yet.
1678
+
1679
+ None of these are architectural impossibilities. Each is a conscious pause
1680
+ while the core shape of the feature settles. If one of these is blocking
1681
+ you, file a proposal — the sidecar design makes most of them additive.
1682
+
1683
+ ---
1684
+
1685
+ # Part II — Reference
1686
+
1687
+ ## 16. Capability matrix
1688
+
1689
+ What each kind's body can contain:
1690
+
1691
+ | Feature | `:input` | `:shape` | `:enum` | `:mixin` | `:model` |
1692
+ | --------------------------------------- | -------- | -------- | -------- | -------- | -------- |
1693
+ | Fields (`name` with optional type) | ✓ | ✓ | — | ✓ | ✓ |
1694
+ | Literal-union type (`"a" \| "b"`) | ✓ | ✓ | — | ✓ | ✓ |
1695
+ | Range / regex / default / attrs | ✓ | ✓ | — | ✓ | ✓ |
1696
+ | Inline transforms (`name, -> fn(it)`) | ✓ | ✓ | — | — | ✓ |
1697
+ | `@mixin` directive | ✓ | ✓ | — | ✓ | ✓ |
1698
+ | `@ensure` refinement | ✓ | ✓ | — | — | ✓ |
1699
+ | Other directives | — | — | — | — | ✓ |
1700
+ | Methods (`name: -> body`) | — | ✓ | — | — | ✓ |
1701
+ | Computed getter (`name: ~> body`) | — | ✓ | — | — | ✓ |
1702
+ | Eager-derived field (`name: !> body`) | — | ✓ | — | — | ✓ |
1703
+ | Hooks (by known name) | — | methods | — | — | ✓ |
1704
+ | Enum members (`:symbol`) | — | — | ✓ | — | — |
1705
+ | Algebra (`.pick` etc.) | ✓ → shape | ✓ → shape | — | — | ✓ → shape |
1706
+ | ORM (`.find`, `.create`) | — | — | — | — | ✓ |
1707
+ | `.parse` / `.safe` / `.ok` | ✓ | ✓ | ✓ | — | ✓ |
1708
+ | `.toSQL()` | — | — | — | — | ✓ |
1709
+
1710
+ "methods" in the `:shape` / Hooks row means: hook-named functions are
1711
+ accepted, but they're just methods with no lifecycle binding.
1712
+
1713
+ ---
1714
+
1715
+ ## 17. Field types
1716
+
1717
+ Built-in type names and their runtime / SQL / TypeScript mappings:
1718
+
1719
+ | Rip type | Validator | SQL | TypeScript |
1720
+ | ---------- | ------------------------------------ | ----------- | ---------- |
1721
+ | `string` | `typeof v === 'string'` | `VARCHAR` | `string` |
1722
+ | `text` | `typeof v === 'string'` | `TEXT` | `string` |
1723
+ | `email` | string + `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` | `VARCHAR` | `string` |
1724
+ | `url` | string + `/^https?:\/\/.+/` | `VARCHAR` | `string` |
1725
+ | `uuid` | string + UUID regex | `UUID` | `string` |
1726
+ | `phone` | string + `/^[\d\s\-+()]+$/` | `VARCHAR` | `string` |
1727
+ | `zip` | string + `/^\d{5}(-\d{4})?$/` (US) | `VARCHAR` | `string` |
1728
+ | `number` | `typeof v === 'number'` and not NaN | `DOUBLE` | `number` |
1729
+ | `integer` | `Number.isInteger(v)` | `INTEGER` | `number` |
1730
+ | `boolean` | `typeof v === 'boolean'` | `BOOLEAN` | `boolean` |
1731
+ | `date` | `Date` instance | `DATE` | `Date` |
1732
+ | `datetime` | `Date` instance | `TIMESTAMP` | `Date` |
1733
+ | `json` | not undefined | `JSON` | `unknown` |
1734
+ | `any` | always true | `JSON` | `any` |
1735
+
1736
+ Arrays: `type[]`. SQL stores as `JSON` (DuckDB native), TS is `T[]`.
1737
+
1738
+ **Nested-schema identifiers.** When a field's type name resolves to
1739
+ another schema in the process-global `__SchemaRegistry`, the
1740
+ validator recurses:
1741
+
1742
+ ```coffee
1743
+ Address = schema :shape
1744
+ street? ..200
1745
+ city? ..100
1746
+
1747
+ User = schema :shape
1748
+ name! ..50
1749
+ address! Address # per-field validation recurses into Address
1750
+ mailing? Address # optional; skipped if missing, validated if present
1751
+
1752
+ Order = schema :shape
1753
+ items! OrderItem[] # arrays of schema-typed values validate each element
1754
+ ```
1755
+
1756
+ Errors from the nested validator surface with path-prefixed `field`
1757
+ entries on the parent's issue list (`address.street`,
1758
+ `items[0].name`, `items[3].price`, etc.). Validation recurses as
1759
+ deep as the registry resolution allows — three-level nesting is
1760
+ tested, deeper nesting is bounded only by the data itself.
1761
+
1762
+ The resolver is lazy — it runs at `.parse()` time, not at
1763
+ declaration, so forward references between modules resolve as long
1764
+ as both are loaded before the first validation call.
1765
+
1766
+ **Unknown identifiers.** If a type name isn't a built-in *and* isn't
1767
+ in the registry at validation time, the field is accepted without a
1768
+ runtime check (SQL defaults to `JSON`, TS uses the identifier
1769
+ as-is). This keeps forward references from hard-failing and lets
1770
+ user-defined enums or shapes compose incrementally.
1771
+
1772
+ ---
1773
+
1774
+ ## 18. Directives
1775
+
1776
+ ### For any fielded kind
1777
+
1778
+ | Directive | Effect |
1779
+ | --------------- | ----------------------------------------------------------------- |
1780
+ | `@mixin Name` | Pull in the fields of mixin `Name` at Layer 2 normalization |
1781
+ | `@ensure "msg", (x) -> pred` | Cross-field refinement — see [§5](#refinement-ensure). Allowed on `:input` / `:shape` / `:model`; rejected on `:enum` / `:mixin`. |
1782
+
1783
+ ### `:model`-only
1784
+
1785
+ | Directive | Effect |
1786
+ | ----------------------------- | ------------------------------------------------------------------- |
1787
+ | `@timestamps` | Adds `created_at` + `updated_at` columns with `CURRENT_TIMESTAMP` defaults |
1788
+ | `@softDelete` | Adds `deleted_at` column; `.destroy()` sets `deleted_at = now()` instead of DELETE |
1789
+ | `@index [a, b, c]` | Composite index on the listed columns |
1790
+ | `@index column` | Single-column index (same as `@index [column]`) |
1791
+ | `@index [...] #` | Unique index |
1792
+ | `@belongs_to Target` | FK column `target_id` referencing `targets.id`, NOT NULL |
1793
+ | `@belongs_to Target?` | Same, nullable |
1794
+ | `@has_one Target` | Accessor `target()` returning one |
1795
+ | `@has_many Target` | Accessor `targets()` returning array |
1796
+
1797
+ ---
1798
+
1799
+ ## 19. Hook reference
1800
+
1801
+ Ten recognized hook names. On `:model` they bind into the lifecycle; on
1802
+ other kinds they're plain methods.
1803
+
1804
+ | Hook name | When it runs |
1805
+ | ------------------ | -------------------------------------------------------- |
1806
+ | `beforeValidation` | Before field validation |
1807
+ | `afterValidation` | After successful validation |
1808
+ | `beforeSave` | Before INSERT or UPDATE (only on valid data) |
1809
+ | `beforeCreate` | Before INSERT only |
1810
+ | `afterCreate` | After successful INSERT |
1811
+ | `beforeUpdate` | Before UPDATE only |
1812
+ | `afterUpdate` | After successful UPDATE |
1813
+ | `afterSave` | After INSERT or UPDATE |
1814
+ | `beforeDestroy` | Before DELETE (or soft-delete UPDATE) |
1815
+ | `afterDestroy` | After DELETE |
1816
+
1817
+ Throwing from any hook aborts the operation and the exception propagates
1818
+ to the caller.
1819
+
1820
+ ---
1821
+
1822
+ ## 20. Constraints
1823
+
1824
+ Each constraint on a field line is self-identifying by its token
1825
+ shape. Multiple constraints combine on one field, separated by commas:
1826
+
1827
+ ```coffee
1828
+ name[!|?|#] [type] [constraint] [constraint] …
1829
+ ```
1830
+
1831
+ ### The forms
1832
+
1833
+ The **type** slot accepts an identifier (`string`, `email`, etc.) or
1834
+ a string-literal union; the **constraint** forms live after the type:
1835
+
1836
+ | Form | Slot | Meaning |
1837
+ | -------------------- | ---------- | ------------------------------------------------------ |
1838
+ | `"a" \| "b" \| …` | type | String-literal union (value must be one of the listed members) |
1839
+ | `min..max` | constraint | Size (string/array length) or value range (numeric) |
1840
+ | `[value]` | constraint | Default value (single literal in brackets) |
1841
+ | `/regex/` | constraint | Pattern constraint (bare regex literal) |
1842
+ | `{key: value}` | constraint | Attrs (unique, index, etc.) |
1843
+
1844
+ ```coffee
1845
+ password! string, 8..100 # length range
1846
+ age? integer, 0..120 # value range
1847
+ role? string, ["guest"] # default
1848
+ zip! string, /^\d{5}$/ # regex pattern
1849
+ status? string, 3..20, ["pending"] # range AND default
1850
+ sex? "M" | "F" | "U" # literal union
1851
+ status? "draft" | "active" | "done", [:draft] # union + default
1852
+ ```
1853
+
1854
+ ### Range semantics by field type
1855
+
1856
+ | Field type | `min..max` means |
1857
+ | -------------------------- | ----------------- |
1858
+ | `string` / `text` / formatted-string types | string length |
1859
+ | `integer` / `number` | numeric value |
1860
+ | `array` (`T[]`) | array length |
1861
+ | `date` / `datetime` / `boolean` | compile error — ranges don't apply |
1862
+ | literal union (`"a" \| "b"`) | compile error — membership is the bound |
1863
+
1864
+ ### Exactly-N
1865
+
1866
+ Use `n..n` for "exactly N":
1867
+
1868
+ ```coffee
1869
+ sex? 1..1 # single-character sex code
1870
+ npi! 10..10 # NPI is exactly 10 digits
1871
+ code! 6..6 # fixed-length code
1872
+ ```
1873
+
1874
+ Reads as "between N and N" which collapses to "exactly N."
1875
+
1876
+ ### Open-ended ranges
1877
+
1878
+ Either endpoint may be omitted. The implicit meaning depends on the
1879
+ modifier:
1880
+
1881
+ | Form | Modifier | Meaning |
1882
+ | ---------- | -------- | ------------------------------------------------------ |
1883
+ | `..N` | `?` | at most N, no minimum (empty string / negative OK) |
1884
+ | `..N` | `!` | at most N, **implicit `min=1`** — required AND non-empty |
1885
+ | `N..` | any | at least N, no maximum (the file-level `schema.defaultMaxString` pragma fills it if set) |
1886
+ | `..` | — | rejected — at least one endpoint must be present |
1887
+
1888
+ The `!` + `..N` rule exists because required fields with `..N`
1889
+ almost universally want `1..N` in practice (100% of required ranges
1890
+ in production code today). Writing `..N` instead of `1..N` drops the
1891
+ redundant `1` that the `!` modifier already implies:
1892
+
1893
+ ```coffee
1894
+ # These pairs mean the same thing:
1895
+ firstName! 1..50 firstName! ..50
1896
+ name! 1..100 name! ..100
1897
+ email! 1..320 email! ..320
1898
+
1899
+ # But explicit always wins:
1900
+ admin! 0..50 # explicit min=0 stays (rare: required but empty allowed)
1901
+ age! 0..120 # explicit min=0 stays (newborns are zero)
1902
+ score! 0..100 # explicit min=0 stays (test score can be zero)
1903
+ ```
1904
+
1905
+ If the sugar would produce an impossible constraint (`! ..0` →
1906
+ `{min:1, max:0}`), the compiler rejects it at parse time with an
1907
+ error naming the conflicting sources.
1908
+
1909
+ Optional (`?`) fields with `..N` are a mirror-image rule: `..20`
1910
+ means "no minimum" rather than implicit-zero, so the `?` case stays
1911
+ open for integers (allows negatives) and strings (allows empty).
1912
+ When an optional field must also be non-empty when present, write
1913
+ the min explicitly: `phone? 1..20`.
1914
+
1915
+ ### Literal values in the default bracket
1916
+
1917
+ The bracket `[…]` now holds a single value — the default. Values are
1918
+ evaluated at compile time and must be literals:
1919
+
1920
+ - Numbers (including negative: `-10`)
1921
+ - Strings (`"text"`)
1922
+ - Booleans (`true`, `false`)
1923
+ - `null`, `undefined`
1924
+ - `:symbol` (compiles to the symbol's name as a string — useful for
1925
+ enum defaults: `[:draft]` ≡ `["draft"]`)
1926
+
1927
+ Arbitrary expressions, identifier references, and function calls are
1928
+ rejected at parse time with a clear error.
1929
+
1930
+ ### Multi-line constraint lists
1931
+
1932
+ Trailing comma + indent continues the line:
1933
+
1934
+ ```coffee
1935
+ password! string,
1936
+ 8..100,
1937
+ /[A-Z]/
1938
+ ```
1939
+
1940
+ This is the same rule Rip applies to any trailing-comma continuation.
1941
+
1942
+ ### Migration from v1
1943
+
1944
+ Three bracket forms are retired in v2 in favor of shape-identifying
1945
+ constraint forms. The compiler emits a migration diagnostic pointing
1946
+ at the exact replacement:
1947
+
1948
+ ```
1949
+ name! string, [8, 100] → name! string, 8..100
1950
+ name! string, [8, 100, 42] → name! string, 8..100, [42]
1951
+ zip! string, [/^\d{5}$/] → zip! string, /^\d{5}$/
1952
+ ```
1953
+
1954
+ The single-value form `[a]` (default) is unchanged.
1955
+
1956
+ ### File-level pragma: `schema.defaultMaxString`
1957
+
1958
+ A defensive ceiling on every VARCHAR-like field in the file. Fills
1959
+ in `max` for fields that the user otherwise left unbounded:
1960
+
1961
+ ```coffee
1962
+ schema.defaultMaxString = 500
1963
+
1964
+ User = schema :model
1965
+ name! # → {min: 1, max: 500} (sugar + pragma)
1966
+ email!# email # → {max: 500}
1967
+ code? # → {max: 500}
1968
+ password! 8..200 # → {min: 8, max: 200} (explicit wins)
1969
+ bio? text # → no constraint (text opts out)
1970
+ zip! /^\d{5}$/ # → {regex: /^\d{5}$/} (regex opts out)
1971
+ status? "on" | "off" # → literal union (union opts out)
1972
+ ```
1973
+
1974
+ **Scope and semantics:**
1975
+
1976
+ - **Top-level only.** Declaring the pragma inside a function / class
1977
+ / `if` / loop body is a compile error — the rule has to be
1978
+ syntactically anchored to the file so it can't leak between
1979
+ scopes.
1980
+ - **Per-declaration snapshot.** Each schema captures the pragma
1981
+ value in effect at *its* declaration. Later pragma writes don't
1982
+ retroactively alter earlier schemas.
1983
+ - **Applies only to VARCHAR-like primitives** — `string`, `email`,
1984
+ `url`, `phone`, `zip` (and bare fields, which default to
1985
+ `string`). `text` stays uncapped by design (it's the opt-out for
1986
+ long-form content). `integer`, `number`, `boolean`, `date`,
1987
+ `datetime`, `uuid`, `json`, `any` are all untouched.
1988
+ - **User's explicit constraints always win.** An explicit range,
1989
+ regex, or literal-union on the field suppresses the pragma's
1990
+ max. Open-ended `N..` fields are the one composition case — the
1991
+ user's min is preserved and the pragma fills the open max.
1992
+ - **`0` resets the pragma.** Useful for turning it off again mid-file.
1993
+
1994
+ **Valid values:** non-negative integer literals. Decimals, strings,
1995
+ negatives, and unknown keys are all hard-fail compile errors with
1996
+ specific diagnostics.
1997
+
1998
+ The pragma is the first of a family — the scanner accepts `schema.<key>`
1999
+ generally and errors on unknown keys, so future ceilings (a
2000
+ `defaultMaxInt`, an `defaultStringType`, etc.) can land additively
2001
+ without changing the scanner shape.
2002
+
2003
+ ---
2004
+
2005
+ ## 21. Relations
2006
+
2007
+ ### Directive → accessor → return type
2008
+
2009
+ | Directive | Accessor name | Returns |
2010
+ | --------------------------- | --------------------- | ---------------------------------------- |
2011
+ | `@belongs_to User` | `user()` | `Promise<UserInstance \| null>` |
2012
+ | `@belongs_to User?` | `user()` | `Promise<UserInstance \| null>` + nullable FK |
2013
+ | `@has_one Profile` | `profile()` | `Promise<ProfileInstance \| null>` |
2014
+ | `@has_many Order` | `orders()` | `Promise<OrderInstance[]>` |
2015
+
2016
+ Accessor names:
2017
+
2018
+ - `belongs_to` / `has_one` use the target's name with a lowercase first
2019
+ letter (`User` → `user`, `UserProfile` → `userProfile`).
2020
+ - `has_many` pluralizes the lowercase-first-letter form (`Order` →
2021
+ `orders`, `Category` → `categories`).
2022
+
2023
+ ### FK columns
2024
+
2025
+ - `@belongs_to User` emits `user_id INTEGER NOT NULL REFERENCES users(id)`
2026
+ - `@belongs_to User?` emits `user_id INTEGER REFERENCES users(id)` (nullable)
2027
+
2028
+ ### Resolution
2029
+
2030
+ Targets resolve lazily through `__SchemaRegistry`. A target is looked up
2031
+ by bare name when the accessor is first called — imports into the module
2032
+ that declares the target (or the model file itself) are enough to make
2033
+ resolution succeed. Unresolved targets throw a runtime error with the
2034
+ name and the caller's schema name included.
2035
+
2036
+ ---
2037
+
2038
+ ## 22. Design invariants
2039
+
2040
+ Twelve rules that define how Rip Schema behaves. Worth keeping in mind
2041
+ when debugging or extending:
2042
+
2043
+ 1. **Default kind is `:input`.** `schema` with no marker and a
2044
+ field-shaped body gets the most common validation case with no
2045
+ ceremony.
2046
+ 2. **Fields use `name type`, not `name: type`.** The colon is reserved
2047
+ for methods, computed, and eager-derived. Using the colon form
2048
+ produces a compile error pointing at the right syntax.
2049
+ 3. **`:shape` has no lifecycle.** Hook names on `:shape` are methods —
2050
+ no binding. Lifecycle is a `:model` concern because it's coupled to
2051
+ persistence.
2052
+ 4. **Algebra on `:model` returns `:shape`.** ORM methods are stripped.
2053
+ Invariant 1 of the algebra section.
2054
+ 5. **Algebra drops instance behavior but preserves field semantics.**
2055
+ Methods, computed getters (`~>`), eager-derived fields (`!>`),
2056
+ hooks, and `@ensure` refinements are dropped by
2057
+ `.pick/.omit/.partial/.required/.extend`. Fields and their metadata
2058
+ — including **inline transforms** — carry through. The transform
2059
+ describes how a field's value is obtained from raw input; it's a
2060
+ property of the field, not the instance.
2061
+ 6. **`:mixin` is non-instantiable.** Mixins declare fields for reuse —
2062
+ they don't have a runtime identity of their own.
2063
+ 7. **Schema names are global.** Relations and `@mixin` references
2064
+ resolve by bare name through a process-global registry. Two models
2065
+ with the same name in different modules produce the "last loaded
2066
+ wins" behavior — avoid it.
2067
+ 8. **Default field type is `string`.** Omitting the type slot is
2068
+ legal; `name!` means "required string". Explicit types
2069
+ (`integer`, `email`, `"M" | "F"`, etc.) are needed only when
2070
+ string isn't what you want.
2071
+ 9. **Transforms are terminal on the field line.** `-> body` must be
2072
+ the last element; nothing follows it. The comma before `->` is
2073
+ required whenever anything precedes it (type, range, regex,
2074
+ default, attrs) — only the bare form `name! -> body` is
2075
+ comma-less, because there's nothing to elide.
2076
+ 10. **Transforms run on `.parse()` only, never on hydrate.** DB rows
2077
+ arrive canonical; re-running a transform on hydrate would
2078
+ double-coerce. Eager-derived (`!>`) is the opposite — it runs on
2079
+ parse AND hydrate so instances loaded from the DB have the same
2080
+ shape as parsed ones.
2081
+ 11. **Eager-derived fields are materialized once, not reactive.** `!>`
2082
+ fires at construction time (parse or hydrate) and stores the
2083
+ result as an own enumerable property. Mutating a dependency
2084
+ afterward does **not** update the derived value — it stays stale
2085
+ by design. Use `~>` for always-current derivations.
2086
+ 12. **Refinements are schema-level, not field-level.** `@ensure`
2087
+ predicates run after per-field validation succeeds, once per
2088
+ parse, against the whole defaulted and typed object. They fail
2089
+ with a declared message that ships verbatim to the caller;
2090
+ thrown exceptions inside a predicate count as failure, not
2091
+ error. Refinements are skipped on DB hydrate (trusted data)
2092
+ and dropped by every algebra op (structural derivation never
2093
+ carries non-structural invariants).
2094
+
2095
+ ---
2096
+
2097
+ # Part III — Architecture
2098
+
2099
+ ## 23. Runtime architecture
2100
+
2101
+ Each schema goes through four layers. Each layer is built lazily on first
2102
+ need, and the caches are independent.
2103
+
2104
+ ### The canonical field parse pipeline
2105
+
2106
+ `.parse()` applies each declared field's value through a fixed sequence.
2107
+ Knowing the order makes the difference between transform-before-default
2108
+ (correct) and transform-after-default (surprising) predictable:
2109
+
2110
+ ```text
2111
+ For each declared field, in order:
2112
+ 1. Obtain raw candidate
2113
+ — transform(raw) if declared, else raw[fieldName]
2114
+ 2. Apply default if the candidate is missing/undefined
2115
+ 3. Required / optional / nullability check
2116
+ 4. Validate per declared type
2117
+ — literal-union membership, primitive type, array
2118
+ 5. Apply range / regex / attrs constraints
2119
+ 6. Assign as own enumerable property on the instance
2120
+
2121
+ After all declared fields:
2122
+ 7. Run `@ensure` refinements in declaration order
2123
+ — reads the fully-typed, defaulted working object; every
2124
+ refinement runs (no short-circuit); failures collect as
2125
+ {field: '', error: 'ensure', message} issues; if any
2126
+ fail, .parse() throws SchemaError, .safe() returns
2127
+ {ok: false, errors}, and .ok() returns false
2128
+ (steps 8+ do not run)
2129
+ 8. Run `!>` eager-derived entries in declaration order
2130
+ — reads the now-populated instance; results land as own
2131
+ enumerable properties; earlier `!>` values are readable
2132
+ by later ones, forward references are not
2133
+ ```
2134
+
2135
+ The `_hydrate` path (used by `.find`, `.where`, etc.) **skips step 1's
2136
+ transform, step 2's default, steps 3–5, and step 7's refinements** —
2137
+ DB rows are trusted. It still runs step 8 so eager-derived fields
2138
+ appear on hydrated instances just as they do on parsed ones.
2139
+
2140
+ ### Value mutation after parse
2141
+
2142
+ Mutating a field after parse **does not re-run `!>` entries** — they
2143
+ were materialized at parse time. Lazy computed (`~>`) values do reflect
2144
+ the current state on every access. This distinction is the key
2145
+ difference between the two arrows; see §5 for the side-by-side
2146
+ comparison.
2147
+
2148
+
2149
+ ### Layer 1 — Descriptor
2150
+
2151
+ The object passed to `__schema({...})` at module load. Pure metadata plus
2152
+ real inlined functions for methods, computed, and hooks. Cheap to build
2153
+ — no validation, no registry lookups, no class generation.
2154
+
2155
+ ```js
2156
+ __schema({
2157
+ kind: "model",
2158
+ name: "User",
2159
+ entries: [
2160
+ { tag: "field", name: "email", modifiers: ["!", "#"], typeName: "email", array: false },
2161
+ { tag: "directive", name: "timestamps" },
2162
+ { tag: "computed", name: "identifier", fn: function() { return `${this.name} <${this.email}>`; } },
2163
+ { tag: "hook", name: "beforeSave", fn: function() { this.email = this.email.toLowerCase(); } },
2164
+ ],
2165
+ });
2166
+ ```
2167
+
2168
+ ### Layer 2 — Normalized metadata
2169
+
2170
+ Built once per schema on first downstream need. Produces:
2171
+
2172
+ - a `fields` Map (field name → {required, unique, typeName, array, constraints})
2173
+ - a `methods` Map, `computed` Map, `hooks` Map
2174
+ - expanded mixin fields (depth-first, diamond-deduped)
2175
+ - resolved relations with accessor names and FK column names
2176
+ - table name (for `:model`)
2177
+ - namespace-collision enforcement across fields, methods, computed,
2178
+ hooks, relation accessors, and reserved ORM names
2179
+
2180
+ This is the shared pre-stage for the three downstream plans.
2181
+
2182
+ ### Layer 3 — Validator plan
2183
+
2184
+ Built on first `.parse/.safe/.ok` call. Compiled validator tree plus (for
2185
+ `:shape` / `:model`) the generated class constructor. Type-check
2186
+ functions, constraint-check functions, required-field checks, array-item
2187
+ walks, and enum-membership checks are all bound into tight closures at
2188
+ this layer.
2189
+
2190
+ ### Layer 4a — ORM plan
2191
+
2192
+ Built on first `.find/.create/.save/.destroy/.where` call on a `:model`.
2193
+ Wires:
2194
+
2195
+ - the query builder
2196
+ - save / destroy flows (including hook lifecycle)
2197
+ - relation accessors on the generated class
2198
+ - instance methods (`save`, `destroy`, `ok`, `errors`, `toJSON`)
2199
+
2200
+ Requires a configured adapter before first use.
2201
+
2202
+ ### Layer 4b — DDL plan
2203
+
2204
+ Built on first `.toSQL()` call. Emits the `CREATE SEQUENCE` /
2205
+ `CREATE TABLE` / indexes + foreign keys for one model. Independent of
2206
+ Layer 4a — a migration script that never touches the ORM builds this
2207
+ layer only.
2208
+
2209
+ ### Lazy is the point
2210
+
2211
+ Module load does Layer 1 only. An `:input` schema used just for
2212
+ `.parse()` never builds Layer 4. A migration script that only calls
2213
+ `.toSQL()` never builds Layer 3 or 4a. A `:model` used only from the
2214
+ API layer never builds Layer 4b. The four caches never share work they
2215
+ don't have to.
2216
+
2217
+ ### The registry
2218
+
2219
+ `__SchemaRegistry` holds every named `:model` and `:mixin` by bare name.
2220
+ Registration happens in the `__SchemaDef` constructor — *importing a
2221
+ file that declares named schemas activates them*. Tests can call
2222
+ `__SchemaRegistry.reset()` between runs to avoid cross-test leakage.
2223
+
2224
+ ### The adapter
2225
+
2226
+ One function: `adapter.query(sql, params) → {columns, data, rows}`. The
2227
+ default adapter talks to rip-db via `fetch`. Custom adapters (for
2228
+ tests, in-memory mocks, alternate SQL backends) install with
2229
+ `globalThis.__ripSchema.__schemaSetAdapter`. Every ORM method funnels
2230
+ through this interface.
2231
+
2232
+ ---
2233
+
2234
+ ## 24. Compiler integration
2235
+
2236
+ The schema keyword is implemented as a compiler sidecar in
2237
+ `src/schema.js`, alongside the existing type and component sidecars.
2238
+ This isolates the feature from the rest of the compiler: the main Rip
2239
+ grammar has two productions for the schema keyword (not hundreds), and
2240
+ the schema-specific body syntax never reaches the main parser.
2241
+
2242
+ ### Lexer path
2243
+
2244
+ `installSchemaSupport(Lexer, CodeEmitter)` adds `rewriteSchema()` to the
2245
+ lexer prototype. It runs between `rewriteRender()` and `rewriteTypes()`
2246
+ in the rewriter pipeline.
2247
+
2248
+ `rewriteSchema()` recognizes a contextual `schema` identifier at
2249
+ expression-start positions followed by either a `:kind` SYMBOL or a
2250
+ direct INDENT. The matching `INDENT ... OUTDENT` range is parsed by a
2251
+ schema-specific sub-parser and collapsed into a single `SCHEMA_BODY`
2252
+ token whose `.data` carries a structured descriptor (kind, entries,
2253
+ per-entry `.loc`).
2254
+
2255
+ ### Grammar
2256
+
2257
+ The main grammar has one production for the feature:
2258
+
2259
+ ```
2260
+ Schema: SCHEMA SCHEMA_BODY
2261
+ ```
2262
+
2263
+ …under `Expression`. Schema body syntax (`name! type`, `@directive`,
2264
+ `name: ~> body`, `name: -> body`) never reaches the main parser. The
2265
+ state table stays lean.
2266
+
2267
+ ### Body-token reparse
2268
+
2269
+ Bodies of methods, computed getters, and hooks are captured as token
2270
+ slices by the sub-parser. At codegen time those slices:
2271
+
2272
+ 1. run through the tail rewriter passes (implicit braces, tagged
2273
+ templates, implicit call commas, etc.)
2274
+ 2. feed into the main `parser.parse()` via a temporary lex adapter
2275
+ 3. emerge as a normal Rip AST
2276
+ 4. wrap in a thin-arrow AST (`['->', [], body]`)
2277
+ 5. emit via the existing `emitThinArrow` path
2278
+
2279
+ Rip `->` already emits `function() {...}` (not JS arrow), so `this`
2280
+ binds to the instance correctly without special codegen.
2281
+
2282
+ ### Where things live
2283
+
2284
+ | File | Role |
2285
+ | -------------------- | ------------------------------------------------------------------ |
2286
+ | `src/schema.js` | Sub-parser, `emitSchema`, Layer 1-4 runtime, shadow TS walker, `installSchemaSupport` |
2287
+ | `src/lexer.js` | Hook point — calls `rewriteSchema()`; comment-token fix for `#` modifier |
2288
+ | `src/grammar/grammar.rip` | The one `Schema` production |
2289
+ | `src/compiler.js` | Dispatch for the `schema` s-expression head; preamble injection |
2290
+ | `src/types.js` | Calls `emitSchemaTypes()` during `.d.ts` emission |
2291
+ | `src/typecheck.js` | `hasSchemas()` probe so schema-only files aren't `@ts-nocheck`d |
2292
+ | `test/rip/schema.rip` | The test suite |
2293
+
2294
+ The total wiring in the core compiler (outside `src/schema.js`) is under
2295
+ 100 lines. That's the sidecar pattern working — the feature is big, but
2296
+ its footprint in the main compiler is small.
2297
+
2298
+ ---
2299
+
2300
+ ## 25. FAQ
2301
+
2302
+ **Why not just use Zod?**
2303
+ Zod gives you the validator. It doesn't give you the ORM, the DDL, the
2304
+ class, the computed getters, or the derived DTOs. Rip Schema is all of
2305
+ that from one declaration. If you only need the validator, `schema :input`
2306
+ is the equivalent surface — and the derived shadow TS is indistinguishable
2307
+ from `z.infer<>`.
2308
+
2309
+ **Is this a full ORM replacement for Prisma / Drizzle?**
2310
+ For the common CRUD shape — yes. `find`, `where`, `create`, `save`,
2311
+ `destroy`, relations, migrations, hooks, lifecycle callbacks, and
2312
+ validations are all present and running in production apps. For
2313
+ transactions, eager loading, scopes, and soft deletes — not yet; see
2314
+ §15.
2315
+
2316
+ **Does the runtime belong to `schema.js` or is it loaded separately?**
2317
+ It's inlined. When a file uses `schema`, the compiler injects a small
2318
+ preamble (under `SCHEMA_RUNTIME` in `src/schema.js`) that defines
2319
+ `SchemaError`, `__SchemaDef`, `__SchemaRegistry`, `Query`, and the
2320
+ helpers. No import statement, no package dependency, no bootstrap call.
2321
+
2322
+ **How big is the runtime?**
2323
+ About 2,250 lines total across runtime + compile-time emission, including
2324
+ the ORM, the DDL emitter, the registry, the validator plan, and the
2325
+ hydration logic. The preamble injected into your compiled output is a
2326
+ fraction of that (the ORM and DDL paths are tree-shaken if unused).
2327
+
2328
+ **Is `.parse()` strict or permissive with extra keys?**
2329
+ Permissive with stripping. Unknown keys are silently dropped — they
2330
+ don't appear on the returned value or instance, and they don't cause a
2331
+ validation error. This matches the invariant that `.parse()` returns
2332
+ clean data shaped only by the declared fields. If you need hard
2333
+ rejection of unexpected keys, check `Object.keys(input)` against
2334
+ `Object.keys(Schema.parse(input))` yourself.
2335
+
2336
+ **Can I use a schema from TypeScript?**
2337
+ Not yet directly — Rip emits shadow `.d.ts` for editor support, but a
2338
+ separate `.ts` consumer doesn't see those. Exporting from `.rip` and
2339
+ importing the result into `.ts` works: you get the runtime object; you
2340
+ lose the algebra-level generic inference. This is on the roadmap.
2341
+
2342
+ **What happens when the adapter isn't configured?**
2343
+ ORM methods throw a `SchemaError` with a clear "no adapter configured"
2344
+ message. Validation (`.parse`, `.safe`, `.ok`) and DDL (`.toSQL`) work
2345
+ without an adapter.
2346
+
2347
+ **Does `:model` require a database?**
2348
+ No. `:model` works as a standalone class-with-validation. If you never
2349
+ call an ORM method, no adapter is invoked. DDL emission is a pure
2350
+ function of the schema definition.
2351
+
2352
+ **What's the relationship between `enum` and `schema :enum`?**
2353
+ The keyword `enum` is a compile-time-only declaration — it exists in the
2354
+ type system and disappears from JS. `schema :enum` exists at runtime —
2355
+ you can call `.parse()` on it, iterate its members, and use it as a
2356
+ field type. Use `enum` when you only need the static type; use
2357
+ `schema :enum` when runtime membership matters.
2358
+
2359
+ **Can algebra operations (`.pick` / `.omit`) be chained?**
2360
+ Yes. They compose: `User.omit("password").pick("name", "email").partial()`
2361
+ produces a `:shape` with the intersection of the three operations.
2362
+
2363
+ **How do I express cross-field rules — "passwords must match", "end after start"?**
2364
+ Use `@ensure`. See [§5](#refinement-ensure) and the summary in [§22](#22-design-invariants)
2365
+ invariant 12. Messages are required, predicates are plain Rip fns,
2366
+ thrown exceptions count as failure, and all refinements run every time
2367
+ (no short-circuit between refinements).
2368
+
2369
+ **Can I put `@ensure` on a `:mixin` so it travels with the mixin's fields?**
2370
+ No. `:mixin` is fields-only, by design. Refinements attach to the host
2371
+ schema because they describe invariants on the whole parsed object,
2372
+ and a mixin doesn't have a "whole object" of its own — it's a pile of
2373
+ fields that get merged into the host. Put the refinement on the host
2374
+ where the invariant has meaning.
2375
+
2376
+ **What does `:shape` have that a plain JS class doesn't?**
2377
+ Runtime validation on construction. Computed getters automatically
2378
+ typed in shadow TS. Fields are enumerable own properties (so `JSON.stringify`
2379
+ works cleanly). Methods and computed getters live on the prototype (so
2380
+ they don't pollute iteration). Algebra methods (`.pick`, `.omit`, etc.)
2381
+ that derive new schemas. And the whole thing is one declaration.
2382
+
2383
+ **If I find a bug, what's authoritative — the docs or the compiler?**
2384
+ The compiler. This document describes current behavior; when they
2385
+ diverge, the compiler wins and the docs get fixed. File a diagnostic.
2386
+
2387
+ ---
2388
+
2389
+ Schemas live at the core of almost every program. In Rip, one keyword
2390
+ handles that core. Write the shape once, and the language does the rest.