feat: hash-based metadata staleness detection (#18649)
## Summary Replace the single `metadataVersion` integer with per-entity-type **collection hashes** for granular metadata staleness detection. The backend already generates a UUID per flat entity map on each cache recompute (`crypto.randomUUID()` in `WorkspaceCacheService`); we now expose these via the minimal metadata endpoint and SSE events so the frontend can compare and know exactly which entity types are stale. ### Key changes **Backend:** - `WorkspaceCacheService.getCacheHashes()` — new public method that reads only `:hash` keys from Redis without fetching full data - `MinimalMetadataDTO` — added `collectionHashes: Record<string, string>` (JSON scalar mapping `AllMetadataName` → collection hash), removed `metadataVersion` - `MetadataEventDTO` — added optional `updatedCollectionHash` field to SSE events - `MetadataEventsToDbListener` — reads the collection hash for the affected entity type after cache invalidation and attaches it to the SSE event before publishing - `MinimalMetadataService` — no longer queries the workspace table; uses `getCacheHashes()` for all flat entity maps and maps cache keys to `AllMetadataName` locally **Frontend:** - `metadataCollectionHashesState` — new Jotai atom with `atomWithStorage` + `getOnInit: true` storing `Partial<Record<MetadataEntityKey, string>>` - `mapAllMetadataNameToEntityKey()` — explicit mapping from backend `AllMetadataName` to frontend `MetadataEntityKey` (23 entries) - `useLoadMinimalMetadata` — stores `collectionHashes` from server, computes `staleEntityKeys` by comparing local vs server hashes - `patchMetadataStoreFromSSEEvent()` — accepts optional `updatedCollectionHash` and updates `metadataCollectionHashesState` - All 11 SSE effect components — pass `eventDetail.updatedCollectionHash` through to the patch function - `useStaleMetadataEntities` — new hook returning entity keys missing from collection hashes (not yet loaded/synced) - `resetMetadataStore()` — also clears collection hashes - Deleted `metadataVersionState` (superseded by collection hashes) ### Design decisions - **No change to hash generation** — existing `crypto.randomUUID()` is sufficient. Hashes are persisted in Redis, survive server restarts, and change only on `invalidateAndRecompute`. - **"Collection hash" naming** — used consistently to clarify the hash represents an entire entity collection (e.g., all views), not a single record. - **Mapping localized** — backend `WorkspaceCacheKeyName` → `AllMetadataName` mapping lives in the minimal metadata service. Frontend `AllMetadataName` → `MetadataEntityKey` mapping lives in a local utility. Nothing in `twenty-shared`. - **Backward compatible** — `collectionHashes` is additive; `updatedCollectionHash` is nullable.
This commit is contained in:
+4
-3
@@ -1,4 +1,5 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
|
||||
import { MinimalObjectMetadataDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-object-metadata.dto';
|
||||
import { MinimalViewDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-view.dto';
|
||||
@@ -11,6 +12,6 @@ export class MinimalMetadataDTO {
|
||||
@Field(() => [MinimalViewDTO])
|
||||
views: MinimalViewDTO[];
|
||||
|
||||
@Field(() => Int)
|
||||
metadataVersion: number;
|
||||
@Field(() => GraphQLJSON)
|
||||
collectionHashes: Record<string, string>;
|
||||
}
|
||||
|
||||
+2
-6
@@ -1,16 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { MinimalMetadataResolver } from 'src/engine/metadata-modules/minimal-metadata/minimal-metadata.resolver';
|
||||
import { MinimalMetadataService } from 'src/engine/metadata-modules/minimal-metadata/minimal-metadata.service';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([WorkspaceEntity]),
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
],
|
||||
imports: [WorkspaceManyOrAllFlatEntityMapsCacheModule, WorkspaceCacheModule],
|
||||
providers: [MinimalMetadataResolver, MinimalMetadataService],
|
||||
exports: [MinimalMetadataService],
|
||||
})
|
||||
|
||||
+38
-12
@@ -1,40 +1,66 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
ALL_METADATA_NAME,
|
||||
type AllMetadataName,
|
||||
} from 'twenty-shared/metadata';
|
||||
import { ViewVisibility } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { isDefined, uncapitalize } from 'twenty-shared/utils';
|
||||
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ALL_FLAT_ENTITY_MAPS_PROPERTIES } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-maps-properties.constant';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { MinimalMetadataDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-metadata.dto';
|
||||
import { MinimalObjectMetadataDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-object-metadata.dto';
|
||||
import { MinimalViewDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-view.dto';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
|
||||
|
||||
// Inverse of getMetadataFlatEntityMapsKey: "flatObjectMetadataMaps" -> "objectMetadata"
|
||||
const flatMapsKeyToMetadataName = (
|
||||
flatMapsKey: string,
|
||||
): AllMetadataName | undefined => {
|
||||
const withoutPrefix = flatMapsKey.replace(/^flat/, '');
|
||||
const withoutSuffix = withoutPrefix.replace(/Maps$/, '');
|
||||
const metadataName = uncapitalize(withoutSuffix);
|
||||
|
||||
return metadataName in ALL_METADATA_NAME
|
||||
? (metadataName as AllMetadataName)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MinimalMetadataService {
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
async getMinimalMetadata(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
): Promise<MinimalMetadataDTO> {
|
||||
const [workspace, { flatObjectMetadataMaps, flatViewMaps }] =
|
||||
const [{ flatObjectMetadataMaps, flatViewMaps }, cacheHashes] =
|
||||
await Promise.all([
|
||||
this.workspaceRepository.findOneOrFail({
|
||||
where: { id: workspaceId },
|
||||
select: ['metadataVersion'],
|
||||
}),
|
||||
this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps({
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatObjectMetadataMaps', 'flatViewMaps'],
|
||||
}),
|
||||
this.workspaceCacheService.getCacheHashes(
|
||||
workspaceId,
|
||||
ALL_FLAT_ENTITY_MAPS_PROPERTIES as WorkspaceCacheKeyName[],
|
||||
),
|
||||
]);
|
||||
|
||||
const collectionHashes: Record<string, string> = {};
|
||||
|
||||
for (const [cacheKey, hash] of Object.entries(cacheHashes)) {
|
||||
const metadataName = flatMapsKeyToMetadataName(cacheKey);
|
||||
|
||||
if (isDefined(metadataName) && isDefined(hash)) {
|
||||
collectionHashes[metadataName] = hash;
|
||||
}
|
||||
}
|
||||
|
||||
const objectMetadataItems: MinimalObjectMetadataDTO[] = Object.values(
|
||||
flatObjectMetadataMaps.byUniversalIdentifier,
|
||||
)
|
||||
@@ -76,7 +102,7 @@ export class MinimalMetadataService {
|
||||
return {
|
||||
objectMetadataItems,
|
||||
views,
|
||||
metadataVersion: workspace.metadataVersion,
|
||||
collectionHashes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user