-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat(): add external storage for field + recently view #15710
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
… synchronization enhancements - Added `lastViewedAt` as a redis-backed field for better tracking user interactions with the `company` object. - Implemented Redis support in `workspace-select-query-builder` and supporting ORM logic (e.g., `redisFieldSqlFactory`, `redisFieldsDataSource`, `redisFieldRepository`). - Enhanced `graphql-query-parser` to compute redis-driven fields dynamically in listings. - Updated metadata cache to handle synthetic redis-backed metadata fields, avoiding duplication. - Extended test cases to validate new field integrations and behavior adjustments. - Refactored `redis-client.service` to support a no-eviction Redis client.
…lastViewedAt` storage in Redis - Introduced `OBJECT_RECORD_VIEWED_EVENT` for tracking object record views. - Optimized `lastViewedAt` field handling by removing legacy synthetic metadata injection. - Refactored frontend `RecordShowPage` with reusable `RecordShowContent` for enhanced component structure. - Enhanced Redis-backed field storage with accurate timestamp conversion logic.
# Conflicts: # packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts # packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts
…t parameters - Corrected indentation issues in `graphql-query-find-many-resolver.service.ts` for better readability. - Removed unused boolean parameters in `graphql-query.parser.spec.ts` to maintain cleaner tests.
# Conflicts: # packages/twenty-front/src/pages/object-record/RecordShowPage.tsx # packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts
…nhance GraphQL query parsing - Removed unused `RecordShowContent` component to streamline frontend logic. - Improved GraphQL query parser by replacing `orderBy` logic with more extensible `addOrderBy` handling. - Added support for external storage aliasing in order-by conditions for better compatibility with composite fields.
# Conflicts: # packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts # packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts # packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts # packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-relation-flat-field-metadatas-for-custom-object.util.ts # packages/twenty-server/src/utils/__test__/get-field-metadata-entity.mock.ts
…across services and tests - Updated Redis client usage to align with the no-eviction policy for improved reliability. - Adjusted relevant tests and services to reflect the change. - Added `storage` to `PartialFieldMetadata`.
…SQL expressions Added logic to dynamically construct order-by clauses based on the expression map, supporting column aliases, property paths, and null handling. Streamlines query customization and improves support for advanced sorting scenarios.
…e order-by alias handling - Removed unused parameters from `parseSelectedFields` to streamline GraphQL query parsing. - Enhanced `workspace-select-query-builder` to prioritize alias usage for `external_` fields when building order-by clauses.
…on and streamline query logic - Deleted `wrapperWithDoubleQuoteWhenUpperCase` utility and its usages to simplify alias handling. - Removed `lastViewedAt` field from `company.workspace-entity` in favor of centralizing logic in `base.workspace-entity`. - Adjusted related tests and constants for consistency.
…iasing, and condition generation logic - Refactored `setFindOptions` in `workspace-select-query-builder` to handle deduplication and parameter preservation efficiently. - Enhanced alias handling for `external_` fields in ordering and SQL expressions. - Added optional `selector` support to `computeWhereConditionParts` for improved query flexibility. - Adjusted Redis field SQL generation for timestamped values.
…query builder and order-by logic - Updated `workspace-select-query-builder` to use more descriptive
…ed logic - Eliminated `externalFieldDrivers` across ORM and GraphQL layers to streamline code. - Removed redundant Redis analytics handling for `Object Record Viewed` events. - Added `isVirtualField` to metadata settings for improved clarity in field configurations. - Refactored type definitions to centralize `CommonFieldMetadataSettings`.
- Introduced `lastViewedAt` field in `build-default-fields-for-custom-object` utility to track the last view of a record by user.
…n join conditions
…tViewedAt` update logic - Updated `updateOneRecordInput` to be optional in `useUpdateOneRecord` hook. - Added `lastViewedAt` field update logic in `RecordShowEffect` for tracking record view timestamps.
| const { id: _, ...fieldsToSelectWithoutId } = fieldsToSelect; | ||
|
|
||
| return fieldsToSelectWithoutId; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: The buildColumnsToSelect function incorrectly removes the id field from the selection object, breaking downstream operations expecting id.
Severity: CRITICAL | Confidence: 1.00
🔍 Detailed Analysis
The buildColumnsToSelect function explicitly removes the id field from the fieldsToSelect object using destructuring const { id: _, ...fieldsToSelectWithoutId } = fieldsToSelect;. This causes downstream functions, such as buildColumnsToReturn and various query runners, to operate without the primary key id, leading to incorrect database select statements and potential data integrity issues. This change directly contradicts existing test expectations where id: true is anticipated in the result.
💡 Suggested Fix
Modify buildColumnsToSelect to retain the id field in the returned object. Remove the destructuring that excludes id to ensure it's always included in the selection.
🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location:
packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select.ts#L64-L66
Potential issue: The `buildColumnsToSelect` function explicitly removes the `id` field
from the `fieldsToSelect` object using destructuring `const { id: _,
...fieldsToSelectWithoutId } = fieldsToSelect;`. This causes downstream functions, such
as `buildColumnsToReturn` and various query runners, to operate without the primary key
`id`, leading to incorrect database select statements and potential data integrity
issues. This change directly contradicts existing test expectations where `id: true` is
anticipated in the result.
Did we get this right? 👍 / 👎 to inform future reviews.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Greptile Overview
Greptile Summary
This PR adds external storage infrastructure for field metadata, specifically implementing Redis storage for the lastViewedAt field to track when users last viewed records.
Key Changes:
- Added
storagecolumn tofieldMetadatatable (postgres/redis) - Implemented Redis storage driver infrastructure with ZSet-based data collection
- Added
lastViewedAtfield to custom objects using Redis storage - Modified ORM query builder to join external storage data via CTEs
- Updated frontend to track record views and update
lastViewedAt
Critical Issues Found:
- SQL Injection Vulnerability: UUID values from Redis are directly interpolated into SQL strings without sanitization in
RedisFieldSqlFactory.buildZSetDateTimeParts()(line 20-21) - Incorrect Join Logic:
leftJoin()API usage inWorkspaceSelectQueryBuilder.appendExternalFields()appears incorrect - passing CTE name as property and alias/condition parameters may not work as intended (line 114) - React Effect Issues:
RecordShowEffecthas unstable dependencies inuseEffectthat could cause infinite loops, and uses effects instead of event handlers per repository guidelines
Additional Concerns:
- Hardcoded 100-entry limit in Redis driver without explanation
- No tests for the critical SQL generation logic
- Missing UUID format validation before SQL interpolation
- Type casting workarounds in Redis data source service suggest TypeScript issues
Confidence Score: 1/5
- This PR has critical security vulnerabilities and logic errors that make it unsafe to merge
- Score of 1 reflects two critical blocking issues: (1) SQL injection vulnerability in Redis field SQL factory where unsanitized UUIDs from Redis are directly interpolated into SQL strings, and (2) incorrect TypeORM leftJoin API usage that will cause join conditions to be ignored, breaking the core functionality. Additionally, the frontend implementation violates repository guidelines with unstable useEffect dependencies that risk infinite loops.
- Pay immediate attention to
redis-field-sql.factory.ts(SQL injection),workspace-select-query-builder.ts(broken join logic), andRecordShowEffect.tsx(infinite loop risk)
Important Files Changed
File Analysis
| Filename | Score | Overview |
|---|---|---|
| packages/twenty-server/src/engine/twenty-orm/factories/redis-field-sql.factory.ts | 1/5 | New factory for Redis field SQL generation with SQL injection vulnerability in UUID interpolation |
| packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts | 1/5 | Added external storage support with incorrect leftJoin API usage that may break join conditions |
| packages/twenty-front/src/modules/object-record/record-show/components/RecordShowEffect.tsx | 2/5 | Added lastViewedAt tracking with missing useEffect dependencies causing potential infinite loop |
| packages/twenty-server/src/database/typeorm/core/migrations/common/1760458320000-add-fieldmetadata-storage-column.ts | 5/5 | Simple migration adding nullable storage column to fieldMetadata table |
| packages/twenty-server/src/engine/twenty-orm/storage/drivers/redis-storage.driver.ts | 3/5 | New Redis storage driver implementing data collection from Redis ZSets with hardcoded 100-entry limit |
Sequence Diagram
sequenceDiagram
participant User
participant RecordShowEffect
participant useUpdateOneRecord
participant GraphQL API
participant WorkspaceSelectQueryBuilder
participant RedisStorageDriver
participant RedisFieldsRepository
participant Redis
participant PostgreSQL
User->>RecordShowEffect: Views record page
RecordShowEffect->>useUpdateOneRecord: updateOneRecord({idToUpdate, optimisticRecord: {lastViewedAt}})
useUpdateOneRecord->>GraphQL API: updateOneRecord mutation
GraphQL API->>RedisFieldsRepository: setZSetEntry(key, recordId, timestamp)
RedisFieldsRepository->>Redis: ZADD key timestamp recordId
Redis-->>RedisFieldsRepository: OK
RedisFieldsRepository-->>GraphQL API: Success
GraphQL API-->>useUpdateOneRecord: Updated record
useUpdateOneRecord-->>RecordShowEffect: Record updated
Note over User,PostgreSQL: Querying records with lastViewedAt
User->>GraphQL API: Query records
GraphQL API->>WorkspaceSelectQueryBuilder: findMany with external storage
WorkspaceSelectQueryBuilder->>WorkspaceSelectQueryBuilder: prepareExternalStorage()
WorkspaceSelectQueryBuilder->>RedisStorageDriver: collectData(userId, workspaceId, objectMetadataId)
RedisStorageDriver->>RedisFieldsRepository: getZSetEntriesRange(key)
RedisFieldsRepository->>Redis: ZREVRANGE key 0 -1 WITHSCORES
Redis-->>RedisFieldsRepository: [recordId1, score1, recordId2, score2...]
RedisFieldsRepository-->>RedisStorageDriver: [{id, score}]
RedisStorageDriver->>RedisStorageDriver: buildZSetDateTimeParts(entries.slice(0,100))
RedisStorageDriver-->>WorkspaceSelectQueryBuilder: {cteSql, joinOn, selectExpr}
WorkspaceSelectQueryBuilder->>WorkspaceSelectQueryBuilder: addCommonTableExpression(cteSql)
WorkspaceSelectQueryBuilder->>WorkspaceSelectQueryBuilder: leftJoin(cteName, alias, joinOn)
WorkspaceSelectQueryBuilder->>PostgreSQL: SELECT with CTE join
PostgreSQL-->>WorkspaceSelectQueryBuilder: Query results
WorkspaceSelectQueryBuilder-->>GraphQL API: Records with lastViewedAt
GraphQL API-->>User: Display records
72 files reviewed, 4 comments
| ({ id, score }) => | ||
| `('${id}', to_timestamp(${Number(score) || 0} / 1000)::timestamptz)`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: SQL injection vulnerability: id from Redis is directly interpolated into SQL without sanitization
The id value from Redis entries is inserted directly into the SQL string using template literals. If malicious data ends up in Redis (e.g., through data corruption or a separate vulnerability), it could execute arbitrary SQL.
| ({ id, score }) => | |
| `('${id}', to_timestamp(${Number(score) || 0} / 1000)::timestamptz)`, | |
| ({ id, score }) => | |
| `($${parameterId++}, to_timestamp(${Number(score) || 0} / 1000)::timestamptz)`, |
Use parameterized queries instead of string concatenation, or at minimum validate/sanitize the UUID format.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/twenty-server/src/engine/twenty-orm/factories/redis-field-sql.factory.ts
Line: 20:21
Comment:
**logic:** SQL injection vulnerability: `id` from Redis is directly interpolated into SQL without sanitization
The `id` value from Redis entries is inserted directly into the SQL string using template literals. If malicious data ends up in Redis (e.g., through data corruption or a separate vulnerability), it could execute arbitrary SQL.
```suggestion
({ id, score }) =>
`($${parameterId++}, to_timestamp(${Number(score) || 0} / 1000)::timestamptz)`,
```
Use parameterized queries instead of string concatenation, or at minimum validate/sanitize the UUID format.
How can I resolve this? If you propose a fix, please make it concise.| columnNames: parts.cteColumns, | ||
| }); | ||
|
|
||
| this.leftJoin(parts.cteName, `${field.name}Records`, parts.joinOn); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: Incorrect leftJoin API usage causes join condition to be ignored
The TypeORM leftJoin signature is leftJoin(property, alias, condition?). Here you're passing:
parts.cteName(the CTE name, not a property)`${field.name}Records`(intended as alias)parts.joinOn(join condition)
But leftJoin expects the second parameter to be the alias and the third to be the condition. Since parts.cteName equals `${field.name}Records`, the alias becomes the join condition string, and the actual join condition is treated as additional ON clause parameters.
This may cause incorrect SQL generation where the join condition "lastViewedAtRecords"."recordId"::uuid = "alias".id::uuid isn't properly applied.
Use leftJoinAndSelect with proper parameters or verify the generated SQL matches expectations in tests.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/twenty-server/src/engine/twenty-orm/repository/workspace-select-query-builder.ts
Line: 114:114
Comment:
**logic:** Incorrect `leftJoin` API usage causes join condition to be ignored
The TypeORM `leftJoin` signature is `leftJoin(property, alias, condition?)`. Here you're passing:
- `parts.cteName` (the CTE name, not a property)
- `` `${field.name}Records` `` (intended as alias)
- `parts.joinOn` (join condition)
But `leftJoin` expects the **second parameter to be the alias** and the **third to be the condition**. Since `parts.cteName` equals `` `${field.name}Records` ``, the alias becomes the join condition string, and the actual join condition is treated as additional ON clause parameters.
This may cause incorrect SQL generation where the join condition `"lastViewedAtRecords"."recordId"::uuid = "alias".id::uuid` isn't properly applied.
Use `leftJoinAndSelect` with proper parameters or verify the generated SQL matches expectations in tests.
How can I resolve this? If you propose a fix, please make it concise.|
|
||
| const { updateOneRecord } = useUpdateOneRecord({ | ||
| objectNameSingular, | ||
| // Important: inclure lastViewedAt si ce n’est pas déjà dans la signature |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: French comment in codebase: "Important: inclure lastViewedAt si ce n'est pas déjà dans la signature"
| // Important: inclure lastViewedAt si ce n’est pas déjà dans la signature | |
| // Important: include lastViewedAt if not already in the signature |
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/twenty-front/src/modules/object-record/record-show/components/RecordShowEffect.tsx
Line: 53:53
Comment:
**style:** French comment in codebase: "Important: inclure lastViewedAt si ce n'est pas déjà dans la signature"
```suggestion
// Important: include lastViewedAt if not already in the signature
```
How can I resolve this? If you propose a fix, please make it concise.| useEffect(() => { | ||
| if (!loading && isDefined(record)) { | ||
| setRecordStore(record); | ||
| updateOneRecord({ | ||
| idToUpdate: recordId, | ||
| optimisticRecord: { lastViewedAt: new Date().toISOString() }, | ||
| }); | ||
| } | ||
| }, [record, setRecordStore, loading]); | ||
| }, [record, setRecordStore, loading, updateOneRecord, recordId]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
logic: Missing dependencies in useEffect causes stale closure and infinite update loop risk
The effect depends on updateOneRecord and setRecordStore but these aren't stable references (created from hooks). On every render:
- New
updateOneRecordfunction is created - Effect re-runs
- Triggers update which may re-render
- Cycle repeats
Additionally, per repository rules (.cursor/rules/react-general-guidelines.mdc), effects should be moved to dedicated EffectComponent or use event handlers instead of useEffect for state updates.
Either:
- Wrap
updateOneRecordcall in a separate effect component - Use
useCallbackto stabilize the references - Move to an event handler pattern instead of effect-based updates
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/twenty-front/src/modules/object-record/record-show/components/RecordShowEffect.tsx
Line: 60:68
Comment:
**logic:** Missing dependencies in `useEffect` causes stale closure and infinite update loop risk
The effect depends on `updateOneRecord` and `setRecordStore` but these aren't stable references (created from hooks). On every render:
1. New `updateOneRecord` function is created
2. Effect re-runs
3. Triggers update which may re-render
4. Cycle repeats
Additionally, per repository rules (`.cursor/rules/react-general-guidelines.mdc`), effects should be moved to dedicated `EffectComponent` or use event handlers instead of `useEffect` for state updates.
Either:
- Wrap `updateOneRecord` call in a separate effect component
- Use `useCallback` to stabilize the references
- Move to an event handler pattern instead of effect-based updates
How can I resolve this? If you propose a fix, please make it concise.|
🚀 Preview Environment Ready! Your preview environment is available at: http://bore.pub:62515 This environment will automatically shut down when the PR is closed or after 5 hours. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the final PR Bugbot will review for you during this billing cycle
Your free Bugbot reviews will reset on December 15
Details
You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.
To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.
|
|
||
| await refetchAggregateQueries(); | ||
| const udpatedRecord = | ||
| updatedRecord?.data?.[mutationResponseField] ?? null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Mismatched Variable, Incorrect Return
Typo in variable name: udpatedRecord should be updatedRecord. This causes the function to return the wrong variable, as udpatedRecord is assigned the mutation result but updatedRecord refers to the promise object from the mutation call, not the actual data.
| alias?: string, | ||
| queryRunner?: QueryRunner, | ||
| ): WorkspaceSelectQueryBuilder<U> { | ||
| assertIsDefinedOrThrow(alias); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Default alias generation unexpectedly fails.
assertIsDefinedOrThrow(alias) is called but alias is optional in the method signature. When createQueryBuilder is called without an alias parameter, this will throw an error, breaking existing code that relies on TypeORM's default behavior of auto-generating aliases.
| application: {} as ApplicationEntity, | ||
| applicationId: faker.string.uuid(), | ||
| universalIdentifier: faker.string.uuid(), | ||
| storage: 'postgres', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm surprised to see storage at that level, shouldn't it be within settings?
| RedisFieldsDataSource, | ||
| RedisStorageDriver, | ||
| RedisFieldSqlFactory, | ||
| // External Field Drivers registry provider |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment probably not needed
| TwentyORMGlobalManager, | ||
| PgPoolSharedModule, | ||
| ScopedWorkspaceContextFactory, | ||
| // Export the registry so it can be injected elsewhere |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove comment? Also is this a standard pattern? Maybe it is!
| type UpdateOneRecordArgs<UpdatedObjectRecord> = { | ||
| idToUpdate: string; | ||
| updateOneRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>; | ||
| updateOneRecordInput?: Partial<Omit<UpdatedObjectRecord, 'id'>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change seems a bit odd? Why would we be allowed to call updateOneRecord without any update param?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be better to solve at the root instead? Not sure where the root issue comes from?
|
|
||
| const { updateOneRecord } = useUpdateOneRecord({ | ||
| objectNameSingular, | ||
| // Important: inclure lastViewedAt si ce n’est pas déjà dans la signature |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
French :)
|
|
||
| public async up(queryRunner: QueryRunner): Promise<void> { | ||
| await queryRunner.query( | ||
| `ALTER TABLE "core"."fieldMetadata" ADD COLUMN "storage" character varying`, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought this was in settings?
- Introduced `BackfillRecentViewCommand` to create a 'Recently View' system view for objects with `lastViewedAt` field, filtered to the past week. - Registered the new command in the upgrade module for seamless integration. - Updated related services and modules to support the new system view creation. - Optimized query builders by removing unused logic.
…t/add-recently-view
Note
Introduce Redis-backed external field storage and per-user
lastViewedAttracking, integrate into query/filter/order pipelines, add migration and metadata support, trigger updates on record view, and switch queue Redis client usage.storagetofieldMetadata(migration) and propagate through metadata, mocks, builders, and sync utilities; skip non-Postgres fields in schema generation, migrations, selects, and formatting.RedisFieldsDataSource,RedisFieldRepository,RedisStorageDriver,ExternalFieldDriversprovider/token; wire intoTwentyORMModuleandWorkspaceDatasourceFactory.WorkspaceSelectQueryBuilderto join external fields via CTEs, select aliased values (external_...), and support ordering; update GraphQL parsers to filter/order using external selectors and iterative addOrderBy.buildColumnsToSelect,computeWhereConditionParts,formatDatato respect non-Postgres storage.lastViewedAtfield to base/custom objects (virtual, Redis-backed) and expose in entities.findOne, write view timestamp to Redis; support sorting/selection of this field.RecordShowEffecttriggers optimisticlastViewedAtset on view;useUpdateOneRecordallows optional input and optimistic-only updates.Object Record Viewedevent and make audit service insert methods async.getQueueClientwithgetNoevictionClientacross health/queue services and tests.Written by Cursor Bugbot for commit 2ef7c9d. This will update automatically on new commits. Configure here.