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.
Files changed (57) hide show
  1. package/README.md +3 -4
  2. package/bin/rip +201 -18
  3. package/bin/rip-schema +175 -0
  4. package/docs/AGENTS.md +1 -1
  5. package/docs/RIP-APP.md +200 -19
  6. package/docs/RIP-DUCKDB.md +64 -1
  7. package/docs/RIP-INTRO.md +4 -4
  8. package/docs/RIP-LANG.md +36 -38
  9. package/docs/RIP-SCHEMA.md +1204 -364
  10. package/docs/RIP-TYPES.md +74 -103
  11. package/docs/demo/README.md +4 -3
  12. package/docs/dist/rip.js +4195 -966
  13. package/docs/dist/rip.min.js +1161 -284
  14. package/docs/dist/rip.min.js.br +0 -0
  15. package/docs/example/index.json +7 -7
  16. package/docs/example/index.json.br +0 -0
  17. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  18. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  19. package/docs/extensions/vscode/rip/index.html +2 -1
  20. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  21. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  22. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  23. package/docs/index.html +1 -1
  24. package/docs/ui/bundle.json +55 -55
  25. package/docs/ui/bundle.json.br +0 -0
  26. package/docs/ui/hljs-rip.js +1 -1
  27. package/docs/ui/index.html +1 -1
  28. package/package.json +15 -7
  29. package/rip-loader.js +59 -2
  30. package/src/AGENTS.md +43 -12
  31. package/src/browser.js +52 -11
  32. package/src/compiler.js +538 -80
  33. package/src/components.js +488 -48
  34. package/src/dts.js +80 -50
  35. package/src/grammar/README.md +29 -170
  36. package/src/grammar/grammar.rip +17 -12
  37. package/src/grammar/solar.rip +4 -17
  38. package/src/lexer.js +82 -32
  39. package/src/parser.js +229 -229
  40. package/src/schema/dts.js +328 -54
  41. package/src/schema/loader-server.js +2 -1
  42. package/src/schema/runtime-browser-stubs.js +20 -9
  43. package/src/schema/runtime-ddl.js +161 -44
  44. package/src/schema/runtime-migrate.js +681 -0
  45. package/src/schema/runtime-orm.js +698 -54
  46. package/src/schema/runtime-validate.js +808 -24
  47. package/src/schema/runtime.generated.js +2395 -135
  48. package/src/schema/schema.js +1054 -94
  49. package/src/typecheck.js +1610 -127
  50. package/src/types.js +90 -6
  51. package/src/grammar/lunar.rip +0 -2412
  52. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  53. /package/docs/demo/{components → routes}/about.rip +0 -0
  54. /package/docs/demo/{components → routes}/card.rip +0 -0
  55. /package/docs/demo/{components → routes}/counter.rip +0 -0
  56. /package/docs/demo/{components → routes}/index.rip +0 -0
  57. /package/docs/demo/{components → routes}/todos.rip +0 -0
package/docs/RIP-APP.md CHANGED
@@ -24,21 +24,39 @@ shaped the way it is, and how to use it well.
24
24
 
25
25
  # Contents
26
26
 
27
- 1. [The four-layer architecture](#1-the-four-layer-architecture)
28
- 2. [Quick start — the 30-second wow](#2-quick-start)
29
- 3. [The subsystems](#3-the-subsystems)
30
- - [Stash](#stash)
31
- - [createResource](#createresource)
32
- - [Timing helpers](#timing-helpers)
33
- - [Components store](#components-store)
34
- - [createRouter](#createrouter)
35
- - [createRenderer](#createrenderer)
36
- - [launch](#launch)
37
- - [ARIA helpers](#aria-helpers)
38
- 4. [Lifecycle invariants — what fires when, what owns what](#4-lifecycle-invariants)
39
- 5. [Async effects — `getEffectSignal` and cancellation](#5-async-effects)
40
- 6. [Gotchas — things that have bitten us before](#6-gotchas)
41
- 7. [When NOT to use Rip App](#7-when-not-to-use)
27
+ - [Rip App — Application Framework](#rip-app--application-framework)
28
+ - [Contents](#contents)
29
+ - [1. The four-layer architecture](#1-the-four-layer-architecture)
30
+ - [2. Quick start](#2-quick-start)
31
+ - [Real apps: bundles + file-based routing](#real-apps-bundles--file-based-routing)
32
+ - [3. The subsystems](#3-the-subsystems)
33
+ - [Stash](#stash)
34
+ - [Sources and render gates](#sources-and-render-gates)
35
+ - [createMutation](#createmutation)
36
+ - [createResource](#createresource)
37
+ - [Timing helpers](#timing-helpers)
38
+ - [Components store](#components-store)
39
+ - [createRouter](#createrouter)
40
+ - [createRenderer](#createrenderer)
41
+ - [launch](#launch)
42
+ - [ARIA helpers](#aria-helpers)
43
+ - [4. Lifecycle invariants](#4-lifecycle-invariants)
44
+ - [Component lifecycle order](#component-lifecycle-order)
45
+ - [User hooks](#user-hooks)
46
+ - [Effect ownership](#effect-ownership)
47
+ - [Effect cleanup-on-rerun](#effect-cleanup-on-rerun)
48
+ - [Parent chain (for context)](#parent-chain-for-context)
49
+ - [Layout and page parentage](#layout-and-page-parentage)
50
+ - [Factory blocks (for/if in render)](#factory-blocks-forif-in-render)
51
+ - [Keyed list reconciliation](#keyed-list-reconciliation)
52
+ - [5. Async effects](#5-async-effects)
53
+ - [6. Gotchas](#6-gotchas)
54
+ - [The bundle boundary matters](#the-bundle-boundary-matters)
55
+ - [Render-template name shadowing (fixed)](#render-template-name-shadowing-fixed)
56
+ - [Nested `for` loops can both name `i` (fixed)](#nested-for-loops-can-both-name-i-fixed)
57
+ - [Snapshot tests are brittle](#snapshot-tests-are-brittle)
58
+ - [No browser e2e tests](#no-browser-e2e-tests)
59
+ - [7. When NOT to use Rip App](#7-when-not-to-use-rip-app)
42
60
 
43
61
  ---
44
62
 
@@ -185,7 +203,7 @@ A deep reactive proxy with path navigation. Single-app state,
185
203
  JSON-persistable, fine-grained signal subscription per key.
186
204
 
187
205
  ```rip
188
- app = stash
206
+ app = createStash
189
207
  user:
190
208
  name: "Alice"
191
209
  prefs:
@@ -205,7 +223,7 @@ app.keys 'user' # → ['name', 'prefs']
205
223
  app.join 'user', email: "..." # shallow merge
206
224
 
207
225
  # Use raw object underneath
208
- plain = raw(app) # back to a plain JS object
226
+ plain = unwrapStash(app) # back to a plain JS object
209
227
  ```
210
228
 
211
229
  **Single-stash policy**: Rip App assumes one stash per page (the one
@@ -214,10 +232,97 @@ beforeunload-flush mechanism both rely on this. Apps needing
214
232
  isolated state silos should use plain `__state(...)` signals or
215
233
  namespace under different keys on `app.data`.
216
234
 
235
+ ### Sources and render gates
236
+
237
+ Server data lives in the stash as **source** keys — cells that know how
238
+ to load themselves. `app/stash.rip` becomes the app's data manifest:
239
+
240
+ ```rip
241
+ import { source } from '@rip-lang/app'
242
+
243
+ export stash =
244
+ cart: { items: [] } # plain client state
245
+ user: source { fetch: -> User.parse(api.get!('user').json!()) }
246
+ products: source # five minutes of freshness is plenty
247
+ fetch: -> Product.array.parse(api.get!('products').json!()) # validated Product[]
248
+ staleTime: '5 min'
249
+ order: source { fetch: (id:: string) -> Order.parse(api.get!("orders/#{id}").json!()) }
250
+ ```
251
+
252
+ A source is **lazy** (loads on first touch, not at startup) and **not a
253
+ promise** — it owns its in-flight load internally; its value is part of
254
+ the stash's reactive fabric like any other key:
255
+
256
+ - **Reading is reading.** An ungated read (`@app.data.user`) is honest —
257
+ `User | null` — and kicks the load without blocking; the null branch is
258
+ the skeleton branch. A gated binding (`user <~ @app.data.user` in a
259
+ route or layout) is loaded before the component constructs and types
260
+ non-null.
261
+ - **Writing is assigning.** `@app.data.user = u` writes through to the
262
+ cell; every consumer updates in place. A write bumps the cell's
263
+ generation and aborts any in-flight load — an old fetch can never
264
+ clobber a newer write. `@app.data.user = null` means *invalidate*.
265
+ - **Keyed families.** A `fetch` with a required first param is one cell
266
+ per key: `@app.data.order(id)` is an ungated keyed read; a route gates
267
+ with `order <~ @app.data.order(params.id)`. Entries live under an LRU
268
+ cap and revalidate like singletons.
269
+ - **Freshness.** `staleTime` (ms, a duration string like `'5 min'`, or
270
+ `'forever'`) controls revalidation. The default `0` is
271
+ stale-while-revalidate: every gate serves the cached value instantly
272
+ and refetches in the background. A gate only ever *blocks* on unloaded
273
+ cells.
274
+ - **Errors are never cached.** A failed initial load returns the cell to
275
+ unloaded (the next gate retries) and rejects the navigation into the
276
+ nearest `onError` boundary as `{ status, message, error, path }`. A
277
+ failed refetch keeps the last-good value and records the error on the
278
+ handle — mounted gates are never yanked (the live-binding invariant).
279
+ - **The handle.** `app.data.source(path, key?)` returns reactive
280
+ `{ value, loading, error, refetch(), reset() }` for screens that render
281
+ *through* loading/error instead of gating.
282
+ - **One reset.** `app.data.reset()` restores plain keys to their declared
283
+ defaults, unloads every source (aborting in-flight loads, clearing
284
+ keyed caches), and purges the persisted snapshot — signout in one call.
285
+ - **Serialization is a projection.** `persistStash` persists plain keys
286
+ and skips source keys (server data is refetchable; the gate reloads on
287
+ restore). `app.data.peek(path)` is the non-triggering read for code
288
+ *about* the stash (serializers, devtools).
289
+ - **Preloading.** Off by default; opt in with `serve({ preload: 'intent' })`
290
+ in the root entry. Once on, resting the pointer on (or focusing) a
291
+ router-owned link for ~50ms warms the gates the destination would *newly*
292
+ mount — a layout already mounted under the current route is skipped, so
293
+ hovering siblings never re-fetches shared shell data. Navigation joins
294
+ loads already in flight (one in-flight load per source). Sweeping past a
295
+ link cancels before it fires, and preload failures never surface — the
296
+ cell records them and navigation's own gate retries.
297
+
298
+ Testing gated components needs no mocks: a write marks a cell loaded, so
299
+ seed and construct — `app.data.user = fixtureUser`, then `new Profile(...)`.
300
+
301
+ ### createMutation
302
+
303
+ The write-side primitive. Reads are per-key (`source`); writes are
304
+ per-action — a mutation wraps the action and exposes reactive
305
+ `pending` / `succeeded` / `error`. Invoke it directly with the payload
306
+ (it *is* the action; there is no `.mutate`):
307
+
308
+ ```rip
309
+ updateUser = createMutation ((data) -> User.parse(api.patch!('user', { json: data }).json!())),
310
+ onSuccess: (u) => @app.data.user = u # write-back — every reader updates, no refetch
311
+ onError: (e) => errors = parseApiError!(e)
312
+
313
+ submit: (e) -> e.preventDefault(); updateUser(form)
314
+ # render: the submit button reads updateUser.pending; banners read succeeded/error
315
+ ```
316
+
317
+ Callbacks (and a source's `fetch:`) are plain options on a runtime call —
318
+ nothing binds their `this`. Use `=>` for anything touching component
319
+ members.
320
+
217
321
  ### createResource
218
322
 
219
- Async data with race protection, abort support, and reactive loading
220
- state.
323
+ The standalone packaging of the same async cell, for **instance-local**
324
+ async (a search box's per-instance results are component state, not app
325
+ data). Same race protection, abort support, and reactive loading state.
221
326
 
222
327
  ```rip
223
328
  user = createResource ->
@@ -291,6 +396,7 @@ router = createRouter components,
291
396
 
292
397
  router.push '/users/42?tab=settings'
293
398
  router.replace '/login'
399
+ router.push '/cart', noScroll: true # don't reset scroll on this nav
294
400
  router.back()
295
401
  router.forward()
296
402
 
@@ -316,6 +422,65 @@ The renderer uses `router.current` to drive its mount effect. Each
316
422
  field is also a separate signal so subscribers can track only what
317
423
  they care about.
318
424
 
425
+ #### Anchor opt-outs and active-link styling
426
+
427
+ The router intercepts plain `<a>` clicks at the document level. Two
428
+ per-anchor attributes adjust that behavior:
429
+
430
+ | Attribute | Effect |
431
+ | ----------------------- | ------------------------------------------------------------------------------------- |
432
+ | `data-router-ignore` | Skip SPA interception entirely. The browser performs a full navigation. |
433
+ | `data-router-noscroll` | Take the SPA navigation, but don't reset scroll to `(0, 0)`. |
434
+
435
+ Anchors with `target="_blank"`, `[download]`, cross-origin hrefs, or
436
+ hrefs outside `base` are also skipped automatically.
437
+
438
+ **Active-link highlighting.** On every navigation the router walks
439
+ in-document anchors and sets `aria-current` on those that match the
440
+ current path:
441
+
442
+ - exact match → `aria-current="page"`
443
+ - prefix match (`/blog` on `/blog/123`) → `aria-current="true"`
444
+ - otherwise → attribute removed (only if the router set it)
445
+
446
+ Style it with attribute selectors — no per-link boilerplate needed:
447
+
448
+ ```css
449
+ nav a[aria-current="page"] { color: red; font-weight: bold; }
450
+ nav a[aria-current="true"] { color: red; }
451
+ ```
452
+
453
+ Setting `aria-current` manually on an anchor wins — the router only
454
+ touches values it set itself.
455
+
456
+ **Scroll restoration.** New navigations (`push` or a link click)
457
+ reset scroll to `(0, 0)`. Back/forward (`popstate`) restores the
458
+ scroll position the page had when you left it. Same-document fragment
459
+ links (`#section`) defer to the browser.
460
+
461
+ **Typed routes (compile-time).** In a typed project (one with
462
+ `rip.strict: true` or `::` annotations), `rip check` synthesizes a
463
+ `__RipRoutes` union from the file tree under `app/routes/` and
464
+ threads it through three places:
465
+
466
+ | Place | Type | Catches |
467
+ | ---------------------------------- | --------------------------------------------------------------------- | ---------------------------------- |
468
+ | `<a href: "...">` in render blocks | `__RipRoutes` for `/`-prefixed literals; any string otherwise | Typos in known routes |
469
+ | `router.push url, opts?` | `__RipRoutes` (replaces base `string`) | Typos in programmatic navigation |
470
+ | `@params` in `routes/[id].rip` | `{ id: string }` (replaces `Record<string, string>`) | Typos like `@params.bogus` |
471
+
472
+ Anchor `href` uses a `const`-generic conditional: a literal starting
473
+ with `/` must satisfy `__RipRoutes`, while external schemes
474
+ (`https://`, `mailto:`, `tel:`), fragments (`#anchor`), and dynamic
475
+ `string` values fall through unchecked. Typos like `<a href: "/crat">`
476
+ produce a single-line error naming the valid routes.
477
+ `router.replace` is deliberately left at `string` — it's commonly
478
+ used to mutate the current URL with query strings, where the built
479
+ value can't satisfy a literal-route union. Catch-all routes
480
+ (`[...rest].rip`) are excluded from `__RipRoutes` — they're runtime
481
+ 404 fallbacks, not navigation targets, so including them as
482
+ `/${string}` would defeat typo-catching for every other route.
483
+
319
484
  ### createRenderer
320
485
 
321
486
  The render loop. Subscribes to `router.current`, mounts/unmounts
@@ -422,6 +587,8 @@ mount(target) ← only the renderer calls this directly
422
587
  Per-child push wrap: each child's _create runs with
423
588
  child as current, so the child's reactive bindings
424
589
  register on child._disposers, not parent's.
590
+ beforeMount() ← user hook; signals/state ready, DOM not yet in tree
591
+ effects created here auto-register on this component
425
592
  _setup() ← post-creation effects (rare; most go in _init)
426
593
  mounted() ← user hook; DOM is in the tree now
427
594
  __popComponent
@@ -436,6 +603,20 @@ unmount({ removeDOM = true }) ← idempotent (_unmounted flag short-circuits se
436
603
  DOM removal (if requested)
437
604
  ```
438
605
 
606
+ ### User hooks
607
+
608
+ The framework recognizes these hook names on any component. All are
609
+ optional; the runtime calls each only if defined.
610
+
611
+ | Hook | When it fires | Notes |
612
+ | --------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- |
613
+ | `beforeMount` | After `_create`, before DOM is attached | Effects created here auto-register on the component |
614
+ | `mounted` | After DOM attached | Runs once per visit |
615
+ | `beforeUnmount` | Before children unmount and disposers fire | Signals/effects still live |
616
+ | `unmounted` | After disposers fire and DOM is removed | Final notification; runs once per visit |
617
+ | `onError` | A throw escapes any component method (render, hook, event) | Receives `{ status?, message?, error?, path? }`; the renderer walks the layout chain to find the nearest defining component |
618
+
619
+
439
620
  ### Effect ownership
440
621
 
441
622
  Every `__effect(fn)` call automatically registers its disposer with
@@ -31,6 +31,7 @@ you're in the right place.
31
31
  6. [When you genuinely need to mutate a referenced indexed column](#6-when-you-genuinely-need-to-mutate)
32
32
  7. [Escape hatches](#7-escape-hatches)
33
33
  8. [Should I use DuckDB for OLTP at all?](#8-should-i-use-duckdb-for-oltp-at-all)
34
+ 9. [Transactions, sequences, and migration edges](#9-transactions-sequences-and-migration-edges)
34
35
 
35
36
  ---
36
37
 
@@ -184,7 +185,7 @@ was originally documented from:
184
185
  ```coffee
185
186
  Partner = schema :model
186
187
  name! string
187
- slug!# string
188
+ slug! string @unique
188
189
 
189
190
  Patient = schema :model
190
191
  mrn? string
@@ -461,12 +462,74 @@ DuckDB is a fine fit when:
461
462
  - You want analytics queries to live in the same engine as the
462
463
  transactional data
463
464
 
465
+ ### Turning a mutation-shaped workload into an append-shaped one
466
+
467
+ A workload that looks write-heavy on paper is often append-heavy in
468
+ disguise. The standard move — shared by change feeds, event logs,
469
+ audit trails, and sync protocols (Replicache / Zero-style) — is an
470
+ **append-only log with a monotonic version column** beside the entity
471
+ tables: a write appends a new row instead of mutating an indexed
472
+ parent in place, and readers pull `WHERE version > :cursor`. This
473
+ plays directly to DuckDB's strengths (append-heavy writes, fast
474
+ monotonic range scans) and routes around the one real weak spot from
475
+ §1 — in-place updates to indexed / FK rows, which an append never
476
+ performs. The trade is the usual one for log-structured designs: the
477
+ log grows and wants periodic compaction (prune history below the
478
+ lowest live reader cursor, `CHECKPOINT` to flush), and "current state"
479
+ becomes a view or materialization over the log rather than the rows
480
+ themselves. When a write-heavy feature can be expressed this way, the
481
+ "poor fit" case above (routinely mutating indexed identifiers) often
482
+ stops applying — and harbor's single-port `/sql` with NDJSON streaming
483
+ is a natural transport for serving the resulting feed.
484
+
464
485
  For a project where you're not sure: prototype with DuckDB, watch
465
486
  the FK behavior, and switch to PostgreSQL or SQLite if you find
466
487
  yourself reaching for the escape hatches more than once or twice.
467
488
 
468
489
  ---
469
490
 
491
+ ## 9. Transactions, sequences, and migration edges
492
+
493
+ Findings from running the Rip Schema transaction and migration
494
+ machinery against live DuckDB (via duckdb-harbor) — each one shapes
495
+ runtime or differ behavior:
496
+
497
+ **FK-referenced tables are frozen for DDL too.** The §1 rule isn't
498
+ just about UPDATE/DELETE — most `ALTER TABLE` operations (add column,
499
+ drop column, type changes) on a table referenced by another table's FK
500
+ fail with `Dependency Error: Cannot alter entry "users" because there
501
+ are entries that depend on it`. Changing such a table means recreating
502
+ the referencing tables around it. The migration differ classifies
503
+ these steps as **`blocked`** in `rip schema status` and refuses to
504
+ write them into a migration file — the rebuild is a human decision.
505
+ Unreferenced tables alter normally.
506
+
507
+ **No `SAVEPOINT`.** DuckDB supports flat transactions only. A nested
508
+ `schema.transaction!` therefore *joins* the outer transaction rather
509
+ than creating an independently-rollbackable unit — one rollback undoes
510
+ the whole nest. Don't design flows that need partial rollback.
511
+
512
+ **Sequences are non-transactional.** `nextval()` consumed inside a
513
+ rolled-back transaction is not returned — a failed `create!` leaves a
514
+ gap in the `id` sequence. Gaps are normal and harmless; never write
515
+ code that assumes ids are contiguous, and never predict "the next id"
516
+ from the last one you saw.
517
+
518
+ **Harbor sessions work in every auth mode.** Transactions ride
519
+ duckdb-harbor's session protocol (`POST /sql/sessions/new`, then
520
+ per-statement `session_id`). Own-session lifecycle is scoped as
521
+ `__HARBOR_SELF__:sessions:create` / `:delete` — allowed by default for
522
+ any caller, including unauthenticated local-dev mode
523
+ (`harbor_serve(..., token := NULL)`), where sessions are owned by the
524
+ synthetic `harbor.local-dev` principal. A 403 from
525
+ `schema.transaction!` means a custom `harbor_authorization_function`
526
+ explicitly denies the `__HARBOR_SELF__:sessions:` scope — add a branch
527
+ matching it. (Earlier harbor versions misfiled session creation as an
528
+ admin action; that required `RIP_DB_TOKEN` plus an admin grant and is
529
+ the reason older notes here said transactions need a token.)
530
+
531
+ ---
532
+
470
533
  ## See also
471
534
 
472
535
  - [`docs/RIP-SCHEMA.md`](./RIP-SCHEMA.md) — the schema/ORM documentation,
package/docs/RIP-INTRO.md CHANGED
@@ -78,7 +78,7 @@ Read as families, not atoms:
78
78
  |---|---|---|
79
79
  | Existence / safety | `x?` · `x ?? y` · `a?.b` · `a?.[0]` · `a?.()` · `a?[0]` · `a?(x)` · `el?.prop = v` · `?!` (presence / Houdini) · `?? throw` | Nothing-safe access and guards |
80
80
  | Dammit / await | `fetch! url` · `user.save!` · `User.find! 1` | One glyph: "call it and await" |
81
- | Void / required | `def process!` (suppresses implicit return) · `name! string` (required field) · `email!#` (required + unique) | Same `!` glyph, context-disambiguated |
81
+ | Void / required | `def process!` (suppresses implicit return) · `name! string` (required field) · `email! email @unique` (required + unique) | Same `!` glyph, context-disambiguated |
82
82
  | Math | `//` floor div · `%%` true mod · `1 < x < 10` chained compare · `arr[-1]` negative index · `"-" * 40` string repeat | Math you can read |
83
83
  | Regex | `str =~ /re/` with `_[1]` captures · `str[/re/, 1]` · `///...///` heregex | Pattern matching as an expression |
84
84
  | Assignment sugar | `.=` method-assign (`x .= trim()`) · `?.=` optional-chain assign · `*>obj = {a:1}` merge-assign | "Mutate this thing" |
@@ -188,7 +188,7 @@ Status = schema
188
188
  # DB-backed model
189
189
  User = schema :model
190
190
  name! string
191
- email!# email
191
+ email! email @unique
192
192
  role? "admin" | "user"
193
193
  @timestamps
194
194
  @has_many Order
@@ -363,7 +363,7 @@ A close second is **non-reactive reads due to aliasing / stash access patterns**
363
363
  | `fetch!` | dammit — call + await |
364
364
  | `def fn!` | void — suppress implicit return |
365
365
  | `name! string` | required field (in `schema` body) |
366
- | `email!#` | required + unique (in `schema :model` body) |
366
+ | `email! email @unique` | required + unique (in `schema :model` body) |
367
367
  | `MAX =! 100` | readonly const |
368
368
 
369
369
  ### The `?` family
@@ -383,7 +383,7 @@ A close second is **non-reactive reads due to aliasing / stash access patterns**
383
383
  | Line form | Example |
384
384
  |---|---|
385
385
  | Field (type implicit string) | `name! 1..50` |
386
- | Field + modifiers | `email!# email` (required + unique) |
386
+ | Field + modifiers | `email! email @unique` (required + unique) |
387
387
  | Field + range | `password! 8..100` |
388
388
  | Field + literal union | `sex? "M" \| "F" \| "U"` |
389
389
  | Inline field transform | `email!, -> it.email.toLowerCase()` |
package/docs/RIP-LANG.md CHANGED
@@ -346,12 +346,12 @@ Multiple lines
346
346
  | `[-n]` | Negative index | `arr[-1]` | `arr.at(-1)` |
347
347
  | `*` | String repeat | `"-" * 40` | `"-".repeat(40)` |
348
348
  | `<` `<=` | Chained comparison | `1 < x < 10` | `(1 < x) && (x < 10)` |
349
- | `\|>` | Pipe | `x \|> fn` or `x \|> fn(y)` | `fn(x)` or `fn(x, y)` |
350
349
  | `.=` | Method assign | `x .= trim()` | `x = x.trim()` |
351
350
  | `*>` | Merge assign | `*>obj = {a: 1}` | `Object.assign(obj, {a: 1})` |
352
351
  | `not in` | Not in | `x not in arr` | Negated membership test |
353
352
  | `not of` | Not of | `k not of obj` | Negated key existence |
354
353
  | `<=>` | Two-way bind | `value <=> name` | Bidirectional reactive binding (render blocks) |
354
+ | `<~` | Render-ready | `user <~ @app.data.user` | Load-before-render binding (component bodies) |
355
355
  | `*{ }` | Map literal | `*{/pat/: val}` | `new Map([[/pat/, val]])` |
356
356
  | `:name` | Symbol literal | `:redo` | `Symbol.for("redo")` — Ruby-style interned symbol |
357
357
 
@@ -700,32 +700,6 @@ arr[i] # → arr[i] — variable index
700
700
 
701
701
  Only literal negative numbers trigger the `.at()` transform. Variable indexes pass through as-is.
702
702
 
703
- ## Pipe Operator (`|>`)
704
-
705
- Pipes a value into a function as its first argument. Chains left-to-right:
706
-
707
- ```coffee
708
- # Simple reference — value becomes the sole argument
709
- 5 |> double # → double(5)
710
- 10 |> Math.sqrt # → Math.sqrt(10)
711
- "hello" |> console.log # → console.log("hello")
712
-
713
- # Multi-arg — value is inserted as the FIRST argument
714
- 5 |> add(3) # → add(5, 3)
715
- data |> filter(isActive) # → filter(data, isActive)
716
- "World" |> greet("!") # → greet("World", "!")
717
-
718
- # Chaining — reads left-to-right like a pipeline
719
- 5 |> double |> add(1) |> console.log
720
- # → console.log(add(double(5), 1))
721
-
722
- # Works with dotted methods
723
- users |> Array.from # → Array.from(users)
724
- data |> JSON.stringify(null, 2) # → JSON.stringify(data, null, 2)
725
- ```
726
-
727
- This is the **Elixir-style** pipe — strictly better than F#'s (which only supports bare function references) and cleaner than Hack's (which requires a `%` placeholder). No special syntax to learn; if the right side is a call, the left value goes first.
728
-
729
703
  ---
730
704
 
731
705
  # 4. Functions
@@ -907,6 +881,7 @@ Rip's reactive features are **language-level operators**, not library imports.
907
881
  | `:=` | State | "gets state" | Reactive state variable |
908
882
  | `~=` | Computed | "always equals" | Computed value (auto-updates) |
909
883
  | `~>` | Effect | "always calls" | Side effect on dependency change |
884
+ | `<~` | Render-ready | "loads from" | Server-backed state, loaded before render (component bodies) |
910
885
  | `=!` | Readonly | "equals, dammit!" | Constant (`const`) |
911
886
 
912
887
  ## Reactive Behavior
@@ -968,6 +943,35 @@ ticker ~>
968
943
  -> clearInterval interval # Cleanup function
969
944
  ```
970
945
 
946
+ ## Render-Ready State (`<~`)
947
+
948
+ The fourth creation form completes the reactivity grid: `<~` binds a
949
+ component member to a server-backed stash key (a `source` — see the
950
+ App framework docs) and declares that the key must be **loaded before
951
+ the component renders**. The binding is therefore non-null — no `if user`
952
+ guards, no loading flags:
953
+
954
+ ```coffee
955
+ export Profile = component
956
+ user <~ @app.data.user # loaded before render → non-null
957
+ form := { ...user } # synchronous — the value is present
958
+ order <~ @app.data.order(params.id) # keyed source: one cell per id
959
+ theme <~ @app.data.settings.theme # subpath: loads the nearest source
960
+ ```
961
+
962
+ Rules, all enforced at compile time or deterministically at mount:
963
+
964
+ - `<~` is only valid at the top of a **component body**, and only in
965
+ routes and layouts (a reusable child takes gated values as props).
966
+ - The right-hand side must be a literal `@app.data.…` path — the compiler
967
+ hoists it into a static gate-set the renderer reads before construction.
968
+ - A keyed gate's key expression may only reference `params` / `query`.
969
+ - The path must resolve to a `source` key — gating a plain key is an error.
970
+
971
+ An **ungated** read of the same key (`user ~= @app.data.user`) is the
972
+ progressive-rendering form: it types as `T | null`, kicks the load without
973
+ blocking, and the null branch is the skeleton branch.
974
+
971
975
  ## Auto-Unwrapping
972
976
 
973
977
  Reactive variables automatically unwrap in most contexts:
@@ -1593,12 +1597,11 @@ App = component
1593
1597
 
1594
1598
  ```coffee
1595
1599
  App = component
1596
- beforeMount = -> p "about to mount"
1597
- mounted = -> p "mounted"
1598
- updated = -> p "updated"
1600
+ beforeMount = -> p "about to mount"
1601
+ mounted = -> p "mounted"
1599
1602
  beforeUnmount = -> p "about to unmount"
1600
- unmounted = -> p "unmounted"
1601
- onError = (err, comp) -> p "caught: #{err.message}"
1603
+ unmounted = -> p "unmounted"
1604
+ onError = (err) -> p "caught: #{err.message}"
1602
1605
  ```
1603
1606
 
1604
1607
  **Effects:**
@@ -1880,7 +1883,7 @@ Status = schema
1880
1883
  # DB-backed model with ORM and migrations
1881
1884
  User = schema :model
1882
1885
  name! string
1883
- email!# email
1886
+ email! email @unique
1884
1887
  @timestamps
1885
1888
  @has_many Order
1886
1889
  beforeValidation: -> @email = @email.toLowerCase()
@@ -2369,11 +2372,6 @@ Each would need design discussion before building.
2369
2372
  `user ~>? fetch!("/api/users/#{id}").json!` gives `user.loading`,
2370
2373
  `user.error`, `user.data`. Park until real-world usage shows demand.
2371
2374
 
2372
- - **Pipe operator (`|>`) — Hack-style placeholder** — Currently Rip uses
2373
- Elixir-style first-arg insertion. A `%` placeholder for arbitrary position
2374
- (`data |> fn(1, %, 3)`) could be added later if needed. Current design
2375
- covers 95%+ of cases.
2376
-
2377
2375
  ---
2378
2376
 
2379
2377
  ## Resources