Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae1e62b7b | ||
|
|
47cbc7c72d | ||
|
|
1ae6508793 | ||
|
|
03407ae79a | ||
|
|
9d6767e731 | ||
|
|
aa6a4a3b58 | ||
|
|
d9a46afbf9 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
orb_version: '0.8'
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+26
-6
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user