rip-lang 3.16.1 → 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 (40) hide show
  1. package/README.md +2 -3
  2. package/bin/rip +39 -8
  3. package/bin/rip-schema +175 -0
  4. package/docs/RIP-APP.md +91 -2
  5. package/docs/RIP-DUCKDB.md +64 -1
  6. package/docs/RIP-INTRO.md +4 -4
  7. package/docs/RIP-LANG.md +32 -33
  8. package/docs/RIP-SCHEMA.md +1204 -364
  9. package/docs/dist/rip.js +3245 -611
  10. package/docs/dist/rip.min.js +1161 -289
  11. package/docs/dist/rip.min.js.br +0 -0
  12. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  13. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  14. package/docs/extensions/vscode/rip/index.html +2 -1
  15. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  16. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  17. package/docs/index.html +1 -1
  18. package/docs/ui/hljs-rip.js +1 -1
  19. package/package.json +7 -4
  20. package/src/AGENTS.md +39 -8
  21. package/src/compiler.js +220 -36
  22. package/src/components.js +315 -14
  23. package/src/dts.js +18 -3
  24. package/src/grammar/README.md +29 -170
  25. package/src/grammar/grammar.rip +17 -12
  26. package/src/grammar/solar.rip +4 -17
  27. package/src/lexer.js +24 -17
  28. package/src/parser.js +229 -229
  29. package/src/schema/dts.js +328 -54
  30. package/src/schema/loader-server.js +2 -1
  31. package/src/schema/runtime-browser-stubs.js +20 -9
  32. package/src/schema/runtime-ddl.js +161 -44
  33. package/src/schema/runtime-migrate.js +681 -0
  34. package/src/schema/runtime-orm.js +698 -54
  35. package/src/schema/runtime-validate.js +808 -24
  36. package/src/schema/runtime.generated.js +2395 -135
  37. package/src/schema/schema.js +1049 -89
  38. package/src/typecheck.js +283 -55
  39. package/src/types.js +5 -1
  40. package/src/grammar/lunar.rip +0 -2412
package/README.md CHANGED
@@ -38,7 +38,7 @@ get '/users/:id' -> # RESTful API endpoint, comma-less
38
38
 
39
39
  **What makes Rip different:**
40
40
  - **Modern output** — ES2022 with native classes, `?.`, `??`, modules
41
- - **New operators** — `!`, `//`, `%%`, `=~`, `|>`, `.new()`, and more
41
+ - **New operators** — `!`, `//`, `%%`, `=~`, `.new()`, and more
42
42
  - **Reactive operators** — `:=`, `~=`, `~>` as language syntax
43
43
  - **Optional types** — `::` annotations, `type` aliases, `.d.ts` emission
44
44
  - **Zero dependencies** — everything included, even the parser generator
@@ -262,7 +262,6 @@ All use `globalThis` with `??=` — override any by redeclaring locally.
262
262
  | `[-n]` (negative index) | `arr[-1]` | Last element via `.at()` |
263
263
  | `*` (string repeat) | `"-" * 40` | String repeat via `.repeat()` |
264
264
  | `<` `<=` (chained) | `1 < x < 10` | Chained comparisons |
265
- | `\|>` (pipe) | `x \|> fn` or `x \|> fn(y)` | Pipe operator (first-arg insertion) |
266
265
  | `not in` | `x not in arr` | Negated membership test |
267
266
  | `not of` | `k not of obj` | Negated key existence |
268
267
  | `.=` (method assign) | `x .= trim()` | `x = x.trim()` — compound method assignment |
@@ -341,7 +340,7 @@ Status = schema
341
340
  # DB-backed model
342
341
  User = schema :model
343
342
  name! string
344
- email!# email
343
+ email! email @unique
345
344
  @timestamps
346
345
  @has_many Order
347
346
  beforeValidation: -> @email = @email.toLowerCase()
package/bin/rip CHANGED
@@ -3,7 +3,7 @@
3
3
  import { readFileSync, readdirSync, writeFileSync, existsSync, statSync, unlinkSync, lstatSync, readlinkSync, rmSync, mkdirSync, symlinkSync, realpathSync } from 'fs';
4
4
  import { execSync, spawnSync, spawn } from 'child_process';
5
5
  import { fileURLToPath } from 'url';
6
- import { dirname, basename, join } from 'path';
6
+ import { dirname, basename, join, resolve, relative } from 'path';
7
7
  import { Compiler, formatError } from '../src/compiler.js';
8
8
  // Side-effect imports — register CLI-side runtime providers. The browser
9
9
  // bundle imports a different schema loader so server-only fragments
@@ -245,12 +245,21 @@ async function main() {
245
245
  const home = process.env.HOME || process.env.USERPROFILE;
246
246
  const scopeDir = join(home, '.bun', 'install', 'global', 'node_modules', '@rip-lang');
247
247
  if (existsSync(scopeDir) && statSync(scopeDir).isDirectory()) {
248
- console.log();
249
- for (const name of readdirSync(scopeDir).sort()) {
250
- try {
251
- const pkg = JSON.parse(readFileSync(join(scopeDir, name, 'package.json'), 'utf-8'));
248
+ // Sort by the displayed package name (e.g. `@rip-lang/ui`,
249
+ // `vscode-rip`) rather than the on-disk directory entry — most
250
+ // entries match either way, but a package whose `name` field
251
+ // diverges from its directory (the VS Code extension lives in
252
+ // packages/vscode/ but publishes as `vscode-rip`) would
253
+ // otherwise sort by its directory and look misplaced.
254
+ const pkgs = readdirSync(scopeDir)
255
+ .map((name) => { try { return JSON.parse(readFileSync(join(scopeDir, name, 'package.json'), 'utf-8')); } catch { return null; } })
256
+ .filter(Boolean)
257
+ .sort((a, b) => a.name.localeCompare(b.name));
258
+ if (pkgs.length) {
259
+ console.log();
260
+ for (const pkg of pkgs) {
252
261
  console.log(` ${pkg.name.padEnd(24)} ${pkg.version}`);
253
- } catch {}
262
+ }
254
263
  }
255
264
  }
256
265
  } catch {}
@@ -291,6 +300,15 @@ async function main() {
291
300
  const name = candidate;
292
301
  const subArgs = args.slice(scriptFileIndex + 1);
293
302
 
303
+ // 0. Sibling subcommand in this CLI's own bin/ — works from any
304
+ // cwd (getRepoRoot below resolves the CWD's git checkout, which
305
+ // is unrelated when running `rip schema …` inside an app dir).
306
+ const selfSibling = join(__dirname, `rip-${name}`);
307
+ if (existsSync(selfSibling)) {
308
+ const r = spawnSync(selfSibling, subArgs, { stdio: 'inherit', env: process.env });
309
+ process.exit(r.status ?? 1);
310
+ }
311
+
294
312
  const repoRoot = getRepoRoot();
295
313
  if (repoRoot) {
296
314
  // 1. Repo package: bin/rip-<name>
@@ -487,8 +505,21 @@ async function main() {
487
505
  }
488
506
 
489
507
  if (showShadow) {
490
- const { compileForCheck } = await import('../src/typecheck.js');
491
- const entry = compileForCheck(inputFile || '<stdin>', source, new Compiler());
508
+ const { compileForCheck, readProjectConfig, globToRegex } = await import('../src/typecheck.js');
509
+ // Honor the project's `rip` config so the shadow matches what `rip
510
+ // check` actually does. Without `checkAll`, an unannotated file gets a
511
+ // `// @ts-nocheck` it wouldn't get under the project's real settings.
512
+ const config = readProjectConfig(inputFile ? dirname(resolve(inputFile)) : process.cwd());
513
+ const checkAll = config.checkAll === true;
514
+ // An excluded file is skipped entirely — surface that, since the shadow
515
+ // below reflects nocheck logic that never runs for excluded files.
516
+ if (inputFile && config._configDir && Array.isArray(config.exclude)) {
517
+ const rel = relative(config._configDir, resolve(inputFile));
518
+ if (config.exclude.map(globToRegex).some(p => p.test(rel)) && !quiet) {
519
+ console.log(`// NOTE: ${rel} matches rip.exclude — \`rip check\` skips this file entirely.\n`);
520
+ }
521
+ }
522
+ const entry = compileForCheck(inputFile || '<stdin>', source, new Compiler(), { checkAll });
492
523
  if (!quiet) console.log(`// == Shadow TypeScript (what \`rip check\` sees) == //\n`);
493
524
  console.log(entry.tsContent);
494
525
  }
package/bin/rip-schema ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env bun
2
+ //
3
+ // rip-schema — schema evolution CLI (`rip schema …`).
4
+ //
5
+ // rip schema status [entry.rip] [--dir DIR]
6
+ // rip schema plan [entry.rip]
7
+ // rip schema make <name> [entry.rip] [--dir DIR] [--allow-lossy] [--allow-destructive]
8
+ // rip schema migrate [entry.rip] [--dir DIR] [--repair]
9
+ //
10
+ // `entry.rip` is a file whose import registers every :model schema
11
+ // (your models file — it may also call connect() to point the adapter
12
+ // at the database). When omitted, conventional locations are tried.
13
+ //
14
+ // Database connection comes from the entry file's own connect() call,
15
+ // or from RIP_DB_URL / RIP_DB_TOKEN for the default duckdb-harbor
16
+ // adapter.
17
+ //
18
+ // Migration files are plain SQL — numbered, hand-editable, checked into
19
+ // git (default directory: ./migrations). `make` writes them from the
20
+ // model-vs-database diff; `migrate` applies pending ones in order and
21
+ // records (version, name, checksum) in the `_rip_migrations` table.
22
+
23
+ // Registers the .rip module loader AND the full (migration-mode) schema
24
+ // runtime — globalThis.schema gains plan/status/make/migrate/introspect.
25
+ import '../rip-loader.js';
26
+
27
+ import { existsSync } from 'fs';
28
+ import { resolve } from 'path';
29
+
30
+ const ENTRY_CANDIDATES = [
31
+ 'models.rip', 'api/models.rip', 'app/models.rip', 'db/models.rip', 'src/models.rip',
32
+ ];
33
+
34
+ const args = process.argv.slice(2);
35
+ const cmd = args[0];
36
+
37
+ if (!cmd || cmd === '--help' || cmd === '-h') {
38
+ usage(0);
39
+ } else if (!['status', 'plan', 'make', 'migrate'].includes(cmd)) {
40
+ console.error(`rip schema: unknown subcommand '${cmd}'\n`);
41
+ usage(1);
42
+ }
43
+
44
+ const rest = args.slice(1);
45
+ const flags = {
46
+ dir: 'migrations',
47
+ allowLossy: false,
48
+ allowDestructive: false,
49
+ repair: false,
50
+ };
51
+ const positional = [];
52
+ for (let i = 0; i < rest.length; i++) {
53
+ const a = rest[i];
54
+ if (a === '--dir') flags.dir = rest[++i];
55
+ else if (a === '--allow-lossy') flags.allowLossy = true;
56
+ else if (a === '--allow-destructive') flags.allowDestructive = true;
57
+ else if (a === '--repair') flags.repair = true;
58
+ else if (a.startsWith('-')) die(`unknown flag: ${a}`);
59
+ else positional.push(a);
60
+ }
61
+
62
+ // `make` takes a migration name first; every command takes an optional
63
+ // models entry. Disambiguate by file existence: a positional that names
64
+ // an existing file is the entry, anything else (for make) is the name.
65
+ let name = null;
66
+ let entry = null;
67
+ for (const p of positional) {
68
+ if (existsSync(p) && /\.(rip|js|ts)$/.test(p)) entry = p;
69
+ else if (cmd === 'make' && !name) name = p;
70
+ else die(`unexpected argument: ${p} (no such file)`);
71
+ }
72
+ if (cmd === 'make' && !name) die("usage: rip schema make <name> [entry.rip] — a migration name is required");
73
+ if (!entry) entry = ENTRY_CANDIDATES.find(c => existsSync(c)) || null;
74
+ if (!entry) {
75
+ die(
76
+ 'no models entry found. Pass one explicitly (rip schema ' + cmd + ' [name] path/to/models.rip)\n' +
77
+ 'or create one of: ' + ENTRY_CANDIDATES.join(', '));
78
+ }
79
+
80
+ // Importing the entry registers every :model it declares (directly or
81
+ // transitively) and runs any connect() it performs.
82
+ await import(resolve(entry));
83
+
84
+ const schema = globalThis.schema;
85
+ if (!schema || typeof schema.plan !== 'function') {
86
+ die('schema runtime did not load — does the entry file declare or import any schemas?');
87
+ }
88
+
89
+ const color = (c, s) => process.stdout.isTTY ? `\x1b[${c}m${s}\x1b[0m` : s;
90
+ const dim = (s) => color('2', s);
91
+ const CLASS_COLOR = { safe: '32', lossy: '33', destructive: '31', blocked: '31;1' };
92
+ const tag = (cls) => color(CLASS_COLOR[cls] || '0', `[${cls}]`);
93
+
94
+ function printSteps(steps) {
95
+ if (!steps.length) {
96
+ console.log(color('32', '✓ database matches the declared models — no changes'));
97
+ return;
98
+ }
99
+ for (const s of steps) {
100
+ console.log(`${tag(s.class)} ${s.kind} ${s.table}`);
101
+ for (const n of s.notes) console.log(dim(` ${n}`));
102
+ for (const line of s.sql) console.log(` ${line}`);
103
+ }
104
+ const counts = {};
105
+ for (const s of steps) counts[s.class] = (counts[s.class] || 0) + 1;
106
+ console.log('\n' + Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', '));
107
+ }
108
+
109
+ try {
110
+ if (cmd === 'plan') {
111
+ printSteps(await schema.plan());
112
+
113
+ } else if (cmd === 'status') {
114
+ const st = await schema.status({ dir: flags.dir });
115
+ console.log(`applied: ${st.applied.length ? st.applied.map(a => a.version + '_' + a.name).join(', ') : '(none)'}`);
116
+ console.log(`pending: ${st.pending.length ? st.pending.map(f => f.version + '_' + f.name).join(', ') : '(none)'}`);
117
+ if (st.mismatched.length) {
118
+ console.log(color('31', `edited after apply: ${st.mismatched.join(', ')} — restore the files or migrate --repair`));
119
+ }
120
+ console.log('');
121
+ if (st.steps.length && !st.pending.length && (st.applied.length || st.files.length)) {
122
+ console.log(color('33', 'drift: the database differs from the models in ways no pending migration explains'));
123
+ }
124
+ printSteps(st.steps);
125
+
126
+ } else if (cmd === 'make') {
127
+ const out = await schema.make(name, {
128
+ dir: flags.dir,
129
+ allowLossy: flags.allowLossy,
130
+ allowDestructive: flags.allowDestructive,
131
+ });
132
+ if (!out) {
133
+ console.log(color('32', '✓ no changes — nothing to write'));
134
+ } else {
135
+ printSteps(out.steps);
136
+ console.log(`\nwrote ${out.file}`);
137
+ console.log(dim('review the file, then apply with: rip schema migrate'));
138
+ }
139
+
140
+ } else if (cmd === 'migrate') {
141
+ const out = await schema.migrate({ dir: flags.dir, repair: flags.repair });
142
+ if (!out.ran.length) console.log(color('32', '✓ no pending migrations'));
143
+ else for (const r of out.ran) console.log(`applied ${r}`);
144
+ }
145
+ process.exit(0);
146
+ } catch (e) {
147
+ die(e?.message || String(e));
148
+ }
149
+
150
+ function usage(code) {
151
+ console.log(`\
152
+ rip schema — diff declared :model schemas against the database and manage migrations.
153
+
154
+ Usage:
155
+ rip schema status [entry.rip] [--dir DIR] applied / pending / drift + the current plan
156
+ rip schema plan [entry.rip] print the classified diff (no files touched)
157
+ rip schema make <name> [entry.rip] [--dir DIR] write migrations/NNNN_<name>.sql from the diff
158
+ [--allow-lossy] [--allow-destructive]
159
+ rip schema migrate [entry.rip] [--dir DIR] apply pending migration files in order
160
+ [--repair]
161
+
162
+ entry.rip file that declares/imports every :model (default: ${ENTRY_CANDIDATES.join(' | ')})
163
+ --dir DIR migrations directory (default: migrations)
164
+ --allow-lossy include steps that may lose data on existing rows (type changes, SET NOT NULL)
165
+ --allow-destructive include DROP TABLE / DROP COLUMN steps
166
+ --repair re-record checksums for applied migrations whose files changed
167
+
168
+ Connection: the entry's connect() call, or RIP_DB_URL / RIP_DB_TOKEN.`);
169
+ process.exit(code);
170
+ }
171
+
172
+ function die(msg) {
173
+ console.error(`rip schema: ${msg}`);
174
+ process.exit(1);
175
+ }
package/docs/RIP-APP.md CHANGED
@@ -31,6 +31,8 @@ shaped the way it is, and how to use it well.
31
31
  - [Real apps: bundles + file-based routing](#real-apps-bundles--file-based-routing)
32
32
  - [3. The subsystems](#3-the-subsystems)
33
33
  - [Stash](#stash)
34
+ - [Sources and render gates](#sources-and-render-gates)
35
+ - [createMutation](#createmutation)
34
36
  - [createResource](#createresource)
35
37
  - [Timing helpers](#timing-helpers)
36
38
  - [Components store](#components-store)
@@ -230,10 +232,97 @@ beforeunload-flush mechanism both rely on this. Apps needing
230
232
  isolated state silos should use plain `__state(...)` signals or
231
233
  namespace under different keys on `app.data`.
232
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
+
233
321
  ### createResource
234
322
 
235
- Async data with race protection, abort support, and reactive loading
236
- 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.
237
326
 
238
327
  ```rip
239
328
  user = createResource ->
@@ -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:
@@ -1879,7 +1883,7 @@ Status = schema
1879
1883
  # DB-backed model with ORM and migrations
1880
1884
  User = schema :model
1881
1885
  name! string
1882
- email!# email
1886
+ email! email @unique
1883
1887
  @timestamps
1884
1888
  @has_many Order
1885
1889
  beforeValidation: -> @email = @email.toLowerCase()
@@ -2368,11 +2372,6 @@ Each would need design discussion before building.
2368
2372
  `user ~>? fetch!("/api/users/#{id}").json!` gives `user.loading`,
2369
2373
  `user.error`, `user.data`. Park until real-world usage shows demand.
2370
2374
 
2371
- - **Pipe operator (`|>`) — Hack-style placeholder** — Currently Rip uses
2372
- Elixir-style first-arg insertion. A `%` placeholder for arbitrary position
2373
- (`data |> fn(1, %, 3)`) could be added later if needed. Current design
2374
- covers 95%+ of cases.
2375
-
2376
2375
  ---
2377
2376
 
2378
2377
  ## Resources