refactor: replace perspective.addListener('link-*') with SPARQL subscriptions#610
refactor: replace perspective.addListener('link-*') with SPARQL subscriptions#610HexaField wants to merge 1 commit into
Conversation
…riptions
The perspective-scoped link listeners are a firehose — every
`perspective.addListener('link-added', ...)` callback runs on every
link change anywhere in the perspective, regardless of whether the
handler cares about that predicate. As perspectives grow this becomes
a meaningful drag on the UI thread, and it scales O(listeners × links)
across the whole app.
Move every callsite to a targeted SPARQL subscription via
`perspective.subscribeQuery(...)` (or an existing `useLiveQuery`
Ad4mModel subscription where the model already covers it). The SPARQL
subscriptions only wake the handler when the indexed query's result set
actually changes — same data, no firehose.
Touchpoints:
- `app/src/composables/useCommunityService.ts` — the channel-participant
tracker used to listen for every link-added event and filter on
`link.data.predicate === CHANNEL`. Replaced with a
`subscribeQuery` over the reified link's
`?_r ad4m:author ?author` metadata, scoped to
`flux://has_channel`. The reifier exposes exactly the author signal
we used to read off `link.author`. The dedup set continues to make
`addLinks` idempotent and the callback refreshes via a single
`LinkQuery` so it doesn't depend on the SPARQL binding shape.
- `packages/api/src/subscribeToLinks.ts` (deleted) — this helper was
already `@deprecated` and only wrapped the listener API. It had no
callers in the Flux app or views, so deleting the file is the
cleanest migration. Removed from `packages/api/src/index.ts`.
- `packages/create/templates/vue/src/components/TodoView.vue` and
`packages/create/templates/preact/src/components/TodoView.tsx` — the
scaffold templates new projects start from. Both used
`perspective.addListener('link-added', ...)` for a single source's
`ad4m://has_child` children. Replaced with a `subscribeQuery` over
the same predicate scoped to the parent source, matching the pattern
used by `TimelineColumn.vue` and the rest of the app.
After this change, the only remaining `addListener` references in app
code are explanatory comments documenting why we don't use them.
✅ Deploy Preview for fluxsocial-dev ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📝 WalkthroughWalkthroughThis PR migrates from global event listener-based link tracking to targeted SPARQL subscriptions across the codebase. The API surface is updated to swap exports, the community service implements subscription-based participant tracking with deduplication, and both Preact and Vue template components apply the same subscription pattern to child link monitoring. ChangesEvent listener to SPARQL subscription migration
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/composables/useCommunityService.ts`:
- Line 521: Replace the hardcoded predicate string in the .addLinks call with
the exported FLUX_PARTICIPANT constant from `@coasys/flux-constants`: add
FLUX_PARTICIPANT to the file's imports and change .addLinks([{ source:
channelId, predicate: 'flux://has_participant', target: link.author }]) to use
the FLUX_PARTICIPANT symbol so the predicate is referenced via the constant
(keep channelId and link.author as-is).
- Around line 507-534: refreshParticipantsFromChannelLinks currently skips links
when allChannels.value lacks channelId causing lost participant edges; remove
the dependency on allChannels there and call perspective.addLinks using
channelId directly (keep the existing knownParticipants check, key handling, and
the error rollback that deletes the key on failure). Update
refreshParticipantsFromChannelLinks to not require finding a channel in
allChannels.value (i.e., eliminate the if (!channel) continue) so existing
channel→author links are always converted to flux://has_participant edges;
ensure you still guard against duplicate work using knownParticipants and keep
the try/catch/error logging behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 15bf9f6f-b9e6-42de-8239-f9042fa35133
📒 Files selected for processing (5)
app/src/composables/useCommunityService.tspackages/api/src/index.tspackages/api/src/subscribeToLinks.tspackages/create/templates/preact/src/components/TodoView.tsxpackages/create/templates/vue/src/components/TodoView.vue
💤 Files with no reviewable changes (2)
- packages/api/src/subscribeToLinks.ts
- packages/api/src/index.ts
| async function refreshParticipantsFromChannelLinks() { | ||
| try { | ||
| const links = await perspective.get(new LinkQuery({ predicate: CHANNEL })); | ||
| for (const link of links) { | ||
| if (!link.author) continue; | ||
| const channelId = link.data.source; | ||
| const channel = allChannels.value.find((c) => c.id === channelId); | ||
| if (!channel) continue; | ||
|
|
||
| const key = `${channelId}::${link.author}`; | ||
| if (knownParticipants.has(key)) continue; | ||
| knownParticipants.add(key); | ||
|
|
||
| perspective | ||
| .addLinks([{ source: channelId, predicate: 'flux://has_participant', target: link.author }]) | ||
| .catch((error) => { | ||
| knownParticipants.delete(key); | ||
| console.error('Failed to add participant to channel:', { | ||
| channelId, | ||
| author: link.author, | ||
| error, | ||
| }); | ||
| }); | ||
| } | ||
| } catch (error) { | ||
| console.error('Error refreshing channel participants:', error); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check for watch() calls on allChannels or initialization order dependencies
rg -n -A5 -B5 'watch.*allChannels|allChannels.*watch' app/src/composables/useCommunityService.ts
# Check if subscribeQuery is documented to fire initially
rg -n -C3 'subscribeQuery.*initial|subscribeQuery.*first' --type=ts
# Look for similar subscription patterns that might clarify initialization behavior
ast-grep --pattern 'await $_.subscribeQuery($$$)'Repository: coasys/flux
Length of output: 1576
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="app/src/composables/useCommunityService.ts"
echo "=== allChannels definition (~line 145) ==="
sed -n '120,190p' "$FILE" | nl -ba | sed -n '1,120p'
echo
echo "=== subscription IIFE / onResult region (~line 520-580) ==="
sed -n '500,590p' "$FILE" | nl -ba | sed -n '1,120p'
echo
echo "=== locate refreshParticipantsFromChannelLinks usage ==="
rg -n "refreshParticipantsFromChannelLinks" "$FILE"
echo
echo "=== search for any watch/allChannels coordination specifically for participants refresh ==="
rg -n "watch\\(.*allChannels|allChannels.*watch" "$FILE"
echo
echo "=== inspect lines around the skip in refresh (line ~513-514) ==="
sed -n '485,545p' "$FILE" | nl -ba | sed -n '1,120p'Repository: coasys/flux
Length of output: 159
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="app/src/composables/useCommunityService.ts"
awk_nr() { awk '{printf "%d:%s\n", NR,$0}'; }
echo "=== allChannels definition (~line 145) ==="
sed -n '120,190p' "$FILE" | awk_nr
echo
echo "=== subscription IIFE / onResult region (~line 500-590) ==="
sed -n '500,590p' "$FILE" | awk_nr
echo
echo "=== locate refreshParticipantsFromChannelLinks usage ==="
rg -n "refreshParticipantsFromChannelLinks" "$FILE" || true
echo
echo "=== search for any watch/allChannels coordination specifically for participants refresh ==="
rg -n "watch\\(.*allChannels|allChannels.*watch" "$FILE" || true
echo
echo "=== inspect lines around the skip in refresh (line ~485-545) ==="
sed -n '485,545p' "$FILE" | awk_nrRepository: coasys/flux
Length of output: 10935
🌐 Web query:
AD4M perspective subscribeQuery onResult fires immediately with initial results
💡 Result:
In AD4M, subscriptions designed to provide real-time updates—including those created via ModelQueryBuilder's subscribe method and QuerySubscriptionProxy's onResult method—are designed to provide immediate feedback by firing with the current state as soon as the subscription is established [1][2][3]. When you initialize a subscription (e.g., via subscribeInfer, subscribeSurrealDB, or a ModelQueryBuilder), the system internally waits for the initial results to arrive from the backend before completing the initialization process [1][4][2]. Key details regarding this behavior include: 1. Immediate Execution: When you register a callback using onResult or the subscription parameter in a query builder, that callback is invoked immediately with the initial result set [1][2][3]. Subsequent calls occur whenever the query results change [1][3]. 2. Initialization Guarantees: Methods such as subscribeInfer and subscribeSurrealDB return a promise that resolves only after the subscription has been successfully initialized and the first result has been received [1][4]. This ensures the subscription is ready to receive updates before your application logic proceeds [1][4]. 3. Implementation: At the core, the AD4M client processes the initial result immediately upon receipt during the subscription setup to ensure fast user experience [5]. Always remember to call dispose on your subscriptions when they are no longer needed to prevent memory leaks and ensure the backend cleans up the associated resources [4][2][3][6].
Citations:
- 1: https://docs.ad4m.dev/jsdoc/classes/QuerySubscriptionProxy
- 2: https://docs.ad4m.dev/jsdoc/classes/ModelQueryBuilder
- 3: https://docs.ad4m.dev/developer-guides/model-classes
- 4: https://docs.ad4m.dev/jsdoc/classes/PerspectiveProxy
- 5: https://github.com/coasys/ad4m/blob/58a48e968/core/src/perspectives/PerspectiveProxy.ts
- 6: https://docs.ad4m.dev/perspectives
Guard participant refresh against allChannels loading race
sub.onResult(...)should fire immediately with the initial query results, so the “no explicit initial invocation” concern is not the issue.- However,
refreshParticipantsFromChannelLinks()drops items whenallChannels.valuedoesn’t yet containchannelId(if (!channel) continue;). Since the subscription only re-triggers when the(source, author)query result set changes (not whenallChannelsfinishes loading), existing channel→author links may never getflux://has_participantedges. - Fix by removing the
allChannelsexistence gate (calladdLinksusingchannelIddirectly) or re-runningrefreshParticipantsFromChannelLinks()onceallChannelshas loaded (e.g., watchallChannelsLoading/non-emptyallChannelsand trigger a one-time refresh).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/composables/useCommunityService.ts` around lines 507 - 534,
refreshParticipantsFromChannelLinks currently skips links when allChannels.value
lacks channelId causing lost participant edges; remove the dependency on
allChannels there and call perspective.addLinks using channelId directly (keep
the existing knownParticipants check, key handling, and the error rollback that
deletes the key on failure). Update refreshParticipantsFromChannelLinks to not
require finding a channel in allChannels.value (i.e., eliminate the if
(!channel) continue) so existing channel→author links are always converted to
flux://has_participant edges; ensure you still guard against duplicate work
using knownParticipants and keep the try/catch/error logging behavior.
| knownParticipants.add(key); | ||
|
|
||
| perspective | ||
| .addLinks([{ source: channelId, predicate: 'flux://has_participant', target: link.author }]) |
There was a problem hiding this comment.
Use the imported constant instead of hardcoding the predicate.
The predicate 'flux://has_participant' is hardcoded here but is defined as the FLUX_PARTICIPANT constant in @coasys/flux-constants (confirmed by context snippet 3). Import and use the constant for consistency and maintainability.
📦 Proposed fix
At the top of the file, update the import:
-const { CHANNEL } = communityPredicates;
+const { CHANNEL, FLUX_PARTICIPANT } = communityPredicates;Then use the constant:
- perspective
- .addLinks([{ source: channelId, predicate: 'flux://has_participant', target: link.author }])
+ perspective
+ .addLinks([{ source: channelId, predicate: FLUX_PARTICIPANT, target: link.author }])🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/composables/useCommunityService.ts` at line 521, Replace the
hardcoded predicate string in the .addLinks call with the exported
FLUX_PARTICIPANT constant from `@coasys/flux-constants`: add FLUX_PARTICIPANT to
the file's imports and change .addLinks([{ source: channelId, predicate:
'flux://has_participant', target: link.author }]) to use the FLUX_PARTICIPANT
symbol so the predicate is referenced via the constant (keep channelId and
link.author as-is).
Summary
The perspective-scoped link listeners are a firehose — every
perspective.addListener('link-added', ...)callback runs on every link change anywhere in the perspective, regardless of whether the handler cares about that predicate. As perspectives grow this becomes a meaningful drag on the UI thread and scales O(listeners × links) across the whole app.Move every callsite to a targeted SPARQL subscription via `perspective.subscribeQuery(...)`. The SPARQL subscriptions only wake the handler when the indexed query's result set actually changes — same data, no firehose.
Companion PR
The corresponding ad4m PR that removes the listener API entirely lands separately ([coasys/ad4m#…]) — that PR depends on this one being merged first so no Flux consumer is using the API at the moment of removal.
Touchpoints
`app/src/composables/useCommunityService.ts` — the channel-participant tracker used to listen for every link-added event and filter on `link.data.predicate === CHANNEL`. Replaced with a `subscribeQuery` over the reified link's `?_r ad4m:author ?author` metadata, scoped to `flux://has_channel`. The reifier exposes exactly the author signal we used to read off `link.author`. The dedup set continues to make `addLinks` idempotent and the callback refreshes via a single `LinkQuery` so it doesn't depend on the SPARQL binding shape.
`packages/api/src/subscribeToLinks.ts` (deleted) — this helper was already `@deprecated` and only wrapped the listener API. It had no callers in the Flux app or views, so deleting the file is the cleanest migration. Removed from `packages/api/src/index.ts`.
`packages/create/templates/vue/src/components/TodoView.vue` and `packages/create/templates/preact/src/components/TodoView.tsx` — the scaffold templates new projects start from. Both used `perspective.addListener('link-added', ...)` for a single source's `ad4m://has_child` children. Replaced with a `subscribeQuery` scoped to the parent source, matching the pattern used by `TimelineColumn.vue` and the rest of the app.
After this change, the only remaining `addListener` references in app code are explanatory comments documenting why we don't use them.
Test plan
Summary by CodeRabbit
Release Notes
subscribeToLinksAPI