Compare commits

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

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

Refs #54

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

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

Extension collaboration 1.2.0; regenerated committed document.

Fixes #54

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

Fixes #52

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 04:25:29 -06:00
james.maesandCodex 03407ae79a ci: adopt managed orb 0.8 patch track (#43)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:26:43 -05:00
james.maesandCodex 9d6767e731 ci: adopt shared orb 0.7.18 (#42)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:43:27 -05:00
james.maesandCodex aa6a4a3b58 ci: adopt shared orb 0.7.17 (#41)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:15:33 -05:00
james.maesandCodex d9a46afbf9 ci: use the shared branch-aware CodeQL worker (#39)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:35:35 -05:00
james.maesandCodex b5308a8bab ci: adopt central orb 0.7.12 and stable gates (#37)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:50:12 -05:00
james.maesandCodex 0caaa3717e ci: centralize rc release completion in qqq orb (#36)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:23:46 -05:00
james.maesandCodex 464db76bc3 ci: adopt shared orb 0.7.11 (#35)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:54:30 -05:00
james.maes 19a06166f5 Merge pull request #34 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb 0.7.4
2026-10-20 19:32:48 -05:00
james.maesandCodex 61060e2793 ci: adopt central QQQ orb 0.7.4
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:26:47 -05:00
james.maes 4e88ed8303 Merge pull request #31 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:03:57 -05:00
james.maesandClaude Opus 5.5 7a7b804106 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:19:25 -05:00
james.maes 4c95151a86 Merge pull request #29 from QRun-IO/feature/ui-api-1.6
feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.15)
2026-10-15 16:12:53 -05:00
james.maesandClaude Opus 5.5 41bbf2c428 chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:27:04 -05:00
james.maesandClaude Opus 5.5 2f6be765f2 feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.14)
Regenerated against UI API 1.6.0: every operation has an operationId and
the standard error responses (BasicErrorResponseV1: error, code, message,
fieldErrors, traceId); the extension's own error schema is kept but
deprecated; hand-written error bodies go through QJavalinUtils so they carry
code/message/traceId. Extension MINOR bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:37 -05:00
james.maes 92dd901409 Merge pull request #28 from QRun-IO/fix/producer-version-from-build
fix: qbit metadata version comes from the build
2026-10-15 01:42:56 -05:00
james.maesandClaude Opus 5.5 6a4ea584f5 fix: the qbit metadata version comes from the build, not a hardcoded constant
The producer read a literal version, so released artifacts reported a stale
or -SNAPSHOT version. qbit-collaboration.properties (version=${project.version}) is
Maven-filtered and the producer loads it; tests assert producer == bundle ==
artifact version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:27:45 -05:00
james.maes 03b3bbaca2 Merge pull request #27 from QRun-IO/fix/help-bundle-version-test
test(help): bundle version test compares with the artifact version
2026-10-14 23:18:42 -05:00
24 changed files with 2129 additions and 316 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+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
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+6
View File
@@ -13,6 +13,9 @@ 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.
- Register the core followers provider for alerts and other qbits, with event preferences, future-mute exclusion, system reads scoped to one record, and caller-owned recipient access checks.
- Cache favorite/recent record labels only for FULL field access. Resolve comment/share notification labels per recipient with a table-label-and-id fallback. Use the typed core label-access API for email labels as well.
@@ -24,6 +27,9 @@ 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.
- The share recipient search and mention candidates no longer offer the caller to themself.
+24 -11
View File
@@ -410,13 +410,13 @@ The runtime provider adds this field and its search entry where TAGS is enabled.
## UI API
The producer registers extension `collaboration` version `1.0.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)`.
Presence, shares, tasks, my-work, direct upload and thumbnail routes are not part
of this extension version.
Requires QQQ `4.1.0-RC.15` and UI API `1.6.0`. Operations carry stable operationIds,
the collaboration tag and standard error responses.
All successes return HTTP 200. All bodies have named `Collaboration*V1` schemas
All successes return HTTP 200. Success and request bodies have named `Collaboration*V1` schemas
in [the extension contract](src/main/resources/openapi/qqq-v1/collaboration.yaml).
The server publishes these alongside core at `/qqq/v1/openapi.yaml` and `.json`.
@@ -478,9 +478,11 @@ attachment filename; preview requests inline content. QQQ's current OpenAPI
`Response` model cannot declare the `Location` header, so this contract uses the
JSON URL alternative consistently.
A missing or invisible parent returns identical `404 {"error":"Not found"}`.
Missing write permissions return 403; invalid input returns 400 with an `error`
message through QQQ's exception mapper. Provider/backend failures remain server
A missing or invisible parent returns 404 with the same `Not found` error message.
Missing write permissions return 403; invalid input returns 400 through QQQ's
exception mapper. Errors use `BasicErrorResponseV1` with `error`, `code`, `message`,
`traceId` and optional `fieldErrors`. `CollaborationErrorV1` remains defined but is
deprecated since 1.1.0. Provider/backend failures remain server
errors. Services continue to own author, record-lock and permission checks.
Mention candidates expose `canRead` as the string enum `"true"`, `"false"`,
`"unknown"`; unknown does not imply the recipient can read the record.
@@ -636,7 +638,7 @@ New routes, all under `/qqq/v1/qbit/collaboration`:
| PUT | `/records/{table}/{pk}/assignment` |
| GET | `/my-work` |
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension stays at unreleased **1.0.0**; contract tooling accepts these additions without a bump.
Typed `CollaborationWorkSchemas` describe requests and results. Task PATCH preserves omitted fields, accepts OPEN/DONE/CANCELLED transitions, and permits explicit null for optional fields; null `remindAt` selects the service's due-date default. Assignment PUT replaces owner and assignee; null clears. Invalid input is 400, denied visible-record operations are 403, and missing/invisible parents are 404. Access requests deliberately never inspect parent existence. The extension is now **1.1.0**, including the UI API 1.6.0 error contract and published presence schemas.
`CollaborationRealtime` publishes `comment.added/updated/deleted`, `attachment.changed`, `tags.changed` and `task.changed` on `record:{table}:{pk}` after commit. Successful notification delivery publishes `notification.created` on `user:{userId}`. Payloads contain identifiers only. `CollaborationFollowersProvider` supplies the core followers SPI for alerts and other consumers. Selectable Tags values remain unavailable because the overlay has no output hook.
@@ -719,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
@@ -731,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
@@ -739,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.
+4 -2
View File
@@ -120,6 +120,7 @@
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>qbit-collaboration.properties</include>
</includes>
</resource>
<resource>
@@ -127,6 +128,7 @@
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>qbit-collaboration.properties</exclude>
</excludes>
</resource>
</resources>
@@ -223,14 +225,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports qqq-bom-pom 4.1.0-RC.7 ahead of the parent's 4.0.0 BOM. -->
<!-- imports qqq-bom-pom 4.1.0-RC.15 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -22,6 +22,9 @@
package com.kingsrook.qbits.collaboration;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.attachments.DeleteAttachmentsProcess;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
@@ -44,13 +47,41 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-collaboration";
public static final String VERSION = "0.1.0";
public static final String VERSION = loadVersion();
private CollaborationQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Read the artifact version from the Maven-filtered properties resource.
*******************************************************************************/
private static String loadVersion()
{
try(InputStream inputStream = CollaborationQBitProducer.class.getClassLoader().getResourceAsStream("qbit-collaboration.properties"))
{
if(inputStream == null)
{
throw new IllegalStateException("Missing qbit-collaboration.properties; build with Maven to generate the artifact version.");
}
Properties properties = new Properties();
properties.load(inputStream);
String version = properties.getProperty("version");
if(version == null || version.isBlank() || version.contains("${"))
{
throw new IllegalStateException("Missing or unfiltered version in qbit-collaboration.properties; build with Maven resource filtering.");
}
return (version);
}
catch(IOException e)
{
throw new IllegalStateException("Could not read the artifact version from qbit-collaboration.properties.", e);
}
}
/*******************************************************************************
** Configure the qbit.
*******************************************************************************/
@@ -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.0.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)
@@ -46,6 +46,7 @@ import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Parameter;
@@ -112,10 +113,10 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
*******************************************************************************/
static List<BasicResponse> errors()
{
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "CollaborationErrorV1"),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", "CollaborationErrorV1")));
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.NOT_FOUND, "Not found (including invisible parents)", BasicErrorResponseV1.class.getSimpleName())));
}
/*******************************************************************************
@@ -160,13 +161,14 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
case "cursor" -> "Opaque nextCursor returned by the previous page; omit for the first page.";
case "before" -> "Timeline continuation token; alias for cursor. Supply only one.";
case "includeDone" -> "Include completed and cancelled tasks; default false.";
case "perConnection" -> "Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0.";
case "status" -> "Task status: OPEN (default), DONE or CANCELLED.";
case "types" -> "Comma-separated AUDIT, ACTIVITY or activity type names (for example COMMENTED).";
case "q" -> "Text to search in display names or vocabulary.";
default -> name;
};
return (new Parameter().withName(name).withIn(location).withRequired(required).withDescription(description)
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") ? Type.BOOLEAN : Type.STRING)));
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") || name.equals("perConnection") ? Type.BOOLEAN : Type.STRING)));
}
/*******************************************************************************
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.collaboration.api;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
import com.kingsrook.qqq.openapi.model.Schema;
@@ -80,10 +81,22 @@ public final class CollaborationPresenceSchemas
}
/*******************************************************************************
** The snapshot is also the data shape of presence.changed events.
** The snapshot is also the data shape of presence.changed events. Viewers are
** included even when empty, whatever inclusion the serializer defaults to.
*******************************************************************************/
public record CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers) implements ToSchema
public record CollaborationPresenceSnapshotV1(String table, String pk, @JsonInclude(JsonInclude.Include.ALWAYS) List<PresenceRegistry.Viewer> viewers,
Integer leaseCount, @JsonInclude(JsonInclude.Include.NON_NULL) List<PresenceRegistry.Viewer> leases) implements ToSchema
{
/*******************************************************************************
** The merged snapshot without per-connection detail.
*******************************************************************************/
public CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers)
{
this(table, pk, viewers, null, null);
}
/*******************************************************************************
** A table snapshot deliberately excludes record ids and focused field names.
*******************************************************************************/
@@ -93,14 +106,17 @@ public final class CollaborationPresenceSchemas
return (new Schema().withType(Type.OBJECT).withDescription("Live viewers, merged across tabs; presence.changed uses this payload.")
.withProperties(new TreeMap<>(Map.of(
"table", text("Parent table name.", false), "pk", text("Record key; absent for table events.", true),
"viewers", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
"viewers", new Schema().withType(Type.ARRAY).withDescription("Live viewers; always present, empty once the last viewer leaves.").withNullable(false).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1")),
"leaseCount", new Schema().withType(Type.INTEGER).withDescription("Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0.").withNullable(true),
"leases", new Schema().withType(Type.ARRAY).withDescription("One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0.")
.withNullable(true).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
}
}
/*******************************************************************************
** Only public identity and focus are carried, never field contents.
*******************************************************************************/
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName) implements ToSchema
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName, Integer connections, String connectionId, String sessionId, String since) implements ToSchema
{
/*******************************************************************************
** Editing takes precedence when one user has several open tabs.
@@ -108,10 +124,14 @@ public final class CollaborationPresenceSchemas
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs; EDITING takes precedence.")
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs (EDITING takes precedence), or one live connection in leases.")
.withProperties(new TreeMap<>(Map.of(
"userId", text("Stable user identity.", false), "displayName", text("Session display name.", false),
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true)))));
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true),
"connections", new Schema().withType(Type.INTEGER).withDescription("Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0.").withNullable(true),
"connectionId", text("Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0.", true),
"sessionId", text("Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0.", true),
"since", text("When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.", true).withFormat("date-time")))));
}
}
@@ -514,7 +514,7 @@ public final class CollaborationSchemas
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Standard QQQ error response.").withProperties(new TreeMap<>(Map.ofEntries(
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("error", new Schema().withType(Type.STRING).withDescription("Human-readable reason the operation failed.").withNullable(true))))));
}
}
@@ -714,6 +714,10 @@ public final class CollaborationSchemas
schemas.put("CollaborationCommentPostRequestV1", new CollaborationCommentPostRequestV1(null, null, null).toSchema());
schemas.put("CollaborationCommentEditRequestV1", new CollaborationCommentEditRequestV1(null).toSchema());
schemas.put("CollaborationFollowRequestV1", new CollaborationFollowRequestV1(null, null).toSchema());
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, 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.
*******************************************************************************/
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
version=${project.version}
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -56,7 +57,7 @@ class CollaborationApiContractTest
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
"mvnw.cmd -o -q -Dtest=CollaborationApiContractTest -Dcollaboration.openapi.update=true test")).isEmpty();
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration"));
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("Collaboration") || name.equals(BasicErrorResponseV1.class.getSimpleName()));
assertThat(model.getPaths().keySet()).allMatch(pathName -> pathName.startsWith("/qqq/v1/qbit/collaboration/"));
assertThat(generated).contains("multipart/form-data", "format: \"binary\"", "clients follow url", "nextCursor");
assertThat(extension.getSpecs()).hasSize(45);
@@ -37,6 +37,7 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qbits.collaboration.attachments.AttachmentStorage;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
@@ -222,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");
@@ -431,8 +447,16 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
HttpResponse<String> hidden = request("GET", "/records/document/3/" + feature, null, "alice");
HttpResponse<String> absent = request("GET", "/records/document/999/" + feature, null, "alice");
assertThat(hidden.statusCode()).isEqualTo(404);
assertThat(hidden.body()).isEqualTo(absent.body());
assertThat(json(hidden, 404).get("error").asText()).isEqualTo("Not found");
ObjectNode hiddenError = (ObjectNode) json(hidden, 404);
ObjectNode absentError = (ObjectNode) json(absent, 404);
assertThat(hiddenError.path("traceId").asText()).isNotBlank();
assertThat(absentError.path("traceId").asText()).isNotBlank();
hiddenError.remove("traceId");
absentError.remove("traceId");
assertThat(hiddenError).isEqualTo(absentError);
assertThat(hiddenError.path("error").asText()).isEqualTo("Not found");
assertThat(hiddenError.path("code").asText()).isEqualTo("NOT_FOUND");
assertThat(hiddenError.path("message").asText()).isEqualTo("Not found");
}
assertError(404, "POST", "/records/document/3/comments", "not json", "alice");
assertError(404, "GET", "/mentions/candidates?table=document&pk=3", null, "alice");
@@ -500,15 +524,19 @@ 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.0.0");
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.2.0");
}
/*******************************************************************************
** Framework errors always contain a human-readable error property.
** Framework errors include the standard message, code and request trace.
*******************************************************************************/
private void assertError(int status, String method, String path, String body, String caller) throws Exception
{
assertThat(json(request(method, path, body, caller), status).get("error").asText()).isNotBlank();
var error = json(request(method, path, body, caller), status);
assertThat(error.path("error").asText()).isNotBlank();
assertThat(error.path("code").asText()).isNotBlank();
assertThat(error.path("message").asText()).isNotBlank();
assertThat(error.path("traceId").asText()).isNotBlank();
}
/*******************************************************************************
@@ -146,7 +146,7 @@ class CollaborationTablesGuardrailTest extends PersonalServiceFixture
assertThat(HelpBundles.of(instance).getBundles()).singleElement().satisfies(bundle ->
{
assertThat(bundle.getId()).isEqualTo("qbit-collaboration");
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion"));
assertThat(bundle.getVersion()).as("the help bundle carries the artifact version").isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CollaborationQBitProducer.VERSION);
assertThat(bundle.getEntries()).anyMatch(entry -> entry.getKey().equals("app:collaboration;slot:help"));
});
}
@@ -146,7 +146,7 @@ class CollaborationTest
assertThat(producer.isEnabled()).isTrue();
assertThat(producer.getQBitConfig()).isSameAs(config);
assertThat(producer.getNamespace()).isEqualTo("collaboration");
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo("0.1.0");
assertThat(producer.getQBitMetaData().getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(CollaborationQBitProducer.VERSION);
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-collaboration");
assertThat(config.getDefaultBackendNameForTables()).isEqualTo("memory");
assertThat(config.getAdminApp()).isTrue();
@@ -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);