@rip-lang/schema 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1042 -0
- package/SCHEMA.md +1113 -0
- package/emit-sql.js +460 -0
- package/emit-types.js +366 -0
- package/generate.js +144 -0
- package/grammar.rip +504 -0
- package/index.js +39 -0
- package/lexer.js +438 -0
- package/orm.js +916 -0
- package/package.json +62 -0
- package/parser.js +246 -0
- package/runtime.js +494 -0
package/README.md
ADDED
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
<img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/rip.png" style="width:50px" /> <br>
|
|
2
|
+
|
|
3
|
+
# Rip Schema - @rip-lang/schema
|
|
4
|
+
|
|
5
|
+
**One definition. Three outputs. Zero drift.**
|
|
6
|
+
|
|
7
|
+
A schema language that generates TypeScript types, runtime validators, and SQL
|
|
8
|
+
from a single source of truth.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Quick Start
|
|
13
|
+
|
|
14
|
+
**CLI — generate types and SQL from a schema file:**
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Generate both .d.ts and .sql
|
|
18
|
+
bun packages/schema/generate.js app.schema
|
|
19
|
+
|
|
20
|
+
# Generate TypeScript types only
|
|
21
|
+
bun packages/schema/generate.js app.schema --types
|
|
22
|
+
|
|
23
|
+
# Generate SQL DDL only (with DROP TABLE)
|
|
24
|
+
bun packages/schema/generate.js app.schema --sql --drop
|
|
25
|
+
|
|
26
|
+
# Output to a specific directory
|
|
27
|
+
bun packages/schema/generate.js app.schema --outdir ./generated
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Programmatic — load, generate, and validate:**
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
import { Schema } from '@rip-lang/schema'
|
|
34
|
+
|
|
35
|
+
const schema = Schema.load('app.schema')
|
|
36
|
+
|
|
37
|
+
const ts = schema.toTypes() // → TypeScript declarations
|
|
38
|
+
const sql = schema.toSQL() // → SQL DDL (CREATE TABLE, INDEX, TYPE)
|
|
39
|
+
const zod = schema.toZod() // → Zod schemas (validation + type inference)
|
|
40
|
+
|
|
41
|
+
const user = schema.create('User', { name: 'Alice', email: 'alice@co.com' })
|
|
42
|
+
const errors = user.$validate() // → null if valid, array of errors if not
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**ORM — schema-driven models with a live database:**
|
|
46
|
+
|
|
47
|
+
```coffee
|
|
48
|
+
import { Schema } from '@rip-lang/schema/orm'
|
|
49
|
+
|
|
50
|
+
schema = Schema.load './app.schema', import.meta.url
|
|
51
|
+
schema.connect 'http://localhost:4213'
|
|
52
|
+
|
|
53
|
+
User = schema.model 'User',
|
|
54
|
+
greet: -> "Hello, #{@name}!"
|
|
55
|
+
computed:
|
|
56
|
+
identifier: -> "#{@name} <#{@email}>"
|
|
57
|
+
isAdmin: -> @role is 'admin'
|
|
58
|
+
|
|
59
|
+
user = User.first!()
|
|
60
|
+
console.log user.identifier # Alice <alice@example.com>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## The Problem
|
|
66
|
+
|
|
67
|
+
Every real application needs three things:
|
|
68
|
+
|
|
69
|
+
1. **TypeScript types** — for compile-time safety and IDE support
|
|
70
|
+
2. **Runtime validators** — for rejecting bad inputs in production
|
|
71
|
+
3. **Database schema** — for tables, constraints, indexes, and migrations
|
|
72
|
+
|
|
73
|
+
Today, you write each one separately:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
types/user.ts → interface User { name: string; email: string; ... }
|
|
77
|
+
schemas/user.ts → z.object({ name: z.string().min(1).max(100), ... })
|
|
78
|
+
prisma/schema.prisma → model User { name String @db.VarChar(100) ... }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Three files. Three syntaxes. Three things to keep in sync manually. They drift
|
|
82
|
+
apart — silently, inevitably — and you find out at the worst possible time.
|
|
83
|
+
|
|
84
|
+
This isn't a tooling failure. It's a structural one. Each tool solves a
|
|
85
|
+
different problem at a different layer:
|
|
86
|
+
|
|
87
|
+
| Tool | What it solves | Where it works | What it can't do |
|
|
88
|
+
|------|---------------|----------------|------------------|
|
|
89
|
+
| TypeScript | Compile-time safety | IDE, build step | Vanishes at runtime |
|
|
90
|
+
| Zod | Runtime validation | API boundaries | No database awareness |
|
|
91
|
+
| Prisma | Database persistence | Migrations, queries | No runtime validation |
|
|
92
|
+
|
|
93
|
+
You can't eliminate this by picking one tool. TypeScript types are erased before
|
|
94
|
+
your code runs. Zod doesn't know about indexes. Prisma can't enforce "must be a
|
|
95
|
+
valid email" at the API layer.
|
|
96
|
+
|
|
97
|
+
But you can eliminate it by writing a definition that's richer than any single
|
|
98
|
+
tool — and generating all three from it.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## The Answer
|
|
103
|
+
|
|
104
|
+
Write one schema:
|
|
105
|
+
|
|
106
|
+
```coffee
|
|
107
|
+
@enum Role: admin, user, guest
|
|
108
|
+
|
|
109
|
+
@model User
|
|
110
|
+
name! string, [1, 100]
|
|
111
|
+
email!# email
|
|
112
|
+
role Role, [user]
|
|
113
|
+
bio? text, [0, 1000]
|
|
114
|
+
active boolean, [true]
|
|
115
|
+
|
|
116
|
+
@belongs_to Organization
|
|
117
|
+
@has_many Post
|
|
118
|
+
|
|
119
|
+
@timestamps
|
|
120
|
+
@index [role, active]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Get three outputs:
|
|
124
|
+
|
|
125
|
+
**TypeScript types** (generated by `emit-types.js`):
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
export enum Role {
|
|
129
|
+
admin = "admin",
|
|
130
|
+
user = "user",
|
|
131
|
+
guest = "guest",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface User {
|
|
135
|
+
id: string;
|
|
136
|
+
/** @minLength 1 @maxLength 100 */
|
|
137
|
+
name: string;
|
|
138
|
+
/** @unique */
|
|
139
|
+
email: string;
|
|
140
|
+
/** @default "user" */
|
|
141
|
+
role: Role;
|
|
142
|
+
/** @minLength 0 @maxLength 1000 */
|
|
143
|
+
bio?: string;
|
|
144
|
+
/** @default true */
|
|
145
|
+
active: boolean;
|
|
146
|
+
organizationId: string;
|
|
147
|
+
organization?: Organization;
|
|
148
|
+
posts?: Post[];
|
|
149
|
+
createdAt: Date;
|
|
150
|
+
updatedAt: Date;
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Runtime validation** (built-in — no Zod needed):
|
|
155
|
+
|
|
156
|
+
```javascript
|
|
157
|
+
import { Schema } from '@rip-lang/schema'
|
|
158
|
+
|
|
159
|
+
const schema = Schema.load('app.schema')
|
|
160
|
+
|
|
161
|
+
const user = schema.create('User', { name: 'Alice', email: 'alice@co.com' })
|
|
162
|
+
// user.role === 'user' (default applied)
|
|
163
|
+
// user.createdAt === Date (auto-set)
|
|
164
|
+
|
|
165
|
+
const errors = user.$validate()
|
|
166
|
+
// null if valid, otherwise:
|
|
167
|
+
// [{ field: 'name', error: 'required', message: 'name is required' }]
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**SQL DDL** (generated by `emit-sql.js`):
|
|
171
|
+
|
|
172
|
+
```sql
|
|
173
|
+
CREATE TYPE role AS ENUM ('admin', 'user', 'guest');
|
|
174
|
+
|
|
175
|
+
CREATE TABLE users (
|
|
176
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
177
|
+
name VARCHAR(100) NOT NULL,
|
|
178
|
+
email VARCHAR NOT NULL UNIQUE,
|
|
179
|
+
role role DEFAULT 'user',
|
|
180
|
+
bio TEXT,
|
|
181
|
+
active BOOLEAN DEFAULT true,
|
|
182
|
+
organization_id UUID NOT NULL REFERENCES organizations(id),
|
|
183
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
184
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
CREATE INDEX idx_users_role_active ON users (role, active);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
One source of truth. Always in sync. Impossible to drift.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Why Not Just TypeScript?
|
|
195
|
+
|
|
196
|
+
TypeScript types are the weakest candidate for a single source of truth:
|
|
197
|
+
|
|
198
|
+
1. **Erased at runtime.** `JSON.parse()` returns `any`. Network responses,
|
|
199
|
+
database rows, and form submissions are all untyped at the moment you need
|
|
200
|
+
protection most. TypeScript can't help because it no longer exists.
|
|
201
|
+
|
|
202
|
+
2. **Can't express constraints.** There is no way to say "string between 1 and
|
|
203
|
+
100 characters" or "must be a valid email" in a TypeScript type. You need
|
|
204
|
+
runtime code for that — which means you need a second system.
|
|
205
|
+
|
|
206
|
+
3. **Can't model persistence.** Indexes, unique constraints, foreign keys,
|
|
207
|
+
cascade rules, column types, precision — none of these exist in TypeScript's
|
|
208
|
+
type system. You need a third system.
|
|
209
|
+
|
|
210
|
+
You could bolt metadata onto TypeScript with decorators, JSDoc tags, or branded
|
|
211
|
+
types. But then TypeScript isn't the source of truth — your annotation layer
|
|
212
|
+
is. And you've built a schema language anyway, just a worse one bolted onto a
|
|
213
|
+
host that fights you.
|
|
214
|
+
|
|
215
|
+
Rip Schema skips the pretense. It's a schema language purpose-built to capture
|
|
216
|
+
everything all three layers need, and it generates each layer's native format
|
|
217
|
+
directly.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Schema Syntax
|
|
222
|
+
|
|
223
|
+
### Fields
|
|
224
|
+
|
|
225
|
+
The basic unit is a field definition:
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
name[modifiers] type[, [constraints]][, { attributes }]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Examples:
|
|
232
|
+
|
|
233
|
+
```coffee
|
|
234
|
+
name! string, [1, 100] # Required string, 1-100 chars
|
|
235
|
+
email!# email # Required + unique email
|
|
236
|
+
bio? text, [0, 1000] # Optional text, max 1000 chars
|
|
237
|
+
role Role, [user] # Enum with default value
|
|
238
|
+
score integer, [0, 100, 50] # Range + default
|
|
239
|
+
tags string[] # Array of strings
|
|
240
|
+
data json, [{}] # JSON with default
|
|
241
|
+
zip! string, [/^\d{5}$/] # Regex pattern
|
|
242
|
+
phone! string, [10, 15], { mask: "(###) ###-####" } # With UI hints
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Modifiers
|
|
246
|
+
|
|
247
|
+
Modifiers appear between the field name and the type:
|
|
248
|
+
|
|
249
|
+
| Modifier | Meaning | TypeScript | SQL |
|
|
250
|
+
|----------|---------|-----------|-----|
|
|
251
|
+
| `!` | Required | non-optional | `NOT NULL` |
|
|
252
|
+
| `#` | Unique | `@unique` JSDoc | `UNIQUE` |
|
|
253
|
+
| `?` | Optional | `field?: T` | nullable |
|
|
254
|
+
|
|
255
|
+
Modifiers can be combined: `email!# email` means required and unique.
|
|
256
|
+
|
|
257
|
+
### Constraints
|
|
258
|
+
|
|
259
|
+
Constraints appear in square brackets after the type:
|
|
260
|
+
|
|
261
|
+
| Syntax | Meaning | Example |
|
|
262
|
+
|--------|---------|---------|
|
|
263
|
+
| `[min, max]` | Range (string length or numeric value) | `[1, 100]` |
|
|
264
|
+
| `[default]` | Default value | `[true]` |
|
|
265
|
+
| `[min, max, default]` | Range + default | `[0, 100, 50]` |
|
|
266
|
+
| `[/regex/]` | Pattern match | `[/^\d{5}$/]` |
|
|
267
|
+
| `[{}]` | Default empty object | `[{}]` |
|
|
268
|
+
| `[->]` | Default from function | `[->]` |
|
|
269
|
+
|
|
270
|
+
### Primitive Types
|
|
271
|
+
|
|
272
|
+
| Type | TypeScript | SQL (DuckDB) |
|
|
273
|
+
|------|-----------|-------------|
|
|
274
|
+
| `string` | `string` | `VARCHAR` |
|
|
275
|
+
| `text` | `string` | `TEXT` |
|
|
276
|
+
| `integer` | `number` | `INTEGER` |
|
|
277
|
+
| `number` | `number` | `DOUBLE` |
|
|
278
|
+
| `boolean` | `boolean` | `BOOLEAN` |
|
|
279
|
+
| `date` | `Date` | `DATE` |
|
|
280
|
+
| `datetime` | `Date` | `TIMESTAMP` |
|
|
281
|
+
| `json` | `unknown` | `JSON` |
|
|
282
|
+
| `uuid` | `string` | `UUID` |
|
|
283
|
+
|
|
284
|
+
### Special Types
|
|
285
|
+
|
|
286
|
+
These carry built-in validation:
|
|
287
|
+
|
|
288
|
+
| Type | Validates | TypeScript | SQL |
|
|
289
|
+
|------|-----------|-----------|-----|
|
|
290
|
+
| `email` | RFC 5322 format | `string` | `VARCHAR` |
|
|
291
|
+
| `url` | Valid URL | `string` | `VARCHAR` |
|
|
292
|
+
| `phone` | E.164 or regional | `string` | `VARCHAR` |
|
|
293
|
+
| `uuid` | UUID format | `string` | `UUID` |
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Definitions
|
|
298
|
+
|
|
299
|
+
### Enums
|
|
300
|
+
|
|
301
|
+
```coffee
|
|
302
|
+
# Inline
|
|
303
|
+
@enum Role: admin, user, guest
|
|
304
|
+
|
|
305
|
+
# Block with explicit values
|
|
306
|
+
@enum Status
|
|
307
|
+
pending: 0
|
|
308
|
+
active: 1
|
|
309
|
+
suspended: 2
|
|
310
|
+
deleted: 3
|
|
311
|
+
|
|
312
|
+
# Block with string values
|
|
313
|
+
@enum Priority
|
|
314
|
+
low: "low"
|
|
315
|
+
medium: "medium"
|
|
316
|
+
high: "high"
|
|
317
|
+
critical: "critical"
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Types
|
|
321
|
+
|
|
322
|
+
Types define reusable structures without database backing — useful for embedded
|
|
323
|
+
objects, API payloads, and shared shapes:
|
|
324
|
+
|
|
325
|
+
```coffee
|
|
326
|
+
@type Address
|
|
327
|
+
street! string, [1, 200]
|
|
328
|
+
city! string, [1, 100]
|
|
329
|
+
state! string, [2, 2]
|
|
330
|
+
zip! string, [/^\d{5}(-\d{4})?$/]
|
|
331
|
+
|
|
332
|
+
@type ContactInfo
|
|
333
|
+
phone? phone
|
|
334
|
+
email? email
|
|
335
|
+
fax? phone
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Types generate TypeScript interfaces and runtime validators but no SQL tables.
|
|
339
|
+
|
|
340
|
+
### Models
|
|
341
|
+
|
|
342
|
+
Models are database-backed entities with full schema, validation, relationships,
|
|
343
|
+
and lifecycle:
|
|
344
|
+
|
|
345
|
+
```coffee
|
|
346
|
+
@model User
|
|
347
|
+
name! string, [1, 100]
|
|
348
|
+
email!# email
|
|
349
|
+
password! string, [8, 100]
|
|
350
|
+
role! Role, [user]
|
|
351
|
+
avatar? url
|
|
352
|
+
bio? text, [0, 1000]
|
|
353
|
+
settings json, [{}]
|
|
354
|
+
active boolean, [true]
|
|
355
|
+
|
|
356
|
+
address? Address # Embedded type
|
|
357
|
+
|
|
358
|
+
@timestamps # createdAt, updatedAt
|
|
359
|
+
@softDelete # deletedAt
|
|
360
|
+
|
|
361
|
+
@index email# # Unique index
|
|
362
|
+
@index [role, active] # Composite index
|
|
363
|
+
@index name # Non-unique index
|
|
364
|
+
|
|
365
|
+
@belongs_to Organization
|
|
366
|
+
@has_many Post
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Models generate TypeScript interfaces, runtime validators, and SQL DDL.
|
|
370
|
+
|
|
371
|
+
Computed properties and custom validation live in model code (see
|
|
372
|
+
[Computed Fields](#computed-fields) and [Validation](#validation) below).
|
|
373
|
+
|
|
374
|
+
### Relationships
|
|
375
|
+
|
|
376
|
+
```coffee
|
|
377
|
+
@belongs_to User # Creates user_id foreign key
|
|
378
|
+
@belongs_to Category, { optional: true }
|
|
379
|
+
@has_one Profile # (or @one Profile)
|
|
380
|
+
@has_many Post # (or @many Post)
|
|
381
|
+
@has_many Comment # (or @many Comment)
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`@one` and `@many` are shorthand aliases for `@has_one` and `@has_many`.
|
|
385
|
+
|
|
386
|
+
### Links (Universal Temporal Associations)
|
|
387
|
+
|
|
388
|
+
```coffee
|
|
389
|
+
@link "admin", Organization # User can be linked as admin to Organization
|
|
390
|
+
@link "mentor", User # Self-referential: User mentors another User
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Links are stored in a shared `links` table (auto-generated in DDL) with temporal
|
|
394
|
+
windowing (`when_from`, `when_till`), enabling role-based, time-bounded, any-to-any
|
|
395
|
+
relationships between models. The ORM provides `link()`, `unlink()`, `links()`, and
|
|
396
|
+
`linked()` methods for querying and managing links.
|
|
397
|
+
|
|
398
|
+
### Indexes and Directives
|
|
399
|
+
|
|
400
|
+
```coffee
|
|
401
|
+
@index email# # Unique index on one field
|
|
402
|
+
@index [role, active] # Composite index on multiple fields
|
|
403
|
+
@index name # Non-unique index
|
|
404
|
+
|
|
405
|
+
@timestamps # Adds createdAt, updatedAt
|
|
406
|
+
@softDelete # Adds deletedAt
|
|
407
|
+
@include Auditable # Include mixin fields
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Computed Fields
|
|
411
|
+
|
|
412
|
+
Computed properties are defined in your model code, not in the `.schema`
|
|
413
|
+
file — they're behavior, not data:
|
|
414
|
+
|
|
415
|
+
```coffee
|
|
416
|
+
User = schema.model 'User',
|
|
417
|
+
computed:
|
|
418
|
+
displayName: -> "#{@name} <#{@email}>"
|
|
419
|
+
isAdmin: -> @role is 'admin'
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Computed fields are accessible as properties (no parens needed), appear in
|
|
423
|
+
`toJSON()` output, but have no SQL column.
|
|
424
|
+
|
|
425
|
+
### Validation
|
|
426
|
+
|
|
427
|
+
Type constraints (`!`, `#`, `?`, `[min, max]`, enum membership) are
|
|
428
|
+
validated automatically by the runtime engine. Custom cross-field validation
|
|
429
|
+
lives in your model code:
|
|
430
|
+
|
|
431
|
+
```coffee
|
|
432
|
+
User = schema.model 'User',
|
|
433
|
+
beforeSave: ->
|
|
434
|
+
throw new Error('Password needs uppercase') unless /[A-Z]/.test(@password)
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
This keeps the `.schema` file focused on structural definitions and lets
|
|
438
|
+
behavior live where it belongs — in code.
|
|
439
|
+
|
|
440
|
+
### Mixins
|
|
441
|
+
|
|
442
|
+
Reusable field groups:
|
|
443
|
+
|
|
444
|
+
```coffee
|
|
445
|
+
@mixin Timestamps
|
|
446
|
+
createdAt! datetime
|
|
447
|
+
updatedAt! datetime
|
|
448
|
+
|
|
449
|
+
@mixin SoftDelete
|
|
450
|
+
deletedAt? datetime
|
|
451
|
+
|
|
452
|
+
@mixin Auditable
|
|
453
|
+
@include Timestamps
|
|
454
|
+
@include SoftDelete
|
|
455
|
+
createdBy? integer
|
|
456
|
+
updatedBy? integer
|
|
457
|
+
|
|
458
|
+
@model Post
|
|
459
|
+
title! string, [1, 200]
|
|
460
|
+
content! text
|
|
461
|
+
@include Auditable
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## Beyond Data: Widgets, Forms, and State
|
|
467
|
+
|
|
468
|
+
The schema language is designed to extend beyond the data layer into UI and
|
|
469
|
+
application state — the same "define once, generate everything" principle
|
|
470
|
+
applied to presentation. The syntax is defined; the code generators are
|
|
471
|
+
future work (see [Future](#future) below).
|
|
472
|
+
|
|
473
|
+
### Widgets
|
|
474
|
+
|
|
475
|
+
```coffee
|
|
476
|
+
@widget DataGrid
|
|
477
|
+
columns! Column[]
|
|
478
|
+
pageSize integer, [25]
|
|
479
|
+
selectionMode SelectionMode, [single]
|
|
480
|
+
sortable boolean, [true]
|
|
481
|
+
|
|
482
|
+
@events onSelect, onSort, onAction
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### Forms
|
|
486
|
+
|
|
487
|
+
```coffee
|
|
488
|
+
@form UserForm: User
|
|
489
|
+
name { x: 0, y: 0, span: 2, label: "Full Name" }
|
|
490
|
+
email { x: 0, y: 1 }
|
|
491
|
+
role { x: 0, y: 2, widget: dropdown }
|
|
492
|
+
bio { x: 0, y: 3, widget: textarea, rows: 3 }
|
|
493
|
+
|
|
494
|
+
@actions
|
|
495
|
+
save { primary: true }
|
|
496
|
+
cancel {}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### State
|
|
500
|
+
|
|
501
|
+
```coffee
|
|
502
|
+
@state App
|
|
503
|
+
currentUser? User
|
|
504
|
+
theme string, ['light']
|
|
505
|
+
sidebarOpen boolean, [true]
|
|
506
|
+
|
|
507
|
+
@computed
|
|
508
|
+
isLoggedIn -> @currentUser?
|
|
509
|
+
isAdmin -> @currentUser?.role is 'admin'
|
|
510
|
+
|
|
511
|
+
@actions
|
|
512
|
+
login { async: true }
|
|
513
|
+
logout {}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
---
|
|
517
|
+
|
|
518
|
+
## How It Works
|
|
519
|
+
|
|
520
|
+
Schemas are parsed by a Solar-generated SLR(1) parser into S-expressions — a
|
|
521
|
+
lightweight tree structure that any code generator can walk:
|
|
522
|
+
|
|
523
|
+
```
|
|
524
|
+
app.schema → lexer → parser → S-expressions → ┬── emit-types.js → app.d.ts
|
|
525
|
+
├── emit-sql.js → app.sql
|
|
526
|
+
├── emit-zod.js → app.zod.ts
|
|
527
|
+
└── runtime.js → validate()
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
The S-expression for a model looks like:
|
|
531
|
+
|
|
532
|
+
```javascript
|
|
533
|
+
["model", "User", null, [
|
|
534
|
+
["field", "name", ["!"], "string", [[1, 100]], null],
|
|
535
|
+
["field", "email", ["!", "#"], "email", null, null],
|
|
536
|
+
["field", "role", [], "Role", [["user"]], null],
|
|
537
|
+
["timestamps"],
|
|
538
|
+
["index", ["role", "active"], false]
|
|
539
|
+
]]
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Each output target is a simple tree-walker over this structure. Adding a new
|
|
543
|
+
target — OpenAPI, JSON Schema, Prisma, GraphQL — means writing one more walker.
|
|
544
|
+
The schema definition never changes.
|
|
545
|
+
|
|
546
|
+
This architecture means the schema language is not coupled to any specific
|
|
547
|
+
output format. It's a **representation of your domain** that happens to be
|
|
548
|
+
renderable into whatever format each layer of your stack needs.
|
|
549
|
+
|
|
550
|
+
---
|
|
551
|
+
|
|
552
|
+
## Runtime ORM
|
|
553
|
+
|
|
554
|
+
Beyond code generation, Rip Schema includes a Schema-centric ORM. The
|
|
555
|
+
schema object itself creates models — one call wires fields, types,
|
|
556
|
+
constraints, methods, and computed properties from the `.schema` file:
|
|
557
|
+
|
|
558
|
+
```coffee
|
|
559
|
+
import { Schema } from '@rip-lang/schema/orm'
|
|
560
|
+
|
|
561
|
+
schema = Schema.load './app.schema', import.meta.url
|
|
562
|
+
schema.connect 'http://localhost:4213'
|
|
563
|
+
|
|
564
|
+
User = schema.model 'User',
|
|
565
|
+
greet: -> "Hello, #{@name}!"
|
|
566
|
+
displayRole: -> @role.charAt(0).toUpperCase() + @role.slice(1)
|
|
567
|
+
computed:
|
|
568
|
+
identifier: -> "#{@name} <#{@email}>"
|
|
569
|
+
isAdmin: -> @role is 'admin'
|
|
570
|
+
|
|
571
|
+
Post = schema.model 'Post',
|
|
572
|
+
computed:
|
|
573
|
+
summary: -> "#{@title} (#{@status})"
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
That's the entire model definition. Fields, types, constraints, table name,
|
|
577
|
+
primary key, foreign keys, timestamps, soft-delete, and relations are all
|
|
578
|
+
derived from the schema. You only write behavior.
|
|
579
|
+
|
|
580
|
+
### Queries
|
|
581
|
+
|
|
582
|
+
```coffee
|
|
583
|
+
user = User.find!(id) # Find by ID
|
|
584
|
+
user = User.first!() # First record
|
|
585
|
+
users = User.all!() # All records
|
|
586
|
+
users = User.where!(active: true).all!() # Filtered
|
|
587
|
+
count = User.count!() # Count
|
|
588
|
+
|
|
589
|
+
# Chainable
|
|
590
|
+
users = User
|
|
591
|
+
.where!('active = ?', true)
|
|
592
|
+
.orderBy!('name')
|
|
593
|
+
.limit!(10)
|
|
594
|
+
.all!()
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
### Records
|
|
598
|
+
|
|
599
|
+
```coffee
|
|
600
|
+
# Schema fields — properties
|
|
601
|
+
user.name # Read
|
|
602
|
+
user.name = 'Alice' # Write (automatic dirty tracking)
|
|
603
|
+
|
|
604
|
+
# Computed — properties (no parens)
|
|
605
|
+
user.identifier # "Alice <alice@example.com>"
|
|
606
|
+
user.isAdmin # true
|
|
607
|
+
|
|
608
|
+
# Methods — called with parens
|
|
609
|
+
user.greet() # "Hello, Alice!"
|
|
610
|
+
user.displayRole() # "Admin"
|
|
611
|
+
|
|
612
|
+
# State
|
|
613
|
+
user.$isNew # Not yet persisted?
|
|
614
|
+
user.$dirty # Changed field names
|
|
615
|
+
user.$changed # Has any changes?
|
|
616
|
+
user.$data # Raw data snapshot
|
|
617
|
+
|
|
618
|
+
# Lifecycle
|
|
619
|
+
user.$validate() # → null or [{ field, error, message }]
|
|
620
|
+
user.save() # INSERT or UPDATE (dirty fields only)
|
|
621
|
+
user.delete() # DELETE
|
|
622
|
+
user.softDelete() # Set deleted_at (if @softDelete)
|
|
623
|
+
user.restore() # Clear deleted_at
|
|
624
|
+
user.reload() # Refresh from database
|
|
625
|
+
user.toJSON() # Serialize (includes computed fields)
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### Relations
|
|
629
|
+
|
|
630
|
+
Relations are derived from the `@belongs_to`, `@has_many` (or `@many`), and
|
|
631
|
+
`@has_one` (or `@one`) directives in the schema. Each relation becomes an async
|
|
632
|
+
method on instances:
|
|
633
|
+
|
|
634
|
+
```coffee
|
|
635
|
+
# Given: User @many Post, Post @belongs_to User
|
|
636
|
+
|
|
637
|
+
# Lazy loading — one query per call
|
|
638
|
+
posts = user.posts!() # → [Post, Post, ...]
|
|
639
|
+
author = post.user!() # → User
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
All models registered with `schema.model` are automatically discoverable — no
|
|
643
|
+
manual wiring needed.
|
|
644
|
+
|
|
645
|
+
### Links (Temporal Associations)
|
|
646
|
+
|
|
647
|
+
Links use a shared `links` table for named, time-bounded, any-to-any associations:
|
|
648
|
+
|
|
649
|
+
```coffee
|
|
650
|
+
# Given: User @link "admin", Organization
|
|
651
|
+
|
|
652
|
+
# Create a link
|
|
653
|
+
user.link! "admin", org
|
|
654
|
+
|
|
655
|
+
# Query outgoing links
|
|
656
|
+
user.links! "admin" # → [{ role, targetType, targetId, ... }]
|
|
657
|
+
|
|
658
|
+
# Query incoming: who is admin of this org?
|
|
659
|
+
admins = User.linked! "admin", org # → [User, User, ...]
|
|
660
|
+
|
|
661
|
+
# End a link (preserves history by setting when_till)
|
|
662
|
+
user.unlink! "admin", org
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
### Eager Loading
|
|
666
|
+
|
|
667
|
+
Eliminate N+1 queries by pre-loading relations in batch:
|
|
668
|
+
|
|
669
|
+
```coffee
|
|
670
|
+
# 2 queries instead of N+1
|
|
671
|
+
users = User.include!('posts').all!()
|
|
672
|
+
for u in users
|
|
673
|
+
posts = u.posts!() # instant — no query, data already loaded
|
|
674
|
+
console.log "#{u.name}: #{posts.length} posts"
|
|
675
|
+
|
|
676
|
+
# Chainable with other query methods
|
|
677
|
+
active = User.include!('posts').where!(active: true).all!()
|
|
678
|
+
|
|
679
|
+
# Multiple relations
|
|
680
|
+
users = User.include!('posts', 'organization').all!()
|
|
681
|
+
|
|
682
|
+
# Works with first() too
|
|
683
|
+
user = User.include!('posts').first!()
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Under the hood, `include` executes one batch query per relation using
|
|
687
|
+
`WHERE fk IN (...)` — the standard 2-query strategy used by Rails and Prisma.
|
|
688
|
+
Lazy loading still works for any relation not pre-loaded.
|
|
689
|
+
|
|
690
|
+
### Soft Delete
|
|
691
|
+
|
|
692
|
+
Models with `@softDelete` in the schema automatically filter deleted records
|
|
693
|
+
from all queries. No configuration needed — the ORM reads it from the schema:
|
|
694
|
+
|
|
695
|
+
```coffee
|
|
696
|
+
# Queries automatically exclude soft-deleted records
|
|
697
|
+
posts = Post.all!() # only non-deleted posts
|
|
698
|
+
|
|
699
|
+
# Soft-delete a record (sets deleted_at, no actual DELETE)
|
|
700
|
+
post.softDelete!()
|
|
701
|
+
|
|
702
|
+
# Explicitly include soft-deleted records
|
|
703
|
+
all = Post.withDeleted!().all!()
|
|
704
|
+
|
|
705
|
+
# Restore a soft-deleted record
|
|
706
|
+
post.restore!()
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
Models without `@softDelete` are unaffected — `delete()` still performs a
|
|
710
|
+
real `DELETE` as before.
|
|
711
|
+
|
|
712
|
+
### Lifecycle Hooks
|
|
713
|
+
|
|
714
|
+
Define hooks in your model to run logic before or after persistence:
|
|
715
|
+
|
|
716
|
+
```coffee
|
|
717
|
+
User = schema.model 'User',
|
|
718
|
+
beforeSave: ->
|
|
719
|
+
@email = @email.toLowerCase()
|
|
720
|
+
afterCreate: ->
|
|
721
|
+
console.log "Welcome, #{@name}!"
|
|
722
|
+
beforeDelete: ->
|
|
723
|
+
console.log "Goodbye, #{@name}"
|
|
724
|
+
computed:
|
|
725
|
+
identifier: -> "#{@name} <#{@email}>"
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
Available hooks: `beforeSave`, `afterSave`, `beforeCreate`, `afterCreate`,
|
|
729
|
+
`beforeUpdate`, `afterUpdate`, `beforeDelete`, `afterDelete`.
|
|
730
|
+
|
|
731
|
+
- Hooks are called with `this` bound to the instance
|
|
732
|
+
- Before hooks run before validation — normalize data (lowercase, trim) and constraints are checked on the final values
|
|
733
|
+
- Before hooks can return `false` to abort the operation
|
|
734
|
+
- Hooks can be async
|
|
735
|
+
- `beforeDelete`/`afterDelete` fire on both `delete()` and `softDelete()`
|
|
736
|
+
|
|
737
|
+
### Transactions
|
|
738
|
+
|
|
739
|
+
Wrap multi-model operations in a transaction for atomicity:
|
|
740
|
+
|
|
741
|
+
```coffee
|
|
742
|
+
schema.transaction! ->
|
|
743
|
+
user = User.create!(name: 'Alice', email: 'alice@co.com')
|
|
744
|
+
post = Post.create!(title: 'Hello', userId: user.id)
|
|
745
|
+
# If either fails, both roll back
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
If the callback throws, all changes are rolled back. If it succeeds, all
|
|
749
|
+
changes are committed. The return value of the callback is returned from
|
|
750
|
+
`transaction()`.
|
|
751
|
+
|
|
752
|
+
### Zod Schemas
|
|
753
|
+
|
|
754
|
+
Generate Zod validation schemas from the same source of truth:
|
|
755
|
+
|
|
756
|
+
```coffee
|
|
757
|
+
# Programmatic
|
|
758
|
+
schema = Schema.load 'app.schema'
|
|
759
|
+
zod = schema.toZod() # → Zod source with all schemas
|
|
760
|
+
|
|
761
|
+
# CLI
|
|
762
|
+
bun generate.js app.schema --zod # → app.zod.ts
|
|
763
|
+
bun generate.js app.schema --zod --stdout # → print to stdout
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
Each model produces three schemas:
|
|
767
|
+
|
|
768
|
+
```typescript
|
|
769
|
+
export const UserSchema = z.object({ ... }) // Full record
|
|
770
|
+
export const UserCreateSchema = z.object({ ... }) // Input for creation
|
|
771
|
+
export const UserUpdateSchema = z.object({ ... }) // Input for updates (all optional)
|
|
772
|
+
export type User = z.infer<typeof UserSchema>
|
|
773
|
+
export type UserCreate = z.infer<typeof UserCreateSchema>
|
|
774
|
+
export type UserUpdate = z.infer<typeof UserUpdateSchema>
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
Constraints from the schema are mapped directly to Zod refinements:
|
|
778
|
+
`string [1, 100]` becomes `z.string().min(1).max(100)`, email types get
|
|
779
|
+
`.email()`, UUIDs get `.uuid()`, and defaults flow through as `.default()`.
|
|
780
|
+
Enum types reference their Zod enum schema. Nested `@type` definitions are
|
|
781
|
+
embedded as `z.object()` references. Relation `has_many`/`has_one` fields
|
|
782
|
+
are omitted since Zod schemas target input validation, not query results.
|
|
783
|
+
|
|
784
|
+
### Factory
|
|
785
|
+
|
|
786
|
+
Schema-driven fake data generation — zero configuration, zero dependencies:
|
|
787
|
+
|
|
788
|
+
```coffee
|
|
789
|
+
# Single record
|
|
790
|
+
user = User.factory!() # create 1 (persisted)
|
|
791
|
+
user = User.factory!(0) # build 1 (not persisted)
|
|
792
|
+
|
|
793
|
+
# Batch
|
|
794
|
+
users = User.factory!(5) # create 5 (persisted, array)
|
|
795
|
+
users = User.factory!(-3) # build 3 (not persisted, array)
|
|
796
|
+
|
|
797
|
+
# With overrides
|
|
798
|
+
admins = User.factory!(3, role: 'admin')
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
The factory knows what to generate from the schema — field names, types,
|
|
802
|
+
constraints, enums, and defaults:
|
|
803
|
+
|
|
804
|
+
| Field | Schema | Generated |
|
|
805
|
+
|-------|--------|-----------|
|
|
806
|
+
| `name!` | `string, [1, 100]` | `"Emma Diaz"` |
|
|
807
|
+
| `email!#` | `email` | `"kate3@staging.app"` |
|
|
808
|
+
| `role` | `Role, ["viewer"]` | `"viewer"` (uses default) |
|
|
809
|
+
| `bio?` | `text, [0, 500]` | Lorem paragraph or null |
|
|
810
|
+
| `active` | `boolean, [true]` | `true` (uses default) |
|
|
811
|
+
|
|
812
|
+
Field-name hinting makes the data realistic — `name` generates person names,
|
|
813
|
+
`email` generates emails, `city` generates cities, `slug` generates slugs.
|
|
814
|
+
No faker library needed.
|
|
815
|
+
|
|
816
|
+
For models that need custom logic, pass a `faker` function:
|
|
817
|
+
|
|
818
|
+
```coffee
|
|
819
|
+
User = schema.model 'User',
|
|
820
|
+
faker: -> { status: Fake.pick ["Active", "Active", "Active", "Closed"] }
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
### Validation
|
|
824
|
+
|
|
825
|
+
```coffee
|
|
826
|
+
errors = user.$validate()
|
|
827
|
+
|
|
828
|
+
# null if valid, otherwise:
|
|
829
|
+
# [
|
|
830
|
+
# { field: 'name', error: 'required', message: 'name is required' },
|
|
831
|
+
# { field: 'email', error: 'type', message: 'email must be a valid email' },
|
|
832
|
+
# { field: 'role', error: 'enum', message: 'role must be one of: admin, user, guest' },
|
|
833
|
+
# ]
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
Error types: `required`, `type`, `enum`, `min`, `max`, `pattern`, `nested`.
|
|
837
|
+
|
|
838
|
+
### Multi-file Layout
|
|
839
|
+
|
|
840
|
+
In a real application, each model lives in its own file:
|
|
841
|
+
|
|
842
|
+
```coffee
|
|
843
|
+
# db.rip — load schema once
|
|
844
|
+
import { Schema } from '@rip-lang/schema/orm'
|
|
845
|
+
export schema = Schema.load './app.schema', import.meta.url
|
|
846
|
+
schema.connect process.env.DB_URL or 'http://localhost:4213'
|
|
847
|
+
|
|
848
|
+
# models/user.rip — one model per file
|
|
849
|
+
import { schema } from '../db.rip'
|
|
850
|
+
export User = schema.model 'User',
|
|
851
|
+
greet: -> "Hello, #{@name}!"
|
|
852
|
+
computed:
|
|
853
|
+
identifier: -> "#{@name} <#{@email}>"
|
|
854
|
+
|
|
855
|
+
# models/post.rip
|
|
856
|
+
import { schema } from '../db.rip'
|
|
857
|
+
export Post = schema.model 'Post',
|
|
858
|
+
computed:
|
|
859
|
+
summary: -> "#{@title} (#{@status})"
|
|
860
|
+
|
|
861
|
+
# app.rip — use models with relations
|
|
862
|
+
import { User } from './models/user.rip'
|
|
863
|
+
import { Post } from './models/post.rip'
|
|
864
|
+
user = User.first!()
|
|
865
|
+
posts = user.posts!() # lazy loads related posts
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
---
|
|
869
|
+
|
|
870
|
+
## Architecture
|
|
871
|
+
|
|
872
|
+
```
|
|
873
|
+
packages/schema/
|
|
874
|
+
├── grammar.rip # Solar grammar definition (schema → S-expressions)
|
|
875
|
+
├── lexer.js # Indentation-aware tokenizer
|
|
876
|
+
├── parser.js # Generated SLR(1) parser
|
|
877
|
+
├── runtime.js # Schema registry, validation, model factory
|
|
878
|
+
├── emit-types.js # AST → TypeScript interfaces and enums
|
|
879
|
+
├── emit-sql.js # AST → SQL DDL (CREATE TABLE, INDEX, TYPE)
|
|
880
|
+
├── emit-zod.js # AST → Zod validation schemas
|
|
881
|
+
├── errors.js # Beautiful parse error formatting
|
|
882
|
+
├── generate.js # CLI: rip-schema generate app.schema
|
|
883
|
+
├── orm.js # ActiveRecord-style ORM
|
|
884
|
+
├── faker.js # Compact fake data generator (field-name hinting)
|
|
885
|
+
├── index.js # Public API entry point
|
|
886
|
+
├── SCHEMA.md # Full specification and design details
|
|
887
|
+
└── README.md # This file
|
|
888
|
+
|
|
889
|
+
packages/db/
|
|
890
|
+
├── db.rip # DuckDB HTTP server
|
|
891
|
+
└── lib/duckdb.mjs # Native bindings
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
The schema package and database package are independent:
|
|
895
|
+
|
|
896
|
+
- **@rip-lang/schema** — Parse schemas, validate data, generate TypeScript
|
|
897
|
+
types, SQL DDL, and Zod schemas, build domain models
|
|
898
|
+
- **@rip-lang/db** — Database server (DuckDB with native FFI bindings, served
|
|
899
|
+
over HTTP)
|
|
900
|
+
|
|
901
|
+
Everything is opt-in. You can use any layer independently:
|
|
902
|
+
|
|
903
|
+
| You need... | You use... | Dependencies |
|
|
904
|
+
|-------------|-----------|-------------|
|
|
905
|
+
| Parse schema files | `Schema.load()` | None |
|
|
906
|
+
| Runtime validation | `schema.validate()`, `schema.create()` | None |
|
|
907
|
+
| TypeScript types | `schema.toTypes()` | None |
|
|
908
|
+
| SQL DDL | `schema.toSQL()` | None |
|
|
909
|
+
| Zod schemas | `schema.toZod()` | None |
|
|
910
|
+
| ORM with database | `schema.model()` + `@rip-lang/db` | DuckDB |
|
|
911
|
+
|
|
912
|
+
The ORM connects to the database over HTTP, keeping the layers cleanly
|
|
913
|
+
separated. You can use schema parsing and code generation without the ORM, and
|
|
914
|
+
you can use the ORM without the code generators.
|
|
915
|
+
|
|
916
|
+
---
|
|
917
|
+
|
|
918
|
+
## Testing
|
|
919
|
+
|
|
920
|
+
### Prerequisites
|
|
921
|
+
|
|
922
|
+
```bash
|
|
923
|
+
bun bin/rip packages/db/db.rip :memory: # start rip-db (in-memory)
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
### Code Generation (`examples/app-demo.rip`)
|
|
927
|
+
|
|
928
|
+
Parses `app.schema` (a blog platform with enums, types, and models) and
|
|
929
|
+
validates all three output targets from a single source file.
|
|
930
|
+
|
|
931
|
+
```bash
|
|
932
|
+
rip packages/schema/examples/app-demo.rip
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
| What | Validates |
|
|
936
|
+
|-------------------------------|-----------------------------------------------------------|
|
|
937
|
+
| **Parse** | `Schema.load` parses 2 enums, 1 type, 4 models |
|
|
938
|
+
| **TypeScript generation** | `schema.toTypes()` produces interfaces, enums, JSDoc |
|
|
939
|
+
| **SQL DDL generation** | `schema.toSQL()` produces CREATE TABLE / CREATE TYPE |
|
|
940
|
+
| **Valid instance** | `schema.create('User', {...})` applies defaults, passes |
|
|
941
|
+
| **Missing required field** | Catches missing `email` |
|
|
942
|
+
| **Invalid email** | Catches `not-an-email` format |
|
|
943
|
+
| **String max length** | Catches name > 100 chars |
|
|
944
|
+
| **Invalid enum** | Catches `superadmin` not in `Role` enum |
|
|
945
|
+
| **Nested type validation** | Catches multiple violations in `Address` sub-object |
|
|
946
|
+
| **Enum + integer defaults** | `Post` gets correct `status` and `viewCount` defaults |
|
|
947
|
+
| **File output** | Writes `generated/app.d.ts` and `generated/app.sql` |
|
|
948
|
+
|
|
949
|
+
### ORM with Live Database (`examples/orm-example.rip`)
|
|
950
|
+
|
|
951
|
+
Connects to `rip-db`, creates tables from schema-generated DDL, seeds
|
|
952
|
+
data, and exercises the full Schema-centric ORM.
|
|
953
|
+
|
|
954
|
+
```bash
|
|
955
|
+
rip packages/schema/examples/orm-example.rip
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
| What | Validates |
|
|
959
|
+
|-------------------------------|-----------------------------------------------------------|
|
|
960
|
+
| **Schema load** | `Schema.load './app.schema'` parses and registers models |
|
|
961
|
+
| **DDL generation** | `schema.toSQL()` produces dependency-ordered DDL |
|
|
962
|
+
| **Setup** | DROP/CREATE TABLE via `/sql` endpoint, seed users + posts |
|
|
963
|
+
| **Model definition** | `schema.model 'User'` + `schema.model 'Post'` wire fields |
|
|
964
|
+
| **Find first** | `User.first()` returns a user with all schema fields |
|
|
965
|
+
| **Find by email** | `User.where(email: ...)` filters correctly |
|
|
966
|
+
| **Computed properties** | `user.identifier` and `user.isAdmin` derive from fields |
|
|
967
|
+
| **All users** | `User.all()` returns all 5 records |
|
|
968
|
+
| **Where (object style)** | `User.where(role: 'editor')` filters correctly |
|
|
969
|
+
| **Where (SQL style)** | `User.where('active = ?', true)` with parameterized query |
|
|
970
|
+
| **Chainable query** | `.where().orderBy().limit()` chains produce correct SQL |
|
|
971
|
+
| **Count** | `User.count()` returns 5 |
|
|
972
|
+
| **Instance methods** | `user.greet()` and `user.displayRole()` work |
|
|
973
|
+
| **Dirty tracking** | Mutation of `name` sets `$dirty` and `$changed` |
|
|
974
|
+
| **Relation: hasMany** | `user.posts()` returns related posts |
|
|
975
|
+
| **Relation: belongsTo** | `post.user()` returns the author |
|
|
976
|
+
| **Eager loading** | `User.include('posts').all()` batch-loads in 2 queries |
|
|
977
|
+
| **Lifecycle hooks** | `beforeSave` normalizes email, `afterCreate` logs creation |
|
|
978
|
+
| **Transaction rollback** | `schema.transaction!` rolls back on error, count unchanged |
|
|
979
|
+
| **Soft delete** | `softDelete()` hides, `withDeleted()` includes, `restore()` recovers |
|
|
980
|
+
| **Factory: build** | `User.factory(0)` builds with realistic fake data (not persisted) |
|
|
981
|
+
| **Factory: batch create** | `User.factory(3, role: 'editor')` creates 3 with overrides |
|
|
982
|
+
| **Validation** | Missing `name` and invalid `email` caught by `$validate()` |
|
|
983
|
+
|
|
984
|
+
### Full Test Suite
|
|
985
|
+
|
|
986
|
+
```bash
|
|
987
|
+
bun test/runner.js test/rip # 1243 tests, 100% passing
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
---
|
|
991
|
+
|
|
992
|
+
## Future
|
|
993
|
+
|
|
994
|
+
Features that would extend the system but aren't needed yet:
|
|
995
|
+
|
|
996
|
+
- **`@computed` / `@validate` in the DSL** — Cross-field validation and
|
|
997
|
+
computed properties work today in model code via `schema.model` options.
|
|
998
|
+
Moving them into the `.schema` file itself would allow code generators to
|
|
999
|
+
emit them too. Not urgent — the model-code approach is clean.
|
|
1000
|
+
- **Migration diffing** — The DDL generator produces target state, not
|
|
1001
|
+
migration paths. Use dbmate, Flyway, or Prisma Migrate for now. Schema-
|
|
1002
|
+
aware diffing (compare two ASTs, emit `ALTER TABLE`) is a natural extension.
|
|
1003
|
+
- **Widget / Form / State systems** — The schema language is designed to
|
|
1004
|
+
extend beyond the data layer (see [SCHEMA.md](./SCHEMA.md) for the full
|
|
1005
|
+
vision). Widget definitions, form layouts, and reactive state management
|
|
1006
|
+
would apply the same "define once, generate everything" principle to UI.
|
|
1007
|
+
- **Per-schema connection state** — `schema.connect()` sets a module-level
|
|
1008
|
+
URL. Two schemas can't connect to different databases. Not a problem for
|
|
1009
|
+
single-database apps; revisit when multi-database support is needed.
|
|
1010
|
+
- **MySQL / SQLite / PostgreSQL** — DuckDB is the starting point. The SQL
|
|
1011
|
+
emitter is a simple AST walker; adding dialect variants is straightforward.
|
|
1012
|
+
|
|
1013
|
+
---
|
|
1014
|
+
|
|
1015
|
+
## Background
|
|
1016
|
+
|
|
1017
|
+
Rip Schema is part of the [Rip](https://github.com/shreeve/rip-lang) language
|
|
1018
|
+
ecosystem. It draws on ideas from:
|
|
1019
|
+
|
|
1020
|
+
- **SPOT/Sage** — Enterprise schema framework that proved unified definitions
|
|
1021
|
+
work at scale (complex medical systems with 2000+ line schemas)
|
|
1022
|
+
- **ActiveRecord** — Ruby's ORM pattern: models as rich domain objects
|
|
1023
|
+
- **Zod** — Runtime validation with composable schemas
|
|
1024
|
+
- **Prisma** — Database schema as code with generated client types
|
|
1025
|
+
- **TypeScript** — Type inference and structural typing
|
|
1026
|
+
- **ASN.1** — Formal notation for describing data structures
|
|
1027
|
+
|
|
1028
|
+
The key insight is that types, validation, and persistence are three views of
|
|
1029
|
+
the same underlying reality — the shape of your data. Writing them separately
|
|
1030
|
+
creates drift. Writing them once and generating the rest eliminates it.
|
|
1031
|
+
|
|
1032
|
+
---
|
|
1033
|
+
|
|
1034
|
+
## See Also
|
|
1035
|
+
|
|
1036
|
+
- [SCHEMA.md](./SCHEMA.md) — Full specification, syntax details, and design
|
|
1037
|
+
rationale
|
|
1038
|
+
- [examples/](./examples/) — Working code examples
|
|
1039
|
+
- [grammar.rip](./grammar.rip) — The Solar grammar that parses schema files
|
|
1040
|
+
- [emit-types.js](./emit-types.js) — TypeScript declaration generator
|
|
1041
|
+
- [emit-sql.js](./emit-sql.js) — SQL DDL generator (DuckDB)
|
|
1042
|
+
- [generate.js](./generate.js) — CLI entry point
|