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:
Paul Rastoin
2026-07-21 17:05:00 +02:00
committed by GitHub
parent 8f704e87e3
commit 71a1ff7ac8
58 changed files with 2137 additions and 1048 deletions
@@ -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;
};
@@ -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;
};
@@ -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>
)}
</>
);
};
@@ -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
}
}
`;
@@ -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);
});
});
@@ -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,
},
);
};
@@ -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 };
};
@@ -0,0 +1,4 @@
export type ApplicationSdkClientChecksumsBroadcastRecord = {
id: string;
sdkClientCoreChecksum?: string | null;
};
@@ -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,
};
};