where_is_waldo 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -0
- data/README.md +47 -0
- data/VERSION +1 -1
- data/app/channels/where_is_waldo/presence_channel.rb +32 -3
- data/app/channels/where_is_waldo/roster_channel.rb +13 -6
- data/app/services/where_is_waldo/roster/org_keys.rb +81 -0
- data/app/services/where_is_waldo/roster.rb +28 -11
- data/app/services/where_is_waldo/roster_delivery.rb +16 -9
- data/lib/generators/where_is_waldo/install/install_generator.rb +35 -3
- data/lib/generators/where_is_waldo/install/templates/migration.rb.tt +6 -1
- data/lib/where_is_waldo/configuration.rb +23 -5
- metadata +12 -9
- data/lib/generators/where_is_waldo/install/templates/create_presences.rb.tt +0 -44
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9b514019f64edaaea98c2d51266e3668f5a3bd270c5727013bb38bd7db51232
|
|
4
|
+
data.tar.gz: c4313e9f5f1f05b3640c2e019dcbe7883f4a59f027944f6395878baa60bdfdf4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 02aaa7a93acfa3c4732eec460b1fad4f6361dc0c64e9ed60539914b025d785db4dd4ed8acb8b0e55ae92a4cda116c31acb1f46745c1567196b461470b2ea8e27
|
|
7
|
+
data.tar.gz: 955493cf1cab229f550e25ffc3a8e54069ba297bf9bf9e0ee6ed0acc5e092bae61c00278b684a44d14833fe23210b0b35409fc2a09f6234cabcd10df2a44bf91
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,91 @@
|
|
|
3
3
|
Notable changes to where_is_waldo. Format loosely follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/).
|
|
5
5
|
|
|
6
|
+
## Unreleased
|
|
7
|
+
|
|
8
|
+
## 0.1.12 - 2026-10-27
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **A subject in more than one org could be put on the wrong roster.**
|
|
13
|
+
`roster_org` receives only the subject, so an app whose users belong to
|
|
14
|
+
several orgs had to guess, and the obvious guess (`accounts.first`) is
|
|
15
|
+
association order rather than the org the person is actually working in.
|
|
16
|
+
They were then invisible to the teammates they were with, and shown to an
|
|
17
|
+
org they were not in.
|
|
18
|
+
|
|
19
|
+
New `roster_org_for` receives `(subject, container)`, where the container is
|
|
20
|
+
the one the CONNECTION is scoped to -- the only place that answer exists. It
|
|
21
|
+
applies on BOTH sides: the presence row stores the resolved org at connect
|
|
22
|
+
and later broadcasts read it back, and the roster subscription resolves the
|
|
23
|
+
same way, so a viewer and the broadcasts meant for them can no longer land
|
|
24
|
+
on different streams. `container` comes from your connection's
|
|
25
|
+
`identified_by :current_container`; without that it is nil and behaviour is
|
|
26
|
+
unchanged. A subject present in two
|
|
27
|
+
orgs now appears in both rosters rather than one arbitrary one.
|
|
28
|
+
|
|
29
|
+
Stored in the existing `metadata` column, so **no migration is required**.
|
|
30
|
+
`roster_org` is untouched and still works for single-org apps.
|
|
31
|
+
|
|
32
|
+
- **A presence transition read a subject's sessions twice.** Publishing needs
|
|
33
|
+
both the org keys and the aggregate state, and both come from the same rows;
|
|
34
|
+
they are now derived from one read. This is the hottest path in the library,
|
|
35
|
+
so the second read was pure overhead.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## 0.1.11 - 2026-10-27
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **The install generator emitted the pre-0.1.5 schema.** It wrote a unique
|
|
43
|
+
index on the session column alone, while 0.1.5 moved presence rows to being
|
|
44
|
+
keyed by `(subject, session)` precisely so two authenticated subjects who
|
|
45
|
+
collide on a session id cannot upsert onto each other's row — session ids are
|
|
46
|
+
client-supplied in some setups, and a reused JWT `jti` is enough. The adapter
|
|
47
|
+
has scoped every session lookup by both columns since; only the generator
|
|
48
|
+
lagged, so every app installed after 0.1.5 got a schema the library had
|
|
49
|
+
already moved away from and needed a follow-up migration to correct. An
|
|
50
|
+
unused second migration template carrying the right index (and a hardcoded
|
|
51
|
+
`Migration[7.0]`) has been removed rather than left as a second answer.
|
|
52
|
+
|
|
53
|
+
- **The install generator overwrote an existing ActionCable connection.**
|
|
54
|
+
Installing into an app that already authenticates its own cable connection
|
|
55
|
+
replaced that file with the turnkey one, silently dropping whatever the app
|
|
56
|
+
did beyond what this library needs — container scoping and token bookkeeping
|
|
57
|
+
in the cases seen. Existing channel files are now kept, and the post-install
|
|
58
|
+
message names them and states what the channel requires of a connection
|
|
59
|
+
(`current_user` and `session_id`).
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- **The Redis adapter is now tested against a real server, on both redis-rb 5
|
|
64
|
+
and 6.** Its existing spec runs against `MockRedis`, a pure-Ruby fake with no
|
|
65
|
+
protocol layer, so it could not see wire-format changes at all. redis-rb 6
|
|
66
|
+
defaults to RESP3, which types several replies differently from RESP2 —
|
|
67
|
+
`SMEMBERS` among them, and this adapter feeds that result straight into
|
|
68
|
+
`filter_map` and `all?`. Nothing in the adapter's command set behaves
|
|
69
|
+
differently between the two, but that was previously unverified rather than
|
|
70
|
+
known.
|
|
71
|
+
|
|
72
|
+
- New integration spec drives the adapter against a live Redis. It skips when
|
|
73
|
+
no server is reachable, so contributors are not required to run one.
|
|
74
|
+
- CI adds an `rspec-redis` job with a Redis service, run as a matrix over
|
|
75
|
+
redis-rb 5.x and 6.x, and fails rather than skips if the service is missing
|
|
76
|
+
— a silent skip would read as a pass.
|
|
77
|
+
- Development dependency widened from `~> 5.0` to `>= 5.0, < 7`. The gem has
|
|
78
|
+
no runtime dependency on `redis` and never has, so this constrains nothing
|
|
79
|
+
for consumers; it only lets the library test what consumers actually use.
|
|
80
|
+
- README documents supported client versions, and that `protocol: 2` is not
|
|
81
|
+
needed.
|
|
82
|
+
|
|
83
|
+
The committed lockfile stays on redis 5.x, which keeps `mock_redis` at
|
|
84
|
+
0.53.0 for every existing spec. `mock_redis` 0.53.0 depends on `redis ~> 5`,
|
|
85
|
+
so resolving against redis 6 forces it down to 0.48.1 — that happens only
|
|
86
|
+
inside the redis 6 CI leg, which runs the real-server spec alone and never
|
|
87
|
+
touches MockRedis. No mock-based spec changed the double it runs against.
|
|
88
|
+
|
|
89
|
+
No runtime code changed.
|
|
90
|
+
|
|
6
91
|
## 0.1.10
|
|
7
92
|
|
|
8
93
|
### Added
|
data/README.md
CHANGED
|
@@ -41,6 +41,13 @@ rails generate where_is_waldo:install --adapter=redis --subject_column=user_id
|
|
|
41
41
|
npm install @byscott-io/where-is-waldo @rails/actioncable
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Installing into an app that already has `app/channels/application_cable/`?
|
|
45
|
+
Those files are kept, never overwritten — the generator only adds what is
|
|
46
|
+
missing, and says which it left alone. The presence channel needs the
|
|
47
|
+
connection to identify **`current_user`** and **`session_id`**; add them to
|
|
48
|
+
your own connection, or `include WhereIsWaldo::JwtConnection` (corebyscott
|
|
49
|
+
apps) to get both from the JWT.
|
|
50
|
+
|
|
44
51
|
### 2. Configure
|
|
45
52
|
|
|
46
53
|
```ruby
|
|
@@ -148,6 +155,20 @@ WhereIsWaldo.configure do |config|
|
|
|
148
155
|
end
|
|
149
156
|
```
|
|
150
157
|
|
|
158
|
+
#### Redis adapter and client versions
|
|
159
|
+
|
|
160
|
+
The gem does not depend on `redis`. Choosing `:redis` means supplying your own
|
|
161
|
+
client, so you pick the version.
|
|
162
|
+
|
|
163
|
+
`redis-rb` 5.x and 6.x are both supported and both run in CI against a real
|
|
164
|
+
server. Version 6 switched to RESP3 by default, which changes reply types for
|
|
165
|
+
some commands; none of the commands this adapter uses behave differently, and
|
|
166
|
+
the integration spec exists to keep that true. Nothing here needs `protocol: 2`.
|
|
167
|
+
|
|
168
|
+
The adapter stores presence under keys prefixed by `config.redis_prefix`
|
|
169
|
+
(default `where_is_waldo`) and sets a TTL on session keys, so it does not need a
|
|
170
|
+
cleanup job the way the database adapter does.
|
|
171
|
+
|
|
151
172
|
### Querying Presence
|
|
152
173
|
|
|
153
174
|
```ruby
|
|
@@ -269,6 +290,32 @@ their live sessions (multiple browser tabs, mobile, etc.):
|
|
|
269
290
|
WhereIsWaldo.configure do |config|
|
|
270
291
|
# The org/account a subject belongs to. Required to enable the roster.
|
|
271
292
|
config.roster_org = ->(subject) { subject.account }
|
|
293
|
+
#
|
|
294
|
+
# MULTI-ORG SUBJECTS: roster_org is handed only the subject, so it has to
|
|
295
|
+
# guess which org the person is in right now -- and `accounts.first` guesses
|
|
296
|
+
# by association order. Someone who has switched is then put on the wrong
|
|
297
|
+
# roster: invisible to the teammates they are with, shown to an org they are
|
|
298
|
+
# not in. Use roster_org_for instead, which also receives the container the
|
|
299
|
+
# CONNECTION is scoped to:
|
|
300
|
+
#
|
|
301
|
+
# config.roster_org_for = ->(user, container) { container || user.accounts.first }
|
|
302
|
+
#
|
|
303
|
+
# `container` is read from your own ActionCable connection, so it is only
|
|
304
|
+
# ever non-nil if that connection identifies one:
|
|
305
|
+
#
|
|
306
|
+
# module ApplicationCable
|
|
307
|
+
# class Connection < ActionCable::Connection::Base
|
|
308
|
+
# identified_by :current_user, :current_container, :session_id
|
|
309
|
+
# end
|
|
310
|
+
# end
|
|
311
|
+
#
|
|
312
|
+
# WhereIsWaldo::JwtConnection already does this. Without it `container` is
|
|
313
|
+
# always nil and the fallback above behaves exactly like plain roster_org --
|
|
314
|
+
# which is to say, like the bug.
|
|
315
|
+
#
|
|
316
|
+
# The resolved org is stored on the presence row at connect and read back by
|
|
317
|
+
# later broadcasts, so the subscriber and the broadcaster always agree on the
|
|
318
|
+
# stream. roster_org keeps working unchanged for single-org apps.
|
|
272
319
|
|
|
273
320
|
# What a VIEWER may see (:poll/:nudge/:fanout). Any visibility rule,
|
|
274
321
|
# server-enforced. Defaults to the viewer's whole org when unset.
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.12
|
|
@@ -88,13 +88,43 @@ module WhereIsWaldo
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
def register_presence
|
|
91
|
+
# The org key is computed HERE and written over anything the client sent.
|
|
92
|
+
# params[:metadata] is client-supplied, so letting a tab name its own
|
|
93
|
+
# roster would let it subscribe itself into an org it has no business
|
|
94
|
+
# seeing.
|
|
95
|
+
#
|
|
96
|
+
# The client's value is REMOVED FIRST, unconditionally. Only overwriting
|
|
97
|
+
# it when the server resolves an org leaves the hole open in exactly the
|
|
98
|
+
# case that matters: a connection with no org of its own could name any
|
|
99
|
+
# org it liked, the value would persist on the presence row, and later
|
|
100
|
+
# broadcasts read it back verbatim and push that subject's presence into
|
|
101
|
+
# a stream they have no claim on.
|
|
102
|
+
metadata = (params[:metadata] || {}).to_h
|
|
103
|
+
metadata.delete(WhereIsWaldo::Roster::ORG_KEY)
|
|
104
|
+
metadata.delete(WhereIsWaldo::Roster::ORG_KEY.to_sym)
|
|
105
|
+
|
|
106
|
+
key = WhereIsWaldo::Roster.org_key(wiw_connection_org)
|
|
107
|
+
metadata[WhereIsWaldo::Roster::ORG_KEY] = key if key
|
|
108
|
+
|
|
91
109
|
WhereIsWaldo.connect(
|
|
92
110
|
session_id: waldo_session_id,
|
|
93
111
|
subject_id: waldo_subject_id,
|
|
94
|
-
metadata:
|
|
112
|
+
metadata: metadata
|
|
95
113
|
)
|
|
96
114
|
end
|
|
97
115
|
|
|
116
|
+
# The org this CONNECTION is scoped to. The container comes from the host
|
|
117
|
+
# app's own cable connection, which is the only place that knows the org
|
|
118
|
+
# the person is actually working in -- a lambda given just the subject has
|
|
119
|
+
# to guess, and guesses by association order.
|
|
120
|
+
def wiw_connection_org
|
|
121
|
+
return @wiw_connection_org if defined?(@wiw_connection_org)
|
|
122
|
+
|
|
123
|
+
subject = WhereIsWaldo.config.subject_class_constant&.find_by(id: waldo_subject_id)
|
|
124
|
+
container = connection.try(:current_container)
|
|
125
|
+
@wiw_connection_org = subject && WhereIsWaldo.config.resolve_roster_org(subject, container: container)
|
|
126
|
+
end
|
|
127
|
+
|
|
98
128
|
def roster_transition?(tab_visible, subject_active)
|
|
99
129
|
tab_visible != @wiw_tab_visible || subject_active != @wiw_subject_active
|
|
100
130
|
end
|
|
@@ -104,8 +134,7 @@ module WhereIsWaldo
|
|
|
104
134
|
def resolve_roster_strategy
|
|
105
135
|
return nil unless WhereIsWaldo.config.roster_enabled?
|
|
106
136
|
|
|
107
|
-
|
|
108
|
-
org = subject && WhereIsWaldo.config.resolve_roster_org(subject)
|
|
137
|
+
org = wiw_connection_org
|
|
109
138
|
return nil unless org
|
|
110
139
|
|
|
111
140
|
WhereIsWaldo::RosterDelivery.for(WhereIsWaldo.config.resolve_mode(org))
|
|
@@ -13,12 +13,15 @@ module WhereIsWaldo
|
|
|
13
13
|
class RosterChannel < ApplicationCable::Channel
|
|
14
14
|
def subscribed
|
|
15
15
|
subject = current_subject
|
|
16
|
-
|
|
16
|
+
org = resolve_org(subject)
|
|
17
|
+
return reject unless org
|
|
18
|
+
|
|
19
|
+
mode = WhereIsWaldo.config.resolve_mode(org)
|
|
17
20
|
return reject unless mode
|
|
18
21
|
|
|
19
22
|
@wiw_subject = subject
|
|
20
23
|
@wiw_strategy = RosterDelivery.for(mode)
|
|
21
|
-
apply_plan(@wiw_strategy.subscribe_plan(subject, waldo_session_id))
|
|
24
|
+
apply_plan(@wiw_strategy.subscribe_plan(subject, waldo_session_id, org: org))
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
# Client-driven poll (pull/nudge modes). Transmits a filtered snapshot
|
|
@@ -31,11 +34,15 @@ module WhereIsWaldo
|
|
|
31
34
|
|
|
32
35
|
private
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
# The org this VIEWER is watching. Resolved from the connection's container
|
|
38
|
+
# like the writer side, so the stream they subscribe to is the one deltas
|
|
39
|
+
# are actually broadcast into. Resolving from the subject alone here was
|
|
40
|
+
# the other half of the same bug: a viewer scoped to their second org sat
|
|
41
|
+
# on their first org's stream and simply never saw anything.
|
|
42
|
+
def resolve_org(subject)
|
|
43
|
+
return nil unless subject
|
|
37
44
|
|
|
38
|
-
WhereIsWaldo.config.
|
|
45
|
+
WhereIsWaldo.config.resolve_roster_org(subject, container: connection.try(:current_container))
|
|
39
46
|
end
|
|
40
47
|
|
|
41
48
|
def apply_plan(plan)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
class Roster
|
|
5
|
+
# Which roster a session belongs to, and how that is stored.
|
|
6
|
+
#
|
|
7
|
+
# Extracted from Roster so the resolution rules sit together and can be
|
|
8
|
+
# read without the delivery machinery around them.
|
|
9
|
+
module OrgKeys
|
|
10
|
+
# WHICH ROSTER A SESSION BELONGS TO IS DECIDED ONCE, AT CONNECT, AND
|
|
11
|
+
# STORED ON THE PRESENCE ROW.
|
|
12
|
+
#
|
|
13
|
+
# It cannot be re-derived later. The connection knows the container the
|
|
14
|
+
# person is actually working in; a background broadcast an hour later has
|
|
15
|
+
# only a subject id, and a subject can belong to several orgs. Deriving
|
|
16
|
+
# it twice is how the subscriber ends up listening to one stream while
|
|
17
|
+
# the broadcaster writes to another -- which looks exactly like presence
|
|
18
|
+
# being broken, not like a resolution bug.
|
|
19
|
+
#
|
|
20
|
+
# Stored in metadata rather than a column so this needs no migration in
|
|
21
|
+
# any consuming app.
|
|
22
|
+
def org_key(org)
|
|
23
|
+
return nil unless org
|
|
24
|
+
|
|
25
|
+
klass = org.class.respond_to?(:base_class) ? org.class.base_class : org.class
|
|
26
|
+
"#{klass.name}:#{org.id}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def org_from_key(key)
|
|
30
|
+
type, id = key.to_s.split(":", 2)
|
|
31
|
+
return nil if type.blank? || id.blank?
|
|
32
|
+
|
|
33
|
+
type.safe_constantize&.find_by(id: id)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Every org this subject is currently connected in -- usually one, but a
|
|
39
|
+
# person with two tabs in two accounts is present in both and must appear
|
|
40
|
+
# in both rosters. Falls back to resolving from the subject alone for
|
|
41
|
+
# rows written before this existed, and for apps with no container.
|
|
42
|
+
def org_keys_for_subject(subject_id, timeout: nil)
|
|
43
|
+
by_subject = WhereIsWaldo::PresenceService.sessions_for_subjects([subject_id], timeout: timeout)
|
|
44
|
+
org_keys_from(sessions_for(by_subject, subject_id), subject_id)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The same answer from sessions the caller has ALREADY loaded. A presence
|
|
48
|
+
# transition needs both the org keys and the subject's aggregate state,
|
|
49
|
+
# and both are derived from one set of session rows -- reading them twice
|
|
50
|
+
# doubles the adapter traffic on the hottest path in the library.
|
|
51
|
+
def org_keys_from(sessions, subject_id)
|
|
52
|
+
keys = sessions.filter_map do |session|
|
|
53
|
+
session.dig(:metadata, ORG_KEY) || session.dig("metadata", ORG_KEY)
|
|
54
|
+
end.uniq
|
|
55
|
+
return keys if keys.any?
|
|
56
|
+
|
|
57
|
+
[org_key(WhereIsWaldo.config.resolve_roster_org(find_subject(subject_id)))].compact
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# THE KEY TYPES ON THE TWO SIDES ARE NOT GUARANTEED TO MATCH.
|
|
63
|
+
#
|
|
64
|
+
# The database adapter keys its result by the AR-typecast column value,
|
|
65
|
+
# typically an Integer, while a subject id arriving from a cable
|
|
66
|
+
# connection is often a String -- authenticate_proc returns whatever the
|
|
67
|
+
# host app decides, and connection params are strings. A plain fetch then
|
|
68
|
+
# misses, and the caller falls back to resolving from the subject alone:
|
|
69
|
+
# silently, and straight back to the guess this exists to replace.
|
|
70
|
+
# Comparing as strings is what stops a type difference becoming a wrong
|
|
71
|
+
# roster.
|
|
72
|
+
def sessions_for(by_subject, subject_id)
|
|
73
|
+
return by_subject[subject_id] if by_subject.key?(subject_id)
|
|
74
|
+
|
|
75
|
+
wanted = subject_id.to_s
|
|
76
|
+
_, sessions = by_subject.find { |key, _| key.to_s == wanted }
|
|
77
|
+
sessions || []
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -34,6 +34,13 @@ module WhereIsWaldo
|
|
|
34
34
|
RANK = { active: 3, idle: 2, background: 1, offline: 0 }.freeze
|
|
35
35
|
DEFAULT_PLATFORM = "web"
|
|
36
36
|
|
|
37
|
+
# The metadata key holding which roster a session belongs to. Defined on
|
|
38
|
+
# the class, not the singleton, so callers can reference
|
|
39
|
+
# WhereIsWaldo::Roster::ORG_KEY.
|
|
40
|
+
ORG_KEY = "wiw_org"
|
|
41
|
+
|
|
42
|
+
extend OrgKeys
|
|
43
|
+
|
|
37
44
|
class << self
|
|
38
45
|
# Shared stream name for an org record (nil when org is absent).
|
|
39
46
|
def stream_name(org)
|
|
@@ -45,8 +52,8 @@ module WhereIsWaldo
|
|
|
45
52
|
|
|
46
53
|
# Shared stream name for the org a subject belongs to (nil when the roster
|
|
47
54
|
# is unconfigured or the subject has no org).
|
|
48
|
-
def stream_for_subject(subject)
|
|
49
|
-
stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
|
|
55
|
+
def stream_for_subject(subject, container: nil)
|
|
56
|
+
stream_name(WhereIsWaldo.config.resolve_roster_org(subject, container: container))
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
# Full roster state for an org: every member subject with their current
|
|
@@ -118,12 +125,23 @@ module WhereIsWaldo
|
|
|
118
125
|
def publish(subject_id, timeout: nil)
|
|
119
126
|
return false if subject_id.blank?
|
|
120
127
|
|
|
121
|
-
subject
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
# ONE read of the subject's sessions, used for both halves. The org
|
|
129
|
+
# keys and the aggregate state are derived from the same rows, and a
|
|
130
|
+
# transition is the hottest path here -- fetching twice doubled the
|
|
131
|
+
# adapter traffic for no new information.
|
|
132
|
+
by_subject = WhereIsWaldo::PresenceService.sessions_for_subjects([subject_id], timeout: timeout)
|
|
133
|
+
sessions = sessions_for(by_subject, subject_id)
|
|
124
134
|
|
|
125
|
-
|
|
126
|
-
|
|
135
|
+
keys = org_keys_from(sessions, subject_id)
|
|
136
|
+
return false if keys.empty?
|
|
137
|
+
|
|
138
|
+
subject = find_subject(subject_id)
|
|
139
|
+
state = sessions.any? ? aggregate(sessions) : offline_state
|
|
140
|
+
member = merge_member(subject, state, id: subject_id)
|
|
141
|
+
keys.each do |key|
|
|
142
|
+
stream = "where_is_waldo:roster:#{key}"
|
|
143
|
+
ActionCable.server.broadcast(stream, delta_message(member))
|
|
144
|
+
end
|
|
127
145
|
true
|
|
128
146
|
rescue StandardError => e
|
|
129
147
|
Rails.logger&.warn("[WhereIsWaldo::Roster] publish failed for #{subject_id}: #{e.class}: #{e.message}")
|
|
@@ -158,11 +176,10 @@ module WhereIsWaldo
|
|
|
158
176
|
def publish_nudge(subject_id)
|
|
159
177
|
return false if subject_id.blank?
|
|
160
178
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return false unless stream
|
|
179
|
+
keys = org_keys_for_subject(subject_id)
|
|
180
|
+
return false if keys.empty?
|
|
164
181
|
|
|
165
|
-
ActionCable.server.broadcast(
|
|
182
|
+
keys.each { |key| ActionCable.server.broadcast("where_is_waldo:roster:#{key}", nudge_message) }
|
|
166
183
|
true
|
|
167
184
|
rescue StandardError => e
|
|
168
185
|
Rails.logger&.warn("[WhereIsWaldo::Roster] nudge failed for #{subject_id}: #{e.class}: #{e.message}")
|
|
@@ -28,7 +28,7 @@ module WhereIsWaldo
|
|
|
28
28
|
# Base contract. Channels call these; return values drive the channel's I/O.
|
|
29
29
|
class Base
|
|
30
30
|
# @return [Hash] { streams: [names], messages: [cable messages] }
|
|
31
|
-
def subscribe_plan(_subject, _session_id)
|
|
31
|
+
def subscribe_plan(_subject, _session_id, org: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
32
32
|
{ streams: [], messages: [] }
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -50,8 +50,11 @@ module WhereIsWaldo
|
|
|
50
50
|
# Instant push to a single shared per-account stream. NO visibility
|
|
51
51
|
# filtering — everyone in the account sees everyone. O(1) per transition.
|
|
52
52
|
class Broadcast < Base
|
|
53
|
-
def subscribe_plan(subject, _session_id)
|
|
54
|
-
org
|
|
53
|
+
def subscribe_plan(subject, _session_id, org: nil)
|
|
54
|
+
# org comes from the channel, which knows the connection's container.
|
|
55
|
+
# Resolving it again here would reach a different answer for a subject
|
|
56
|
+
# in several orgs, and put the viewer on a stream nobody broadcasts to.
|
|
57
|
+
org ||= config.resolve_roster_org(subject)
|
|
55
58
|
stream = Roster.stream_name(org)
|
|
56
59
|
return { streams: [], messages: [] } unless stream
|
|
57
60
|
|
|
@@ -77,10 +80,13 @@ module WhereIsWaldo
|
|
|
77
80
|
:poll
|
|
78
81
|
end
|
|
79
82
|
|
|
80
|
-
def subscribe_plan(subject, session_id)
|
|
83
|
+
def subscribe_plan(subject, session_id, org: nil)
|
|
81
84
|
current = current_members(subject)
|
|
82
85
|
write_baseline(session_id, current)
|
|
83
|
-
{
|
|
86
|
+
{
|
|
87
|
+
streams: subscribe_streams(subject, org: org),
|
|
88
|
+
messages: [Roster.snapshot_message(current.values, mode: mode)]
|
|
89
|
+
}
|
|
84
90
|
end
|
|
85
91
|
|
|
86
92
|
def poll_messages(subject, session_id)
|
|
@@ -101,7 +107,7 @@ module WhereIsWaldo
|
|
|
101
107
|
|
|
102
108
|
# Streams to subscribe on connect. :poll needs none (data rides the poll
|
|
103
109
|
# transmit); :nudge overrides this to also receive the account nudge.
|
|
104
|
-
def subscribe_streams(_subject)
|
|
110
|
+
def subscribe_streams(_subject, org: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
105
111
|
[]
|
|
106
112
|
end
|
|
107
113
|
|
|
@@ -156,8 +162,9 @@ module WhereIsWaldo
|
|
|
156
162
|
private
|
|
157
163
|
|
|
158
164
|
# Also subscribe the account stream so this client receives nudges.
|
|
159
|
-
def subscribe_streams(subject)
|
|
160
|
-
|
|
165
|
+
def subscribe_streams(subject, org: nil)
|
|
166
|
+
org ||= config.resolve_roster_org(subject)
|
|
167
|
+
stream = Roster.stream_name(org)
|
|
161
168
|
stream ? [stream] : []
|
|
162
169
|
end
|
|
163
170
|
end
|
|
@@ -168,7 +175,7 @@ module WhereIsWaldo
|
|
|
168
175
|
# its directional audience (config.roster_viewers_of). O(audience) per
|
|
169
176
|
# transition — the price of instant delivery under restricted visibility.
|
|
170
177
|
class Fanout < Base
|
|
171
|
-
def subscribe_plan(subject, _session_id)
|
|
178
|
+
def subscribe_plan(subject, _session_id, org: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
172
179
|
members = Roster.members_for(config.resolve_visible_to(subject))
|
|
173
180
|
{
|
|
174
181
|
streams: [Roster.viewer_stream(subject.id)],
|
|
@@ -32,9 +32,17 @@ module WhereIsWaldo
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def create_channels
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
# NEVER overwrite an app's existing cable connection or channel base.
|
|
36
|
+
# A host app that already authenticates ActionCable has its auth,
|
|
37
|
+
# container scoping and session identity in those files, and the
|
|
38
|
+
# turnkey versions here provide only what this library needs -- so
|
|
39
|
+
# replacing them silently drops whatever else they did. Installing
|
|
40
|
+
# into an existing app is the common case, not the exception.
|
|
41
|
+
@kept_channels = []
|
|
42
|
+
|
|
43
|
+
copy_channel "connection.rb.tt", "app/channels/application_cable/connection.rb"
|
|
44
|
+
copy_channel "channel.rb.tt", "app/channels/application_cable/channel.rb"
|
|
45
|
+
copy_channel "presence_channel.rb.tt", "app/channels/presence_channel.rb"
|
|
38
46
|
end
|
|
39
47
|
|
|
40
48
|
def show_post_install_message
|
|
@@ -54,10 +62,34 @@ module WhereIsWaldo
|
|
|
54
62
|
say " <App />"
|
|
55
63
|
say " </PresenceProvider>"
|
|
56
64
|
say ""
|
|
65
|
+
report_kept_channels
|
|
57
66
|
end
|
|
58
67
|
|
|
59
68
|
private
|
|
60
69
|
|
|
70
|
+
# Say plainly what was left alone. A silent skip is as confusing as a
|
|
71
|
+
# silent overwrite: the install looks complete and presence then fails
|
|
72
|
+
# because the app's own connection exposes no session identity.
|
|
73
|
+
def report_kept_channels
|
|
74
|
+
return if @kept_channels.blank?
|
|
75
|
+
|
|
76
|
+
say "Kept your existing files (not overwritten):", :yellow
|
|
77
|
+
@kept_channels.each { |f| say " - #{f}" }
|
|
78
|
+
say ""
|
|
79
|
+
say " WhereIsWaldo's channel reads connection.current_user and"
|
|
80
|
+
say " connection.session_id. If your connection does not identify"
|
|
81
|
+
say " both, add them -- or include WhereIsWaldo::JwtConnection."
|
|
82
|
+
say ""
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def copy_channel(source, destination)
|
|
86
|
+
if File.exist?(File.join(destination_root, destination))
|
|
87
|
+
@kept_channels << destination
|
|
88
|
+
else
|
|
89
|
+
template source, destination
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
61
93
|
def migration_version
|
|
62
94
|
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
63
95
|
end
|
|
@@ -14,7 +14,12 @@ class CreatePresences < ActiveRecord::Migration<%= migration_version %>
|
|
|
14
14
|
t.timestamps
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
# (subject, session) must be unique TOGETHER, never session alone. Session
|
|
18
|
+
# ids are not globally unique -- they are client-supplied in some setups,
|
|
19
|
+
# and a reused JWT jti is enough -- so keying on the session alone lets two
|
|
20
|
+
# authenticated subjects upsert onto each other's presence row. This is the
|
|
21
|
+
# keying the adapter uses: every session lookup scopes by both columns.
|
|
22
|
+
add_index :presences, [ :<%= subject_column %>, :<%= session_column %> ], unique: true
|
|
18
23
|
add_index :presences, :last_heartbeat
|
|
19
24
|
end
|
|
20
25
|
end
|
|
@@ -83,6 +83,16 @@ module WhereIsWaldo
|
|
|
83
83
|
# roster_members_association: association used to build the default roster
|
|
84
84
|
# from the org when roster_members is unset (defaults to the pluralized
|
|
85
85
|
# subject_class, e.g. :users).
|
|
86
|
+
# roster_org_for: the container-aware form of roster_org. Receives
|
|
87
|
+
# (subject, container) where container is the one the CONNECTION is
|
|
88
|
+
# scoped to -- the account the person is actually working in, which a
|
|
89
|
+
# subject-only lambda cannot know. Prefer this whenever subjects can
|
|
90
|
+
# belong to more than one org; roster_org picks by association order,
|
|
91
|
+
# which puts a switched user on the wrong roster: invisible to the
|
|
92
|
+
# teammates they are with, visible to an org they are not.
|
|
93
|
+
# config.roster_org_for = ->(user, container) { container || user.accounts.first }
|
|
94
|
+
attr_accessor :roster_org_for
|
|
95
|
+
|
|
86
96
|
attr_accessor :roster_org, :roster_members, :roster_members_association
|
|
87
97
|
|
|
88
98
|
# roster_visible_to: given a VIEWER, return the AR scope of subjects
|
|
@@ -142,6 +152,7 @@ module WhereIsWaldo
|
|
|
142
152
|
|
|
143
153
|
# Presence roster (set per app to enable)
|
|
144
154
|
@roster_org = nil
|
|
155
|
+
@roster_org_for = nil
|
|
145
156
|
@roster_members = nil
|
|
146
157
|
@roster_members_association = nil
|
|
147
158
|
@roster_visible_to = nil
|
|
@@ -165,10 +176,17 @@ module WhereIsWaldo
|
|
|
165
176
|
end
|
|
166
177
|
|
|
167
178
|
# Resolve the org/container record for a subject (nil if unset/absent).
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
179
|
+
# @param container [Object, nil] the connection's container, when known.
|
|
180
|
+
# Only roster_org_for receives it; roster_org keeps its old arity so
|
|
181
|
+
# existing apps are untouched.
|
|
182
|
+
def resolve_roster_org(subject, container: nil)
|
|
183
|
+
return nil unless subject
|
|
184
|
+
|
|
185
|
+
if roster_org_for
|
|
186
|
+
roster_org_for.call(subject, container)
|
|
187
|
+
elsif roster_org
|
|
188
|
+
roster_org.call(subject)
|
|
189
|
+
end
|
|
172
190
|
end
|
|
173
191
|
|
|
174
192
|
# Association name used to derive the default roster from an org when
|
|
@@ -193,7 +211,7 @@ module WhereIsWaldo
|
|
|
193
211
|
|
|
194
212
|
# True when the live-presence roster feature is configured.
|
|
195
213
|
def roster_enabled?
|
|
196
|
-
!roster_org.nil?
|
|
214
|
+
!roster_org.nil? || !roster_org_for.nil?
|
|
197
215
|
end
|
|
198
216
|
|
|
199
217
|
# Resolve the AR scope of subjects a VIEWER may see. Defaults to the
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: where_is_waldo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.12
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Scott Gibson
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: rails
|
|
@@ -84,16 +83,22 @@ dependencies:
|
|
|
84
83
|
name: redis
|
|
85
84
|
requirement: !ruby/object:Gem::Requirement
|
|
86
85
|
requirements:
|
|
87
|
-
- - "
|
|
86
|
+
- - ">="
|
|
88
87
|
- !ruby/object:Gem::Version
|
|
89
88
|
version: '5.0'
|
|
89
|
+
- - "<"
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '7'
|
|
90
92
|
type: :development
|
|
91
93
|
prerelease: false
|
|
92
94
|
version_requirements: !ruby/object:Gem::Requirement
|
|
93
95
|
requirements:
|
|
94
|
-
- - "
|
|
96
|
+
- - ">="
|
|
95
97
|
- !ruby/object:Gem::Version
|
|
96
98
|
version: '5.0'
|
|
99
|
+
- - "<"
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: '7'
|
|
97
102
|
- !ruby/object:Gem::Dependency
|
|
98
103
|
name: rspec-rails
|
|
99
104
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -206,11 +211,11 @@ files:
|
|
|
206
211
|
- app/services/where_is_waldo/broadcaster.rb
|
|
207
212
|
- app/services/where_is_waldo/presence_service.rb
|
|
208
213
|
- app/services/where_is_waldo/roster.rb
|
|
214
|
+
- app/services/where_is_waldo/roster/org_keys.rb
|
|
209
215
|
- app/services/where_is_waldo/roster_delivery.rb
|
|
210
216
|
- lib/generators/where_is_waldo/install/install_generator.rb
|
|
211
217
|
- lib/generators/where_is_waldo/install/templates/channel.rb.tt
|
|
212
218
|
- lib/generators/where_is_waldo/install/templates/connection.rb.tt
|
|
213
|
-
- lib/generators/where_is_waldo/install/templates/create_presences.rb.tt
|
|
214
219
|
- lib/generators/where_is_waldo/install/templates/initializer.rb.tt
|
|
215
220
|
- lib/generators/where_is_waldo/install/templates/migration.rb.tt
|
|
216
221
|
- lib/generators/where_is_waldo/install/templates/presence_channel.rb.tt
|
|
@@ -228,7 +233,6 @@ metadata:
|
|
|
228
233
|
changelog_uri: https://github.com/byscott-io/where_is_waldo/blob/master/CHANGELOG.md
|
|
229
234
|
allowed_push_host: https://rubygems.org
|
|
230
235
|
rubygems_mfa_required: 'true'
|
|
231
|
-
post_install_message:
|
|
232
236
|
rdoc_options: []
|
|
233
237
|
require_paths:
|
|
234
238
|
- lib
|
|
@@ -243,8 +247,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
243
247
|
- !ruby/object:Gem::Version
|
|
244
248
|
version: '0'
|
|
245
249
|
requirements: []
|
|
246
|
-
rubygems_version:
|
|
247
|
-
signing_key:
|
|
250
|
+
rubygems_version: 4.0.16
|
|
248
251
|
specification_version: 4
|
|
249
252
|
summary: Real-time presence tracking for Rails + React applications
|
|
250
253
|
test_files: []
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
class Create<%= table_name.camelize %> < ActiveRecord::Migration[7.0]
|
|
4
|
-
def change
|
|
5
|
-
create_table :<%= table_name %> do |t|
|
|
6
|
-
# Identifiers - column names are configurable
|
|
7
|
-
t.string :<%= session_column %>, null: false # Unique session/connection identifier
|
|
8
|
-
t.bigint :<%= subject_column %>, null: false # Who (user, member, student, etc.)
|
|
9
|
-
|
|
10
|
-
# Timestamps
|
|
11
|
-
t.datetime :connected_at, null: false
|
|
12
|
-
t.datetime :last_heartbeat, null: false
|
|
13
|
-
t.datetime :last_activity
|
|
14
|
-
|
|
15
|
-
# Activity state
|
|
16
|
-
t.boolean :tab_visible, default: true, null: false
|
|
17
|
-
t.boolean :subject_active, default: true, null: false
|
|
18
|
-
|
|
19
|
-
# Extensible metadata
|
|
20
|
-
t.<%= json_column_type %> :metadata
|
|
21
|
-
|
|
22
|
-
t.timestamps
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# (subject, session) must be unique together. Keying by session alone
|
|
26
|
-
# would let two authenticated subjects that happen to collide on a
|
|
27
|
-
# session id (client-supplied values, JWT jti reuse, etc.) upsert onto
|
|
28
|
-
# each other's row.
|
|
29
|
-
add_index :<%= table_name %>, [:<%= subject_column %>, :<%= session_column %>], unique: true
|
|
30
|
-
|
|
31
|
-
# Session lookups (heartbeat, disconnect, session_status) always scope
|
|
32
|
-
# by (subject, session); the composite unique index above serves those.
|
|
33
|
-
# Query by subject alone
|
|
34
|
-
add_index :<%= table_name %>, :<%= subject_column %>
|
|
35
|
-
|
|
36
|
-
# Cleanup old records
|
|
37
|
-
add_index :<%= table_name %>, :last_heartbeat
|
|
38
|
-
|
|
39
|
-
<% if subject_table.present? -%>
|
|
40
|
-
# Foreign key to subject table
|
|
41
|
-
add_foreign_key :<%= table_name %>, :<%= subject_table %>, column: :<%= subject_column %>
|
|
42
|
-
<% end -%>
|
|
43
|
-
end
|
|
44
|
-
end
|