A standalone locker sync primitive: versioned, subscribable per-room key/value
blobs ("lockers") with real-time, per-session fan-out over gRPC (unary over HTTP,
streaming over WebSockets). A locker is an opaque payload identified by
(room, keyspace, lockerId) and carrying a monotonic version for optimistic
concurrency. Clients subscribe to a room and receive live events as lockers change.
Scope: this repo is the primitive only — durable storage, sessions, subscriptions, and push fan-out. Higher-level features (chat, profiles, games, …) are add-ons layered on top by consumers and are intentionally out of scope.
| Module | What it is |
|---|---|
api |
All protobuf definitions and the generated Kotlin (one codegen tree). KMP, JVM target. |
connector |
Client SDK: LockerClient / TypedLockerClient and the Stream reconnect loop. KMP, JVM target. |
server |
JVM service host: session, session-gateway, room, and push services wired via kotlin-inject. |
server:test |
Test fixtures (attachTestServices) that boot the real monolith over an in-memory store. |
server:run |
Application entrypoint (Main.kt): config, persistence selection, HTTP endpoints, graceful shutdown. |
Proto codegen shells out to protoc-gen-kt (the Kotlin protoc plugin, a Go binary)
which must be on your PATH. Install the pinned version (matching CI):
go install latenighthack.com/protoc-gen-kt@v0.0.0-20251214023608-0fa742406fbf
# ensure "$(go env GOPATH)/bin" is on your PATHThen:
./gradlew build # compile everything + run all tests
./gradlew :connector:jvmTest # acceptance gate: integration tests vs. an in-process server
./gradlew :server:test # server-side unit tests (persistence, config, rate limiter)
./gradlew detekt # static analysis (advisory)./gradlew :server:run:run # boots on :8080Configuration is read from the environment at startup (12-factor). Every value has a
safe local-dev default; production must set at least LOCKERS_DB_URL.
| Env var | Default | Meaning |
|---|---|---|
LOCKERS_HTTP_PORT |
8080 |
HTTP port. |
LOCKERS_DB_URL |
(unset) | Postgres JDBC URL. Unset ⇒ in-memory store (NOT durable). |
LOCKERS_MAX_LOCKER_BYTES |
1048576 |
Max serialized locker payload; larger writes are rejected. |
LOCKERS_ROOM_WRITES_PER_SEC |
50 |
Per-room sustained write rate (<=0 disables limiting). |
LOCKERS_ROOM_WRITE_BURST |
100 |
Per-room burst allowance. |
LOCKERS_SESSION_CACHE_SIZE |
1000 |
Room→sessions cache entries. |
LOCKERS_SHARD_MULTIPLIER |
4 |
Shard count = CPU cores × this. |
APNS_TEAM_ID / APNS_KEY_ID / APNS_KEY_PATH |
(unset) | APNS credentials; absent ⇒ push no-ops. |
APNS_ENVIRONMENT |
development |
development or production. |
APNS_TOPIC |
com.latenighthack.lockers |
APNS push topic. |
LOG_LEVEL |
INFO |
Root log level. |
Durable storage uses ktstore's Postgres-backed StoreDelegate
(SqlStoreDelegate over a JDBC driver, BYTEA blobs). Set LOCKERS_DB_URL to a
Postgres JDBC URL, e.g.:
export LOCKERS_DB_URL="jdbc:postgresql://localhost:5432/lockers?user=lockers&password=secret"With no LOCKERS_DB_URL, the server falls back to an in-memory store and prints a
warning — convenient for local dev, not durable across restarts. The
server:test PersistenceTest proves data survives a store "restart" through the
SQL delegate.
- Metrics: Prometheus exposition at
GET /metrics(Micrometer). All metrics are namespacedlockers.*. - Health:
GET /healthz(liveness) andGET /readyz(readiness). - Logs: structured JSON to stdout (logback + logstash encoder), directly ingestible by Loki/Promtail, ELK, or Datadog.
The client wraps lockers as typed values. See
connector/src/jvmTest/.../LockerClientTests.kt for complete, runnable examples.
val typed = TypedLockerClient(
lockerClient,
keyspace = LockerKeyspace { value = MY_KEYSPACE },
writer = MyValue::toByteArray,
reader = MyValue.Companion::fromByteArray,
)
// optimistic, versioned update
typed.updateLocker(roomId, lockerId) { current -> current.mutate() }
// live, per-room view that folds change events into a map
typed.watchAll(roomId).collect { lockers -> render(lockers) }The Stream reconnect loop retries transient session-open failures with backoff and
surfaces terminal failures (rejected key, rejected session id, upgrade required) via
Stream.fatalError instead of crashing.
api and connector publish via maven-publish:
./gradlew :api:publishToMavenLocal :connector:publishToMavenLocal- Authorization & tenant isolation — session open is signature-verified, but room and locker operations are not yet authorized per-identity. Design pending.
- iOS/JS targets —
apiandconnectorare structured for Kotlin Multiplatform but currently build the JVM target only. Upstream libraries have iOS/JS variants, so adding targets is a follow-up, not a rewrite. - Distributed tracing — structured logs and Prometheus metrics are in place; OpenTelemetry span instrumentation (or attaching the OTel Java agent) is the next step for end-to-end traces.
MIT — see LICENSE. (Chosen as a permissive default; change if your project requires otherwise.)