Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e78182985f |
No files matched your search
@@ -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.
|
||||
|
||||
+5
-3
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user