Skip to content

refactor: replace perspective.addListener('link-*') with SPARQL subscriptions#610

Open
HexaField wants to merge 1 commit into
devfrom
refactor/replace-link-listeners-with-sparql
Open

refactor: replace perspective.addListener('link-*') with SPARQL subscriptions#610
HexaField wants to merge 1 commit into
devfrom
refactor/replace-link-listeners-with-sparql

Conversation

@HexaField

@HexaField HexaField commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

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

  • `vue-tsc --noEmit` clean for the changed files (same pre-existing errors as dev — none introduced)
  • Manual: two agents on the same neighbourhood, agent A creates a channel → agent B's UI tracks them as a participant via `flux://has_participant` link (validates the reifier-author subscription)
  • Manual: scaffold a new app via `@coasys/flux-create` (Vue + Preact templates) → adding a todo refreshes the list reactively
  • Manual: no regressions in channel pinning, recent conversations, or member-list behaviour in the community service

Summary by CodeRabbit

Release Notes

  • Refactor
    • Improved internal subscription mechanisms for more reliable state tracking across components
    • Removed deprecated subscribeToLinks API

…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.
@netlify

netlify Bot commented Jun 11, 2026

Copy link
Copy Markdown

Deploy Preview for fluxsocial-dev ready!

Name Link
🔨 Latest commit b2f5087
🔍 Latest deploy log https://app.netlify.com/projects/fluxsocial-dev/deploys/6a2a85e8cebd780008056c03
😎 Deploy Preview https://deploy-preview-610--fluxsocial-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This 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.

Changes

Event listener to SPARQL subscription migration

Layer / File(s) Summary
API surface migration
packages/api/src/index.ts
Updated module exports to remove subscribeToLinks and add subscribeToSyncState, shifting the public API surface from listener-based to subscription-based link tracking.
Community service participant tracking
app/src/composables/useCommunityService.ts
Implemented participant tracking via targeted SPARQL subscription. New state manages deduplication and subscription lifecycle. refreshParticipantsFromChannelLinks() queries channel links and conditionally creates participant links. Subscription monitors distinct (source, author) pairs and refreshes on updates. Cleanup cancels tracking and disposes the subscription.
Template component SPARQL subscriptions
packages/create/templates/preact/src/components/TodoView.tsx, packages/create/templates/vue/src/components/TodoView.vue
Updated Preact and Vue TodoView components to replace global link-added listeners with targeted subscribeQuery for ad4m://has_child links. Both manage subscription lifecycle with unmounted guards, dispose subscriptions on teardown, and trigger loadTodos() on result updates.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • coasys/flux#580: Direct alignment with broader migration from SurrealQL to SPARQL in targeted link tracking patterns.

Suggested reviewers

  • lucksus
  • jhweir

Poem

🐰 No more global broadcasts echo through the land,
We query what we need with a targeted hand,
From listeners unbound to subscriptions so true,
The participants sing, and the todos are few. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main refactoring objective—replacing perspective event listeners with SPARQL subscriptions across multiple files and modules.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/replace-link-listeners-with-sparql

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between bc3bae2 and b2f5087.

📒 Files selected for processing (5)
  • app/src/composables/useCommunityService.ts
  • packages/api/src/index.ts
  • packages/api/src/subscribeToLinks.ts
  • packages/create/templates/preact/src/components/TodoView.tsx
  • packages/create/templates/vue/src/components/TodoView.vue
💤 Files with no reviewable changes (2)
  • packages/api/src/subscribeToLinks.ts
  • packages/api/src/index.ts

Comment on lines +507 to 534
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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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_nr

Repository: 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:


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 when allChannels.value doesn’t yet contain channelId (if (!channel) continue;). Since the subscription only re-triggers when the (source, author) query result set changes (not when allChannels finishes loading), existing channel→author links may never get flux://has_participant edges.
  • Fix by removing the allChannels existence gate (call addLinks using channelId directly) or re-running refreshParticipantsFromChannelLinks() once allChannels has loaded (e.g., watch allChannelsLoading/non-empty allChannels and 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 }])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant