Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 e78182985f fix(presence): always send viewers, empty after the last leave
When the last viewer left, presence.changed arrived as {"table":"person"}
with no viewers. The registry did put an empty list in the event data,
but core's SSE writer serializes event data with JsonUtils.toJson, whose
NON_EMPTY inclusion drops empty collections, including map values. A
client waiting for viewers kept showing the last person, although
CollaborationPresenceSnapshotV1.viewers is a required array.

The event's viewers list is now a PresenceRegistry.Viewers list whose
serializer reports it is never empty, so the SSE stream sends
"viewers":[]. The snapshot record marks viewers JsonInclude.ALWAYS so it
does not depend on the HTTP layer's inclusion either, and the OpenAPI
extension document now states viewers is non-nullable and always present.

The root cause sits in qqq core (RealtimeStreamSpecV1 serializes with
NON_EMPTY, unlike AbstractJsonEndpointSpec), which also drops other empty
arrays such as record.* fields; this fix covers presence until core does.

Fixes #52

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-31 19:12:33 -05:00
5 changed files with 112 additions and 4 deletions

No files matched your search

+1
View File
@@ -27,6 +27,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.
@@ -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,9 +81,10 @@ 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) implements ToSchema
{
/*******************************************************************************
** A table snapshot deliberately excludes record ids and focused field names.
@@ -93,7 +95,7 @@ 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"))))));
}
}
@@ -21,6 +21,7 @@
package com.kingsrook.qbits.collaboration.presence;
import java.io.IOException;
import java.io.Serializable;
import java.time.Clock;
import java.util.ArrayList;
@@ -33,6 +34,10 @@ import java.util.WeakHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
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;
@@ -323,7 +328,7 @@ public final class PresenceRegistry implements AutoCloseable
{
data.put("pk", pk);
}
ArrayList<LinkedHashMap<String, Serializable>> viewers = new ArrayList<>();
Viewers viewers = new Viewers();
synchronized(this)
{
for(Viewer viewer : snapshotInternal(table, pk))
@@ -340,6 +345,61 @@ public final class PresenceRegistry implements AutoCloseable
bus.publish(new RealtimeMessage().withChannel(channel).withEvent("presence.changed").withData(data));
}
/*******************************************************************************
** 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.
*******************************************************************************/
@@ -758,8 +758,10 @@ 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:
@@ -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;
@@ -87,6 +90,46 @@ 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();
}
}
/*******************************************************************************
** At capacity, existing leases renew while new targets are refused on both pods.
*******************************************************************************/