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/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <a href="https://github.com/shreeve/rip-lang/commits/main"><img src="https://img.shields.io/badge/version-3.16.0-blue.svg" alt="Version"></a>
12
+ <a href="https://github.com/shreeve/rip-lang/commits/main"><img src="https://img.shields.io/badge/version-3.16.1-blue.svg" alt="Version"></a>
13
13
  <a href="#zero-dependencies"><img src="https://img.shields.io/badge/dependencies-ZERO-brightgreen.svg" alt="Dependencies"></a>
14
14
  <a href="#"><img src="https://img.shields.io/badge/tests-1%2C436%2F1%2C436-brightgreen.svg" alt="Tests"></a>
15
15
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License"></a>
@@ -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
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { readFileSync, readdirSync, writeFileSync, existsSync, statSync, unlinkSync } from 'fs';
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
@@ -60,7 +60,12 @@ Options:
60
60
 
61
61
  Subcommands:
62
62
  rip check [dir] Type-check all .rip files in directory
63
- rip check --strict [dir] Require type annotations in all .rip files
63
+ rip check --audit [pkgDir] Audit a typed package's public API surface for 'any' leaks
64
+ rip check --sourcemap [dir] Verify source-map round-trip for every identifier
65
+ (hover/go-to-def integrity). Useful for compiler work.
66
+ rip link [--quiet] After 'bun install', symlink @rip-lang/* deps to
67
+ local source (opt-in, for framework devs;
68
+ undo with 'bun install --force')
64
69
  rip <name> [args] Run rip-<name> (repo bin/, node_modules, or PATH)
65
70
 
66
71
  Configure via rip.json or package.json:
@@ -95,6 +100,121 @@ Shebang support:
95
100
  `);
96
101
  }
97
102
 
103
+ // `rip link` — opt-in override for framework maintainers, layered on top of a
104
+ // normal install. The canonical setup for any rip project is `bun install`
105
+ // (published packages, lockfile, transitive deps); `rip link` then redirects
106
+ // the @rip-lang/* deps to this CLI's own source checkout, by symlinking them
107
+ // into ./node_modules/@rip-lang/* (plus matching .bin shims). The `rip-lang`
108
+ // compiler/toolchain is pointed at source too, so the loader and VS Code LSP
109
+ // load the matching toolchain instead of a shadowing published tarball. Lets
110
+ // you edit framework source live without publishing.
111
+ //
112
+ // Requires `bun install` first — it overrides an existing install, it is not a
113
+ // substitute for one. Reversible: the symlinks live in node_modules (gitignored)
114
+ // and package.json stays clean semver, so `bun install --force` restores the
115
+ // published packages (a plain `bun install` sees the lockfile as satisfied and
116
+ // leaves the symlinks in place). No-op when no source tree is found (e.g. `rip`
117
+ // installed from npm without a dev checkout) — projects then just use installed
118
+ // packages.
119
+ //
120
+ // Source is this CLI's repo root (so it Just Works after `link-global`),
121
+ // overridable via RIP_SRC.
122
+ function ripLink(args) {
123
+ const quiet = args.includes('--quiet');
124
+ const log = (msg) => { if (!quiet) console.log(`[rip] link: ${msg}`); };
125
+
126
+ const rawSrc = process.env.RIP_SRC || join(__dirname, '..');
127
+ if (!existsSync(join(rawSrc, 'packages'))) {
128
+ log(`no rip-lang source at ${rawSrc} — using installed packages (set RIP_SRC to override)`);
129
+ return;
130
+ }
131
+ const src = realpathSync(rawSrc);
132
+
133
+ const cwd = process.cwd();
134
+ const pkgPath = join(cwd, 'package.json');
135
+ if (!existsSync(pkgPath)) {
136
+ console.error('[rip] link: no package.json in current directory');
137
+ process.exit(1);
138
+ }
139
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
140
+ const deps = Object.keys(pkg.dependencies || {}).filter((d) => d.startsWith('@rip-lang/'));
141
+ if (deps.length === 0) {
142
+ log('no @rip-lang/* dependencies declared — nothing to link');
143
+ return;
144
+ }
145
+
146
+ const nodeModules = join(cwd, 'node_modules');
147
+ if (!existsSync(nodeModules)) {
148
+ console.error('[rip] link: no node_modules found — run `bun install` first, then `rip link`.');
149
+ process.exit(1);
150
+ }
151
+ const binDir = join(nodeModules, '.bin');
152
+
153
+ const linkTo = (path, target, type = 'dir') => {
154
+ try {
155
+ if (lstatSync(path).isSymbolicLink() && readlinkSync(path) === target) return false;
156
+ rmSync(path, { recursive: true, force: true });
157
+ } catch {}
158
+ mkdirSync(dirname(path), { recursive: true });
159
+ symlinkSync(target, path, type);
160
+ return true;
161
+ };
162
+
163
+ const linked = []; // { dep, bins: [names] } for each resolved package
164
+ let changed = false;
165
+
166
+ for (const dep of deps) {
167
+ const short = dep.slice('@rip-lang/'.length);
168
+ const target = join(src, 'packages', short);
169
+ if (!existsSync(target)) { log(`skip ${dep} — not found at ${target}`); continue; }
170
+ if (linkTo(join(nodeModules, '@rip-lang', short), target)) changed = true;
171
+
172
+ // Re-create .bin shims from the package's own `bin` field so `rip server`
173
+ // and friends resolve source binaries from this project's node_modules.
174
+ let meta = {};
175
+ try { meta = JSON.parse(readFileSync(join(target, 'package.json'), 'utf-8')); } catch {}
176
+ const binMap = typeof meta.bin === 'string' ? { [short]: meta.bin } : (meta.bin || {});
177
+ const bins = [];
178
+ for (const [name, rel] of Object.entries(binMap)) {
179
+ const binTarget = join(target, rel);
180
+ if (!existsSync(binTarget)) continue;
181
+ if (linkTo(join(binDir, name), binTarget, 'file')) changed = true;
182
+ bins.push(name);
183
+ }
184
+ linked.push({ dep, bins });
185
+ }
186
+
187
+ // The compiler/toolchain itself. Every @rip-lang/* package depends on it,
188
+ // and the loader + VS Code LSP resolve it from the project's node_modules —
189
+ // so it must point at source too, otherwise a published `rip-lang` tarball
190
+ // shadows it (e.g. the LSP loading a typecheck.js without the latest API).
191
+ if (existsSync(join(nodeModules, 'rip-lang'))) {
192
+ if (linkTo(join(nodeModules, 'rip-lang'), src)) changed = true;
193
+ linked.push({ dep: 'rip-lang', bins: [], label: 'compiler' });
194
+ }
195
+
196
+ if (quiet || linked.length === 0) {
197
+ if (linked.length === 0) log('no @rip-lang/* packages resolved to source');
198
+ return;
199
+ }
200
+
201
+ const home = process.env.HOME || process.env.USERPROFILE || '';
202
+ const tilde = (p) => home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
203
+ const tty = process.stdout.isTTY;
204
+ const dim = (s) => tty ? `\x1b[2m${s}\x1b[0m` : s;
205
+ const bold = (s) => tty ? `\x1b[1m${s}\x1b[0m` : s;
206
+
207
+ const n = linked.length;
208
+ const verb = changed ? 'linked' : 'already linked';
209
+ console.log(`${bold('rip link')} ${dim('·')} ${verb} ${n} package${n === 1 ? '' : 's'} ${dim('→ ' + tilde(src))}`);
210
+ const width = Math.max(...linked.map((l) => l.dep.length));
211
+ for (const { dep, bins, label } of linked) {
212
+ const tag = label || (bins.length ? `bin: ${bins.join(', ')}` : '');
213
+ const line = tag ? `${dep.padEnd(width)}${dim(` ${tag}`)}` : dep;
214
+ console.log(` ${dim('•')} ${line}`);
215
+ }
216
+ }
217
+
98
218
  async function main() {
99
219
  const args = process.argv.slice(2);
100
220
 
@@ -125,12 +245,21 @@ async function main() {
125
245
  const home = process.env.HOME || process.env.USERPROFILE;
126
246
  const scopeDir = join(home, '.bun', 'install', 'global', 'node_modules', '@rip-lang');
127
247
  if (existsSync(scopeDir) && statSync(scopeDir).isDirectory()) {
128
- console.log();
129
- for (const name of readdirSync(scopeDir).sort()) {
130
- try {
131
- 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) {
132
261
  console.log(` ${pkg.name.padEnd(24)} ${pkg.version}`);
133
- } catch {}
262
+ }
134
263
  }
135
264
  }
136
265
  } catch {}
@@ -140,14 +269,27 @@ async function main() {
140
269
  // --- Built-in subcommands ---
141
270
 
142
271
  if (args[0] === 'check') {
143
- const { runCheck } = await import('../src/typecheck.js');
144
272
  const checkArgs = args.slice(1);
273
+ const VALID_FLAGS = new Set(['--audit', '--sourcemap']);
274
+ const unknown = checkArgs.filter(a => a.startsWith('-') && !VALID_FLAGS.has(a));
275
+ if (unknown.length > 0) {
276
+ console.error(`rip check: unknown flag${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
277
+ console.error(`Valid flags: ${[...VALID_FLAGS].join(', ')}`);
278
+ process.exit(2);
279
+ }
145
280
  const dir = checkArgs.find(a => !a.startsWith('-')) || '.';
146
- const exitCode = await runCheck(dir, {
147
- quiet: checkArgs.includes('-q') || checkArgs.includes('--quiet'),
148
- strict: checkArgs.includes('--strict'),
149
- });
150
- process.exit(exitCode);
281
+ const wantAudit = checkArgs.includes('--audit');
282
+ const wantSourcemap = checkArgs.includes('--sourcemap');
283
+ const { runCheck, runAudit } = await import('../src/typecheck.js');
284
+ const checkCode = await runCheck(dir, { sourceMapAudit: wantSourcemap });
285
+ if (!wantAudit) process.exit(checkCode);
286
+ const auditCode = await runAudit(dir);
287
+ process.exit(checkCode || auditCode);
288
+ }
289
+
290
+ if (args[0] === 'link') {
291
+ ripLink(args.slice(1));
292
+ process.exit(0);
151
293
  }
152
294
 
153
295
  // --- Subcommand dispatch: rip <name> → rip-<name> ---
@@ -158,6 +300,15 @@ async function main() {
158
300
  const name = candidate;
159
301
  const subArgs = args.slice(scriptFileIndex + 1);
160
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
+
161
312
  const repoRoot = getRepoRoot();
162
313
  if (repoRoot) {
163
314
  // 1. Repo package: bin/rip-<name>
@@ -197,13 +348,32 @@ async function main() {
197
348
  }
198
349
  }
199
350
 
200
- // 5. Global PATH: rip-<name>
351
+ // 5. Nearest node_modules/.bin walking up from the cwd. `getRepoRoot`
352
+ // uses `git rev-parse --show-toplevel`, which returns the OUTERMOST git
353
+ // repo — wrong when a project lives in a subdirectory of a larger git
354
+ // repo: step 4 then probes the outer repo's node_modules and misses the
355
+ // project's own declared bins. Resolve the bin the way Node resolves a
356
+ // dependency — nearest node_modules up the tree from the cwd — which is
357
+ // correct for nested projects and standalone consumers alike.
358
+ let nmDir = process.cwd();
359
+ while (true) {
360
+ const nmBin = join(nmDir, 'node_modules', '.bin', `rip-${name}`);
361
+ if (existsSync(nmBin)) {
362
+ const r = spawnSync(nmBin, subArgs, { stdio: 'inherit', env: process.env });
363
+ process.exit(r.status ?? 1);
364
+ }
365
+ const parent = dirname(nmDir);
366
+ if (parent === nmDir) break;
367
+ nmDir = parent;
368
+ }
369
+
370
+ // 6. Global PATH: rip-<name>
201
371
  const pathResult = spawnSync(`rip-${name}`, subArgs, { stdio: 'inherit', env: process.env });
202
372
  if (pathResult.error?.code !== 'ENOENT') {
203
373
  process.exit(pathResult.status ?? 1);
204
374
  }
205
375
 
206
- // 6. Not found
376
+ // 7. Not found
207
377
  console.error(`rip: unknown command '${name}'\n\nRun 'rip --help' for usage.`);
208
378
  process.exit(1);
209
379
  }
@@ -335,8 +505,21 @@ async function main() {
335
505
  }
336
506
 
337
507
  if (showShadow) {
338
- const { compileForCheck } = await import('../src/typecheck.js');
339
- 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 });
340
523
  if (!quiet) console.log(`// == Shadow TypeScript (what \`rip check\` sees) == //\n`);
341
524
  console.log(entry.tsContent);
342
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/AGENTS.md CHANGED
@@ -38,6 +38,6 @@ App.mount()
38
38
  - `demo.html` and `charts.html` — dashboard demos
39
39
  - `sierpinski.html` — CDN demo
40
40
  - `example/index.html` and `results/index.html` — app launchers / examples. `example/index.json` is generated from `docs/demo/` via `bun run bundle:demo` (the source-of-truth lives in `docs/demo/`, the JSON is the deployable artifact).
41
- - `ui/index.html` — widget gallery. `ui/bundle.json` is generated from `packages/ui/browser/components/` via `bun run bundle:ui` (auto-runs as part of `bun run build`). The source-of-truth is the workspace package; the JSON is the deployable artifact. The gallery loads the bundle at boot via `data-src="bundle.json"` and reads view-source text synchronously from the in-memory components store (`window.__RIP__.components.read("components/<id>.rip")`) — no per-component fetches.
41
+ - `ui/index.html` — widget gallery. `ui/bundle.json` is generated from `packages/ui/browser/components/` via `bun run bundle:ui` (auto-runs as part of `bun run build`). The source-of-truth is the workspace package; the JSON is the deployable artifact. The gallery loads the bundle at boot via `data-src="bundle.json"` and reads view-source text synchronously from the in-memory components store (`window.__RIP__.components.read("_pkg/ui/<id>.rip")`) — no per-component fetches.
42
42
 
43
43
  Static demos can be opened via `file://`. The playground and example app require `bun run serve`.