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