Skip to content

Conversation

@allouis
Copy link
Collaborator

@allouis allouis commented Apr 24, 2025

ref https://linear.app/ghost/issue/AP-1082

The Account entity had grown complex due to its implementation with private
members and public getters for privately mutable state. We were also
encountering issues with nullable id fields that required awkward
PersistedAccount type workarounds.

  • Account now always has a required id field
  • Introduced AccountDraft type for creating new accounts without IDs
  • Made AccountEntity immutable, returning new instances on save operations
  • Aligned apFollowers field to be nullable, matching database schema

This refactoring simplifies type checking in the block method implementation,
eliminating the need for ID checking and type casting when ensuring we're
working with persisted accounts. No functional changes were introduced, though
there is a slight type change with apFollowers now being nullable to match
the database.

@coderabbitai
Copy link

coderabbitai bot commented Apr 24, 2025

Walkthrough

This change introduces a comprehensive refactor of the Account entity and its usage across the codebase. The mutable, inheritance-based Account class is replaced by an immutable AccountEntity class implementing a new Account interface, with all properties set as readonly and updates performed by returning new instances. The entity construction is now explicit, requiring all properties in the constructor, and internal logic for generating UUIDs or URLs is removed in favor of clear distinctions between internal and external account drafts. The static methods for entity creation and drafting are updated to reflect these changes. The update also removes the site property and associated logic, replacing it with an isInternal boolean and explicit handling of ActivityPub URLs. All references and usages in tests, repositories, and services are updated to use the new immutable model and factory methods. Additionally, helper files and their tests related to the old profile DTO logic are deleted, and snapshot test data is updated to reflect new URL patterns.

Possibly related PRs

  • TryGhost/ActivityPub#535: The main PR refactors the Account entity into an immutable AccountEntity class with explicit profile update methods, while this PR implements the profile update logic by extending the updateProfile method and adding username support; both modify account profile update functionality and related entity methods.
  • TryGhost/ActivityPub#571: The main PR refactors the getByApId method in account.repository.knex.ts to remove the site join and simplify account construction, while the referenced PR improves query performance by using a hashed column for filtering; both modify the same method but focus on different aspects.
  • TryGhost/ActivityPub#546: The main PR refactors the Account entity into an immutable model with explicit URL handling and removes internal URL construction logic, while the referenced PR modifies internal logic of getApId() and getApFollowers() methods to return fixed 'index' URLs instead of username-based URLs; both touch the same methods but the main PR supersedes the referenced PR’s changes.

Suggested labels

princi-vershwal-12.ghost.is

✨ Finishing Touches
  • 📝 Generate Docstrings

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🔭 Outside diff range comments (1)
src/account/account.entity.ts (1)

88-108: ⚠️ Potential issue

Incorrect URL construction leads to duplicated “.ghost” segment

When this.apId already contains /.ghost/activitypub/users/index, using a relative spec '.ghost/activitypub/${type}/${post.uuid}' results in
…/.ghost/activitypub/users/.ghost/activitypub/<type>/<uuid> (note the duplicated path element).
Pre-pending a leading slash makes the path absolute and eliminates the nesting.

-        return new URL(`.ghost/activitypub/${type}/${post.uuid}`, this.apId);
+        return new URL(`/.ghost/activitypub/${type}/${post.uuid}`, this.apId);
🧹 Nitpick comments (3)
src/account/account.repository.knex.integration.test.ts (1)

200-202: Improved test approach by using repository method

The test now retrieves an actual entity through the repository instead of manually constructing one. This better tests the real behavior of the system.

However, consider creating a more complete Site object for clarity:

-const accountEntity = await accountRepository.getBySite({
-    id: 1,
-} as Site);
+const accountEntity = await accountRepository.getBySite({
+    id: 1,
+    host: 'testing.com',
+    webhook_secret: 'test-secret'
+});
src/account/account.entity.ts (2)

26-37: url is non-nullable on AccountDraft but nullable in draft input

AccountDraft.url is declared as URL (non-nullable), yet both InternalAccountDraftData and ExternalAccountDraftData allow url: URL | null.
draft() patches this by replacing a null with apId, but the discrepancy can still confuse users of the types.

Consider aligning the types so that url is consistently optional/nullable in the input and guaranteed non-null in the output:

 export interface AccountDraft {
     uuid: string;
-    url: URL;
+    url: URL;           // guaranteed non-null in the result
 }
 
 // …
 type InternalAccountDraftData = {
     // …
-    url: URL | null;
+    url?: URL | null;
 };

or document the “nullable-in, non-nullable-out” contract in comments.


54-68: Constructor proxy in create() is verbose

create() manually passes every property to the constructor, which is error-prone when new properties are added.
You can leverage parameter spreading to avoid the boilerplate:

-        return new AccountEntity(
-            data.id,
-            data.uuid,
-            data.username,
-            data.name,
-            data.bio,
-            data.url,
-            data.avatarUrl,
-            data.bannerImageUrl,
-            data.apId,
-            data.apFollowers,
-            data.isInternal,
-        );
+        return new AccountEntity(
+            data.id,
+            data.uuid,
+            data.username,
+            data.name,
+            data.bio,
+            data.url,
+            data.avatarUrl,
+            data.bannerImageUrl,
+            data.apId,
+            data.apFollowers,
+            data.isInternal
+        );

—or even—

return new AccountEntity(...Object.values(data) as ConstructorParameters<typeof AccountEntity>);

to keep the method future-proof.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a263579 and d4e3603.

📒 Files selected for processing (29)
  • src/account/account.entity.ts (2 hunks)
  • src/account/account.entity.unit.test.ts (2 hunks)
  • src/account/account.repository.knex.integration.test.ts (4 hunks)
  • src/account/account.repository.knex.ts (3 hunks)
  • src/account/account.service.ts (1 hunks)
  • src/account/account.service.unit.test.ts (5 hunks)
  • src/activitypub/fediverse-bridge.unit.test.ts (3 hunks)
  • src/feed/feed-update.service.unit.test.ts (4 hunks)
  • src/feed/feed.service.integration.test.ts (3 hunks)
  • src/http/api/__snapshots__/post-authored-by-me.json (2 hunks)
  • src/http/api/__snapshots__/post-liked-by-me.json (2 hunks)
  • src/http/api/__snapshots__/post-reposted-by-me.json (2 hunks)
  • src/http/api/__snapshots__/post.json (2 hunks)
  • src/http/api/__snapshots__/thread.json (4 hunks)
  • src/http/api/account.ts (3 hunks)
  • src/http/api/feed.unit.test.ts (3 hunks)
  • src/http/api/helpers/account.ts (0 hunks)
  • src/http/api/helpers/account.unit.test.ts (0 hunks)
  • src/http/api/helpers/post.unit.test.ts (3 hunks)
  • src/http/api/post.unit.test.ts (3 hunks)
  • src/http/api/thread.unit.test.ts (2 hunks)
  • src/http/api/views/account.follows.view.integration.test.ts (3 hunks)
  • src/http/api/views/account.follows.view.ts (3 hunks)
  • src/http/api/views/account.posts.view.integration.test.ts (3 hunks)
  • src/http/api/views/account.posts.view.ts (2 hunks)
  • src/post/post.entity.ts (0 hunks)
  • src/post/post.entity.unit.test.ts (1 hunks)
  • src/post/post.repository.knex.integration.test.ts (1 hunks)
  • src/post/post.repository.knex.ts (3 hunks)
💤 Files with no reviewable changes (3)
  • src/post/post.entity.ts
  • src/http/api/helpers/account.unit.test.ts
  • src/http/api/helpers/account.ts
🧰 Additional context used
🧬 Code Graph Analysis (13)
src/activitypub/fediverse-bridge.unit.test.ts (1)
src/account/account.entity.ts (1)
  • AccountEntity (39-124)
src/post/post.entity.unit.test.ts (1)
src/account/account.entity.ts (2)
  • draft (70-86)
  • AccountEntity (39-124)
src/http/api/views/account.posts.view.integration.test.ts (1)
src/account/account.entity.ts (1)
  • Account (4-24)
src/http/api/views/account.follows.view.integration.test.ts (1)
src/account/account.entity.ts (1)
  • Account (4-24)
src/account/account.service.unit.test.ts (1)
src/account/account.entity.ts (1)
  • AccountEntity (39-124)
src/http/api/thread.unit.test.ts (1)
src/account/account.entity.ts (2)
  • draft (70-86)
  • AccountEntity (39-124)
src/http/api/feed.unit.test.ts (1)
src/account/account.entity.ts (2)
  • AccountEntity (39-124)
  • draft (70-86)
src/http/api/views/account.posts.view.ts (1)
src/account/account.entity.ts (1)
  • Account (4-24)
src/http/api/post.unit.test.ts (1)
src/account/account.entity.ts (2)
  • AccountEntity (39-124)
  • draft (70-86)
src/post/post.repository.knex.ts (2)
src/account/account.entity.ts (1)
  • AccountEntity (39-124)
src/core/url.ts (1)
  • parseURL (1-7)
src/account/account.repository.knex.integration.test.ts (2)
src/account/account.entity.ts (1)
  • AccountEntity (39-124)
src/site/site.service.ts (1)
  • Site (7-11)
src/account/account.entity.ts (1)
src/post/post.entity.ts (1)
  • CreatePostType (14-14)
src/account/account.repository.knex.ts (2)
src/account/account.entity.ts (1)
  • AccountEntity (39-124)
src/core/url.ts (1)
  • parseURL (1-7)
🔇 Additional comments (65)
src/http/api/__snapshots__/post-reposted-by-me.json (5)

4-4: Approve updated avatarUrl
The protocol change to HTTP and the path adjustment from /avatars/ to /avatar/ align with the new URL conventions adopted in this refactor.


6-6: Approve updated author id
Replacing the previous HTTPS-based user ID with the explicit /.ghost/activitypub/users/index path correctly reflects the new ActivityPub URL pattern for author entities.


8-8: Approve updated author url
This change mirrors the author’s id update and conforms to the standardized HTTP ActivityPub URL format.


14-14: Approve updated post id
The nested /.ghost/activitypub/users/ segment in the post’s id accurately captures the new path structure introduced by the immutable AccountEntity refactor.


29-29: No action needed
The closing JSON brace adjustment is purely structural and does not impact the snapshot’s semantics.

src/activitypub/fediverse-bridge.unit.test.ts (3)

2-2: Replaced Account with AccountEntity import.

The import of Account has been replaced with AccountEntity, aligning with the PR's refactoring that makes Account an interface instead of a concrete class.


38-38: Updated mock object creation to use AccountEntity.

Mock author creation now uses AccountEntity as the prototype object instead of Account, consistent with the refactoring that makes Account an interface and AccountEntity the implementing class.


83-83: Updated mock object creation to use AccountEntity.

Similar to the previous instance, this mock author creation now uses AccountEntity as the prototype object instead of Account.

src/post/post.repository.knex.integration.test.ts (1)

512-513: Improved assertion style for immutable objects.

Changed from using strict equality checks with assert(x === y) to using value equality with assert.equal(x, y). This is a better practice when working with immutable objects, as it focuses on value comparison rather than reference equality.

src/http/api/account.ts (3)

115-115: Removed explicit type assertion to PersistedAccount.

The type assertion to PersistedAccount has been removed as part of the PR's refactoring, which eliminates the need for the PersistedAccount type. The AccountEntity instances returned by accountRepository.getBySite() now directly implement the Account interface.


257-257: Removed explicit type assertion to PersistedAccount.

Similar to the previous instance, the type assertion has been removed, simplifying the code while maintaining type safety through the Account interface.


279-279: Removed explicit type assertion to PersistedAccount.

The type assertion for getByApId has been removed, consistent with the overall refactoring approach that replaces the PersistedAccount type with the Account interface.

src/http/api/views/account.posts.view.integration.test.ts (4)

1-1: Updated import to use Account interface.

Changed the import from PersistedAccount to Account, reflecting the PR's goal of using the Account interface as the primary type throughout the codebase.


29-31: Updated type annotations for account variables.

The type annotations for siteDefaultAccount and accountEntity have been changed from PersistedAccount | null to Account | null, aligning with the new type system where Account is the primary interface.


84-87: Removed explicit type assertion in repository call.

Removed the explicit type assertion to PersistedAccount when retrieving an account by AP ID. The repository method now correctly returns an Account type without needing the cast.


92-95: Removed explicit type assertion in repository call.

Similarly, removed the type assertion for the second repository call, maintaining consistency with the new type system that uses the Account interface directly.

src/post/post.entity.unit.test.ts (1)

3-3: Properly implemented the new immutable AccountEntity pattern

The updated code correctly implements the new immutable account pattern using AccountEntity.draft() and AccountEntity.create() factory methods. This approach aligns with the overall refactoring goal of using immutable entities with explicit construction rather than mutable objects.

Also applies to: 7-33

src/account/account.service.ts (1)

545-547: Correctly implemented immutable entity update pattern

The changes properly capture the new immutable entity returned by updateProfile and pass it to the repository's save method. This aligns with the immutable entity pattern where methods return new instances rather than mutating the existing one.

src/http/api/views/account.follows.view.integration.test.ts (1)

2-2: Type simplification through consistent Account interface usage

The changes correctly remove references to the PersistedAccount type, using the Account interface consistently throughout the test. This aligns with the refactoring goal of simplifying types by ensuring that the Account entity always has a required id field, eliminating the need for explicit type casting.

Also applies to: 45-45, 137-139

src/account/account.service.unit.test.ts (1)

6-6: Tests properly updated to match immutable entity pattern

The changes correctly update the tests to match the immutable entity pattern implemented in the service. The test now:

  1. Properly imports and uses the AccountEntity type
  2. Creates a separate updated variable to represent the new instance returned by updateProfile
  3. Mocks the updateProfile method to return this new instance
  4. Verifies that the repository's save method is called with the new instance

This ensures the test accurately validates the service's handling of immutable entities.

Also applies to: 38-41, 60-60, 79-79, 95-95

src/http/api/helpers/post.unit.test.ts (3)

3-3: Import updated to use the new AccountEntity class.

The import is now correctly using the refactored immutable AccountEntity class instead of the previous mutable Account class, aligning with the broader refactoring effort.


7-23: Good abstraction with the createAuthor helper function.

Creating a reusable helper function for author creation improves test maintainability and reduces duplication. The implementation properly follows the new two-step pattern with AccountEntity.draft() followed by AccountEntity.create(), which ensures immutability.


27-27: Consistent use of the helper function throughout tests.

All test cases now use the centralized createAuthor() function, maintaining consistency and making future changes easier to implement.

Also applies to: 40-40, 54-54

src/http/api/views/account.posts.view.ts (2)

7-7: Updated import to use the new Account interface.

The import now uses the Account interface type instead of the previous PersistedAccount type, which aligns with the refactoring goals of simplifying type checking.


85-86: Method signature updated to use Account interface.

The getPostsByApId method signature has been updated to use:

  • account: Account | null instead of PersistedAccount
  • currentContextAccount: Account instead of PersistedAccount

This change properly implements the PR objective to remove the need for ID presence checks and type casting since the Account interface now guarantees that id is always present.

src/http/api/__snapshots__/thread.json (1)

6-6: Updated URL formats in test snapshot.

The URL formats have been updated to match the refactored account entity implementation:

  1. Avatar URLs now use HTTP protocol and a simplified path format (/avatar/ instead of /avatars/)
  2. Account IDs and URLs now include the ActivityPub path prefix (/.ghost/activitypub/users/index)
  3. Post IDs now include the nested ActivityPub path structure

These changes correctly reflect the URL generation logic in the refactored AccountEntity class and ensure test snapshots remain valid.

Also applies to: 8-8, 10-10, 16-16, 35-35, 37-37, 39-39, 45-45, 64-64, 66-66, 68-68, 74-74

src/http/api/post.unit.test.ts (3)

3-3: Updated import to use the new AccountEntity class.

The import now correctly uses the refactored immutable AccountEntity class instead of the previous mutable Account class.


14-14: Updated account type to use AccountEntity.

The account variable type is updated to AccountEntity to match the refactored immutable entity class.


76-89: Refactored account creation to use the immutable pattern.

The account creation logic has been updated to use the new two-step pattern for creating immutable account entities:

  1. First create a draft with AccountEntity.draft() including the necessary properties
  2. Then create the final entity with AccountEntity.create() with an explicit ID

This correctly implements the immutable entity pattern introduced in the PR and ensures type safety without needing to check for ID presence.

src/http/api/__snapshots__/post-liked-by-me.json (2)

4-8: URL pattern changes in the snapshot test data

The changes to the avatar, id, and url fields reflect the new URL pattern used by the refactored AccountEntity. The protocol changed from HTTPS to HTTP and the path structure now follows the ActivityPub URL convention with the .ghost/activitypub/users/index pattern.


14-14: Updated post ID URL structure

The post ID URL now includes additional path segments with a nested structure that includes both the user path and article path. This change aligns with the refactored AccountEntity.getApIdForPost() method implementation.

src/http/api/thread.unit.test.ts (2)

3-3: Updated import to use type Account and AccountEntity

The import statement has been updated to import the AccountEntity class and Account as a type-only import, which aligns with the refactored immutable account model.


25-38: Refactored account creation using immutable pattern

The account creation has been refactored to use the new two-step pattern:

  1. First creating a draft using AccountEntity.draft() with necessary properties
  2. Then creating the final entity with AccountEntity.create() by adding an ID

This correctly implements the immutable entity pattern introduced in the PR, where entities are created explicitly rather than mutated after creation.

src/http/api/feed.unit.test.ts (3)

3-3: Updated import to use AccountEntity

The import statement has been updated to import the AccountEntity class, aligning with the refactored immutable account model.


15-15: Updated account type annotation

The type annotation has been updated to explicitly use AccountEntity instead of the interface type, which makes the code more precise about the expected implementation.


25-38: Refactored account creation using immutable pattern

Similar to the thread test, the account creation has been refactored to use the new two-step pattern with AccountEntity.draft() and AccountEntity.create(), correctly implementing the immutable entity pattern.

src/feed/feed.service.integration.test.ts (3)

7-7: Updated import to use AccountEntity type

The import has been updated to import AccountEntity as a type for use in the function signatures, aligning with the refactored account model.


73-76: Updated parameter type for createPost function

The parameter type has been updated from Account to AccountEntity to properly type-check against the refactored immutable account entity.


98-98: Updated parameter type for getFeedDataForAccount function

The parameter type has been updated from Account to AccountEntity to maintain type consistency throughout the codebase.

src/http/api/__snapshots__/post-authored-by-me.json (2)

4-8: URL structure updated to match new ActivityPub conventions

The author's URLs have been changed to use HTTP protocol and follow the ActivityPub URL structure with the .ghost/activitypub/users/ path. These changes align with the account entity refactoring to use standardized URL formats.


14-14: Post ID URL updated to new format

The post ID has been updated to a longer format that includes the ActivityPub path prefix. This standardizes how resources are identified in the system.

src/feed/feed-update.service.unit.test.ts (3)

6-6: Import updated to use new AccountEntity

The import statement has been updated to use the new immutable AccountEntity class instead of the previous Account class.


20-46: Account creation refactored to use immutable pattern

The account creation has been updated to use the new two-step process: first creating a draft with AccountEntity.draft() and then creating the full entity with AccountEntity.create(). This follows the new immutable pattern where an account always has a required ID.

The code now explicitly sets isInternal: true and uses proper URL objects instead of relying on internal site logic.


184-200: Blocked account creation updated to match new pattern

Similar to the main account creation, the blocked account is now created using the two-step draft and create process. This ensures consistency in how accounts are instantiated throughout the codebase.

src/account/account.entity.unit.test.ts (4)

3-5: Import and describe updated for new entity name

The import and test suite description have been updated to use AccountEntity instead of Account.


7-22: Account creation updated to use two-step process

The test now uses the new immutable pattern with AccountEntity.draft() and AccountEntity.create() factory methods instead of directly constructing an Account instance. This aligns with the refactoring goal of making the entity immutable with a required ID.


91-98: UpdateProfile now returns a new instance instead of mutating

The test has been updated to capture the return value from updateProfile() and assert against it, rather than expecting mutations on the original object. This change verifies the immutability pattern where update operations return new instances.


298-309: Null value handling with immutable updates

The test for setting values to null now correctly verifies that the returned instance has null values while keeping other properties unchanged. This ensures the immutable update pattern works correctly with null values.

src/http/api/__snapshots__/post.json (2)

4-8: Author URLs updated to ActivityPub format

The author's avatar, ID, and URL have been updated to use HTTP protocol and the standardized ActivityPub path structure. This ensures consistent URL handling across the application.


14-14: Post ID URL standardized to include ActivityPub path

The post ID URL now follows the standardized format with the ActivityPub path prefix, consistent with other entities in the system.

src/account/account.repository.knex.integration.test.ts (6)

11-11: Type import added for better type safety

The explicit import of Site type alongside the service class improves type clarity in the code.


13-13: Updated import to use new AccountEntity class

This change aligns with the refactoring to use the immutable AccountEntity model.


63-63: Updated assertion to check for AccountEntity instance

The assertion now correctly verifies the returned object is an instance of AccountEntity.


204-207: Using immutable update pattern correctly

The code now uses the immutable pattern where updateProfile returns a new instance rather than mutating the existing one.


210-210: Saving the updated entity

Correctly saves the new entity instance returned from updateProfile.


220-220: Updated event assertion

The assertion now correctly verifies the updated entity is included in the event.

src/http/api/views/account.follows.view.ts (3)

2-2: Simplified type import

Import changed to use the simpler Account type, removing the need for the PersistedAccount type and explicit type checks.


46-51: Simplified parameter type

The parameter type was changed from PersistedAccount to Account, matching the PR objective of simplifying type checking by making the Account entity always have a required id field. This removes the need for existence checks on the ID property.


93-98: Simplified parameter type

Similarly updated siteDefaultAccount parameter type to Account, maintaining consistency throughout the codebase.

src/post/post.repository.knex.ts (1)

5-5: Updated import to use AccountEntity

Changed import to use the new immutable AccountEntity class.

src/account/account.repository.knex.ts (5)

7-7: Updated import for new entity model

Import changed to use both the Account type and the AccountEntity class implementation.


15-30: Simplified save method

The save method no longer needs to check for new accounts, aligning with the immutable entity pattern where all entities are considered valid with required properties.


46-59: Improved variable naming and UUID generation

Renamed account to accountRow to better reflect that this is a database row, not an entity. Also updated the UUID generation to modify the row before updating the database.


61-73: Using factory method for entity creation

Now properly using AccountEntity.create to instantiate immutable entities from database rows. This matches the new pattern where all properties are set at creation time.


107-119: Proper immutable entity creation

Correctly using AccountEntity.create for entity instantiation, with the isInternal flag based on site_id presence.

Comment on lines +110 to 123
updateProfile(params: ProfileUpdateParams) {
type P = ProfileUpdateParams;
const get = <K extends keyof P>(prop: K): P[K] =>
params[prop] === undefined ? this[prop] : params[prop];

return AccountEntity.create({
...this,
username: get('username'),
name: get('name'),
bio: get('bio'),
avatarUrl: get('avatarUrl'),
bannerImageUrl: get('bannerImageUrl'),
});
}
Copy link

@coderabbitai coderabbitai bot Apr 24, 2025

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

updateProfile can assign undefined to non-optional fields

get('username') (and the other calls) returns string | undefined, but username in Data<Account> is required (string).
With strictNullChecks enabled this does not type-check and at runtime may propagate an undefined username.

A safer, fully-typed alternative:

-        type P = ProfileUpdateParams;
-        const get = <K extends keyof P>(prop: K): P[K] =>
-            params[prop] === undefined ? this[prop] : params[prop];
-
-        return AccountEntity.create({
-            ...this,
-            username: get('username'),
-            name: get('name'),
-            bio: get('bio'),
-            avatarUrl: get('avatarUrl'),
-            bannerImageUrl: get('bannerImageUrl'),
-        });
+        return AccountEntity.create({
+            ...this,
+            username: params.username ?? this.username,
+            name: params.name ?? this.name,
+            bio: params.bio ?? this.bio,
+            avatarUrl: params.avatarUrl ?? this.avatarUrl,
+            bannerImageUrl: params.bannerImageUrl ?? this.bannerImageUrl,
+        });

This keeps the implementation concise, avoids the helper closure, and preserves correct types.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
updateProfile(params: ProfileUpdateParams) {
type P = ProfileUpdateParams;
const get = <K extends keyof P>(prop: K): P[K] =>
params[prop] === undefined ? this[prop] : params[prop];
return AccountEntity.create({
...this,
username: get('username'),
name: get('name'),
bio: get('bio'),
avatarUrl: get('avatarUrl'),
bannerImageUrl: get('bannerImageUrl'),
});
}
updateProfile(params: ProfileUpdateParams) {
return AccountEntity.create({
...this,
username: params.username ?? this.username,
name: params.name ?? this.name,
bio: params.bio ?? this.bio,
avatarUrl: params.avatarUrl ?? this.avatarUrl,
bannerImageUrl: params.bannerImageUrl ?? this.bannerImageUrl,
});
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't think this is true, we explicitly do not return undefined if it is that on the params object, and instead return the prop from this which does match the type required in Data<Account>

Copy link

Choose a reason for hiding this comment

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

@allouis You're absolutely right. Looking closer at your implementation, the runtime behavior is perfectly safe:

const get = <K extends keyof P>(prop: K): P[K] =>
    params[prop] === undefined ? this[prop] : params[prop];

This correctly falls back to this[prop] when params[prop] is undefined, so it will never return undefined for required fields.

The only subtlety is that TypeScript's type system declares the return type as P[K] which includes undefined for optional properties, so static type checking might complain even though the runtime behavior is correct.

Thanks for the clarification and pushing back on my suggestion!


✏️ Learnings added
Learnt from: allouis
PR: TryGhost/ActivityPub#566
File: src/account/account.entity.ts:110-123
Timestamp: 2025-04-24T10:41:15.039Z
Learning: In the Account entity's `updateProfile` method, the current implementation using a helper function that checks for undefined values is type-safe at runtime, even though TypeScript might not perfectly infer this due to the function's return type declaration.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

*/
updateProfile(params: ProfileUpdateParams): Account;
/**
* @deprecated
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This code is weird and shouldn't really live here, or at least not be exposed like this - why does an Account have a getApIdForPost method lol

readonly avatarUrl: URL | null;
readonly bannerImageUrl: URL | null;
readonly apId: URL;
readonly apFollowers: URL | null;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is nullable now

Copy link
Member

Choose a reason for hiding this comment

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

Should this be null though? When would an account never have a followers URL?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Great question, I don't know - but it's nullable in the DB, so I think we should fix that all together.

);
updateProfile(params: ProfileUpdateParams) {
type P = ProfileUpdateParams;
const get = <K extends keyof P>(prop: K): P[K] =>
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I just wanted to avoid the repetition of

username: params.username === undefined ? this.username : params.username,

But I can put it back if it's preferred

Copy link
Member

Choose a reason for hiding this comment

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

Nah, this is a cool utility, maybe we have generic utils module thing somewhere?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I did try to pull it out - but unfortunately it does't work well when you pull it out, because you need to pass in the this value and it becomes a bit of a mess like get(this, params, 'key') PLUS the types become super fucky

Copy link
Member

Choose a reason for hiding this comment

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

Ah cool, let leave it in

type AccountDraftData = InternalAccountDraftData | ExternalAccountDraftData;

type Data<T> = {
// biome-ignore lint/suspicious/noExplicitAny: These are not publicly usable instances of any
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is legit - the any here is just to match functions, which we strip out - it doesn't leak into our code

Copy link
Member

Choose a reason for hiding this comment

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

Maybe update the message to include this?


type AccountDraftData = InternalAccountDraftData | ExternalAccountDraftData;

type Data<T> = {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah I can probably put this somewhere else, but where?

} as Site);

accountEntity.updateProfile({
const updated = accountEntity.updateProfile({
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Immutable - so this had to change

) {}

async save(account: Account): Promise<void> {
if (account.isNew) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Accounts are never new!

apId: URL,
account: PersistedAccount,
currentContextAccount: PersistedAccount,
account: Account | null,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No more PersistedAccount! But this is nullable, I needed to fix the type. We do account?.isInternal below - so this is safe

}

addRepost(account: Account) {
if (!account.id) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yay

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/account/account.repository.knex.ts (1)

5-5: Consider removing unused Site import

The Site type is still being imported, but since the PR mentions removing the internal site property from the entity in favor of the isInternal flag, this import might be unnecessary unless it's used elsewhere in the file.

-import type { Site } from '../site/site.service';
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d4e3603 and 4490ef6.

📒 Files selected for processing (2)
  • src/account/account.repository.knex.ts (2 hunks)
  • src/post/post.repository.knex.ts (5 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/post/post.repository.knex.ts
🧰 Additional context used
🧬 Code Graph Analysis (1)
src/account/account.repository.knex.ts (2)
src/account/account.entity.ts (2)
  • AccountEntity (39-124)
  • Account (4-24)
src/core/url.ts (1)
  • parseURL (1-7)
🔇 Additional comments (5)
src/account/account.repository.knex.ts (5)

7-7: Import change to support the Account refactoring

The import statement correctly switches from the previous Account class to importing both the Account interface type and AccountEntity implementation class, aligning with the PR's goal of making accounts immutable.


46-59: Database query simplified by removing site-related joins

The query has been appropriately simplified to only select account fields, removing any site-related joins that were needed for the previous mutable implementation.


73-85: Account creation refactored to use factory method

The implementation now correctly uses AccountEntity.create factory method to create immutable accounts, with explicit isInternal: true for site-based accounts, which is cleaner than the previous approach using site objects.


94-94: Added uuid column to SELECT query

This change addresses the previous review comment about missing uuid in the SELECT query, which was causing unnecessary UUID regeneration and updates.


118-130: Improved account creation with explicit isInternal flag

The implementation now uses the AccountEntity.create factory method with isInternal flag based on the presence of a site_id, replacing the mutable approach. This better aligns with the PR objective of simplifying type checking for persisted accounts.

readonly avatarUrl: URL | null;
readonly bannerImageUrl: URL | null;
readonly apId: URL;
readonly apFollowers: URL | null;
Copy link
Member

Choose a reason for hiding this comment

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

Should this be null though? When would an account never have a followers URL?

type AccountDraftData = InternalAccountDraftData | ExternalAccountDraftData;

type Data<T> = {
// biome-ignore lint/suspicious/noExplicitAny: These are not publicly usable instances of any
Copy link
Member

Choose a reason for hiding this comment

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

Maybe update the message to include this?

);
updateProfile(params: ProfileUpdateParams) {
type P = ProfileUpdateParams;
const get = <K extends keyof P>(prop: K): P[K] =>
Copy link
Member

Choose a reason for hiding this comment

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

Nah, this is a cool utility, maybe we have generic utils module thing somewhere?

export interface AccountData {
id: number;
uuid: string | null;
export interface AccountDraft {
Copy link
Member

Choose a reason for hiding this comment

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

What about this just being type AccountDraft = Omit<Account, 'id'> so we don't have to list all of the account properties here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah so - my thinking here is that these two types at the top of the file serve as documentation, and the duplication is preferred because it's easier to read right away. What do you think about that?

Copy link
Member

Choose a reason for hiding this comment

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

Ah ok, yeh i'm fine with that, i guess it's unlikely they are going to come out of sync without us knowing

allouis added 2 commits April 27, 2025 15:11
ref 959010f

Since we've moved to using an AccountView which bypasses the entity layer
completely when reading accounts we are not using these helpers.
ref https://linear.app/ghost/issue/AP-1082

The Account entity had grown complex due to its implementation with private
members and public getters for privately mutable state. We were also
encountering issues with nullable `id` fields that required awkward
PersistedAccount type workarounds.

- Account now always has a required `id` field
- Introduced AccountDraft type for creating new accounts without IDs
- Made AccountEntity immutable, returning new instances on save operations
- Aligned `apFollowers` field to be nullable, matching database schema

This refactoring simplifies type checking in the block method implementation,
eliminating the need for ID checking and type casting when ensuring we're
working with persisted accounts. No functional changes were introduced, though
there is a slight type change with `apFollowers` now being nullable to match
the database.
@allouis allouis force-pushed the account-entity-block-unblock branch from c132064 to c7cdeb1 Compare April 27, 2025 08:12
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/http/api/__snapshots__/post-liked-by-me.json (1)

29-29: Restore trailing newline in snapshot file
Most JSON snapshot tools expect a newline at the end of the file. Re-adding it helps prevent inconsequential diffs in future snapshot updates.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c132064 and c7cdeb1.

📒 Files selected for processing (29)
  • src/account/account.entity.ts (2 hunks)
  • src/account/account.entity.unit.test.ts (2 hunks)
  • src/account/account.repository.knex.integration.test.ts (5 hunks)
  • src/account/account.repository.knex.ts (2 hunks)
  • src/account/account.service.ts (1 hunks)
  • src/account/account.service.unit.test.ts (5 hunks)
  • src/activitypub/fediverse-bridge.unit.test.ts (3 hunks)
  • src/feed/feed-update.service.unit.test.ts (4 hunks)
  • src/feed/feed.service.integration.test.ts (3 hunks)
  • src/http/api/__snapshots__/post-authored-by-me.json (2 hunks)
  • src/http/api/__snapshots__/post-liked-by-me.json (2 hunks)
  • src/http/api/__snapshots__/post-reposted-by-me.json (2 hunks)
  • src/http/api/__snapshots__/post.json (2 hunks)
  • src/http/api/__snapshots__/thread.json (4 hunks)
  • src/http/api/account.ts (3 hunks)
  • src/http/api/feed.unit.test.ts (3 hunks)
  • src/http/api/helpers/account.ts (0 hunks)
  • src/http/api/helpers/account.unit.test.ts (0 hunks)
  • src/http/api/helpers/post.unit.test.ts (3 hunks)
  • src/http/api/post.unit.test.ts (3 hunks)
  • src/http/api/thread.unit.test.ts (2 hunks)
  • src/http/api/views/account.follows.view.integration.test.ts (3 hunks)
  • src/http/api/views/account.follows.view.ts (3 hunks)
  • src/http/api/views/account.posts.view.integration.test.ts (3 hunks)
  • src/http/api/views/account.posts.view.ts (2 hunks)
  • src/post/post.entity.ts (0 hunks)
  • src/post/post.entity.unit.test.ts (1 hunks)
  • src/post/post.repository.knex.integration.test.ts (1 hunks)
  • src/post/post.repository.knex.ts (5 hunks)
💤 Files with no reviewable changes (3)
  • src/post/post.entity.ts
  • src/http/api/helpers/account.unit.test.ts
  • src/http/api/helpers/account.ts
✅ Files skipped from review due to trivial changes (2)
  • src/http/api/snapshots/thread.json
  • src/feed/feed.service.integration.test.ts
🚧 Files skipped from review as they are similar to previous changes (23)
  • src/activitypub/fediverse-bridge.unit.test.ts
  • src/post/post.entity.unit.test.ts
  • src/http/api/snapshots/post-reposted-by-me.json
  • src/http/api/account.ts
  • src/http/api/helpers/post.unit.test.ts
  • src/feed/feed-update.service.unit.test.ts
  • src/account/account.service.ts
  • src/account/account.service.unit.test.ts
  • src/http/api/views/account.posts.view.ts
  • src/http/api/snapshots/post.json
  • src/http/api/feed.unit.test.ts
  • src/http/api/views/account.posts.view.integration.test.ts
  • src/account/account.repository.knex.integration.test.ts
  • src/http/api/thread.unit.test.ts
  • src/http/api/snapshots/post-authored-by-me.json
  • src/post/post.repository.knex.integration.test.ts
  • src/account/account.entity.unit.test.ts
  • src/http/api/views/account.follows.view.integration.test.ts
  • src/post/post.repository.knex.ts
  • src/account/account.entity.ts
  • src/http/api/post.unit.test.ts
  • src/http/api/views/account.follows.view.ts
  • src/account/account.repository.knex.ts
🔇 Additional comments (2)
src/http/api/__snapshots__/post-liked-by-me.json (2)

4-4: Confirm updated avatar URL path
The change from "avatars/foobar.png" to "avatar/foobar.png" reflects the new URL pattern. Please verify that the underlying code generating avatarUrl was updated to use the singular avatar segment and that no endpoints or clients expect the plural avatars.


6-8: Verify author ID and URL consistency
Both the id and url fields are now "http://example.com/.ghost/activitypub/users/index". Ensure this aligns with the new AccountEntity design (isInternal vs. external URL handling) and that id and url should indeed be identical for internal actor endpoints.

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.

3 participants