diff --git a/Cargo.lock b/Cargo.lock index 7bbeb2af..42ccbea7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4414,7 +4414,7 @@ dependencies = [ [[package]] name = "solidb" -version = "0.26.0" +version = "0.26.2" dependencies = [ "anyhow", "argon2", @@ -4442,6 +4442,7 @@ dependencies = [ "hex", "hmac", "hostname", + "hyper-util", "image", "indicatif", "jsonschema", diff --git a/Cargo.toml b/Cargo.toml index ac943ebb..c8a732b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["clients/rust-client", "sdbql-core", "benchmarks"] [package] name = "solidb" -version = "0.26.0" +version = "0.26.2" edition = "2021" default-run = "solidb" description = "A lightweight, high-performance structured database server written in Rust." @@ -19,6 +19,9 @@ tokio = { version = "1.35", features = ["full"] } # HTTP server axum = { version = "0.8.7", features = ["multipart", "ws", "macros"] } +# Drive HTTP connections manually (instead of `axum::serve`) so we can set an +# HTTP/1 header-read timeout — see the multiplexed HTTP server in main.rs. +hyper-util = { version = "0.1", features = ["server-auto", "server-graceful", "service", "tokio", "http1", "http2"] } tower = { version = "0.5.2", features = ["util"] } tower-http = { version = "0.6.8", features = ["trace", "cors", "compression-gzip", "compression-zstd"] } async-stream = "0.3" diff --git a/admin/.env.test b/admin/.env.test new file mode 100644 index 00000000..24fbbcdd --- /dev/null +++ b/admin/.env.test @@ -0,0 +1,22 @@ +# Test environment -- `soli test` reads this instead of .env. +# Use a dedicated database so tests don't touch development data; the test +# runner drops & recreates this database per worker on each run. + +# 127.0.0.1, not localhost: see .env. +SOLIDB_HOST=http://127.0.0.1:6745 +SOLIDB_DATABASE=solidb_admin_test +SOLIDB_USERNAME=admin +SOLIDB_PASSWORD=admin + +# HTTP.request()'s SSRF guard blocks loopback; the specs exercise the real +# local SoliDB API, so allow it (same as .env). +SOLI_DEV_ALLOW_SSRF=1 + +# The test client and the test server are colocated and trusted; the Origin +# check would reject every POST otherwise. +SOLI_DISABLE_CSRF=true + +# .env sets a LAN SOLIDB_PUBLIC_WS_URL for remote browsers; it leaks into the +# test process env. Force it empty so public_ws_url() derives from SOLIDB_HOST +# (blank counts as unset). +SOLIDB_PUBLIC_WS_URL= diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 00000000..e1a5811f --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Build artifacts +/target/ +*.o +*.so + +# Process files +*.pid + +# Logs +*.log +logs/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Coverage +coverage/ +.coverage diff --git a/admin/AGENTS.md b/admin/AGENTS.md new file mode 100644 index 00000000..6bf1e381 --- /dev/null +++ b/admin/AGENTS.md @@ -0,0 +1,5 @@ +# AGENTS.md + +This project's agent guidance lives in `CLAUDE.md` (root) and per-directory `CLAUDE.md` files under `app/`, `tests/`, and `db/migrations/`. They apply to all coding agents — Claude Code, Cursor, Aider, Copilot CLI, Codex CLI, etc. + +Start by reading `CLAUDE.md`. The "For AI agents — read this first" section at the top is the contract: verification loop, generator-first, footguns. diff --git a/admin/CLAUDE.md b/admin/CLAUDE.md new file mode 100644 index 00000000..ac9b9919 --- /dev/null +++ b/admin/CLAUDE.md @@ -0,0 +1,513 @@ +# Soli Lang + +Soli is a dynamically-typed, high-performance web framework written in Rust. This file orients an AI assistant (and future you) to how *this* application is laid out and what the language syntax actually looks like. + +## For AI agents — read this first + +You are working in a Soli MVC app. Soli looks like Ruby/JS but has its own quirks; skim the **Footgun cheatsheet** below before generating code. Per-directory `CLAUDE.md` files in `app/controllers/`, `app/models/`, `app/views/`, `app/middleware/`, `tests/`, and `db/migrations/` give you the local rules — Claude Code loads them automatically when you work in those directories. + +### Verification loop (mandatory before reporting done) + +1. `soli lint ` — naming, smells, undefined-locals. +2. `soli test tests/.sl` — narrow, fast feedback. +3. `soli test --coverage --coverage-min 90.0` — full sweep before handing off. +4. `soli serve . --dev`, then hit the route in a browser if you changed a UI/route — confirm 200 and that the page renders. + +If a step fails, fix the root cause. Don't weaken assertions, lower the coverage bar, or `--no-verify` past hooks. The `/soli-verify` slash command bundles steps 1-3. + +### Reach for generators first + +| Task | Command | +|------------------------------------------|-----------------------------------------------| +| New controller (+ views + spec) | `soli generate controller posts` | +| New model | `soli generate model post` | +| New migration | `soli generate migration create_posts` | +| Full RESTful resource end-to-end | `/soli-resource posts` (slash command) | + +Generators encode the naming, location, and boilerplate the framework expects. Hand-writing diverges and triggers lint failures. + +## Footgun cheatsheet (Soli ≠ Ruby ≠ JS) + +| You'd type… | In Soli it's… | Why | +|--------------------------------------------|--------------------------------------------|------------------------------------------------------------------------------| +| `// comment` | `# comment` | `//` was standardized away — lint flags it. | +| `${name}` / `#{name}` in a string | `\(name)` | Backslash-paren is the only interpolation form. | +| `@"multi\nline"` raw string | `[[multi\nline]]` or `""" ... """` | `@"..."` doesn't exist; `@` is only for `@sdbql{...}` query blocks. | +| `if (x) { … }` | `if x … end` | C-style parses, but Ruby-style is the convention here. | +| `xs.forEach(…)` | `xs.each do \|x\| … end` or `for x in xs` | No `forEach`. | +| `x \|\| default` | `x ?? default` | `\|\|` returns the wrong side when `x` is `0` or `""` (those are TRUTHY). | +| `if (xs.length)` | `if xs.length() > 0` | `0` and `""` are truthy in Soli — only `false` and `null` are falsy. | +| `import "../models/post.sl"` in controller | nothing — already auto-loaded | Triggers `style/redundant-model-import` lint. | +| Building URLs by hand | `posts_path()`, `post_path(post)` | Named helpers come from `resources(...)` in `config/routes.sl`. | +| Overriding `Model.all` / `Model.find` | don't | Inherited from `Model`; the framework relies on it. | +| `if x == nil \|\| x == ""` | `if x.blank?` | `.blank?` covers both nil and empty string in one call. | +| `if x == nil` / `if x != nil` | `if x.nil?` / `unless x.nil?` | `.nil?` reads as the question; reserve `==`/`!=` for value comparisons. | +| `user == nil ? nil : user._key` | `user&._key` | Safe navigation short-circuits to `nil` if the receiver is `nil`. | +| `if s != "a" && s != "b" && s != "c"` | `unless ["a", "b", "c"].includes?(s)` | Intent is membership check, not a pile of `&&`. | +| `x = x \|\| default` | `x \|\|= default` | `\|\|=` is a single operator for "set if nil/false". | + +## Recipes + +### Add a RESTful resource end-to-end + +1. `soli generate model post` → fill fields, validations, associations. +2. `soli generate migration create_posts` → fill `up`/`down`, then `soli db:migrate up`. +3. `soli generate controller posts` → fill `index`/`show`/`create`/etc. +4. In `config/routes.sl` add `resources("posts")`. +5. Edit `app/views/posts/*.html.slv`. +6. Add specs in `tests/posts_controller_spec.sl`. +7. Run the verification loop. + +(Or just: `/soli-resource post` — bundles steps 1-4 and stubs 5-6.) + +### Add an authenticated route + +Wrap the routes in a `middleware("authenticate", -> { … })` block in `config/routes.sl`. The `authenticate` middleware in `app/middleware/auth.sl` is `scope_only`, so unscoped routes are unaffected. + +### Debug a request live + +Run `soli serve . --dev`. The dev bar shows the AQL queries (`dev_queries()`) issued for the request, with bind vars and durations. + +### Add a partial + +- File: `app/views//_name.html.slv` (leading underscore is mandatory). +- Render: `<%- partial("dir/name", { "key": value }) %>` — use `<%-` (raw output), not `<%=`, since the partial returns HTML that must not be re-escaped. +- Inside the partial: read via `key` (or `locals["key"]` if it collides with a builtin/helper). + +## Project Structure + +``` +app/ +├── controllers/ # Request handlers (one class per resource, < Controller) +├── helpers/ # View helper functions +├── middleware/ # Request/response filters (per-file `# order:` directives) +├── models/ # Data models (< Model — ORM is inherited) +└── views/ # ERB-style templates with .html.slv extension +config/ +└── routes.sl # URL routing +db/ +└── migrations/ # Database migrations +public/ # Static assets (CSS/JS compiled into here) +tests/ # *_spec.sl test files +``` + +## Naming Conventions + +| Type | Convention | Example | +|-----------|------------------------|------------------------| +| Files | `snake_case.sl` | `posts_controller.sl` | +| Classes | `PascalCase` | `PostsController` | +| Functions | `snake_case` | `get_user_by_id` | +| Constants | `SCREAMING_SNAKE_CASE` | `MAX_SIZE` | + +**Use intelligible variable names** — no single-letter or cryptic short names. The name should make the intent obvious without scanning back for the assignment. + +```soli +# Bad — what is p? r? pg? qb? +let p = params +let r = users_result(p["q"], p["sort"]) +let pg = r["pagination"] +let qb = User.where(...) + +# Good — read top-to-bottom and the meaning is clear. +let search_query = params["q"] +let sort_column = params["sort"] +let result = users_result(search_query, sort_column) +let pagination = result["pagination"] +let query_builder = User.where(...) +``` + +Short names are only acceptable for true conventions: loop indices (`i`, `j`), block parameters whose role is obvious from context (`fn(x) x * 2`), and well-known math symbols inside their natural domain. + +## Syntax basics + +Soli supports both Ruby-style (`def`/`end`, `class X < Y ... end`, `if cond ... end`) and C-style (`fn`/`{ }`, `class X extends Y { ... }`, `if cond { ... }`); they parse to the same AST. **The convention in this project is Ruby-style** for class declarations and control flow (`class Demo < Test ... end`, `if cond ... end`). Reserve `fn { ... }` for free-standing functions and lambdas. Match this style when writing new code. + +```soli +# Variables +name = "Alice" # `let` is optional — bare assignment creates the binding +let age: Int = 30 # Use `let` when you want a type annotation, or to + # forward-declare before a branch that assigns it +const MAX = 100 # Immutable + +# Prefer the bare `name = value` form. Reach for `let` only when it earns +# its keep: a type annotation, or a hoisted declaration before `if`/`match`. + +# Free-standing functions +fn add(a: Int, b: Int) -> Int { + return a + b; +} + +# Implicit return: the last expression in a block is returned +fn greet(name) { + "Hello, " + name + "!" +} + +# Lambdas +let double = fn(x) { return x * 2; }; +let halve = |x| { return x / 2; }; + +# String interpolation +let msg = "Hi \(name), age \(age)" + +# Multiline / raw strings (NO @"..." — that form does not exist) +let lua_raw = [[ + Raw text. No escape processing. + Good for queries with embedded "double quotes". +]] +let triple = """ + Raw, multiline. Closes on """. + Good for content with ] or ]] inside. +""" +let single_raw = r"C:\Users\name" # raw, single-line only + +# Collection iteration — Ruby-style block, no parens before `do` +[1, 2, 3].map do |x| x * 2 end +[1, 2, 3].filter do |x| x > 2 end + +# Pipelines (when chaining multiple stages) +[1, 2, 3] |> map(fn(x) x * 2) |> filter(fn(x) x > 2) + +# Pattern matching +let label = match value { + 42 => "the answer", + n if n > 0 => "positive", + [first, ...rest] => "head: " + str(first), + _ => "other" +}; + +# Postfix conditionals (idiomatic) +print("adult") if age >= 18 +let data = fetch() rescue null # returns null if fetch() throws + +# Concise defaults and guards +this.balance ||= 0 # ||= sets when nil/false +this.email = this.email.trim().downcase() unless this.email.blank? # .blank? covers nil + "" +unless ["up", "late", "overdue"].includes?(this.status) # membership check + add_error("invalid status") +end +``` + +## Routes (`config/routes.sl`) + +```soli +# Basic routes +get("/", "home#index", name: "root") +get("/about", "pages#about", name: "about") +post("/users", "users#create") + +# RESTful resources — registers index/show/new/create/edit/update/destroy +# plus path/url helpers: posts_path(), post_path(post), new_post_path(), +# edit_post_path(post), and *_url variants. +resources("posts") + +# Scoped middleware — only runs for routes inside the block +middleware("authenticate", -> { + get("/admin", "admin#index") + resources("admin/users") +}) +``` + +Use the named helpers (`posts_path`, `root_path`, etc.) in controllers and views — never concatenate URLs by hand. + +## Controllers + +Controllers are classes that inherit from `Controller`. Action methods take a request hash and return a response. + +```soli +# app/controllers/posts_controller.sl +class PostsController < Controller + static + this.layout = "application" + end + + # GET /posts + def index(req) + let posts = Post.all() + return render("posts/index", { "posts": posts, "title": "Posts" }) + end + + # GET /posts/:id — Model.find raises on miss; framework maps to 404 + def show(req) + let post = Post.find(req.params["id"]) + return render("posts/show", { "post": post }) + end + + # POST /posts + def create(req) + let permitted = this._permit_params(req.params) + let post = Post.create(permitted) + if post._errors + return render("posts/new", { "post": post }) + end + return redirect(post_path(post)) + end + + # Mass-assignment protection — whitelist allowed fields + def _permit_params(params) + return { "title": params["title"], "body": params["body"] } + end +end +``` + +### Request access + +- `req.params["id"]` — route + query + body params merged +- `req["json"]` — parsed JSON body +- `req["headers"]`, `req["cookies"]`, `req["method"]` +- Bare `params` is also available globally inside actions (= `req.params`) + +### Response shapes + +- `render("view/name", {...})` — render `app/views/view/name.html.slv` with the given locals +- `redirect("/path")` or `redirect(post_path(post))` — HTTP redirect +- `{"status": 422, "headers": {...}, "body": "..."}` — raw response + +## Models + +Models inherit from `Model`; CRUD methods come with the inheritance — don't redefine them. + +```soli +# app/models/post.sl +class Post < Model + # Inherited from Model: + # Post.all() Post.find(id) Post.find_by(field, val) + # Post.where({...}) Post.create({...}) post.save() post.delete() + # + # `Post.find(id)` RAISES RecordNotFound on miss — the framework converts + # that to a 404 automatically. Don't add `if post.nil? { 404 }` after it; + # that branch is unreachable. Use `find_by` / `first_by` when you want + # the "or nil" shape instead. + # + # Add associations and validations declaratively: + belongs_to("user") + has_many("comments") + + validates("title", { "presence": true, "min_length": 3 }) + validates("body", { "presence": true }) + + before_save("normalize_title") + + def normalize_title + this.title = this.title.trim() + end +end +``` + +`Model.create(...)` always returns an instance. On validation/database failure, the instance has `_errors` populated — check `if post._errors` and re-render the form. Don't write fake `static` shims around the inherited CRUD. + +### Raw queries (SDBQL) + +Drop down to raw SDBQL only when the ORM doesn't cover the case. **Always parameterize** — never concatenate user input. + +```soli +# `@sdbql{}` block — preferred for multi-line queries. +# `#{expr}` is bound as a parameter, not interpolated as text. +let min_age = 18 +let users = @sdbql{ + FOR u IN users + FILTER u.age >= #{min_age} + SORT u.name ASC + LIMIT 50 + RETURN u +} +``` + +## Views (`.html.slv`) + +```erb +

<%= title %>

+ +<% for post in posts %> +
+

<%= h(post.title) %>

+ <%= post.body %> +
+<% end %> + +<%= link_to("New post", new_post_path()) %> +``` + +Always use `h()` to escape user-supplied content — XSS is the default risk. + +## Middleware + +A middleware file declares one function. Per-file directive comments at the top configure how the framework wires it up: + +```soli +# app/middleware/auth.sl + +# order: 20 +# scope_only: true — only runs when wrapped in `middleware("authenticate", -> { ... })` + +def authenticate(req) + let key = req["headers"]["X-Api-Key"] ?? "" + if key == "" + return { + "continue": false, + "response": { "status": 401, "body": "Unauthorized" } + } + end + return { "continue": true, "request": req } +end +``` + +| Directive | Meaning | +|----------------------|--------------------------------------------------------| +| `# order: N` | Lower runs first. Default 100. | +| `# global_only: true` | Always runs; cannot be scoped. | +| `# scope_only: true` | Only runs when explicitly scoped via `middleware(...)`. | + +Returning `{"continue": false, "response": {...}}` short-circuits with that response. Returning `{"continue": true, "request": req}` proceeds to the next middleware / handler. + +## Testing + +Specs live in `tests/` and run with `soli test`. Use the BDD DSL with `describe` / `test` / `before_each`. Controller tests get an E2E client (`get`, `post`, `put`, `delete`, `assigns()`, `view_path()`, `as_guest()`). + +```soli +# tests/posts_controller_spec.sl +describe("PostsController", fn() { + before_each(fn() { + as_guest(); + }); + + describe("GET /posts", fn() { + test("returns list of posts", fn() { + let response = get("/posts"); + assert_eq(res_status(response), 200); + assert_hash_has_key(assigns(), "posts"); + }); + }); + + describe("POST /posts", fn() { + test("creates with valid data", fn() { + let response = post("/posts", { "title": "Hello", "body": "World" }); + assert_eq(res_status(response), 302); + }); + + test("rejects invalid data", fn() { + let response = post("/posts", {}); + assert_eq(res_status(response), 422); + }); + }); +}); +``` + +### Test coverage requirement + +**Every new feature must ship with tests achieving >90% coverage of the changed code.** Run coverage locally before opening a PR: + +```bash +soli test --coverage # generate report +soli test --coverage --coverage-min 90.0 # fail if under 90% +``` + +This applies to controllers, models, middleware, helpers, and any new library code. Don't merge a feature whose coverage report is missing or below the threshold — write the tests first if it helps you design the API. + +## SOLID Principles + +Apply these for maintainable code. + +```soli +# Single Responsibility — one reason to change per class +class UserValidator + def validate(user) end +end + +class UserRepository + def save(user) end +end + +# Open/Closed — extend via subclasses, don't edit the base +class Shape + def area -> Float + 0.0 + end +end + +class Circle < Shape + radius: Float + + def area -> Float + 3.14159 * this.radius * this.radius + end +end + +# Liskov — subclasses must honor the parent's contract. +# Don't override a method to throw where the parent returns. + +# Interface Segregation — many small interfaces beat one fat one +interface Printable + def print() +end + +interface Exportable + def export() +end + +# Dependency Inversion — depend on abstractions +interface UserRepository + def find(id: Int) -> User +end + +class UserService + repo: UserRepository + + def get(id) + this.repo.find(id) + end +end +``` + +## Linting + +```bash +soli lint # lint entire project +soli lint app/controllers/ # lint a directory +soli lint path/to/file.sl # lint a single file +``` + +Key rules: + +- `naming/snake-case`, `naming/pascal-case` +- `style/empty-block`, `style/line-length` (≤120 chars) +- `style/redundant-model-import` — don't `import "../models/*.sl"` inside `app/controllers/`; models are auto-loaded +- `smell/unreachable-code`, `smell/empty-catch`, `smell/duplicate-methods`, `smell/dangerous-server-builtin` (flags `db_query_raw` / `Trusted.*` / `System.shell` / backticks in `app/controllers/`, `app/middleware/`, `app/views/`) +- `smell/deep-nesting` (≤4 levels) +- `smell/undefined-local` — reads of a name never assigned in scope (catches typos) + +## Common commands + +```bash +soli serve . --dev # dev server, hot reload, dev bar enabled +soli serve . --port 5011 # run without --dev (still single-process) + +soli generate controller posts # scaffold controller + spec + views +soli generate model post # scaffold model +soli generate migration create_posts # scaffold migration + +soli db:migrate up # run pending migrations +soli db:migrate down # roll back last migration +soli db:migrate status # show migration state + +soli test # run all tests in tests/ +soli test --coverage --coverage-min 90.0 +soli lint # static analysis +``` + +## Conventions to follow + +1. **Prefer Ruby-style** for classes and control flow — `class Demo < Test ... end`, `def name(args) ... end`, `if cond ... end`. Reserve `fn { }` for free-standing functions and lambdas. +2. **Use type annotations** on public function signatures — they catch errors and document intent. +3. **Prefer immutability** — `const` for values that never change. +4. **Chain collection methods** instead of writing manual loops. +5. **Use named parameters** when a function has multiple optional args. +6. **Use named route helpers** (`posts_path`, `root_path`) — never hand-built URL strings. +7. **Validate at the model**, not in the controller — keep controllers thin. +8. **Return errors early** — don't pile `if`s; bail with a 422/redirect at the first invalid branch. +9. **Use `.blank?` for nil/empty checks** — replaces `x == nil || x == ""`. +10. **Use `.nil?` over `== nil`** — `if x.nil?` / `unless x.nil?` reads as a question; keep `==`/`!=` for value comparisons. +11. **Use `&.` to short-circuit on nil** — `user&._key` replaces `user == nil ? nil : user._key`; chain it (`user&.address&.city`) instead of nested guards. +12. **Use `||=` for falsey defaults** — `this.balance ||= 0` instead of `if this.balance == nil`. +13. **Use `.includes?` for membership checks** — replaces chained `||` comparisons. +14. **Test new features to >90% coverage** — non-negotiable, see above. diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 00000000..a559e252 --- /dev/null +++ b/admin/README.md @@ -0,0 +1,92 @@ +# admin + +A Soli MVC application. + +## Getting Started + +### Development Server + +Start the development server with hot reload: + +```bash +soli serve . --dev +``` + +Your app will be available at [http://localhost:5011](http://localhost:5011) + +### Production Server + +Start the production server: + +```bash +soli serve . --port 5011 +``` + +Or run as a daemon: + +```bash +soli serve . -d +``` + +## Project Structure + +``` +admin/ +├── app/ +│ ├── assets/ +│ │ └── css/ +│ │ └── application.css # Source CSS with Tailwind directives +│ ├── controllers/ # Request handlers +│ ├── models/ # Data models +│ └── views/ # HTML templates +│ ├── home/ # Home page views +│ └── layouts/ # Layout templates +├── config/ +│ └── routes.sl # Route definitions +├── db/ +│ └── migrations/ # Database migrations +├── public/ # Static assets (compiled output) +│ ├── css/ +│ │ └── application.css # Compiled CSS (generated) +│ ├── js/ +│ └── images/ +├── tests/ # Test files +├── package.json # npm dependencies +└── tailwind.config.js # Tailwind configuration +``` + +## Database Migrations + +Generate a new migration: + +```bash +soli db:migrate generate create_users +``` + +Run pending migrations: + +```bash +soli db:migrate up +``` + +Rollback last migration: + +```bash +soli db:migrate down +``` + +Check migration status: + +```bash +soli db:migrate status +``` + +## Documentation + +- [Soli MVC Documentation](https://soli.solisoft.net/docs) +- [Soli Language Reference](https://soli.solisoft.net/docs/soli-language) +- [Tailwind CSS](https://tailwindcss.com/docs) + +## License + +MIT diff --git a/admin/app/assets/css/application.css b/admin/app/assets/css/application.css new file mode 100644 index 00000000..94c9c318 --- /dev/null +++ b/admin/app/assets/css/application.css @@ -0,0 +1,112 @@ +/* Tailwind CSS directives */ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Base styles — SoliDB admin ops console (dark zinc + terminal teal) */ +@layer base { + html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scroll-behavior: smooth; + } + + body { + @apply bg-zinc-950 text-zinc-100 leading-relaxed; + } + + h1 { @apply text-2xl font-semibold tracking-tight; } + h2 { @apply text-xl font-semibold tracking-tight; } + h3 { @apply text-lg font-medium; } + + img, video { @apply max-w-full h-auto; } +} + +@layer components { + /* Faint blueprint grid behind the content area. */ + .console-grid { + background-image: + linear-gradient(rgba(63, 63, 70, 0.16) 1px, transparent 1px), + linear-gradient(90deg, rgba(63, 63, 70, 0.16) 1px, transparent 1px); + background-size: 32px 32px; + } + + /* Uppercase mono section label — the console's signature typographic move. */ + .console-label { + @apply font-mono text-[10px] uppercase tracking-[0.2em] text-zinc-500; + } + + /* No backdrop-blur here: backdrop-filter makes the card the containing + block for position:fixed descendants, which would scope/crop the + modals that live inside cards (e.g. per-row document edit). */ + .console-card { + @apply border border-zinc-800 bg-zinc-900/80; + } + + .console-input { + @apply w-full border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 + placeholder-zinc-600 outline-none transition-colors + focus:border-teal-500/60 focus:ring-1 focus:ring-teal-500/30; + } + + .btn-primary { + @apply inline-flex items-center gap-2 border border-teal-500/50 bg-teal-500/10 px-3 py-1.5 + font-mono text-xs uppercase tracking-wider text-teal-300 transition-all + hover:bg-teal-500/20 hover:border-teal-400; + } + + .btn-ghost { + @apply inline-flex items-center gap-2 border border-zinc-700 bg-transparent px-3 py-1.5 + font-mono text-xs uppercase tracking-wider text-zinc-400 transition-all + hover:border-zinc-500 hover:text-zinc-200; + } + + .btn-danger { + @apply inline-flex items-center gap-2 border border-red-900 bg-transparent px-3 py-1.5 + font-mono text-xs uppercase tracking-wider text-red-400/80 transition-all + hover:border-red-500 hover:bg-red-500/10 hover:text-red-300; + } + + .console-table { + @apply w-full text-left text-sm; + } + + .console-table thead th { + @apply border-b border-zinc-800 px-4 py-2 font-mono text-[10px] uppercase tracking-[0.2em] + text-zinc-500 font-normal; + } + + .console-table tbody td { + @apply border-b border-zinc-800/60 px-4 py-2.5 align-middle; + } + + .console-table tbody tr { + @apply transition-colors hover:bg-zinc-900/80; + } +} + +/* Alpine: hide x-cloak'd elements until Alpine boots. */ +[x-cloak] { display: none !important; } + +/* HTMX swap transition — content eases in on every fragment swap. */ +#content.htmx-swapping { opacity: 0; } +#content { transition: opacity 120ms ease-out; } + +/* Custom scrollbar — teal-tinted to match the accent. */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(20, 184, 166, 0.25); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(20, 184, 166, 0.45); +} diff --git a/admin/app/controllers/CLAUDE.md b/admin/app/controllers/CLAUDE.md new file mode 100644 index 00000000..4cbe5b12 --- /dev/null +++ b/admin/app/controllers/CLAUDE.md @@ -0,0 +1,527 @@ +# Controllers + +This directory holds request handlers. **One file per resource**: +`posts_controller.sl` defines `class PostsController < Controller`. Filenames +are `snake_case.sl`; class names are `PascalCase` ending in `Controller`. + +Controllers stay thin: they pull params off the request, ask a model to do +the work, and return a response. Validation, persistence, and business rules +belong on the model — not here. + +## The Controller contract + +Inherit from `Controller`. The class body holds action methods (one per route) +plus a `static { ... }` block for layout and lifecycle hooks. + +```soli +class PostsController < Controller + static { + this.layout = "application" + } + + def index + @posts = Post.all + @title = "Posts" + end +end +``` + +The class itself uses Ruby-style `class X < Y ... end`, and methods use +`def name ... end` — but the `static` block **requires braces** (`static { ... }`). +The Ruby-style `static ... end` form does not parse. + +Free-function actions (no class wrapper) also work — see +`www/app/controllers/docs_controller.sl` for a real example — but the class +form is recommended for anything stateful or with hooks. + +## The `static { }` block + +Set the layout, register `before_action` / `after_action` hooks. The runtime +calls to `before_action` / `after_action` are no-ops — the controller registry +**textually scans the class body** at `soli serve` startup to wire hooks up, +so the syntax has to match what the scanner expects. + +```soli +class PostsController < Controller + static { + this.layout = "application" + + # Runs on every action. + this.before_action = fn(req) { + @current_user = session_get("user_id") + req + } + + # Runs only on the listed actions. + this.before_action(:show, :edit, :update, :delete) = fn(req) { + @post = Post.find(params["id"]) + req + } + + # after_action receives the response too. + this.after_action = fn(req, response) { + response + } + } +end +``` + +A `before_action` returning `req` proceeds; returning a response hash (one +with a `"status"` key) short-circuits and that response is returned to the +client. + +## Reading the request + +Inside an action, `req` is the request hash. The framework also exposes a few +globals so you don't have to dig: + +| Read | What it gives you | +|-----------------------|----------------------------------------------------------------| +| `params` | Merged route + query + JSON body (= `req["all"]`). Most common.| +| `params["id"]` | Path segment from `/posts/:id`. | +| `req["json"]` | Parsed JSON body (when the request had one). | +| `req["query"]` | Just the URL query string params. | +| `req["headers"]` | Lowercased header hash. `req["headers"]["user-agent"]`. | +| `req["method"]` | `"GET"`, `"POST"`, ... | +| `req["path"]` | Request path. | +| `req["files"]` | Array of uploaded files (multipart only). See **Handling file uploads**. | +| `cookies` | **Global** read-only hash of parsed cookies. `cookies.theme`. | + +`params` reads route params, query params, and parsed body fields with the +same key — write `params["id"]` whether the value came from `/posts/:id`, +`?id=42`, or `{"id": 42}` in the JSON body. + +## Response shapes + +Soli supports an **implicit render** that covers 80% of cases. Only reach for +explicit response builders when you need something special. + +### 1. Implicit render (preferred for the common case) + +If an action returns anything that is **not** a response hash (i.e. doesn't +have a `"status"` key), the framework auto-renders the default template at +`app/views//.html.slv`. `PostsController#show` → +`posts/show`. So an action that just sets up view state can be a one-liner: + +```soli +def show + @post = Post.find(params["id"]) +end +``` + +That's it. No `render(...)` call, no return statement. Every `@field` on the +instance is auto-injected as a view local (see "@-variables" below). + +### 2. Explicit render — non-default template or extra locals + +```soli +def create + permitted = this._permit_params(params) + @post = Post.create(permitted) + if @post._errors + return render("posts/new", { "title": "New post" }) # re-render form + end + redirect(post_path(@post)) +end +``` + +### 3. JSON + +```soli +def show + @post = Post.find(params["id"]) + render_json({ "id": @post.id, "title": @post.title }) +end +``` + +`render_json` sets `Content-Type: application/json` and serializes the hash +for you. + +### 4. Plain text + +```soli +def health + render_text("OK") +end +``` + +### 5. Redirect + +```soli +redirect("/posts") # 302 to a path +redirect(post_path(post)) # use named-route helpers, not hand-built URLs +redirect(:back) # back to the Referer if safe +redirect_external(url) # opt-in to redirect to a different host +``` + +### 6. Short-circuit with `halt` + +```soli +def admin + halt(403, "Forbidden") unless current_user.admin + @users = User.all +end +``` + +`halt(status, body)` immediately returns that response and skips the rest of +the action. + +### 7. Raw hash — when you need full control + +```soli +def webhook + return { + "status": 202, + "headers": { "Content-Type": "application/json", "X-Request-Id": req["id"] }, + "body": "{\"ok\":true}" + } +end +``` + +Any hash with a `"status"` key is treated as a final response and bypasses +auto-render. + +### 8. Content negotiation with `respond_to` + +```soli +def show + @post = Post.find(params["id"]) + respond_to(req, { + "html": fn() { render("posts/show") }, + "json": fn() { render_json({ "id": @post.id, "title": @post.title }) } + }) +end +``` + +## `@`-variables are injected into views + +Every non-underscore-prefixed instance field you set on the controller is +auto-exposed as a top-level view local. `@post = Post.find(...)` makes `post` +available in the template. + +```soli +def index + @posts = Post.all + @title = "Posts" + @filter = params["filter"] ?? "all" +end +``` + +In `app/views/posts/index.html.slv`: + +```erb +

<%= @title %>

+

Showing: <%= @filter %>

+<% for post in @posts %> +
  • <%= h(post.title) %>
  • +<% end %> +``` + +(Both `@title` and bare `title` resolve to the same value — `@` is the +canonical form.) + +**Underscore-prefixed fields are private.** `@_internal_state = ...` is *not* +exposed to the view — useful for state shared between hooks and actions that +shouldn't leak into templates. + +Because of this, you usually don't pass a data hash to `render` at all — set +`@fields` and let the framework do the rest. Reach for `render(view, {...})` +only when you need to render a *different* view than the default, or when you +want to override a field's name for the template. + +## Full CRUD sample + +```soli +# app/controllers/posts_controller.sl + +class PostsController < Controller + static { + this.layout = "application" + + # Look up @post once for every action that needs it. + this.before_action(:show, :edit, :update, :delete) = fn(req) { + @post = Post.find(params["id"]) + req + } + } + + # GET /posts — implicit render of posts/index + def index + @posts = Post.all + @title = "All posts" + end + + # GET /posts/:id — @post set by before_action, implicit render of posts/show + def show + @title = "Post: #{@post.title}" + end + + # GET /posts/new — implicit render of posts/new + def new + @post = Post.new + @title = "New post" + end + + # POST /posts + def create + permitted = this._permit_params(params) + @post = Post.create(permitted) + if @post._errors + @title = "New post" + return render("posts/new") # explicit: re-render the form view + end + redirect(post_path(@post)) + end + + # GET /posts/:id/edit — implicit render of posts/edit + def edit + @title = "Edit #{@post.title}" + end + + # PATCH/PUT /posts/:id + def update + permitted = this._permit_params(params) + @post.update(permitted) + if @post._errors + return render("posts/edit") + end + redirect(post_path(@post)) + end + + # DELETE /posts/:id + def delete + @post.delete + redirect(posts_path()) + end + + # Mass-assignment guard — whitelist the fields users can write. + def _permit_params(params) + { + "title": params["title"], + "body": params["body"] + } + end +end +``` + +Notes on the sample: + +- `before_action(:show, ...)` does the `Post.find` once instead of repeating + it in four actions. +- `_permit_params` is a private helper (the leading `_` makes it + non-routable). Only its return value is passed to `Model.create` / `update`. +- `index` / `show` / `new` / `edit` rely on **implicit render** — they just + set `@fields` and exit. +- `create` and `update` use **explicit render** for the validation-failure + re-render, because they need to render a *different* template than the + default for the action. +- All redirects use **named helpers** (`post_path(post)`, `posts_path()`) — + never hand-built URL strings. + +## Validation re-render flow + +`Model.create(attrs)` and `instance.save()` always return; on failure they +populate `_errors` on the returned instance. The controller checks `_errors`, +re-renders the form view passing the invalid instance, and the view displays +the errors. + +```soli +@post = Post.create(permitted) +if @post._errors + return render("posts/new") # view reads @post._errors to show messages +end +redirect(post_path(@post)) +``` + +**Don't wrap `Model.find` in nil-checks or `try/catch`.** On miss it raises +`RecordNotFound`, which the framework converts to a 404 automatically — so a +manual `if post.nil? ... end` branch is unreachable. Use `find_by(field, val)` +or `first_by(...)` when you want the "or nil" shape: + +```soli +@post = Post.find(params["id"]) # raises → 404 +@draft = Post.find_by("slug", params["slug"]) # nil on miss +``` + +## Handling file uploads + +For `multipart/form-data` requests, the framework parses every file part into +`req["files"]` — an array of hashes. Use the `find_uploaded_file(req, "field")` +helper to pull one by form field name; it returns `nil` if no file was +attached under that name or the request wasn't multipart. + +```soli +photo = find_uploaded_file(params, "photo") +# nil, or: +# { +# "name": "photo", # form field name +# "filename": "vacation.jpg", # client-supplied filename +# "content_type": "image/jpeg", +# "size": 184_213, # bytes +# "data": "" +# } +``` + +**Don't read the bytes yourself.** When the field is declared with +`uploader(...)` on the model, hand the file straight to the auto-generated +`attach_` method — it runs the configured MIME/size validations and +stores the blob in SoliDB for you: + +```soli +def create + @contact = Contact.create(this._permit_params(params)) + if @contact._errors + return render("contacts/new") + end + + photo = find_uploaded_file(params, "photo") + if !photo.nil? && !@contact.attach_photo(photo) + # attach_ populates @contact._errors on failure (bad MIME, + # too large, or storage error). Re-render with the same flow you + # use for validation errors. + return render("contacts/new") + end + + redirect(contact_path(@contact)) +end +``` + +For multi-file fields (`uploader("attachments", { "multiple": true, ... })`), +iterate `req["files"]` directly and attach one by one: + +```soli +def upload_batch + @document = Document.find(params["id"]) + for file in (req["files"] ?? []) + next unless file["name"] == "attachments" + @document.attach_attachments(file) # array column; each call pushes one blob + end + redirect(document_path(@document)) +end +``` + +The whole upload contract (declarations, options, routes, cleanup) is in +`app/models/CLAUDE.md` → **Attachments and uploads**. Don't re-implement +blob storage in the controller. + +### Form markup + +The HTML form needs `enctype="multipart/form-data"` and one `` +per uploader field. Anything posted under a name that doesn't match an +uploader is just ignored. + +```erb +
    + + + +
    +``` + +The cap on `req["files"]` array length is `SOLI_MAX_UPLOAD_FILES` (default 32 +per request); excess files are dropped before the action runs. + +## Cookies and sessions + +Cookies are a read-only global; write them with `set_cookie`: + +```soli +@theme = cookies["theme"] ?? "light" # read +set_cookie("theme", "dark") # write (Path=/) +``` + +Sessions are read/write via builtins (storage backend configured in +`config/application.sl`): + +```soli +session_set("user_id", user.id) +let uid = session_get("user_id") # nil if not set +session_has("user_id") # bool +session_delete("user_id") +session_regenerate # after a successful login (security) +session_destroy # on logout +``` + +## Named route helpers + +`resources("posts")` in `config/routes.sl` auto-registers a family of helpers +as globals. Use them — never concatenate URLs by hand. + +| Route | Path helper | URL helper | +|----------------------|-------------------------|-------------------------| +| `GET /posts` | `posts_path()` | `posts_url()` | +| `GET /posts/new` | `new_post_path()` | `new_post_url()` | +| `GET /posts/:id` | `post_path(post)` | `post_url(post)` | +| `GET /posts/:id/edit` | `edit_post_path(post)` | `edit_post_url(post)` | + +Custom routes named with `name: "..."` get the same treatment: +`get("/about", "pages#about", name: "about")` → `about_path()` / `about_url()`. + +`*_path` returns a relative path; `*_url` is the absolute form (and respects +`enable_trust_proxy` if set in `config/application.sl`). + +## Spec location + +Every controller has a sibling spec at `tests/_controller_spec.sl`. +`soli generate controller posts` scaffolds it for you. Use the E2E client: + +```soli +describe("PostsController") do + before_each() do + as_guest() + end + + test("GET /posts returns 200") do + response = get("/posts") + assert_eq(res_status(response), 200) + assert_hash_has_key(assigns(), "posts") + end + + test("POST /posts with invalid params re-renders new") do + response = post("/posts", {}) + assert_eq(res_status(response), 200) + assert_eq(view_path(), "posts/new.html") + end +end +``` + +E2E helpers: `get` / `post` / `put` / `delete` to make requests; `res_status`, +`assigns()` (the `@field` hash exposed to the view), `view_path()`, +`render_template()`, `as_guest()`. + +## Do / Don't + +| Do | Don't | +|----------------------------------------------------------|------------------------------------------------------------------| +| Use named route helpers — `post_path(post)` | Hand-build URLs — `"/posts/" + str(post.id)` | +| Let `Model.find` raise → 404 | Wrap `Model.find` in `try/catch` or `if record.nil?` | +| Whitelist via `_permit_params` before `Model.create` | Pass `params` (or `req["json"]`) straight to `Model.create` | +| Keep actions thin; push rules to the model | Stuff validation / business logic into controller actions | +| Set `@fields` and let the framework auto-render | Repeat `@field` in `render(...)`'s data hash | +| Use `_`-prefixed methods for non-routable helpers | Expose helper methods as public actions | +| Use `find_by` / `first_by` when you want nil-on-miss | Add `if record.nil?` guards after `find` — they're unreachable | +| | `import "../models/*.sl"` — models are auto-loaded | +| | Use `db_query_raw` / backticks here — push raw SQL to the model | + +## Lints that fire here + +- `style/redundant-model-import` — models in `app/models/*.sl` are auto-loaded; + importing them from a controller triggers this. +- `smell/dangerous-server-builtin` — `db_query_raw`, `Trusted.*`, `System.shell`, + and backtick commands are flagged inside controllers. Use the model layer + or a dedicated service object instead. +- `smell/deep-nesting` — keep actions ≤4 levels of nesting. If you're past + that, the action is doing too much. +- `smell/unreachable-code` — typically catches dead branches after an early + `return` or after a `Model.find` nil-check that can never fire. +- `smell/undefined-local` — flags reads of a name that's never assigned in + the action's scope (catches typos that bypass `let`). +- `naming/pascal-case` — class name must be `PascalCase`. +- `naming/snake-case` — action and helper names must be `snake_case`. + +Run on the directory: + +```bash +soli lint app/controllers/ +soli lint app/controllers/posts_controller.sl +``` diff --git a/admin/app/controllers/api_keys_controller.sl b/admin/app/controllers/api_keys_controller.sl new file mode 100644 index 00000000..a8d3266f --- /dev/null +++ b/admin/app/controllers/api_keys_controller.sl @@ -0,0 +1,75 @@ +# API keys - create / list / revoke server API keys. The raw key is only +# returned by SoliDB at creation time, so it is surfaced once in a banner. + +class ApiKeysController < Controller + static { + this.layout = "application" + } + + # GET /api-keys + def index + @title = "API Keys" + this._reset_banners() + this._load() + end + + # POST /api-keys + def create + name = (params["name"] ?? "").trim() + if name.blank? + return this._respond({ "ok": false, "status": 422, "error": "key name is required" }, "") + end + payload = { "name": name } + roles = this._split_csv(params["roles"] ?? "") + payload["roles"] = roles if roles.length() > 0 + scoped = this._split_csv(params["scoped_databases"] ?? "") + payload["scoped_databases"] = scoped if scoped.length() > 0 + result = SolidbClient.post_api(SolidbEndpoints.api_keys(), payload) + raw_key = (result["data"] ?? {})["key"] ?? "" + return this._respond(result, "api key " + name + " created", raw_key) + end + + # DELETE /api-keys/:id + def delete + key_id = params["id"] ?? "" + result = SolidbClient.delete_api(SolidbEndpoints.api_key(key_id)) + return this._respond(result, "api key revoked") + end + + def _split_csv(text) + return [] if text.trim().blank? + parts = text.split(",").map do |part| part.trim() end + return parts.filter do |part| !part.blank? end + end + + def _load + result = SolidbClient.get_api(SolidbEndpoints.api_keys()) + @keys = (result["data"] ?? {})["keys"] ?? [] + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + @created_key = "" + end + + def _respond(result, notice, created_key = "") + @title = "API Keys" + this._reset_banners() + @created_key = created_key if result["ok"] + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("api_keys/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("api_keys/index") + end +end diff --git a/admin/app/controllers/collections_controller.sl b/admin/app/controllers/collections_controller.sl new file mode 100644 index 00000000..4922f95b --- /dev/null +++ b/admin/app/controllers/collections_controller.sl @@ -0,0 +1,247 @@ +# Collections - browse / create / truncate / drop collections in a database. + +class CollectionsController < Controller + static { + this.layout = "application" + } + + # GET /databases/:db/collections + def index + this._ctx() + @title = "Collections · " + @db + this._reset_banners() + this._load() + end + + # POST /databases/:db/collections + def create + this._ctx() + name = (params["name"] ?? "").trim() + if name.blank? + return this._respond({ "ok": false, "status": 422, "error": "collection name is required" }, "") + end + return this._create_columnar(name) if params["type"] == "columnar" + payload = this._build_create_payload(name) + if payload.nil? + return this._respond({ "ok": false, "status": 422, "error": "schema must be a valid JSON object" }, "") + end + result = SolidbClient.post_api(SolidbEndpoints.collections(@db), payload) + return this._respond(result, "collection " + name + " created") + end + + # Columnar collections live behind their own API and require column + # definitions: [{ "name": "host", "type": "string" }, ...] + def _create_columnar(name) + columns_text = (params["columns"] ?? "").trim() + columns = JSON.parse(columns_text) rescue nil + if columns.nil? || columns.length() == 0 + return this._respond({ "ok": false, "status": 422, + "error": "columnar collections need a JSON array of column definitions" }, "") + end + result = SolidbClient.post_api(SolidbEndpoints.columnar(@db), { "name": name, "columns": columns }) + return this._respond(result, "columnar collection " + name + " created") + end + + # Optional creation settings from the modal; nil when the schema textarea + # holds invalid JSON. + def _build_create_payload(name) + payload = { "name": name } + coll_type = (params["type"] ?? "").trim() + payload["type"] = coll_type unless coll_type.blank? + num_shards = (params["num_shards"] ?? "").trim() + payload["numShards"] = num_shards.to_int() unless num_shards.blank? + shard_key = (params["shard_key"] ?? "").trim() + payload["shardKey"] = shard_key unless shard_key.blank? + replication_factor = (params["replication_factor"] ?? "").trim() + payload["replicationFactor"] = replication_factor.to_int() unless replication_factor.blank? + validation_mode = (params["validation_mode"] ?? "").trim() + payload["validationMode"] = validation_mode unless validation_mode.blank? + schema_text = (params["schema"] ?? "").trim() + if !schema_text.blank? + schema = JSON.parse(schema_text) rescue nil + return nil if schema.nil? + payload["schema"] = schema + end + return payload + end + + # GET /databases/:db/collections/:name/stats - HTMX-loaded detail fragment + def stats + this._ctx() + @collection_name = params["name"] ?? "" + result = SolidbClient.get_api(SolidbEndpoints.collection_stats(@db, @collection_name)) + @stats = result["data"] ?? {} + @stats_error = result["ok"] ? "" : (result["error"] ?? "request failed") + return render("collections/_stats", { "layout": false }) + end + + # PUT /databases/:db/collections/:name/truncate + def truncate + this._ctx() + name = params["name"] ?? "" + result = SolidbClient.put_api(SolidbEndpoints.collection_truncate(@db, name)) + return this._respond(result, "collection " + name + " truncated") + end + + # DELETE /databases/:db/collections/:name (?ctype=columnar for columnar) + def delete + this._ctx() + name = params["name"] ?? "" + if params["ctype"] == "columnar" + result = SolidbClient.delete_api(SolidbEndpoints.columnar_collection(@db, name)) + return this._respond(result, "columnar collection " + name + " dropped") + end + result = SolidbClient.delete_api(SolidbEndpoints.collection(@db, name)) + return this._respond(result, "collection " + name + " dropped") + end + + # GET /databases/:db/collections/:name/indexes - HTMX-loaded panel + def indexes + this._ctx_indexes() + return this._render_indexes({ "ok": true }, "") + end + + # POST /databases/:db/collections/:name/indexes + def create_index + this._ctx_indexes() + index_name = (params["index_name"] ?? "").trim() + fields_text = (params["fields"] ?? "").trim() + if index_name.blank? || fields_text.blank? + return this._render_indexes({ "ok": false, "status": 422, + "error": "index name and fields are required" }, "") + end + index_type = (params["index_type"] ?? "persistent").trim() + result = this._create_index_request(index_name, fields_text, index_type) + return this._render_indexes(result, "index " + index_name + " created") + end + + # PUT /databases/:db/collections/:name/indexes/rebuild + def rebuild_indexes + this._ctx_indexes() + result = SolidbClient.put_api(SolidbEndpoints.collection_indexes_rebuild(@db, @collection_name)) + indexed_count = (result["data"] ?? {})["documents_indexed"] ?? 0 + return this._render_indexes(result, "indexes rebuilt (" + str(indexed_count) + " documents)") + end + + # DELETE /databases/:db/collections/:name/indexes/:index_name + # The unified API drops standard / fulltext / geo / ttl indexes by name. + def delete_index + this._ctx_indexes() + index_name = params["index_name"] ?? "" + result = SolidbClient.delete_api(SolidbEndpoints.collection_index(@db, @collection_name, index_name)) + return this._render_indexes(result, "index " + index_name + " dropped") + end + + # Geo and TTL indexes are created through their own API families; everything + # else (persistent / hash / fulltext / bloom / cuckoo) goes to /index. + def _create_index_request(index_name, fields_text, index_type) + fields = [] + for part in fields_text.split(",") + cleaned = part.trim() + fields.push(cleaned) unless cleaned.blank? + end + if index_type == "geo" + return SolidbClient.post_api(SolidbEndpoints.geo_indexes(@db, @collection_name), + { "name": index_name, "field": fields[0] }) + end + if index_type == "ttl" + expire_text = (params["expire_after_seconds"] ?? "").trim() + if expire_text.blank? + return { "ok": false, "status": 422, "error": "ttl indexes need expire_after_seconds" } + end + return SolidbClient.post_api(SolidbEndpoints.ttl_indexes(@db, @collection_name), + { "name": index_name, "field": fields[0], + "expire_after_seconds": expire_text.to_int() }) + end + return SolidbClient.post_api(SolidbEndpoints.collection_indexes(@db, @collection_name), + { "name": index_name, "fields": fields, + "type": index_type, "unique": params["unique"] == "true" }) + end + + def _ctx_indexes + this._ctx() + @collection_name = params["name"] ?? "" + end + + def _render_indexes(result, notice) + @indexes_error = result["ok"] ? "" : (result["error"] ?? "request failed") + @indexes_notice = result["ok"] ? notice : "" + this._load_indexes() + return render("collections/_indexes", { "layout": false }) + end + + # Merge the three index families (standard+fulltext, geo, ttl) into one + # display list of { name, fields, type, unique, detail } rows. + def _load_indexes + @indexes = [] + result = SolidbClient.get_api(SolidbEndpoints.collection_indexes(@db, @collection_name)) + if !result["ok"] + @indexes_error = result["error"] ?? "request failed" if @indexes_error.blank? + return + end + for entry in ((result["data"] ?? {})["indexes"] ?? []) + type_name = str(entry["index_type"] ?? "persistent").downcase() + @indexes.push({ "name": entry["name"] ?? "", "fields": entry["fields"] ?? [], + "type": type_name, "unique": entry["unique"] ?? false, + "detail": str(entry["indexed_documents"] ?? 0) + " docs" }) + end + geo_result = SolidbClient.get_api(SolidbEndpoints.geo_indexes(@db, @collection_name)) + for entry in ((geo_result["data"] ?? {})["indexes"] ?? []) + @indexes.push({ "name": entry["name"] ?? "", "fields": [entry["field"] ?? ""], + "type": "geo", "unique": false, + "detail": str(entry["indexed_documents"] ?? 0) + " docs" }) + end + ttl_result = SolidbClient.get_api(SolidbEndpoints.ttl_indexes(@db, @collection_name)) + for entry in ((ttl_result["data"] ?? {})["indexes"] ?? []) + @indexes.push({ "name": entry["name"] ?? "", "fields": [entry["field"] ?? ""], + "type": "ttl", "unique": false, + "detail": "expires after " + str(entry["expire_after_seconds"] ?? 0) + "s" }) + end + end + + # Route context: set explicitly per action (before_action hooks are wired + # by a startup-time scan and are unreliable under dev hot-reload). + def _ctx + @db = params["db"] ?? "" + @databases = AdminContext.database_names() + end + + def _load + result = SolidbClient.get_api(SolidbEndpoints.collections(@db)) + regular = (result["data"] ?? {})["collections"] ?? [] + # Columnar collections appear in the regular list as internal + # `_columnar_` document collections -- hide those and merge the + # real entries from the columnar API instead. + @collections = regular.filter do |coll| !(coll["name"] ?? "").starts_with("_columnar_") end + columnar_result = SolidbClient.get_api(SolidbEndpoints.columnar(@db)) + columnar_entries = (columnar_result["data"] ?? {})["collections"] ?? [] + for entry in columnar_entries + @collections.push({ "name": entry["name"] ?? "", "type": "columnar", + "count": entry["row_count"] ?? 0, "stats": {} }) + end + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + def _respond(result, notice) + @title = "Collections · " + @db + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("collections/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("collections/index") + end +end diff --git a/admin/app/controllers/cron_controller.sl b/admin/app/controllers/cron_controller.sl new file mode 100644 index 00000000..37a038ed --- /dev/null +++ b/admin/app/controllers/cron_controller.sl @@ -0,0 +1,106 @@ +# Cron - scheduled jobs (cron expression -> script on a queue). + +class CronController < Controller + static { + this.layout = "application" + } + + # GET /databases/:db/cron + def index + this._ctx() + @title = "Cron · " + @db + this._reset_banners() + this._load() + end + + # POST /databases/:db/cron + def create + this._ctx() + payload = this._build_payload() + if payload.nil? + return this._respond({ "ok": false, "status": 422, "error": "params must be a JSON object" }, "") + end + cron_name = payload["name"] ?? "" + if cron_name.blank? || (payload["cron_expression"] ?? "") == "" || (payload["script"] ?? "") == "" + return this._respond({ "ok": false, "status": 422, + "error": "name, cron expression and script are required" }, "") + end + result = SolidbClient.post_api(SolidbEndpoints.cron_jobs(@db), payload) + return this._respond(result, "cron job " + cron_name + " created") + end + + # PUT /databases/:db/cron/:id + def update + this._ctx() + payload = this._build_payload() + if payload.nil? + return this._respond({ "ok": false, "status": 422, "error": "params must be a JSON object" }, "") + end + result = SolidbClient.put_api(SolidbEndpoints.cron_job(@db, params["id"] ?? ""), payload) + return this._respond(result, "cron job " + (payload["name"] ?? "") + " updated") + end + + # DELETE /databases/:db/cron/:id + def delete + this._ctx() + result = SolidbClient.delete_api(SolidbEndpoints.cron_job(@db, params["id"] ?? "")) + return this._respond(result, "cron job deleted") + end + + def _ctx + @db = params["db"] ?? "" + @databases = AdminContext.database_names() + end + + # nil when the params textarea holds invalid JSON. + def _build_payload + job_params = {} + params_text = (params["job_params"] ?? "").trim() + if !params_text.blank? + job_params = JSON.parse(params_text) rescue nil + return nil if job_params.nil? + end + payload = { + "name": (params["cron_name"] ?? "").trim(), + "cron_expression": (params["cron_expression"] ?? "").trim(), + "script": (params["script"] ?? "").trim(), + "params": job_params + } + queue = (params["queue"] ?? "").trim() + payload["queue"] = queue unless queue.blank? + priority = (params["priority"] ?? "").trim() + payload["priority"] = priority.to_int() unless priority.blank? + max_retries = (params["max_retries"] ?? "").trim() + payload["max_retries"] = max_retries.to_int() unless max_retries.blank? + return payload + end + + def _load + result = SolidbClient.get_api(SolidbEndpoints.cron_jobs(@db)) + @cron_jobs = result["data"] ?? [] + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + def _respond(result, notice) + @title = "Cron · " + @db + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("cron/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("cron/index") + end +end diff --git a/admin/app/controllers/databases_controller.sl b/admin/app/controllers/databases_controller.sl new file mode 100644 index 00000000..9ab1476b --- /dev/null +++ b/admin/app/controllers/databases_controller.sl @@ -0,0 +1,59 @@ +# Databases - list / create / drop databases on the SoliDB server. + +class DatabasesController < Controller + static { + this.layout = "application" + } + + # GET /databases + def index + @title = "Databases" + this._reset_banners() + this._load() + end + + # POST /databases + def create + name = (params["name"] ?? "").trim() + if name.blank? + return this._respond({ "ok": false, "status": 422, "error": "database name is required" }, "") + end + result = SolidbClient.post_api(SolidbEndpoints.database_create(), { "name": name }) + return this._respond(result, "database " + name + " created") + end + + # DELETE /databases/:db + def delete + db_name = params["db"] ?? "" + result = SolidbClient.delete_api(SolidbEndpoints.database(db_name)) + return this._respond(result, "database " + db_name + " dropped") + end + + def _load + @database_names = AdminContext.database_names() + end + + # Reading an unset @field raises in Soli, so banner fields are always + # assigned before the view renders. + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + # Shared tail for mutations: set banners, reload the list, re-render the + # section (fragment for HTMX swaps, full page otherwise). + def _respond(result, notice) + @title = "Databases" + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("databases/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("databases/index") + end +end diff --git a/admin/app/controllers/documents_controller.sl b/admin/app/controllers/documents_controller.sl new file mode 100644 index 00000000..cf66314c --- /dev/null +++ b/admin/app/controllers/documents_controller.sl @@ -0,0 +1,191 @@ +# Documents - browse a collection's documents with an SDBQL filter and +# offset/limit pagination, plus create / edit / delete single documents. + +class DocumentsController < Controller + static { + this.layout = "application" + } + + # GET /databases/:db/collections/:name/docs + def index + this._ctx() + @title = @collection_name + " · " + @db + this._reset_banners() + this._load() + end + + # POST /databases/:db/collections/:name/docs + def create + this._ctx() + document = this._parse_document() + if document.nil? + return this._respond({ "ok": false, "status": 422, "error": "document must be a JSON object" }, "") + end + result = SolidbClient.post_api(SolidbEndpoints.documents(@db, @collection_name), document) + return this._respond(result, "document created") + end + + # PUT /databases/:db/collections/:name/docs/:key + def update + this._ctx() + document = this._parse_document() + if document.nil? + return this._respond({ "ok": false, "status": 422, "error": "document must be a JSON object" }, "") + end + key = params["key"] ?? "" + result = SolidbClient.put_api(SolidbEndpoints.document(@db, @collection_name, key), document) + return this._respond(result, "document " + key + " updated") + end + + # POST /databases/:db/collections/:name/docs/upload - multipart file upload + # into a blob collection. The file's base64 payload goes through the binary + # driver protocol (store_blob), never through an HTTP string body, so the + # bytes arrive exactly as sent. + def upload + this._ctx() + wants_json = (req["headers"]["accept"] ?? "").includes?("application/json") + file = find_uploaded_file(req, "file") + if file.nil? + return render_json({ "ok": false, "error": "no file selected" }) if wants_json + return this._respond({ "ok": false, "status": 422, "error": "no file selected" }, "") + end + blob_id = nil + try + conn = this._driver() + blob_id = conn.store_blob(@collection_name, file["data"], + file["filename"] ?? "upload.bin", + file["content_type"] ?? "application/octet-stream") + catch upload_error + return render_json({ "ok": false, "error": str(upload_error) }) if wants_json + return this._respond({ "ok": false, "status": 500, "error": str(upload_error) }, "") + end + return render_json({ "ok": true, "blob_id": blob_id }) if wants_json + return this._respond({ "ok": true }, "file " + (file["filename"] ?? "") + " uploaded (" + str(blob_id) + ")") + end + + # GET /databases/:db/collections/:name/docs/:key/blob - stream a blob back + # (inline for preview, attachment with ?download=1). + def blob + this._ctx() + key = params["key"] ?? "" + meta = nil + data_base64 = nil + try + conn = this._driver() + meta = conn.get_blob_metadata(@collection_name, key) + data_base64 = conn.get_blob(@collection_name, key) + catch blob_error + meta = nil + end + # The driver returns null (rather than raising) for unknown keys. + if meta == nil || data_base64 == nil + return { "status": 404, "headers": { "Content-Type": "text/plain" }, "body": "blob not found" } + end + # Blob metadata fields vary by upload path: `name`/`type` are canonical, + # `filename`/`content_type` only present on some. + content_type = meta["content_type"] ?? (meta["type"] ?? "application/octet-stream") + filename = meta["filename"] ?? (meta["name"] ?? key) + disposition = params["download"] == "1" ? "attachment" : "inline" + return { + "status": 200, + "headers": { + "Content-Type": content_type, + "Content-Disposition": disposition + "; filename=\"" + filename + "\"" + }, + "body": Base64.decode(data_base64) + } + end + + # DELETE /databases/:db/collections/:name/docs/:key + def delete + this._ctx() + key = params["key"] ?? "" + result = SolidbClient.delete_api(SolidbEndpoints.document(@db, @collection_name, key)) + return this._respond(result, "document " + key + " deleted") + end + + def _ctx + @db = params["db"] ?? "" + @collection_name = params["name"] ?? "" + @databases = AdminContext.database_names() + @filter = (params["filter"] ?? "").trim() + @limit = this._page_limit() + offset = (params["offset"] ?? "0").to_int() + offset = 0 if offset < 0 + @offset = offset + this._load_collection_type() + end + + # blob collections get upload/download/preview affordances in the view. + def _load_collection_type + result = SolidbClient.get_api(SolidbEndpoints.collections(@db)) + collections = (result["data"] ?? {})["collections"] ?? [] + matching = collections.filter do |coll| coll["name"] == @collection_name end + @collection_type = matching.length() > 0 ? (matching[0]["type"] ?? "document") : "document" + end + + # Authenticated binary-protocol connection (byte-safe for blob payloads). + def _driver + conn = Solidb(SolidbClient.host(), @db) + conn.auth(SolidbClient.username(), SolidbClient.password()) + return conn + end + + def _page_limit + limit = (params["limit"] ?? "25").to_int() + return 25 unless [25, 50, 100].includes?(limit) + return limit + end + + # nil when the document textarea holds invalid JSON. + def _parse_document + document = JSON.parse((params["document"] ?? "").trim()) rescue nil + return document + end + + # Runs the listing query: the filter input is spliced as a FILTER clause on + # `doc`, offset/limit are bound. Fetches limit+1 rows to know has-more. + # Blob collections project metadata only -- their docs embed binary chunk + # data that must never be rendered into HTML. + def _load + return_clause = " LIMIT @offset, @batch RETURN doc" + if @collection_type == "blob" + return_clause = " LIMIT @offset, @batch RETURN { \"_key\": doc._key, \"filename\": doc.filename," + + " \"name\": doc.name, \"size\": doc.size, \"content_type\": doc.content_type," + + " \"type\": doc.type, \"chunks\": doc.chunks, \"created\": doc.created }" + end + query = "FOR doc IN " + @collection_name + query = query + " FILTER " + @filter unless @filter.blank? + query = query + return_clause + payload = { "query": query, "bindVars": { "offset": @offset, "batch": @limit + 1 } } + result = SolidbClient.post_api(SolidbEndpoints.cursor(@db), payload) + rows = (result["data"] ?? {})["result"] ?? [] + @has_more = rows.length() > @limit + @documents = @has_more ? rows.slice(0, @limit) : rows + @query_error = "" + if !result["ok"] + @query_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + def _respond(result, notice) + @title = @collection_name + " · " + @db + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("documents/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("documents/index") + end +end diff --git a/admin/app/controllers/env_vars_controller.sl b/admin/app/controllers/env_vars_controller.sl new file mode 100644 index 00000000..30aafc59 --- /dev/null +++ b/admin/app/controllers/env_vars_controller.sl @@ -0,0 +1,74 @@ +# Env vars - per-database key/value settings exposed to Lua scripts. +# SoliDB's PUT /env/{key} is an upsert, so create and edit share one action. + +class EnvVarsController < Controller + static { + this.layout = "application" + } + + # GET /databases/:db/env + def index + this._ctx() + @title = "Env vars · " + @db + this._reset_banners() + this._load() + end + + # POST /databases/:db/env + def upsert + this._ctx() + env_key = (params["env_key"] ?? "").trim() + env_value = params["env_value"] ?? "" + if env_key.blank? + return this._respond({ "ok": false, "status": 422, "error": "key is required" }, "") + end + result = SolidbClient.put_api(SolidbEndpoints.env_var(@db, env_key), { "value": env_value }) + return this._respond(result, "env var " + env_key + " saved") + end + + # DELETE /databases/:db/env/:key + def delete + this._ctx() + result = SolidbClient.delete_api(SolidbEndpoints.env_var(@db, params["key"] ?? "")) + return this._respond(result, "env var deleted") + end + + def _ctx + @db = params["db"] ?? "" + @databases = AdminContext.database_names() + end + + # SoliDB returns a flat { key: value } hash -- flatten to a sorted array + # of rows so the view stays a dumb loop. + def _load + result = SolidbClient.get_api(SolidbEndpoints.env_vars(@db)) + env_map = result["data"] ?? {} + @env_vars = env_map.keys().sort().map do |env_key| + { "key": env_key, "value": env_map[env_key] ?? "" } + end + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + def _respond(result, notice) + @title = "Env vars · " + @db + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("env_vars/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("env_vars/index") + end +end diff --git a/admin/app/controllers/home_controller.sl b/admin/app/controllers/home_controller.sl new file mode 100644 index 00000000..d9adda60 --- /dev/null +++ b/admin/app/controllers/home_controller.sl @@ -0,0 +1,25 @@ +# Home controller - server dashboard + app health endpoint + +class HomeController < Controller + static { + this.layout = "application" + } + + # GET / - server health, cluster info, database count + def index + @title = "Dashboard" + health_result = SolidbClient.get_api(SolidbEndpoints.health()) + @solidb_down = health_result["status"] == 0 + @server_ok = health_result["ok"] + info_result = SolidbClient.get_api(SolidbEndpoints.cluster_info()) + @cluster = info_result["data"] ?? {} + @database_names = AdminContext.database_names() + @flash_error = "" + @flash_notice = "" + end + + # GET /health - app-level health check (used by the proxy) + def health + render_json({ "status": "ok" }) + end +end diff --git a/admin/app/controllers/live_queries_controller.sl b/admin/app/controllers/live_queries_controller.sl new file mode 100644 index 00000000..3e582b11 --- /dev/null +++ b/admin/app/controllers/live_queries_controller.sl @@ -0,0 +1,41 @@ +# Live queries - stream the SoliDB changefeed over a WebSocket the browser +# opens directly against the server. Tokens are short-lived, so the page +# fetches a fresh one from /databases/:db/live/token on every (re)connect. + +class LiveQueriesController < Controller + static { + this.layout = "application" + } + + # GET /databases/:db/live + def show + this._ctx() + @title = "Live · " + @db + @flash_error = "" + @flash_notice = "" + @solidb_down = false + collections_result = SolidbClient.get_api(SolidbEndpoints.collections(@db)) + @collections = (collections_result["data"] ?? {})["collections"] ?? [] + @collection_names = @collections.map do |coll| coll["name"] ?? "" end + end + + # GET /databases/:db/live/token - JSON consumed by the page's JS + def token + this._ctx() + live_token = SolidbClient.livequery_token() + if live_token.blank? + return render_json({ "ok": false, "error": "could not mint a livequery token (is SoliDB up?)" }) + end + return render_json({ + "ok": true, + "token": live_token, + "ws_url": SolidbClient.public_ws_url() + "/_api/ws/changefeed", + "database": @db + }) + end + + def _ctx + @db = params["db"] ?? "" + @databases = AdminContext.database_names() + end +end diff --git a/admin/app/controllers/materialized_views_controller.sl b/admin/app/controllers/materialized_views_controller.sl new file mode 100644 index 00000000..6f2ade64 --- /dev/null +++ b/admin/app/controllers/materialized_views_controller.sl @@ -0,0 +1,124 @@ +# Materialized views - list / create / refresh / drop per database. +# +# SoliDB manages these through SDBQL statements (CREATE/REFRESH MATERIALIZED +# VIEW) run on the cursor API. Metadata lives in the per-db `_views` system +# collection; the server stores the view query as a serialized AST, so on +# create we annotate the metadata doc with the original SDBQL source +# (query_text) to keep definitions readable in the UI. There is no DROP +# statement: dropping = delete the metadata doc + drop the backing collection. + +class MaterializedViewsController < Controller + static { + this.layout = "application" + } + + # GET /databases/:db/views + def index + this._ctx() + @title = "Views · " + @db + this._reset_banners() + this._load() + end + + # POST /databases/:db/views + def create + this._ctx() + name = (params["name"] ?? "").trim() + query_text = (params["query"] ?? "").trim() + if !this._valid_view_name(name) + return this._respond({ "ok": false, "status": 422, + "error": "view name must be an identifier (letters, digits, underscore)" }, "") + end + if query_text.blank? + return this._respond({ "ok": false, "status": 422, "error": "view query is required" }, "") + end + statement = "CREATE MATERIALIZED VIEW " + name + " AS " + query_text + result = SolidbClient.post_api(SolidbEndpoints.cursor(@db), { "query": statement }) + this._annotate_query_text(name, query_text) if result["ok"] + return this._respond(result, "view " + name + " created") + end + + # PUT /databases/:db/views/:name/refresh + def refresh + this._ctx() + name = params["name"] ?? "" + if !this._valid_view_name(name) + return this._respond({ "ok": false, "status": 422, "error": "invalid view name" }, "") + end + statement = "REFRESH MATERIALIZED VIEW " + name + result = SolidbClient.post_api(SolidbEndpoints.cursor(@db), { "query": statement }) + refreshed_count = (result["data"] ?? {})["inserted"] ?? 0 + return this._respond(result, "view " + name + " refreshed (" + str(refreshed_count) + " documents)") + end + + # DELETE /databases/:db/views/:name + def delete + this._ctx() + name = params["name"] ?? "" + result = SolidbClient.delete_api(SolidbEndpoints.document(@db, "_views", name)) + if !result["ok"] + return this._respond(result, "") + end + # Backing collection may already be gone - the metadata doc was the + # source of truth, so a miss here is not an error worth surfacing. + SolidbClient.delete_api(SolidbEndpoints.collection(@db, name)) + return this._respond(result, "view " + name + " dropped") + end + + # View names are spliced into SDBQL statements - restrict to identifiers + # so a crafted name can't smuggle extra clauses in. + def _valid_view_name(name) + return false if name.blank? + cleaned = name.gsub("[^A-Za-z0-9_]", "") + return false if cleaned != name + first_char = name.substring(0, 1) + return !"0123456789".includes?(first_char) + end + + # Best-effort: PUT merges fields into the metadata doc, preserving the + # stored AST. A failure here only loses the pretty definition display. + def _annotate_query_text(name, query_text) + SolidbClient.put_api(SolidbEndpoints.document(@db, "_views", name), { "query_text": query_text }) + end + + # Route context: set explicitly per action (before_action hooks are wired + # by a startup-time scan and are unreliable under dev hot-reload). + def _ctx + @db = params["db"] ?? "" + @databases = AdminContext.database_names() + end + + def _load + list_query = "FOR v IN _views FILTER v.type == \"materialized\" SORT v._key ASC " + + "RETURN { name: v._key, created_at: v.created_at, query_text: v.query_text }" + result = SolidbClient.post_api(SolidbEndpoints.cursor(@db), { "query": list_query }) + # No _views collection yet just means no views were ever created. + @views = result["ok"] ? ((result["data"] ?? {})["result"] ?? []) : [] + counts = {} + collections_result = SolidbClient.get_api(SolidbEndpoints.collections(@db)) + for coll in ((collections_result["data"] ?? {})["collections"] ?? []) + counts[coll["name"] ?? ""] = coll["count"] ?? 0 + end + @view_counts = counts + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + def _respond(result, notice) + @title = "Views · " + @db + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("materialized_views/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("materialized_views/index") + end +end diff --git a/admin/app/controllers/query_controller.sl b/admin/app/controllers/query_controller.sl new file mode 100644 index 00000000..d9cf50d5 --- /dev/null +++ b/admin/app/controllers/query_controller.sl @@ -0,0 +1,124 @@ +# Query console - run SDBQL queries and explain plans against a database. +# Also exposes the slow query log: SoliDB records every query slower than +# 100ms into the per-db _slow_queries system collection. + +class QueryController < Controller + static { + this.layout = "application" + } + + # GET /databases/:db/query + def show + this._ctx() + @title = "Query · " + @db + @flash_error = "" + @flash_notice = "" + @solidb_down = false + collections_result = SolidbClient.get_api(SolidbEndpoints.collections(@db)) + raw_collections = (collections_result["data"] ?? {})["collections"] ?? [] + @collection_names = raw_collections.map do |coll| coll["name"] ?? "" end + end + + # POST /databases/:db/query/run - returns the results fragment (HTMX) + def run + this._ctx() + payload = this._build_payload() + if payload.nil? + return this._render_results({ "ok": false, "error": "bind vars must be a JSON object" }) + end + result = SolidbClient.post_api(SolidbEndpoints.cursor(@db), payload) + return this._render_results(result) + end + + # POST /databases/:db/query/explain - returns the explain fragment (HTMX) + def explain + this._ctx() + payload = this._build_payload() + if payload.nil? + return this._render_explain({ "ok": false, "error": "bind vars must be a JSON object" }) + end + result = SolidbClient.post_api(SolidbEndpoints.explain(@db), payload) + return this._render_explain(result) + end + + # GET /databases/:db/query/slow + def slow + this._ctx() + @title = "Slow queries · " + @db + @flash_error = "" + @flash_notice = "" + @solidb_down = false + this._load_slow() + end + + # GET /databases/:db/query/slow/count - sidebar badge fragment (HTMX) + def slow_count + this._ctx() + @slow_count = this._count_slow() + return render("query/_slow_badge", { "layout": false }) + end + + # PUT /databases/:db/query/slow/clear + def clear_slow + this._ctx() + @title = "Slow queries · " + @db + result = SolidbClient.put_api(SolidbEndpoints.collection_truncate(@db, "_slow_queries")) + @flash_error = result["ok"] ? "" : (result["error"] ?? "request failed") + @flash_notice = result["ok"] ? "slow query log cleared" : "" + @solidb_down = (result["status"] ?? -1) == 0 + this._load_slow() + return render("query/slow", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("query/slow") + end + + # Route context: set explicitly per action (before_action hooks are wired + # by a startup-time scan and are unreliable under dev hot-reload). + def _ctx + @db = params["db"] ?? "" + @databases = AdminContext.database_names() + end + + # nil when the bind-vars textarea holds invalid JSON. + def _build_payload + bind_text = (params["bind_vars"] ?? "").trim() + bind_vars = {} + if !bind_text.blank? + bind_vars = JSON.parse(bind_text) rescue nil + return nil if bind_vars.nil? + end + return { "query": params["query"] ?? "", "bindVars": bind_vars, "batchSize": 200 } + end + + def _render_results(result) + @query_error = result["ok"] ? "" : (result["error"] ?? "request failed") + data = result["data"] ?? {} + @rows = data["result"] ?? [] + @meta = data + return render("query/_results", { "layout": false }) + end + + def _render_explain(result) + @explain_error = result["ok"] ? "" : (result["error"] ?? "request failed") + @plan = result["data"] ?? {} + return render("query/_explain", { "layout": false }) + end + + def _load_slow + statement = "FOR q IN _slow_queries SORT q.timestamp DESC LIMIT 200 RETURN q" + result = SolidbClient.post_api(SolidbEndpoints.cursor(@db), { "query": statement }) + @slow_queries = (result["data"] ?? {})["result"] ?? [] + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + # 0 when the _slow_queries collection is missing or the query fails -- + # the badge just stays hidden. + def _count_slow + statement = "FOR q IN _slow_queries COLLECT WITH COUNT INTO total RETURN total" + result = SolidbClient.post_api(SolidbEndpoints.cursor(@db), { "query": statement }) + counts = (result["data"] ?? {})["result"] ?? [] + return counts[0] ?? 0 + end +end diff --git a/admin/app/controllers/queues_controller.sl b/admin/app/controllers/queues_controller.sl new file mode 100644 index 00000000..b3f6943d --- /dev/null +++ b/admin/app/controllers/queues_controller.sl @@ -0,0 +1,108 @@ +# Queues - background job queues: stats, job lists, enqueue, cancel, run-now. + +class QueuesController < Controller + static { + this.layout = "application" + } + + # GET /databases/:db/queues + def index + this._ctx() + @title = "Queues · " + @db + this._reset_banners() + this._load() + end + + # GET /databases/:db/queues/:name/jobs - HTMX-loaded fragment + def jobs + this._ctx() + @queue_name = params["name"] ?? "" + result = SolidbClient.get_api(SolidbEndpoints.queue_jobs(@db, @queue_name)) + @jobs = (result["data"] ?? {})["jobs"] ?? [] + @jobs_error = result["ok"] ? "" : (result["error"] ?? "request failed") + return render("queues/_jobs", { "layout": false }) + end + + # POST /databases/:db/queues/enqueue (queue name comes from the form) + def enqueue + this._ctx() + queue_name = (params["queue"] ?? "").trim() + queue_name = "default" if queue_name.blank? + payload = this._build_enqueue_payload() + if payload.nil? + return this._respond({ "ok": false, "status": 422, "error": "params must be a JSON object" }, "") + end + job_script = payload["script"] ?? "" + if job_script.blank? + return this._respond({ "ok": false, "status": 422, "error": "script is required" }, "") + end + result = SolidbClient.post_api(SolidbEndpoints.queue_enqueue(@db, queue_name), payload) + return this._respond(result, "job enqueued on " + queue_name) + end + + # DELETE /databases/:db/queues/jobs/:id + def cancel_job + this._ctx() + result = SolidbClient.delete_api(SolidbEndpoints.queue_job(@db, params["id"] ?? "")) + return this._respond(result, "job cancelled") + end + + # POST /databases/:db/queues/jobs/:id/run-now + def run_now + this._ctx() + result = SolidbClient.post_api(SolidbEndpoints.queue_job_run_now(@db, params["id"] ?? "")) + return this._respond(result, "job scheduled to run now") + end + + def _ctx + @db = params["db"] ?? "" + @databases = AdminContext.database_names() + end + + # nil when the params textarea holds invalid JSON. + def _build_enqueue_payload + job_params = {} + params_text = (params["job_params"] ?? "").trim() + if !params_text.blank? + job_params = JSON.parse(params_text) rescue nil + return nil if job_params.nil? + end + payload = { "script": (params["script"] ?? "").trim(), "params": job_params } + priority = (params["priority"] ?? "").trim() + payload["priority"] = priority.to_int() unless priority.blank? + max_retries = (params["max_retries"] ?? "").trim() + payload["max_retries"] = max_retries.to_int() unless max_retries.blank? + run_at = (params["run_at"] ?? "").trim() + payload["run_at"] = run_at unless run_at.blank? + return payload + end + + def _load + result = SolidbClient.get_api(SolidbEndpoints.queues(@db)) + @queues = result["data"] ?? [] + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + def _respond(result, notice) + @title = "Queues · " + @db + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("queues/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("queues/index") + end +end diff --git a/admin/app/controllers/roles_controller.sl b/admin/app/controllers/roles_controller.sl new file mode 100644 index 00000000..253319d9 --- /dev/null +++ b/admin/app/controllers/roles_controller.sl @@ -0,0 +1,102 @@ +# Roles - RBAC role management (builtin roles are read-only). + +class RolesController < Controller + static { + this.layout = "application" + } + + # GET /roles + def index + @title = "Roles" + this._reset_banners() + this._load() + end + + # GET /roles/:name + def show + @title = "Role · " + (params["name"] ?? "") + this._reset_banners() + result = SolidbClient.get_api(SolidbEndpoints.role(params["name"] ?? "")) + @role = result["data"] ?? {} + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + # POST /roles + def create + name = (params["name"] ?? "").trim() + payload = this._build_payload(name) + if payload.nil? + return this._respond({ "ok": false, "status": 422, "error": "permissions must be a JSON array" }, "") + end + if name.blank? + return this._respond({ "ok": false, "status": 422, "error": "role name is required" }, "") + end + result = SolidbClient.post_api(SolidbEndpoints.roles(), payload) + return this._respond(result, "role " + name + " created") + end + + # PUT /roles/:name + def update + name = params["name"] ?? "" + payload = this._build_payload(name) + if payload.nil? + return this._respond({ "ok": false, "status": 422, "error": "permissions must be a JSON array" }, "") + end + result = SolidbClient.put_api(SolidbEndpoints.role(name), payload) + return this._respond(result, "role " + name + " updated") + end + + # DELETE /roles/:name + def delete + name = params["name"] ?? "" + result = SolidbClient.delete_api(SolidbEndpoints.role(name)) + return this._respond(result, "role " + name + " deleted") + end + + # nil when the permissions textarea holds invalid JSON. + def _build_payload(name) + permissions = [] + permissions_text = (params["permissions"] ?? "").trim() + if !permissions_text.blank? + permissions = JSON.parse(permissions_text) rescue nil + return nil if permissions.nil? + end + return { + "name": name, + "description": params["description"] ?? "", + "permissions": permissions + } + end + + def _load + result = SolidbClient.get_api(SolidbEndpoints.roles()) + @roles = result["data"] ?? [] + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + def _respond(result, notice) + @title = "Roles" + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("roles/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("roles/index") + end +end diff --git a/admin/app/controllers/scripts_controller.sl b/admin/app/controllers/scripts_controller.sl new file mode 100644 index 00000000..63ad20da --- /dev/null +++ b/admin/app/controllers/scripts_controller.sl @@ -0,0 +1,127 @@ +# Lua scripts - CRUD for the database's custom Lua endpoints. + +class ScriptsController < Controller + static { + this.layout = "application" + } + + # GET /databases/:db/scripts + def index + this._ctx() + @title = "Scripts · " + @db + this._reset_banners() + this._load() + end + + # GET /databases/:db/scripts/new + def new + this._ctx() + @title = "New script · " + @db + this._reset_banners() + @script = {} + end + + # POST /databases/:db/scripts + def create + this._ctx() + payload = this._build_payload() + script_name = payload["name"] ?? "" + script_path = payload["path"] ?? "" + if script_name.blank? || script_path.blank? + return this._respond({ "ok": false, "status": 422, "error": "name and path are required" }, "") + end + result = SolidbClient.post_api(SolidbEndpoints.scripts(@db), payload) + return this._respond(result, "script " + payload["name"] + " created") + end + + # GET /databases/:db/scripts/:id + def show + this._ctx() + this._reset_banners() + this._load_script() + @title = "Script · " + (@script["name"] ?? "") + end + + # GET /databases/:db/scripts/:id/edit + def edit + this._ctx() + this._reset_banners() + this._load_script() + @title = "Edit script · " + (@script["name"] ?? "") + end + + # PUT /databases/:db/scripts/:id + def update + this._ctx() + payload = this._build_payload() + result = SolidbClient.put_api(SolidbEndpoints.script(@db, params["id"] ?? ""), payload) + return this._respond(result, "script " + (payload["name"] ?? "") + " updated") + end + + # DELETE /databases/:db/scripts/:id + def delete + this._ctx() + result = SolidbClient.delete_api(SolidbEndpoints.script(@db, params["id"] ?? "")) + return this._respond(result, "script deleted") + end + + def _ctx + @db = params["db"] ?? "" + @databases = AdminContext.database_names() + end + + def _build_payload + methods = [] + methods.push("GET") if params["method_get"] == "on" + methods.push("POST") if params["method_post"] == "on" + methods.push("PUT") if params["method_put"] == "on" + methods.push("DELETE") if params["method_delete"] == "on" + methods = ["GET"] if methods.length() == 0 + return { + "name": (params["name"] ?? "").trim(), + "path": (params["path"] ?? "").trim(), + "methods": methods, + "code": params["code"] ?? "", + "description": params["description"] ?? "", + "service": (params["service"] ?? "").trim() + } + end + + def _load + result = SolidbClient.get_api(SolidbEndpoints.scripts(@db)) + @scripts = (result["data"] ?? {})["scripts"] ?? [] + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + def _load_script + result = SolidbClient.get_api(SolidbEndpoints.script(@db, params["id"] ?? "")) + @script = result["data"] ?? {} + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + def _respond(result, notice) + @title = "Scripts · " + @db + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("scripts/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("scripts/index") + end +end diff --git a/admin/app/controllers/triggers_controller.sl b/admin/app/controllers/triggers_controller.sl new file mode 100644 index 00000000..13e9a2f8 --- /dev/null +++ b/admin/app/controllers/triggers_controller.sl @@ -0,0 +1,119 @@ +# Triggers - fire a script or webhook when documents change in a collection. + +class TriggersController < Controller + static { + this.layout = "application" + } + + # GET /databases/:db/triggers + def index + this._ctx() + @title = "Triggers · " + @db + this._reset_banners() + this._load() + end + + # POST /databases/:db/triggers + def create + this._ctx() + payload = this._build_payload() + trigger_name = payload["name"] ?? "" + events = payload["events"] ?? [] + script_target = payload["script_path"] ?? "" + webhook_target = payload["webhook_url"] ?? "" + has_target = script_target != "" || webhook_target != "" + if trigger_name.blank? || (payload["collection"] ?? "") == "" || events.length() == 0 || !has_target + validation_error = "name, collection, at least one event and a script or webhook target are required" + return this._respond({ "ok": false, "status": 422, "error": validation_error }, "") + end + result = SolidbClient.post_api(SolidbEndpoints.triggers(@db), payload) + return this._respond(result, "trigger " + trigger_name + " created") + end + + # POST /databases/:db/triggers/:id/toggle + def toggle + this._ctx() + result = SolidbClient.post_api(SolidbEndpoints.trigger_toggle(@db, params["id"] ?? "")) + now_enabled = (result["data"] ?? {})["enabled"] ?? false + return this._respond(result, now_enabled ? "trigger enabled" : "trigger disabled") + end + + # DELETE /databases/:db/triggers/:id + def delete + this._ctx() + result = SolidbClient.delete_api(SolidbEndpoints.trigger(@db, params["id"] ?? "")) + return this._respond(result, "trigger deleted") + end + + def _ctx + @db = params["db"] ?? "" + @databases = AdminContext.database_names() + end + + def _build_payload + # Checkboxes only submit when checked -- absent means off. + insert_flag = params["event_insert"] ?? "" + update_flag = params["event_update"] ?? "" + delete_flag = params["event_delete"] ?? "" + events = [] + events.push("insert") if insert_flag != "" + events.push("update") if update_flag != "" + events.push("delete") if delete_flag != "" + payload = { + "name": (params["trigger_name"] ?? "").trim(), + "collection": (params["collection"] ?? "").trim(), + "events": events + } + script_path = (params["script_path"] ?? "").trim() + payload["script_path"] = script_path unless script_path.blank? + webhook_url = (params["webhook_url"] ?? "").trim() + payload["webhook_url"] = webhook_url unless webhook_url.blank? + webhook_secret = (params["webhook_secret"] ?? "").trim() + payload["webhook_secret"] = webhook_secret unless webhook_secret.blank? + filter_expr = (params["filter"] ?? "").trim() + payload["filter"] = filter_expr unless filter_expr.blank? + queue = (params["queue"] ?? "").trim() + payload["queue"] = queue unless queue.blank? + priority = (params["priority"] ?? "").trim() + payload["priority"] = priority.to_int() unless priority.blank? + max_retries = (params["max_retries"] ?? "").trim() + payload["max_retries"] = max_retries.to_int() unless max_retries.blank? + return payload + end + + def _load + result = SolidbClient.get_api(SolidbEndpoints.triggers(@db)) + @triggers = (result["data"] ?? {})["triggers"] ?? [] + if !result["ok"] + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + # Collection names feed the target-collection dropdown in the create + # modal. Internal collections (_triggers, _env, _columnar_*...) are not + # valid trigger targets -- hide them. + collections_result = SolidbClient.get_api(SolidbEndpoints.collections(@db)) + all_collections = (collections_result["data"] ?? {})["collections"] ?? [] + visible = all_collections.filter do |coll| !(coll["name"] ?? "").starts_with("_") end + @collection_names = visible.map do |coll| coll["name"] ?? "" end + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + def _respond(result, notice) + @title = "Triggers · " + @db + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("triggers/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("triggers/index") + end +end diff --git a/admin/app/controllers/users_controller.sl b/admin/app/controllers/users_controller.sl new file mode 100644 index 00000000..4e67266a --- /dev/null +++ b/admin/app/controllers/users_controller.sl @@ -0,0 +1,88 @@ +# Users - manage SoliDB users and their role assignments. + +class UsersController < Controller + static { + this.layout = "application" + } + + # GET /users + def index + @title = "Users" + this._reset_banners() + this._load() + end + + # POST /users + def create + username = (params["username"] ?? "").trim() + password = params["password"] ?? "" + if username.blank? || password.blank? + return this._respond({ "ok": false, "status": 422, "error": "username and password are required" }, "") + end + payload = { "username": username, "password": password } + initial_role = (params["initial_role"] ?? "").trim() + payload["initial_role"] = initial_role unless initial_role.blank? + result = SolidbClient.post_api(SolidbEndpoints.users(), payload) + return this._respond(result, "user " + username + " created") + end + + # DELETE /users/:username + def delete + username = params["username"] ?? "" + result = SolidbClient.delete_api(SolidbEndpoints.user(username)) + return this._respond(result, "user " + username + " deleted") + end + + # POST /users/:username/roles + def add_role + username = params["username"] ?? "" + role = (params["role"] ?? "").trim() + if role.blank? + return this._respond({ "ok": false, "status": 422, "error": "role is required" }, "") + end + payload = { "role": role } + database = (params["database"] ?? "").trim() + payload["database"] = database unless database.blank? + result = SolidbClient.post_api(SolidbEndpoints.user_roles(username), payload) + return this._respond(result, "role " + role + " granted to " + username) + end + + # DELETE /users/:username/roles/:role + def remove_role + username = params["username"] ?? "" + role = params["role"] ?? "" + result = SolidbClient.delete_api(SolidbEndpoints.user_role(username, role)) + return this._respond(result, "role " + role + " revoked from " + username) + end + + def _load + users_result = SolidbClient.get_api(SolidbEndpoints.users()) + @users = (users_result["data"] ?? {})["users"] ?? [] + roles_result = SolidbClient.get_api(SolidbEndpoints.roles()) + @role_names = (roles_result["data"] ?? []).map do |role| role["name"] end + if !users_result["ok"] + @flash_error = users_result["error"] ?? "request failed" + @solidb_down = (users_result["status"] ?? -1) == 0 + end + end + + def _reset_banners + @flash_error = "" + @flash_notice = "" + @solidb_down = false + end + + def _respond(result, notice) + @title = "Users" + this._reset_banners() + if result["ok"] + @flash_notice = notice + else + @flash_error = result["error"] ?? "request failed" + @solidb_down = (result["status"] ?? -1) == 0 + end + this._load() + return render("users/index", { "layout": false }) if req["headers"]["hx-request"] == "true" + return render("users/index") + end +end diff --git a/admin/app/helpers/application_helper.sl b/admin/app/helpers/application_helper.sl new file mode 100644 index 00000000..c5ec07ae --- /dev/null +++ b/admin/app/helpers/application_helper.sl @@ -0,0 +1,82 @@ +# Application-wide view helpers + +# Truncate text to a maximum length with ellipsis +def truncate_text(text: String, length: Int, suffix: String) -> String + if text.length() <= length + return text + end + return text.substring(0, length - suffix.length()) + suffix +end + +# Capitalize first letter of a string +def capitalize(text: String) -> String + if text.length() == 0 + return text + end + return text.substring(0, 1).upcase() + text.substring(1, text.length()) +end + +# SEC-012: Reject href values that would let an attacker run JS through +# `javascript:` (or similar) URL schemes. HTML-escaping the URL is *not* +# enough — the browser still parses `javascript:alert(1)` inside an +# `href` attribute. Mirror the allowlist used by the markdown sanitiser. +def _is_safe_link_url(url) + lower = url.downcase() + if lower.starts_with("http://") or lower.starts_with("https://") or lower.starts_with("mailto:") + return true + end + if lower.starts_with("/") or lower.starts_with("#") or lower.starts_with("?") + return true + end + # No allowed scheme prefix; treat as relative *only* if there is no + # scheme separator (`:`) before the first /?#. Anything else is a + # custom scheme like javascript:/data: and must be refused. + cut = len(lower) + s = lower.index_of("/") + if s != -1 and s < cut + cut = s + end + q = lower.index_of("?") + if q != -1 and q < cut + cut = q + end + h = lower.index_of("#") + if h != -1 and h < cut + cut = h + end + return !lower.substring(0, cut).contains(":") +end + +def _safe_link_url(url) + if _is_safe_link_url(url) + return url + end + return "#" +end + +# Generate an HTML link +def link_to(text: String, url: String) -> String + return "" + html_escape(text) + "" +end + +# Generate an HTML link with CSS class +def link_to_class(text: String, url: String, css_class: String) -> String + opening = "" + return opening + html_escape(text) + "" +end + +# Pluralize a word based on count +def pluralize(count: Int, singular: String, plural: String) -> String + if count == 1 + return str(count) + " " + singular + end + return str(count) + " " + plural +end + +# Simple pluralize (adds 's') +def pluralize_simple(count: Int, word: String) -> String + if count == 1 + return str(count) + " " + word + end + return str(count) + " " + word + "s" +end diff --git a/admin/app/helpers/solidb_helper.sl b/admin/app/helpers/solidb_helper.sl new file mode 100644 index 00000000..bcd70028 --- /dev/null +++ b/admin/app/helpers/solidb_helper.sl @@ -0,0 +1,83 @@ +# View formatters for SoliDB admin values (bytes, uptime, JSON blobs). + +def fmt_bytes(num_bytes) + return "0 B" if num_bytes.nil? || num_bytes <= 0 + return str(num_bytes) + " B" if num_bytes < 1024 + kb = num_bytes / 1024.0 + return str((kb * 10.0).round() / 10.0) + " KB" if kb < 1024 + mb = kb / 1024.0 + return str((mb * 10.0).round() / 10.0) + " MB" if mb < 1024 + gb = mb / 1024.0 + return str((gb * 10.0).round() / 10.0) + " GB" +end + +def fmt_uptime(seconds) + return "-" if seconds.nil? || seconds < 0 + return str(seconds) + "s" if seconds < 60 + minutes = seconds / 60 + return str(minutes) + "m" if minutes < 60 + hours = minutes / 60 + return str(hours) + "h " + str(minutes % 60) + "m" if hours < 24 + days = hours / 24 + return str(days) + "d " + str(hours % 24) + "h" +end + +def fmt_percent(value) + return "-" if value.nil? + return str((value * 10.0).round() / 10.0) + "%" +end + +def json_compact(value) + return "" if value.nil? + compact = JSON.stringify(value) rescue "" + return compact ?? "" +end + +def fmt_us(microseconds) + return "-" if microseconds.nil? + return str(microseconds) + " µs" if microseconds < 1000 + milliseconds = microseconds / 1000.0 + return str((milliseconds * 100.0).round() / 100.0) + " ms" if milliseconds < 1000 + seconds = milliseconds / 1000.0 + return str((seconds * 100.0).round() / 100.0) + " s" +end + +# The explain API returns filter expressions as Rust AST debug strings +# ("BinaryOp { left: FieldAccess(Variable(\"c\"), \"age\"), ... }"). +# Rewrite the common nodes back into SDBQL-ish syntax; unknown nodes are +# left as-is, so a partial rewrite still beats the raw dump. +def explain_expr(raw) + return "" if raw.nil? + text = raw + # Leaves. BindVariable must run before Variable (it contains it). + text = text.gsub("BindVariable\\(\"([\\w]+)\"\\)", "@$1") + text = text.gsub("Variable\\(\"([\\w]+)\"\\)", "$1") + text = text.gsub("Literal\\(Bool\\((\\w+)\\)\\)", "$1") + text = text.gsub("Literal\\(Int\\((-?\\d+)\\)\\)", "$1") + text = text.gsub("Literal\\(Float\\((-?[\\d.]+)\\)\\)", "$1") + text = text.gsub("Literal\\(String\\((\"[^\"]*\")\\)\\)", "$1") + text = text.gsub("Literal\\(Null\\)", "null") + # Composite nodes, innermost first - repeat until nothing changes. + prev = "" + while prev != text + prev = text + text = text.gsub("FieldAccess\\(([\\w.@\"\\[\\]]+), \"([\\w]+)\"\\)", "$1.$2") + text = text.gsub("FunctionCall\\(\"([\\w]+)\", \\[([^\\[\\]{}]*)\\]\\)", "$1($2)") + text = text.gsub("UnaryOp \\{ op: Not, operand: ([^{}]*?) \\}", "(NOT $1)") + text = text.gsub("UnaryOp \\{ op: Negate, operand: ([^{}]*?) \\}", "(-$1)") + text = text.gsub("BinaryOp \\{ left: ([^{}]*?), op: ([\\w]+), right: ([^{}]*?) \\}", "($1 $2 $3)") + end + operator_symbols = { + " Equal ": " == ", " NotEqual ": " != ", " LessThan ": " < ", + " LessThanOrEqual ": " <= ", " GreaterThan ": " > ", " GreaterThanOrEqual ": " >= ", + " In ": " IN ", " NotIn ": " NOT IN ", " And ": " AND ", " Or ": " OR ", + " Add ": " + ", " Subtract ": " - ", " Multiply ": " * ", " Divide ": " / ", + " Modulus ": " % ", " Exponent ": " ^ ", " Like ": " LIKE ", " NotLike ": " NOT LIKE ", + " RegEx ": " =~ ", " NotRegEx ": " !~ ", " FuzzyEqual ": " ~= " + } + for operator_name in operator_symbols.keys() + text = text.replace(operator_name, operator_symbols[operator_name]) + end + return text +end + diff --git a/admin/app/middleware/CLAUDE.md b/admin/app/middleware/CLAUDE.md new file mode 100644 index 00000000..7c64aa39 --- /dev/null +++ b/admin/app/middleware/CLAUDE.md @@ -0,0 +1,259 @@ +# Middleware + +Middleware sits between the HTTP server and your controller. Use it for +cross-cutting concerns: auth, logging, request munging, response headers, +rate limits. + +Files live in `app/middleware/*.sl`. The loader scans each `.sl` file at boot, +registers every top-level `def` / `fn` declaration as a middleware function, +and orders them by per-function `# order:` directives. + +## A minimal middleware + +```soli +# app/middleware/auth.sl + +# order: 20 +# scope_only: true + +def authenticate(req) + api_key = req["headers"]["x-api-key"] ?? "" + if api_key == "" + return { + "continue": false, + "response": { + "status": 401, + "headers": { "Content-Type": "application/json" }, + "body": { "error": "Unauthorized" }.to_json + } + } + end + + return { "continue": true, "request": req } +end +``` + +Two things to internalize: + +1. The **return shape** decides whether the request proceeds or is + short-circuited. +2. The **comment directives** above the `def` line determine the function's + order and scoping. + +## Return shape + +Every middleware must return a hash. Two valid shapes: + +| Shape | Effect | +|---------------------------------------------------------|-------------------------------------------------| +| `{ "continue": true, "request": req }` | Proceed to the next middleware / handler. | +| `{ "continue": false, "response": { "status": ..., "body": ..., "headers": {...} } }` | Stop here; return that response. | + +When proceeding, you can pass back a **modified copy** of `req` — that's how +middleware feeds data forward: + +```soli +def attach_request_id(req) + req["request_id"] = uuid() # new field for downstream layers + return { "continue": true, "request": req } +end +``` + +Downstream middleware and the controller see the updated `req`. Don't mutate +`req` in place — return the modified hash via the `"request"` key. + +## File-top directives + +Comments **immediately above** a `def` / `fn` line attach to that function. +Three are supported (see `docs/middleware.md` for the canonical reference): + +| Directive | Default | Meaning | +|----------------------|----------|--------------------------------------------------------------------| +| `# order: N` | `100` | Lower numbers run first. Use `10`-`20` for auth, `90`+ for tail. | +| `# global_only: true`| `false` | Always runs on every request; cannot be excluded by a scope block. | +| `# scope_only: true` | `false` | Only runs when explicitly wrapped in `middleware("name", -> {...})`.| + +Both `#` and `//` are accepted as the comment marker (the loader tries +each prefix). + +There is **no** `# methods:` or `# paths:` directive — restrict by `req["method"]` +or `req["path"]` inside the function body, or use route-level scoping (next +section). + +### Function names != filenames + +The loader registers each function by its **function name**, not the file +name. A file `app/middleware/auth.sl` containing `def authenticate(req) ...` +exposes the middleware as `authenticate`, not `auth`. Use the function name +when scoping in `config/routes.sl`: + +```soli +middleware("authenticate", -> { + get("/admin", "admin#index") +}) +``` + +You can also put multiple middleware functions in one file — each gets its +own directives from the comments immediately above its `def` line. + +```soli +# app/middleware/api.sl + +# order: 10 +def cors(req) + # ... +end + +# order: 50 +# scope_only: true +def require_api_key(req) + # ... +end +``` + +Private functions (names starting with `_`) are skipped — useful for helpers +inside the file. + +## Execution order + +Middleware runs in **ascending `order` value** — `order: 10` runs before +`order: 50` runs before `order: 100`. Default is `100`. Pick small numbers +for things that should see the raw request (auth, rate-limit), large numbers +for things that wrap the response (compression, logging). + +Recommended ranges: + +| Range | Typical use | +|-------------|---------------------------------------------------| +| `1-20` | Logging, request ID assignment, CORS pre-flight. | +| `20-40` | Authentication, session bootstrap. | +| `40-80` | Authorization, feature flags, body parsing. | +| `80-100` | Catch-alls; ad-hoc per-app middleware. | + +Ties between middleware with the same `order` are broken by load order +(filesystem walk order). Be explicit with `order:` to avoid relying on that. + +## Scoping in `config/routes.sl` + +`scope_only` middleware run only inside `middleware("name", -> { ... })` +blocks. Routes outside the block are unaffected. + +```soli +# config/routes.sl + +get("/", "home#index") # no auth +get("/health", "home#health") # no auth + +middleware("authenticate", -> { + get("/admin", "admin#index") + resources("admin/users") + resources("admin/posts") +}) +``` + +A non-`scope_only` middleware (default) always runs — `middleware("name", ...)` +blocks only opt **in** additional `scope_only` middleware; they don't opt +**out** of global ones. + +Combine multiple scoped middleware by nesting: + +```soli +middleware("authenticate", -> { + middleware("require_admin", -> { + resources("admin/users") + }) +}) +``` + +## Common patterns + +### Auth — short-circuit on failure, forward `current_user` on success + +```soli +# order: 20 +# scope_only: true + +def authenticate(req) + user_id = session_get("user_id") + if user_id == nil + return { + "continue": false, + "response": { "status": 302, "headers": { "Location": "/login" }, "body": "" } + } + end + + req["current_user"] = User.find_by("id", user_id) + return { "continue": true, "request": req } +end +``` + +The handler reads `req["current_user"]` without needing to look it up again. + +### Logging — runs on every request + +```soli +# order: 5 +# global_only: true + +def request_log(req) + print("#{req[\"method\"]} #{req[\"path\"]}") + return { "continue": true, "request": req } +end +``` + +### Rate limit — short-circuit with 429 + +```soli +# order: 15 + +def rate_limit(req) + key = req["headers"]["x-api-key"] ?? req["remote_ip"] + if not _allow(key) + return { + "continue": false, + "response": { "status": 429, "body": "Too many requests" } + } + end + return { "continue": true, "request": req } +end + +def _allow(key) # private, not registered as middleware + # ... bucket logic ... +end +``` + +## Testing middleware + +E2E specs in `tests/` exercise middleware as a side-effect of hitting a route: + +```soli +describe("authenticate middleware") do + test("denies unauthenticated requests to /admin") do + response = get("/admin") + assert_eq(res_status(response), 302) + assert_eq(res_headers(response)["Location"], "/login") + end + + test("allows authenticated requests to /admin") do + as_user(1) + response = get("/admin") + assert_eq(res_status(response), 200) + end +end +``` + +There's no built-in way to call a middleware function in isolation — they're +designed to run in the request pipeline. Test them via E2E. + +## Do / Don't + +| Do | Don't | +|-------------------------------------------------------------|------------------------------------------------------------------| +| Use `# order:` to make the ordering explicit | Rely on filesystem load order | +| Return the modified `req` via `"request"` | Mutate `req` in place | +| Use `# scope_only: true` for anything not globally desired | Add per-request `req["path"]` checks to gate a global middleware | +| Use `.to_json` to serialize the response body | Use legacy `json_stringify(...)` — convention is the method form | +| Use `#{...}` interpolation | Use `\(...)` — the lexer rejects it | +| Add `_helper` private functions in the same file | Pull single-file helpers into a separate module | +| Pass forward data via `req["custom_key"]` | Use global variables to communicate between middleware and handler| +| Test via E2E specs | Try to unit-test by calling the function directly | diff --git a/admin/app/middleware/cors.sl b/admin/app/middleware/cors.sl new file mode 100644 index 00000000..b4c895da --- /dev/null +++ b/admin/app/middleware/cors.sl @@ -0,0 +1,24 @@ +# ============================================================================ +# CORS Middleware (Global) +# ============================================================================ +# +# This middleware adds CORS headers to all responses. +# It runs for ALL requests automatically. +# +# Configuration: +# - `# order: N` - Execution order (lower runs first) +# - `# global_only: true` - Runs for all requests, cannot be scoped +# +# ============================================================================ + +# order: 5 +# global_only: true + +def add_cors_headers(req: Any) -> Any + # Add CORS headers to the request context + # These will be included in the response + return { + "continue": true, + "request": req + } +end diff --git a/admin/app/models/CLAUDE.md b/admin/app/models/CLAUDE.md new file mode 100644 index 00000000..7753bb3f --- /dev/null +++ b/admin/app/models/CLAUDE.md @@ -0,0 +1,460 @@ +# Models + +This directory holds the data layer. **One file per model**: `post.sl` defines +`class Post < Model`. Filenames are `snake_case.sl`; class names are +`PascalCase`, singular. + +Models are auto-loaded by `soli serve` — controllers and migrations reference +them by class name without an `import`. Adding `import "../models/*.sl"` +inside a controller trips `style/redundant-model-import`. + +Models own validation, persistence, and business rules. Controllers are thin; +push every "X happens when Y is created" rule into the model layer. + +## Anatomy of a model + +```soli +class Post < Model + # Associations + belongs_to("user") + has_many("comments") + + # Validations + validates("title", { "presence": true, "min_length": 3, "max_length": 200 }) + validates("body", { "presence": true }) + + # Lifecycle callbacks + before_save("normalize_title") + after_create("notify_subscribers") + + # Named scopes + scope("published", fn() { this.where({ "status": "published" }) }) + scope("recent", fn() { this.order("created_at", "desc").limit(10) }) + + # Instance methods (your own logic) + def normalize_title + this.title = this.title.trim() + end + + def notify_subscribers + # ... + end +end +``` + +Models are **untyped** — you don't declare `title: String` at class level. +Fields are inferred from what you assign / persist, and validated by the rules +you register. + +## Inherited CRUD (don't override) + +These come with `< Model`. They use the worker's pre-configured SoliDB +connection. + +| Class method | What it does | +|---------------------------------------|-----------------------------------------------------------------------| +| `Model.all` | All records as instances. | +| `Model.find(id)` | Lookup by id. **Raises `RecordNotFound` on miss → 404 in controllers.**| +| `Model.find_by(field, val)` | First match, or `nil`. | +| `Model.first_by(field, val)` | First match with ordering, or `nil`. | +| `Model.where({...})` / `where("doc.x == @a", {"a": ...})` | Filter (see Querying below). | +| `Model.create({...})` | Insert with validation. Always returns an instance (see `_errors`). | +| `Model.find_or_create_by(field, val, defaults?)` | Look up or insert. | +| `Model.upsert(key, data)` | Insert if absent, else update. | +| `Model.create_many([{...}, ...])` | Batch insert. | +| `Model.count` | Row count. | +| `Model.delete_all` | Wipe the collection. Dangerous — use with care. | +| `Model.with_deleted` / `Model.only_deleted` | Include / restrict to soft-deleted records. | +| `Model.transaction` | Open a transaction (returns a Transaction with `get/create/update/delete/commit/rollback`). | +| `Model.paginate({ "page": 1, "per": 20 })` | Returns `{ "records": [...], "pagination": {...} }`. | + +| Instance method | What it does | +|---------------------------------------|-----------------------------------------------------------------------| +| `instance.save([attrs])` | Insert or update. Returns `true` / `false`. | +| `instance.update({...})` | Apply attrs and save. | +| `instance.delete` | Delete (or soft-delete if the model uses soft-deletes). | +| `instance.restore` | Undo a soft-delete. | +| `instance.reload` | Re-fetch from the DB; refresh all fields. | +| `instance.increment("counter", n=1)` | Atomic `+=`. | +| `instance.decrement("counter", n=1)` | Atomic `-=`. | +| `instance.touch` | Bump `_updated_at`. | +| `instance._errors` | Array of `{"field": ..., "message": ...}` after a failed save/create. | + +If you find yourself shadowing one of these with a `static def all ... end` or +similar, **stop** — you're working against the framework. Add a `scope` or a +class-side helper with a different name instead. + +## Querying + +Three forms, in order of preference: + +### 1. Hash form (safe — use this for user input) + +```soli +User.where({ "role": params["role"], "active": true }) +``` + +Keys are validated against the model's field set; values are bound as +parameters. Safe to pass `params` values straight in. + +### 2. String form with binds (developer-trusted, but parameterized) + +```soli +User.where("doc.age >= @min AND doc.role == @role", { + "min": params["min_age"], + "role": params["role"] +}) +``` + +The condition is your code — **never concatenate user input into the string**. +Bind everything that came from outside via `@name` placeholders. Use this when +the hash form isn't expressive enough (range, OR, function calls). + +### 3. Raw `@sdbql{ ... }` (when the ORM doesn't fit) + +```soli +let min_age = 18 +let users = @sdbql{ + FOR u IN users + FILTER u.age >= #{min_age} + SORT u.name ASC + LIMIT 50 + RETURN u +} +``` + +`#{expr}` inside the block is **bound as a parameter** (not string-interpolated +as text), so it's safe. Reach for this for joins, subqueries, and anything +hand-tuned. Returns raw documents, not model instances. + +### Chaining + +`where`, `order`, `limit`, `offset`, `select`/`fields`, `pluck`, `includes`, +`includes_count`, `join` all return a chainable `QueryBuilder`. Terminate the +chain with one of: + +| Terminator | Returns | +|--------------------|----------------------------------------------------------| +| `.all` | Array of instances. | +| `.first` | First instance, or `nil`. | +| `.count` | Number. | +| `.exists` | Boolean. | +| `.pluck("field")` | Array of values. | +| `.sum/avg/min/max("field")` | Numeric aggregate. | +| `.group_by(field, func, agg_field)` | Array of `{group, result}` hashes. | + +```soli +let recent = Post + .where({ "status": "published" }) + .order("created_at", "desc") + .limit(20) + .all + +let total_views = Post.where({ "user_id": user.id }).sum("views") +``` + +## Validations + +Pass an options hash to `validates`. All keys are optional; combine freely. + +| Option | Effect | +|------------------------|----------------------------------------------------------------------------| +| `"presence": true` | Required; rejects `null`, `""`, missing. | +| `"uniqueness": true` | Best-effort pre-check + relies on a unique DB index for atomicity. | +| `"min_length": N` | String length ≥ N. | +| `"max_length": N` | String length ≤ N. | +| `"format": "regex"` | String matches the pattern. | +| `"numericality": true` | Value is a number. | +| `"min": N` / `"max": N`| Numeric bounds. | +| `"custom": "method"` | Calls `this.method()` for full custom checks; method appends to `_errors`. | + +```soli +class User < Model + validates("email", { "presence": true, "uniqueness": true, "format": "^[^@]+@[^@]+$" }) + validates("age", { "numericality": true, "min": 0, "max": 150 }) + validates("name", { "custom": "validate_name" }) + + def validate_name + if this.name.blank? || this.name.length() < 2 + this._errors.push({ "field": "name", "message": "too short" }) + end + end +end +``` + +### Reading `_errors` + +`_errors` is an **array of hashes**, not a hash keyed by field: + +```soli +@user = User.create(params) +if @user._errors + for err in @user._errors + print("#{err.field}: #{err.message}") + end +end +``` + +On a clean save `_errors` is `nil` (not `[]`). Check `if @user._errors` — +truthiness is correct here. + +## Associations + +```soli +class Post < Model + belongs_to("user") # Post.user_id (FK), post.user (instance) + has_many("comments") # user.comments (QueryBuilder) + has_one("featured_image") # one-to-one + has_and_belongs_to_many("tags") # M2M via join collection +end +``` + +Conventions: + +- `belongs_to("user")` adds the `user_id` FK to **this** collection. Instance + accessor `post.user` lazy-loads on first read. +- `has_many("comments")` adds the FK on the *other* side (comments have + `user_id`). The accessor returns a `QueryBuilder` — chain on it: + `user.posts.where({"status": "published"}).count`. +- `has_one` works like `has_many` but returns a single instance. +- All four accept overrides: + `belongs_to("author", { "class_name": "User", "foreign_key": "author_id" })`. + +### Eager loading + +Avoid N+1 by pre-loading on the query: + +```soli +let posts = Post.where({...}).includes("user", "comments").all +# posts[0].user and posts[0].comments are now materialized in memory +``` + +`includes_count("comments")` adds a `comments_count` integer to each +instance — handy for index pages. + +## Scopes + +A `scope` is a class-side query alias. The body runs with `this` bound to a +fresh `QueryBuilder`, so `this.where(...)` / `this.order(...)` chain off it. + +```soli +class Post < Model + scope("published", fn() { this.where({ "status": "published" }) }) + scope("by_user", fn(user_id) { this.where({ "user_id": user_id }) }) +end + +Post.published.order("created_at", "desc").limit(20).all +Post.by_user(current_user.id).published.count +``` + +Both `Post.published` and `Post.published()` invoke the scope. + +For class-body DSL closures — scopes, validators, callbacks — prefer the +explicit `fn() { this.method(...) }` form over implicit-self alternatives. + +## Lifecycle callbacks + +Eight hooks, each takes a **method-name string** (not a lambda): + +| Hook | When it fires | +|-------------------|------------------------------------------------| +| `before_create` | New record, before insert. | +| `after_create` | New record, after insert succeeded. | +| `before_update` | Existing record, before save. | +| `after_update` | Existing record, after save succeeded. | +| `before_save` | Either insert or update, before persist. | +| `after_save` | Either insert or update, after persist. | +| `before_delete` | Before `instance.delete`. | +| `after_delete` | After delete succeeded. | + +```soli +class Post < Model + before_save("normalize_title") + after_create("notify_subscribers") + + def normalize_title + this.title = this.title.trim() + end + + def notify_subscribers + # send mail, enqueue job, etc. + end +end +``` + +A `before_*` callback that mutates `this._errors` (or returns `false`, +depending on hook) aborts the operation. + +## Other class-body helpers + +- `attr_accessible(field1, field2, ...)` — whitelist fields for mass-assignment. + When set, `Model.create(params)` silently drops any key not on the list. + Pair with controller-side `_permit_params` for defense in depth. +- `uploader("avatar", { ... })` — declare a blob attachment field. See + **Attachments and uploads** below for the full contract. +- `translate("title", "body")` — declare translatable fields (i18n). + +## Attachments and uploads + +Declare a blob attachment with `uploader("field", { ... })` in the class body. +The framework wires the validation, storage (SoliDB blob collection), and +URL/HTTP plumbing for you — controllers don't need to touch the blob store. + +```soli +class Contact < Model + uploader("photo", { + "multiple": false, + "content_types": ["image/jpeg", "image/png", "image/webp"], + "max_size": 2_000_000, # bytes — rejects above this + "collection": "contact_photos" # optional; defaults to "contact_photos" + }) + + uploader("attachments", { # multi-file field + "multiple": true, + "content_types": ["application/pdf", "image/png"], + "max_size": 5_000_000 + }) +end +``` + +| Option | Meaning | +|-----------------|----------------------------------------------------------------------------------| +| `multiple` | `false` (default) → one blob per record. `true` → array of blob ids. | +| `content_types` | Allow-list of MIME types. Anything else is rejected before storage. | +| `max_size` | Hard cap in bytes. Above this → `_errors` populated, no blob stored. | +| `collection` | SoliDB blob collection name. Defaults to `_s` (`contact_photos`). | + +The uploader adds a `_blob_id` column (single) or `_blob_ids` +array (multiple) to the document. You don't read those directly — use the +auto-generated instance methods below. + +### Auto-generated instance methods + +`uploader("photo", ...)` adds three methods on every instance: + +| Method | What it does | +|-------------------------------|------------------------------------------------------------------------------| +| `contact.attach_photo(file)` | Validate + store the file, update the `_blob_id(s)` column. | +| `contact.detach_photo(id?)` | Delete the blob and clear the column. `id` is required when `multiple: true`. | +| `contact.photo_url(opts?)` | Return the public URL for the stored blob (or `nil` if none stored). | + +`file` is the hash returned by `find_uploaded_file(req, "photo")` in a +controller (see **Controllers — Handling file uploads**). On a failed attach, +`contact._errors` is populated and `attach_photo` returns `false` — the same +error-rendering flow used by `Model.create` validation failures. + +```soli +def create + @contact = Contact.create(this._permit_params(params)) + if @contact._errors + return render("contacts/new") + end + + photo = find_uploaded_file(params, "photo") + if !photo.nil? && !@contact.attach_photo(photo) + return render("contacts/new") # attach failed → _errors set + end + + redirect(contact_path(@contact)) +end +``` + +### Wiring the upload routes + +Add `uploads("contacts", "photo")` to `config/routes.sl`. That single call +registers a GET / POST / DELETE family (plus `:blob_id`-scoped variants for +multi-file fields) backed by the framework's built-in `AttachmentsController`: + +```soli +# config/routes.sl +resources("contacts") +uploads("contacts", "photo") # for the photo field +uploads("contacts", "attachments") # for the multi-file field +``` + +| Route | Purpose | +|------------------------------------------------------|----------------------------------| +| `GET /contacts/:id/photo` | Stream the blob (with transforms).| +| `POST /contacts/:id/photo` | Upload a file (multipart). | +| `DELETE /contacts/:id/photo` | Detach (single) or the named blob.| +| `GET /contacts/:id/attachments/:blob_id` | Stream one entry from a multi field. | +| `DELETE /contacts/:id/attachments/:blob_id` | Remove that one entry. | + +The default routes target the framework's `AttachmentsController` — override +by defining your own `class AttachmentsController < Controller` if you need +auth checks, signed URLs, etc. Soli's loader processes app controllers after +the framework prelude, so a same-named class shadows the default cleanly. + +### Cleanup on delete + +`detach_all_uploads(record)` is available for `before_delete` hooks if you +need to wipe attached blobs alongside the record. Without it, a deleted +record leaves orphan blobs in the collection. + +```soli +class Contact < Model + uploader("photo", { ... }) + before_delete("cleanup_uploads") + + def cleanup_uploads + detach_all_uploads(this) + end +end +``` + +## Inspecting AQL queries (`--dev`) + +`dev_queries()` returns the AQL stack issued for the current request when the +server runs with `--dev`. Each entry is `{ "query": String, "bind_vars": +Hash | null, "duration_ms": Float }`. Useful for building a debug bar or +spotting N+1s. + +```erb +<% if dev_queries().length() > 0 %> +
    + <% for q in dev_queries() %> +
    <%= q.query %> (<%= q.duration_ms %>ms)
    + <% end %> +
    +<% end %> +``` + +In production, `dev_queries()` returns `[]` (so the `length() > 0` guard +collapses to nothing) with zero overhead. + +## Do / Don't + +| Do | Don't | +|---------------------------------------------------------------|--------------------------------------------------------------------| +| Put validation rules on the model | Validate in the controller | +| Push business rules into model methods | Spread "what happens when X is created" across controllers | +| Use `where({...})` (hash form) for user input | Concatenate strings: `where("role = " + params["role"])` | +| Use `#{expr}` bound interpolation in `@sdbql{...}` | Use `\(expr)` — that's a docs typo; the lexer rejects it | +| Use `find_by` / `first_by` when nil-on-miss is correct | Wrap `find` in try/catch to convert raise → nil | +| Chain `.where.order.limit.all` for readability | Build SDBQL strings in the controller | +| Use `includes(...)` to dodge N+1 | Call `post.user` inside a `for post in posts` loop without eager load | +| Use callbacks for cross-cutting concerns (timestamps, slugs) | Use callbacks for anything you'd want to disable in a test | +| Declare `attr_accessible` on the model | Trust the controller to filter every caller | + +## Spec location + +Model specs live in `tests/_model_spec.sl`. Example: + +```soli +describe("Post") do + test("rejects empty title") do + @post = Post.new({ "title": "", "body": "x" }) + @post.save + assert(@post._errors) + assert_eq(@post._errors[0].field, "title") + end + + test("normalize_title trims whitespace before save") do + @post = Post.create({ "title": " hello ", "body": "x" }) + assert_eq(@post.title, "hello") + end +end +``` + +Hit the real DB in model specs — that's where the validation and constraint +behavior actually lives. Don't mock the database. diff --git a/admin/app/services/admin_context.sl b/admin/app/services/admin_context.sl new file mode 100644 index 00000000..d0588d0e --- /dev/null +++ b/admin/app/services/admin_context.sl @@ -0,0 +1,14 @@ +# app/services/admin_context.sl +# +# Small shared lookups used by several controllers (e.g. the topbar database +# picker on db-scoped pages). + +class AdminContext + # Sorted database names, [] when SoliDB is unreachable. + static def database_names() + result = SolidbClient.get_api(SolidbEndpoints.databases()) + return [] unless result["ok"] + names = (result["data"] ?? {})["databases"] ?? [] + return names.sort() + end +end diff --git a/admin/app/services/solidb_client.sl b/admin/app/services/solidb_client.sl new file mode 100644 index 00000000..adb02d75 --- /dev/null +++ b/admin/app/services/solidb_client.sl @@ -0,0 +1,172 @@ +# app/services/solidb_client.sl +# +# Single gateway to the SoliDB HTTP API. Controllers never call HTTP.request +# directly -- they go through SolidbClient.get_api/post_api/put_api/delete_api, +# which return a uniform { "ok", "status", "data", "error" } hash. +# +# Auth: POST /auth/login with the SOLIDB_USERNAME/SOLIDB_PASSWORD env creds +# yields a JWT (~24h exp). The token is cached process-wide in a static field +# (same pattern as bonfire's SolidbLive). On any 401 the cache is cleared and +# the request retried once with a fresh login, so expiry is self-healing. +# +# NOTE: HTTP.request()'s SSRF guard blocks loopback hosts unless +# SOLI_DEV_ALLOW_SSRF=1 is set (dev only -- see .env). + +class SolidbClient + # Process-wide JWT cache. One shared admin token is correct here: every + # request acts as the single SOLIDB_USERNAME identity, nothing per-user. + static cached_token: String = "" + + static def host() + return getenv("SOLIDB_HOST") ?? "http://localhost:6745" + end + + static def username() + return getenv("SOLIDB_USERNAME") ?? "admin" + end + + static def password() + return getenv("SOLIDB_PASSWORD") ?? "admin" + end + + # Browser-reachable WebSocket base for the changefeed page. Prefer an + # explicit SOLIDB_PUBLIC_WS_URL (prod, behind a proxy); otherwise derive + # from SOLIDB_HOST by swapping the scheme to ws(s). + static def public_ws_url() + explicit = getenv("SOLIDB_PUBLIC_WS_URL") ?? "" + return explicit unless explicit.blank? + return SolidbClient.derive_ws_url(SolidbClient.host()) + end + + # Scheme-swap derivation, split out so every host shape is unit-testable. + static def derive_ws_url(http_url) + if http_url.starts_with("https://") + return "wss://" + http_url.substring(8, http_url.length()) + elsif http_url.starts_with("http://") + return "ws://" + http_url.substring(7, http_url.length()) + end + return http_url + end + + # --- pure helpers (unit-tested without network) --------------------------- + + static def auth_headers(token) + return { + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + "Accept": "application/json" + } + end + + static def is_unauthorized(resp) + return false if resp.nil? + return (resp["status"] ?? 0) == 401 + end + + # Raw HTTP response -> uniform result hash. nil means the request never got + # out (connection refused / SSRF-blocked); status 0 marks that case so the + # UI can show the "SoliDB unreachable" banner instead of a per-action error. + static def interpret(resp) + if resp.nil? + return { "ok": false, "status": 0, "data": nil, "error": "SoliDB unreachable" } + end + status = resp["status"] ?? 0 + data = JSON.parse(resp["body"] ?? "") rescue nil + if status >= 200 && status < 300 + return { "ok": true, "status": status, "data": data, "error": nil } + end + return { "ok": false, "status": status, "data": data, "error": SolidbClient.error_message(status, data) } + end + + # Best-effort human message from a SoliDB error body. + static def error_message(status, data) + detail = "" + detail = data["error"] ?? (data["message"] ?? "") unless data.nil? + return "HTTP " + str(status) if detail.blank? + return "HTTP " + str(status) + " - " + detail + end + + # --- auth ------------------------------------------------------------------ + + # Mint a JWT via POST /auth/login and cache it. Returns "" on failure (a + # failed mint never poisons a previously-good cache entry -- it returns + # before the assignment). + static def login() + headers = { "Content-Type": "application/json", "Accept": "application/json" } + body = { "username": SolidbClient.username(), "password": SolidbClient.password() } + resp = HTTP.request("POST", SolidbClient.host() + "/auth/login", headers, body) rescue nil + result = SolidbClient.interpret(resp) + return "" unless result["ok"] + token = (result["data"] ?? {})["token"] ?? "" + return "" if token.blank? + SolidbClient.cached_token = token + return token + end + + static def token() + return SolidbClient.cached_token unless SolidbClient.cached_token.blank? + return SolidbClient.login() + end + + static def clear_token() + SolidbClient.cached_token = "" + end + + # --- generic request with one re-auth retry on 401 ------------------------- + + static def api(method, path, body = nil) + auth = SolidbClient.token() + if auth.blank? + return { "ok": false, "status": 0, "data": nil, + "error": "SoliDB authentication failed (check SOLIDB_HOST / SOLIDB_USERNAME / SOLIDB_PASSWORD)" } + end + resp = SolidbClient.send_request(method, path, body, auth) + # A nil response can be a stale pooled keep-alive connection dying on + # send ("error sending request") - the request never reached SoliDB, + # so one immediate retry on a fresh connection is safe and self-heals. + resp = SolidbClient.send_request(method, path, body, auth) if resp.nil? + if SolidbClient.is_unauthorized(resp) + SolidbClient.clear_token() + auth = SolidbClient.login() + return SolidbClient.interpret(resp) if auth.blank? + resp = SolidbClient.send_request(method, path, body, auth) + end + return SolidbClient.interpret(resp) + end + + static def send_request(method, path, body, auth) + headers = SolidbClient.auth_headers(auth) + full_url = SolidbClient.host() + path + if body.nil? + resp = HTTP.request(method, full_url, headers) rescue nil + return resp + end + resp = HTTP.request(method, full_url, headers, body) rescue nil + return resp + end + + static def get_api(path) + return SolidbClient.api("GET", path) + end + + static def post_api(path, body = nil) + return SolidbClient.api("POST", path, body) + end + + static def put_api(path, body = nil) + return SolidbClient.api("PUT", path, body) + end + + static def delete_api(path) + return SolidbClient.api("DELETE", path) + end + + # --- live queries ----------------------------------------------------------- + + # Short-lived token for the browser's changefeed WebSocket. + static def livequery_token() + result = SolidbClient.get_api("/_api/livequery/token") + return "" unless result["ok"] + return (result["data"] ?? {})["token"] ?? "" + end +end diff --git a/admin/app/services/solidb_endpoints.sl b/admin/app/services/solidb_endpoints.sl new file mode 100644 index 00000000..94eb4ee4 --- /dev/null +++ b/admin/app/services/solidb_endpoints.sl @@ -0,0 +1,192 @@ +# app/services/solidb_endpoints.sl +# +# Pure SoliDB API path builders. Controllers compose SolidbClient calls with +# these instead of hand-concatenating URL strings, so the paths live in one +# place and every builder is unit-testable. + +class SolidbEndpoints + # --- server / cluster --- + static def health() + return "/_api/health" + end + + static def cluster_info() + return "/_api/cluster/info" + end + + static def livequery_token() + return "/_api/livequery/token" + end + + # --- databases --- + static def databases() + return "/_api/databases" + end + + static def database_create() + return "/_api/database" + end + + static def database(name) + return "/_api/database/" + name + end + + # --- users / roles / api keys --- + static def users() + return "/_api/auth/users" + end + + static def user(username) + return "/_api/auth/users/" + username + end + + static def user_roles(username) + return "/_api/auth/users/" + username + "/roles" + end + + static def user_role(username, role) + return "/_api/auth/users/" + username + "/roles/" + role + end + + static def roles() + return "/_api/auth/roles" + end + + static def role(name) + return "/_api/auth/roles/" + name + end + + static def api_keys() + return "/_api/auth/api-keys" + end + + static def api_key(key_id) + return "/_api/auth/api-keys/" + key_id + end + + # --- collections --- + static def collections(db) + return "/_api/database/" + db + "/collection" + end + + static def collection(db, name) + return "/_api/database/" + db + "/collection/" + name + end + + static def collection_stats(db, name) + return SolidbEndpoints.collection(db, name) + "/stats" + end + + static def collection_truncate(db, name) + return SolidbEndpoints.collection(db, name) + "/truncate" + end + + # --- indexes (standard + fulltext share one family; geo / ttl have + # their own create+list APIs, but DELETE /index/{coll}/{name} drops any) --- + static def collection_indexes(db, name) + return "/_api/database/" + db + "/index/" + name + end + + static def collection_index(db, name, index_name) + return SolidbEndpoints.collection_indexes(db, name) + "/" + index_name + end + + static def collection_indexes_rebuild(db, name) + return SolidbEndpoints.collection_indexes(db, name) + "/rebuild" + end + + static def geo_indexes(db, name) + return "/_api/database/" + db + "/geo/" + name + end + + static def ttl_indexes(db, name) + return "/_api/database/" + db + "/ttl/" + name + end + + # --- columnar collections (separate API family) --- + static def columnar(db) + return "/_api/database/" + db + "/columnar" + end + + static def columnar_collection(db, name) + return "/_api/database/" + db + "/columnar/" + name + end + + # --- documents --- + static def documents(db, collection_name) + return "/_api/database/" + db + "/document/" + collection_name + end + + static def document(db, collection_name, key) + return "/_api/database/" + db + "/document/" + collection_name + "/" + key + end + + # --- queries --- + static def cursor(db) + return "/_api/database/" + db + "/cursor" + end + + static def explain(db) + return "/_api/database/" + db + "/explain" + end + + # --- lua scripts --- + static def scripts(db) + return "/_api/database/" + db + "/scripts" + end + + static def script(db, script_id) + return "/_api/database/" + db + "/scripts/" + script_id + end + + # --- queues / jobs / cron --- + static def queues(db) + return "/_api/database/" + db + "/queues" + end + + static def queue_jobs(db, queue_name) + return "/_api/database/" + db + "/queues/" + queue_name + "/jobs" + end + + static def queue_enqueue(db, queue_name) + return "/_api/database/" + db + "/queues/" + queue_name + "/enqueue" + end + + static def queue_job(db, job_id) + return "/_api/database/" + db + "/queues/jobs/" + job_id + end + + static def queue_job_run_now(db, job_id) + return "/_api/database/" + db + "/queues/jobs/" + job_id + "/run-now" + end + + static def cron_jobs(db) + return "/_api/database/" + db + "/cron" + end + + static def cron_job(db, cron_id) + return "/_api/database/" + db + "/cron/" + cron_id + end + + # --- triggers --- + static def triggers(db) + return "/_api/database/" + db + "/triggers" + end + + static def trigger(db, trigger_id) + return "/_api/database/" + db + "/triggers/" + trigger_id + end + + static def trigger_toggle(db, trigger_id) + return SolidbEndpoints.trigger(db, trigger_id) + "/toggle" + end + + # --- env vars --- + static def env_vars(db) + return "/_api/database/" + db + "/env" + end + + static def env_var(db, key) + return "/_api/database/" + db + "/env/" + key + end +end diff --git a/admin/app/views/CLAUDE.md b/admin/app/views/CLAUDE.md new file mode 100644 index 00000000..38da48cb --- /dev/null +++ b/admin/app/views/CLAUDE.md @@ -0,0 +1,409 @@ +# Views + +This directory holds ERB-style templates with the `.html.slv` extension. + +**Convention**: a controller action renders `app/views//.html.slv` +by default. `PostsController#show` → `app/views/posts/show.html.slv`. +Partials live next to their parent view and start with an underscore: +`_post.html.slv`. + +## Template tags + +| Tag | Behavior | +|----------------------|----------------------------------------------------------------| +| `<%= expr %>` | Evaluate and **HTML-escape**. Safe default for user input. | +| `<%- expr %>` | Evaluate, output **raw** (no escaping). Trusted HTML only. | +| `<% stmt %>` | Evaluate; no output. Used for control flow. | +| `<%# comment %>` | Comment — content is silently dropped, even across lines. | + +`<%== expr %>` was removed (SEC-023). If you need to render pre-escaped HTML +(e.g. a Markdown render), use `<%- html %>` *or* `<%= html_unescape(html) %>`, +not `<%==`. + +XSS is the default risk. Default to `<%= %>` for anything that touches user +content; reach for `<%- %>` only when you can prove the value is trusted. + +## Locals — three ways to pass data into a template + +1. **Implicit via `@`-variables on the controller** (recommended; covers most + cases). Anything you assign to `@field` inside an action becomes available + as both `@field` and bare `field` in the template — no need to pass it in + `render(...)`'s data hash. Underscore-prefixed fields (`@_internal`) are + private and not exposed. + + ```soli + # in the controller + def show + @post = Post.find(params["id"]) + @title = "Read \"#{@post.title}\"" + end + ``` + + ```erb +

    <%= @title %>

    +
    <%= @post.body %>
    + ``` + +2. **Explicit data hash on `render`**. Use this when you want to render a + different template, or pass a value under a name that doesn't match a + controller field. + + ```soli + render("posts/show_summary", { "summary_text": build_summary(@post) }) + ``` + +3. **`locals[...]` for collisions**. When a local name shadows a builtin or + helper, read it through the `locals` hash instead of bare. `locals` is + always defined (even when no data was passed). + + ```erb + <%# `partial` is a builtin; locals["partial"] disambiguates %> + <%= locals["partial"] %> + ``` + +## Iteration + +```erb +<% for post in @posts %> +
    +

    <%= h(post.title) %>

    + <%= post.body %> +
    +<% end %> + +<% for post, i in @posts %> +

    #<%= i %>: <%= h(post.title) %>

    +<% end %> + +<% if @posts.length() == 0 %> +

    No posts yet.

    +<% end %> +``` + +`<% xs.each do |x| %> ... <% end %>` does **NOT** work inside ERB. The +template engine only recognises `for x in xs` / `for x, i in xs` as the loop +syntax. Use `each` in regular `.sl` code, `for` in templates. + +## Escape helpers + +Pick the helper that matches the *context* where the value lands. `<%= %>` +calls `h()` automatically, which is right for HTML body text but wrong for +attributes / JS / URLs. + +| Helper | Use when the value goes into… | +|--------------------------|----------------------------------------------------------------------| +| `h(value)` | HTML body text (default — auto-applied by `<%= %>`). | +| `attr(value)` | An HTML attribute. Escapes `"`, `'`, `<`, `>`, `&`. | +| `j(value)` | A ` + +">search + +<%# user-authored rich text: sanitize, then emit as raw output %> +<%- sanitize_html(post.body_html) %> +``` + +## Partials + +Render a partial with `partial("dir/name", { "local_key": value })`. The +template file **must** be named `_name.html.slv` — the leading underscore is +mandatory. `render_partial(...)` is an alias for the same builtin. + +```erb +<% for post in @posts %> + <%- partial("posts/post", { "post": post }) %> +<% end %> +``` + +`partial` returns HTML, so render it with `<%-` (raw output). `<%=` would +HTML-escape the partial's own markup and show tags as text. + +> **`<%-` skips escaping — sanitize untrusted HTML first.** `<%-` is safe for +> trusted, framework-produced HTML (like a `partial`). For any HTML that +> originated from a user (rich-text fields, imported content, Markdown +> rendered to HTML), pass it through `sanitize_html(value)` first — it keeps +> safe tags (links, basic formatting) and strips scripts/event handlers/ +> `javascript:` URLs. Use `strip_html(value)` instead when you want plain text +> with no tags at all. Never put raw user HTML straight into `<%-`. + +`app/views/posts/_post.html.slv`: + +```erb +
    +

    <%= h(post.title) %>

    + <%= h(post.body) %> +
    +``` + +Inside the partial, `post` is read as a bare local. Use `locals["post"]` if +the name collides with anything. + +## View helpers + +Always available inside templates: + +| Helper | What it does | +|--------------------------------------------|--------------------------------------------------------------------| +| `partial("dir/name", { ... })` | Render a partial. | +| `public_path("css/app.css")` | Cache-busted asset URL (fingerprinted). | +| `upload_url(record, "field", opts?)` | URL for an uploader-managed blob (with optional transforms — see **Rendering uploads**). | +| `t("posts.title")` | i18n lookup; respects current locale. | +| `time_ago(timestamp)` | "3 minutes ago"-style relative time. | +| `current_path()` | Current request path. | +| `current_method()` | Current HTTP method (`"GET"`, `"POST"`, ...). | +| `current_path?(path)` | Boolean — is the request on this path? | +| `range(start, stop)` | `[start, ..., stop-1]` for `for i in range(0, 5)`. | +| `sanitize_html(html)` / `strip_html(html)` | See "Escape helpers". | +| `dev_queries()` | AQL stack for the current request (`--dev` only — `[]` otherwise). | + +**Named route helpers** (`posts_path()`, `post_path(post)`, `new_post_path()`, +`edit_post_path(post)`, plus `*_url` variants) come from `resources(...)` in +`config/routes.sl`. Use them in templates — never hand-build URLs. + +```erb +All posts +Read +Edit +``` + +There is **no `link_to` helper** in Soli — write the `` tag yourself with +`<%= attr(...) %>` around URLs that contain user data. + +## Rendering uploads and image transforms + +For attachment fields declared with `uploader(...)` on a model (see +`app/models/CLAUDE.md` → **Attachments and uploads**), the auto-generated +`_url` instance method — or the standalone `upload_url(record, "field")` +helper — returns a URL served by the framework's `AttachmentsController`. +The URL hits SoliDB, decodes the blob, and streams it back. + +```erb +<% if !@contact.photo_url().nil? %> + <%= attr(@contact.name) %> +<% end %> +``` + +For a `multiple: true` field, the per-record getter returns `nil` — iterate +the stored blob ids and ask for each URL: + +```erb +<% for blob_id in (@document.attachments_blob_ids ?? []) %> +
  • + Download +
  • +<% end %> +``` + +### Image transforms via query string + +When the stored blob is an image (`Content-Type: image/*`), the URL accepts a +small set of query parameters and the controller pipes the blob through +Soli's `Image` builtin before responding. Browsers cache each (URL, +query-string) combination independently — same params → cache hit, no +re-transformation cost. Failed transforms fall back to streaming the original +bytes. + +Pass options as a hash to `upload_url` / `_url`: + +```erb +"> + +"> + +"> +``` + +| Key | Effect | +|----------------------|--------------------------------------------------------------------------| +| `w`, `h` | Resize to those dimensions (may distort if both set without `fit`). | +| `thumb` | Square-fit thumbnail, max edge = value. | +| `square` | Sugar for `w=N&h=N&fit=cover` — square crop to N×N. | +| `fit` | `"cover"` (fill + center-crop) or `"contain"` (fit inside). Needs `w`+`h`.| +| `crop` | `"x,y,w,h"` — pick a source region first; runs before sizing. | +| `flipx`, `flipy` | Horizontal / vertical flip. | +| `rot` | `90`, `180`, or `270` — quarter-turn rotation. | +| `blur` | Gaussian blur sigma (float). | +| `bright` | Brightness delta (signed int). | +| `contrast` | Contrast factor (float; 1.0 = unchanged). | +| `hue` | Hue rotation in degrees. | +| `gray` | Truthy → grayscale. | +| `invert` | Truthy → invert colors. | +| `fmt` | Re-encode: `"webp"`, `"png"`, `"jpeg"`. | +| `q` | Output quality (int, encoder-specific). | + +Pipeline order is fixed: `crop` → `flip`/`rot` → resize (`thumb` / `fit` / +`w+h` / `square`) → effects (`blur`, `bright`, `contrast`, `hue`, `invert`, +`gray`) → encode (`fmt`, `q`). Width/height are clamped server-side to 1000 px +so a hand-crafted URL can't drive a multi-GB allocation. + +For ad-hoc image processing outside the uploader pipeline (e.g. a job that +generates a derivative and stores it under another field), use the `Image` +builtin directly: + +```soli +img = Image.from_buffer(file["data"]) +thumb_b64 = img.thumbnail(200).format("webp").quality(80).to_buffer() +``` + +`Image` is mostly used in models/jobs, not in views — keep templates thin. + +## App-level helpers + +Anything you put in `app/helpers/*.sl` is auto-loaded and available inside +every template. No `import` needed. Define a helper as a free-standing +function: + +```soli +# app/helpers/markdown_helpers.sl + +def render_markdown(text) + # ... call your markdown engine ... +end +``` + +Then in a view: + +```erb +<%- render_markdown(@post.body) %> +``` + +Group helpers thematically — `application_helper.sl`, `markdown_helpers.sl`, +`form_helpers.sl` — rather than one mega-file. + +## Client interactivity — HTMX + Alpine.js + +Every scaffolded app ships with **HTMX** and **Alpine.js** preloaded in +`app/views/layouts/application.html.slv`: + +```erb + + +``` + +This is Soli's default client stack. Reach for it before introducing React / +Vue / similar — most "make this page interactive" tasks fit it cleanly, +without a build step. + +**HTMX** for server-driven partials. Swap a piece of the DOM with the HTML +response of a Soli action — no JSON, no client-side templating: + +```erb + + +<%= post.likes %> +``` + +Pair with a controller that returns the partial fragment directly: + +```soli +def like + @post = Post.find(params["id"]) + @post.increment("likes") + render("posts/_like_count") # tiny partial, no layout +end +``` + +For HTMX requests, skip the layout by detecting the `HX-Request` header in +the controller (e.g. `if req["headers"]["hx-request"] == "true"`) and pass +`{ "layout": false }` to `render`. + +**Alpine.js** for purely-local UI state (open/closed, expanded/collapsed, +in-input validation flash). Keep it scoped — no app-level Alpine stores. + +```erb +
    + +
    …content…
    +
    +``` + +Rule of thumb: **server first**. If a piece of state lives on the server +(user data, validations, search results), use HTMX. If it lives only in the +DOM (modals, dropdowns, tabs), use Alpine. Mix freely — they don't conflict. + +See `docs/client-interactivity.md` (bundled in every Soli app) for the +deeper end of either library. + +## Layouts + +Every render runs inside a layout unless explicitly opted out. + +- **Default**: `app/views/layouts/application.html.slv`. +- **Per-controller**: set `this.layout = "X"` in the controller's `static { }` + block to use `app/views/layouts/X.html.slv` instead. +- **Per-render**: pass `"layout"` in the data hash: + `render("posts/show", { "layout": "minimal" })`. +- **No layout**: `render("posts/show", { "layout": false })`. + +The layout calls `<%= yield %>` (or the equivalent `<%= content %>` local) +where the view's content gets inserted. + +```erb + + + + + <%= @title ?? "MyApp" %> + "> + + + <%= yield %> + + +``` + +## File layout + +``` +app/views/ +├── layouts/ +│ └── application.html.slv # wrap-around for all renders by default +├── posts/ +│ ├── index.html.slv +│ ├── show.html.slv +│ ├── new.html.slv +│ ├── edit.html.slv +│ └── _post.html.slv # partial — underscore prefix is mandatory +└── shared/ + └── _nav.html.slv # cross-cutting partials live here +``` + +`render("posts/show")` → `app/views/posts/show.html.slv`. +`partial("posts/post", ...)` → `app/views/posts/_post.html.slv`. + +## Style + +- **Indent at 2 spaces** in `.slv` files — matches the docs site convention, + keeps diffs and partials consistent. +- One template per action; pull cross-cutting markup into a partial. +- Keep logic out of templates. If a `<% %>` block grows past a few lines, + move it to a helper or a controller `@field`. +- Always close your tags. `<% if %>` needs `<% end %>`; `<% for %>` needs + `<% end %>`. + +## Do / Don't + +| Do | Don't | +|----------------------------------------------------------|--------------------------------------------------------------------| +| Use `<%= %>` by default | Use `<%- %>` for values that touched user input | +| Use `attr(...)` / `j(...)` / `url(...)` for non-body contexts | Trust `h()` to be safe inside attributes — it's HTML-body-only | +| Set `@field` in the controller and read it in the view | Re-pass `@field` in the `render(...)` data hash | +| Use named route helpers — `post_path(post)` | Hand-build `"/posts/" + str(post.id)` | +| Use `for x in xs` for loops | Try `<% xs.each do |x| %>` — that doesn't parse inside ERB | +| Use `#{expr}` for interpolation in strings inside `.sl` blocks | Use `\(expr)` — the lexer rejects that | +| Keep templates thin; push logic into helpers | Embed business rules in `<% %>` blocks | +| Put cross-cutting markup in `_partials.html.slv` | Copy-paste header/nav across every action's view | diff --git a/admin/app/views/api_keys/index.html.slv b/admin/app/views/api_keys/index.html.slv new file mode 100644 index 00000000..e2fb7add --- /dev/null +++ b/admin/app/views/api_keys/index.html.slv @@ -0,0 +1,91 @@ +<%- partial("shared/banners", { "solidb_down": @solidb_down, "flash_error": @flash_error, "flash_notice": @flash_notice }) %> + +<% if !@created_key.blank? %> +
    +

    raw key — shown once, copy it now

    +
    + <%= @created_key %> + +
    +
    +<% end %> + +
    +
    +
    +

    Access

    +

    API Keys (<%= @keys.length() %>)

    +

    + token credentials for programmatic access without a username/password — + the raw key is shown once at creation, store it somewhere safe +

    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + +
    + + + + + + + + + + + + <% for key in @keys %> + + + + + + + + <% end %> + +
    namerolesscoped databasescreatedactions
    <%= key["name"] ?? "" %> +
    + <% for role_name in (key["roles"] ?? []) %> + <%= role_name %> + <% end %> +
    +
    + <% scoped = key["scoped_databases"] ?? [] %> + <%= scoped.length() > 0 ? scoped.join(", ") : "all" %> + <%= (key["created_at"] ?? "").substring(0, 10) %> + +
    + <% if @keys.length() == 0 %> +

    no api keys

    + <% end %> +
    +
    diff --git a/admin/app/views/collections/_indexes.html.slv b/admin/app/views/collections/_indexes.html.slv new file mode 100644 index 00000000..7dcbf139 --- /dev/null +++ b/admin/app/views/collections/_indexes.html.slv @@ -0,0 +1,178 @@ +<%# x-effect locks body scroll while the modal is open so wheel events over + the fixed overlay don't scroll the page behind it. %> +
    +
    +

    indexes / <%= collection_name %> + (<%= indexes.length() %>)

    +
    + + +
    +
    + + <% if !indexes_error.blank? %> +

    <%= indexes_error %>

    + <% end %> + <% if !indexes_notice.blank? %> +

    <%= indexes_notice %>

    + <% end %> + + <% if indexes.length() > 0 %> + + + + + + + + + + + + + <% for idx in indexes %> + <% idx_name = idx["name"] ?? "" %> + <%# Literal class strings so the Tailwind scanner compiles them: + border-amber-800 text-amber-400 border-violet-800 text-violet-400 + border-sky-800 text-sky-400 border-rose-800 text-rose-400 + border-zinc-700 text-zinc-400 %> + <% idx_badges = { + "hash": "border-sky-800 text-sky-400", + "fulltext": "border-violet-800 text-violet-400", + "geo": "border-amber-800 text-amber-400", + "ttl": "border-rose-800 text-rose-400", + "persistent": "border-zinc-700 text-zinc-400" + } %> + + + + + + + + + <% end %> + +
    nametypefieldsuniqueinfoactions
    <%= idx_name %>"><%= idx["type"] %><%= (idx["fields"] ?? []).join(", ") %><%= idx["unique"] ? "yes" : "—" %><%= idx["detail"] %> + +
    + <% else %> +

    no indexes on <%= collection_name %>

    + <% end %> + + <%# Teleported to : the panel lives inside a table row, and an + ancestor there acts as containing block for position:fixed, cropping + the modal to the panel area. htmx.process() is required because htmx + never initializes