Skip to content

Conversation

@jonbarrow
Copy link
Member

@jonbarrow jonbarrow commented Apr 30, 2025

Resolves #XXX

Changes:

Complete overhaul of the DataStore implementation.

Marked as a draft for now, since this is nowhere near the final version of this PR. Creating the PR now, despite the early state, mostly just to communicate some design goals and start actually tracking progress, as well as to discuss things like optimizing database queries

Details on the PR below

Current method progress:

  • PrepareGetObjectV1
  • PreparePostObjectV1
  • CompletePostObjectV1
  • DeleteObject
  • DeleteObjects
  • ChangeMetaV1
  • ChangeMetasV1
  • GetMeta
  • GetMetas
  • PrepareUpdateObject
  • CompleteUpdateObject
  • SearchObject
  • GetNotificationUrl
  • GetNewArrivedNotificationsV1
  • RateObject
  • GetRating
  • GetRatings
  • ResetRating
  • ResetRatings
  • GetSpecificMetaV1
  • PostMetaBinary
  • TouchObject
  • GetRatingWithLog
  • PreparePostObject
  • PrepareGetObject
  • CompletePostObject
  • GetNewArrivedNotifications
  • GetSpecificMeta
  • GetPersistenceInfo
  • GetPersistenceInfos
  • PerpetuateObject
  • UnperpetuateObject
  • PrepareGetObjectOrMetaBinary
  • GetPasswordInfo
  • GetPasswordInfos
  • GetMetasMultipleParam
  • CompletePostObjects
  • ChangeMeta
  • ChangeMetas
  • RateObjects
  • PostMetaBinaryWithDataId
  • PostMetaBinariesWithDataId
  • RateObjectWithPosting
  • RateObjectsWithPosting
  • GetObjectInfos
  • SearchObjectLight

Details

General tasks

  • Implement all methods
  • Add rollback/transactional handling
  • Limit the number of inputs for "batch" methods
  • Block access to objects unless the caller is the owner when status is DATA_STATUS_PENDING or DATA_STATUS_REJECTED regardless of password/permission settings

Philosophy

The current DataStore implementation opts to only provide a common boilerplate for NEX methods. It leaves the actual logic up to the developer still. The original philosophy was to be as unopinionated as possible, and let each server define the functionality they need independently. However most titles will use nearly the same logic, which has proved to be less than helpful in most cases.

The new philosophy takes inspiration from @DaniElectra's MatchMaking changes. DataStore is now opinionated on how it stores and manages data, opting for a standard database schema using Postgres, while exporting some functionality back to the developer for fine-tuned modifications should they need them.

Notes

Below are some key notes about the implementation and where to go from here:

  • As per the usual idiomatic Go way of doing things, we do not use an ORM. We will opt to write raw database queries as often as possible. Go does not tend to take kindly to ORMs (namely stemming from the fact that the lack of classes makes it harder to map data, historically bad implementations, and avoidance of "adding magic" with abstractions in favor of clear explicitly code as per Go's general philosophy), and we follow suit
  • Names in these notes are taken from https://github.com/tech-ticks/RTDXTools/blob/232a7797e01369e9c12704f58fdde65dd3ac1c32/Assets/Scripts/Stubs/Generated/Assembly-CSharp/NexPlugin/DataStore.cs or guessed to the best of my ability
  • 99% of this is unoptimized. There are several NEX methods which can make many, expensive, database queries. I have opted to first focus on the implementation/logic side of things, to set a foundation, and then work on improving the database queries
  • Rollback/transactions are NOT implemented at this time. This means that in methods like PreparePostObject if one operation succeeds (such as the object insertion) but later operation fails (such as the rating slot initialization) then data MAY become out of sync
  • Several methods have a transactional field, which tell the server to either commit all changes as they happen (transactional=false) or use a "all-or-nothing" approach (transactional=true). Rollback/transactions are needed for these cases
  • Some data is still unknown, despite intensive research, and at this point I'm unsure if some of said data will ever be fully understood. Such as the extraData field seen on several types, which the official servers seem to ignore entirely (meaning I got 0 feedback during live testing prior to the shutdown)
  • There are a handful of "unofficial implementations", where we do things intentionally "incorrectly" from the official servers. Details are noted in each specific case
  • Some code in Xenoblade, plus some method names, suggests that objects can be uploaded with specific data IDs? I believe this is likely used as a way to create objects in the 900,000-999,999 range of data IDs (however it is unclear right now if this is intended for system use or not. It seems to be part of the retail client though, so maybe normal clients ARE allowed to upload in this range?). This is based on 3 things:
    1. There's some special cases when interacting with objects in this range (see DeleteObject)
    2. We know for a fact that all objects uploaded normally start at data ID 1,000,000 and count up sequentially. If objects can be created with a specific data ID, it would only make sense for that to be limited to the 900,000-999,999 range as to not interfere with normal objects
    3. We can observe in Super Mario Maker how Nintendo was able to push new objects into the 900,000-999,999 range while the 1,000,000+ range is counting still, unaffected
  • Most methods which handle "batches" (such as GetMetas) of data will return DataStore::InvalidArgument if the length of the input list(s) is(are) greater than MAX_SEARCH_RESULT_SIZE (100)
  • RateObjectsWithPosting can only handle 16 pieces of data at once (BATCH_PROCESSING_CAPACITY_POST_OBJECT?)
  • Methods which take in multiple related lists (such as ChangeMetas) must have their input list lengths match exactly, otherwise return DataStore::InvalidArgument
  • Methods which return a pResults (or similar) field will (usually) not throw errors, and will instead populate errors inside pResults. If the response also contains real response data (such as RateObjects), the list is populated with zeroed structs for where there are errors (as to map pResults correctly)
  • When calling PrepareGetObject, PrepareUpdateObject and TouchObject, the object has been "referenced"
  • When an object is "referenced", the following happens:
    • referredTime is updated to the time of the request (except when using PrepareUpdateObject)
    • referredCnt is incremented by 1 (except when using PrepareUpdateObject)
    • If DATA_FLAG_PERIOD_FROM_LAST_REFERRED is set, the objects expiration time is increased by the objects period days starting from the current time
    • If DATA_FLAG_PERIOD_FROM_LAST_REFERRED is not set, do not update the expiration time. Unless PrepareUpdateObject is used, then the objects expiration time is increased by the objects period days starting from the current time
  • It seems like newer versions of NEX no longer track the "reference" data? Modern titles like Super Mario Maker do not track it, but older ones like Animal Crossing: New Leaf do (we track it at all times)

Method details (delete from here as methods get implemented)

PrepareGetObjectV1

Permission level: Access

Same as PrepareGetObject. Older version which uses a smaller data ID range

PreparePostObjectV1

Same as PreparePostObject. Older version which uses a smaller data ID range

CompletePostObjectV1

Permission level: Owner

Same as CompletePostObject. Older version which uses a smaller data ID range

DeleteObject

Permission level: Update

Deletes an object. Anyone with update permissions can delete the object. The exception is objects whose data IDs fall between 900,000-999,999. No one can delete these regardless of permission settings

DeleteObjects

Permission level: Update

Same as DeleteObject, but for multiple objects. If transactional is true, then deletions are "all or nothing", rolled back if there are any errors. pResults stores the success/error codes

ChangeMetaV1

Permission level: Update

Same as ChangeMeta. Older version which uses a smaller data ID range

ChangeMetasV1

Permission level: Update

Same as ChangeMetas. Older version which uses a smaller data ID range

GetMetas

Permission level: Access

Same as GetMeta, but for multiple objects using the same parameters. pMetaInfo will contain zeroed entries for objects which had errors. pResults stores the success/error codes

PrepareUpdateObject

Permission level: Update

Increments the object's version number and returns an S3 presigned POST response. DataStore::OperationNotAllowed is the object does not use the file server

CompleteUpdateObject

Permission level: Owner

Tells the server that an object update was a success or not

SearchObject

Permission level: Access

Complex, fine-grained, searches for objects in the database. Covers objects which both do and do not use the storage server. Details on DataStoreSearchParam:

- `DataStoreSearchParam.searchTarget` - Enum that does a more fine-grained filter of search results:
	- `SEARCH_TYPE_PUBLIC` = 1. Objects whose access permission is set to `PERMISSION_PUBLIC`
	- `SEARCH_TYPE_SEND_FRIEND` = 2. Objects which the current user owns and whose access permission is set to `PERMISSION_FRIEND`
	- `SEARCH_TYPE_SEND_SPECIFIED` = 3. Objects which the current user owns and whose access permission is set to `PERMISSION_SPECIFIED`
	- `SEARCH_TYPE_SEND_SPECIFIED_FRIEND` = 4. Objects which the current user owns and whose access permission is set to `PERMISSION_SPECIFIED_FRIEND`
	- `SEARCH_TYPE_SEND` = 5. Objects which the current user owns and whose access permission is set to one of `PERMISSION_SPECIFIED`, `PERMISSION_FRIEND`, `PERMISSION_SPECIFIED_FRIEND`
	- `SEARCH_TYPE_FRIEND` = 6. Objects which are owned by a friend of the current user and whose access permission is set to `PERMISSION_FRIEND`
	- `SEARCH_TYPE_RECEIVED_SPECIFIED` = 7. Objects whose access permission is set to either `PERMISSION_SPECIFIED` or `PERMISSION_SPECIFIED_FRIEND` and has the current user in `recipients`
	- `SEARCH_TYPE_RECEIVED` = 8. Combination of `SEARCH_TYPE_FRIEND` and `SEARCH_TYPE_RECEIVED_SPECIFIED`
	- `SEARCH_TYPE_PRIVATE` = 9. Objects which the current user owns and whose access permission is set to `PERMISSION_PRIVATE`
	- `SEARCH_TYPE_OWN` = 10. Objects which the current user owns and and whose status is set to `DATA_STATUS_NONE`
	- Unknown name, assumed something like `SEARCH_TYPE_EXCLUDE_OWN_AND_FRIEND` = 11. Objects whose access permission is set to `PERMISSION_PUBLIC` and were NOT uploaded by either the current user or their friends
	- `SEARCH_TYPE_OWN_PENDING` = 12. Objects which the current user owns and and whose status is set to `DATA_STATUS_PENDING`
	- `SEARCH_TYPE_OWN_REJECTED` = 13. Objects which the current user owns and and whose status is set to `DATA_STATUS_REJECTED`
	- `SEARCH_TYPE_OWN_ALL` = 14. Objects which the current user owns and and whose status is set to either `DATA_STATUS_NONE`, `DATA_STATUS_PENDING` or `DATA_STATUS_REJECTED`
- `DataStoreSearchParam.ownerIds` - A list of PIDs used to filter search results by ownership. See `DataStoreSearchParam.ownerType`
- `DataStoreSearchParam.ownerType` - Enum that does a more broad filter of search results:
	- `SEARCH_TARGET_ANYBODY` = 0. Target all objects
	- `SEARCH_TARGET_FRIEND` = 1. Target friend objects
	- Unknown name, assumed something like `SEARCH_TARGET_EXCLUDE_OWNERS` = 2. Target all objects uploaded by everyone besides those owned by the PIDs inside `DataStoreSearchParam.ownerIds`
- `DataStoreSearchParam.destinationIds` - Unknown use, possibly unused?
- `DataStoreSearchParam.dataType` - Filters objects by a single data type. See `DataStoreSearchParam.dataTypes`
- `DataStoreSearchParam.createdAfter` - Self-explanatory
- `DataStoreSearchParam.createdBefore` - Self-explanatory
- `DataStoreSearchParam.updatedAfter` - Self-explanatory
- `DataStoreSearchParam.updatedBefore` - Self-explanatory
- `DataStoreSearchParam.referDataId` - Filters search results to those who have this field set as their `refer_data_id`
- `DataStoreSearchParam.tags` - Filters objects by tags. Does not need to contain a full list of object tags, but objects must have all tags in this list (IE, object with tags ["one", "two", "three"] will be matched if `DataStoreSearchParam.tags` is set to ["one", "three"], but NOT if `DataStoreSearchParam.tags` is set to ["one", "four"])
- `DataStoreSearchParam.resultOrderColumn` - Enum used to specify the target "column" (database column) for which to order search results by:
	- `SEARCH_SORT_COLUMN_DATAID` = 0. Order by object data ID
	- Unknown name, assumed something like `SEARCH_SORT_COLUMN_SIZE` = 1. Order by object size
	- Unknown name, assumed something like `SEARCH_SORT_COLUMN_NAME` = 2. Order by object name, alphabetical
	- Unknown name, assumed something like `SEARCH_SORT_COLUMN_DATA_TYPE` = 3. Order by object data type
	- Unknown name, assumed something like `SEARCH_SORT_COLUMN_REFERED` = 4. Order by object `reference_count` column
	- `SEARCH_SORT_COLUMN_CREATED_TIME` = 5. Order by object creation time
	- `SEARCH_SORT_COLUMN_UPDATED_TIME` = 6. Order by object update time
	- `SEARCH_SORT_COLUMN_RATING0` = 64. Order by the total number of ratings in objects rating slot 0
	- `SEARCH_SORT_COLUMN_RATING1` = 65. Order by the total number of ratings in objects rating slot 1
	- `SEARCH_SORT_COLUMN_RATING2` = 66. Order by the total number of ratings in objects rating slot 2
	- `SEARCH_SORT_COLUMN_RATING3` = 67. Order by the total number of ratings in objects rating slot 3
	- `SEARCH_SORT_COLUMN_RATING4` = 68. Order by the total number of ratings in objects rating slot 4
	- `SEARCH_SORT_COLUMN_RATING5` = 69. Order by the total number of ratings in objects rating slot 5
	- `SEARCH_SORT_COLUMN_RATING6` = 70. Order by the total number of ratings in objects rating slot 6
	- `SEARCH_SORT_COLUMN_RATING7` = 71. Order by the total number of ratings in objects rating slot 7
	- `SEARCH_SORT_COLUMN_RATING8` = 72. Order by the total number of ratings in objects rating slot 8
	- `SEARCH_SORT_COLUMN_RATING9` = 73. Order by the total number of ratings in objects rating slot 9
	- `SEARCH_SORT_COLUMN_RATING10` = 74. Order by the total number of ratings in objects rating slot 10
	- `SEARCH_SORT_COLUMN_RATING11` = 75. Order by the total number of ratings in objects rating slot 11
	- `SEARCH_SORT_COLUMN_RATING12` = 76. Order by the total number of ratings in objects rating slot 12
	- `SEARCH_SORT_COLUMN_RATING13` = 77. Order by the total number of ratings in objects rating slot 13
	- `SEARCH_SORT_COLUMN_RATING14` = 78. Order by the total number of ratings in objects rating slot 14
	- `SEARCH_SORT_COLUMN_RATING15` = 79. Order by the total number of ratings in objects rating slot 15
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE0` = 96. Order by the average of all the ratings in objects rating slow 0
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE1` = 97. Order by the average of all the ratings in objects rating slow 1
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE2` = 98. Order by the average of all the ratings in objects rating slow 2
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE3` = 99. Order by the average of all the ratings in objects rating slow 3
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE4` = 100. Order by the average of all the ratings in objects rating slow 4
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE5` = 101. Order by the average of all the ratings in objects rating slow 5
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE6` = 102. Order by the average of all the ratings in objects rating slow 6
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE7` = 103. Order by the average of all the ratings in objects rating slow 7
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE8` = 104. Order by the average of all the ratings in objects rating slow 8
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE9` = 105. Order by the average of all the ratings in objects rating slow 9
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE10` = 106. Order by the average of all the ratings in objects rating slow 10
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE11` = 107. Order by the average of all the ratings in objects rating slow 11
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE12` = 108. Order by the average of all the ratings in objects rating slow 12
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE13` = 109. Order by the average of all the ratings in objects rating slow 13
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE14` = 110. Order by the average of all the ratings in objects rating slow 14
	- `SEARCH_SORT_COLUMN_RATING_AVERAGE15` = 111. Order by the average of all the ratings in objects rating slow 15
- `DataStoreSearchParam.resultOrder` - Enum used to specify the order in which to order search results by:
	- `SEARCH_SORT_ORDER_ASC` = 0. Ascending order
	- `SEARCH_SORT_ORDER_DESC` = 1. Descending order
- `DataStoreSearchParam.resultRange` - Self-explanatory
- `DataStoreSearchParam.resultOption` - Self-explanatory
- `DataStoreSearchParam.minimalRatingFrequency` - When using any of the `SEARCH_SORT_COLUMN_RATING` or `SEARCH_SORT_COLUMN_RATING_AVERAGE` columns, `minimalRatingFrequency` sets the minimum number of times an objects slot needs to have been rated (total number, not rating total)
- `DataStoreSearchParam.useCache` - Cache search results for later use?
- `DataStoreSearchParam.totalCountEnabled` - Determines whether or not `DataStoreSearchResult.totalCount` and `DataStoreSearchResult.totalCountType` are used
- `DataStoreSearchParam.dataTypes` - Filters by several valid data types. Seems to have replaced `DataStoreSearchParam.dataType` entirely in later versions, prefer this if populated
- Invalid `DataStoreSearchParam.ownerType` and `DataStoreSearchParam.searchTarget` combinations:
	- `DataStoreSearchParam.ownerType`: `SEARCH_TARGET_FRIEND`, `DataStoreSearchParam.searchTarget`: `SEARCH_TYPE_PUBLIC` (DataStore::InvalidArgument (0x80690002))
	- `DataStoreSearchParam.ownerType`: `SEARCH_TARGET_FRIEND`, `DataStoreSearchParam.searchTarget`: `SEARCH_TYPE_SEND_FRIEND` (DataStore::InvalidArgument (0x80690002))
	- `DataStoreSearchParam.ownerType`: `SEARCH_TARGET_FRIEND`, `DataStoreSearchParam.searchTarget`: `SEARCH_TYPE_SEND_SPECIFIED` (DataStore::InvalidArgument (0x80690002))
	- `DataStoreSearchParam.ownerType`: `SEARCH_TARGET_FRIEND`, `DataStoreSearchParam.searchTarget`: `SEARCH_TYPE_SEND_SPECIFIED_FRIEND` (DataStore::InvalidArgument (0x80690002))
	- `DataStoreSearchParam.ownerType`: `SEARCH_TARGET_FRIEND`, `DataStoreSearchParam.searchTarget`: `SEARCH_TYPE_RECEIVED` (DataStore::InvalidArgument (0x80690002))
	- `DataStoreSearchParam.ownerType`: `SEARCH_TARGET_FRIEND`, `DataStoreSearchParam.searchTarget`: `SEARCH_TYPE_PRIVATE` (DataStore::InvalidArgument (0x80690002))
	- `DataStoreSearchParam.ownerType`: `SEARCH_TARGET_FRIEND`, `DataStoreSearchParam.searchTarget`: `SEARCH_TYPE_OWN` (DataStore::InvalidArgument (0x80690002))
	- `DataStoreSearchParam.ownerType`: `SEARCH_TARGET_FRIEND`, `DataStoreSearchParam.searchTarget`: `SEARCH_TYPE_EXCLUDE_OWN_AND_FRIEND` (DataStore::InvalidArgument (0x80690002))
	- `DataStoreSearchParam.ownerType`: `SEARCH_TARGET_FRIEND`, `DataStoreSearchParam.searchTarget`: `SEARCH_TYPE_OWN_PENDING` (DataStore::InvalidArgument (0x80690002))
	- `DataStoreSearchParam.ownerType`: `SEARCH_TARGET_FRIEND`, `DataStoreSearchParam.searchTarget`: `SEARCH_TYPE_OWN_REJECTED` (DataStore::InvalidArgument (0x80690002))

GetNotificationUrl

Exchange an old notification URL for a current one? Seems to be used by BOSS/HPP

GetNewArrivedNotificationsV1

Same as GetNewArrivedNotifications. Older version which uses a smaller data ID range

RateObject

Permission level: Access

Rates a specific objects rating slot. The following rating rules apply:

  • If the slot is using RATING_FLAG_MODIFIABLE, each user only has a single rating for the slot. Update this value and create a log
  • If the slot is using RATING_FLAG_ROUND_MINUS, round the rating to 0 if below 0
  • If the slot is using RATING_FLAG_DISABLE_SELF_RATING, return DataStore::OperationNotAllowed if the rater owns the object
  • If the slot is using RATING_INTERNAL_FLAG_USE_RANGE_MIN, return DataStore::InvalidArgument if the rating value is below the configured minimum (DataStoreRatingInitParam.rangeMin)
  • If the slot is using RATING_INTERNAL_FLAG_USE_RANGE_MAX, return DataStore::InvalidArgument if the rating value is above the configured maximum(DataStoreRatingInitParam.rangeMax)
  • If the slot is using RATING_LOCK_NONE, apply no locks. The user may freely update this slot again at any time
  • If the slot is using RATING_LOCK_INTERVAL, DataStoreRatingInitParam.periodDuration is used as the number of seconds to lock the user from rating this slot again. DataStoreRatingInitParam.periodDuration must be positive
  • If the slot is using RATING_LOCK_PERIOD:
    • DataStoreRatingInitParam.periodDuration is used as the day of the week/month the lock expires
    • DataStoreRatingInitParam.periodHour is used as the hour of the selected day (0-23)
    • DataStoreRatingInitParam.periodDuration can be one of:
      • RATING_LOCK_PERIOD_DAY1 = -17. The 1st day of the following month
      • RATING_LOCK_PERIOD_SUN = -7. Sunday of the following week
      • RATING_LOCK_PERIOD_SAT = -6. Saturday of the following week
      • RATING_LOCK_PERIOD_FRI = -5. Friday of the following week
      • RATING_LOCK_PERIOD_THU = -4. Thursday of the following week
      • RATING_LOCK_PERIOD_WED = -3. Wednesday of the following week
      • RATING_LOCK_PERIOD_TUE = -2. Tuesday of the following week
      • RATING_LOCK_PERIOD_MON = -1. Monday of the following week
    • For example, if DataStoreRatingInitParam.periodDuration is set to RATING_LOCK_PERIOD_TUE and DataStoreRatingInitParam.periodHour is set to 0, and a user rates the slot on March 7th 2024, the lock expiration time is set to 12-3-2024 0:00:00 (the following Tuesday at midnight)
    • DataStoreRatingInitParam.periodHour is sent as a signed value, so it may be possible to do negative hours? For example, setting periodDuration=RATING_LOCK_PERIOD_DAY1 and periodHour=-1 to target the last day of the current month?
  • If the slot is using RATING_LOCK_PERMANENT, the user can never rate the slot again
  • If the slot is using any lock type besides RATING_LOCK_NONE, create a log
  • All lock dates are UTC

GetRating

Permission level: Access

Gets the rating data for a specific rating slot on a single object

GetRatings

Permission level: Access

Gets all the rating data for every slot for all input data IDs. pRatings will contain zeroed entries for objects which had errors. pResults stores the success/error codes

ResetRating

Permission level: Update

Resets a specific rating slot data. Rating logs are cleared, rating count is set to 0, and the rating total value is set to the initial value

ResetRatings

Permission level: Update

Resets all the rating data for every slot for all input data IDs. Rating logs are cleared, rating counts are set to 0, and the rating total values are set to their slots initial value. If transactional is true, then deletions are "all or nothing", rolled back if there are any errors

GetSpecificMetaV1

Permission level: Access

Same as GetSpecificMeta. Older version which uses a smaller data ID range

PostMetaBinary

Creates an object which does not use the file server. If the size is anything besides 0, return DataStore::InvalidArgument. If DATA_FLAG_NOT_USE_FILESERVER is not set on the object flags on upload, the server sets it automatically

TouchObject

Permission level: Access

Simulates PrepareGetObject? Seemingly used to update object expiration times and/or to cheaply check if an object exists

GetRatingWithLog

Permission level: Access

Gets the rating data for a specific rating slot on a single object, as well as a log of the current users last rating (if exists). A log is only created if:

  1. RATING_FLAG_MODIFIABLE is used
  2. Any lock type besides RATING_LOCK_NONE is used

If neither case is true then a log cannot be created for a slot, return DataStore::OperationNotAllowed

If a user has not rated the objects slot, DataStoreRatingLog.isRated is false and DataStoreRatingLog.ratingValue is -1

If RATING_FLAG_MODIFIABLE is used and the user has rated the slot, DataStoreRatingLog.isRated is true and DataStoreRatingLog.ratingValue is the users current rating value

If a rating lock is used and the user has rated the slot, DataStoreRatingLog.isRated is true, DataStoreRatingLog.ratingValue is the users most recent rating value, and DataStoreRatingLog.lockExpirationTime is the lock expiration date

DataStoreRatingLog.pid is always the users PID

GetNewArrivedNotifications

Gets the list of IDs of notifications created since the input ID. Seems to delete notifications made before the input ID once called? A notification can also be linked to data ID 0 in some cases. Seems to be used by BOSS/HPP

GetSpecificMeta

Permission level: Access

Gets only a subset of data about an object

GetPersistenceInfo

Permission level: Access

Maps a PID and persistence slot to a data ID

GetPersistenceInfos

Permission level: Access

Same as GetPersistenceInfo, but for multiple objects. pPersistenceInfo will contain zeroed entries for objects which had errors. pResults stores the success/error codes

PerpetuateObject

Permission level: Owner

Marks an object as persisted. If an object occupies the slot already, and deleteLastObject is false, the old object has its expiration timer started. If an object occupies the slot already, and deleteLastObject is true, the old object is deleted

UnperpetuateObject

Permission level: Owner

Stops persisting an object. If deleteLastObject is false, the object has its expiration timer started. If deleteLastObject is true, the object is deleted

PrepareGetObjectOrMetaBinary

Permission level: Access

Likely used by the client to download data for an object regardless of if it uses the file server or not in a single request. If using the file server, populate pReqGetInfo. If not using the file server, populate pReqGetAdditionalMeta

GetPasswordInfo

Permission level: Owner

Returns an object's access and update passwords. Can only be called by the owner of the object. Passwords bypass normal permissions checks (besides owner permissions)

GetPasswordInfos

Permission level: Owner

Same as GetPasswordInfo, but for multiple objects. pPasswordInfos will contain zeroed entries for objects which had errors. pResults stores the success/error codes

GetMetasMultipleParam

Permission level: Access

Similar to GetMetas, but for multiple objects using the different parameters

CompletePostObjects

Permission level: Owner

Same as CompletePostObject, but for multiple objects. "All or nothing" updates

ChangeMetas

Permission level: Update

Same as ChangeMeta, but for multiple objects. dataIds and params must be the same length, as they index into each other

RateObjects

Permission level: Access

Same as RateObject, but for multiple objects. pRatings will contain zeroed entries for objects which had errors. pResults stores the success/error codes

PostMetaBinaryWithDataId

Based on the name and some code within Xenoblade, seems to upload an object using a specific data ID? We should stub this for now until we have more information

PostMetaBinariesWithDataId

Same as PostMetaBinaryWithDataId, but for multiple objects

RateObjectWithPosting

Permission level: Access

Will rate an object if data exists, and if not will create the data. For example, if an object was uploaded with only rating slots 1 and 3, and then RateObjectWithPosting is called targeting slot 2, slot 2 is both initialized and then subsequently rated in this single call

RateObjectsWithPosting

Permission level: Access

Same as RateObjectWithPosting, but for multiple objects. targets, rateParams and postParams must be the same length, as they index into each other

GetObjectInfos

Permission level: Access

Same as PrepareGetObject, but for multiple objects. pInfos will contain zeroed entries for objects which had errors. pResults stores the success/error codes

SearchObjectLight

Permission level: Access

Identical to SearchObject? Possibly just a slightly search implementation, or uses a cache or something?

Copy link
Member

@DaniElectra DaniElectra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the design this is taking, I think we can build something great from here!

Marked as a draft for now, since this is nowhere near the final version of this PR. Creating the PR now, despite the early state, mostly just to communicate some design goals and start actually tracking progress, as well as to discuss things like optimizing database queries

Just to be clear, what is the scope of the PR? Does it intend to implement every method and functionality, or only some of them and add more methods and functionality on a future PR?

The new philosophy takes inspiration from DaniElectra's MatchMaking changes. DataStore is now opinionated on how it stores and manages data, opting for a standard database schema using Postgres, while exporting some functionality back to the developer for fine-tuned modifications should they need them.

I thought we were going to allow custom methods for database too, as per #37 ? I'm not against this, though, just mentioning it

It seems like newer versions of NEX no longer track the "reference" data? Modern titles like Super Mario Maker do not track it, but older ones like Animal Crossing: New Leaf do (we track it at all times)

I need a refreshal about this, is the "reference" data from before the amiibo update? While AC:NL originally released with NEX 2.7, it got updated with the amiibo update to NEX 3.10, and thus newer than SMM in that aspect

GetNewArrivedNotifications

Gets the list of IDs of notifications created since the input ID. Seems to delete notifications made before the input ID once called? A notification can also be linked to data ID 0 in some cases. Seems to be used by BOSS/HPP

The Data ID 0 notifications are triggered when the console calls GetNotificationUrl, I was able to test this before the shutdown.

common_globals "github.com/PretendoNetwork/nex-protocols-common-go/v2/globals"
)

func EnableObject(manager *common_globals.DataStoreManager, dataID types.UInt64) *nex.Error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we want to add godoc comments on these database funcitons, a short explanation would work

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was holding off on writing comments here since I know that the database functions are going to change over time

@jonbarrow
Copy link
Member Author

jonbarrow commented May 1, 2025

I like the design this is taking, I think we can build something great from here!

Thank you!! Ideally once this is implemented, we should never have to worry about DataStore again (outside of custom implementations)

Just to be clear, what is the scope of the PR? Does it intend to implement every method and functionality, or only some of them and add more methods and functionality on a future PR?

Ideally, all of them. The foundation for them all is already here, and several of them are "duplicates" of other methods (all the V1 methods for example use the same logic as the non-V1 methods, just with different field sizes, and methods like GetMetas are basically the same as GetMeta just for multiple targets rather than one)

It shouldn't be too much effort to get these all in here tbh, especially since I've also left notes for all the methods. Once they're in here we can start doing some real testing with something like AC:NL

I thought we were going to allow custom methods for database too, as per #37 ? I'm not against this, though, just mentioning it

I would still like to do this at some point, but right now the database API isn't even finalized. Like I mentioned earlier, this is highly unoptimized and ideally several of these database functions get reworked or removed entirely to reduce database strain. My priority right now is getting this functional, and then worrying about customization afterwards

I need a refreshal about this, is the "reference" data from before the amiibo update? While AC:NL originally released with NEX 2.7, it got updated with the amiibo update to NEX 3.10, and thus newer than SMM in that aspect

My hunch is that "reference" data stopped being tracked either in NEX 3.0 or 3.5 (which is where many changes seems to have occured). AC:NL, despite being updated to 3.10, still tracked this however. So I suspect it's just some jank that AC:NL does specifically (like it's weird permissions). Either than or it's a configurable thing, but I've seen no modern Wii U titles use the "reference" feature. It shouldn't hurt to track it anyway though

The Data ID 0 notifications are triggered when the console calls GetNotificationUrl, I was able to test this before the shutdown.

I may rely on you a bit for the HPP methods tbh, since I was only able to do a small amount of testing with these and you've looked into them more than I have

Comment on lines +208 to +209
accessPassword := rand.Uint64()
updatePassword := rand.Uint64()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi there, just a small note. This causes the insert to fail ~50% of the time:

[DataStore::Unknown] sql: converting argument $24 type: uint64 values with high bit set are not supported

see also lib/pq#72.
It's unlikely this will ever be a problem on sequential uint64's (hitting the limit there would require a ridiculous amount of data), but it's far more likely here where it's a random number

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't realize this wasn't mentioned here yet, my apologies. We found this out about a week or so ago as well and came up with a solution on Discord. I had thought I brought the info over here, but it seems I did not. To copy-paste from Discord:

the solutions I've seen are:

- Stay within the signed range (not helpful)
- Use a different data type entirely (suggestions like `bytea`, `uuid`, etc. all as hacks)
- Use strings instead of numbers directly

The last one still seems kinda hacky, but it DOES work when using `numeric` columns. If you use `bigint` columns then you run into `value "some giant number" is out of range for type bigint`, but `numeric(20)` accepts a string up to `math.MaxUint64` just fine, and it works just like any other numeric value

So we should probably just add a `Value` function to each of our unsigned types that just returns the result of the types `String` function and call it a day (thats what I did for the uint64 test at least, seems to work fine). We'd need to cast native unsigned integers to ours for this, but that's w/e

Copy link
Member

@DaniElectra DaniElectra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also rebase the branch to fix the conflict with the main branch?

)

func AddUserRating(manager *common_globals.DataStoreManager, dataID types.UInt64, slot types.UInt8, pid types.PID, ratingValue types.Int32) *nex.Error {
// TODO - Check rows affected?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we want to check the rows affected here?

)

func DeleteObjectRatings(manager *common_globals.DataStoreManager, dataID types.UInt64) *nex.Error {
// TODO - Should we just LOGICALLY delete these, like we do everything else, or continue to HARD delete them?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we may want to delete these logically to keep track of any cheaters trying to set maximum or minimum ratings for their advantage

rs.initial_value
`, dataID, slot).Scan(&ratingInfo.TotalValue, &ratingInfo.Count, &ratingInfo.InitialValue)
if err != nil {
if err.Error() == "sql: no rows in result set" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if err.Error() == "sql: no rows in result set" {
if err == sql.ErrNoRows {

Comment on lines 20 to 23
$1,
$2,
$3,
$4
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$1,
$2,
$3,
$4
$1,
$2,
$3

)

func UpdateUserRating(manager *common_globals.DataStoreManager, dataID types.UInt64, slot types.UInt8, pid types.PID, ratingValue types.Int32) *nex.Error {
// TODO - Check rows affected?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we want to check the rows affected here?

func (commonProtocol *CommonProtocol) getObjectInfos(err error, packet nex.PacketInterface, callID uint32, dataIDs types.List[types.UInt64]) (*nex.RMCMessage, *nex.Error) {
if err != nil {
common_globals.Logger.Error(err.Error())
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, "change_error")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, "change_error")
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, err.Error())

func (commonProtocol *CommonProtocol) getRating(err error, packet nex.PacketInterface, callID uint32, target datastore_types.DataStoreRatingTarget, accessPassword types.UInt64) (*nex.RMCMessage, *nex.Error) {
if err != nil {
common_globals.Logger.Error(err.Error())
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, "change_error")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, "change_error")
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, err.Error())

func (commonProtocol *CommonProtocol) getRatingWithLog(err error, packet nex.PacketInterface, callID uint32, target datastore_types.DataStoreRatingTarget, accessPassword types.UInt64) (*nex.RMCMessage, *nex.Error) {
if err != nil {
common_globals.Logger.Error(err.Error())
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, "change_error")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, "change_error")
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, err.Error())

func (commonProtocol *CommonProtocol) getRatings(err error, packet nex.PacketInterface, callID uint32, dataIDs types.List[types.UInt64], accessPassword types.UInt64) (*nex.RMCMessage, *nex.Error) {
if err != nil {
common_globals.Logger.Error(err.Error())
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, "change_error")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, "change_error")
return nil, nex.NewError(nex.ResultCodes.DataStore.Unknown, err.Error())

Well the same on each function, not gonna repeat it on each instance

return nex.NewError(nex.ResultCodes.DataStore.PermissionDenied, "change_error")
}
_, err = manager.Database.Exec(`CREATE TABLE datastore.ratings (
id serial PRIMARY KEY,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use bigserial here for good measure

Suggested change
id serial PRIMARY KEY,
id bigserial PRIMARY KEY,

Notifications are used by HPP to share objects with specific access
recipients and notify them about it. One such example is letters on
Swapdoodle. Implement the necessary functionality and methods to support
it.

On the database side, add a new table `datastore.notifications` with the
following columns:
- `id`: The notification ID
- `read`: Whether the notification has been read with the
  `GetNewArrivedNotifications` method
- `recipient_id`: The target PID of the notification
- `data_id`: The referal data ID of the notification. Can be zero on
  notifications triggered with `GetNotificationURL`, so don't add an
  actual reference
- `sender_pid`: The PID who triggered the notification. For tracking
  purposes since this may not always be the owner (for example, on
  update notifications triggered by an external PID who has the update
  password)
- `creation_date`: When the notification was triggered. For tracking
  purposes
- `read_date`: If the notification has been read, timestamp of that
  event. For tracking purposes

Changes on the S3 side are also required, since we want to store the
notification files on a separate key than object files. For this, add a
separate `NotifyKeyBase` to specify the location for notification
updates, as well as using a different presign function `PresignNotify`
for these files for API compatibility with the original design.

Alongside that, we need a method for actually putting files into S3 from
the server itself. For this, a new `PutObject` is added into
`MinIOPresigner` to keep API compatibility. I don't exactly like how
this is done, so I'm open to making more changes such as renaming the
`S3Presigner` interface to expand its scope in name.
// DataStoreManager manages a DataStore instance
type DataStoreManager struct {
Database *sql.DB
Endpoint *nex.PRUDPEndPoint
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a dependency on PRUDP, which wouldn't work for HPP. Since this isn't being used anyway, it should be removed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants