Database

Two opt-in data layers: SQL over any JDBC DataSource (Sql, Migrations) and MongoDB (Mongo, Doc, Schema). Each loads only when its driver is on the classpath.

SQL: setup

Add spring-boot-starter-jdbc and a driver, set spring.datasource.url, and the DataSource bean Spring Boot creates is bound to Sql at startup. Migrations under db/migration run first.

spring:
  datasource:
    url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/myapp}
    username: ${DATABASE_USER:myapp}
    password: ${DATABASE_PASSWORD:}

SQL: queries to records

The SQL is yours; the mapping is the framework's. A row maps to a record by column name, ignoring case and underscores, so created_at fills createdAt.

import jweb.Sql;

record User(Long id, String name, String email, LocalDateTime createdAt) {}

List<User> users = Sql.query("select * from users where email like ?", "%@example.com").list(User.class);
Optional<User> one = Sql.query("select * from users where id = ?", 7).first(User.class);
long n = Sql.query("select count(*) from users").scalar(Long.class);
List<User> named = Sql.query("select * from users where name = :name", Map.of("name", "Ada")).list(User.class);

int changed = Sql.update("update users set name = ? where id = ?", "Ada", 7);
long id = Sql.insert("insert into users (name, email) values (?, ?)", "Ada", "ada@example.com").key(Long.class);

Sql.transaction(() -> {
    Sql.update("update accounts set balance = balance - ? where id = ?", 50, 1);
    Sql.update("update accounts set balance = balance + ? where id = ?", 50, 2);
});

SQL: a table through a record

Columns are the record's components in snake_case; the component named id is the key. Forms and tables meet on the record: Form.of renders it, Form.bind validates it, the table stores it.

import jweb.Sql;

record Todo(Long id, String title, boolean done) {}

Sql.Table<Todo> todos = Sql.table("todos", Todo.class);

Todo t = todos.insert(new Todo(null, "Write docs", false));   // the copy returned carries the generated id
todos.update(new Todo(t.id(), t.title(), true));
Optional<Todo> maybe = todos.find(t.id());
List<Todo> open = todos.where("done = ? order by id", false);  // everything after `where`
long left = todos.count("done = ?", false);
todos.delete(t.id());

SQL: migrations

Versioned files under src/main/resources/db/migration, applied once each in order at startup and remembered in jweb_migrations with a checksum. The name is Flyway's convention, so the files move to Flyway unchanged if an app ever wants it.

-- db/migration/V1__create_todos.sql
create table todos (
    id bigint generated always as identity primary key,
    title varchar(200) not null,
    done boolean not null default false
);
import jweb.Migrations;
import javax.sql.DataSource;

DataSource dataSource = Sql.dataSource();

Migrations.Report report = Migrations.migrate(dataSource);       // what this call applied
List<Migrations.Migration> pending = Migrations.pending(dataSource);
List<Migrations.Applied> history = Migrations.applied(dataSource);
A migration edited after it was applied fails the next start, naming the version. Put the change in a new file, or Migrations.repair(dataSource) when the edit was deliberate.

MongoDB: connection

Add org.mongodb:mongodb-driver-sync and set jweb.data.enabled: true to connect at startup; or connect by hand.

import static jweb.Mongo.*;

// Connect with URI and database name
Mongo.connect("mongodb://localhost:27017", "mydb");

// Or use environment variables (MONGO_URI, MONGO_DB)
Mongo.connect();

Documents

Use Doc for dynamic document creation and field access.

import static jweb.Doc.*;

// Create a document
Doc user = Doc.of("users")
    .set("name", "John")
    .set("email", "john@example.com")
    .set("age", 25);

// Save to database
Mongo.save(user);

// Access fields
String name = user.getString("name");
int age = user.getInt("age");

Queries

Fluent query builder with filters, sorting, and pagination.

String id = "507f1f77bcf86cd799439011";

// Find by ID
Doc user = Mongo.findById("users", id);

// Find with filters
List<Doc> users = Mongo.find("users")
    .where("age").gte(18)
    .where("status", "active")
    .orderBy("name")
    .limit(10)
    .toList();

// Find first match
Doc admin = Mongo.find("users")
    .where("role", "admin")
    .first();

Updates

String id = "507f1f77bcf86cd799439011";
String visitorId = "507f191e810c19729de860ea";

// Update fields
Mongo.update("users")
    .where("id", id)
    .set("name", "Jane")
    .set("age", 26)
    .execute();

// Increment a field
Mongo.update("users")
    .where("id", visitorId)
    .inc("visitCount", 1)
    .execute();

Deletes

String userId = "507f1f77bcf86cd799439011";

// Delete by ID
Mongo.delete("users")
    .where("id", userId)
    .execute();

// Delete multiple
Mongo.delete("users")
    .where("status", "inactive")
    .executeAll();

Schema Definition

Define schemas for validation and automatic timestamps.

import static jweb.Schema.*;

Schema.collection("users")
    .id("id")
    .string("name").required()
    .string("email").required().unique()
    .integer("age").min(0).max(150)
    .list("roles")
    .timestamps()
    .register();
Schemas add createdAt/updatedAt automatically when timestamps() is called.

File Storage (Storage)

Where the app keeps the files its users give it: a directory in development, any S3-compatible bucket in production (Amazon, Cloudflare R2, MinIO, Backblaze, Spaces, GCS) — the same calls, and the framework signs the bucket's requests itself. The app serves the local store at /files with ETags, Cache-Control and byte ranges; signed URLs expire.

import jweb.Storage;
import jweb.FileUpload;
import jweb.UploadedFile;
import static jweb.El.*;

String avatarKey = "avatars/3f2a9c.png";
byte[] csv = "a,b\n".getBytes();

// an upload under a generated key: avatars/<32 hex>.png — keep the key on the user
UploadedFile file = FileUpload.getFile(req, "avatar");
Storage.Stored avatar = Storage.upload("avatars", file);

// the URL a browser fetches it from
Element picture = img(src(Storage.url(avatarKey)), alt("Avatar"));

// any bytes, any key
Storage.put("exports/7.csv", csv, "text/csv");
Optional<byte[]> bytes = Storage.bytes("exports/7.csv");
List<Storage.Stored> exports = Storage.list("exports/");
Storage.delete("exports/7.csv");

// a private document: a link that stops working after ten minutes
Element link = a(href(Storage.url("invoices/7.pdf", Duration.ofMinutes(10))), "Download");
jweb:
  storage:
    dir: storage          # the local store (default)
    access: public        # or signed: every URL expires and is signed under jweb.auth.secret
    s3:
      bucket: ${S3_BUCKET:}          # set → the bucket
      region: us-east-1
      endpoint:                      # blank = Amazon; an R2 / MinIO address otherwise
      access-key: ${S3_ACCESS_KEY:}
      secret-key: ${S3_SECRET_KEY:}
The storage route is a route like any other: app.guard("/files/private/*", Auth.requireAuth()).

Admin and Scaffolding

A back office for the tables an app keeps through Sql.table: a page per table that lists, searches and pages the rows, a form to add or edit one (the record is the form), and a delete. The pages sit in the page table, so the layout wraps them and the guard stands in front of every admin route.

import jweb.Admin;
import jweb.Auth;
import jweb.Sql;

record Todo(Long id, @Form.Required String title, boolean done) {}
Sql.Table<Todo> todos = Sql.table("todos", Todo.class);

Admin.mount(app, "/admin", todos).title("Back office").guard(Auth.requireRole("admin"));

List<Todo> page = todos.page(2, 50);           // the helpers behind it, for your own pages
List<Todo> hits = todos.search("docs", 1, 50);
jweb new shop                                              # a project with H2 under ./data and db/migration
jweb generate resource Product name:string! price:double inStock:boolean
# → models/Product.java (the row and the form), V1__create_products.sql,
#   ProductListPage, ProductFormPage, ProductRoutes (/products, /products/new, /products/:id)
The generated code is plain code to edit; the migration runs at the next start.