From 220e895c1e4c75cfa5a1b5581212b9358cd8df3e Mon Sep 17 00:00:00 2001 From: Abhishek7Tech Date: Fri, 14 Aug 2026 18:27:17 +0530 Subject: [PATCH 1/5] remove unused imports --- .../molecules/TaggedItem/TaggedItem.utils.ts | 5 ++ .../molecules/TaggedList/TaggedList.tsx | 61 ++++++++----------- src/core/controllers/user/user.ts | 2 +- src/hooks/usePostTaggers/usePostTaggers.ts | 36 +++++++++++ .../usePostTaggers/usePostTaggers.types.ts | 15 +++++ 5 files changed, 82 insertions(+), 37 deletions(-) diff --git a/src/components/molecules/TaggedItem/TaggedItem.utils.ts b/src/components/molecules/TaggedItem/TaggedItem.utils.ts index 830f2e8f52..c99973a15c 100644 --- a/src/components/molecules/TaggedItem/TaggedItem.utils.ts +++ b/src/components/molecules/TaggedItem/TaggedItem.utils.ts @@ -18,6 +18,11 @@ export function transformTagWithAvatars(tag: NexusTag): TagWithAvatars { }; } +export function transfromTaggersWithAvatars(taggersIds: Pubky[]): { id: Pubky; avatarUrl: string }[] { + const taggers = taggersIds.map((taggerId) => ({ id: taggerId, avatarUrl: FileController.getAvatarUrl(taggerId) })); + return taggers; +} + /** * Transform an array of NexusTags to TagWithAvatars, adding viewer relationship. * Filters out invalid tags (missing label) and resolves avatar URLs. diff --git a/src/components/molecules/TaggedList/TaggedList.tsx b/src/components/molecules/TaggedList/TaggedList.tsx index fec6f4211a..d4c0cd5444 100644 --- a/src/components/molecules/TaggedList/TaggedList.tsx +++ b/src/components/molecules/TaggedList/TaggedList.tsx @@ -1,28 +1,21 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import { TagKind } from '@/application/tag/tag.types'; +import { useState } from 'react'; import { Container } from '@/atoms/Container/Container'; import { Skeleton } from '@/atoms/Skeleton/Skeleton'; import { useInfiniteScroll } from '@/hooks/useInfiniteScroll/useInfiniteScroll'; import { usePostTaggers } from '@/hooks/usePostTaggers/usePostTaggers'; +import { useProfileContext } from '@/providers/ProfileProvider/ProfileProvider'; import { TaggedItem } from '../TaggedItem/TaggedItem'; +import { TagWithAvatars } from '../TaggedItem/TaggedItem.types'; import type { TaggedListProps } from './TaggedList.types'; -export function TaggedList({ - tags, - taggedId, - taggedKind, - hasMore = false, - isLoadingMore = false, - onLoadMore, - onTagToggle, -}: TaggedListProps) { +export function TaggedList({ tags, hasMore = false, isLoadingMore = false, onLoadMore, onTagToggle }: TaggedListProps) { // Track which tag is currently expanded (only one at a time - accordion behavior) const [expandedTagLabel, setExpandedTagLabel] = useState(null); - - const shouldFetchTaggers = taggedKind === TagKind.POST && !!taggedId; - const { taggersByLabel, taggerStates, fetchAllTaggers } = usePostTaggers(shouldFetchTaggers ? taggedId : null); + const [tagsState, setTagsState] = useState(tags); + const { fetchTaggedList } = usePostTaggers(null); + const { pubky } = useProfileContext(); const { sentinelRef } = useInfiniteScroll({ onLoadMore: onLoadMore || (() => {}), @@ -32,34 +25,32 @@ export function TaggedList({ debounceMs: 300, }); - const handleExpandToggle = (tagLabel: string) => { + const handleExpandToggle = async (tagLabel: string) => { // Toggle: if clicking the same tag, collapse it; otherwise expand the new one setExpandedTagLabel((prev) => (prev === tagLabel ? null : tagLabel)); - }; - // Use ref for tags to avoid re-triggering the fetch effect when tags update - const tagsRef = useRef(tags); - useEffect(() => { - tagsRef.current = tags; - }, [tags]); + if (tagLabel === expandedTagLabel) return; + const selectedTag = tags.find((tag) => tag.label === tagLabel); + const selectedTagsRef = tagsState.find((tag) => tag.label === tagLabel); + if (!selectedTag || !pubky) return; - useEffect(() => { - if (!expandedTagLabel || !shouldFetchTaggers) return; - const selectedTag = tagsRef.current.find((tag) => tag.label === expandedTagLabel); - if (!selectedTag) return; - const initialIds = selectedTag.taggers.map((tagger) => tagger.id); - void fetchAllTaggers(expandedTagLabel, initialIds, selectedTag.taggers_count); - }, [expandedTagLabel, shouldFetchTaggers, fetchAllTaggers]); + // Fetch tagger details only once. + if (selectedTagsRef?.taggers_count === selectedTagsRef?.taggers.length) return; + + const response = await fetchTaggedList(tagLabel, pubky, selectedTag.taggers); + if (!response?.allTaggers) { + return; + } + const upadtedTagsState = tagsState.map((tag) => + tag.label === tagLabel ? { ...tag, taggers: [...tag.taggers, ...response.allTaggers] } : tag, + ); + setTagsState(upadtedTagsState); + }; return ( - {tags.map((tag) => { - const tagLabelKey = tag.label.toLowerCase(); + {tagsState.map((tag) => { const isExpanded = expandedTagLabel === tag.label; - const expandedTaggerIds = taggersByLabel.get(tagLabelKey); - const taggerState = taggerStates.get(tagLabelKey); - const isLoadingTaggers = taggerState?.isLoading ?? false; - return ( ); })} diff --git a/src/core/controllers/user/user.ts b/src/core/controllers/user/user.ts index 3382b78048..79d9916b81 100644 --- a/src/core/controllers/user/user.ts +++ b/src/core/controllers/user/user.ts @@ -104,7 +104,7 @@ export class UserController { * @param params - The parameters for fetching taggers * @returns The taggers for the user */ - static async fetchTaggers(params: TUserTaggersParams): Promise { + static async fetchTaggers(params: TUserTaggersParams): Promise { return await UserApplication.fetchTaggers(params); } diff --git a/src/hooks/usePostTaggers/usePostTaggers.ts b/src/hooks/usePostTaggers/usePostTaggers.ts index d6877e7ffa..435487ec1f 100644 --- a/src/hooks/usePostTaggers/usePostTaggers.ts +++ b/src/hooks/usePostTaggers/usePostTaggers.ts @@ -2,8 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { PostController } from '@/controllers/post/post'; +import { UserController } from '@/controllers/user/user'; import { Logger } from '@/libs/logger/logger'; import type { Pubky } from '@/models/models.types'; +import { TaggerWithAvatar } from '@/molecules/TaggedItem/TaggedItem.types'; +import { transfromTaggersWithAvatars } from '@/molecules/TaggedItem/TaggedItem.utils'; import type { NexusTaggers } from '@/services/nexus/nexus.types'; import { TAGGERS_PAGE_SIZE } from './usePostTaggers.constants'; import type { TaggersStateMap, UsePostTaggersResult } from './usePostTaggers.types'; @@ -40,6 +43,38 @@ export function usePostTaggers(postId?: string | null): UsePostTaggersResult { * @param initialIds - Initial tagger IDs already known from the tag response * @param totalCount - Expected total count of taggers (used for pagination control) */ + + const fetchTaggedList = async (label: string, userId: string, taggers: TaggerWithAvatar[]) => { + if (!label || !userId) return; + try { + const response = (await UserController.fetchTaggers({ label, user_id: userId })) as NexusTaggers; + const users = response.users; + if (users) { + const ids = new Set(users); + for (const tagger of taggers) { + if (ids.has(tagger.id)) { + ids.delete(tagger.id); + } + } + const allTaggerIds = Array.from(ids); + const taggersWithAvatars = transfromTaggersWithAvatars(allTaggerIds); + const taggersNames = await UserController.getManyDetails({ userIds: allTaggerIds }); + const taggersDetails = taggersWithAvatars.map((tagger) => { + if (taggersNames.has(tagger.id)) { + const taggerName = taggersNames.get(tagger.id)?.name; + return { ...tagger, name: taggerName }; + } else { + // sometimes userdetails aren't fetched so using a placeholder + return { ...tagger, name: 'Anon.' }; + } + }); + return { allTaggers: taggersDetails }; + } + } catch (error) { + Logger.error('[usePostTaggers] Failed to fetch tagged list', { userId, label, error }); + } + }; + const fetchAllTaggers = useCallback( async (label: string, initialIds: Pubky[], totalCount?: number) => { if (!postId) return; @@ -140,5 +175,6 @@ export function usePostTaggers(postId?: string | null): UsePostTaggersResult { taggersByLabel, taggerStates, fetchAllTaggers, + fetchTaggedList, }; } diff --git a/src/hooks/usePostTaggers/usePostTaggers.types.ts b/src/hooks/usePostTaggers/usePostTaggers.types.ts index 0c7db718bc..95dc621a51 100644 --- a/src/hooks/usePostTaggers/usePostTaggers.types.ts +++ b/src/hooks/usePostTaggers/usePostTaggers.types.ts @@ -1,4 +1,5 @@ import type { Pubky } from '@/models/models.types'; +import { TaggerWithAvatar } from '@/molecules/TaggedItem/TaggedItem.types'; export type TaggersState = { ids: Pubky[]; @@ -14,4 +15,18 @@ export interface UsePostTaggersResult { taggersByLabel: Map; taggerStates: TaggersStateMap; fetchAllTaggers: (label: string, initialIds: Pubky[], totalCount?: number) => Promise; + fetchTaggedList: ( + label: string, + user_id: string, + taggers: TaggerWithAvatar[], + ) => Promise< + | { + allTaggers: { + name: string | undefined; + id: Pubky; + avatarUrl: string; + }[]; + } + | undefined + >; } From 34125e34cbd6560264d1c9c11149303c8c79e18b Mon Sep 17 00:00:00 2001 From: Abhishek7Tech Date: Fri, 14 Aug 2026 19:19:35 +0530 Subject: [PATCH 2/5] update tagsState on adding a new tag --- src/components/molecules/TaggedList/TaggedList.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/molecules/TaggedList/TaggedList.tsx b/src/components/molecules/TaggedList/TaggedList.tsx index d4c0cd5444..0d5443dbde 100644 --- a/src/components/molecules/TaggedList/TaggedList.tsx +++ b/src/components/molecules/TaggedList/TaggedList.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Container } from '@/atoms/Container/Container'; import { Skeleton } from '@/atoms/Skeleton/Skeleton'; import { useInfiniteScroll } from '@/hooks/useInfiniteScroll/useInfiniteScroll'; @@ -17,6 +17,10 @@ export function TaggedList({ tags, hasMore = false, isLoadingMore = false, onLoa const { fetchTaggedList } = usePostTaggers(null); const { pubky } = useProfileContext(); + useEffect(() => { + setTagsState(tags); + }, [tags]); + const { sentinelRef } = useInfiniteScroll({ onLoadMore: onLoadMore || (() => {}), hasMore, From 627aeadf97a72d62083b21ab158e292a2520ce91 Mon Sep 17 00:00:00 2001 From: Abhishek7Tech Date: Fri, 14 Aug 2026 19:26:30 +0530 Subject: [PATCH 3/5] conclucde merge --- .greptile/config.json | 2 +- 0 | 0 AGENTS.md | 2 +- ...-application-cross-domain-orchestration.md | 261 ++++-------------- ...-notification-application-orchestration.md | 14 +- docs/architecture.md | 12 +- 6 files changed, 74 insertions(+), 217 deletions(-) create mode 100644 0 diff --git a/.greptile/config.json b/.greptile/config.json index 6e26d07db5..91169409f8 100644 --- a/.greptile/config.json +++ b/.greptile/config.json @@ -38,7 +38,7 @@ }, { "id": "cross-domain-app-restriction", - "rule": "Only PostApplication, NotificationApplication, BootstrapApplication, HotApplication, PostStreamApplication, and TtlApplication may call other Applications. Max call depth is 1. No circular dependencies.", + "rule": "Only PostApplication, NotificationApplication, BootstrapApplication, MigrationApplication, HotApplication, PostStreamApplication, and TtlApplication may call other Applications. Max call depth is 1 by default. The only permitted depth-2 paths are PostApplication, NotificationApplication, or TtlApplication calling PostStreamApplication, which may call FileApplication for attachment persistence. All other depth-2 paths, paths of depth 3 or greater, and cycles are forbidden.", "scope": ["src/core/application/**"], "severity": "high" }, diff --git a/0 b/0 new file mode 100644 index 0000000000..e69de29bb2 diff --git a/AGENTS.md b/AGENTS.md index 8c55417016..c32a87ac6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ Import modules through the path aliases in `tsconfig.json` (for example `@/hooks - Coordinators NEVER call Application — go through Controllers - Application NEVER accesses Stores — only Controllers manage stores - Pipes are pure — NO IO, NO side effects -- Only PostApplication, NotificationApplication, BootstrapApplication, HotApplication, PostStreamApplication, TtlApplication may call other Applications (max depth 1, no cycles) +- Only PostApplication, NotificationApplication, BootstrapApplication, MigrationApplication, HotApplication, PostStreamApplication, TtlApplication may call other Applications (max depth 1 by default; only PostApplication/NotificationApplication/TtlApplication → PostStreamApplication → FileApplication attachment persistence may reach depth 2; no cycles) ### Controller naming diff --git a/docs/adr/0009-application-cross-domain-orchestration.md b/docs/adr/0009-application-cross-domain-orchestration.md index d213ae7be8..f83e66ab55 100644 --- a/docs/adr/0009-application-cross-domain-orchestration.md +++ b/docs/adr/0009-application-cross-domain-orchestration.md @@ -12,7 +12,7 @@ Complex user workflows often require coordinating operations across multiple dom 2. **Post creation** (PostApplication) 3. **Tag association** (TagApplication) -These operations must be orchestrated as a single cohesive workflow with proper ordering (files before post, post before tags) and transactional semantics. +These operations must be orchestrated as a single cohesive workflow with proper ordering (files before post, post before tags) and explicit partial-failure handling. Under the current architecture (ADR-0004), the allowed dependencies are: @@ -42,106 +42,44 @@ The fundamental issue: **Where does cross-domain orchestration belong?** ### Core Rules -1. **Horizontal calls permitted**: Application classes MAY call other Application classes within the same layer +1. **Horizontal calls permitted**: Applications with orchestration privilege MAY call other Application classes within the same layer 2. **Acyclic dependency graph**: Circular dependencies between Application classes are FORBIDDEN -3. **Maximum call depth of 1**: If Application A calls Application B, then B MUST NOT call any other Application class within that execution flow -4. **Orchestration privilege**: `PostApplication` and `UserApplication` are permitted to call other Application classes. `NotificationApplication` is also permitted as a scoped exception defined by ADR-0010 (read-only hydration before notification persistence). `BootstrapApplication` and `MigrationApplication` are also permitted as **root-node orchestrators** — see rationale below. All other Application classes (FileApplication, TagApplication, BookmarkApplication, etc.) MUST NOT call other Application classes, including PostApplication, UserApplication, or NotificationApplication. This ensures orchestrators can coordinate cross-domain workflows while specialized domains remain independent and cannot create reverse dependencies on core entities. +3. **Maximum call depth of 1 by default**: If Application A calls Application B, then B MUST NOT call another Application within that execution flow. The only permitted depth-2 paths start from `PostApplication`, `NotificationApplication`, or `TtlApplication`, continue through `PostStreamApplication`, and end at `FileApplication` for attachment persistence inside `fetchMissingPostsFromNexus()` or `fetchOriginalPostsByUris()`. All other depth-2 paths and every path of depth 3 or greater are FORBIDDEN. +4. **Orchestration privilege**: Only the Applications listed below may call other Application classes. All other Application classes (`FileApplication`, `TagApplication`, `BookmarkApplication`, `UserApplication`, etc.) MUST NOT call other Application classes. This keeps specialized domains independent and prevents reverse dependencies on core orchestrators. -#### BootstrapApplication and MigrationApplication as Root-Node Orchestrators +#### Allowed orchestrators -In the Application dependency DAG, `BootstrapApplication` and `MigrationApplication` are **source nodes (in-degree 0)**: no other Application class holds a reference to them or calls into them. They are invoked from the Controller layer and fan out to multiple domain Applications. +| Application | Why privilege exists | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `PostApplication` | Single user action spans files, tags, and related posts (create / edit / delete / replies). | +| `NotificationApplication` | Hydrates referenced posts/users before notification persistence; see [ADR-0010](./0010-notification-application-orchestration.md). | +| `BootstrapApplication` | Session startup must hydrate multiple homeserver/Nexus domains in one coordinated fan-out. **Root-node** orchestrator (see below). | +| `MigrationApplication` | After DB recreation, critical homeserver-backed state must be re-synced once. **Root-node** orchestrator (see below). | +| `HotApplication` | Hot-tag UI needs tagger profiles cached before tags are written, to avoid liveQuery flashes with missing users. | +| `PostStreamApplication` | Stream slices include attachment metadata that must land in the file domain when posts are persisted. | +| `TtlApplication` | Force-refresh of stale posts must also persist attachments and hydrate embedded original posts for reposts. | -`BootstrapApplication` is invoked exactly once per session for initial hydration: +#### Root-node orchestrators -``` -BootstrapApplication ← in-degree 0 (root/source node) - ├─→ MuteApplication (fetch muted users) - ├─→ FeedApplication (fetch feeds) - ├─→ NotificationApplication (persist & summarize) - ├─→ FileApplication (persist files) - └─→ SettingsApplication (initialize settings) -``` - -After bootstrap, the canonical mute list for filtering remains the homeserver-backed Dexie stream; **ongoing** cross-device alignment uses `MuteListSyncCoordinator` (homeserver event stream → `MuteController.fetchMutedUsers`), not Nexus. See [ADR-0014: Muting System](./0014-muting-system.md). - -`MigrationApplication` is invoked after a database recreation (version bump) to re-fetch critical homeserver data that is not automatically re-populated: - -``` -MigrationApplication ← in-degree 0 (root/source node) - ├─→ MuteApplication (fetch muted users) - ├─→ FeedApplication (fetch feeds) - └─→ SettingsApplication (initialize settings) -``` - -Because no edge points **into** these orchestrators, they structurally **cannot** participate in a cycle, and the max-depth constraint is satisfied (orchestrator → leaf, depth 1). The constraints are: - -- ✅ `BootstrapApplication` MAY call other Application classes for startup hydration. -- ✅ `MigrationApplication` MAY call other Application classes for post-migration re-sync. -- ❌ No Application class MAY call `BootstrapApplication` or `MigrationApplication` (enforced by their root-node positions; adding such an edge would violate the acyclic graph rule). +`BootstrapApplication` and `MigrationApplication` are **source nodes (in-degree 0)** in the Application dependency DAG: Controllers invoke them, and no Application may call them, preventing cycles through these roots. Their current execution paths stop after one cross-Application hop, so they remain at depth 1. ### Example -```typescript -// ✅ ALLOWED: PostApplication (orchestrator) calls helper applications -// Real: src/core/application/post/post.ts -class PostApplication { - static async commitCreate({ postUrl, compositePostId, post, fileAttachments, tags }) { - // Depth 0 → Depth 1 - await FileApplication.commitCreate({ fileAttachments }); // OK (PostApplication can call others) - await LocalPostService.create({ compositePostId, post }); // OK (own service call) - await TagApplication.commitCreate({ tagList: tags }); // OK (PostApplication can call others) - } -} - -// ✅ ALLOWED: BootstrapApplication (orchestrator) calls helper applications -// Real: src/core/application/bootstrap/bootstrap.ts -class BootstrapApplication { - static async run({ pubky }) { - await FileApplication.commitCreate({ ... }); // OK (BootstrapApplication can call others) - await SettingsApplication.fetchFromHomeserver(pubky); // OK (BootstrapApplication can call others) - } -} - -// ❌ FORBIDDEN: Helper applications cannot call other applications -class FileApplication { - static async commitCreate({ fileAttachments }) { - // FileApplication is NOT in the allowed list — cannot call other Applications - await TagApplication.commitCreate({ tagList }); // ❌ VIOLATION - await PostApplication.commitCreate({ ... }); // ❌ VIOLATION - } -} - -// ❌ FORBIDDEN: Helper applications cannot call orchestrator applications -class TagApplication { - static async commitCreate({ tagList }) { - await PostApplication.commitCreate({ ... }); // ❌ VIOLATION - } -} - -// ❌ FORBIDDEN: Deep chains (even from orchestrators) -class PostApplication { - static async commitCreate({ fileAttachments, ... }) { - await FileApplication.commitCreate({ fileAttachments }); // OK (Depth 0 → 1) - } -} -class FileApplication { - static async commitCreate({ fileAttachments }) { - // Depth 1 → Depth 2 (violates max depth rule) - await ImageProcessorApplication.process(); // ❌ NOT ALLOWED - } -} - -// ❌ FORBIDDEN: Circular dependencies -class PostApplication { - static async commitCreate({ ... }) { - await FileApplication.commitCreate({ ... }); // A → B - } -} -class FileApplication { - static async commitCreate({ ... }) { - await PostApplication.commitCreate({ ... }); // B → A (circular!) - } -} +``` +// ✅ Depth 1 — allowed orchestrator → helper +PostApplication → FileApplication +PostApplication → TagApplication + +// ✅ Depth 2 — only permitted attachment-persistence exception +PostApplication | NotificationApplication | TtlApplication + → PostStreamApplication + → FileApplication + +// ❌ Forbidden +FileApplication → TagApplication // helper → other Application +HotApplication → PostStreamApplication → FileApplication // depth 2 outside the exception +A → B → C → D // depth ≥ 3 +A → B → A // cycle ``` ### Enforcement Strategy @@ -155,17 +93,9 @@ class FileApplication { **We rely on:** -1. **Code Reviews**: Reviewers MUST check for: - - Circular dependencies - - Excessive call depth (max depth 1) - <<<<<<< HEAD:.cursor/adr/0009-application-cross-domain-orchestration.md - - **Orchestration privilege violations** (only PostApplication/UserApplication/NotificationApplication/BootstrapApplication/MigrationApplication can call other Applications) - - # **Root-node invariant**: no Application class may add a dependency edge pointing into `BootstrapApplication` or `MigrationApplication` - - **Orchestration privilege violations** (only PostApplication, NotificationApplication, BootstrapApplication, HotApplication, PostStreamApplication, TtlApplication can call other Applications) - > > > > > > > dev:docs/adr/0009-application-cross-domain-orchestration.md -2. **Documentation**: This ADR as the source of truth -3. **Testing**: Integration tests to catch violations at runtime -4. **Code Comments**: Developers MUST document cross-Application calls with ADR reference +1. **Code Reviews**: Reviewers MUST check for circular dependencies, the call-depth rule and its single explicit exception, orchestration privilege (Allowed orchestrators table), and the root-node invariant (no Application may call `BootstrapApplication` or `MigrationApplication`) +2. **Automated Review**: The `cross-domain-app-restriction` rule in `.greptile/config.json` mirrors the allowlist and call-depth constraints +3. **Documentation**: This ADR is the source of truth **Future Tooling** (optional, not required now): @@ -178,22 +108,16 @@ class FileApplication { **When TO use cross-Application calls:** - ✅ Single user action requires multi-domain coordination -- ✅ Complex workflow with ordering/transactional requirements +- ✅ Complex workflow with ordering or explicit partial-failure handling - ✅ Avoiding code duplication of orchestration logic - <<<<<<< HEAD:.cursor/adr/0009-application-cross-domain-orchestration.md -- # ✅ **Only from PostApplication, UserApplication, NotificationApplication, BootstrapApplication, or MigrationApplication** (NotificationApplication is constrained by ADR-0010; BootstrapApplication is constrained to startup hydration and MigrationApplication to post-migration re-sync as root-node orchestrators) -- ✅ **Only from approved orchestrators**: PostApplication, NotificationApplication, BootstrapApplication, HotApplication, PostStreamApplication, TtlApplication - > > > > > > > dev:docs/adr/0009-application-cross-domain-orchestration.md +- ✅ Only from an **allowed orchestrator** (rule 4) **When NOT to use:** - ❌ Simple read operations (use services directly) - ❌ Single-domain workflows (stay within one Application) -- ❌ Deep processing chains (refactor to flatten) - <<<<<<< HEAD:.cursor/adr/0009-application-cross-domain-orchestration.md -- # ❌ **From specialized Application classes** (FileApplication, TagApplication, BookmarkApplication, etc.) - these must remain independent and cannot depend on PostApplication, UserApplication, NotificationApplication, BootstrapApplication, or MigrationApplication -- ❌ **From specialized Application classes** (FileApplication, TagApplication, BookmarkApplication, etc.) — these must remain independent and cannot call other Applications - > > > > > > > dev:docs/adr/0009-application-cross-domain-orchestration.md +- ❌ Deep processing chains outside the explicit depth-2 attachment-persistence exception (refactor to flatten) +- ❌ From Applications **without** orchestration privilege (rule 4) ## Consequences @@ -223,116 +147,43 @@ class FileApplication { ### Alternative 1: Full Dependency Injection Refactor -**Description**: Move from static classes to instance-based classes with constructor injection. - -```typescript -class PostApplication { - constructor( - private fileApp: FileApplication, - private tagApp: TagApplication, - private postService: LocalPostService, - ) {} - - async create({ files, tags, post }) { - await this.fileApp.upload(files); - await this.tagApp.create(tags); - } -} -``` - -**Pros**: - -- Explicit dependencies in constructor -- Compile-time circular dependency detection (would fail instantiation) -- Better testability (easier mocking) -- Could enforce call depth with types -- Industry standard pattern +Move from static Application classes to instance-based classes with constructor injection. -**Cons**: +**Pros**: Explicit dependencies in constructors; a composition root or DI container can detect cycles; easier mocking; possible typed call-depth enforcement. -- **Massive refactor** (affects every Controller, Application, Service) -- Need DI container or manual wiring -- Increased boilerplate -- Initialization complexity -- Team learning curve -- No immediate business value +**Cons**: Large refactor across Controllers / Applications / Services; DI container or manual wiring; more boilerplate and init complexity; little immediate product value. -**Why not chosen**: The refactor scope is too large for the immediate problem. This could be reconsidered later if: - -- Architecture violations become frequent -- Team size grows beyond effective code review capacity -- Complexity requires stronger compile-time guarantees +**Why not chosen**: Scope is too large for the immediate problem. Reconsider if privilege violations become frequent or code review alone cannot enforce the rules. ### Alternative 2: UI Orchestration -**Description**: Have UI components coordinate multiple controller calls sequentially. +Have React components sequence multiple controller calls (upload files → create post → add tags). -```typescript -// In React component -const handlePostCreate = async () => { - const fileUrls = await Promise.all(files.map((f) => FileController.upload({ file: f, pubky }))); - await PostController.create({ content, fileUrls }); - await Promise.all(tags.map((tag) => TagController.commitCreate({ taggedId: postId, label: tag }))); -}; -``` +**Pros**: No Application-layer change; domains stay separate at the controller boundary; flow is easy to read in one place. -**Pros**: +**Cons**: Business workflow leaks into presentation; ordering/error handling scatters across components; hard to reuse for non-UI flows (bootstrap, migration, TTL). -- No architecture changes needed -- Clear separation between domains -- Easy to understand flow +**Why not chosen**: Cross-domain orchestration belongs in the Application layer, not the UI. -**Cons**: +### Alternative 3: Controller-Level Orchestration -- Business logic leaks into UI layer -- Orchestration logic scattered across components -- Hard to test (requires UI component testing) -- Error handling becomes complex -- Violates separation of concerns +Have Controllers call several Application classes in sequence for one user/system action, with no Application→Application calls. -**Why not chosen**: Business logic belongs in the domain layer, not presentation layer. This approach makes the UI responsible for orchestration, which is not its role. +**Pros**: Keeps Application classes independent; Controllers already sit above Application; easy to follow from an entry point. -### Alternative 3: Duplicate Orchestration Logic +**Cons**: Places cross-domain business ordering alongside Controller concerns such as session and store management. Other Applications cannot reuse the workflow without duplicating it or introducing a forbidden Application → Controller dependency. -**Description**: Each Application class duplicates the logic of other applications it needs. - -```typescript -class PostApplication { - static async commitCreate({ fileAttachments, tags, post, ... }) { - // Duplicate FileApplication.commitCreate logic - for (const file of fileAttachments) { - await HomeserverService.putBlob(...); - await HomeserverService.request(...); - await LocalFileService.create(...); - } - - // Duplicate TagApplication.commitCreate logic - for (const tag of tags) { - await LocalPostTagService.create(...); - await HomeserverService.request(...); - } - - // Post creation logic - await LocalPostService.create(...); - } -} -``` +**Why not chosen**: Cross-domain business invariants belong in Application. Controllers select workflows and reconcile their results with UI state. They may sequence Applications for controller-owned session or store coordination, but must not own reusable cross-domain business invariants. -**Pros**: +### Alternative 4: Duplicate Orchestration Logic -- No cross-Application dependencies -- Each Application fully independent -- Easy to reason about (all logic in one place) +Inline file/tag/stream logic inside each orchestrating Application instead of calling peer Applications. -**Cons**: +**Pros**: No cross-Application dependencies; each Application is fully self-contained; easy to reason about in isolation. -- Violates DRY principle (logic duplicated 2-3x) -- Hard to maintain (changes needed in multiple places) -- Inconsistency risk (different implementations drift) -- Increased bug surface area -- Code bloat +**Cons**: Duplicates domain rules; changes must be made in multiple places; implementations drift; larger bug surface. -**Why not chosen**: The maintenance burden and inconsistency risk outweigh the benefits of independence. Orchestration logic should be reusable. +**Why not chosen**: Prefer one Application owning its domain and being called by an allowed orchestrator. ## Related Decisions diff --git a/docs/adr/0010-notification-application-orchestration.md b/docs/adr/0010-notification-application-orchestration.md index d7931a2fb0..a110ee0f1c 100644 --- a/docs/adr/0010-notification-application-orchestration.md +++ b/docs/adr/0010-notification-application-orchestration.md @@ -13,13 +13,13 @@ Notifications are cross-domain entity aggregations. Each notification references When fetching notifications from Nexus, referenced posts and users must be hydrated into the local cache **before** persisting notifications. -ADR-0009 restricts orchestration privilege to `PostApplication` and `UserApplication`. However, `NotificationApplication` legitimately requires cross-Application calls for entity pre-fetching. +[ADR-0009](./0009-application-cross-domain-orchestration.md) defines which Applications may call other Applications. `NotificationApplication` needs that privilege for entity pre-fetching, but with tighter constraints than general orchestrators (network reads with local cache persistence, before notification persistence only). ## Decision -**Extend the orchestration privilege (ADR-0009) to include `NotificationApplication`.** +**Include `NotificationApplication` as an allowed orchestrator under ADR-0009 rule #4, scoped to network-read cache hydration before notification persistence.** -This ADR amends ADR-0009 rule #4 with a scoped exception: `NotificationApplication` is an allowed orchestrator only for pre-persistence read hydration. +The canonical allowlist lives in ADR-0009. This ADR defines the **extra constraints** that apply when `NotificationApplication` uses that privilege. `NotificationApplication` MAY call: @@ -28,16 +28,16 @@ This ADR amends ADR-0009 rule #4 with a scoped exception: `NotificationApplicati Constraints: -1. **Read-only hydration**: Only fetch/read operations permitted—no writes to post or user domains +1. **Network-read cache hydration only**: Nexus reads and local post, file, and user cache persistence are permitted; homeserver writes and user-authored domain mutations are not 2. **Pre-persistence only**: Cross-Application calls must occur before persisting notifications 3. **No reverse dependencies**: `PostStreamApplication` and `UserStreamApplication` MUST NOT call `NotificationApplication` -4. **Max depth 1**: Standard ADR-0009 depth rule applies +4. **Call depth**: The ADR-0009 attachment-persistence exception permits `NotificationApplication` → `PostStreamApplication` → `FileApplication`; no other depth-2 path is permitted ## Consequences ✅ Ensures UI renders complete notification data (no missing entities) -✅ Formalizes existing implementation pattern -⚠️ Adds third orchestrator to review checklist (Post, User, Notification) +✅ Formalizes the hydration-before-persist pattern +⚠️ Reviewers must check Notification’s scoped constraints in addition to the ADR-0009 allowlist ## Related Decisions diff --git a/docs/architecture.md b/docs/architecture.md index 94b79948f5..bc81c52d6b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,7 +116,7 @@ UI → Controllers (user-initiated actions) Coordinators → Controllers (system-initiated actions) Controllers → Pipes, Application, Stores Application → Pipes, Services (local, homeserver, nexus) -Application → Application (cross-domain, acyclic only, max depth 1) +Application → Application (cross-domain, acyclic, max depth 1 by default; see ADR-0009 for the depth-2 attachment-persistence exception) Services: local → Models homeserver → network only @@ -134,6 +134,7 @@ Only these Applications can call other Applications: - `PostApplication` - `NotificationApplication` - `BootstrapApplication` +- `MigrationApplication` - `HotApplication` - `PostStreamApplication` - `TtlApplication` @@ -157,8 +158,13 @@ static async commitCreate({ fileAttachments }) { // FORBIDDEN: No circular dependencies PostApplication → FileApplication → PostApplication // VIOLATION -// FORBIDDEN: Max call depth is 1 -PostApplication → FileApplication → ImageProcessor // VIOLATION +// ONLY ALLOWED DEPTH-2 PATHS: attachment persistence via PostStreamApplication +PostApplication | NotificationApplication | TtlApplication + → PostStreamApplication + → FileApplication + +// FORBIDDEN: Every other depth-2 path and all paths of depth 3 or greater +PostApplication → FileApplication → ImageProcessorApplication // VIOLATION ``` Since the architecture uses static classes without dependency injection, these constraints **cannot be enforced at compile time**. They are enforced through code reviews and documentation. See ADR-0009. From 389de5779e5cfc44ddd21c87ce5db5e681f6d23a Mon Sep 17 00:00:00 2001 From: Abhishek7Tech Date: Fri, 14 Aug 2026 22:51:34 +0530 Subject: [PATCH 4/5] fix expand taggers on post tags --- .../molecules/TaggedList/TaggedList.tsx | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/components/molecules/TaggedList/TaggedList.tsx b/src/components/molecules/TaggedList/TaggedList.tsx index 0d5443dbde..b9bb5b130d 100644 --- a/src/components/molecules/TaggedList/TaggedList.tsx +++ b/src/components/molecules/TaggedList/TaggedList.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { TagKind } from '@/application/tag/tag.types'; import { Container } from '@/atoms/Container/Container'; import { Skeleton } from '@/atoms/Skeleton/Skeleton'; import { useInfiniteScroll } from '@/hooks/useInfiniteScroll/useInfiniteScroll'; @@ -10,14 +11,27 @@ import { TaggedItem } from '../TaggedItem/TaggedItem'; import { TagWithAvatars } from '../TaggedItem/TaggedItem.types'; import type { TaggedListProps } from './TaggedList.types'; -export function TaggedList({ tags, hasMore = false, isLoadingMore = false, onLoadMore, onTagToggle }: TaggedListProps) { +export function TaggedList({ + tags, + hasMore = false, + taggedId, + taggedKind, + isLoadingMore = false, + onLoadMore, + onTagToggle, +}: TaggedListProps) { // Track which tag is currently expanded (only one at a time - accordion behavior) const [expandedTagLabel, setExpandedTagLabel] = useState(null); const [tagsState, setTagsState] = useState(tags); - const { fetchTaggedList } = usePostTaggers(null); + const shouldFetchTaggers = taggedKind === TagKind.POST && !!taggedId; + const { taggersByLabel, taggerStates, fetchAllTaggers, fetchTaggedList } = usePostTaggers( + shouldFetchTaggers ? taggedId : null, + ); const { pubky } = useProfileContext(); - + // Use ref for tags to avoid re-triggering the fetch effect when tags update + const tagsRef = useRef(tags); useEffect(() => { + tagsRef.current = tags; setTagsState(tags); }, [tags]); @@ -29,17 +43,24 @@ export function TaggedList({ tags, hasMore = false, isLoadingMore = false, onLoa debounceMs: 300, }); + useEffect(() => { + if (!expandedTagLabel || !shouldFetchTaggers) return; + const selectedTagRef = tagsRef.current.find((tag) => tag.label === expandedTagLabel); + if (!selectedTagRef) return; + const initialIds = selectedTagRef.taggers.map((tagger) => tagger.id); + void fetchAllTaggers(expandedTagLabel, initialIds, selectedTagRef.taggers_count); + }, [expandedTagLabel, shouldFetchTaggers, fetchAllTaggers]); + const handleExpandToggle = async (tagLabel: string) => { // Toggle: if clicking the same tag, collapse it; otherwise expand the new one setExpandedTagLabel((prev) => (prev === tagLabel ? null : tagLabel)); if (tagLabel === expandedTagLabel) return; const selectedTag = tags.find((tag) => tag.label === tagLabel); - const selectedTagsRef = tagsState.find((tag) => tag.label === tagLabel); + const selectedTagsState = tagsState.find((tag) => tag.label === tagLabel); if (!selectedTag || !pubky) return; - // Fetch tagger details only once. - if (selectedTagsRef?.taggers_count === selectedTagsRef?.taggers.length) return; + if (selectedTagsState?.taggers_count === selectedTagsState?.taggers.length) return; const response = await fetchTaggedList(tagLabel, pubky, selectedTag.taggers); if (!response?.allTaggers) { @@ -48,13 +69,19 @@ export function TaggedList({ tags, hasMore = false, isLoadingMore = false, onLoa const upadtedTagsState = tagsState.map((tag) => tag.label === tagLabel ? { ...tag, taggers: [...tag.taggers, ...response.allTaggers] } : tag, ); + setTagsState(upadtedTagsState); }; return ( {tagsState.map((tag) => { + const tagLabelKey = tag.label.toLowerCase(); const isExpanded = expandedTagLabel === tag.label; + const expandedTaggerIds = taggersByLabel.get(tagLabelKey); + const taggerState = taggerStates.get(tagLabelKey); + const isLoadingTaggers = taggerState?.isLoading ?? false; + return ( ); })} From 798aba21d9fc8d85d38c6056f1a82059411f26bf Mon Sep 17 00:00:00 2001 From: Abhishek7Tech Date: Fri, 14 Aug 2026 23:15:24 +0530 Subject: [PATCH 5/5] fix typo --- src/components/molecules/TaggedList/TaggedList.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/molecules/TaggedList/TaggedList.tsx b/src/components/molecules/TaggedList/TaggedList.tsx index b9bb5b130d..fde716d6ef 100644 --- a/src/components/molecules/TaggedList/TaggedList.tsx +++ b/src/components/molecules/TaggedList/TaggedList.tsx @@ -48,13 +48,13 @@ export function TaggedList({ const selectedTagRef = tagsRef.current.find((tag) => tag.label === expandedTagLabel); if (!selectedTagRef) return; const initialIds = selectedTagRef.taggers.map((tagger) => tagger.id); + void fetchAllTaggers(expandedTagLabel, initialIds, selectedTagRef.taggers_count); }, [expandedTagLabel, shouldFetchTaggers, fetchAllTaggers]); const handleExpandToggle = async (tagLabel: string) => { // Toggle: if clicking the same tag, collapse it; otherwise expand the new one setExpandedTagLabel((prev) => (prev === tagLabel ? null : tagLabel)); - if (tagLabel === expandedTagLabel) return; const selectedTag = tags.find((tag) => tag.label === tagLabel); const selectedTagsState = tagsState.find((tag) => tag.label === tagLabel); @@ -66,11 +66,11 @@ export function TaggedList({ if (!response?.allTaggers) { return; } - const upadtedTagsState = tagsState.map((tag) => + const updatedTagsState = tagsState.map((tag) => tag.label === tagLabel ? { ...tag, taggers: [...tag.taggers, ...response.allTaggers] } : tag, ); - setTagsState(upadtedTagsState); + setTagsState(updatedTagsState); }; return (