Replaced IDX cache actions / triggers with composeDB ones

This commit is contained in:
Alec LaLonde
2023-03-23 20:36:23 -06:00
parent 9d2bc4f249
commit 631174bc20
27 changed files with 325 additions and 436 deletions

View File

@@ -51,12 +51,7 @@ type Mutation {
type Mutation {
updateExpiredIDXProfiles : ExpiredPlayerProfiles
}
type Mutation {
updateIDXProfile (
updateCachedProfile (
playerId: uuid
): CacheProcessOutput
}

View File

@@ -50,18 +50,11 @@ actions:
definition:
kind: synchronous
handler: '{{ACTION_BASE_ENDPOINT}}/syncSourceCredAccounts'
- name: updateExpiredIDXProfiles
definition:
kind: synchronous
handler: '{{ACTION_BASE_ENDPOINT}}/idxCache/checkExpired'
- name: updateIDXProfile
- name: updateCachedProfile
definition:
kind: asynchronous
handler: '{{ACTION_BASE_ENDPOINT}}/idxCache/updateSingle'
handler: http://host.do{{ACTION_BASE_ENDPOINT}}/composeDB/updateCachedProfile.internal:3000
forward_client_headers: true
permissions:
- role: player
- role: public
- name: updateQuestCompletion
definition:
kind: synchronous

View File

@@ -1,15 +1,3 @@
- name: queueExpiredIDXCacheEntries
webhook: '{{ACTION_BASE_ENDPOINT}}/idxCache/checkExpired'
schedule: 0 3 * * *
include_in_metadata: true
payload: {}
retry_conf:
num_retries: 0
timeout_seconds: 600
tolerance_seconds: 21600
retry_interval_seconds: 10
comment: Checks for cache entries more than four days old and queues them to be
recached.
- name: syncAllGuildDiscordMembers
webhook: '{{ACTION_BASE_ENDPOINT}}/syncAllGuildDiscordMembers'
schedule: 31 5 * * *
@@ -26,3 +14,10 @@
tolerance_seconds: 21600
retry_interval_seconds: 10
comment: Reads account data from XP Github and upserts users into the Database
- name: refreshPlayersFromComposeDB
webhook: '{{ACTION_BASE_ENDPOINT}}/composeDB/refreshPlayers'
schedule: 0 3 * * *
include_in_metadata: true
payload: {}
comment: Finds any cached player profiles more than four days old and queues them
to be recached from ComposeDB.

View File

@@ -611,13 +611,14 @@
set:
id: x-hasura-User-Id
event_triggers:
- name: cacheIDXProfile
- name: cacheComposeDBProfile
definition:
enable_manual: true
insert:
columns: '*'
update:
columns:
- ceramic_profile_id
- ethereum_address
retry_conf:
num_retries: 0

View File

@@ -1,8 +0,0 @@
import express from 'express';
import { asyncHandlerWrapper } from '../../../lib/apiHelpers.js';
import linkProfileNode from './linkProfileNode.js';
export const ceramicRoutes = express.Router();
ceramicRoutes.post('/linkProfileNode', asyncHandlerWrapper(linkProfileNode));

View File

@@ -1,7 +1,7 @@
import { Maybe } from '@metafam/utils';
import Bottleneck from 'bottleneck';
import updateCachedProfile from '../handlers/actions/idxCache/updateSingle.js';
import { updatePlayerFromComposeDB } from './updatePlayerFromComposeDB.js';
let count = 0;
@@ -33,7 +33,7 @@ export const queueRecache = async ({
const preRun = count++;
const result = await limiter.schedule({ id: playerId, ...opts }, () =>
updateCachedProfile(playerId),
updatePlayerFromComposeDB(playerId),
);
console.debug({
msg: 'Completed Profile Update',

View File

@@ -2,12 +2,12 @@ import { ComposeClient } from '@composedb/client';
import { composeDBDefinition } from '@metafam/utils';
import { Request, Response } from 'express';
import { CONFIG } from '../../../config.js';
import { CONFIG } from '../../../../config.js';
import {
LinkCeramicProfileNodeResponse,
Mutation_RootLinkCeramicProfileNodeArgs,
} from '../../../lib/autogen/hasura-sdk.js';
import { client } from '../../../lib/hasuraClient.js';
} from '../../../../lib/autogen/hasura-sdk.js';
import { client } from '../../../../lib/hasuraClient.js';
export default async (req: Request, res: Response): Promise<void> => {
const { input, session_variables: sessionVariables } = req.body;
@@ -52,7 +52,6 @@ export default async (req: Request, res: Response): Promise<void> => {
controllerEthAddress.toLowerCase() === ethereumAddress.toLowerCase()
) {
// We confirmed they indeed control this model, so persist it as theirs
await client.UpdatePlayerById({
playerId,
input: { ceramicProfileId: nodeId },

View File

@@ -0,0 +1,13 @@
import express from 'express';
import { asyncHandlerWrapper } from '../../../lib/apiHelpers.js';
import linkProfileNode from './linkProfileNode/handler.js';
import updateSingleProfileHandler from './updateSingleProfile/handler.js';
export const routes = express.Router();
routes.post('/linkProfileNode', asyncHandlerWrapper(linkProfileNode));
routes.post(
'/updateCachedProfile',
asyncHandlerWrapper(updateSingleProfileHandler),
);

View File

@@ -0,0 +1,154 @@
import { Caip10Link } from '@ceramicnetwork/stream-caip10-link';
import { ComposeClient } from '@composedb/client';
import {
composeDBDefinition,
ComposeDBProfile,
composeDBToHasuraProfile,
} from '@metafam/utils';
import { CONFIG } from '../../../config.js';
import {
Profile_Update_Column,
UpdateIdxProfileResponse,
} from '../../../lib/autogen/hasura-sdk.js';
import { client } from '../../../lib/hasuraClient.js';
const composeDBClient = new ComposeClient({
ceramic: CONFIG.ceramicURL,
definition: composeDBDefinition,
});
export const updatePlayerFromComposeDB = async (
playerId: string,
): Promise<UpdateIdxProfileResponse> => {
const accountLinks: string[] = [];
const fields: string[] = [];
const { player_by_pk: player } = await client.GetPlayer({ playerId });
if (!player) {
throw new Error(`Unknown Player: "${playerId}"`);
} else {
console.debug(`Updating Profile Cache For ${player.ethereumAddress}`);
}
const { ethereumAddress, ceramicProfileId } = player;
if (ceramicProfileId == null) {
// they are not yet in ComposeDB; ignore
return {
success: false,
ceramic: CONFIG.ceramicURL,
};
}
const modelInstanceDoc = await composeDBClient.context.loadDoc(
ceramicProfileId,
);
if (modelInstanceDoc == null) {
// their profile document wasn't found, now what? Why would this happen?
// should we dereference the ceramicProfileId from their player record?
return {
success: false,
ceramic: CONFIG.ceramicURL,
};
}
const values = composeDBToHasuraProfile(
modelInstanceDoc.content as ComposeDBProfile,
);
let did = null;
try {
({ did } = await Caip10Link.fromAccount(
composeDBClient.context.ceramic,
// mainnet; the site prompts them to switch if necessary
`${ethereumAddress.toLowerCase()}@eip155:1`,
));
try {
fields.push(...Object.keys(values));
values.playerId = playerId;
await client.UpsertProfile({
objects: [values],
updateColumns: fields as Profile_Update_Column[],
});
} catch (err) {
if (
(err as Error).message.includes(
'violates unique constraint "profile_username_key"',
)
) {
// this is brittle and likely subject to exploit
values.username = `${values.username}-${(did ?? ethereumAddress).slice(
-8,
)}`;
await client.UpsertProfile({
objects: [values],
updateColumns: fields as Profile_Update_Column[],
});
} else {
throw err;
}
}
// if (did) {
// const alsoKnownAs = ((await store.get('alsoKnownAs', did)) ??
// {}) as AlsoKnownAs;
// const { accounts = [] } = alsoKnownAs;
// await Promise.all(
// accounts?.map(async ({ host, id: username }: Account) => {
// const service = host
// ?.replace(/\.com$/, '')
// .toUpperCase() as AccountType_Enum;
// if (!service) {
// console.error(`No hostname for AlsoKnownAs: "${host}"`);
// } else {
// // If the account has been registered previously, this will
// // destructively assign it to the current user removing any
// // other users.
// //
// // ToDo: Examine the JWT to validate that it came from a
// // trusted source. Specifically, either the IdentityLink
// // service backing //self.id or one established by MetaGame
// const { insert_player_account: insert } =
// await client.UpsertAccount({
// objects: [
// {
// playerId,
// type: service,
// identifier: username,
// },
// ],
// });
// if (insert?.affected_rows === undefined) {
// // eslint-disable-next-line no-console
// console.warn(
// `Unable to insert ${service} user ${username} for playerId ${playerId}.`,
// );
// } else if (insert.affected_rows > 0) {
// accountLinks.push(service);
// }
// }
// }),
// );
// }
} catch (err) {
if (!(err as Error).message.includes('No DID')) {
throw err;
}
}
const ret = {
success: true,
ceramic: CONFIG.ceramicURL,
did,
ethereumAddress,
accountLinks,
fields,
};
return ret;
};

View File

@@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import { queueRecache } from '../../../../lib/cacheHelper.js';
import { queueRecache } from '../cacheHelper.js';
export default async (req: Request, res: Response): Promise<void> => {
const role = req.body.session_variables['x-hasura-role'];

View File

@@ -1,17 +0,0 @@
import express from 'express';
import { asyncHandlerWrapper } from '../../../lib/apiHelpers.js';
import updateExpiredProfilesHandler from './updateExpiredProfiles/handler.js';
import updateSingleProfileHandler from './updateSingleProfile/handler.js';
export const cacheRoutes = express.Router();
cacheRoutes.post(
'/updateSingle',
asyncHandlerWrapper(updateSingleProfileHandler),
);
cacheRoutes.post(
'/checkExpired',
asyncHandlerWrapper(updateExpiredProfilesHandler),
);

View File

@@ -1,29 +0,0 @@
import { Request, Response } from 'express';
import { queueRecache } from '../../../../lib/cacheHelper.js';
import { client } from '../../../../lib/hasuraClient.js';
const INVALIDATE_AFTER_DAYS = 4; // number of days after which to recache
export default async (req: Request, res: Response): Promise<void> => {
const { limiter } = req.app.locals;
const expiration = new Date();
const invalidateAfterDays =
req.query.invalidate_after_days != null
? parseInt(req.query.invalidate_after_days as string, 10)
: INVALIDATE_AFTER_DAYS;
expiration.setDate(expiration.getDate() - invalidateAfterDays);
const { profile: players } = await client.GetCacheEntries({
updatedBefore: expiration,
});
const ids = (
await Promise.all(
players.map(async ({ playerId }) => {
const queued = await queueRecache({ playerId, limiter });
return queued ? playerId : null;
}),
)
).filter((id) => !!id);
res.json({ ids });
};

View File

@@ -1,253 +0,0 @@
import type { CeramicApi } from '@ceramicnetwork/common';
import { CeramicClient } from '@ceramicnetwork/http-client';
import { Caip10Link } from '@ceramicnetwork/stream-caip10-link';
import {
Account,
AlsoKnownAs,
model as alsoKnownAsModel,
} from '@datamodels/identity-accounts-web';
import {
BasicProfile,
ImageSources,
model as basicProfileModel,
} from '@datamodels/identity-profile-basic';
import { DataModel } from '@glazed/datamodel';
import { DIDDataStore } from '@glazed/did-datastore';
import {
BasicProfileImages,
BasicProfileStrings,
ExtendedProfile,
ExtendedProfileImages,
extendedProfileModel,
ExtendedProfileObjects,
ExtendedProfileStrings,
HasuraProfileProps,
maskFor,
simplifyAliases,
Values,
} from '@metafam/utils';
import { getLegacy3BoxProfileAsBasicProfile } from '@self.id/3box-legacy';
import { CONFIG } from '../../../config.js';
import {
AccountType_Enum,
Maybe,
Profile_Update_Column,
UpdateIdxProfileResponse,
} from '../../../lib/autogen/hasura-sdk.js';
import { client } from '../../../lib/hasuraClient.js';
import { handleMeetWithWalletIntegration } from '../meetwithwallet/handler.js';
export default async (playerId: string): Promise<UpdateIdxProfileResponse> => {
const accountLinks: string[] = [];
const fields: string[] = [];
const { player_by_pk: player } = await client.GetPlayer({ playerId });
const { ethereumAddress } = player ?? {};
let did = null;
if (!ethereumAddress) {
throw new Error(`Unknown Player: "${playerId}"`);
} else {
console.debug(`Updating Profile Cache For ${ethereumAddress}`);
}
const aliases = simplifyAliases([
basicProfileModel,
extendedProfileModel,
alsoKnownAsModel,
]);
try {
const ceramic = new CeramicClient(CONFIG.ceramicURL) as CeramicApi;
const model = new DataModel({ ceramic, aliases });
const store = new DIDDataStore({ ceramic, model });
({ did } = await Caip10Link.fromAccount(
ceramic,
// mainnet; the site prompts them to switch if necessary
`${ethereumAddress.toLowerCase()}@eip155:1`,
));
const values: HasuraProfileProps = {};
let basicProfile: Maybe<BasicProfile> = null;
let extendedProfile: Maybe<ExtendedProfile> = null;
if (!did) {
console.debug(`No CAIP-10 Link For ${ethereumAddress}`);
} else {
basicProfile = await store.get('basicProfile', did);
}
if (!basicProfile) {
basicProfile = await getLegacy3BoxProfileAsBasicProfile(ethereumAddress);
}
if (!basicProfile) {
console.debug(`No Basic Profile For: ${ethereumAddress} (${did})`);
} else {
Object.entries(BasicProfileStrings).forEach(([hasuraId, ceramicId]) => {
const fromKey = ceramicId as Values<typeof BasicProfileStrings>;
const toKey = hasuraId as keyof typeof BasicProfileStrings;
if (basicProfile?.[fromKey] != null) {
values[toKey] = (basicProfile[fromKey] as string) ?? null;
}
});
Object.entries(BasicProfileImages).forEach(([hasuraId, ceramicId]) => {
const fromKey = ceramicId as Values<typeof BasicProfileImages>;
const toKey = hasuraId as keyof typeof BasicProfileImages;
values[toKey] =
(basicProfile?.[fromKey] as ImageSources)?.original.src ?? null;
});
}
if (did) {
extendedProfile = await store.get('extendedProfile', did);
if (extendedProfile == null) {
console.debug(`No Extended Profile For: ${ethereumAddress} (${did})`);
} else {
Object.entries(ExtendedProfileStrings).forEach(
([hasuraId, ceramicId]) => {
const fromKey = ceramicId as Values<typeof ExtendedProfileStrings>;
const toKey = hasuraId as keyof typeof ExtendedProfileStrings;
if (extendedProfile?.[fromKey] != null) {
values[toKey] = (extendedProfile[fromKey] as string) ?? null;
}
},
);
if (extendedProfile.meetWithWalletDomain != null) {
await handleMeetWithWalletIntegration(
playerId,
extendedProfile.meetWithWalletDomain,
);
}
Object.entries(ExtendedProfileImages).forEach(
([hasuraId, ceramicId]) => {
const fromKey = ceramicId as Values<typeof ExtendedProfileImages>;
const toKey = hasuraId as keyof typeof ExtendedProfileImages;
values[toKey] =
(extendedProfile?.[fromKey] as ImageSources)?.original.src ??
null;
},
);
Object.entries(ExtendedProfileObjects).forEach(
([hasuraId, ceramicId]) => {
const fromKey = ceramicId as Values<typeof ExtendedProfileObjects>;
const toKey = hasuraId as keyof typeof ExtendedProfileObjects;
if (extendedProfile?.[fromKey] != null) {
switch (fromKey) {
case 'availableHours': {
values.availableHours = extendedProfile.availableHours;
break;
}
case 'magicDisposition': {
values.colorMask =
maskFor(extendedProfile.magicDisposition) ?? undefined;
break;
}
default: {
console.info('Unrecognized Key', { fromKey, toKey });
}
}
}
},
);
}
}
if (!basicProfile && !extendedProfile) {
console.info(`No Profile Information For ${ethereumAddress}.`);
} else {
try {
fields.push(...Object.keys(values));
values.playerId = playerId;
await client.UpsertProfile({
objects: [values],
updateColumns: fields as Profile_Update_Column[],
});
} catch (err) {
if (
!(err as Error).message.includes(
'violates unique constraint "profile_username_key"',
)
) {
throw err;
} else {
// this is brittle and likely subject to exploit
values.username = `${values.username}-${(
did ?? ethereumAddress
).slice(-8)}`;
await client.UpsertProfile({
objects: [values],
updateColumns: fields as Profile_Update_Column[],
});
}
}
}
if (did) {
const alsoKnownAs = ((await store.get('alsoKnownAs', did)) ??
{}) as AlsoKnownAs;
const { accounts = [] } = alsoKnownAs;
await Promise.all(
accounts?.map(async ({ host, id: username }: Account) => {
const service = host
?.replace(/\.com$/, '')
.toUpperCase() as AccountType_Enum;
if (!service) {
console.error(`No hostname for AlsoKnownAs: "${host}"`);
} else {
// If the account has been registered previously, this will
// destructively assign it to the current user removing any
// other users.
//
// ToDo: Examine the JWT to validate that it came from a
// trusted source. Specifically, either the IdentityLink
// service backing //self.id or one established by MetaGame
const { insert_player_account: insert } =
await client.UpsertAccount({
objects: [
{
playerId,
type: service,
identifier: username,
},
],
});
if (insert?.affected_rows === undefined) {
// eslint-disable-next-line no-console
console.warn(
`Unable to insert ${service} user ${username} for playerId ${playerId}.`,
);
} else if (insert.affected_rows > 0) {
accountLinks.push(service);
}
}
}),
);
}
} catch (err) {
if (!(err as Error).message.includes('No DID')) {
throw err;
}
}
const ret = {
success: true,
ceramic: CONFIG.ceramicURL,
did,
ethereumAddress,
accountLinks,
fields,
};
if (fields.length === 0) {
Object.assign(ret, { aliases });
}
return ret;
};

View File

@@ -1,18 +1,16 @@
import express from 'express';
import { asyncHandlerWrapper } from '../../lib/apiHelpers.js';
import { ceramicRoutes } from './ceramic/routes.js';
import { routes as composeDBRoutes } from './composeDB/routes.js';
import { guildRoutes } from './guild/routes.js';
import { syncAllGuildDiscordMembers } from './guild/sync.js';
import { cacheRoutes } from './idxCache/routes.js';
import { questsRoutes } from './quests/routes.js';
import { syncSourceCredAccounts } from './sourcecred/sync.js';
export const actionRoutes = express.Router();
actionRoutes.use('/ceramic', ceramicRoutes);
actionRoutes.use('/composeDB', composeDBRoutes);
actionRoutes.use('/guild', guildRoutes);
actionRoutes.use('/idxCache', cacheRoutes);
actionRoutes.post(
'/syncSourceCredAccounts',

View File

@@ -1,7 +1,7 @@
import Bottleneck from 'bottleneck';
import { cacheProfile } from '../../lib/cacheHelper.js';
import { client } from '../../lib/hasuraClient.js';
import { cacheProfile } from '../actions/composeDB/cacheHelper.js';
async function createPlayer(ethAddress: string, limiter: Bottleneck) {
const { insert_profile: insert } = await client.CreatePlayerFromETH({

View File

@@ -1,11 +1,11 @@
import Bottleneck from 'bottleneck';
import { Player } from '../../lib/autogen/hasura-sdk.js';
import { queueRecache } from '../../lib/cacheHelper.js';
import { queueRecache } from '../actions/composeDB/cacheHelper.js';
import { TriggerPayload } from './types.js';
// This trigger is called when new accounts are created.
export const cacheIDXProfile = async (
export const cacheComposeDBProfile = async (
payload: TriggerPayload<Player>,
limiter: Bottleneck,
) => {

View File

@@ -3,13 +3,13 @@ import { ParamsDictionary } from 'express-serve-static-core';
import { Guild, Player, Player_Role } from '../../lib/autogen/hasura-sdk.js';
import { syncDiscordGuildMembers } from '../actions/guild/sync.js';
import { cacheIDXProfile } from './cacheIDXProfile.js';
import { cacheComposeDBProfile } from './cacheComposeDBProfile.js';
import { playerRankUpdated } from './playerRankUpdated.js';
import { playerRoleChanged } from './playerRoleChanged.js';
import { TriggerPayload } from './types.js';
const TRIGGERS = {
cacheIDXProfile,
cacheComposeDBProfile,
playerRankUpdated,
playerRoleChanged,
syncDiscordGuildMembers,

View File

@@ -1,4 +1,4 @@
import { Values } from './extendedProfileTypes.js';
import { Values } from '../extendedProfileTypes.js';
export const composeDBProfileFieldName = 'name';
export const composeDBProfileFieldDescription = 'description';
@@ -99,23 +99,3 @@ export const hasuraImageFields = [
] as const;
export type HasuraImageFieldKey = typeof hasuraImageFields[number];
// typesafe Array.includes, see https://fettblog.eu/typescript-array-includes/
function includes<T extends U, U>(coll: ReadonlyArray<T>, el: U): el is T {
return coll.includes(el as T);
}
export function isComposeDBImageField(key: string) {
return includes(composeDBImageFields, key);
}
export function isHasuraImageField(key: string) {
return includes(hasuraImageFields, key);
}
export function isImageMetadata(value: ComposeDBPayloadValue) {
const maybeImageMetadata = value as ComposeDBImageMetadata;
return (
maybeImageMetadata?.url != null && maybeImageMetadata?.mimeType != null
);
}

View File

@@ -0,0 +1,59 @@
import { maskFor } from '../colorHelpers.js';
import {
ComposeDBField,
composeDBImageFields,
ComposeDBImageMetadata,
ComposeDBPayloadValue,
ComposeDBProfile,
composeDBProfileFieldFiveColorDisposition,
hasuraImageFields,
profileMapping,
} from './fields.js';
// typesafe Array.includes, see https://fettblog.eu/typescript-array-includes/
function includes<T extends U, U>(coll: ReadonlyArray<T>, el: U): el is T {
return coll.includes(el as T);
}
export function isComposeDBImageField(key: string) {
return includes(composeDBImageFields, key);
}
export function isHasuraImageField(key: string) {
return includes(hasuraImageFields, key);
}
export function isImageMetadata(value: ComposeDBPayloadValue) {
const maybeImageMetadata = value as ComposeDBImageMetadata;
return (
maybeImageMetadata?.url != null && maybeImageMetadata?.mimeType != null
);
}
export const composeDBToHasuraProfile = (
composeDBProfile: ComposeDBProfile,
) => {
// todo we should be able to make this typesafe
const hasuraProfile: Record<string, unknown> = {};
Object.entries(composeDBProfile).forEach(([key, value]) => {
const match = Object.entries(profileMapping).find(
([, composeDBKey]) => composeDBKey === key,
) as [keyof typeof profileMapping, ComposeDBField];
const hasuraKey = match[0];
// Some fields required custom translations
let hasuraValue = value;
if (value && key === composeDBProfileFieldFiveColorDisposition) {
const maskNumber = maskFor(value as string);
if (maskNumber != null) {
hasuraValue = maskNumber;
}
} else if (value && isComposeDBImageField(key)) {
hasuraValue = (value as ComposeDBImageMetadata).url;
}
hasuraProfile[hasuraKey] = hasuraValue;
});
return hasuraProfile;
};

View File

@@ -5,7 +5,8 @@ import extendedProfileModel from './ExtendedProfileModel.json' assert { type: 'j
export * from './arrayHelpers.js';
export * from './ceramic.js';
export * from './colorHelpers.js';
export * from './composeDBProfileFields.js';
export * from './composeDB/fields.js';
export * from './composeDB/utils.js';
export * as Constants from './constants.js';
export * as did from './did/index.js';
export * as DiscordUtil from './discordHelpers.js';

View File

@@ -36,7 +36,11 @@ import {
isHasuraImageField,
profileMapping,
} from '@metafam/utils';
import { Maybe, Player, Profile } from 'graphql/autogen/types';
import {
Maybe,
Player,
useInsertCacheInvalidationMutation,
} from 'graphql/autogen/types';
import { getPlayer } from 'graphql/getPlayer';
import { PlayerProfile } from 'graphql/types';
import { useWeb3 } from 'lib/hooks';
@@ -106,6 +110,7 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({
const username = player.profile?.username;
const { save } = useSaveToComposeDB();
const [, invalidateCache] = useInsertCacheInvalidationMutation();
const initialFormValues = useMemo(
() => getDefaultFormValues(player),
@@ -234,6 +239,11 @@ export const EditProfileModal: React.FC<EditProfileModalProps> = ({
const payload = hasuraToComposeDBProfile(profile, profileImages);
const ceramicStreamID = await save(payload);
if (player) {
setStatus('Invalidating Cache…');
await invalidateCache({ playerId: player.id });
}
// if they changed their username, the page will 404 on reload
if (player && inputs.username !== username) {
window.history.replaceState(

View File

@@ -9,7 +9,6 @@ export const MetaLink: React.FC<Props> = ({
children,
href,
as,
passHref,
replace,
scroll = true,
shallow,
@@ -33,7 +32,8 @@ export const MetaLink: React.FC<Props> = ({
scroll,
shallow,
}}
passHref={passHref || true}
passHref={true}
legacyBehavior={true}
>
{/* NextLink passes the href */}
<Link color="cyan.400" {...props}>

View File

@@ -1,5 +1,5 @@
export const InsertCacheInvalidation = /* GraphQL */ `
mutation InsertCacheInvalidation($playerId: uuid!) {
updateIDXProfile(playerId: $playerId)
updateCachedProfile(playerId: $playerId)
}
`;

View File

@@ -1,12 +1,7 @@
import {
ComposeDBField,
ComposeDBImageMetadata,
ComposeDBProfile,
composeDBProfileFieldFiveColorDisposition,
isComposeDBImageField,
maskFor,
composeDBToHasuraProfile,
Maybe,
profileMapping,
} from '@metafam/utils';
import { Player } from 'graphql/autogen/types';
import { buildPlayerProfileQuery } from 'graphql/composeDB/queries/profile';
@@ -82,31 +77,3 @@ export const hydratePlayerProfile = (
}
return player;
};
export const composeDBToHasuraProfile = (
composeDBProfile: ComposeDBProfile,
) => {
// todo we should be able to make this typesafe
const hasuraProfile: Record<string, unknown> = {};
Object.entries(composeDBProfile).forEach(([key, value]) => {
const match = Object.entries(profileMapping).find(
([, composeDBKey]) => composeDBKey === key,
) as [keyof typeof profileMapping, ComposeDBField];
const hasuraKey = match[0];
// Some fields required custom translations
let hasuraValue = value;
if (value && key === composeDBProfileFieldFiveColorDisposition) {
const maskNumber = maskFor(value as string);
if (maskNumber != null) {
hasuraValue = maskNumber;
}
} else if (value && isComposeDBImageField(key)) {
hasuraValue = (value as ComposeDBImageMetadata).url;
}
hasuraProfile[hasuraKey] = hasuraValue;
});
return hasuraProfile;
};

View File

@@ -12,6 +12,7 @@ import {
profileMapping,
} from '@metafam/utils';
import { useSetupFlow } from 'contexts/SetupContext';
import { useInsertCacheInvalidationMutation } from 'graphql/autogen/types';
import { PlayerProfile } from 'graphql/types';
import { CeramicError } from 'lib/errors';
import { ReactElement, useCallback, useEffect, useState } from 'react';
@@ -20,6 +21,7 @@ import { getImageDimensions } from 'utils/imageHelpers';
import { uploadFile } from 'utils/uploadHelpers';
import { FileReaderData } from '../useImageReader';
import { useUser } from '../useUser';
import { useSaveToComposeDB } from './useSaveToComposeDB';
export type PlayerSetupSaveToComposeDBProps = {
@@ -34,18 +36,12 @@ export function usePlayerSetupSaveToComposeDB({
pickedFile: fileData,
}: PlayerSetupSaveToComposeDBProps) {
const toast = useToast();
const { user } = useUser();
const { onNextPress } = useSetupFlow();
const [status, setStatus] = useState<Maybe<string | ReactElement>>();
const { save: saveToComposeDB, status: saveStatus } = useSaveToComposeDB();
const persist = useCallback(
(values: Record<string, unknown>) => {
setStatus('Saving to Ceramic…');
return saveToComposeDB(values);
},
[saveToComposeDB],
);
const [, invalidateCache] = useInsertCacheInvalidationMutation();
useEffect(() => {
if (saveStatus === 'authenticating') {
@@ -94,8 +90,13 @@ export function usePlayerSetupSaveToComposeDB({
payload[composeDBProfileFieldAvatar] = cleanImageMetadata;
}
setStatus('Saving…');
nodeId = await persist(payload);
setStatus('Saving to ComposeDB…');
nodeId = await saveToComposeDB(payload);
if (user) {
setStatus('Invalidating Cache…');
await invalidateCache({ playerId: user.id });
}
}
if (onComplete) {
@@ -116,7 +117,16 @@ export function usePlayerSetupSaveToComposeDB({
setStatus(null);
}
},
[fileData, isChanged, onComplete, onNextPress, persist, toast],
[
fileData,
invalidateCache,
isChanged,
onComplete,
onNextPress,
saveToComposeDB,
toast,
user,
],
);
return {

View File

@@ -17,6 +17,7 @@ export const useSaveToComposeDB = () => {
// When saving to ComposeDB, it's essential that we have the most recent value for user.ceramicProfileId so that we don't inadvertently create duplicate profile models. Thus, we specify 'network-only' to ALWAYS fetch the latest value of 'user' from Hasura.
// Ideally, we should be invalidating the urql cache when persisting the ceramicProfileId below, but that would require switching over to Normalized caching (https://formidable.com/open-source/urql/docs/graphcache/normalized-caching/) which could be a big lift requiring a bunch of testing
// This is likely causing the warning "Cannot update a component while rendering a different component"
const { user } = useUser({ requestPolicy: 'network-only' });
const [, linkNode] = useLinkOwnCeramicNodeMutation();

View File

@@ -1076,6 +1076,14 @@ type DiscordGuildAuthResponse {
success: Boolean!
}
type DiscordGuildsSyncOutput {
name: String!
numDeleted: Int
numInserted: Int
numSkipped: Int
username: String!
}
type DiscordRole {
id: String!
name: String!
@@ -4083,14 +4091,19 @@ type mutation_root {
saveGuildLayout(guildLayoutInfo: GuildLayoutInfoInput!): SaveGuildLayoutResponse
"""
perform the action: "updateExpiredIDXProfiles"
perform the action: "syncAllGuildDiscordMembers"
"""
updateExpiredIDXProfiles: ExpiredPlayerProfiles
syncAllGuildDiscordMembers: [DiscordGuildsSyncOutput]
"""
perform the action: "updateIDXProfile"
perform the action: "syncSourceCredAccounts"
"""
updateIDXProfile(playerId: uuid): uuid!
syncSourceCredAccounts: SourceCredSyncOutput
"""
perform the action: "updateCachedProfile"
"""
updateCachedProfile(playerId: uuid): uuid!
"""
perform the action: "updateQuestCompletion"
@@ -8452,14 +8465,14 @@ type query_root {
skill_by_pk(id: uuid!): skill
"""
retrieve the result of action: "updateIDXProfile"
retrieve the result of action: "updateCachedProfile"
"""
updateIDXProfile(
updateCachedProfile(
"""
id of the action: "updateIDXProfile"
id of the action: "updateCachedProfile"
"""
id: uuid!
): updateIDXProfile
): updateCachedProfile
}
"""
@@ -10863,6 +10876,13 @@ enum SkillCategory_update_column {
name
}
type SourceCredSyncOutput {
numInserted: Int!
numSkipped: Int!
numUnclaimed: Int!
numUpdated: Int!
}
"""
expression to compare columns of type String. All fields are combined with logical 'AND'.
"""
@@ -12046,14 +12066,14 @@ type subscription_root {
skill_by_pk(id: uuid!): skill
"""
retrieve the result of action: "updateIDXProfile"
retrieve the result of action: "updateCachedProfile"
"""
updateIDXProfile(
updateCachedProfile(
"""
id of the action: "updateIDXProfile"
id of the action: "updateCachedProfile"
"""
id: uuid!
): updateIDXProfile
): updateCachedProfile
}
scalar timestamptz
@@ -12080,9 +12100,9 @@ type TokenBalances {
}
"""
fields of action: "updateIDXProfile"
fields of action: "updateCachedProfile"
"""
type updateIDXProfile {
type updateCachedProfile {
"""the time at which this action was created"""
created_at: timestamptz