jimble

Traps#

Bugs that crash find themselves. What is collected here are the bugs that do not crash.

Cookies do not work locally#

Symptom: session, login, CSRF, flash — none of them work, and there is no error.

Cause: cookie.secure defaults to true. Local is http, so the browser never sends the cookie back.

Fix: put this in your local application.conf. Take it out in production.

cookie {
	secure = false
}

A WARN is printed at startup, so read the log.

The route is not registered#

Symptom: you get a 404. The code is right there.

Cause: you forgot to write install(SomeController::new). jimble does not scan the classpath, so if you do not write it, nothing is registered.

Fix: look at the route list in the startup log. If it is not there, it is not registered.

before is not applying (and it is the same path)#

before applies only inside the block you wrote it in. It does not attach to a path.

path("/admin", () -> {
	before(requireAuth);
	get("/users", ...);
});

install(() -> new SpaController("/admin", ...));   // ← not authenticated

Same /admin or not, a route registered somewhere else is in a different block. If you want them covered together, put them in the same block.

The opposite symptom — it applies where you did not want it to — does not happen. That is what used to happen in the code this was ported from, where even the SPA login screen under /admin was caught by the auth filter.

Also, adding a before after the routes are settled throws. That is so "I added it and it does nothing" never gets through in silence. Keep route definitions entirely inside the controller's initializer block.

Reading straight off request() silently gives you nothing#

Symptom: you are posting a form or JSON, but no value comes back. No exception.

Cause: Request is a Data too, so this is writable:

context.request().getString("title")   // compiles. returns null

The Request object itself holds neither the body nor the query string.

What to do: go through bodyAll() (or bodyJson() and friends when you want to pin down where the value came from).

Data input = context.request().bodyAll();
String title = input.getString("title");

getString on a missing key is null. Put that into a NOT NULL column and you get Column 'title' cannot be null.

The session was not saved#

Symptom: you called put(), and on the next request it is gone.

Cause: you did not call save(). Nothing is saved automatically.

context.session().put("user_id", 42);

// 明示的に保存する(要件 F-S-02)。自動保存はしない
context.session().save();

You cannot get at the select result#

Symptom: row.getString("title") comes back empty.

Cause: the result is nested under the table name.

Data row = db.select(
	SQL.select()
		.from(Post.instance())
		.where(Post.id.eq(1L))
);

// SELECT の結果はテーブル名でネストする(要件 F-D-02)
String title = row.getData("post").getString("title");

// Column で引けば、途中の文字列が出てこない
String same = row.getString(Post.title);

Look it up with Column and you cannot get it wrong.

You do not notice the DB error#

Symptom: zero rows when zero is impossible. Or an NPE.

Cause: DB errors are return values, not exceptions. select gives null, the update family gives -1.

try (DB db = BlogExample.db()) {

	List<Data> rows = db.selectList(SQL.select().from(Post.instance()));

	/*
	 * DB のエラーは例外ではなく戻り値で返る(要件 F-D-11)。
	 * select 系は null、更新系は -1。
	 */
	if (rows == null) {
		Log.error("引けませんでした: " + db.getError());
		return;
	}

}

The transaction did not carry on#

Symptom: an update made after commit() does not go away when you roll back.

Cause: this one is about the code this was ported from. Its commit() ended the transaction, so everything past that point ran in autocommit. In jimble, commit() does not end it. commitEndTransaction() is what ends it.

When you read code you brought over, check what comes after each commit().

"A transaction was left neither committed nor rolled back"#

ERROR コミットもロールバックもされていないトランザクションが残っていました。ロールバックします

Cause: you called beginTransaction() without wrapping it in try-with-resources, and then returned partway through.

Fix: wrap it. jimble picks it up at the end of the execution and rolls it back, but that is cleaning up after an accident, not the right way to write it.

Which tables exist depends on which entry point you started#

{"error":"トランザクションのコミットに失敗しました。"}

Cause: MqQueue#install() or BatchTables.install() is only called from some of your entry points.

When the web app, the batch and the scheduler each have their own main, it is easy for each one to set up only what it thinks it needs. You end up creating the MQ table in the batch entry point while it is the web app that pushes onto the queue.

The nasty part: on a machine where the batch has been run even once, it works. You will not see it locally. It shows up the first time you point at an empty database — CI, or a new environment.

Fix: Put the startup setup in one place and have every entry point call it.

public final class Bootstrap {

	public static void load () {

		Migration.install();                                  // before DBUtil.load

		// false when it could not connect. Ignore it and you fall over later
		// with an exception that says nothing about the DB
		if (!DBUtil.load(Conf.conf().config(), Bootstrap.class)) {
			throw new IllegalStateException("could not load the DB");
		}

		BatchTables.install(DBUtil.getMainDB());
		new MqQueue(NoticeExecutor.QUEUE_NAME).install();

	}

}

Call the same thing from your tests. Set the tests up separately and you get the state where only the tests pass once an entry point changes.

Tip

Run it once against an empty database — that is the quickest way to check. It is how we found examples/blog was broken.

The generated column order changes when you change products#

Symptom: codegen produces a diff although nothing was changed. Only the column order differs.

Cause: the generated column order is the physical order. Where ALTER TABLE ... ADD COLUMN puts the column differs between MySQL (which takes AFTER body) and PostgreSQL (which always appends), so the same schema can come out in a different order.

What to do: generate on the product you lead with, and run codegenCheck there too. Types, nullability and comments all match, so nothing but the order changes.

SSE does not arrive, or arrives all at once#

Cause: an nginx in between is buffering it up.

Fix: jimble attaches X-Accel-Buffering: no. If that is not enough, set proxy_buffering off; on the nginx side.

The SSE loop never ends#

Cause: you are not checking sse.isOpen().

isOpen() tells you whether the lifetime is still running. It cannot detect a disconnect (helidon does not tell it, and the write can block forever). Check it inside every loop. SSE has the details.

You cannot read cookies in a WebSocket#

Cause: you are trying to read them after the upgrade. The HTTP headers are visible at onUpgrade and nowhere else.

Fix: authenticate in onUpgrade. Return false and you get a 403.

An MQ message ran twice#

Cause: that is how it is. Exactly-once cannot be built.

Fix: write it so that running it again changes nothing. MQ has the details.

A HOCON comment breaks the config#

Key '/' may not be followed by token: '*'

Cause: you used /* */. HOCON comments are # or //.

jimbleRun says "no main manifest attribute"#

gradle-wrapper.jar has no Main-Class. Your gradlew and your gradle-wrapper.jar do not match.

Today's gradlew starts like this.

exec java -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" ...

That needs a JAR with a Main-Class. Leave an old JAR (one written for -classpath) lying around and gradlew and the JAR look like a matched pair while nothing works.

unzip -p gradle/wrapper/gradle-wrapper.jar META-INF/MANIFEST.MF
# Main-Class: org.gradle.wrapper.GradleWrapperMain  ← if this is missing, that is your problem

To fix it, run this once in that project.

gradle wrapper --gradle-version 9.7.1

jimbleRun shells out to ./gradlew on every rebuild, so you see this on every save. Once it works out that the wrapper is broken, it prints the reason and falls back to the gradle on your PATH, so hot reload itself keeps going.

Gradle will not start#

* What went wrong: 25.0.4

Cause: Gradle 8 does not run on Java 25. (Using Java 25 as a toolchain works on 8 — it is Gradle itself that will not run.)

Fix: use Gradle 9 or later.

Kotlin comments nest#

If a /* turns up inside a block comment in build.gradle.kts, a nested comment starts right there (Kotlin block comments nest). The outer one never closes, and every line of code below it disappears. There is no error.

/*
 * Reads docs/site/<lang>/*.md      ← this /* opens a nested comment
 */
tasks.register("site") { ... }      ← never registered, and never complains

When you write a path, avoid *, or use //.

docs/ in .gitignore applies at every level#

Symptom: the templates are missing from the repository you published.

Cause: write docs/ in .gitignore and git applies it to a docs/ at any level. It drags jimble-docs/src/main/jte/docs/ in with it.

Fix: put a / in front and write /docs/.

Build scripts that break on Gradle 9#

Some things pass on Gradle 8 and break on 9.

What you wrote What happens
val x by tasks.registering { } Deprecated in 9.6. In the Kotlin DSL it is a compile error. Use tasks.register("x") { }
"...".formatted(...) A Java 15 method. From a 9.7 Kotlin script it cannot be resolved. Use a string template

Run your build script at least once on the version you are actually going to use.