Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 0ae1e62b7b fix(presence): never store or publish the session uuid
The session uuid is a bearer credential (clients resume sessions with
it as a cookie or bearer token). The first cut kept it on the lease,
replicated it on the internal presence bus and exposed it through a
public leases() accessor.

Derive an opaque session id instead: "s" + the first 16 hex digits of
HMAC-SHA256(secret, sessionUuid), with QQQ_PRESENCE_SESSION_SECRET
(shared across pods) or a random per-process secret. The raw uuid is
dropped at heartbeat; only the opaque id is stored, replicated and
returned (per-connection viewers and presence.changed leases). The
leases() accessor and Lease record are removed. Tests assert the raw
uuid never appears in bus messages, snapshots or HTTP responses.

Refs #54

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 16:35:11 -06:00
james.maesandClaude Fable 5.1 47cbc7c72d feat(presence): per-connection viewers and connection counts
Two terminals or tabs of one user collapsed into a single viewer, so
clients could not tell "Admin, twice" from "Admin". The registry
already kept one lease per connection; expose it additively:

- merged viewers carry connections (live leases behind the viewer)
- snapshots and presence.changed carry leaseCount
- GET /presence?perConnection=true adds leases, one viewer per lease
  with an opaque connection id, mode, field and lease start;
  presence.changed always carries leases (subscribers cannot opt in)
- leases record the server-side session uuid, never sent to clients
- counts() still counts users; connectionCounts() counts leases and
  the Who's here card shows both

Extension collaboration 1.2.0; regenerated committed document.

Fixes #54

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 16:31:37 -06:00
james.maesandClaude Fable 5.1 1ae6508793 fix(presence): always send viewers, empty after the last leave (#53)
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / gradle-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-1 (push) Skipped
QRun-IO/ci-policy: ci / node-3 (push) Skipped
QRun-IO/ci-policy: ci / node-4 (push) Skipped
QRun-IO/ci-policy: ci / source-config-5 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QBit CodeQL / Analyze branch source (push) Canceled after 0s
CodeQL / analyze (push) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 2m51s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 16s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 8s
QRun-IO/ci-policy: ci / java-maven-2 (push) Successful in 26m47s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
The presence payload omitted viewers once the last viewer left, so clients kept showing the previous list. Viewers are now always serialized, as an empty list after the last leave.

Fixes #52

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:25:29 -06:00
james.maesandCodex 03407ae79a ci: adopt managed orb 0.8 patch track (#43)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:26:43 -05:00
james.maesandCodex 9d6767e731 ci: adopt shared orb 0.7.18 (#42)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:43:27 -05:00
james.maesandCodex aa6a4a3b58 ci: adopt shared orb 0.7.17 (#41)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:15:33 -05:00
james.maesandCodex d9a46afbf9 ci: use the shared branch-aware CodeQL worker (#39)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:35:35 -05:00
17 changed files with 633 additions and 48 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.7.12
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+1 -1
View File
@@ -1,5 +1,5 @@
profile: qbit
orb_version: 0.7.12
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
+2
View File
@@ -13,6 +13,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Changed
- UI API extension collaboration 1.2.0, additive (#54): presence viewers carry `connections` (live leases merged into the viewer), snapshots carry `leaseCount`, and `GET /presence?perConnection=true` adds `leases`, one viewer per connection with an opaque `connectionId`, `mode`, `fieldName`, `sessionId` and `since`. `presence.changed` events always carry `leaseCount` and `leases`. Leases keep an opaque HMAC-SHA256 session id (secret `QQQ_PRESENCE_SESSION_SECRET` or random per process), never the session uuid, which is a bearer credential. `PresenceRegistry.connectionCounts()` counts leases next to the unchanged user `counts()`; the Who's here card shows both.
- UI API extension collaboration 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and published presence request/snapshot/viewer schemas to resolve dangling references. Retain CollaborationErrorV1 as deprecated.
- Upgrade the default QQQ BOM to `4.1.0-RC.7` from Maven Central.
@@ -27,6 +28,7 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Fixed
- QBit metadata reports the Maven artifact version from a filtered resource, including release versions.
- `presence.changed` events and presence snapshots always carry `viewers`, sending `[]` once the last viewer leaves, as the schema requires (#52).
- Timeline audit summaries read "Edited by Alice Morgan: Title, Total" or "Created by Alice Morgan" instead of "Record was Edited"; detail rows are unchanged.
- The demo server chooses its user per browser session (`?user=bob`, remembered in a cookie) instead of once per JVM, and sends the person's name to the frontend so it no longer shows Anonymous.
+15 -4
View File
@@ -410,7 +410,7 @@ The runtime provider adds this field and its search entry where TAGS is enabled.
## UI API
The producer registers extension `collaboration` version `1.1.0` at
The producer registers extension `collaboration` version `1.2.0` at
`/qqq/v1/qbit/collaboration`. It uses the signed-in user's session and existing
services. Disable registration with `new CollaborationQBitConfig().withUiApi(false)`.
Requires QQQ `4.1.0-RC.15` and UI API `1.6.0`. Operations carry stable operationIds,
@@ -721,7 +721,7 @@ Enable `RecordFeature.PRESENCE` on the table. The typed endpoints below
| --- | --- |
| `POST /presence` | `{connectionId, table, pk?, mode: "VIEWING"|"EDITING", fieldName?}` |
| `DELETE /presence` | `{connectionId, table, pk?}` |
| `GET /presence?table=...&pk=...` | Omit pk for a table snapshot |
| `GET /presence?table=...&pk=...&perConnection=true` | Omit pk for a table snapshot; `perConnection` is optional |
Session identity supplies userId and displayName. Every call rechecks table READ and record
GetAction visibility. Disabled features and denied/missing parents return the same 404. A
@@ -733,6 +733,17 @@ sweep and expiry checks on reads. The cap is 50,000 leases per pod: new targets
a warning while existing leases can renew. Tabs merge per user; EDITING wins and the latest
editor supplies focus. Nothing is persisted, including directory rows.
Per-connection presence (extension 1.2.0, #54): each merged viewer carries `connections`, the
number of live leases (tabs, terminals) behind it, and every snapshot carries `leaseCount`, so
two terminals of one user read as one person with two connections. `perConnection=true` adds
`leases`, one viewer per lease with an opaque `connectionId` (`c` and the first 16 hex digits of
SHA-256 of `userId`, a newline and the client connection id), its `mode`, `fieldName` (record
targets only), `sessionId` and `since`, the lease start. `presence.changed` events always carry
`leaseCount` and `leases`. Session uuids are bearer credentials, so a lease never stores or
publishes one: it keeps `sessionId`, `s` and the first 16 hex digits of HMAC-SHA256 of the
session uuid under `QQQ_PRESENCE_SESSION_SECRET` (system property or environment; set the same
value on every pod so ids agree) or else a random per-process secret. `PresenceRegistry.counts()` still counts users; `connectionCounts()` counts leases.
Every heartbeat/leave replicates over core's bus on `instance:presence`, a channel refused by
core client subscriptions. `presence.changed` is emitted to record and table channels. Table
snapshots aggregate users without record ids or focused field names; those stay on record
@@ -741,5 +752,5 @@ refetch after reconnecting.
The initial overlay refresh starts the registry on every pod, including those without local
viewers. Endpoints also initialize it lazily. At shutdown call `PresenceRegistry.close(instance)`
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user counts
per table, and core record-feature summaries include the presence snapshot.
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user and
connection counts per table, and core record-feature summaries include the presence snapshot.
@@ -56,6 +56,7 @@ public class CollaborationSummaryProvider implements RecordFeatureSummaryProvide
entry.put("displayName", viewer.displayName());
entry.put("mode", viewer.mode());
entry.put("fieldName", viewer.fieldName());
entry.put("connections", viewer.connections());
return (entry);
}).toList());
}
@@ -52,7 +52,7 @@ public final class CollaborationApi
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.1.0")
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.2.0")
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence")
.withSpec(PostPresenceSpec.class).withSpec(DeletePresenceSpec.class).withSpec(GetPresenceSpec.class)
.withSpec(ListFavoritesSpec.class)
@@ -161,13 +161,14 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
case "cursor" -> "Opaque nextCursor returned by the previous page; omit for the first page.";
case "before" -> "Timeline continuation token; alias for cursor. Supply only one.";
case "includeDone" -> "Include completed and cancelled tasks; default false.";
case "perConnection" -> "Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0.";
case "status" -> "Task status: OPEN (default), DONE or CANCELLED.";
case "types" -> "Comma-separated AUDIT, ACTIVITY or activity type names (for example COMMENTED).";
case "q" -> "Text to search in display names or vocabulary.";
default -> name;
};
return (new Parameter().withName(name).withIn(location).withRequired(required).withDescription(description)
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") ? Type.BOOLEAN : Type.STRING)));
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") || name.equals("perConnection") ? Type.BOOLEAN : Type.STRING)));
}
/*******************************************************************************
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.collaboration.api;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
import com.kingsrook.qqq.openapi.model.Schema;
@@ -80,10 +81,22 @@ public final class CollaborationPresenceSchemas
}
/*******************************************************************************
** The snapshot is also the data shape of presence.changed events.
** The snapshot is also the data shape of presence.changed events. Viewers are
** included even when empty, whatever inclusion the serializer defaults to.
*******************************************************************************/
public record CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers) implements ToSchema
public record CollaborationPresenceSnapshotV1(String table, String pk, @JsonInclude(JsonInclude.Include.ALWAYS) List<PresenceRegistry.Viewer> viewers,
Integer leaseCount, @JsonInclude(JsonInclude.Include.NON_NULL) List<PresenceRegistry.Viewer> leases) implements ToSchema
{
/*******************************************************************************
** The merged snapshot without per-connection detail.
*******************************************************************************/
public CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers)
{
this(table, pk, viewers, null, null);
}
/*******************************************************************************
** A table snapshot deliberately excludes record ids and focused field names.
*******************************************************************************/
@@ -93,14 +106,17 @@ public final class CollaborationPresenceSchemas
return (new Schema().withType(Type.OBJECT).withDescription("Live viewers, merged across tabs; presence.changed uses this payload.")
.withProperties(new TreeMap<>(Map.of(
"table", text("Parent table name.", false), "pk", text("Record key; absent for table events.", true),
"viewers", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
"viewers", new Schema().withType(Type.ARRAY).withDescription("Live viewers; always present, empty once the last viewer leaves.").withNullable(false).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1")),
"leaseCount", new Schema().withType(Type.INTEGER).withDescription("Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0.").withNullable(true),
"leases", new Schema().withType(Type.ARRAY).withDescription("One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0.")
.withNullable(true).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
}
}
/*******************************************************************************
** Only public identity and focus are carried, never field contents.
*******************************************************************************/
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName) implements ToSchema
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName, Integer connections, String connectionId, String sessionId, String since) implements ToSchema
{
/*******************************************************************************
** Editing takes precedence when one user has several open tabs.
@@ -108,10 +124,14 @@ public final class CollaborationPresenceSchemas
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs; EDITING takes precedence.")
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs (EDITING takes precedence), or one live connection in leases.")
.withProperties(new TreeMap<>(Map.of(
"userId", text("Stable user identity.", false), "displayName", text("Session display name.", false),
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true)))));
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true),
"connections", new Schema().withType(Type.INTEGER).withDescription("Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0.").withNullable(true),
"connectionId", text("Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0.", true),
"sessionId", text("Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0.", true),
"since", text("When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.", true).withFormat("date-time")))));
}
}
@@ -717,7 +717,7 @@ public final class CollaborationSchemas
schemas.put("CollaborationPresenceRequestV1", new CollaborationPresenceSchemas.CollaborationPresenceRequestV1(null, null, null, null, null).toSchema());
schemas.put("CollaborationPresenceLeaveV1", new CollaborationPresenceSchemas.CollaborationPresenceLeaveV1(null, null, null).toSchema());
schemas.put("CollaborationPresenceSnapshotV1", new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(null, null, null).toSchema());
schemas.put("CollaborationPresenceViewerV1", new CollaborationPresenceSchemas.CollaborationPresenceViewerV1(null, null, null, null).toSchema());
schemas.put("CollaborationPresenceViewerV1", new CollaborationPresenceSchemas.CollaborationPresenceViewerV1(null, null, null, null, null, null, null, null).toSchema());
schemas.putAll(CollaborationWorkSchemas.components());
return (schemas);
}
@@ -23,6 +23,7 @@ package com.kingsrook.qbits.collaboration.api;
import java.util.List;
import com.kingsrook.qbits.collaboration.presence.PresenceService;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Parameter;
@@ -48,7 +49,7 @@ public class GetPresenceSpec extends CollaborationJsonSpec
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters("table!", "pk"));
return (parameters("table!", "pk", "perConnection"));
}
/*******************************************************************************
@@ -68,6 +69,14 @@ public class GetPresenceSpec extends CollaborationJsonSpec
{
String table = required(input.getQueryParam("table"), "table");
String pk = input.getQueryParam("pk");
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, new PresenceService().snapshot(table, pk)));
String perConnection = input.getQueryParam("perConnection");
if(perConnection != null && !perConnection.equalsIgnoreCase("true") && !perConnection.equalsIgnoreCase("false"))
{
throw (new QBadRequestException("perConnection must be true or false"));
}
var service = new PresenceService();
var viewers = service.snapshot(table, pk);
var leases = service.connections(table, pk);
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, viewers, leases.size(), "true".equalsIgnoreCase(perConnection) ? leases : null));
}
}
@@ -267,15 +267,19 @@ public final class CollaborationHome
List<Map<String, Object>> rows = new ArrayList<>();
if(admin)
{
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
var registry = PresenceRegistry.get(QContext.getQInstance());
Map<String, Integer> connections = registry.connectionCounts();
registry.counts().forEach((table, viewers) ->
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("table", tableLabel(table));
row.put("viewers", viewers);
row.put("connections", connections.getOrDefault(table, viewers));
rows.add(row);
});
}
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right")));
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right"),
column("Connections", "connections", "right")));
}
@@ -21,18 +21,33 @@
package com.kingsrook.qbits.collaboration.presence;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.WeakHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
@@ -49,6 +64,7 @@ public final class PresenceRegistry implements AutoCloseable
public static final long TTL_MILLIS = 45000;
public static final int MAX_ENTRIES = 50000;
private static final QLogger LOG = QLogger.getLogger(PresenceRegistry.class);
private static final byte[] SESSION_SECRET = sessionSecret();
private static final Map<QInstance, PresenceRegistry> REGISTRIES = new WeakHashMap<>();
private final Map<Key, Entry> entries = new HashMap<>();
private final RealtimeBusInterface bus;
@@ -117,27 +133,52 @@ public final class PresenceRegistry implements AutoCloseable
}
/*******************************************************************************
** Each tab has its own lease; user views merge the live leases.
** Each tab has its own lease; user views merge the live leases. The lease
** keeps an opaque id of the server-side session (never the session uuid,
** which is a bearer credential) and the time it started (renewals keep it).
*******************************************************************************/
private record Entry(String displayName, String mode, String fieldName, long at)
private record Entry(String displayName, String mode, String fieldName, long at, String sessionId, long since)
{
}
/*******************************************************************************
** Transport-independent snapshot entry, never a persisted directory row.
** Merged per user, connections counts the live leases behind it; per
** connection (connections = 1), connectionId is an opaque per-lease id,
** sessionId an opaque id of the signed-in session (HMAC, never reversible to
** the session uuid) and since the lease start (ISO-8601). None of the three
** is set on merged viewers.
*******************************************************************************/
public record Viewer(String userId, String displayName, String mode, String fieldName) implements Serializable
public record Viewer(String userId, String displayName, String mode, String fieldName, Integer connections, @JsonInclude(JsonInclude.Include.NON_NULL) String connectionId,
@JsonInclude(JsonInclude.Include.NON_NULL) String sessionId, @JsonInclude(JsonInclude.Include.NON_NULL) String since) implements Serializable
{
/*******************************************************************************
** A merged viewer behind a number of live leases.
*******************************************************************************/
public Viewer(String userId, String displayName, String mode, String fieldName, Integer connections)
{
this(userId, displayName, mode, fieldName, connections, null, null, null);
}
}
/*******************************************************************************
** Refresh locally even if transport delivery fails; replicate every heartbeat.
*******************************************************************************/
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName)
{
return (heartbeat(user, displayName, connection, null, table, pk, mode, fieldName));
}
/*******************************************************************************
** Renew a lease tied to the caller's server-side session. Only an opaque HMAC
** of the session uuid is kept and replicated; the uuid itself is dropped here.
*******************************************************************************/
public boolean heartbeat(String user, String displayName, String connection, String sessionUuid, String table, String pk, String mode, String fieldName)
{
expire();
var key = new Key(user, connection, table, pk);
var entry = new Entry(displayName, mode, fieldName, clock.millis());
long now = clock.millis();
var entry = new Entry(displayName, mode, fieldName, now, opaqueSessionId(sessionUuid), now);
if(!apply(key, entry, false))
{
return (false);
@@ -152,7 +193,8 @@ public final class PresenceRegistry implements AutoCloseable
public void leave(String user, String connection, String table, String pk)
{
var key = new Key(user, connection, table, pk);
var entry = new Entry(null, null, null, clock.millis());
long now = clock.millis();
var entry = new Entry(null, null, null, now, null, now);
apply(key, entry, true);
publish(key, entry, true);
}
@@ -171,6 +213,7 @@ public final class PresenceRegistry implements AutoCloseable
data.put("mode", entry.mode());
data.put("fieldName", entry.fieldName());
data.put("at", entry.at());
data.put("sessionId", entry.sessionId());
data.put("leave", leave);
bus.publish(new RealtimeMessage().withChannel(INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(data));
}
@@ -197,7 +240,7 @@ public final class PresenceRegistry implements AutoCloseable
return;
}
apply(new Key(user, connection, table, (String) data.get("pk")),
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt, (String) data.get("sessionId"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
}
/*******************************************************************************
@@ -218,16 +261,25 @@ public final class PresenceRegistry implements AutoCloseable
LOG.warn("Presence capacity reached; refusing a new target");
return (false);
}
List<Viewer> before = snapshotInternal(key.table(), key.pk());
List<Viewer> before = projection(key.table(), key.pk());
if(leave)
{
entries.remove(key);
}
else
{
/////////////////////////////////////////////////////////////////
// a renewal keeps the lease start (pods may differ by a little: //
// the earliest start wins) and the first known session id //
/////////////////////////////////////////////////////////////////
if(old != null)
{
entry = new Entry(entry.displayName(), entry.mode(), entry.fieldName(), entry.at(),
old.sessionId() != null ? old.sessionId() : entry.sessionId(), Math.min(old.since(), entry.since()));
}
entries.put(key, entry);
}
changed = !before.equals(snapshotInternal(key.table(), key.pk()));
changed = !before.equals(projection(key.table(), key.pk()));
}
if(changed)
{
@@ -274,16 +326,137 @@ public final class PresenceRegistry implements AutoCloseable
*******************************************************************************/
private List<Viewer> snapshotInternal(String table, String pk)
{
Map<String, Entry> users = new java.util.TreeMap<>();
Map<String, Entry> users = new TreeMap<>();
Map<String, Integer> connections = new TreeMap<>();
for(var row : live(table, pk))
{
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
connections.merge(row.getKey().user(), 1, Integer::sum);
}
return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(),
pk == null ? null : row.getValue().fieldName(), connections.get(row.getKey()))).toList());
}
/*******************************************************************************
** One viewer per live lease, ordered by user, lease start and opaque id. A
** table target counts each connection once per record it holds a lease on.
*******************************************************************************/
private List<Viewer> connectionsInternal(String table, String pk)
{
return (live(table, pk).stream()
.map(row -> new Viewer(row.getKey().user(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName(), 1,
opaqueConnectionId(row.getKey().user(), row.getKey().connection()), row.getValue().sessionId(), Instant.ofEpochMilli(row.getValue().since()).toString()))
.sorted(java.util.Comparator.comparing(Viewer::userId).thenComparing(Viewer::since).thenComparing(Viewer::connectionId))
.toList());
}
/*******************************************************************************
** Live leases on a table (any record when pk is null) or on one record.
*******************************************************************************/
private List<Map.Entry<Key, Entry>> live(String table, String pk)
{
List<Map.Entry<Key, Entry>> result = new ArrayList<>();
for(var row : entries.entrySet())
{
if(row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis())
{
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
result.add(row);
}
}
return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName())).toList());
return (result);
}
/*******************************************************************************
** What clients can observe: merged viewers and per-connection viewers. Lease
** renewals that change neither generate no event.
*******************************************************************************/
private List<Viewer> projection(String table, String pk)
{
List<Viewer> result = new ArrayList<>(snapshotInternal(table, pk));
result.addAll(connectionsInternal(table, pk));
return (result);
}
/*******************************************************************************
** Opaque, stable per-lease id: "c" and the first 16 hex digits of
** SHA-256(userId + "\n" + connectionId). A client can recognise its own
** connection; nobody learns another client's connection id.
*******************************************************************************/
public static String opaqueConnectionId(String userId, String connectionId)
{
try
{
byte[] digest = MessageDigest.getInstance("SHA-256").digest((userId + "\n" + connectionId).getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder("c");
for(int i = 0; i < 8; i++)
{
result.append(String.format("%02x", digest[i]));
}
return (result.toString());
}
catch(NoSuchAlgorithmException e)
{
throw (new IllegalStateException("SHA-256 is unavailable", e));
}
}
/*******************************************************************************
** Opaque session id: "s" and the first 16 hex digits of
** HMAC-SHA256(secret, sessionUuid), or null without a session. The secret is
** QQQ_PRESENCE_SESSION_SECRET (system property or environment; share it across
** pods for ids that agree between pods) or else random per process. Session
** uuids are bearer credentials and must never be stored or published.
*******************************************************************************/
public static String opaqueSessionId(String sessionUuid)
{
if(sessionUuid == null || sessionUuid.isBlank())
{
return (null);
}
try
{
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SESSION_SECRET, "HmacSHA256"));
byte[] digest = mac.doFinal(sessionUuid.getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder("s");
for(int i = 0; i < 8; i++)
{
result.append(String.format("%02x", digest[i]));
}
return (result.toString());
}
catch(NoSuchAlgorithmException | InvalidKeyException e)
{
throw (new IllegalStateException("HmacSHA256 is unavailable", e));
}
}
/*******************************************************************************
** Configured shared secret, or 32 random bytes for this process.
*******************************************************************************/
private static byte[] sessionSecret()
{
String configured = System.getProperty("QQQ_PRESENCE_SESSION_SECRET", System.getenv("QQQ_PRESENCE_SESSION_SECRET"));
if(configured != null && !configured.isBlank())
{
return (configured.getBytes(StandardCharsets.UTF_8));
}
byte[] random = new byte[32];
new SecureRandom().nextBytes(random);
return (random);
}
/*******************************************************************************
** One viewer per live lease (opt-in per-connection presence, #54).
*******************************************************************************/
public List<Viewer> connections(String table, String pk)
{
expire();
synchronized(this)
{
return (connectionsInternal(table, pk));
}
}
/*******************************************************************************
@@ -294,12 +467,27 @@ public final class PresenceRegistry implements AutoCloseable
expire();
synchronized(this)
{
Map<String, Integer> result = new java.util.TreeMap<>();
Map<String, Integer> result = new TreeMap<>();
entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, snapshotInternal(table, null).size()));
return (result);
}
}
/*******************************************************************************
** Live lease (connection) counts per table, next to the user counts above.
*******************************************************************************/
public Map<String, Integer> connectionCounts()
{
expire();
synchronized(this)
{
Map<String, Integer> result = new TreeMap<>();
entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, live(table, null).size()));
result.values().removeIf(count -> count == 0);
return (result);
}
}
/*******************************************************************************
** Record focus stays on its record channel; table events contain table snapshots.
*******************************************************************************/
@@ -323,23 +511,97 @@ public final class PresenceRegistry implements AutoCloseable
{
data.put("pk", pk);
}
ArrayList<LinkedHashMap<String, Serializable>> viewers = new ArrayList<>();
Viewers viewers = new Viewers();
Viewers leases = new Viewers();
synchronized(this)
{
for(Viewer viewer : snapshotInternal(table, pk))
{
LinkedHashMap<String, Serializable> value = new LinkedHashMap<>();
value.put("userId", viewer.userId());
value.put("displayName", viewer.displayName());
value.put("mode", viewer.mode());
value.put("fieldName", viewer.fieldName());
viewers.add(value);
}
snapshotInternal(table, pk).forEach(viewer -> viewers.add(toMap(viewer)));
connectionsInternal(table, pk).forEach(viewer -> leases.add(toMap(viewer)));
}
data.put("viewers", viewers);
data.put("leaseCount", leases.size());
data.put("leases", leases);
bus.publish(new RealtimeMessage().withChannel(channel).withEvent("presence.changed").withData(data));
}
/*******************************************************************************
** Event shape of a viewer; per-connection fields only when set.
*******************************************************************************/
private static LinkedHashMap<String, Serializable> toMap(Viewer viewer)
{
LinkedHashMap<String, Serializable> value = new LinkedHashMap<>();
value.put("userId", viewer.userId());
value.put("displayName", viewer.displayName());
value.put("mode", viewer.mode());
value.put("fieldName", viewer.fieldName());
value.put("connections", viewer.connections());
if(viewer.connectionId() != null)
{
value.put("connectionId", viewer.connectionId());
if(viewer.sessionId() != null)
{
value.put("sessionId", viewer.sessionId());
}
value.put("since", viewer.since());
}
return (value);
}
/*******************************************************************************
** Event viewers list that is never omitted: the SSE writer serializes event data
** with NON_EMPTY inclusion, which would drop an empty list, but the schema
** declares viewers a required array, so the last leave must send [] (#52).
*******************************************************************************/
@JsonSerialize(using = ViewersSerializer.class)
public static final class Viewers extends ArrayList<LinkedHashMap<String, Serializable>>
{
}
/*******************************************************************************
** Writes each viewer with the caller's mapper settings and reports never empty.
*******************************************************************************/
public static final class ViewersSerializer extends StdSerializer<Viewers>
{
/*******************************************************************************
** Jackson instantiates the serializer reflectively.
*******************************************************************************/
public ViewersSerializer()
{
super(Viewers.class);
}
/*******************************************************************************
** Always an array, including the empty array after the last viewer leaves.
*******************************************************************************/
@Override
public void serialize(Viewers viewers, JsonGenerator generator, SerializerProvider provider) throws IOException
{
generator.writeStartArray(viewers, viewers.size());
for(LinkedHashMap<String, Serializable> viewer : viewers)
{
provider.defaultSerializeValue(viewer, generator);
}
generator.writeEndArray();
}
/*******************************************************************************
** NON_EMPTY inclusion consults this; an empty viewer list is still a value.
*******************************************************************************/
@Override
public boolean isEmpty(SerializerProvider provider, Viewers viewers)
{
return (false);
}
}
/*******************************************************************************
** Release listeners and timer on host shutdown or in tests.
*******************************************************************************/
@@ -62,7 +62,7 @@ public class PresenceService
throw (new QBadRequestException("Unknown focused field"));
}
String name = QContext.getQSession().getUser().getFullName();
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, table, pk, mode, field))
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, QContext.getQSession().getUuid(), table, pk, mode, field))
{
throw (new QBadRequestException("Presence capacity reached; retry later"));
}
@@ -89,6 +89,15 @@ public class PresenceService
return (registry().snapshot(table, authorize(table, pk)));
}
/*******************************************************************************
** One viewer per live lease, behind the same access boundary as snapshot.
*******************************************************************************/
public List<PresenceRegistry.Viewer> connections(String table, String pk) throws QException
{
PersonalRecords.currentUserId();
return (registry().connections(table, authorize(table, pk)));
}
/*******************************************************************************
** A refusal always has the same not-found result, including disabled features.
*******************************************************************************/
@@ -749,6 +749,19 @@ components:
CollaborationPresenceSnapshotV1:
description: "Live viewers, merged across tabs; presence.changed uses this payload."
properties:
leaseCount:
description: "Live connections (leases) behind viewers; a user with two\
\ terminals counts twice. Since 1.2.0."
nullable: true
type: "integer"
leases:
description: "One viewer per live connection, with connectionId and since.\
\ On GET only with perConnection=true; always on presence.changed. Since\
\ 1.2.0."
items:
$ref: "#/components/schemas/CollaborationPresenceViewerV1"
nullable: true
type: "array"
pk:
description: "Record key; absent for table events."
nullable: true
@@ -758,13 +771,26 @@ components:
nullable: false
type: "string"
viewers:
description: "Live viewers; always present, empty once the last viewer leaves."
items:
$ref: "#/components/schemas/CollaborationPresenceViewerV1"
nullable: false
type: "array"
type: "object"
CollaborationPresenceViewerV1:
description: "One user merged across live tabs; EDITING takes precedence."
description: "One user merged across live tabs (EDITING takes precedence), or\
\ one live connection in leases."
properties:
connectionId:
description: "Opaque per-connection id, only in leases: c + the first 16\
\ hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0."
nullable: true
type: "string"
connections:
description: "Live connections (tabs, terminals) merged into this viewer;\
\ 1 in leases. Since 1.2.0."
nullable: true
type: "integer"
displayName:
description: "Session display name."
nullable: false
@@ -780,6 +806,18 @@ components:
- "EDITING"
nullable: false
type: "string"
sessionId:
description: "Opaque id of the signed-in session behind this connection,\
\ only in leases (HMAC of the server session; never the session itself).\
\ Since 1.2.0."
nullable: true
type: "string"
since:
description: "When this connection's lease started (ISO-8601), only in leases.\
\ Since 1.2.0."
format: "date-time"
nullable: true
type: "string"
userId:
description: "Stable user identity."
nullable: false
@@ -1250,7 +1288,7 @@ info:
description: "Record collaboration: favorites, recents, attachments, tags, conversations,\
\ following, timeline, people and presence"
title: "QQQ Middleware API extension: collaboration"
version: "1.1.0"
version: "1.2.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/collaboration/presence:
@@ -1337,6 +1375,13 @@ paths:
required: false
schema:
type: "string"
- description: "Also return one viewer per live connection (lease) in leases;\
\ default false. Since collaboration 1.2.0."
in: "query"
name: "perConnection"
required: false
schema:
type: "boolean"
responses:
400:
content:
@@ -223,6 +223,21 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
assertThat(ok("POST", "/presence", body).get("viewers")).hasSize(1);
assertThat(ok("GET", "/presence?table=document&pk=1", null).at("/viewers/0/mode").asText()).isEqualTo("EDITING");
assertThat(ok("GET", "/presence?table=document", null).at("/viewers/0/fieldName").isNull()).isTrue();
ok("POST", "/presence", body.replace("tab-one", "tab-two").replace("EDITING", "VIEWING"));
JsonNode merged = ok("GET", "/presence?table=document&pk=1", null);
assertThat(merged.get("viewers")).hasSize(1);
assertThat(merged.at("/viewers/0/connections").asInt()).isEqualTo(2);
assertThat(merged.get("leaseCount").asInt()).isEqualTo(2);
assertThat(merged.has("leases")).isFalse();
JsonNode perConnection = ok("GET", "/presence?table=document&pk=1&perConnection=true", null);
assertThat(perConnection.get("viewers")).hasSize(1);
assertThat(perConnection.get("leases")).hasSize(2);
assertThat(perConnection.at("/leases/0/connectionId").asText()).startsWith("c").doesNotContain("tab-");
assertThat(perConnection.at("/leases/0/since").asText()).isNotBlank();
assertThat(perConnection.at("/leases/0/sessionId").asText()).startsWith("s");
assertThat(perConnection.toString()).doesNotContain("alice" + sessionSuffix).doesNotContain(sessionSuffix);
assertError(400, "GET", "/presence?table=document&perConnection=maybe", null, "alice");
ok("DELETE", "/presence", "{\"connectionId\":\"tab-two\",\"table\":\"document\",\"pk\":\"1\"}");
assertError(404, "GET", "/presence?table=document&pk=3", null, "alice");
assertError(404, "POST", "/presence", body, "outsider");
assertError(400, "POST", "/presence", "{\"connectionId\":\"x\",\"table\":\"document\",\"mode\":\"BAD\"}", "alice");
@@ -509,7 +524,7 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
JsonNode version = json(client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/qqq/v1/version")).GET().build(),
HttpResponse.BodyHandlers.ofString()), 200);
assertThat(version.at("/extensions/0/name").asText()).isEqualTo("collaboration");
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.1.0");
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.2.0");
}
/*******************************************************************************
@@ -24,8 +24,10 @@ package com.kingsrook.qbits.collaboration;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import com.kingsrook.qbits.collaboration.api.CollaborationPresenceSchemas;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qbits.collaboration.presence.PresenceService;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
@@ -36,6 +38,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.realtime.InMemoryRealtimeBus;
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -61,7 +64,7 @@ class PresenceTest extends PersonalServiceFixture
assertThat(second.snapshot("document", "1")).hasSize(1);
clock.advance(1000);
second.heartbeat("alice", "Alice", "two", "document", "1", "EDITING", "title");
assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title"));
assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title", 2));
assertThat(first.snapshot("document", null).getFirst().fieldName()).isNull();
int changes = (int) events.stream().filter(event -> event.getEvent().equals("presence.changed")).count();
clock.advance(20000);
@@ -87,6 +90,177 @@ class PresenceTest extends PersonalServiceFixture
}
}
/*******************************************************************************
** The last leave still sends viewers: the SSE writer and the HTTP snapshot must
** both carry an empty array, never omit the field (#52).
*******************************************************************************/
@Test
void lastLeaveSendsEmptyViewers() throws Exception
{
var clock = new MutableClock();
try(var bus = new InMemoryRealtimeBus(); var registry = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
{
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
bus.subscribe(events::add);
registry.heartbeat("bob", "Bob", "one", "person", null, "VIEWING", null);
registry.heartbeat("bob", "Bob", "one", "person", "7", "EDITING", "name");
registry.leave("bob", "one", "person", null);
registry.leave("bob", "one", "person", "7");
assertThat(registry.snapshot("person", null)).isEmpty();
for(String channel : List.of("table:person", "record:person:7"))
{
RealtimeMessage last = events.stream().filter(event -> event.getChannel().equals(channel) && event.getEvent().equals("presence.changed")).reduce((first, second) -> second).orElseThrow();
assertThat(last.getData()).containsKey("viewers");
assertThat((List<?>) last.getData().get("viewers")).isEmpty();
///////////////////////////////////////////////////////////////////////
// the realtime hub copies the data map and writes it with toJson, //
// whose NON_EMPTY inclusion used to drop the empty viewers list //
///////////////////////////////////////////////////////////////////////
String json = JsonUtils.toJson(new LinkedHashMap<>(last.getData()));
assertThat(JsonUtils.toJSONObject(json).getJSONArray("viewers").length()).isZero();
}
String occupied = JsonUtils.toJson(events.stream().filter(event -> event.getChannel().equals("record:person:7")).findFirst().orElseThrow().getData());
assertThat(JsonUtils.toJSONObject(occupied).getJSONArray("viewers").getJSONObject(0).getString("fieldName")).isEqualTo("name");
var snapshot = new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("person", null, registry.snapshot("person", null));
assertThat(JsonUtils.toJSONObject(JsonUtils.toJson(snapshot)).getJSONArray("viewers").length()).isZero();
}
}
/*******************************************************************************
** Two leases of one user merge into one viewer with connections = 2; per
** connection they are two viewers with opaque ids, modes, fields and starts.
** Counts keep counting users; connectionCounts counts leases (#54).
*******************************************************************************/
@Test
void perConnectionViewersAndCounts() throws Exception
{
var clock = new MutableClock();
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES);
var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
{
first.heartbeat("admin", "Admin", "terminal-1", "session-a", "person", "7", "VIEWING", null);
clock.advance(1000);
second.heartbeat("admin", "Admin", "terminal-2", "session-b", "person", "7", "EDITING", "name");
first.heartbeat("bob", "Bob", "tab", "session-c", "person", "7", "VIEWING", null);
assertThat(first.snapshot("person", "7")).containsExactly(
new PresenceRegistry.Viewer("admin", "Admin", "EDITING", "name", 2),
new PresenceRegistry.Viewer("bob", "Bob", "VIEWING", null, 1));
List<PresenceRegistry.Viewer> connections = second.connections("person", "7");
assertThat(connections).hasSize(3);
assertThat(connections.subList(0, 2)).extracting(PresenceRegistry.Viewer::userId, PresenceRegistry.Viewer::mode, PresenceRegistry.Viewer::fieldName, PresenceRegistry.Viewer::connections)
.containsExactly(org.assertj.core.groups.Tuple.tuple("admin", "VIEWING", null, 1), org.assertj.core.groups.Tuple.tuple("admin", "EDITING", "name", 1));
assertThat(connections.getFirst().connectionId()).isEqualTo(PresenceRegistry.opaqueConnectionId("admin", "terminal-1")).startsWith("c").hasSize(17);
assertThat(connections.getFirst().since()).isEqualTo("2026-10-13T12:00:00Z");
assertThat(connections.get(1).since()).isEqualTo("2026-10-13T12:00:01Z");
////////////////////////////////////////////////////////////////////
// renewals keep the lease start; the session uuid replicates //
////////////////////////////////////////////////////////////////////
clock.advance(20000);
first.heartbeat("admin", "Admin", "terminal-1", "session-a", "person", "7", "VIEWING", null);
assertThat(second.connections("person", "7").getFirst().since()).isEqualTo("2026-10-13T12:00:00Z");
assertThat(second.connections("person", "7")).extracting(PresenceRegistry.Viewer::sessionId)
.containsExactly(PresenceRegistry.opaqueSessionId("session-a"), PresenceRegistry.opaqueSessionId("session-b"), PresenceRegistry.opaqueSessionId("session-c"));
assertThat(PresenceRegistry.opaqueSessionId("session-a")).startsWith("s").hasSize(17).isNotEqualTo(PresenceRegistry.opaqueSessionId("session-b"));
////////////////////////////////////////////////////////////////////
// table snapshots and counts: users vs leases //
////////////////////////////////////////////////////////////////////
assertThat(first.snapshot("person", null)).extracting(PresenceRegistry.Viewer::connections).containsExactly(2, 1);
assertThat(first.connections("person", null)).extracting(PresenceRegistry.Viewer::fieldName).containsOnlyNulls();
assertThat(first.counts()).containsExactly(java.util.Map.entry("person", 2));
assertThat(first.connectionCounts()).containsExactly(java.util.Map.entry("person", 3));
second.leave("admin", "terminal-2", "person", "7");
assertThat(first.snapshot("person", "7").getFirst()).isEqualTo(new PresenceRegistry.Viewer("admin", "Admin", "VIEWING", null, 1));
}
}
/*******************************************************************************
** Session uuids are bearer credentials: no bus message (internal replication
** or client event) and no snapshot carries one, only the opaque session id.
*******************************************************************************/
@Test
void rawSessionUuidIsNeverPublished() throws Exception
{
var clock = new MutableClock();
String sessionUuid = "7d1c2a9e-secret-session-uuid";
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES);
var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
{
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
bus.subscribe(events::add);
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", "7", "VIEWING", null);
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", "7", "EDITING", "name");
first.leave("admin", "terminal-1", "person", "7");
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", null, "VIEWING", null);
assertThat(events).isNotEmpty().anySatisfy(event -> assertThat(event.getChannel()).isEqualTo(PresenceRegistry.INTERNAL_CHANNEL));
for(RealtimeMessage event : events)
{
assertThat(JsonUtils.toJson(new LinkedHashMap<>(event.getData()))).doesNotContain(sessionUuid);
}
assertThat(events).filteredOn(event -> event.getChannel().equals(PresenceRegistry.INTERNAL_CHANNEL) && !Boolean.TRUE.equals(event.getData().get("leave")))
.allSatisfy(event -> assertThat(event.getData().get("sessionId")).isEqualTo(PresenceRegistry.opaqueSessionId(sessionUuid)));
var snapshot = new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("person", null, second.snapshot("person", null), 1, second.connections("person", null));
String json = JsonUtils.toJson(snapshot);
assertThat(json).doesNotContain(sessionUuid).contains(PresenceRegistry.opaqueSessionId(sessionUuid));
}
}
/*******************************************************************************
** presence.changed carries connections per merged viewer, a lease count and
** one lease entry per connection; a second terminal of one user is a change.
*******************************************************************************/
@Test
void changedEventCarriesConnections() throws Exception
{
var clock = new MutableClock();
try(var bus = new InMemoryRealtimeBus(); var registry = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
{
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
bus.subscribe(events::add);
registry.heartbeat("admin", "Admin", "terminal-1", "person", "7", "VIEWING", null);
int before = recordEvents(events).size();
registry.heartbeat("admin", "Admin", "terminal-2", "person", "7", "VIEWING", null);
List<RealtimeMessage> after = recordEvents(events);
assertThat(after).hasSize(before + 1);
var json = JsonUtils.toJSONObject(JsonUtils.toJson(new LinkedHashMap<>(after.getLast().getData())));
assertThat(json.getJSONArray("viewers").length()).isEqualTo(1);
assertThat(json.getJSONArray("viewers").getJSONObject(0).getInt("connections")).isEqualTo(2);
assertThat(json.getJSONArray("viewers").getJSONObject(0).has("connectionId")).isFalse();
assertThat(json.getInt("leaseCount")).isEqualTo(2);
assertThat(json.getJSONArray("leases").length()).isEqualTo(2);
var lease = json.getJSONArray("leases").getJSONObject(1);
assertThat(lease.getString("userId")).isEqualTo("admin");
assertThat(lease.getString("mode")).isEqualTo("VIEWING");
assertThat(lease.getInt("connections")).isEqualTo(1);
assertThat(lease.getString("connectionId")).startsWith("c");
assertThat(lease.getString("since")).isEqualTo("2026-10-13T12:00:00Z");
assertThat(json.toString()).doesNotContain("terminal-");
registry.leave("admin", "terminal-1", "person", "7");
registry.leave("admin", "terminal-2", "person", "7");
var last = JsonUtils.toJSONObject(JsonUtils.toJson(new LinkedHashMap<>(recordEvents(events).getLast().getData())));
assertThat(last.getInt("leaseCount")).isZero();
assertThat(last.getJSONArray("leases").length()).isZero();
}
}
/*******************************************************************************
** Record-channel presence.changed events so far.
*******************************************************************************/
private static List<RealtimeMessage> recordEvents(List<RealtimeMessage> events)
{
return (events.stream().filter(event -> event.getChannel().equals("record:person:7") && event.getEvent().equals("presence.changed")).toList());
}
/*******************************************************************************
** At capacity, existing leases renew while new targets are refused on both pods.
*******************************************************************************/
@@ -117,7 +291,15 @@ class PresenceTest extends PersonalServiceFixture
void accessFeatureAndSnapshot() throws Exception
{
var service = new PresenceService();
QContext.getQSession().setUuid("session-uuid-alice");
assertThat(service.heartbeat("one", "document", "01", "EDITING", "title")).hasSize(1);
assertThat(service.connections("document", "1")).singleElement().satisfies(viewer ->
{
assertThat(viewer.connectionId()).isEqualTo(PresenceRegistry.opaqueConnectionId("alice", "one"));
assertThat(viewer.sessionId()).isEqualTo(PresenceRegistry.opaqueSessionId("session-uuid-alice"));
assertThat(JsonUtils.toJson(new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("document", "1", service.snapshot("document", "1"), 1, service.connections("document", "1"))))
.doesNotContain("session-uuid-alice");
});
assertThat(service.snapshot("document", "1")).extracting(PresenceRegistry.Viewer::userId).containsExactly("alice");
assertThat(service.snapshot("document", null)).hasSize(1);
assertThatThrownBy(() -> service.heartbeat("one", "document", "3", "VIEWING", null)).isInstanceOf(QNotFoundException.class);
@@ -137,6 +319,7 @@ class PresenceTest extends PersonalServiceFixture
admin();
assertThat(CollaborationHome.presence().getRows()).extracting(row -> row.get("table")).containsExactly("Documents");
assertThat(CollaborationHome.presence().getRows().getFirst().get("viewers")).isEqualTo(1);
assertThat(CollaborationHome.presence().getRows().getFirst().get("connections")).isEqualTo(1);
login("alice");
assertThat(CollaborationHome.presence().getRows()).isEmpty();
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY);