Compare commits

...
Author SHA1 Message Date
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
8 changed files with 138 additions and 7 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
+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.
*******************************************************************************/