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.
- package/README.md +2 -3
- package/bin/rip +39 -8
- package/bin/rip-schema +175 -0
- package/docs/RIP-APP.md +91 -2
- package/docs/RIP-DUCKDB.md +64 -1
- package/docs/RIP-INTRO.md +4 -4
- package/docs/RIP-LANG.md +32 -33
- package/docs/RIP-SCHEMA.md +1204 -364
- package/docs/dist/rip.js +3245 -611
- package/docs/dist/rip.min.js +1161 -289
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/index.html +2 -1
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
- package/docs/index.html +1 -1
- package/docs/ui/hljs-rip.js +1 -1
- package/package.json +7 -4
- package/src/AGENTS.md +39 -8
- package/src/compiler.js +220 -36
- package/src/components.js +315 -14
- package/src/dts.js +18 -3
- package/src/grammar/README.md +29 -170
- package/src/grammar/grammar.rip +17 -12
- package/src/grammar/solar.rip +4 -17
- package/src/lexer.js +24 -17
- package/src/parser.js +229 -229
- package/src/schema/dts.js +328 -54
- package/src/schema/loader-server.js +2 -1
- package/src/schema/runtime-browser-stubs.js +20 -9
- package/src/schema/runtime-ddl.js +161 -44
- package/src/schema/runtime-migrate.js +681 -0
- package/src/schema/runtime-orm.js +698 -54
- package/src/schema/runtime-validate.js +808 -24
- package/src/schema/runtime.generated.js +2395 -135
- package/src/schema/schema.js +1049 -89
- package/src/typecheck.js +283 -55
- package/src/types.js +5 -1
- 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** — `!`, `//`, `%%`, `=~`,
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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 ->
|
package/docs/RIP-DUCKDB.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|