@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 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