Cache twenty-client-sdk modules host-side via content-addressed URLs (#22981)
## Context Front component sources are fetched host-side and integrity-verified by the SHA-256 checksum embedded in their URL, cached in Cache Storage — a layer that exists specifically because their download URLs are presigned and rotate. The `twenty-client-sdk` modules (`core` and `metadata`) were re-fetched on every render and could not be cached safely: their URLs carried no checksum and the server exposed no freshness signal. This PR makes the SDK module URLs **content-addressed** and relies on the **browser HTTP cache** for immutability, and it keys the checksums on their real owners: the **application** for `core`, the **instance** for `metadata`. The checksum does double duty: cache invalidation (regeneration changes the checksum → the URL changes → guaranteed cache miss) and a server-side cacheability guard (the server only grants `immutable` when the checksum in the URL matches the authoritative checksum it knows for that module — persisted at generation time for `core`, hashed once at bootstrap for `metadata` — so no per-request hashing of the served bytes). Note this is **not** an end-to-end integrity guarantee: there is no client-side hash verification, and on a fingerprint mismatch the server still serves the current bytes with `no-store` (self-healing for stale URLs) rather than failing. <img width="2412" height="926" alt="image" src="https://github.com/user-attachments/assets/d97935d2-0fdb-4c44-89ac-596b7ca8ca64" /> Closes twentyhq/core-team-issues#2688. ## Routes | Module | URL | Scope | | --- | --- | --- | | `core` | `/rest/sdk-client/{applicationId}/core[/{checksum}]` | Per application (generated bundle) | | `metadata` | `/rest/sdk-client/metadata[/{checksum}]` | **Instance-wide**: no application segment, so every application converges on one URL and the browser downloads the module once per release instead of once per application | The previous application-scoped metadata path (`/rest/sdk-client/{applicationId}/metadata[/{checksum}]`) is **kept for backward compatibility**, new clients just stop generating those URLs. The instance-wide route is declared before the parameterized route so `metadata/{checksum}` is not swallowed as `:applicationId/:moduleName`. ## Caching model | Request | `Cache-Control` | Effect | | --- | --- | --- | | Fingerprinted URL, checksum matches the known module checksum | `immutable` | Cached indefinitely by the browser HTTP cache; a new checksum is a new URL | | Fingerprinted URL, checksum does not match | `no-store` | Current bytes served uncached (self-healing for stale URLs) | | Bare URL (pre-generation fallback, `core` only in practice) | `no-store` | Never cached | - Both responses also set `X-Content-Type-Options: nosniff` and `Content-Type: application/javascript`. - SDK modules are intentionally **not** placed in Cache Storage. That layer stays reserved for the presigned/rotating component-source URLs; SDK modules are served directly and authenticated, so the browser HTTP cache (keyed by the content-addressed URL) is their single cache layer. ## Checksum provenance - **core** — per **application**, persisted on `application.sdkClientCoreChecksum` at generation time and read back from `flatApplicationMaps` (never re-hashed per request). - **metadata** — **instance-wide**, hashed once from the installed `twenty-client-sdk/dist/metadata.mjs` package (warmed at bootstrap, memoized per process) and served straight from that package, so it is fresh from the first request after a release with no archive dependency. ## Server (twenty-server) - Hash `dist/core.mjs` at SDK generation and persist `sdkClientCoreChecksum` via `applicationRepository.update`. Adds the nullable text column to `application.entity.ts` (mirroring `packageJsonChecksum`) plus a fast instance command with up/down; `FlatApplication` picks it up automatically. - New **application-scoped** query `applicationSdkClientChecksums(applicationId: UUID!): SdkClientChecksums` on `ApplicationResolver` (metadata schema, `WorkspaceAuthGuard` + `NoPermissionGuard`). `SdkClientChecksums.core` is **nullable** and stays `null` until the SDK has been generated at least once; `metadata` is **always present** (bootstrap-warmed), so the metadata module is cacheable from the very first render of any app. The query itself returns `null` only for unknown applications. - `SdkClientChecksumsDTO` now lives in the shared `core-modules/sdk-client/dtos/`. `FrontComponentDTO` and the `frontComponent` resolver no longer carry checksums (decoupled from the front-component row). - `sdk-client` controller: instance-wide `metadata[/:checksum]` route (no workspace-cache or application lookup, serves the memoized installed module) + application-scoped `:applicationId/:moduleName[/:checksum]` route (serves `core` from the per-application archive, `metadata` kept for back-compat). Cacheability compares the URL checksum against the **known** checksum — persisted `sdkClientCoreChecksum` for `core`, memoized package hash for `metadata` — instead of hashing the served bytes on every request: `immutable` on match, `no-store` otherwise (bare URL or stale fingerprint), plus `nosniff`. A persisted checksum out of sync with the archive only downgrades to `no-store` until the next regeneration. ## Front (twenty-front) - New metadata query `GetApplicationSdkClientChecksums`, keyed by `applicationId`; removed the `sdkClientChecksums` selection from `FindOneFrontComponent`. - `getSdkClientUrls` builds the two module URLs independently: `/sdk-client/{applicationId}/core/{checksum}` and the **instance-wide** `/sdk-client/metadata/{checksum}` (no application segment → one shared browser cache entry per release across all applications). Each falls back to its bare URL when its checksum is absent — since `core` is nullable, a never-generated app still gets a content-addressed metadata URL and only `core` falls back. The checksum type is sourced from the codegen `SdkClientChecksums` type rather than a hand-maintained duplicate. - `FrontComponentRenderer` is split into a gating outer component (runs `FindOneFrontComponent`, renders nothing while loading) and a content component that receives a guaranteed-non-null `frontComponent`. Following project conventions, the side effects live in dedicated effect components: `FrontComponentLoadErrorSnackBarEffect` (query error → snackbar) and `FrontComponentApplicationTokenPairEffect` (mirrors the query-derived token pair into component state unconditionally, `null` included, so revoked credentials can never be retained or refreshed). The content component fetches checksums via the application-keyed query and **gates the mount of SDK-using components on that query**, so the very first module fetch is always the content-addressed (`immutable`) URL instead of the bare `no-store` one. Non-SDK components skip the query and are never blocked. - **Live invalidation without reload:** SDK regeneration updates the application row, and the server broadcasts an `application` metadata event carrying the new core checksum. `useOnApplicationSdkClientChecksumsUpdated` / `useUpdateSdkClientChecksumsApolloCache` patch the application-keyed checksum query cache (core only; the instance-wide metadata is preserved), so every mounted component of that application picks up the new URL at once. This replaces the previous frontComponent-derived field and closes the earlier "known gap" (a mounted component staying on a session-old checksum until a full reload). The cache-patching callback is memoized (`useCallback`) so the window listener is registered once per application, and the listener is **skipped entirely** for non-SDK components (`useListenToMetadataOperationBrowserEvent` gained a `skip` option) — they register no listener and never refetch a query they don't consume. ## Renderer (twenty-front-component-renderer) - SDK sources are fetched through a dedicated plain authenticated fetch, `fetchJavaScriptModuleSourceText` (Bearer header, `credentials: 'omit'`), instead of the Cache Storage `fetchComponentSource` path; `fetchSdkClientSources` uses it. Execution stays exclusively in the opaque-origin worker via blob URLs; the host only fetches and forwards source strings (no hashing host-side). Staleness self-resolves through the checksum: new checksum → new URL → cache miss. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22981?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
||||
import { frontComponentApplicationTokenPairComponentState } from '@/front-components/states/frontComponentApplicationTokenPairComponentState';
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { useEffect } from 'react';
|
||||
import { type ApplicationTokenPair } from '~/generated-metadata/graphql';
|
||||
|
||||
type FrontComponentApplicationTokenPairEffectProps = {
|
||||
frontComponentId: string;
|
||||
applicationTokenPair: ApplicationTokenPair | null;
|
||||
};
|
||||
|
||||
export const FrontComponentApplicationTokenPairEffect = ({
|
||||
frontComponentId,
|
||||
applicationTokenPair,
|
||||
}: FrontComponentApplicationTokenPairEffectProps) => {
|
||||
const setFrontComponentApplicationTokenPair = useSetAtomComponentState(
|
||||
frontComponentApplicationTokenPairComponentState,
|
||||
frontComponentId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFrontComponentApplicationTokenPair(applicationTokenPair);
|
||||
}, [applicationTokenPair, setFrontComponentApplicationTokenPair]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type FrontComponentLoadErrorSnackBarEffectProps = {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export const FrontComponentLoadErrorSnackBarEffect = ({
|
||||
errorMessage,
|
||||
}: FrontComponentLoadErrorSnackBarEffectProps) => {
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(errorMessage)) {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to load front component: ${errorMessage}`,
|
||||
});
|
||||
}
|
||||
}, [errorMessage, enqueueErrorSnackBar]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+97
-66
@@ -1,20 +1,25 @@
|
||||
import { FrontComponentApplicationTokenPairEffect } from '@/front-components/components/FrontComponentApplicationTokenPairEffect';
|
||||
import { FrontComponentLoadErrorSnackBarEffect } from '@/front-components/components/FrontComponentLoadErrorSnackBarEffect';
|
||||
import { FrontComponentRendererProvider } from '@/front-components/components/FrontComponentRendererProvider';
|
||||
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
|
||||
import { useOnApplicationSdkClientChecksumsUpdated } from '@/front-components/hooks/useOnApplicationSdkClientChecksumsUpdated';
|
||||
import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated';
|
||||
import { getFrontComponentUrl } from '@/front-components/utils/getFrontComponentUrl';
|
||||
import { getSdkClientUrls } from '@/front-components/utils/getSdkClientUrls';
|
||||
import { useGetLogicFunctionHttpUrl } from '@/settings/logic-functions/hooks/useGetLogicFunctionHttpUrl';
|
||||
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
|
||||
import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated';
|
||||
import { frontComponentApplicationTokenPairComponentState } from '@/front-components/states/frontComponentApplicationTokenPairComponentState';
|
||||
import { getFrontComponentUrl } from '@/front-components/utils/getFrontComponentUrl';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useCallback, useContext, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { FrontComponentRenderer as SharedFrontComponentRenderer } from 'twenty-front-component-renderer';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { FindOneFrontComponentDocument } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
FindOneFrontComponentDocument,
|
||||
type FindOneFrontComponentQuery,
|
||||
GetApplicationSdkClientChecksumsDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type FrontComponentRendererProps = {
|
||||
frontComponentId: string;
|
||||
@@ -22,20 +27,55 @@ type FrontComponentRendererProps = {
|
||||
selectedRecordIds?: string[];
|
||||
};
|
||||
|
||||
type ResolvedFrontComponent = NonNullable<
|
||||
FindOneFrontComponentQuery['frontComponent']
|
||||
>;
|
||||
|
||||
type FrontComponentRendererContentProps = {
|
||||
frontComponent: ResolvedFrontComponent;
|
||||
commandMenuItemId?: string;
|
||||
selectedRecordIds?: string[];
|
||||
};
|
||||
|
||||
export const FrontComponentRenderer = ({
|
||||
frontComponentId,
|
||||
commandMenuItemId,
|
||||
selectedRecordIds,
|
||||
}: FrontComponentRendererProps) => {
|
||||
const { data, loading, error } = useQuery(FindOneFrontComponentDocument, {
|
||||
variables: { id: frontComponentId },
|
||||
});
|
||||
|
||||
useOnFrontComponentUpdated({
|
||||
frontComponentId,
|
||||
});
|
||||
|
||||
const frontComponent = data?.frontComponent;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FrontComponentLoadErrorSnackBarEffect errorMessage={error?.message} />
|
||||
{!loading && isDefined(frontComponent) && (
|
||||
<FrontComponentRendererContent
|
||||
frontComponent={frontComponent}
|
||||
commandMenuItemId={commandMenuItemId}
|
||||
selectedRecordIds={selectedRecordIds}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FrontComponentRendererContent = ({
|
||||
frontComponent,
|
||||
commandMenuItemId,
|
||||
selectedRecordIds,
|
||||
}: FrontComponentRendererContentProps) => {
|
||||
const { colorScheme } = useContext(ThemeContext);
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const { functionsBaseUrl } = useGetLogicFunctionHttpUrl();
|
||||
|
||||
const setFrontComponentApplicationTokenPair = useSetAtomComponentState(
|
||||
frontComponentApplicationTokenPairComponentState,
|
||||
frontComponentId,
|
||||
);
|
||||
const { id: frontComponentId, applicationId, usesSdkClient } = frontComponent;
|
||||
|
||||
const { executionContext, frontComponentHostCommunicationApi } =
|
||||
useFrontComponentExecutionContext({
|
||||
@@ -51,78 +91,69 @@ export const FrontComponentRenderer = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = error.message;
|
||||
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to load front component: ${errorMessage}`,
|
||||
message: t`Failed to load front component: ${error.message}`,
|
||||
});
|
||||
},
|
||||
[enqueueErrorSnackBar],
|
||||
);
|
||||
|
||||
const { data, loading, error } = useQuery(FindOneFrontComponentDocument, {
|
||||
variables: { id: frontComponentId },
|
||||
const applicationTokenPair = frontComponent.applicationTokenPair ?? null;
|
||||
|
||||
const { data: sdkClientChecksumsData, loading: sdkClientChecksumsLoading } =
|
||||
useQuery(GetApplicationSdkClientChecksumsDocument, {
|
||||
variables: { applicationId },
|
||||
skip: !usesSdkClient,
|
||||
});
|
||||
|
||||
useOnApplicationSdkClientChecksumsUpdated({
|
||||
applicationId,
|
||||
skip: !usesSdkClient,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
handleError(error);
|
||||
}
|
||||
}, [error, handleError]);
|
||||
|
||||
const applicationTokenPair =
|
||||
data?.frontComponent?.applicationTokenPair ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isDefined(applicationTokenPair)) {
|
||||
setFrontComponentApplicationTokenPair(applicationTokenPair);
|
||||
}
|
||||
}, [applicationTokenPair, setFrontComponentApplicationTokenPair]);
|
||||
|
||||
useOnFrontComponentUpdated({
|
||||
frontComponentId,
|
||||
});
|
||||
|
||||
const applicationId = data?.frontComponent?.applicationId;
|
||||
const sdkClientChecksums =
|
||||
sdkClientChecksumsData?.applicationSdkClientChecksums;
|
||||
|
||||
const sdkClientUrls = useMemo(
|
||||
() =>
|
||||
isDefined(applicationId) ? getSdkClientUrls(applicationId) : undefined,
|
||||
[applicationId],
|
||||
() => getSdkClientUrls(applicationId, sdkClientChecksums),
|
||||
[applicationId, sdkClientChecksums],
|
||||
);
|
||||
|
||||
if (
|
||||
loading ||
|
||||
!isDefined(data?.frontComponent) ||
|
||||
!isDefined(applicationTokenPair)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const componentUrl = getFrontComponentUrl({
|
||||
frontComponentId,
|
||||
checksum: data.frontComponent.builtComponentChecksum,
|
||||
checksum: frontComponent.builtComponentChecksum,
|
||||
});
|
||||
|
||||
const accessToken = applicationTokenPair.applicationAccessToken.token;
|
||||
const applicationVariables = frontComponent.applicationVariables ?? undefined;
|
||||
|
||||
const applicationVariables =
|
||||
data.frontComponent.applicationVariables ?? undefined;
|
||||
const isSdkClientReady = !usesSdkClient || !sdkClientChecksumsLoading;
|
||||
|
||||
return (
|
||||
<FrontComponentRendererProvider frontComponentId={frontComponentId}>
|
||||
<SharedFrontComponentRenderer
|
||||
colorScheme={colorScheme}
|
||||
componentUrl={componentUrl}
|
||||
applicationAccessToken={accessToken}
|
||||
apiUrl={REACT_APP_SERVER_BASE_URL}
|
||||
functionsBaseUrl={functionsBaseUrl}
|
||||
sdkClientUrls={sdkClientUrls}
|
||||
executionContext={executionContext}
|
||||
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
|
||||
applicationVariables={applicationVariables}
|
||||
onError={handleError}
|
||||
<>
|
||||
<FrontComponentApplicationTokenPairEffect
|
||||
frontComponentId={frontComponentId}
|
||||
applicationTokenPair={applicationTokenPair}
|
||||
/>
|
||||
</FrontComponentRendererProvider>
|
||||
{isDefined(applicationTokenPair) && isSdkClientReady && (
|
||||
<FrontComponentRendererProvider frontComponentId={frontComponentId}>
|
||||
<SharedFrontComponentRenderer
|
||||
colorScheme={colorScheme}
|
||||
componentUrl={componentUrl}
|
||||
applicationAccessToken={
|
||||
applicationTokenPair.applicationAccessToken.token
|
||||
}
|
||||
apiUrl={REACT_APP_SERVER_BASE_URL}
|
||||
functionsBaseUrl={functionsBaseUrl}
|
||||
sdkClientUrls={sdkClientUrls}
|
||||
executionContext={executionContext}
|
||||
frontComponentHostCommunicationApi={
|
||||
frontComponentHostCommunicationApi
|
||||
}
|
||||
applicationVariables={applicationVariables}
|
||||
onError={handleError}
|
||||
/>
|
||||
</FrontComponentRendererProvider>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const GET_APPLICATION_SDK_CLIENT_CHECKSUMS = gql`
|
||||
query GetApplicationSdkClientChecksums($applicationId: UUID!) {
|
||||
applicationSdkClientChecksums(applicationId: $applicationId) {
|
||||
core
|
||||
metadata
|
||||
}
|
||||
}
|
||||
`;
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
|
||||
import { useUpdateSdkClientChecksumsApolloCache } from '@/front-components/hooks/useUpdateSdkClientChecksumsApolloCache';
|
||||
import { type ApplicationSdkClientChecksumsBroadcastRecord } from '@/front-components/types/ApplicationSdkClientChecksumsBroadcastRecord';
|
||||
|
||||
const mockReadQuery = jest.fn();
|
||||
const mockUpdateQuery = jest.fn();
|
||||
const mockQuery = jest.fn().mockResolvedValue({ data: undefined });
|
||||
const mockApolloClient = {
|
||||
cache: { readQuery: mockReadQuery, updateQuery: mockUpdateQuery },
|
||||
query: mockQuery,
|
||||
};
|
||||
|
||||
jest.mock('@apollo/client/react', () => ({
|
||||
...jest.requireActual('@apollo/client/react'),
|
||||
useApolloClient: () => mockApolloClient,
|
||||
}));
|
||||
|
||||
const APPLICATION_ID = 'app-test-id';
|
||||
|
||||
const CACHED_CHECKSUM_PAIR = {
|
||||
applicationSdkClientChecksums: {
|
||||
__typename: 'SdkClientChecksums' as const,
|
||||
core: 'old-core-checksum',
|
||||
metadata: 'old-metadata-checksum',
|
||||
},
|
||||
};
|
||||
|
||||
const buildApplicationRecord = (
|
||||
overrides: Partial<ApplicationSdkClientChecksumsBroadcastRecord> = {},
|
||||
): ApplicationSdkClientChecksumsBroadcastRecord => ({
|
||||
id: APPLICATION_ID,
|
||||
sdkClientCoreChecksum: 'a'.repeat(64),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildUpdateDetail = (
|
||||
updatedRecord: ApplicationSdkClientChecksumsBroadcastRecord,
|
||||
): MetadataOperationBrowserEventDetail<ApplicationSdkClientChecksumsBroadcastRecord> => ({
|
||||
metadataName: 'application',
|
||||
operation: {
|
||||
type: 'update',
|
||||
updatedRecord,
|
||||
},
|
||||
});
|
||||
|
||||
describe('useUpdateSdkClientChecksumsApolloCache', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockReadQuery.mockReturnValue(CACHED_CHECKSUM_PAIR);
|
||||
});
|
||||
|
||||
it('should call cache.updateQuery when the application id matches', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUpdateSdkClientChecksumsApolloCache({
|
||||
applicationId: APPLICATION_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.updateSdkClientChecksumsApolloCache(
|
||||
buildUpdateDetail(buildApplicationRecord()),
|
||||
);
|
||||
|
||||
expect(mockUpdateQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call cache.updateQuery when the application id does not match', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUpdateSdkClientChecksumsApolloCache({
|
||||
applicationId: APPLICATION_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.updateSdkClientChecksumsApolloCache(
|
||||
buildUpdateDetail(buildApplicationRecord({ id: 'other-app-id' })),
|
||||
);
|
||||
|
||||
expect(mockUpdateQuery).not.toHaveBeenCalled();
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call cache.updateQuery when applicationId is undefined', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUpdateSdkClientChecksumsApolloCache({}),
|
||||
);
|
||||
|
||||
result.current.updateSdkClientChecksumsApolloCache(
|
||||
buildUpdateDetail(buildApplicationRecord()),
|
||||
);
|
||||
|
||||
expect(mockUpdateQuery).not.toHaveBeenCalled();
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call cache.updateQuery when checksums are missing', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUpdateSdkClientChecksumsApolloCache({
|
||||
applicationId: APPLICATION_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.updateSdkClientChecksumsApolloCache(
|
||||
buildUpdateDetail(
|
||||
buildApplicationRecord({ sdkClientCoreChecksum: null }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(mockUpdateQuery).not.toHaveBeenCalled();
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call cache.updateQuery for create operations', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUpdateSdkClientChecksumsApolloCache({
|
||||
applicationId: APPLICATION_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.updateSdkClientChecksumsApolloCache({
|
||||
metadataName: 'application',
|
||||
operation: {
|
||||
type: 'create',
|
||||
createdRecord: buildApplicationRecord(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockUpdateQuery).not.toHaveBeenCalled();
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update only the core checksum and preserve the cached metadata checksum', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useUpdateSdkClientChecksumsApolloCache({
|
||||
applicationId: APPLICATION_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.updateSdkClientChecksumsApolloCache(
|
||||
buildUpdateDetail(buildApplicationRecord()),
|
||||
);
|
||||
|
||||
const updaterFn = mockUpdateQuery.mock.calls[0][1];
|
||||
|
||||
const updatedData = updaterFn(CACHED_CHECKSUM_PAIR);
|
||||
|
||||
expect(updatedData.applicationSdkClientChecksums).toEqual({
|
||||
__typename: 'SdkClientChecksums',
|
||||
core: 'a'.repeat(64),
|
||||
metadata: 'old-metadata-checksum',
|
||||
});
|
||||
});
|
||||
|
||||
it('should refetch the checksum query when no checksum pair is cached yet', () => {
|
||||
mockReadQuery.mockReturnValue({ applicationSdkClientChecksums: null });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useUpdateSdkClientChecksumsApolloCache({
|
||||
applicationId: APPLICATION_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.updateSdkClientChecksumsApolloCache(
|
||||
buildUpdateDetail(buildApplicationRecord()),
|
||||
);
|
||||
|
||||
expect(mockUpdateQuery).not.toHaveBeenCalled();
|
||||
expect(mockQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: { applicationId: APPLICATION_ID },
|
||||
fetchPolicy: 'network-only',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should refetch the checksum query when the query result is absent from the cache', () => {
|
||||
mockReadQuery.mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useUpdateSdkClientChecksumsApolloCache({
|
||||
applicationId: APPLICATION_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.updateSdkClientChecksumsApolloCache(
|
||||
buildUpdateDetail(buildApplicationRecord()),
|
||||
);
|
||||
|
||||
expect(mockUpdateQuery).not.toHaveBeenCalled();
|
||||
expect(mockQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
|
||||
import { useUpdateSdkClientChecksumsApolloCache } from '@/front-components/hooks/useUpdateSdkClientChecksumsApolloCache';
|
||||
import { type ApplicationSdkClientChecksumsBroadcastRecord } from '@/front-components/types/ApplicationSdkClientChecksumsBroadcastRecord';
|
||||
|
||||
type UseOnApplicationSdkClientChecksumsUpdatedArgs = {
|
||||
applicationId?: string;
|
||||
skip?: boolean;
|
||||
};
|
||||
|
||||
export const useOnApplicationSdkClientChecksumsUpdated = ({
|
||||
applicationId,
|
||||
skip = false,
|
||||
}: UseOnApplicationSdkClientChecksumsUpdatedArgs) => {
|
||||
const { updateSdkClientChecksumsApolloCache } =
|
||||
useUpdateSdkClientChecksumsApolloCache({
|
||||
applicationId,
|
||||
});
|
||||
|
||||
useListenToMetadataOperationBrowserEvent<ApplicationSdkClientChecksumsBroadcastRecord>(
|
||||
{
|
||||
metadataName: 'application',
|
||||
onMetadataOperationBrowserEvent: updateSdkClientChecksumsApolloCache,
|
||||
skip,
|
||||
},
|
||||
);
|
||||
};
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
|
||||
import { type ApplicationSdkClientChecksumsBroadcastRecord } from '@/front-components/types/ApplicationSdkClientChecksumsBroadcastRecord';
|
||||
import { useApolloClient } from '@apollo/client/react';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
GetApplicationSdkClientChecksumsDocument,
|
||||
type GetApplicationSdkClientChecksumsQuery,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type UseUpdateSdkClientChecksumsApolloCacheArgs = {
|
||||
applicationId?: string;
|
||||
};
|
||||
|
||||
export const useUpdateSdkClientChecksumsApolloCache = ({
|
||||
applicationId,
|
||||
}: UseUpdateSdkClientChecksumsApolloCacheArgs) => {
|
||||
const apolloClient = useApolloClient();
|
||||
|
||||
const updateSdkClientChecksumsApolloCache = useCallback(
|
||||
(
|
||||
detail: MetadataOperationBrowserEventDetail<ApplicationSdkClientChecksumsBroadcastRecord>,
|
||||
) => {
|
||||
if (detail.operation.type !== 'update') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { updatedRecord } = detail.operation;
|
||||
|
||||
if (!isDefined(applicationId) || updatedRecord.id !== applicationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sdkClientCoreChecksum } = updatedRecord;
|
||||
|
||||
if (!isDefined(sdkClientCoreChecksum)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedData =
|
||||
apolloClient.cache.readQuery<GetApplicationSdkClientChecksumsQuery>({
|
||||
query: GetApplicationSdkClientChecksumsDocument,
|
||||
variables: { applicationId },
|
||||
});
|
||||
|
||||
const existingChecksums = cachedData?.applicationSdkClientChecksums;
|
||||
|
||||
if (!isDefined(existingChecksums)) {
|
||||
void apolloClient.query({
|
||||
query: GetApplicationSdkClientChecksumsDocument,
|
||||
variables: { applicationId },
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
apolloClient.cache.updateQuery<GetApplicationSdkClientChecksumsQuery>(
|
||||
{
|
||||
query: GetApplicationSdkClientChecksumsDocument,
|
||||
variables: { applicationId },
|
||||
},
|
||||
(existingData) => ({
|
||||
...existingData,
|
||||
applicationSdkClientChecksums: {
|
||||
...existingChecksums,
|
||||
core: sdkClientCoreChecksum,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[apolloClient, applicationId],
|
||||
);
|
||||
|
||||
return { updateSdkClientChecksumsApolloCache };
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type ApplicationSdkClientChecksumsBroadcastRecord = {
|
||||
id: string;
|
||||
sdkClientCoreChecksum?: string | null;
|
||||
};
|
||||
+26
-2
@@ -2,10 +2,34 @@ import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { getSdkClientUrls } from '@/front-components/utils/getSdkClientUrls';
|
||||
|
||||
describe('getSdkClientUrls', () => {
|
||||
it('builds application-scoped core and metadata sdk client urls', () => {
|
||||
it('builds bare urls when checksums are absent', () => {
|
||||
expect(getSdkClientUrls('application-id')).toEqual({
|
||||
core: `${REST_API_BASE_URL}/sdk-client/application-id/core`,
|
||||
metadata: `${REST_API_BASE_URL}/sdk-client/application-id/metadata`,
|
||||
metadata: `${REST_API_BASE_URL}/sdk-client/metadata`,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds content-addressed urls when checksums are provided', () => {
|
||||
expect(
|
||||
getSdkClientUrls('application-id', {
|
||||
core: 'core-checksum',
|
||||
metadata: 'metadata-checksum',
|
||||
}),
|
||||
).toEqual({
|
||||
core: `${REST_API_BASE_URL}/sdk-client/application-id/core/core-checksum`,
|
||||
metadata: `${REST_API_BASE_URL}/sdk-client/metadata/metadata-checksum`,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the bare core url when only the metadata checksum is available', () => {
|
||||
expect(
|
||||
getSdkClientUrls('application-id', {
|
||||
core: null,
|
||||
metadata: 'metadata-checksum',
|
||||
}),
|
||||
).toEqual({
|
||||
core: `${REST_API_BASE_URL}/sdk-client/application-id/core`,
|
||||
metadata: `${REST_API_BASE_URL}/sdk-client/metadata/metadata-checksum`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type SdkClientChecksums } from '~/generated-metadata/graphql';
|
||||
|
||||
export const getSdkClientUrls = (applicationId: string) => ({
|
||||
core: `${REST_API_BASE_URL}/sdk-client/${applicationId}/core`,
|
||||
metadata: `${REST_API_BASE_URL}/sdk-client/${applicationId}/metadata`,
|
||||
});
|
||||
export const getSdkClientUrls = (
|
||||
applicationId: string,
|
||||
checksums?: Pick<SdkClientChecksums, 'core' | 'metadata'> | null,
|
||||
) => {
|
||||
const applicationBaseUrl = `${REST_API_BASE_URL}/sdk-client/${applicationId}`;
|
||||
const metadataBaseUrl = `${REST_API_BASE_URL}/sdk-client/metadata`;
|
||||
|
||||
const coreChecksum = checksums?.core;
|
||||
const metadataChecksum = checksums?.metadata;
|
||||
|
||||
return {
|
||||
core: isDefined(coreChecksum)
|
||||
? `${applicationBaseUrl}/core/${coreChecksum}`
|
||||
: `${applicationBaseUrl}/core`,
|
||||
metadata: isDefined(metadataChecksum)
|
||||
? `${metadataBaseUrl}/${metadataChecksum}`
|
||||
: metadataBaseUrl,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user