Recipes
The other pages explain the design. This page collects the corresponding commands and configuration. Copy, paste and adjust them as needed.
Everything here is verified against the repository; where a value is a
placeholder it is written in ANGLE_BRACKETS.
Start a new application
tools/install/new-app.sh ~/projects/my-app my-app
cd ~/projects/my-app
./mvnw quarkus:dev
That scaffolds a runnable application and leaves you with a dev server. It carries its own Maven wrapper, so the directory is self-contained from the first commit.
What it writes:
my-app/
├── pom.xml the engine dependency, pinned
├── mvnw, mvnw.cmd its own wrapper — self-contained
├── .mvn/jvm.config the engine's JVM flags
├── README.md the commands, filled in for you
└── src/main/
├── java/com/example/myapp/
│ ├── domain/DomainRecords.java your event and record types
│ └── functions/AppFunctions.java the functions the tree names
└── resources/
├── lounge/project.yaml THE TREE
└── application.properties configuration
Two conventions are required: the spec lives at
src/main/resources/lounge/project.yaml, and the functions your tree
names by procFnRef must be registered under the names it uses.
Send it something:
curl -s -X POST localhost:8080/lounge/process/raw.Ping \
-H 'Content-Type: application/json' -d '{"message":"hi"}'
curl -s localhost:8080/lounge/health
If you are building against the engine source rather than a released artifact, install it once first — otherwise Maven cannot resolve the dependency the scaffold pinned:
cd PATH/TO/lounge && ./mvnw install -DskipTests
Build
On metal
./mvnw package
Output is a Quarkus fast-jar layout under target/quarkus-app/:
target/quarkus-app/
├── quarkus-run.jar the entry point — this is what you run
├── app/ your classes
├── lib/ dependencies (a stable layer for images)
└── quarkus/ generated runtime metadata
Run it:
java -jar target/quarkus-app/quarkus-run.jar
The engine is compiled with preview features on, so a bare java -jar needs
the same flags the build used. The scaffold puts them in .mvn/jvm.config and
the container images carry them in JAVA_OPTS; if you launch by hand, use:
java --enable-preview --add-modules=jdk.incubator.vector \
--add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.nio=ALL-UNNAMED \
--add-opens java.base/sun.nio.ch=ALL-UNNAMED \
-jar target/quarkus-app/quarkus-run.jar
As a container image
Build the application first — the Dockerfile copies build output, it does not compile:
./mvnw package
docker build -f src/main/docker/Dockerfile.jvm -t my-app:jvm .
docker run --rm -p 8080:8080 my-app:jvm
The layer split is deliberate: lib/ is copied before app/, so dependency
layers stay cached across application changes.
Three other Dockerfiles ship beside it — Dockerfile.native and
Dockerfile.native-distroless for a GraalVM native image
(./mvnw package -Pnative first), and Dockerfile.legacy-jar for the
uber-jar layout. The JVM image is the one the release pipeline builds and the
one to reach for unless you have a specific reason.
Multi-architecture
docker buildx build --platform linux/amd64,linux/arm64 \
-f src/main/docker/Dockerfile.jvm -t my-app:jvm .
Configure
Configuration is application.properties, and every key is also an
environment variable: uppercase it and replace ., - and quotes with _.
quarkus.log.file.level=WARN
QUARKUS_LOG_FILE_LEVEL=WARN
That rule allows the same image to run in every environment by changing its environment rather than rebuilding it.
The keys you will actually set
# Which edition to activate. Unset = dev mode: full PRO capabilities, loudly
# not for production. An unknown value refuses to boot.
lounge.edition=pro
# Durability. Off by default; see the durability recipes below.
lounge.ha.log.enabled=false
# Ops surfaces: rest | none | your own implementation.
lounge.ops.surfaces=rest
lounge.ops.health.enabled=true
# Identity. Provider-neutral — any compliant issuer.
mp.jwt.verify.publickey.location=${JWT_PUBLIC_KEY}
mp.jwt.verify.issuer=${JWT_ISSUER}
Databases
Both are Postgres. The profile decides what the platform does with it.
Plain Postgres
lounge.platform.profile=postgres-minimal
lounge.platform.postgres.jdbc-url=jdbc:postgresql://HOST:5432/DATABASE
quarkus.datasource.username=USER
quarkus.datasource.password=PASSWORD
With no jdbc-url this profile falls back to in-memory H2, which is why
clone-and-run needs no database at all.
Supabase
lounge.platform.profile=supabase
lounge.platform.supabase.project-url=${SUPABASE_URL}
lounge.platform.supabase.anon-key=${SUPABASE_ANON_KEY}
lounge.platform.supabase.service-role-key=${SUPABASE_SERVICE_ROLE_KEY}
lounge.platform.postgres.jdbc-url=${SUPABASE_DB_JDBC_URL}
lounge.platform.vector.enabled=true
lounge.platform.vector.extension=pgvector
Choosing between them
Supabase is a preset, not a different integration. Under it you get the same Postgres-compatible behaviour plus pgvector switched on, and its auth works through the ordinary provider-neutral JWT knobs — Supabase is simply the issuer they are pointed at.
Choose on operational grounds: Supabase if you want the database, auth and storage operated for you; plain Postgres if you already run one, or if the data must stay somewhere specific.
What neither of them makes durable is your application state. A profile gives the scope registry and audit trail a home. Records stay in memory until you enable durability, as described in the next section.
Durability
Two backends, same contract. Pick by what your deployment already operates.
A file log
lounge.ha.log.enabled=true
lounge.ha.log.dir=/var/lib/lounge/ha
lounge.ha.log.durability=async # async | msync
The directory must be a volume that outlives the process. async batches the
force with low overhead; msync makes a completed session wait for the
forced watermark, so nothing completed is ever lost, at the cost of your
storage's honest fsync rate.
In Kubernetes this needs a StatefulSet with a volumeClaimTemplate — the
shipped Helm chart refuses ha.enabled until that lands, rather than writing
your log to a disk that dies with the pod.
A database
// wherever you build the tree
HaSetup.enableSql(tree, dataSource);
Give it the container's pooled DataSource. That is not a style
preference: a connection handshake costs about as much as the insert it
precedes, so an unpooled datasource gives away most of the throughput even
though the writer batches.
The writer takes an entry and sweeps up everything that arrived while the last statement was in flight, so batches grow with load. Nothing accepted is dropped: if the database is unreachable the batch is held and retried, and the queue in front of it pushes back on callers.
Archiving logs
Logs are split by severity, because the two audiences want opposite things.
# The record you keep: warnings and errors, rotated daily, compressed.
quarkus.log.file.level=WARN
quarkus.log.file.path=logs/lounge.log
quarkus.log.file.rotation.file-suffix=.yyyy-MM-dd.gz
quarkus.log.file.rotation.max-file-size=10M
quarkus.log.file.rotation.max-backup-index=100
# The diagnostics stream: verbose, self-deleting, bounded at ~110 MB.
quarkus.log.handler.file."debug".level=ALL
quarkus.log.handler.file."debug".path=logs/lounge-debug.log
quarkus.log.handler.file."debug".rotation.max-file-size=10M
quarkus.log.handler.file."debug".rotation.max-backup-index=10
Ship the kept stream to object storage on a schedule:
lounge.log-archive.enabled=true
lounge.log-archive.cron=0 0 3 ? * MON # Mondays at 03:00
lounge.log-archive.dir=logs
lounge.log-archive.min-age-days=1 # never touch today's rotations
lounge.log-archive.local-retention-days=30 # disk guard, upload or not
lounge.log-archive.delete-after-upload=true
lounge.log-archive.s3.bucket=BUCKET
lounge.log-archive.s3.prefix=logs/
There are two relevant behaviors. Leave s3.bucket empty and the job runs in
cleanup-only mode — local retention still applies, nothing is uploaded,
which is a legitimate configuration rather than a broken one. And
local-retention-days deletes on age whether or not a file was ever
uploaded; it is a disk guard, not a shipping receipt.
Archiving data
Records live in memory. Three mechanisms decide what that costs over time.
Keep less
lounge.storage.retain=queried # all | auto (default) | queried
The default, auto, retains exceptions (under every policy), external
arrivals, record types read by a query, and anything retained explicitly
(storeOutput: true, tree.retainType, or a sweepable registration).
queried retains only query-read types; a tree that declares no queries
therefore keeps nothing under that setting. all retains everything and is
intended primarily for debugging.
Age out the hot tier
lounge.sweep.enabled=true
lounge.sweep.hot-window=PT10M
lounge.sweep.interval=1m
Eviction requires two explicit choices: this switch and registration of each type. Eviction removes rows that hot queries would otherwise scan, so registering a type asserts that a colder destination is available.
The colder place
SqlRecordArchive archive = new SqlRecordArchive(dataSource, "my_archive");
archive.createSchema();
archive.archive(MAPPING, record); // idempotent on natural id
archive.query(ArchiveQuery.of().groups("g1").limit(100));
Each archived record carries a natural id — a domain identity such as a content hash, never a surrogate — which makes re-archiving a no-op and at-least-once delivery safe by construction.
The pattern is two lanes: memory holds the working set, SQL holds the corpus, and your application routes a question to the lane that can answer it. If the database is remote, archive in batches rather than per record; the round trip dominates, and batching amortises it almost entirely.
Check it works
tools/install/doctor.sh # toolchain, versions, what is missing
tools/install/smoke-dev.sh # scaffold, build, run, POST an event
tools/install/smoke-container.sh
Authentication needs no manual probe: a local mistake refuses at boot, and a remote issuer is a readiness condition, so a wrong one stalls the rollout instead of serving 500s. If you want to see its state directly:
curl -s https://APP/q/health/ready | jq '.checks[] | select(.name=="lounge-auth-key")'
Where to go next
- The Tree and the Flow — if you arrived here first and want to know what any of this is.
- Building, Operating and Securing — the reasoning behind the operational choices above.
- Deep reference:
docs/guide/for the spec language and integrations,docs/ops/for the operational detail.