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.
- package/README.md +46 -4
- package/docs/RIP-LANG.md +116 -11
- package/docs/RIP-SCHEMA.md +2390 -0
- package/docs/RIP-TYPES.md +21 -14
- package/docs/assets/rip-schema-logo-960w.png +0 -0
- package/docs/assets/rip-schema-social.png +0 -0
- package/docs/dist/rip.js +6817 -3670
- package/docs/dist/rip.min.js +1454 -211
- package/docs/dist/rip.min.js.br +0 -0
- package/package.json +10 -4
- package/src/AGENTS.md +130 -0
- package/src/compiler.js +65 -2
- package/src/components.js +19 -5
- package/src/grammar/grammar.rip +20 -1
- package/src/lexer.js +42 -0
- package/src/parser.js +222 -220
- package/src/schema.js +3298 -0
- package/src/sourcemap-utils.js +155 -0
- package/src/typecheck.js +395 -23
- package/src/types.js +25 -0
- package/src/ui.rip +203 -45
- package/CHANGELOG.md +0 -1500
|
@@ -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.
|