lounge.

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.