//// Deterministic presence tracker and diff model.
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
/// Presence metadata for one connection.
pub type Meta {
Meta(ref: String, online_at_ms: Int)
}
/// Presence entry keyed by user identifier.
pub type Entry {
Entry(key: String, metas: List(Meta))
}
/// Presence diff payload with joins and leaves.
pub type Diff {
Diff(topic: String, joins: List(Entry), leaves: List(Entry))
}
type TopicEntries {
TopicEntries(topic: String, entries_rev: List(Entry))
}
/// Presence tracker state.
pub opaque type Tracker {
Tracker(topics_rev: List(TopicEntries))
}
/// New tracker.
pub fn new() -> Tracker {
Tracker(topics_rev: [])
}
/// Track one join presence and return resulting diff.
pub fn join(
tracker: Tracker,
topic: String,
key: String,
meta: Meta,
) -> #(Tracker, Diff) {
let #(topics_rev, joined_meta) =
join_topic(tracker.topics_rev, topic, key, meta)
let joins = case joined_meta {
Some(joined) -> [Entry(key: key, metas: [joined])]
None -> []
}
#(
Tracker(topics_rev: topics_rev),
Diff(topic: topic, joins: joins, leaves: []),
)
}
/// Track one leave presence and return resulting diff.
pub fn leave(
tracker: Tracker,
topic: String,
key: String,
ref: String,
) -> #(Tracker, Diff) {
let #(topics_rev, removed_meta) =
leave_topic(tracker.topics_rev, topic, key, ref)
let leaves = case removed_meta {
Some(left) -> [Entry(key: key, metas: [left])]
None -> []
}
#(
Tracker(topics_rev: topics_rev),
Diff(topic: topic, joins: [], leaves: leaves),
)
}
/// List full presence state for one topic.
pub fn list_topic(tracker: Tracker, topic: String) -> List(Entry) {
find_topic_entries(tracker.topics_rev, topic)
}
/// Get one key presence entry.
pub fn get(tracker: Tracker, topic: String, key: String) -> Option(Entry) {
list_topic(tracker, topic)
|> find_entry(key)
}
/// Number of tracked keys in one topic.
pub fn key_count(tracker: Tracker, topic: String) -> Int {
list.length(list_topic(tracker, topic))
}
/// Number of tracked refs in one topic.
pub fn ref_count(tracker: Tracker, topic: String) -> Int {
list_topic(tracker, topic)
|> count_refs(0)
}
/// Stable topic labels for logs and tests.
pub fn topic_labels(tracker: Tracker) -> List(String) {
tracker.topics_rev
|> list.map(fn(topic_entries) {
topic_entries.topic
<> ":keys="
<> int.to_string(list.length(topic_entries.entries_rev))
<> ":refs="
<> int.to_string(count_refs(topic_entries.entries_rev, 0))
})
}
/// Diff topic.
pub fn diff_topic(diff: Diff) -> String {
diff.topic
}
/// Joined entries.
pub fn joins(diff: Diff) -> List(Entry) {
diff.joins
}
/// Left entries.
pub fn leaves(diff: Diff) -> List(Entry) {
diff.leaves
}
/// Number of join entries in one diff.
pub fn join_count(diff: Diff) -> Int {
list.length(diff.joins)
}
/// Number of leave entries in one diff.
pub fn leave_count(diff: Diff) -> Int {
list.length(diff.leaves)
}
/// Stable diff label for fixtures.
pub fn diff_label(diff: Diff) -> String {
diff.topic
<> ":joins="
<> int.to_string(join_count(diff))
<> ":leaves="
<> int.to_string(leave_count(diff))
}
/// Meta reference.
pub fn meta_ref(meta: Meta) -> String {
meta.ref
}
/// Meta online timestamp.
pub fn meta_online_at_ms(meta: Meta) -> Int {
meta.online_at_ms
}
fn join_topic(
topics_rev: List(TopicEntries),
topic: String,
key: String,
meta: Meta,
) -> #(List(TopicEntries), Option(Meta)) {
case topics_rev {
[] -> #(
[
TopicEntries(topic: topic, entries_rev: [Entry(key: key, metas: [meta])]),
],
Some(meta),
)
[topic_entries, ..rest] ->
case topic_entries.topic == topic {
True -> {
let #(entries_rev, joined) =
join_entry(topic_entries.entries_rev, key, meta)
#(
[
TopicEntries(topic: topic_entries.topic, entries_rev: entries_rev),
..rest
],
joined,
)
}
False -> {
let #(updated_rest, joined) = join_topic(rest, topic, key, meta)
#([topic_entries, ..updated_rest], joined)
}
}
}
}
fn join_entry(
entries_rev: List(Entry),
key: String,
meta: Meta,
) -> #(List(Entry), Option(Meta)) {
case entries_rev {
[] -> #([Entry(key: key, metas: [meta])], Some(meta))
[entry, ..rest] ->
case entry.key == key {
True ->
case has_ref(entry.metas, meta.ref) {
True -> #([entry, ..rest], None)
False -> {
let updated = Entry(key: entry.key, metas: [meta, ..entry.metas])
#([updated, ..rest], Some(meta))
}
}
False -> {
let #(updated_rest, joined) = join_entry(rest, key, meta)
#([entry, ..updated_rest], joined)
}
}
}
}
fn leave_topic(
topics_rev: List(TopicEntries),
topic: String,
key: String,
ref: String,
) -> #(List(TopicEntries), Option(Meta)) {
case topics_rev {
[] -> #([], None)
[topic_entries, ..rest] ->
case topic_entries.topic == topic {
True -> {
let #(entries_rev, removed_meta) =
leave_entry(topic_entries.entries_rev, key, ref)
case entries_rev {
[] -> #(rest, removed_meta)
_ -> #(
[
TopicEntries(
topic: topic_entries.topic,
entries_rev: entries_rev,
),
..rest
],
removed_meta,
)
}
}
False -> {
let #(updated_rest, removed_meta) = leave_topic(rest, topic, key, ref)
#([topic_entries, ..updated_rest], removed_meta)
}
}
}
}
fn leave_entry(
entries_rev: List(Entry),
key: String,
ref: String,
) -> #(List(Entry), Option(Meta)) {
case entries_rev {
[] -> #([], None)
[entry, ..rest] ->
case entry.key == key {
True -> {
let #(metas, removed) = remove_meta(entry.metas, ref)
case metas {
[] -> #(rest, removed)
_ -> #([Entry(key: entry.key, metas: metas), ..rest], removed)
}
}
False -> {
let #(updated_rest, removed) = leave_entry(rest, key, ref)
#([entry, ..updated_rest], removed)
}
}
}
}
fn remove_meta(metas: List(Meta), ref: String) -> #(List(Meta), Option(Meta)) {
case metas {
[] -> #([], None)
[meta, ..rest] ->
case meta.ref == ref {
True -> #(rest, Some(meta))
False -> {
let #(updated_rest, removed) = remove_meta(rest, ref)
#([meta, ..updated_rest], removed)
}
}
}
}
fn has_ref(metas: List(Meta), ref: String) -> Bool {
case metas {
[] -> False
[meta, ..rest] ->
case meta.ref == ref {
True -> True
False -> has_ref(rest, ref)
}
}
}
fn find_topic_entries(
topics_rev: List(TopicEntries),
topic: String,
) -> List(Entry) {
case topics_rev {
[] -> []
[topic_entries, ..rest] ->
case topic_entries.topic == topic {
True -> topic_entries.entries_rev
False -> find_topic_entries(rest, topic)
}
}
}
fn find_entry(entries: List(Entry), key: String) -> Option(Entry) {
case entries {
[] -> None
[entry, ..rest] ->
case entry.key == key {
True -> Some(entry)
False -> find_entry(rest, key)
}
}
}
fn count_refs(entries: List(Entry), total: Int) -> Int {
case entries {
[] -> total
[entry, ..rest] -> count_refs(rest, total + list.length(entry.metas))
}
}