From c0ea92d083982cd8bbef2e2174b6c702c8bdd2da Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Sat, 23 May 2026 19:51:48 -0700 Subject: [PATCH] Clean up Stately and migration stuff --- .env | 6 +- .github/workflows/pr-build.yml | 3 - api/db/apps-queries.ts | 9 - api/db/loadout-share-queries.ts | 37 - api/db/migration-state-queries.ts | 344 --- api/db/settings-queries.ts | 19 - api/routes/delete-all-data.ts | 3 - api/routes/profile.ts | 4 +- api/routes/update.ts | 1 - api/server.test.ts | 2034 ++++++++--------- api/stately/bulk-queries.test.ts | 141 -- api/stately/bulk-queries.ts | 358 --- api/stately/client.ts | 11 - api/stately/generated/.gitattributes | 7 - api/stately/generated/README.md | 25 - api/stately/generated/index.d.ts | 5 - api/stately/generated/index.js | 5 - api/stately/generated/stately_item_types.d.ts | 121 - api/stately/generated/stately_item_types.js | 78 - api/stately/generated/stately_pb.d.ts | 975 -------- api/stately/generated/stately_pb.js | 226 -- api/stately/init/global-settings.ts | 40 - api/stately/init/migrate-loadout-shares.ts | 6 - .../init/migrate-stately-to-postgres.ts | 390 ---- api/stately/init/migrate-users.ts | 34 - api/stately/init/stately-backfill.ts | 520 ----- api/stately/item-annotations-queries.test.ts | 158 -- api/stately/item-annotations-queries.ts | 185 -- api/stately/item-hash-tags-queries.test.ts | 110 - api/stately/item-hash-tags-queries.ts | 118 - api/stately/loadout-share-queries.test.ts | 57 - api/stately/loadout-share-queries.ts | 106 - api/stately/loadouts-queries.test.ts | 180 -- api/stately/loadouts-queries.ts | 414 ---- api/stately/migrator/user.ts | 44 - api/stately/schema/README.md | 29 - api/stately/schema/app.ts | 29 - api/stately/schema/global-settings.ts | 31 - api/stately/schema/index.ts | 9 - api/stately/schema/loadout-share.ts | 33 - api/stately/schema/loadouts.ts | 309 --- api/stately/schema/search.ts | 66 - api/stately/schema/settings.ts | 297 --- api/stately/schema/tags.ts | 60 - api/stately/schema/triumphs.ts | 17 - api/stately/schema/types.ts | 30 - api/stately/searches-queries.test.ts | 151 -- api/stately/searches-queries.ts | 188 -- api/stately/settings-queries.test.ts | 77 - api/stately/settings-queries.ts | 361 --- api/stately/stately-utils.test.ts | 168 -- api/stately/stately-utils.ts | 228 -- api/stately/triumphs-queries.test.ts | 50 - api/stately/triumphs-queries.ts | 69 - eslint.config.js | 7 +- kubernetes/README.md | 90 - kubernetes/deploy-migration-worker.sh | 17 - kubernetes/deploy-stately-backfill-job.sh | 18 - kubernetes/dim-api-deployment.yaml | 5 - .../dim-api-migration-worker-deployment.yaml | 56 - kubernetes/dim-api-stately-backfill-job.yaml | 92 - package.json | 10 +- pnpm-lock.yaml | 72 +- 63 files changed, 1007 insertions(+), 8336 deletions(-) delete mode 100644 api/db/migration-state-queries.ts delete mode 100644 api/stately/bulk-queries.test.ts delete mode 100644 api/stately/bulk-queries.ts delete mode 100644 api/stately/client.ts delete mode 100644 api/stately/generated/.gitattributes delete mode 100644 api/stately/generated/README.md delete mode 100644 api/stately/generated/index.d.ts delete mode 100644 api/stately/generated/index.js delete mode 100644 api/stately/generated/stately_item_types.d.ts delete mode 100644 api/stately/generated/stately_item_types.js delete mode 100644 api/stately/generated/stately_pb.d.ts delete mode 100644 api/stately/generated/stately_pb.js delete mode 100644 api/stately/init/global-settings.ts delete mode 100644 api/stately/init/migrate-loadout-shares.ts delete mode 100644 api/stately/init/migrate-stately-to-postgres.ts delete mode 100644 api/stately/init/migrate-users.ts delete mode 100644 api/stately/init/stately-backfill.ts delete mode 100644 api/stately/item-annotations-queries.test.ts delete mode 100644 api/stately/item-annotations-queries.ts delete mode 100644 api/stately/item-hash-tags-queries.test.ts delete mode 100644 api/stately/item-hash-tags-queries.ts delete mode 100644 api/stately/loadout-share-queries.test.ts delete mode 100644 api/stately/loadout-share-queries.ts delete mode 100644 api/stately/loadouts-queries.test.ts delete mode 100644 api/stately/loadouts-queries.ts delete mode 100644 api/stately/migrator/user.ts delete mode 100644 api/stately/schema/README.md delete mode 100644 api/stately/schema/app.ts delete mode 100644 api/stately/schema/global-settings.ts delete mode 100644 api/stately/schema/index.ts delete mode 100644 api/stately/schema/loadout-share.ts delete mode 100644 api/stately/schema/loadouts.ts delete mode 100644 api/stately/schema/search.ts delete mode 100644 api/stately/schema/settings.ts delete mode 100644 api/stately/schema/tags.ts delete mode 100644 api/stately/schema/triumphs.ts delete mode 100644 api/stately/schema/types.ts delete mode 100644 api/stately/searches-queries.test.ts delete mode 100644 api/stately/searches-queries.ts delete mode 100644 api/stately/settings-queries.test.ts delete mode 100644 api/stately/settings-queries.ts delete mode 100644 api/stately/stately-utils.test.ts delete mode 100644 api/stately/stately-utils.ts delete mode 100644 api/stately/triumphs-queries.test.ts delete mode 100644 api/stately/triumphs-queries.ts delete mode 100755 kubernetes/deploy-migration-worker.sh delete mode 100755 kubernetes/deploy-stately-backfill-job.sh delete mode 100644 kubernetes/dim-api-migration-worker-deployment.yaml delete mode 100644 kubernetes/dim-api-stately-backfill-job.yaml diff --git a/.env b/.env index 0bb8df85..baba1ba6 100644 --- a/.env +++ b/.env @@ -5,8 +5,4 @@ PGUSER=postgres PGPASSWORD=postgres PGSSL=false JWT_SECRET=dummysecret -VHOST=api.destinyitemmanager.com -STATELY_STORE_ID=4691621389625154 -STATELY_REGION=us-west-2 -VHOST=api.destinyitemmanager.com -STATELY_ACCESS_KEY="CAISRzBFAiEAhBwlEIYOXbLFzWyqZsTn3iLbyBUCjOVL8HzaAwDz6WkCIBf1QSVzt5VLV0VckmSsv2D3OHvnzHnctfTDDPifUOsDGgkI0sHq9_DVqAM" \ No newline at end of file +VHOST=api.destinyitemmanager.com \ No newline at end of file diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 2db7c692..090faabd 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -33,6 +33,3 @@ jobs: - name: Test run: pnpm test - env: - STATELY_STORE_ID: ${{ vars.STATELY_STORE_ID}} - STATELY_ACCESS_KEY: ${{ secrets.STATELY_ACCESS_KEY }} diff --git a/api/db/apps-queries.ts b/api/db/apps-queries.ts index 7f1e90a0..2c4cb9e1 100644 --- a/api/db/apps-queries.ts +++ b/api/db/apps-queries.ts @@ -1,7 +1,6 @@ import { ClientBase, QueryResult } from 'pg'; import { ApiApp } from '../shapes/app.js'; import { camelize, KeysToSnakeCase, TypesForKeys } from '../utils.js'; -import { transaction } from './index.js'; /** * Get all registered apps. @@ -16,14 +15,6 @@ export async function getAllApps(client: ClientBase): Promise { // TODO: Add a last modified column to apps and use that for more efficient syncing. This will require us to be able to disable apps rather than deleting them. And we need an index on that column. -export async function addAllApps(apps: ApiApp[]): Promise { - await transaction(async (client) => { - for (const app of apps) { - await insertApp(client, app); - } - }); -} - /** * Get an app by its ID. */ diff --git a/api/db/loadout-share-queries.ts b/api/db/loadout-share-queries.ts index e4ee80ac..8e79a924 100644 --- a/api/db/loadout-share-queries.ts +++ b/api/db/loadout-share-queries.ts @@ -62,43 +62,6 @@ values ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, return response; } -/** - * Insert a loadout share, but ignore if the share ID already exists. This is - * used for backfilling from Stately where we don't want to overwrite existing - * shares, but we also don't want to fail the whole batch if there's a - * duplicate. - */ -export async function addLoadoutShareIgnoring( - client: ClientBase, - bungieMembershipId: number | undefined, - platformMembershipId: string, - shareId: string, - loadout: Loadout, - viewCount = 0, -): Promise { - const response = await client.query({ - name: 'add_loadout_share_ignoring', - text: `insert into loadout_shares (id, membership_id, platform_membership_id, name, notes, class_type, items, parameters, view_count) -values ($1, $2, $3, $4, $5, $6, $7, $8, $9) on conflict (id) do nothing`, - values: [ - shareId, - bungieMembershipId, - platformMembershipId, - loadout.name, - loadout.notes, - loadout.classType, - { - equipped: loadout.equipped.map(cleanItem), - unequipped: loadout.unequipped.map(cleanItem), - }, - loadout.parameters, - viewCount, - ], - }); - - return (response.rowCount ?? 0) > 1; -} - /** * Touch the last_accessed_at and visits fields to keep track of access. This returns the loadout, if it exists. */ diff --git a/api/db/migration-state-queries.ts b/api/db/migration-state-queries.ts deleted file mode 100644 index 3e554caa..00000000 --- a/api/db/migration-state-queries.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { ClientBase } from 'pg'; -import { metrics } from '../metrics/index.js'; -import { transaction } from './index.js'; - -export const MAX_MIGRATION_ATTEMPTS = 3; - -export const enum MigrationState { - Invalid = 0, - Stately = 1, - MigratingToPostgres = 2, - Postgres = 3, -} - -export interface MigrationStateInfo { - platformMembershipId: string; - bungieMembershipId: number | undefined; - state: MigrationState; - lastStateChangeAt: number; - attemptCount: number; - lastError?: string; -} - -export interface MigrationWorkItem { - platformMembershipId: string; - bungieMembershipId: number | undefined; - attemptCount: number; -} - -interface MigrationStateRow { - membership_id: number | null; - platform_membership_id: string; - state: number; - last_state_change_at: Date; - attempt_count: number; - last_error: string | null; -} - -// This is a blind backfill that only adds missing rows - it doesn't update existing ones -export async function backfillMigrationState( - client: ClientBase, - platformMembershipId: string, - bungieMembershipId: number | undefined, - state: MigrationState = MigrationState.Postgres, -): Promise { - const result = await client.query<{ state: MigrationState }>({ - name: 'backfill_migration_state', - text: `insert into migration_state (platform_membership_id, membership_id, state) VALUES ($1, $2, $3) -on conflict (platform_membership_id) do update set state = migration_state.state returning state`, - values: [platformMembershipId, bungieMembershipId, state], - }); - - return result.rows[0].state; -} - -export async function getUsersToMigrate(client: ClientBase): Promise { - const results = await client.query({ - name: 'get_users_to_migrate', - text: 'select platform_membership_id from migration_state where state != 3 limit 1000', - }); - return results.rows.map((row) => row.platform_membership_id); -} - -export async function claimMigrationWork( - client: ClientBase, - batchSize: number, -): Promise { - const results = await client.query<{ - platform_membership_id: string; - membership_id: number | null; - attempt_count: number; - }>({ - name: 'claim_migration_work', - text: `with candidates as ( - select platform_membership_id - from migration_state - where state = $1 - and attempt_count < $2 - order by last_state_change_at asc - limit $3 - ) - update migration_state - set state = $4, - attempt_count = migration_state.attempt_count + 1, - last_state_change_at = current_timestamp - from candidates - where migration_state.platform_membership_id = candidates.platform_membership_id - and migration_state.state = $1 - and migration_state.attempt_count < $2 - returning migration_state.platform_membership_id, migration_state.membership_id, migration_state.attempt_count`, - values: [ - MigrationState.Stately, - MAX_MIGRATION_ATTEMPTS, - batchSize, - MigrationState.MigratingToPostgres, - ], - }); - - return results.rows.map((row) => ({ - platformMembershipId: row.platform_membership_id, - bungieMembershipId: row.membership_id ?? undefined, - attemptCount: row.attempt_count, - })); -} - -export async function getMigrationState( - client: ClientBase, - platformMembershipId: string, -): Promise { - const results = await client.query({ - name: 'get_migration_state', - text: 'SELECT membership_id, platform_membership_id, state, last_state_change_at, attempt_count, last_error FROM migration_state WHERE platform_membership_id = $1', - values: [platformMembershipId], - }); - if (results.rows.length > 0) { - return convert(results.rows[0]); - } else { - return { - bungieMembershipId: undefined, - platformMembershipId, - state: MigrationState.Postgres, - lastStateChangeAt: 0, - attemptCount: 0, - }; - } -} - -function convert(row: MigrationStateRow): MigrationStateInfo { - return { - bungieMembershipId: row.membership_id ?? undefined, - platformMembershipId: row.platform_membership_id, - state: row.state, - lastStateChangeAt: row.last_state_change_at.getTime(), - attemptCount: row.attempt_count, - lastError: row.last_error ?? undefined, - }; -} - -export function startMigrationToPostgres( - client: ClientBase, - bungieMembershipId: number | undefined, - platformMembershipId: string, -): Promise { - return updateMigrationState( - client, - bungieMembershipId, - platformMembershipId, - MigrationState.MigratingToPostgres, - MigrationState.Stately, - true, - ); -} - -export function finishMigrationToPostgres( - client: ClientBase, - bungieMembershipId: number | undefined, - platformMembershipId: string, -): Promise { - return updateMigrationState( - client, - bungieMembershipId, - platformMembershipId, - MigrationState.Postgres, - MigrationState.MigratingToPostgres, - false, - ); -} - -export function abortMigrationToPostgres( - client: ClientBase, - bungieMembershipId: number | undefined, - platformMembershipId: string, - err: string, -): Promise { - return updateMigrationState( - client, - bungieMembershipId, - platformMembershipId, - MigrationState.Stately, - MigrationState.MigratingToPostgres, - false, - err, - ); -} - -async function updateMigrationState( - client: ClientBase, - bungieMembershipId: number | undefined, - platformMembershipId: string, - state: MigrationState, - expectedState: MigrationState, - incrementAttempt = true, - err?: string, -): Promise { - // Postgres upserts are awkward but nice to have - const response = await client.query({ - name: 'update_migration_state', - text: `insert into migration_state (platform_membership_id, membership_id, state, last_state_change_at, attempt_count, last_error) VALUES ($1, $2, $3, current_timestamp, $4, $5) -on conflict (platform_membership_id) -do update set - state = $3, - membership_id = coalesce($2, migration_state.membership_id), - last_state_change_at = current_timestamp, - attempt_count = migration_state.attempt_count + $4, - last_error = $5 -where migration_state.state = $6`, - values: [ - platformMembershipId, - bungieMembershipId ?? null, - state, - incrementAttempt ? 1 : 0, - err ?? null, - expectedState, - ], - }); - if (response.rowCount === 0) { - throw new Error('Migration state was not in expected state'); - } -} - -// Mostly for tests and delete-my-data -export async function deleteMigrationState( - client: ClientBase, - platformMembershipId: string, -): Promise { - await client.query({ - name: 'delete_migration_state', - text: 'DELETE FROM migration_state WHERE platform_membership_id = $1', - values: [platformMembershipId], - }); -} - -/** - * Unconditionally set migration state for a test account. Unlike the other - * migration state functions, this doesn't validate state transitions. - */ -export async function setMigrationStateForTest( - client: ClientBase, - platformMembershipId: string, - bungieMembershipId: number | undefined, - state: MigrationState, -): Promise { - await client.query({ - text: `INSERT INTO migration_state (platform_membership_id, membership_id, state) - VALUES ($1, $2, $3) - ON CONFLICT (platform_membership_id) - DO UPDATE SET state = $3, last_state_change_at = NOW()`, - values: [platformMembershipId, bungieMembershipId ?? null, state], - }); -} - -// const forcePostgresMembershipIds = new Set([ -// // Ben -// 7094, -// // Test user -// 1234, -// ]); - -// const dialPercentage = 1.0; // 0 - 1.0 - -// This would be better as a uniform hash but this is good enough for now -// function isUserDialedIn(bungieMembershipId: number) { -// return (bungieMembershipId % 10000) / 10000 < dialPercentage; -// } - -export async function getDesiredMigrationState(_migrationState: MigrationStateInfo) { - return MigrationState.Stately; - - // TODO: we'll handle this later - - // // TODO: use a uniform hash and a percentage dial to control this - // const desiredState = - // forceStatelyMembershipIds.has(migrationState.bungieMembershipId) || - // isUserDialedIn(migrationState.bungieMembershipId) - // ? MigrationState.Stately - // : MigrationState.Postgres; - - // if (desiredState === migrationState.state) { - // return migrationState.state; - // } - - // if ( - // desiredState === MigrationState.Stately && - // migrationState.state === MigrationState.Postgres && - // migrationState.attemptCount >= MAX_MIGRATION_ATTEMPTS - // ) { - // return MigrationState.Postgres; - // } - - // if ( - // migrationState.state === MigrationState.MigratingToStately && - // // If we've been in this state for more than 15 minutes, just move on - // migrationState.lastStateChangeAt < Date.now() - 1000 * 60 * 15 - // ) { - // await transaction(async (client) => { - // abortMigrationToStately(client, migrationState.bungieMembershipId, 'Migration timed out'); - // }); - // return MigrationState.Postgres; - // } - - // if (migrationState.state === MigrationState.MigratingToStately) { - // throw new Error('Unable to update - please wait a bit and try again.'); - // } - - // return desiredState; -} - -/** - * Wrap the migration process - start a migration, run fn(), finish the - * migration. Abort on failure. - */ -export async function doMigration( - bungieMembershipId: number, - platformMembershipId: string, - fn: () => Promise, - onBeforeFinish?: (client: ClientBase) => Promise, -): Promise { - try { - metrics.increment('migration.start.count'); - await transaction(async (client) => { - await startMigrationToPostgres(client, bungieMembershipId, platformMembershipId); - }); - await fn(); - await transaction(async (client) => { - await onBeforeFinish?.(client); - await finishMigrationToPostgres(client, bungieMembershipId, platformMembershipId); - }); - metrics.increment('migration.finish.count'); - } catch (e) { - console.error( - `Stately migration failed for ${platformMembershipId} (${bungieMembershipId})`, - e, - ); - await transaction(async (client) => { - await abortMigrationToPostgres( - client, - bungieMembershipId, - platformMembershipId, - e instanceof Error ? e.message : 'Unknown error', - ); - }); - metrics.increment('migration.abort.count'); - throw e; - } -} diff --git a/api/db/settings-queries.ts b/api/db/settings-queries.ts index 0f9badf0..23e90dd7 100644 --- a/api/db/settings-queries.ts +++ b/api/db/settings-queries.ts @@ -71,25 +71,6 @@ do update set settings = $2, deleted_at = null`, return result; } -/** - * Insert or update (upsert) an entire settings tree, totally replacing whatever's there. - */ -export async function replaceSettingsIfNotPresent( - client: ClientBase, - bungieMembershipId: number, - settings: Partial, -): Promise { - const result = await client.query({ - name: 'upsert_settings', - text: `insert into settings (membership_id, settings) -values ($1, $2) -on conflict (membership_id) -do nothing`, - values: [bungieMembershipId, settings], - }); - return result; -} - /** * Update specific key/value pairs within settings, leaving the rest alone. Creates the settings row if it doesn't exist. */ diff --git a/api/routes/delete-all-data.ts b/api/routes/delete-all-data.ts index 7eff93f5..eef0441c 100644 --- a/api/routes/delete-all-data.ts +++ b/api/routes/delete-all-data.ts @@ -4,7 +4,6 @@ import { transaction } from '../db/index.js'; import { softDeleteAllItemAnnotations } from '../db/item-annotations-queries.js'; import { softDeleteAllItemHashTags } from '../db/item-hash-tags-queries.js'; import { softDeleteAllLoadouts } from '../db/loadouts-queries.js'; -import { deleteMigrationState } from '../db/migration-state-queries.js'; import { softDeleteAllSearches } from '../db/searches-queries.js'; import { deleteSettings } from '../db/settings-queries.js'; import { softDeleteAllTrackedTriumphs } from '../db/triumphs-queries.js'; @@ -34,8 +33,6 @@ export const deleteAllDataHandler = asyncHandler(async (req, res) => { result = mergeResult(result, pgResult1); const pgResult2 = await deleteAllData(client, profileId, 2); result = mergeResult(result, pgResult2); - - await deleteMigrationState(client, profileId); } }); diff --git a/api/routes/profile.ts b/api/routes/profile.ts index 29aa5231..e1c5212b 100644 --- a/api/routes/profile.ts +++ b/api/routes/profile.ts @@ -223,8 +223,8 @@ async function loadProfile( const response: ProfileResponse = { sync: Boolean(incomingSyncTokens), }; - const timerPrefix = response.sync ? 'profileSync' : 'profileStately'; - const counterPrefix = response.sync ? 'sync' : 'stately'; + const timerPrefix = response.sync ? 'profileSync' : 'profileFull'; + const counterPrefix = response.sync ? 'sync' : 'full'; const syncTokens: { [component: string]: number } = {}; const addSyncToken = (name: string, token: { canSync: boolean; tokenData: number }) => { if (token.canSync) { diff --git a/api/routes/update.ts b/api/routes/update.ts index b8060510..3d30c4c8 100644 --- a/api/routes/update.ts +++ b/api/routes/update.ts @@ -171,7 +171,6 @@ function validateUpdates( dimVersion: `v${dimVersion?.replaceAll('.', '_') ?? 'Unknown'}`, }, }); - console.log('Stately failed update', update.action, result, appId); } results.push(result); } diff --git a/api/server.test.ts b/api/server.test.ts index cc673903..5014c06c 100644 --- a/api/server.test.ts +++ b/api/server.test.ts @@ -5,8 +5,7 @@ import { makeFetch } from 'supertest-fetch'; import { promisify } from 'util'; import { v4 as uuid } from 'uuid'; import { refreshApps, stopAppsRefresh } from './apps/index.js'; -import { closeDbPool, transaction } from './db/index.js'; -import { MigrationState, setMigrationStateForTest } from './db/migration-state-queries.js'; +import { closeDbPool } from './db/index.js'; import { app } from './server.js'; import { ApiApp } from './shapes/app.js'; import { DeleteAllResponse } from './shapes/delete-all.js'; @@ -21,25 +20,8 @@ import { defaultSettings } from './shapes/settings.js'; const fetch = makeFetch(app); -// Test backend configurations -const backendConfigs = [ - { - backend: 'Postgres', - state: MigrationState.Postgres, - bungieMembershipId: 5678, - platformMembershipId: '4611686018433092313', - async setup() { - await transaction(async (client) => { - await setMigrationStateForTest( - client, - this.platformMembershipId, - this.bungieMembershipId, - this.state, - ); - }); - }, - }, -]; +const bungieMembershipId = 1234; +const platformMembershipId = '4611686018433092312'; // Global test state let testApiKey: string; @@ -95,1127 +77,1113 @@ it('can create new apps idempotently', async () => { expect(response.dimApiKey).toEqual(testApiKey); }); -describe.each(backendConfigs)('$backend backend', (backend) => { - let testUserToken: string; - const { platformMembershipId, bungieMembershipId } = backend; - - beforeAll(async () => { - await backend.setup(); - - // Generate JWT token for this test user - testUserToken = jwt.sign( - { - profileIds: [platformMembershipId], - }, - process.env.JWT_SECRET!, - { - subject: bungieMembershipId.toString(), - issuer: testApiKey, - expiresIn: 60 * 60, - }, - ); +let testUserToken: string; + +beforeAll(async () => { + // Generate JWT token for this test user + testUserToken = jwt.sign( + { + profileIds: [platformMembershipId], + }, + process.env.JWT_SECRET!, + { + subject: bungieMembershipId.toString(), + issuer: testApiKey, + expiresIn: 60 * 60, + }, + ); +}); + +describe('import/export', () => { + it('can import and export data', async () => { + await importData(); + + const exportResponse = (await getRequestAuthed('/export').expect(200).json()) as ExportResponse; + + expect(exportResponse.settings.itemSortOrderCustom).toEqual([ + 'sunset', + 'tag', + 'primStat', + 'season', + 'ammoType', + 'rarity', + 'typeName', + 'name', + ]); + + expect(exportResponse.loadouts.length).toBe(37); + expect(exportResponse.tags.length).toBe(592); }); - // Importing will migrate to postgres, so we don't run it on the Stately data - if (backend.state === MigrationState.Postgres) { - describe('import/export', () => { - it('can import and export data', async () => { - await importData(); - - const exportResponse = (await getRequestAuthed('/export') - .expect(200) - .json()) as ExportResponse; - - expect(exportResponse.settings.itemSortOrderCustom).toEqual([ - 'sunset', - 'tag', - 'primStat', - 'season', - 'ammoType', - 'rarity', - 'typeName', - 'name', - ]); - - expect(exportResponse.loadouts.length).toBe(37); - expect(exportResponse.tags.length).toBe(592); - }); - - // TODO: other import formats, validation - }); - } - - describe('profile', () => { - // Applies only to tests in this describe block - beforeEach(importData); - - it('can retrieve all profile data', async () => { - const profileResponse = (await getRequestAuthed( - `/profile?components=settings,loadouts,tags,triumphs,searches,hashtags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse.settings!.itemSortOrderCustom).toEqual([ - 'sunset', - 'tag', - 'primStat', - 'season', - 'ammoType', - 'rarity', - 'typeName', - 'name', - ]); - expect(profileResponse.loadouts!.length).toBe(19); - expect(profileResponse.tags!.length).toBe(592); - expect(profileResponse.triumphs!.length).toBe(30); - expect(profileResponse.searches!.length).toBe(208); - expect(profileResponse.itemHashTags!.length).toBe(71); - expect(profileResponse.syncToken).toBeDefined(); - }); + // TODO: other import formats, validation +}); - it('can sync profile data', async () => { - const profileResponse = (await getRequestAuthed( - `/profile?components=settings,loadouts,tags,triumphs,searches,hashtags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse.settings!.itemSortOrderCustom).toEqual([ - 'sunset', - 'tag', - 'primStat', - 'season', - 'ammoType', - 'rarity', - 'typeName', - 'name', - ]); - expect(profileResponse.tags!.length).toBe(592); - expect(profileResponse.loadouts!.length).toBe(19); - expect(profileResponse.sync).toBe(false); - - await delay(15); - - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'setting', - payload: { - showNewItems: true, - }, - }, - { - action: 'tag', - payload: { - id: '1234', - tag: 'favorite', - }, +describe('profile', () => { + // Applies only to tests in this describe block + beforeEach(importData); + + it('can retrieve all profile data', async () => { + const profileResponse = (await getRequestAuthed( + `/profile?components=settings,loadouts,tags,triumphs,searches,hashtags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.settings!.itemSortOrderCustom).toEqual([ + 'sunset', + 'tag', + 'primStat', + 'season', + 'ammoType', + 'rarity', + 'typeName', + 'name', + ]); + expect(profileResponse.loadouts!.length).toBe(19); + expect(profileResponse.tags!.length).toBe(592); + expect(profileResponse.triumphs!.length).toBe(30); + expect(profileResponse.searches!.length).toBe(208); + expect(profileResponse.itemHashTags!.length).toBe(71); + expect(profileResponse.syncToken).toBeDefined(); + }); + + it('can sync profile data', async () => { + const profileResponse = (await getRequestAuthed( + `/profile?components=settings,loadouts,tags,triumphs,searches,hashtags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.settings!.itemSortOrderCustom).toEqual([ + 'sunset', + 'tag', + 'primStat', + 'season', + 'ammoType', + 'rarity', + 'typeName', + 'name', + ]); + expect(profileResponse.tags!.length).toBe(592); + expect(profileResponse.loadouts!.length).toBe(19); + expect(profileResponse.sync).toBe(false); + + await delay(15); + + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'setting', + payload: { + showNewItems: true, }, - { - action: 'delete_loadout', - payload: profileResponse.loadouts![0].id, + }, + { + action: 'tag', + payload: { + id: '1234', + tag: 'favorite', }, - ], - }; - await postRequestAuthed('/profile', request).expect(200); - - const profileSyncResponse = (await getRequestAuthed( - `/profile?components=settings,loadouts,tags,triumphs,searches,hashtags&platformMembershipId=${platformMembershipId}&sync=${encodeURIComponent(profileResponse.syncToken!)}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileSyncResponse.syncToken).toBeDefined(); - expect(profileSyncResponse.syncToken).not.toBe(profileResponse.syncToken); - expect(profileSyncResponse.syncToken).toContain('"s":'); - expect(profileSyncResponse.sync).toBe(true); - expect(profileSyncResponse.settings?.showNewItems).toBe(true); - expect(profileSyncResponse.tags?.length).toBe(1); - expect(profileSyncResponse.tags?.[0].id).toBe('1234'); - expect(profileSyncResponse.deletedLoadoutIds?.length).toBe(1); - - const request2: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'setting', - payload: { - compareBaseStats: true, - }, + }, + { + action: 'delete_loadout', + payload: profileResponse.loadouts![0].id, + }, + ], + }; + await postRequestAuthed('/profile', request).expect(200); + + const profileSyncResponse = (await getRequestAuthed( + `/profile?components=settings,loadouts,tags,triumphs,searches,hashtags&platformMembershipId=${platformMembershipId}&sync=${encodeURIComponent(profileResponse.syncToken!)}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileSyncResponse.syncToken).toBeDefined(); + expect(profileSyncResponse.syncToken).not.toBe(profileResponse.syncToken); + expect(profileSyncResponse.syncToken).toContain('"s":'); + expect(profileSyncResponse.sync).toBe(true); + expect(profileSyncResponse.settings?.showNewItems).toBe(true); + expect(profileSyncResponse.tags?.length).toBe(1); + expect(profileSyncResponse.tags?.[0].id).toBe('1234'); + expect(profileSyncResponse.deletedLoadoutIds?.length).toBe(1); + + const request2: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'setting', + payload: { + compareBaseStats: true, }, - ], - }; - await postRequestAuthed('/profile', request2).expect(200); - - await delay(15); - - const profileSyncResponse2 = (await getRequestAuthed( - `/profile?components=settings,loadouts,tags,triumphs,searches,hashtags&platformMembershipId=${platformMembershipId}&sync=${encodeURIComponent(profileSyncResponse.syncToken!)}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileSyncResponse2.syncToken).toBeDefined(); - expect(profileSyncResponse2.syncToken).not.toBe(profileSyncResponse.syncToken); - expect(profileSyncResponse2.syncToken).toContain('"s":'); - expect(profileSyncResponse2.sync).toBe(true); - expect(profileSyncResponse2.settings).toBeDefined(); - expect(profileSyncResponse2.settings?.compareBaseStats).toBe(true); - expect(profileSyncResponse2.settings?.showNewItems).toBe(true); - }); + }, + ], + }; + await postRequestAuthed('/profile', request2).expect(200); - it('can retrieve only settings, without needing a platform membership ID', async () => { - const profileResponse = (await getRequestAuthed('/profile?components=settings') - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse.settings!.itemSortOrderCustom).toEqual([ - 'sunset', - 'tag', - 'primStat', - 'season', - 'ammoType', - 'rarity', - 'typeName', - 'name', - ]); - expect(profileResponse.loadouts).toBeUndefined(); - expect(profileResponse.tags).toBeUndefined(); - expect(profileResponse.triumphs).toBeUndefined(); - }); + await delay(15); + + const profileSyncResponse2 = (await getRequestAuthed( + `/profile?components=settings,loadouts,tags,triumphs,searches,hashtags&platformMembershipId=${platformMembershipId}&sync=${encodeURIComponent(profileSyncResponse.syncToken!)}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileSyncResponse2.syncToken).toBeDefined(); + expect(profileSyncResponse2.syncToken).not.toBe(profileSyncResponse.syncToken); + expect(profileSyncResponse2.syncToken).toContain('"s":'); + expect(profileSyncResponse2.sync).toBe(true); + expect(profileSyncResponse2.settings).toBeDefined(); + expect(profileSyncResponse2.settings?.compareBaseStats).toBe(true); + expect(profileSyncResponse2.settings?.showNewItems).toBe(true); + }); - it('can retrieve only loadouts', async () => { - const profileResponse = (await getRequestAuthed( - `/profile?components=loadouts&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; + it('can retrieve only settings, without needing a platform membership ID', async () => { + const profileResponse = (await getRequestAuthed('/profile?components=settings') + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.settings!.itemSortOrderCustom).toEqual([ + 'sunset', + 'tag', + 'primStat', + 'season', + 'ammoType', + 'rarity', + 'typeName', + 'name', + ]); + expect(profileResponse.loadouts).toBeUndefined(); + expect(profileResponse.tags).toBeUndefined(); + expect(profileResponse.triumphs).toBeUndefined(); + }); - expect(profileResponse.settings).toBeUndefined(); - expect(profileResponse.loadouts!.length).toBe(19); - expect(profileResponse.tags).toBeUndefined(); - }); + it('can retrieve only loadouts', async () => { + const profileResponse = (await getRequestAuthed( + `/profile?components=loadouts&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; - it('can delete all data with /delete_all_data', async () => { - const response = (await postRequestAuthed('/delete_all_data') - .expect(200) - .json()) as DeleteAllResponse; - - expect(response.deleted.itemHashTags).toBe(71); - expect(response.deleted.loadouts).toBe(37); - expect(response.deleted.searches).toBeGreaterThanOrEqual(205); - expect(response.deleted.settings).toBe(1); - expect(response.deleted.tags).toBe(592); - expect(response.deleted.triumphs).toBe(30); - - // Now re-export and make sure it's all gone - const exportResponse = (await getRequestAuthed('/export') - .expect(200) - .json()) as ExportResponse; - - expect(Object.keys(exportResponse.settings).length).toBe(0); - expect(exportResponse.loadouts.length).toBe(0); - expect(exportResponse.tags.length).toBe(0); - }); + expect(profileResponse.settings).toBeUndefined(); + expect(profileResponse.loadouts!.length).toBe(19); + expect(profileResponse.tags).toBeUndefined(); }); - describe('settings', () => { - beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); + it('can delete all data with /delete_all_data', async () => { + const response = (await postRequestAuthed('/delete_all_data') + .expect(200) + .json()) as DeleteAllResponse; - it('returns default settings', async () => { - const profileResponse = (await getRequestAuthed('/profile?components=settings') - .expect(200) - .json()) as ProfileResponse; + expect(response.deleted.itemHashTags).toBe(71); + expect(response.deleted.loadouts).toBe(37); + expect(response.deleted.searches).toBeGreaterThanOrEqual(205); + expect(response.deleted.settings).toBe(1); + expect(response.deleted.tags).toBe(592); + expect(response.deleted.triumphs).toBe(30); - expect(profileResponse.settings).toEqual(defaultSettings); - }); + // Now re-export and make sure it's all gone + const exportResponse = (await getRequestAuthed('/export').expect(200).json()) as ExportResponse; - it('can update a setting', async () => { - const request: ProfileUpdateRequest = { - updates: [ - { - action: 'setting', - payload: { - showNewItems: true, - }, - }, - ], - }; + expect(Object.keys(exportResponse.settings).length).toBe(0); + expect(exportResponse.loadouts.length).toBe(0); + expect(exportResponse.tags.length).toBe(0); + }); +}); - await postRequestAuthed('/profile', request).expect(200); +describe('settings', () => { + beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); - // Read settings back - const profileResponse = (await getRequestAuthed('/profile?components=settings') - .expect(200) - .json()) as ProfileResponse; + it('returns default settings', async () => { + const profileResponse = (await getRequestAuthed('/profile?components=settings') + .expect(200) + .json()) as ProfileResponse; - expect(profileResponse.settings?.showNewItems).toBe(true); - }); + expect(profileResponse.settings).toEqual(defaultSettings); }); - const loadout: Loadout = { - id: uuid(), - name: 'Test Loadout', - classType: 1, - equipped: [ - { - hash: 100, - id: '1234', - socketOverrides: { 7: 9 }, - }, - ], - unequipped: [ - // This item has an extra property which shouldn't be saved - { - hash: 200, - id: '5678', - amount: 10, - fizbuzz: 11, - } as any as LoadoutItem, - ], - }; - - describe('loadouts', () => { - beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); - - it('can add a loadout', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'loadout', - payload: loadout, + it('can update a setting', async () => { + const request: ProfileUpdateRequest = { + updates: [ + { + action: 'setting', + payload: { + showNewItems: true, }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - // Read loadouts back - const profileResponse = (await getRequestAuthed( - `/profile?components=loadouts&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse.loadouts?.length).toBe(1); - const resultLoadout = profileResponse.loadouts![0]; - expect(resultLoadout.id).toBe(loadout.id); - expect(resultLoadout.name).toBe(loadout.name); - expect(resultLoadout.classType).toBe(loadout.classType); - expect(resultLoadout.equipped).toEqual(loadout.equipped); - // This property should have been stripped - expect((resultLoadout.unequipped[0] as { fizbuzz?: string }).fizbuzz).toBeUndefined(); - }); + }, + ], + }; - it('can update a loadout', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'loadout', - payload: loadout, - }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - // Change name - const request2: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'loadout', - payload: { ...loadout, name: 'Updated Name' }, - }, - ], - }; + await postRequestAuthed('/profile', request).expect(200); + + // Read settings back + const profileResponse = (await getRequestAuthed('/profile?components=settings') + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.settings?.showNewItems).toBe(true); + }); +}); - const updateResult2 = (await postRequestAuthed('/profile', request2) - .expect(200) - .json()) as ProfileUpdateResponse; +const loadout: Loadout = { + id: uuid(), + name: 'Test Loadout', + classType: 1, + equipped: [ + { + hash: 100, + id: '1234', + socketOverrides: { 7: 9 }, + }, + ], + unequipped: [ + // This item has an extra property which shouldn't be saved + { + hash: 200, + id: '5678', + amount: 10, + fizbuzz: 11, + } as any as LoadoutItem, + ], +}; + +describe('loadouts', () => { + beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); + + it('can add a loadout', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'loadout', + payload: loadout, + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; - expect(updateResult2.results[0].status).toBe('Success'); + expect(updateResult.results[0].status).toBe('Success'); - // Read loadouts back - const profileResponse = (await getRequestAuthed( - `/profile?components=loadouts&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; + // Read loadouts back + const profileResponse = (await getRequestAuthed( + `/profile?components=loadouts&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.loadouts?.length).toBe(1); + const resultLoadout = profileResponse.loadouts![0]; + expect(resultLoadout.id).toBe(loadout.id); + expect(resultLoadout.name).toBe(loadout.name); + expect(resultLoadout.classType).toBe(loadout.classType); + expect(resultLoadout.equipped).toEqual(loadout.equipped); + // This property should have been stripped + expect((resultLoadout.unequipped[0] as { fizbuzz?: string }).fizbuzz).toBeUndefined(); + }); - expect(profileResponse.loadouts?.length).toBe(1); - expect(profileResponse.loadouts![0].name).toBe('Updated Name'); - }); + it('can update a loadout', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'loadout', + payload: loadout, + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + + // Change name + const request2: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'loadout', + payload: { ...loadout, name: 'Updated Name' }, + }, + ], + }; + + const updateResult2 = (await postRequestAuthed('/profile', request2) + .expect(200) + .json()) as ProfileUpdateResponse; - it('can delete a loadout', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'loadout', - payload: loadout, - }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - // Delete the loadout - const request2: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'delete_loadout', - payload: loadout.id, - }, - ], - }; + expect(updateResult2.results[0].status).toBe('Success'); - const updateResult2 = (await postRequestAuthed('/profile', request2) - .expect(200) - .json()) as ProfileUpdateResponse; + // Read loadouts back + const profileResponse = (await getRequestAuthed( + `/profile?components=loadouts&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; - expect(updateResult2.results[0].status).toBe('Success'); + expect(profileResponse.loadouts?.length).toBe(1); + expect(profileResponse.loadouts![0].name).toBe('Updated Name'); + }); - // Read loadouts back - const profileResponse = (await getRequestAuthed( - `/profile?components=loadouts&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; + it('can delete a loadout', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'loadout', + payload: loadout, + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + + // Delete the loadout + const request2: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'delete_loadout', + payload: loadout.id, + }, + ], + }; + + const updateResult2 = (await postRequestAuthed('/profile', request2) + .expect(200) + .json()) as ProfileUpdateResponse; - expect(profileResponse.loadouts?.length).toBe(0); - }); + expect(updateResult2.results[0].status).toBe('Success'); + + // Read loadouts back + const profileResponse = (await getRequestAuthed( + `/profile?components=loadouts&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.loadouts?.length).toBe(0); }); +}); - describe('tags', () => { - beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); - - it('can add a tag', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'tag', - payload: { - id: '1234', - tag: 'favorite', - }, +describe('tags', () => { + beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); + + it('can add a tag', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'tag', + payload: { + id: '1234', + tag: 'favorite', }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=tags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse.tags?.length).toBe(1); - const resultTag = profileResponse.tags![0]; - expect(resultTag).toEqual({ - id: '1234', - tag: 'favorite', - }); + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=tags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.tags?.length).toBe(1); + const resultTag = profileResponse.tags![0]; + expect(resultTag).toEqual({ + id: '1234', + tag: 'favorite', }); + }); - it('can update a tag', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'tag', - payload: { - id: '12345', - tag: 'favorite', - }, - }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - // Change tag and notes - const request2: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'tag', - payload: { - id: '12345', - tag: 'junk', - notes: 'super junky', - }, + it('can update a tag', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'tag', + payload: { + id: '12345', + tag: 'favorite', }, - ], - }; - - const updateResult2 = (await postRequestAuthed('/profile', request2) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult2.results[0].status).toBe('Success'); - - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=tags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse.tags?.length).toBe(1); - const resultTag = profileResponse.tags![0]; - expect(resultTag).toEqual({ - id: '12345', - tag: 'junk', - notes: 'super junky', - }); - - // Delete tag - const request3: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'tag', - payload: { - id: '12345', - tag: null, - }, + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + + // Change tag and notes + const request2: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'tag', + payload: { + id: '12345', + tag: 'junk', + notes: 'super junky', }, - ], - }; - - const updateResult3 = (await postRequestAuthed('/profile', request3) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult3.results[0].status).toBe('Success'); - - // Read tags back after deleting the tag - const profileResponse2 = (await getRequestAuthed( - `/profile?components=tags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse2.tags?.length).toBe(1); - const resultTag2 = profileResponse2.tags![0]; - expect(resultTag2).toEqual({ - id: '12345', - notes: 'super junky', - }); + }, + ], + }; + + const updateResult2 = (await postRequestAuthed('/profile', request2) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult2.results[0].status).toBe('Success'); + + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=tags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.tags?.length).toBe(1); + const resultTag = profileResponse.tags![0]; + expect(resultTag).toEqual({ + id: '12345', + tag: 'junk', + notes: 'super junky', }); - it('can delete a tag', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'tag', - payload: { - id: '1234567', - tag: 'favorite', - notes: 'the best', - }, - }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - // delete tag and notes - const request2: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'tag', - payload: { - id: '1234567', - tag: null, - notes: '', - }, + // Delete tag + const request3: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'tag', + payload: { + id: '12345', + tag: null, }, - ], - }; + }, + ], + }; - const updateResult2 = (await postRequestAuthed('/profile', request2) - .expect(200) - .json()) as ProfileUpdateResponse; + const updateResult3 = (await postRequestAuthed('/profile', request3) + .expect(200) + .json()) as ProfileUpdateResponse; - expect(updateResult2.results[0].status).toBe('Success'); + expect(updateResult3.results[0].status).toBe('Success'); - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=tags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; + // Read tags back after deleting the tag + const profileResponse2 = (await getRequestAuthed( + `/profile?components=tags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; - expect(profileResponse.tags?.length).toBe(0); + expect(profileResponse2.tags?.length).toBe(1); + const resultTag2 = profileResponse2.tags![0]; + expect(resultTag2).toEqual({ + id: '12345', + notes: 'super junky', }); + }); - it('can clear tags', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'tag', - payload: { - id: '1234567', - tag: 'favorite', - notes: 'the best', - }, + it('can delete a tag', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'tag', + payload: { + id: '1234567', + tag: 'favorite', + notes: 'the best', }, - { - action: 'tag', - payload: { - id: '7654321', - tag: 'junk', - notes: 'the worst', - }, + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + + // delete tag and notes + const request2: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'tag', + payload: { + id: '1234567', + tag: null, + notes: '', + }, + }, + ], + }; + + const updateResult2 = (await postRequestAuthed('/profile', request2) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult2.results[0].status).toBe('Success'); + + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=tags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.tags?.length).toBe(0); + }); + + it('can clear tags', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'tag', + payload: { + id: '1234567', + tag: 'favorite', + notes: 'the best', }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - expect(updateResult.results[1].status).toBe('Success'); - - // cleanup tags by id - const request2: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'tag_cleanup', - payload: ['1234567', '7654321'], + }, + { + action: 'tag', + payload: { + id: '7654321', + tag: 'junk', + notes: 'the worst', }, - ], - }; + }, + ], + }; - const updateResult2 = (await postRequestAuthed('/profile', request2) - .expect(200) - .json()) as ProfileUpdateResponse; + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + expect(updateResult.results[1].status).toBe('Success'); + + // cleanup tags by id + const request2: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'tag_cleanup', + payload: ['1234567', '7654321'], + }, + ], + }; + + const updateResult2 = (await postRequestAuthed('/profile', request2) + .expect(200) + .json()) as ProfileUpdateResponse; - expect(updateResult2.results[0].status).toBe('Success'); + expect(updateResult2.results[0].status).toBe('Success'); - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=tags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=tags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; - expect(profileResponse.tags?.length).toBe(0); - }); + expect(profileResponse.tags?.length).toBe(0); }); +}); - describe('item hash tags', () => { - beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); - - it('can add an item hash tag', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'item_hash_tag', - payload: { - hash: 1234, - tag: 'favorite', - }, +describe('item hash tags', () => { + beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); + + it('can add an item hash tag', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'item_hash_tag', + payload: { + hash: 1234, + tag: 'favorite', }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=hashtags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse.itemHashTags?.length).toBe(1); - const resultTag = profileResponse.itemHashTags![0]; - expect(resultTag).toEqual({ - hash: 1234, - tag: 'favorite', - }); + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=hashtags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.itemHashTags?.length).toBe(1); + const resultTag = profileResponse.itemHashTags![0]; + expect(resultTag).toEqual({ + hash: 1234, + tag: 'favorite', }); + }); - it('can update an item hash tag', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'item_hash_tag', - payload: { - hash: 1234, - tag: 'favorite', - }, + it('can update an item hash tag', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'item_hash_tag', + payload: { + hash: 1234, + tag: 'favorite', }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - // Change tag and notes - const request2: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'item_hash_tag', - payload: { - hash: 1234, - tag: 'junk', - notes: 'super junky', - }, + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + + // Change tag and notes + const request2: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'item_hash_tag', + payload: { + hash: 1234, + tag: 'junk', + notes: 'super junky', }, - ], - }; - - const updateResult2 = (await postRequestAuthed('/profile', request2) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult2.results[0].status).toBe('Success'); - - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=hashtags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse.itemHashTags?.length).toBe(1); - const resultTag = profileResponse.itemHashTags![0]; - expect(resultTag).toEqual({ - hash: 1234, - tag: 'junk', - notes: 'super junky', - }); - - // Delete tag - const request3: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'item_hash_tag', - payload: { - hash: 1234, - tag: null, - }, + }, + ], + }; + + const updateResult2 = (await postRequestAuthed('/profile', request2) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult2.results[0].status).toBe('Success'); + + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=hashtags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse.itemHashTags?.length).toBe(1); + const resultTag = profileResponse.itemHashTags![0]; + expect(resultTag).toEqual({ + hash: 1234, + tag: 'junk', + notes: 'super junky', + }); + + // Delete tag + const request3: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'item_hash_tag', + payload: { + hash: 1234, + tag: null, }, - ], - }; - - const updateResult3 = (await postRequestAuthed('/profile', request3) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult3.results[0].status).toBe('Success'); - - // Read tags back after deleting the tag - const profileResponse2 = (await getRequestAuthed( - `/profile?components=hashtags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse2.itemHashTags?.length).toBe(1); - const resultTag2 = profileResponse2.itemHashTags![0]; - expect(resultTag2).toEqual({ - hash: 1234, - notes: 'super junky', - }); + }, + ], + }; + + const updateResult3 = (await postRequestAuthed('/profile', request3) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult3.results[0].status).toBe('Success'); + + // Read tags back after deleting the tag + const profileResponse2 = (await getRequestAuthed( + `/profile?components=hashtags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; + + expect(profileResponse2.itemHashTags?.length).toBe(1); + const resultTag2 = profileResponse2.itemHashTags![0]; + expect(resultTag2).toEqual({ + hash: 1234, + notes: 'super junky', }); + }); - it('can delete an item hash tag', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'item_hash_tag', - payload: { - hash: 1234, - tag: 'favorite', - notes: 'the best', - }, + it('can delete an item hash tag', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'item_hash_tag', + payload: { + hash: 1234, + tag: 'favorite', + notes: 'the best', }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - // delete tag and notes - const request2: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'item_hash_tag', - payload: { - hash: 1234, - tag: null, - notes: '', - }, + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + + // delete tag and notes + const request2: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'item_hash_tag', + payload: { + hash: 1234, + tag: null, + notes: '', }, - ], - }; + }, + ], + }; - const updateResult2 = (await postRequestAuthed('/profile', request2) - .expect(200) - .json()) as ProfileUpdateResponse; + const updateResult2 = (await postRequestAuthed('/profile', request2) + .expect(200) + .json()) as ProfileUpdateResponse; - expect(updateResult2.results[0].status).toBe('Success'); + expect(updateResult2.results[0].status).toBe('Success'); - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=tags&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=tags&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; - expect(profileResponse.tags?.length).toBe(0); - }); + expect(profileResponse.tags?.length).toBe(0); }); +}); - describe('triumphs', () => { - beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); - - it('can add a tracked triumph', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'track_triumph', - payload: { - recordHash: 1234, - tracked: true, - }, +describe('triumphs', () => { + beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); + + it('can add a tracked triumph', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'track_triumph', + payload: { + recordHash: 1234, + tracked: true, }, - ], - }; + }, + ], + }; - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; - expect(updateResult.results[0].status).toBe('Success'); + expect(updateResult.results[0].status).toBe('Success'); - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=triumphs&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=triumphs&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; - expect(profileResponse.triumphs?.length).toBe(1); - expect(profileResponse.triumphs!).toEqual([1234]); - }); + expect(profileResponse.triumphs?.length).toBe(1); + expect(profileResponse.triumphs!).toEqual([1234]); + }); - it('can remove a tracked triumph', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'track_triumph', - payload: { - recordHash: 1234, - tracked: true, - }, + it('can remove a tracked triumph', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'track_triumph', + payload: { + recordHash: 1234, + tracked: true, }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - const request2: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'track_triumph', - payload: { - recordHash: 1234, - tracked: false, - }, + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + + const request2: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'track_triumph', + payload: { + recordHash: 1234, + tracked: false, }, - ], - }; + }, + ], + }; - const updateResult2 = (await postRequestAuthed('/profile', request2) - .expect(200) - .json()) as ProfileUpdateResponse; + const updateResult2 = (await postRequestAuthed('/profile', request2) + .expect(200) + .json()) as ProfileUpdateResponse; - expect(updateResult.results[0].status).toBe('Success'); + expect(updateResult.results[0].status).toBe('Success'); - expect(updateResult2.results[0].status).toBe('Success'); + expect(updateResult2.results[0].status).toBe('Success'); - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=triumphs&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=triumphs&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; - expect(profileResponse.triumphs?.length).toBe(0); - }); + expect(profileResponse.triumphs?.length).toBe(0); + }); - it('can set the same state twice', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'track_triumph', - payload: { - recordHash: 1234, - tracked: true, - }, + it('can set the same state twice', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'track_triumph', + payload: { + recordHash: 1234, + tracked: true, }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - const request2: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'track_triumph', - payload: { - recordHash: 1234, - tracked: true, - }, + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; + + expect(updateResult.results[0].status).toBe('Success'); + + const request2: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'track_triumph', + payload: { + recordHash: 1234, + tracked: true, }, - ], - }; + }, + ], + }; - const updateResult2 = (await postRequestAuthed('/profile', request2) - .expect(200) - .json()) as ProfileUpdateResponse; + const updateResult2 = (await postRequestAuthed('/profile', request2) + .expect(200) + .json()) as ProfileUpdateResponse; - expect(updateResult2.results[0].status).toBe('Success'); + expect(updateResult2.results[0].status).toBe('Success'); - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=triumphs&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=triumphs&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; - expect(profileResponse.triumphs?.length).toBe(1); - expect(profileResponse.triumphs!).toEqual([1234]); - }); + expect(profileResponse.triumphs?.length).toBe(1); + expect(profileResponse.triumphs!).toEqual([1234]); }); +}); - describe('searches', () => { - beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); - - it('can add a recent search', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'search', - payload: { - query: 'tag:favorite', - type: SearchType.Item, - }, +describe('searches', () => { + beforeEach(async () => postRequestAuthed('/delete_all_data').expect(200)); + + it('can add a recent search', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'search', + payload: { + query: 'tag:favorite', + type: SearchType.Item, }, - ], - }; + }, + ], + }; - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; - expect(updateResult.results[0].status).toBe('Success'); + expect(updateResult.results[0].status).toBe('Success'); - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=searches&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=searches&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; - expect(profileResponse.searches?.filter((s) => s.usageCount > 0)?.length).toBe(1); - expect(profileResponse.searches![0].query).toBe('tag:favorite'); - expect(profileResponse.searches![0].usageCount).toBe(1); - }); + expect(profileResponse.searches?.filter((s) => s.usageCount > 0)?.length).toBe(1); + expect(profileResponse.searches![0].query).toBe('tag:favorite'); + expect(profileResponse.searches![0].usageCount).toBe(1); + }); - it('can save a search', async () => { - const request: ProfileUpdateRequest = { - platformMembershipId, - destinyVersion: 2, - updates: [ - { - action: 'search', - payload: { - query: 'tag:favorite', - type: SearchType.Item, - }, + it('can save a search', async () => { + const request: ProfileUpdateRequest = { + platformMembershipId, + destinyVersion: 2, + updates: [ + { + action: 'search', + payload: { + query: 'tag:favorite', + type: SearchType.Item, }, - { - action: 'save_search', - payload: { - query: 'tag:favorite', - type: SearchType.Item, - saved: true, - }, + }, + { + action: 'save_search', + payload: { + query: 'tag:favorite', + type: SearchType.Item, + saved: true, }, - ], - }; - - const updateResult = (await postRequestAuthed('/profile', request) - .expect(200) - .json()) as ProfileUpdateResponse; - - expect(updateResult.results[0].status).toBe('Success'); - - // Read tags back - const profileResponse = (await getRequestAuthed( - `/profile?components=searches&platformMembershipId=${platformMembershipId}`, - ) - .expect(200) - .json()) as ProfileResponse; - - expect(profileResponse.searches?.filter((s) => s.usageCount > 0)?.length).toBe(1); - expect(profileResponse.searches![0].query).toBe('tag:favorite'); - expect(profileResponse.searches![0].saved).toBe(true); - expect(profileResponse.searches![0].usageCount).toBe(1); - }); - }); + }, + ], + }; + + const updateResult = (await postRequestAuthed('/profile', request) + .expect(200) + .json()) as ProfileUpdateResponse; - describe('loadouts', () => { - it('can share a loadout', async () => { - const request: LoadoutShareRequest = { - platformMembershipId, - loadout, - }; + expect(updateResult.results[0].status).toBe('Success'); - const updateResult = (await postRequestAuthed('/loadout_share', request) - .expect(200) - .json()) as LoadoutShareResponse; + // Read tags back + const profileResponse = (await getRequestAuthed( + `/profile?components=searches&platformMembershipId=${platformMembershipId}`, + ) + .expect(200) + .json()) as ProfileResponse; - expect(updateResult.shareUrl).toMatch(/https:\/\/dim.gg\/[a-z0-9]{7}\/Test-Loadout/); - }); + expect(profileResponse.searches?.filter((s) => s.usageCount > 0)?.length).toBe(1); + expect(profileResponse.searches![0].query).toBe('tag:favorite'); + expect(profileResponse.searches![0].saved).toBe(true); + expect(profileResponse.searches![0].usageCount).toBe(1); }); +}); + +describe('loadouts', () => { + it('can share a loadout', async () => { + const request: LoadoutShareRequest = { + platformMembershipId, + loadout, + }; - function getRequestAuthed(url: string) { - return fetch(url, { - headers: { - 'X-API-Key': testApiKey, - Authorization: `Bearer ${testUserToken}`, - }, - }).expect('Content-Type', /json/); - } - - function postRequestAuthed(url: string, body?: any) { - return fetch(url, { - method: 'POST', - headers: { - 'X-API-Key': testApiKey, - Authorization: `Bearer ${testUserToken}`, - 'Content-Type': 'application/json', - }, - body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined, - }).expect('Content-Type', /json/); - } - - async function importData() { - const file = JSON.parse( - (await promisify(readFile)('./dim-data.json')) - .toString() - .replaceAll('"4611686018433092312"', `"${backend.platformMembershipId}"`), - ) as ExportResponse; - - const resp = (await postRequestAuthed('/import', file).expect(200).json()) as ImportResponse; - expect(resp.tags).toBeGreaterThan(1); - - return file; - } + const updateResult = (await postRequestAuthed('/loadout_share', request) + .expect(200) + .json()) as LoadoutShareResponse; + + expect(updateResult.shareUrl).toMatch(/https:\/\/dim.gg\/[a-z0-9]{7}\/Test-Loadout/); + }); }); +function getRequestAuthed(url: string) { + return fetch(url, { + headers: { + 'X-API-Key': testApiKey, + Authorization: `Bearer ${testUserToken}`, + }, + }).expect('Content-Type', /json/); +} + +function postRequestAuthed(url: string, body?: any) { + return fetch(url, { + method: 'POST', + headers: { + 'X-API-Key': testApiKey, + Authorization: `Bearer ${testUserToken}`, + 'Content-Type': 'application/json', + }, + body: body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined, + }).expect('Content-Type', /json/); +} + +async function importData() { + const file = JSON.parse( + (await promisify(readFile)('./dim-data.json')).toString(), + ) as ExportResponse; + + const resp = (await postRequestAuthed('/import', file).expect(200).json()) as ImportResponse; + expect(resp.tags).toBeGreaterThan(1); + + return file; +} + async function createApp() { const response = (await fetch('/new_app', { method: 'POST', diff --git a/api/stately/bulk-queries.test.ts b/api/stately/bulk-queries.test.ts deleted file mode 100644 index 9492d3d7..00000000 --- a/api/stately/bulk-queries.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { SearchType } from '../shapes/search.js'; -import { deleteAllDataForUser, exportDataForProfile } from './bulk-queries.js'; -import { client } from './client.js'; -import { getItemAnnotationsForProfile, updateItemAnnotation } from './item-annotations-queries.js'; -import { getItemHashTagsForProfile, updateItemHashTag } from './item-hash-tags-queries.js'; -import { getLoadoutsForProfile, updateLoadout } from './loadouts-queries.js'; -import { loadout } from './loadouts-queries.test.js'; -import { getSearchesForProfile, updateSearches } from './searches-queries.js'; -import { getSettings, setSetting } from './settings-queries.js'; -import { getTrackedTriumphsForProfile, trackUntrackTriumphs } from './triumphs-queries.js'; - -const platformMembershipId = '213512057'; -const bungieMembershipId = 4321; - -beforeEach(async () => deleteAllDataForUser(bungieMembershipId, [platformMembershipId])); - -describe('deleteAllDataForUser', () => { - it('should delete all kinds of data', async () => { - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: 'favorite', - notes: 'the best', - }, - ]); - await updateItemAnnotation(txn, platformMembershipId, 1, [ - { - id: '1234567', - tag: 'favorite', - notes: 'the best??', - }, - ]); - await updateItemHashTag(txn, platformMembershipId, { - hash: 2926662838, - tag: 'favorite', - notes: 'the best', - }); - await updateLoadout(txn, platformMembershipId, 2, [loadout]); - await updateSearches(txn, platformMembershipId, 1, [ - { - query: 'is:handcannon', - type: SearchType.Item, - incrementUsed: 1, - saved: false, - deleted: false, - }, - ]); - await updateSearches(txn, platformMembershipId, 2, [ - { - query: 'tag:junk', - type: SearchType.Item, - incrementUsed: 1, - saved: false, - deleted: false, - }, - ]); - await trackUntrackTriumphs(txn, platformMembershipId, [ - { recordHash: 3851137658, tracked: true }, - ]); - await setSetting(txn, bungieMembershipId, { - showNewItems: true, - }); - }); - - await deleteAllDataForUser(bungieMembershipId, [platformMembershipId]); - - expect((await getItemAnnotationsForProfile(platformMembershipId, 2)).tags).toEqual([]); - expect((await getItemAnnotationsForProfile(platformMembershipId, 1)).tags).toEqual([]); - expect((await getItemHashTagsForProfile(platformMembershipId)).hashTags).toEqual([]); - expect((await getLoadoutsForProfile(platformMembershipId, 2)).loadouts).toEqual([]); - expect( - (await getSearchesForProfile(platformMembershipId, 1)).searches.filter( - (s) => s.usageCount > 0, - ), - ).toEqual([]); - expect( - (await getSearchesForProfile(platformMembershipId, 2)).searches.filter( - (s) => s.usageCount > 0, - ), - ).toEqual([]); - expect((await getTrackedTriumphsForProfile(platformMembershipId)).triumphs).toEqual([]); - expect((await getSettings(bungieMembershipId))?.showNewItems).toBe(undefined); - }); -}); - -describe('exportDataForProfile', () => { - it('should export all kinds of data', async () => { - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: 'favorite', - notes: 'the best', - }, - ]); - await updateItemAnnotation(txn, platformMembershipId, 1, [ - { - id: '1234567', - tag: 'favorite', - notes: 'the best??', - }, - ]); - await updateItemHashTag(txn, platformMembershipId, { - hash: 2926662838, - tag: 'favorite', - notes: 'the best', - }); - await updateLoadout(txn, platformMembershipId, 2, [loadout]); - await updateSearches(txn, platformMembershipId, 1, [ - { - query: 'is:handcannon', - type: SearchType.Item, - incrementUsed: 1, - saved: false, - deleted: false, - }, - ]); - await updateSearches(txn, platformMembershipId, 2, [ - { - query: 'tag:junk', - type: SearchType.Item, - incrementUsed: 1, - saved: false, - deleted: false, - }, - ]); - await trackUntrackTriumphs(txn, platformMembershipId, [ - { recordHash: 3851137658, tracked: true }, - ]); - }); - - const exportResponse = await exportDataForProfile(platformMembershipId); - - expect(exportResponse.tags.length).toBe(2); - expect(exportResponse.itemHashTags.length).toBe(1); - expect(exportResponse.loadouts.length).toBe(1); - expect(exportResponse.searches.length).toBe(2); - expect(exportResponse.triumphs[0]?.triumphs.length).toBe(1); - }); -}); diff --git a/api/stately/bulk-queries.ts b/api/stately/bulk-queries.ts deleted file mode 100644 index bca2f408..00000000 --- a/api/stately/bulk-queries.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { captureMessage } from '@sentry/node'; -import { keyPath, ListToken } from '@stately-cloud/client'; -import { uniqBy } from 'es-toolkit'; -import { transaction } from '../db/index.js'; -import { replaceSettings } from '../db/settings-queries.js'; -import { DeleteAllResponse } from '../shapes/delete-all.js'; -import { ExportResponse } from '../shapes/export.js'; -import { DestinyVersion } from '../shapes/general.js'; -import { ItemAnnotation, ItemHashTag } from '../shapes/item-annotations.js'; -import { Loadout } from '../shapes/loadouts.js'; -import { ProfileResponse } from '../shapes/profile.js'; -import { Settings } from '../shapes/settings.js'; -import { delay } from '../utils.js'; -import { client } from './client.js'; -import { AnyItem } from './generated/index.js'; -import { - convertItemAnnotation, - importTags, - keyFor as tagKeyFor, -} from './item-annotations-queries.js'; -import { - convertItemHashTag, - keyFor as hashTagKeyFor, - importHashTags, -} from './item-hash-tags-queries.js'; -import { - convertLoadoutFromStately, - importLoadouts, - keyFor as loadoutKeyFor, -} from './loadouts-queries.js'; -import { - convertSearchFromStately, - importSearches, - keyFor as searchKeyFor, -} from './searches-queries.js'; -import { deleteSettings as deleteSettingsInStately } from './settings-queries.js'; -import { batches, fromStatelyUUID, parseKeyPath } from './stately-utils.js'; -import { importTriumphs, keyFor as triumphKeyFor } from './triumphs-queries.js'; - -type PlatformLoadout = Loadout & { - platformMembershipId: string; - destinyVersion: DestinyVersion; -}; - -type PlatformItemAnnotation = ItemAnnotation & { - platformMembershipId: string; - destinyVersion: DestinyVersion; -}; - -export async function statelyImport( - bungieMembershipId: number, - platformMembershipIds: string[], - settings: Partial, - loadouts: PlatformLoadout[], - itemAnnotations: PlatformItemAnnotation[], - triumphs: ExportResponse['triumphs'], - searches: ExportResponse['searches'], - itemHashTags: ItemHashTag[], -): Promise { - // TODO: what we should do, is map all these to items, and then we can just do - // batch puts, 25 at a time. - - let numTriumphs = 0; - await deleteAllDataForUser(bungieMembershipId, platformMembershipIds); - - // The export will have duplicates because import saved to each profile - // instead of the one that was exported. - itemHashTags = uniqBy(itemHashTags, (a) => a.hash); - searches = uniqBy(searches, (s) => s.search.query); - - const items: AnyItem[] = []; - items.push(...importLoadouts(loadouts)); - items.push(...importTags(itemAnnotations)); - // TODO: I guess save item hash tags to each platform? I should really - // refactor the import shape to have hashtags per platform, or merge/unique - // them. - for (const platformMembershipId of platformMembershipIds) { - items.push(...importHashTags(platformMembershipId, itemHashTags)); - } - if (Array.isArray(triumphs)) { - for (const triumphData of triumphs) { - if (Array.isArray(triumphData?.triumphs)) { - items.push(...importTriumphs(triumphData.platformMembershipId, triumphData.triumphs)); - numTriumphs += triumphData.triumphs.length; - } - } - } - for (const platformMembershipId of platformMembershipIds) { - // TODO: I guess save them to each platform? I should really refactor the - // import shape to have searches per platform, or merge/unique them. - items.push(...importSearches(platformMembershipId, searches)); - } - - // Settings live in Postgres now - await transaction(async (client) => replaceSettings(client, bungieMembershipId, settings)); - - // OK now put them in as fast as we can - for (const batch of batches(items)) { - // We shouldn't have any existing items... - await client.putBatch( - ...batch.map((item) => ({ item, mustNotExist: true, overwriteMetadataTimestamps: true })), - ); - await delay(100); // give it some time to flush - } - - return numTriumphs; -} - -/** - * Delete all the data for a user+profile combo. - */ -export async function deleteAllDataForUser( - bungieMembershipId: number, - platformMembershipIds: string[], -): Promise { - const responses = await Promise.all(platformMembershipIds.map((p) => deleteAllDataForProfile(p))); - - // Also delete settings, which are stored by membershipId - await deleteSettingsInStately(bungieMembershipId); - - const response = responses.reduce( - (acc, r) => { - for (const key in r) { - (acc as Record)[key] += (r as Record)[key]; - } - return acc; - }, - { - settings: 1, - loadouts: 0, - tags: 0, - itemHashTags: 0, - triumphs: 0, - searches: 0, - }, - ); - - return response; -} - -async function deleteAllDataForProfile( - platformMembershipId: string, -): Promise { - const response: DeleteAllResponse['deleted'] = { - settings: 0, - loadouts: 0, - tags: 0, - itemHashTags: 0, - triumphs: 0, - searches: 0, - }; - - // TODO: This really calls for a deleteGroup API! - const prefix = keyPath`/p-${BigInt(platformMembershipId)}`; - - // First, get all the keys we need to delete - const iter = client.beginList(prefix); - const keys: string[] = []; - for await (const item of iter) { - const [key, responseKey] = keyFor(item); - if (key) { - response[responseKey] += 1; - keys.push(key); - } - } - - // Then delete them all. We're not in a transaction! - for (const batch of batches(keys)) { - await client.del(...batch); - await delay(100); // give it some time to flush - } - return response; -} - -function keyFor(item: AnyItem): [keyPath: string, responseKey: keyof DeleteAllResponse['deleted']] { - // TODO: This is where we *really* need an item key helper! - if (client.isType(item, 'Triumph')) { - return [triumphKeyFor(item.profileId, item.recordHash), 'triumphs']; - } else if (client.isType(item, 'ItemAnnotation')) { - return [tagKeyFor(item.profileId, item.destinyVersion as DestinyVersion, item.id), 'tags']; - } else if (client.isType(item, 'ItemHashTag')) { - return [hashTagKeyFor(item.profileId, item.hash), 'itemHashTags']; - } else if (client.isType(item, 'Loadout')) { - return [ - loadoutKeyFor(item.profileId, item.destinyVersion as DestinyVersion, item.id), - 'loadouts', - ]; - } else if (client.isType(item, 'Search')) { - return [ - searchKeyFor(item.profileId, item.destinyVersion as DestinyVersion, item.query), - 'searches', - ]; - } - return ['', 'settings']; -} - -export async function exportDataForProfile(platformMembershipId: string): Promise { - const prefix = keyPath`/p-${BigInt(platformMembershipId)}`; - - const loadouts: ExportResponse['loadouts'] = []; - const itemAnnotations: ExportResponse['tags'] = []; - const itemHashTags: { - platformMembershipId: string; - itemHashTag: ItemHashTag; - }[] = []; - const searches: ExportResponse['searches'] = []; - const triumphs: number[] = []; - - // Now get all the data under the profile in one listing. - const iter = client.beginList(prefix); - for await (const item of iter) { - if (client.isType(item, 'Triumph')) { - triumphs.push(item.recordHash); - } else if (client.isType(item, 'ItemAnnotation')) { - itemAnnotations.push({ - platformMembershipId, - destinyVersion: item.destinyVersion as DestinyVersion, - annotation: convertItemAnnotation(item), - }); - } else if (client.isType(item, 'ItemHashTag')) { - itemHashTags.push({ - platformMembershipId, - itemHashTag: convertItemHashTag(item), - }); - } else if (client.isType(item, 'Loadout')) { - loadouts.push({ - platformMembershipId, - destinyVersion: item.destinyVersion as DestinyVersion, - loadout: convertLoadoutFromStately(item), - }); - } else if (client.isType(item, 'Search')) { - searches.push({ - destinyVersion: item.destinyVersion as DestinyVersion, - search: convertSearchFromStately(item), - }); - } - } - - return { - settings: {}, - loadouts, - tags: itemAnnotations, - itemHashTags, - triumphs: triumphs.length - ? [ - { - platformMembershipId, - triumphs, - }, - ] - : [], - searches, - }; -} - -export async function getProfile( - platformMembershipId: string | bigint, - destinyVersion: DestinyVersion, - suffix: string, -): Promise<{ profile: ProfileResponse; token: ListToken }> { - const prefix = keyPath`/p-${BigInt(platformMembershipId)}/d-${destinyVersion}` + suffix; - - const response: ProfileResponse = {}; - - // Now get all the data under the profile in one listing. - const iter = client.beginList(prefix); - for await (const item of iter) { - if (client.isType(item, 'Triumph')) { - (response.triumphs ??= []).push(item.recordHash); - } else if (client.isType(item, 'ItemAnnotation')) { - (response.tags ??= []).push(convertItemAnnotation(item)); - } else if (client.isType(item, 'ItemHashTag')) { - (response.itemHashTags ??= []).push(convertItemHashTag(item)); - } else if (client.isType(item, 'Loadout')) { - (response.loadouts ??= []).push(convertLoadoutFromStately(item)); - } else if (client.isType(item, 'Search')) { - (response.searches ??= []).push(convertSearchFromStately(item)); - } - } - - return { profile: response, token: iter.token! }; -} - -export async function syncProfile( - tokenData: Buffer, -): Promise<{ profile: ProfileResponse; token: ListToken }> { - const response: ProfileResponse = { - sync: true, - }; - - // Now get all the data under the profile in one listing. - const iter = client.syncList(tokenData); - for await (const change of iter) { - switch (change.type) { - case 'reset': { - response.sync = false; - break; - } - case 'changed': { - const item = change.item; - if (client.isType(item, 'Triumph')) { - (response.triumphs ??= []).push(item.recordHash); - } else if (client.isType(item, 'ItemAnnotation')) { - (response.tags ??= []).push(convertItemAnnotation(item)); - } else if (client.isType(item, 'ItemHashTag')) { - (response.itemHashTags ??= []).push(convertItemHashTag(item)); - } else if (client.isType(item, 'Loadout')) { - (response.loadouts ??= []).push(convertLoadoutFromStately(item)); - } else if (client.isType(item, 'Search')) { - (response.searches ??= []).push(convertSearchFromStately(item)); - } - break; - } - case 'deleted': { - const keyPath = parseKeyPath(change.keyPath); - if (keyPath[0].ns === 'p') { - const lastPart = keyPath.at(-1)!; - const idStr = lastPart.id; - const type = lastPart.ns; - switch (type) { - case 'triumph': { - (response.deletedTriumphs ??= []).push(Number(idStr)); - break; - } - case 'ia': { - (response.deletedTagsIds ??= []).push(idStr); - break; - } - case 'iht': { - (response.deletedItemHashTagHashes ??= []).push(Number(idStr)); - break; - } - case 'loadout': { - (response.deletedLoadoutIds ??= []).push(fromStatelyUUID(idStr)); - break; - } - case 'search': { - (response.deletedSearchHashes ??= []).push(idStr); - break; - } - default: - captureMessage(`Unknown deleted type ${type}`); - break; - } - } else { - captureMessage(`Unknown deleted keyPath ${change.keyPath}`); - } - break; - } - case 'updatedOutsideWindow': { - captureMessage(`Unexpected updatedOutsideWindow ${change.keyPath}`); - break; - } - } - } - - return { profile: response, token: iter.token! }; -} diff --git a/api/stately/client.ts b/api/stately/client.ts deleted file mode 100644 index b309abf2..00000000 --- a/api/stately/client.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { nodeTransport } from '@stately-cloud/client/node'; -import { createClient } from './generated/index.js'; - -/** - * Our StatelyDB client, bound to our types and store. - */ -export const client = createClient({ - storeId: BigInt(process.env.STATELY_STORE_ID!), - region: process.env.STATELY_REGION || 'us-west-2', - transport: nodeTransport, -}); diff --git a/api/stately/generated/.gitattributes b/api/stately/generated/.gitattributes deleted file mode 100644 index 93431834..00000000 --- a/api/stately/generated/.gitattributes +++ /dev/null @@ -1,7 +0,0 @@ -index.js linguist-generated=true -index.d.ts linguist-generated=true -stately_pb.js linguist-generated=true -stately_pb.d.ts linguist-generated=true -stately_item_types.js linguist-generated=true -stately_item_types.d.ts linguist-generated=true -README.md linguist-generated=true diff --git a/api/stately/generated/README.md b/api/stately/generated/README.md deleted file mode 100644 index b2f7f01c..00000000 --- a/api/stately/generated/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Schema Info - -This is an auto-generated README file to help you understand your schema! - -* SchemaID => `8030842688320564` -* Schema Version => `9` -* See schema on the [Stately Console](https://console.stately.cloud/6529794557127699/schemas/?schemaVersion=9). - -### Key Path Layout - -| Group | Key Path | Item Type | primary | required | syncable | txn type | -|:------------------|:---------------------|:---------------|:--------|:---------|:---------|:---------| -| `/apps-*` | `/apps-*/app-*` | ApiApp | Yes | Yes | Yes | group | -| `/gs-*` | `/gs-*` | GlobalSettings | Yes | Yes | Yes | group | -| `/loadoutShare-*` | `/loadoutShare-*` | LoadoutShare | Yes | Yes | Yes | group | -| `/member-*` | `/member-*/settings` | Settings | Yes | Yes | Yes | group | -| `/p-*` | `/p-*/d-*/ia-*` | ItemAnnotation | Yes | Yes | Yes | group | -| `/p-*` | `/p-*/d-*/iht-*` | ItemHashTag | Yes | Yes | Yes | group | -| `/p-*` | `/p-*/d-*/loadout-*` | Loadout | Yes | Yes | Yes | group | -| `/p-*` | `/p-*/d-*/search-*` | Search | Yes | Yes | Yes | group | -| `/p-*` | `/p-*/d-*/triumph-*` | Triumph | Yes | Yes | Yes | group | - -### TODO: - -What else should we add here? Let us know! diff --git a/api/stately/generated/index.d.ts b/api/stately/generated/index.d.ts deleted file mode 100644 index 954bbb44..00000000 --- a/api/stately/generated/index.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @generated by Stately. DO NOT EDIT. - - -export * from './stately_item_types.js'; -export * from './stately_pb.js'; diff --git a/api/stately/generated/index.js b/api/stately/generated/index.js deleted file mode 100644 index 84dcec65..00000000 --- a/api/stately/generated/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// @generated by Stately. DO NOT EDIT. -/* eslint-disable */ - -export * from './stately_item_types.js'; -export * from './stately_pb.js'; diff --git a/api/stately/generated/stately_item_types.d.ts b/api/stately/generated/stately_item_types.d.ts deleted file mode 100644 index e2fd95fb..00000000 --- a/api/stately/generated/stately_item_types.d.ts +++ /dev/null @@ -1,121 +0,0 @@ -// @generated by Stately. DO NOT EDIT. - - -import type { - ClientOptions, - DatabaseClient as GenericDatabaseClient, - Item, - ItemInit, -} from '@stately-cloud/client'; -import type { - ApiApp, - ApiAppSchema, - ArtifactUnlocksSchema, - CollapsedSectionSchema, - CustomStatDefSchema, - CustomStatsEntrySchema, - CustomStatWeightsEntrySchema, - GlobalSettings, - GlobalSettingsSchema, - InGameLoadoutIdentifiersSchema, - ItemAnnotation, - ItemAnnotationSchema, - ItemHashTag, - ItemHashTagSchema, - Loadout, - LoadoutItemSchema, - LoadoutParametersSchema, - LoadoutSchema, - LoadoutShare, - LoadoutShareSchema, - ModsByBucketEntrySchema, - Search, - SearchSchema, - SetBonusCountSchema, - Settings, - SettingsSchema, - SocketOverrideSchema, - StatConstraintSchema, - StatConstraintsEntrySchema, - Triumph, - TriumphSchema, -} from './stately_pb.js'; - -export declare const itemTypeToSchema: { - ApiApp: typeof ApiAppSchema; - GlobalSettings: typeof GlobalSettingsSchema; - ItemAnnotation: typeof ItemAnnotationSchema; - ItemHashTag: typeof ItemHashTagSchema; - Loadout: typeof LoadoutSchema; - LoadoutShare: typeof LoadoutShareSchema; - Search: typeof SearchSchema; - Settings: typeof SettingsSchema; - Triumph: typeof TriumphSchema; - ArtifactUnlocks: typeof ArtifactUnlocksSchema; - CollapsedSection: typeof CollapsedSectionSchema; - CustomStatDef: typeof CustomStatDefSchema; - CustomStatsEntry: typeof CustomStatsEntrySchema; - CustomStatWeightsEntry: typeof CustomStatWeightsEntrySchema; - InGameLoadoutIdentifiers: typeof InGameLoadoutIdentifiersSchema; - LoadoutItem: typeof LoadoutItemSchema; - LoadoutParameters: typeof LoadoutParametersSchema; - ModsByBucketEntry: typeof ModsByBucketEntrySchema; - SetBonusCount: typeof SetBonusCountSchema; - SocketOverride: typeof SocketOverrideSchema; - StatConstraint: typeof StatConstraintSchema; - StatConstraintsEntry: typeof StatConstraintsEntrySchema; -}; - -// AllItemTypes is a convenience type that represents all item type names in your schema. -export type AllItemTypes = - | 'ApiApp' - | 'GlobalSettings' - | 'ItemAnnotation' - | 'ItemHashTag' - | 'Loadout' - | 'LoadoutShare' - | 'Search' - | 'Settings' - | 'Triumph'; - -// AnyItem is a convenience type that represents any item shape in your schema. -export type AnyItem = - | ApiApp - | GlobalSettings - | ItemAnnotation - | ItemHashTag - | Loadout - | LoadoutShare - | Search - | Settings - | Triumph; - -type TypeMap = typeof itemTypeToSchema; - -// DatabaseClient is a database client that has been customized with your schema. -export type DatabaseClient = GenericDatabaseClient; - -/** - * Create a new DatabaseClient bound to your schema that allows operations against - * stores that use that schema. - * @example - * import { createClient } from "./my_schema"; - * import { nodeTransport } from "@stately-cloud/client/node"; - * const client = createClient({ storeId: 1221515n, transport: nodeTransport }); - * const item = await client.get("Equipment", "/jedi-luke/equipment-lightsaber"); - * @private this is used by the generated code and should not be called directly. - */ -export declare function createClient(options: ClientOptions): DatabaseClient; - -/** - * create builds a new item of the specified type. You *must* use this - * function to create items so that they have the proper metadata for the - * client to use them. - * @param typeName - One of the itemType or objectType names from your schema. - * @param init - The initial data for the item. Any values that aren't set - * here will be set to their zero value. - */ -export declare function create( - typeName: T, - init?: ItemInit, -): Item; diff --git a/api/stately/generated/stately_item_types.js b/api/stately/generated/stately_item_types.js deleted file mode 100644 index e27ef4ac..00000000 --- a/api/stately/generated/stately_item_types.js +++ /dev/null @@ -1,78 +0,0 @@ -// @generated by Stately. DO NOT EDIT. -/* eslint-disable */ - -import { createClient as createGenericClient, makeCreateFunction } from '@stately-cloud/client'; -import { - ApiAppSchema, - ArtifactUnlocksSchema, - CollapsedSectionSchema, - CustomStatDefSchema, - CustomStatsEntrySchema, - CustomStatWeightsEntrySchema, - GlobalSettingsSchema, - InGameLoadoutIdentifiersSchema, - ItemAnnotationSchema, - ItemHashTagSchema, - LoadoutItemSchema, - LoadoutParametersSchema, - LoadoutSchema, - LoadoutShareSchema, - ModsByBucketEntrySchema, - SearchSchema, - SetBonusCountSchema, - SettingsSchema, - SocketOverrideSchema, - StatConstraintSchema, - StatConstraintsEntrySchema, - TriumphSchema, -} from './stately_pb.js'; - -export const typeToSchema = { - // itemTypes - ApiApp: ApiAppSchema, - GlobalSettings: GlobalSettingsSchema, - ItemAnnotation: ItemAnnotationSchema, - ItemHashTag: ItemHashTagSchema, - Loadout: LoadoutSchema, - LoadoutShare: LoadoutShareSchema, - Search: SearchSchema, - Settings: SettingsSchema, - Triumph: TriumphSchema, - - // objectTypes - ArtifactUnlocks: ArtifactUnlocksSchema, - CollapsedSection: CollapsedSectionSchema, - CustomStatDef: CustomStatDefSchema, - CustomStatsEntry: CustomStatsEntrySchema, - CustomStatWeightsEntry: CustomStatWeightsEntrySchema, - InGameLoadoutIdentifiers: InGameLoadoutIdentifiersSchema, - LoadoutItem: LoadoutItemSchema, - LoadoutParameters: LoadoutParametersSchema, - ModsByBucketEntry: ModsByBucketEntrySchema, - SetBonusCount: SetBonusCountSchema, - SocketOverride: SocketOverrideSchema, - StatConstraint: StatConstraintSchema, - StatConstraintsEntry: StatConstraintsEntrySchema, -}; - -/** The version of the schema that this client was generated for. */ -export const SCHEMA_VERSION_ID = 9; -export const SCHEMA_ID = 8030842688320564; - -export function createClient({ storeId, transport, ...transportOptions }) { - return createGenericClient({ - storeId, - itemTypeMap: typeToSchema, - schemaVersionID: SCHEMA_VERSION_ID, - schemaID: SCHEMA_ID, - clientFactory: transport(transportOptions), - }); -} - -export const create = makeCreateFunction(typeToSchema); - -if (createGenericClient.length !== 1) { - throw new Error( - 'Your version of @stately-cloud/client is too old. Please update to the latest version.', - ); -} diff --git a/api/stately/generated/stately_pb.d.ts b/api/stately/generated/stately_pb.d.ts deleted file mode 100644 index 26acacaa..00000000 --- a/api/stately/generated/stately_pb.d.ts +++ /dev/null @@ -1,975 +0,0 @@ -// @generated by Stately. DO NOT EDIT. -/* eslint-disable */ - -import type { GenEnum, GenFile, GenMessage, ProtobufESMessage } from '@stately-cloud/client'; - -export declare const file_stately: GenFile; - -/** - * These items can be accessed via the following key paths: - * * /apps-:partition/app-:id - */ -export declare type ApiApp = ProtobufESMessage<'stately.generated.ApiApp'> & { - /** - * A short ID that uniquely identifies the app. - */ - id: string; - /** - * Apps must share their Bungie.net API key with us. - */ - bungieApiKey: string; - /** - * Apps also get a generated API key for accessing DIM APIs that don't involve user data. - */ - dimApiKey: string; - /** - * The origin used to allow CORS for this app. Only requests from this origin are allowed. - */ - origin: string; - /** - * This isn't a "real" field but StatelyDB won't allow a group without an ID. - */ - partition: bigint; -}; - -/** - * Describes the message stately.generated.ApiApp. - * Use `create(ApiAppSchema)` to create a new message. - */ -export declare const ApiAppSchema: GenMessage; - -/** - * These items can be accessed via the following key paths: - * * /gs-:stage - */ -export declare type GlobalSettings = ProtobufESMessage<'stately.generated.GlobalSettings'> & { - stage: string; - /** - * Whether the API is enabled or not. - */ - dimApiEnabled: boolean; - /** - * Don't allow refresh more often than this many seconds. - */ - destinyProfileMinimumRefreshInterval: bigint; - /** - * Time in seconds to refresh the profile when autoRefresh is true. - */ - destinyProfileRefreshInterval: bigint; - /** - * Whether to refresh profile automatically. - */ - autoRefresh: boolean; - /** - * Whether to refresh profile when the page becomes visible after being in the background. - */ - refreshProfileOnVisible: boolean; - /** - * Don't automatically refresh DIM profile info more often than this many seconds. - */ - dimProfileMinimumRefreshInterval: bigint; - /** - * Display an issue banner, if there is one. - */ - showIssueBanner: boolean; - lastUpdated: bigint; -}; - -/** - * Describes the message stately.generated.GlobalSettings. - * Use `create(GlobalSettingsSchema)` to create a new message. - */ -export declare const GlobalSettingsSchema: GenMessage; - -/** - * Any extra info added by the user to individual items - tags, notes, etc. - * - * These items can be accessed via the following key paths: - * * /p-:profileId/d-:destinyVersion/ia-:id - */ -export declare type ItemAnnotation = ProtobufESMessage<'stately.generated.ItemAnnotation'> & { - profileId: bigint; - destinyVersion: number; - tag: TagValue; - notes: string; - /** - * The item instance ID for an individual item - */ - id: bigint; - /** - * UTC epoch seconds timestamp of when the item was crafted. Used to - * match up items that have changed instance ID from being reshaped since they - * were tagged. - */ - craftedDate: bigint; -}; - -/** - * Describes the message stately.generated.ItemAnnotation. - * Use `create(ItemAnnotationSchema)` to create a new message. - */ -export declare const ItemAnnotationSchema: GenMessage; - -/** - * Any extra info added by the user to item hashes (shaders and mods) - * - * These items can be accessed via the following key paths: - * * /p-:profileId/d-:destinyVersion/iht-:hash - */ -export declare type ItemHashTag = ProtobufESMessage<'stately.generated.ItemHashTag'> & { - profileId: bigint; - destinyVersion: number; - tag: TagValue; - notes: string; - /** - * The inventory item hash for an item - */ - hash: number; -}; - -/** - * Describes the message stately.generated.ItemHashTag. - * Use `create(ItemHashTagSchema)` to create a new message. - */ -export declare const ItemHashTagSchema: GenMessage; - -/** - * These items can be accessed via the following key paths: - * * /p-:profileId/d-:destinyVersion/loadout-:id - */ -export declare type Loadout = ProtobufESMessage<'stately.generated.Loadout'> & { - /** - * A globally unique (UUID) identifier for the loadout. Chosen by the client, not autogenerated by the DB. - */ - id: Uint8Array; - name: string; - notes: string; - classType: DestinyClass; - equipped: LoadoutItem[]; - unequipped: LoadoutItem[]; - parameters?: LoadoutParameters; - createdAt: bigint; - lastUpdatedAt: bigint; - destinyVersion: number; - profileId: bigint; -}; - -/** - * Describes the message stately.generated.Loadout. - * Use `create(LoadoutSchema)` to create a new message. - */ -export declare const LoadoutSchema: GenMessage; - -/** - * These items can be accessed via the following key paths: - * * /loadoutShare-:id - */ -export declare type LoadoutShare = ProtobufESMessage<'stately.generated.LoadoutShare'> & { - /** - * A globally unique short random string to be used when sharing the loadout, but which is hard to guess. - * This is essentially 35 random bits encoded via base32 into a 7-character string. It'd be neat if we could - * support that, with a parameterizable string length. - */ - id: string; - name: string; - notes: string; - classType: DestinyClass; - equipped: LoadoutItem[]; - unequipped: LoadoutItem[]; - parameters?: LoadoutParameters; - createdAt: bigint; - lastUpdatedAt: bigint; - destinyVersion: number; - profileId: bigint; - /** - * A count that increases on each view. Not the most efficient way to deal - * with this - maybe that should be a child document. - */ - viewCount: number; -}; - -/** - * Describes the message stately.generated.LoadoutShare. - * Use `create(LoadoutShareSchema)` to create a new message. - */ -export declare const LoadoutShareSchema: GenMessage; - -/** - * A search query. This can either be from history (recent searches), pinned (saved searches), or suggested. - * - * These items can be accessed via the following key paths: - * * /p-:profileId/d-:destinyVersion/search-:qhash - */ -export declare type Search = ProtobufESMessage<'stately.generated.Search'> & { - /** - * The full search query. These are - */ - query: string; - /** - * A zero usage count means this is a suggested/preloaded search. - */ - usageCount: number; - /** - * Has this search been saved/favorite'd/pinned by the user? - */ - saved: boolean; - /** - * The last time this was used, as a unix millisecond timestamp. We don't - * use fromMetadata: 'lastModifiedAtTime' because on import we want to set this - * to whatever value it was, not the insert time. - */ - lastUsage: bigint; - /** - * Which kind of thing is this search for? Searches of different types are - * stored together and need to be filtered to the specific type. - */ - type: SearchType; - /** - * MD5 hash of the query string. This is used to enforce uniqueness of - * queries without using the whole long query string as a key. - */ - qhash: Uint8Array; - /** - * The profile ID this search is associated with. - */ - profileId: bigint; - /** - * The Destiny version this search is associated with. - */ - destinyVersion: number; -}; - -/** - * Describes the message stately.generated.Search. - * Use `create(SearchSchema)` to create a new message. - */ -export declare const SearchSchema: GenMessage; - -/** - * These items can be accessed via the following key paths: - * * /member-:memberId/settings - */ -export declare type Settings = ProtobufESMessage<'stately.generated.Settings'> & { - memberId: bigint; - /** - * Show item quality percentages - */ - itemQuality: boolean; - /** - * Show new items with an overlay - */ - showNewItems: boolean; - /** - * Sort characters (mostRecent, mostRecentReverse, fixed) - */ - characterOrder: CharacterOrder; - /** - * Custom sorting properties, in order of application - */ - itemSortOrderCustom: string[]; - /** - * supplements itemSortOrderCustom by allowing each sort to be reversed - */ - itemSortReversals: string[]; - /** - * How many columns to display character buckets - */ - charCol: number; - /** - * How many columns to display character buckets on Mobile - */ - charColMobile: number; - /** - * How big in pixels to draw items - start smaller for iPad - */ - itemSize: number; - /** - * Which categories or buckets should be collapsed? - */ - collapsedSections: CollapsedSection[]; - /** - * Hide triumphs once they're completed - */ - completedRecordsHidden: boolean; - /** - * Hide show triumphs the manifest recommends be redacted - */ - redactedRecordsRevealed: boolean; - /** - * Whether to keep one slot per item type open - */ - farmingMakeRoomForItems: boolean; - /** - * How many spaces to clear when using Farming Mode (make space). - */ - inventoryClearSpaces: number; - /** - * Hide completed triumphs/collections - */ - hideCompletedRecords: boolean; - /** - * Custom character sort - across all accounts and characters! The values are character IDs. - */ - customCharacterSort: string[]; - /** - * The last direction the infusion fuel finder was set to. - */ - infusionDirection: InfuseDirection; - /** - * The user's preferred language code. - */ - language: string; - /** - * External sources for wish lists. - * Expected to be a valid URL. - * initialState should hold the current location of a reasonably-useful collection of rolls. - * Set to empty string to not use wishListSource. - */ - wishListSources: string[]; - /** - * The last used settings for the Loadout Optimizer. These apply to all classes. - */ - loParameters?: LoadoutParameters; - /** - * Stat order, enablement, etc. Stored per class. - */ - loStatConstraintsByClass: StatConstraintsEntry[]; - /** - * list of stat hashes of interest, keyed by class enum - */ - customTotalStatsByClass: CustomStatsEntry[]; - /** - * Selected columns for the Vault Organizer - */ - organizerColumnsWeapons: string[]; - organizerColumnsArmor: string[]; - organizerColumnsGhost: string[]; - /** - * Compare base stats or actual stats in Compare - */ - compareBaseStats: boolean; - /** - * Item popup sidecar collapsed just shows icon and no character locations - */ - sidecarCollapsed: boolean; - /** - * In "Single Character Mode" DIM pretends you only have one (active) character and all the other characters' items are in the vault. - */ - singleCharacter: boolean; - /** - * Badge the app icon with the number of postmaster items on the current character - */ - badgePostmaster: boolean; - /** - * Display perks as a list instead of a grid (mobile). - */ - perkList: boolean; - /** - * How the loadouts menu and page should be sorted - */ - loadoutSort: LoadoutSort; - /** - * Hide tagged items in the Item Feed - */ - itemFeedHideTagged: boolean; - /** - * Show the Item Feed - */ - itemFeedExpanded: boolean; - /** - * Pull from postmaster is an irreversible action and some people don't want to accidentally hit it. - */ - hidePullFromPostmaster: boolean; - /** - * Select descriptions to display - */ - descriptionsToDisplay: DescriptionOptions; - /** - * Plug the T10 masterwork into D2Y2+ random roll weapons for comparison purposes. - */ - compareWeaponMasterwork: boolean; - /** - * Cutoff point; the instance ID of the newest item that isn't shown in - * the item feed anymore after the user presses the "clear" button. - */ - itemFeedWatermark: bigint; - /** - * a set of user-defined custom stat totals. - * this will supersede customTotalStatsByClass. - * it defaults below to empty, which in DIM, initiates fallback to customTotalStatsByClass - */ - customStats: CustomStatDef[]; - /** - * Automatically sync lock status with tag - */ - autoLockTagged: boolean; - /** - * The currently chosen theme. - */ - theme: string; - /** - * Whether to sort triumphs on the records tab by their progression percentage. - */ - sortRecordProgression: boolean; - /** - * Whether to hide items that cost silver from the Vendors screen. - */ - vendorsHideSilverItems: boolean; - /** - * An additional layer of grouping for weapons in the vault. - */ - vaultWeaponGrouping: string; - /** - * How grouped weapons in the vault should be displayed. - */ - vaultWeaponGroupingStyle: VaultWeaponGroupingStyle; - /** - * The currently selected item popup tab. - */ - itemPopupTab: ItemPopupTab; - /** - * How grouped armor in the vault should be displayed. - */ - vaultArmorGroupingStyle: VaultWeaponGroupingStyle; - /** - * Display perks as a list instead of a grid (desktop). - */ - perkListDesktop: boolean; - /** - * Whether to show vaulted items underneath equipped items in Desktop view. - */ - vaultBelow: boolean; - /** - * Different modes for how armor stats can be compared. - */ - armorCompare: ArmorStatCompare; - /** - * Whether to show the ornamented state of items. - */ - ornamentDisplay: OrnamentDisplay; -}; - -/** - * Describes the message stately.generated.Settings. - * Use `create(SettingsSchema)` to create a new message. - */ -export declare const SettingsSchema: GenMessage; - -/** - * Triumph stores a single record hash for a tracked triumph. Users can have any - * number of tracked triumphs, with one item per triumph. - * - * These items can be accessed via the following key paths: - * * /p-:profileId/d-:destinyVersion/triumph-:recordHash - */ -export declare type Triumph = ProtobufESMessage<'stately.generated.Triumph'> & { - recordHash: number; - profileId: bigint; - destinyVersion: number; -}; - -/** - * Describes the message stately.generated.Triumph. - * Use `create(TriumphSchema)` to create a new message. - */ -export declare const TriumphSchema: GenMessage; -export declare type ArtifactUnlocks = ProtobufESMessage<'stately.generated.ArtifactUnlocks'> & { - /** - * The item hashes of the unlocked artifact perk items. - */ - unlockedItemHashes: number[]; - /** - * The season this set of artifact unlocks was chosen from. - */ - seasonNumber: number; -}; - -/** - * Describes the message stately.generated.ArtifactUnlocks. - * Use `create(ArtifactUnlocksSchema)` to create a new message. - */ -export declare const ArtifactUnlocksSchema: GenMessage; -export declare type CollapsedSection = ProtobufESMessage<'stately.generated.CollapsedSection'> & { - key: string; - /** - * Whether this section is collapsed - */ - collapsed: boolean; -}; - -/** - * Describes the message stately.generated.CollapsedSection. - * Use `create(CollapsedSectionSchema)` to create a new message. - */ -export declare const CollapsedSectionSchema: GenMessage; -export declare type CustomStatDef = ProtobufESMessage<'stately.generated.CustomStatDef'> & { - /** - * a unique-per-user fake statHash used to look this stat up - */ - statHash: number; - /** - * a unique-per-class name for this stat - */ - label: string; - /** - * an abbreviated/crunched form of the stat label, for use in search filters - */ - shortLabel: string; - /** - * which guardian class this stat should be used for. DestinyClass.Unknown makes a global (all 3 classes) stat - */ - class: DestinyClass; - /** - * info about how to calculate the stat total - */ - weights: CustomStatWeightsEntry[]; -}; - -/** - * Describes the message stately.generated.CustomStatDef. - * Use `create(CustomStatDefSchema)` to create a new message. - */ -export declare const CustomStatDefSchema: GenMessage; -export declare type CustomStatsEntry = ProtobufESMessage<'stately.generated.CustomStatsEntry'> & { - classType: DestinyClass; - customStats: number[]; -}; - -/** - * Describes the message stately.generated.CustomStatsEntry. - * Use `create(CustomStatsEntrySchema)` to create a new message. - */ -export declare const CustomStatsEntrySchema: GenMessage; -/** - * traditional custom stats use a binary 1 or 0 for all 6 armor stats, but this could support more complex weights - */ -export declare type CustomStatWeightsEntry = - ProtobufESMessage<'stately.generated.CustomStatWeightsEntry'> & { - statHash: number; - weight: number; - }; - -/** - * Describes the message stately.generated.CustomStatWeightsEntry. - * Use `create(CustomStatWeightsEntrySchema)` to create a new message. - */ -export declare const CustomStatWeightsEntrySchema: GenMessage; -/** - * normally found inside DestinyLoadoutComponent, mapped to respective definition tables - */ -export declare type InGameLoadoutIdentifiers = - ProtobufESMessage<'stately.generated.InGameLoadoutIdentifiers'> & { - colorHash: number; - iconHash: number; - nameHash: number; - }; - -/** - * Describes the message stately.generated.InGameLoadoutIdentifiers. - * Use `create(InGameLoadoutIdentifiersSchema)` to create a new message. - */ -export declare const InGameLoadoutIdentifiersSchema: GenMessage; -export declare type LoadoutItem = ProtobufESMessage<'stately.generated.LoadoutItem'> & { - /** - * itemInstanceId of the item (if it's instanced). Default to zero for an uninstanced item or unknown ID. - */ - id: bigint; - /** - * DestinyInventoryItemDefinition hash of the item - */ - hash: number; - /** - * Optional amount (for consumables), default to zero - */ - amount: number; - /** - * The socket overrides for the item. These signal what DestinyInventoryItemDefinition - * (by it's hash) is supposed to be socketed into the given socket index. - */ - socketOverrides: SocketOverride[]; - /** - * UTC epoch seconds timestamp of when the item was crafted. Used to - * match up items that have changed instance ID from being reshaped since they - * were added to the loadout. - */ - craftedDate: bigint; -}; - -/** - * Describes the message stately.generated.LoadoutItem. - * Use `create(LoadoutItemSchema)` to create a new message. - */ -export declare const LoadoutItemSchema: GenMessage; -/** - * Parameters that explain how this loadout was chosen (in Loadout Optimizer) - * and at the same time, how this loadout should be configured when equipped. - * This can be used to re-load a loadout into Loadout Optimizer with its - * settings intact, or to equip the right mods when applying a loadout if AWA is - * ever released. - * - * Originally this was meant to model parameters independent of specific items, - * as a means of sharing Loadout Optimizer settings between users, but now we - * just share whole loadouts, so this can be used for any sort of parameter we - * want to add to loadouts. - * - * All properties are optional, but most have defaults specified in - * defaultLoadoutParameters that should be used if they are undefined. - */ -export declare type LoadoutParameters = ProtobufESMessage<'stately.generated.LoadoutParameters'> & { - /** - * The stats the user cared about for this loadout, in the order they cared about them and - * with optional range by tier. If a stat is "ignored" it should just be missing from this - * list. - */ - statConstraints: StatConstraint[]; - /** - * The mods that will be used with this loadout. Each entry is an inventory - * item hash representing the mod item. Hashes may appear multiple times. - * These are not associated with any specific item in the loadout - when - * applying the loadout we should automatically determine the minimum of - * changes required to match the desired mods, and apply these mods to the - * equipped items. - */ - mods: number[]; - /** - * If set, after applying the mods above, all other mods will be removed from armor. - */ - clearMods: boolean; - /** - * Whether to clear out other weapons when applying this loadout - */ - clearWeapons: boolean; - /** - * Whether to clear out other weapons when applying this loadout - */ - clearArmor: boolean; - /** - * Mods that must be applied to a specific bucket hash. In general, prefer to - * use the flat mods list above, and rely on the loadout function to assign - * mods automatically. However there are some mods like shaders which can't - * be automatically assigned to the right piece. These only apply to the equipped - * item. - */ - modsByBucket: ModsByBucketEntry[]; - /** - * The artifact unlocks relevant to this build. - */ - artifactUnlocks?: ArtifactUnlocks; - /** - * Whether to automatically add stat mods. - */ - autoStatMods: boolean; - /** - * A search filter applied while editing the loadout in Loadout Optimizer, - * which constrains the items that can be in the loadout. - */ - query: string; - /** - * Whether armor of this type will have assumed masterwork stats in the Loadout Optimizer. - */ - assumeArmorMasterwork: AssumeArmorMasterwork; - /** - * The InventoryItemHash of the pinned exotic, if any was chosen. - */ - exoticArmorHash: bigint; - /** - * a user may optionally specify which icon/color/name will be used, - * if this DIM loadout is saved to an in-game slot. - */ - inGameIdentifiers?: InGameLoadoutIdentifiers; - /** - * When calculating loadout stats, should "Font of ..." mods be assumed active - * and their runtime bonus stats be included? - */ - includeRuntimeStatBenefits: boolean; - /** - * A list of armor perks that should be included in this loadout. This - * expresses a desire in the Loadout Optimizer to generate sets that have - * these perks. - * - * For regular perks each occurrence of the perk in this list represents one - * instance of the perk that should appear on an item in the loadout. For - * armor set bonuses, use setBonuses instead. - * - * For example, this can be used to: - * - Specify what exotic class item perks you want - * - Specify that you want some seasonal armor perks to be used (e.g. 3 - * instances of Iron Lord's Pride) - * - * For picking specific perks on weapons, use modsByBucket instead. - */ - perks: number[]; - /** - * The set bonuses that we want to activate with this loadout. This is a - * mapping of one or more DestinyEquipableItemSetDefinition hashes to the - * number of pieces we require that provide that setBonus. - */ - setBonuses: SetBonusCount[]; -}; - -/** - * Describes the message stately.generated.LoadoutParameters. - * Use `create(LoadoutParametersSchema)` to create a new message. - */ -export declare const LoadoutParametersSchema: GenMessage; -export declare type ModsByBucketEntry = ProtobufESMessage<'stately.generated.ModsByBucketEntry'> & { - bucketHash: number; - modHashes: number[]; -}; - -/** - * Describes the message stately.generated.ModsByBucketEntry. - * Use `create(ModsByBucketEntrySchema)` to create a new message. - */ -export declare const ModsByBucketEntrySchema: GenMessage; -export declare type SetBonusCount = ProtobufESMessage<'stately.generated.SetBonusCount'> & { - /** - * The DestinyEquipableItemSetDefinition hash of the set bonus - */ - setBonusHash: number; - /** - * The number of pieces we require that provide that setBonus - */ - count: number; -}; - -/** - * Describes the message stately.generated.SetBonusCount. - * Use `create(SetBonusCountSchema)` to create a new message. - */ -export declare const SetBonusCountSchema: GenMessage; -export declare type SocketOverride = ProtobufESMessage<'stately.generated.SocketOverride'> & { - /** - * The index of the socket in the item - */ - socketIndex: number; - /** - * The hash of the item that should be in this socket - */ - itemHash: number; -}; - -/** - * Describes the message stately.generated.SocketOverride. - * Use `create(SocketOverrideSchema)` to create a new message. - */ -export declare const SocketOverrideSchema: GenMessage; -/** - * A constraint on the values an armor stat can take - */ -export declare type StatConstraint = ProtobufESMessage<'stately.generated.StatConstraint'> & { - /** - * The stat definition hash of the stat - */ - statHash: number; - /** - * The minimum tier value for the stat. 0 if unset. - */ - minTier: number; - /** - * The maximum tier value for the stat. 10 if unset. - */ - maxTier: number; - /** - * Minimum absolute value for the stat. 0 if unset. Replaces minTier in Edge of Fate. - */ - minStat: number; - /** - * Maximum absolute value for the stat. Max Possible Stat Value if unset. Replaces maxTier in Edge of Fate. - */ - maxStat: number; -}; - -/** - * Describes the message stately.generated.StatConstraint. - * Use `create(StatConstraintSchema)` to create a new message. - */ -export declare const StatConstraintSchema: GenMessage; -export declare type StatConstraintsEntry = - ProtobufESMessage<'stately.generated.StatConstraintsEntry'> & { - classType: DestinyClass; - constraints: StatConstraint[]; - }; - -/** - * Describes the message stately.generated.StatConstraintsEntry. - * Use `create(StatConstraintsEntrySchema)` to create a new message. - */ -export declare const StatConstraintsEntrySchema: GenMessage; - -export enum ArmorStatCompare { - ArmorStatCompare_Current = 0, - ArmorStatCompare_Base = 1, - ArmorStatCompare_BaseMasterwork = 2, -} - -/** - * Describes the enum stately.generated.ArmorStatCompare. - */ -export declare const ArmorStatCompareSchema: GenEnum; - -/** - * Whether armor of this type will have assumed masterworked stats in the Loadout Optimizer. - */ -export enum AssumeArmorMasterwork { - /** - * No armor will have assumed masterworked stats. - */ - AssumeArmorMasterwork_None = 0, - /** - * Only legendary armor will have assumed masterworked stats. - */ - AssumeArmorMasterwork_Legendary = 1, - /** - * All armor (legendary & exotic) will have assumed masterworked stats. - */ - AssumeArmorMasterwork_All = 2, - /** - * All armor (legendary & exotic) will have assumed masterworked stats, and Exotic Armor will be upgraded to have an artifice mod slot. - */ - AssumeArmorMasterwork_ArtificeExotic = 3, -} - -/** - * Describes the enum stately.generated.AssumeArmorMasterwork. - */ -export declare const AssumeArmorMasterworkSchema: GenEnum; - -export enum CharacterOrder { - /** - * The zero value for CharacterOrder, used when it is not set to any other value. - */ - CharacterOrder_UNSPECIFIED = 0, - CharacterOrder_mostRecent = 1, - CharacterOrder_mostRecentReverse = 2, - CharacterOrder_fixed = 3, - CharacterOrder_custom = 4, -} - -/** - * Describes the enum stately.generated.CharacterOrder. - */ -export declare const CharacterOrderSchema: GenEnum; - -export enum DescriptionOptions { - /** - * The zero value for DescriptionOptions, used when it is not set to any other value. - */ - DescriptionOptions_UNSPECIFIED = 0, - DescriptionOptions_bungie = 1, - DescriptionOptions_community = 2, - DescriptionOptions_both = 3, -} - -/** - * Describes the enum stately.generated.DescriptionOptions. - */ -export declare const DescriptionOptionsSchema: GenEnum; - -export enum DestinyClass { - DestinyClass_Titan = 0, - DestinyClass_Hunter = 1, - DestinyClass_Warlock = 2, - DestinyClass_Unknown = 3, -} - -/** - * Describes the enum stately.generated.DestinyClass. - */ -export declare const DestinyClassSchema: GenEnum; - -export enum InfuseDirection { - /** - * The zero value for InfuseDirection, used when it is not set to any other value. - */ - InfuseDirection_UNSPECIFIED = 0, - /** - * infuse something into the query (query = target) - */ - InfuseDirection_Infuse = 1, - /** - * infuse the query into the target (query = source) - */ - InfuseDirection_Fuel = 2, -} - -/** - * Describes the enum stately.generated.InfuseDirection. - */ -export declare const InfuseDirectionSchema: GenEnum; - -export enum ItemPopupTab { - ItemPopupTab_Overview = 0, - ItemPopupTab_Triage = 1, -} - -/** - * Describes the enum stately.generated.ItemPopupTab. - */ -export declare const ItemPopupTabSchema: GenEnum; - -/** - * How the loadouts menu and page should be sorted - */ -export enum LoadoutSort { - LoadoutSort_ByEditTime = 0, - LoadoutSort_ByName = 1, -} - -/** - * Describes the enum stately.generated.LoadoutSort. - */ -export declare const LoadoutSortSchema: GenEnum; - -export enum OrnamentDisplay { - /** - * Always display the ornament's image. - */ - OrnamentDisplay_All = 0, - /** - * Always display the base image. - */ - OrnamentDisplay_None = 1, -} - -/** - * Describes the enum stately.generated.OrnamentDisplay. - */ -export declare const OrnamentDisplaySchema: GenEnum; - -export enum SearchType { - SearchType_Item = 0, - SearchType_Loadout = 1, -} - -/** - * Describes the enum stately.generated.SearchType. - */ -export declare const SearchTypeSchema: GenEnum; - -export enum TagValue { - /** - * The zero value for TagValue, used when it is not set to any other value. - */ - TagValue_UNSPECIFIED = 0, - TagValue_favorite = 1, - TagValue_keep = 2, - TagValue_infuse = 3, - TagValue_junk = 4, - TagValue_archive = 5, -} - -/** - * Describes the enum stately.generated.TagValue. - */ -export declare const TagValueSchema: GenEnum; - -export enum VaultWeaponGroupingStyle { - VaultWeaponGroupingStyle_Lines = 0, - VaultWeaponGroupingStyle_Inline = 1, -} - -/** - * Describes the enum stately.generated.VaultWeaponGroupingStyle. - */ -export declare const VaultWeaponGroupingStyleSchema: GenEnum; diff --git a/api/stately/generated/stately_pb.js b/api/stately/generated/stately_pb.js deleted file mode 100644 index d839e6e1..00000000 --- a/api/stately/generated/stately_pb.js +++ /dev/null @@ -1,226 +0,0 @@ -// @generated by Stately. DO NOT EDIT. -/* eslint-disable */ - -import { enumDesc, fileDesc, messageDesc, tsEnum } from '@stately-cloud/client'; - -export const file_stately = - /*@__PURE__*/ - fileDesc( - 'Cg1zdGF0ZWx5LnByb3RvEhFzdGF0ZWx5LmdlbmVyYXRlZCKcAQoGQXBpQXBwEhAKAmlkGAEgASgJQgBSAmlkEiQKDGJ1bmdpZUFwaUtleRgCIAEoCUIAUgxidW5naWVBcGlLZXkSHgoJZGltQXBpS2V5GAMgASgJQgBSCWRpbUFwaUtleRIYCgZvcmlnaW4YBCABKAlCAFIGb3JpZ2luEh4KCXBhcnRpdGlvbhgFIAEoBEIAUglwYXJ0aXRpb246ACJpCg9BcnRpZmFjdFVubG9ja3MSMAoSdW5sb2NrZWRJdGVtSGFzaGVzGAEgAygNQgBSEnVubG9ja2VkSXRlbUhhc2hlcxIkCgxzZWFzb25OdW1iZXIYAiABKA1CAFIMc2Vhc29uTnVtYmVyIkYKEENvbGxhcHNlZFNlY3Rpb24SEgoDa2V5GAEgASgJQgBSA2tleRIeCgljb2xsYXBzZWQYAiABKAhCAFIJY29sbGFwc2VkIucBCg1DdXN0b21TdGF0RGVmEhwKCHN0YXRIYXNoGAEgASgNQgBSCHN0YXRIYXNoEhYKBWxhYmVsGAIgASgJQgBSBWxhYmVsEiAKCnNob3J0TGFiZWwYAyABKAlCAFIKc2hvcnRMYWJlbBI3CgVjbGFzcxgEIAEoDjIfLnN0YXRlbHkuZ2VuZXJhdGVkLkRlc3RpbnlDbGFzc0IAUgVjbGFzcxJFCgd3ZWlnaHRzGAUgAygLMikuc3RhdGVseS5nZW5lcmF0ZWQuQ3VzdG9tU3RhdFdlaWdodHNFbnRyeUIAUgd3ZWlnaHRzIncKEEN1c3RvbVN0YXRzRW50cnkSPwoJY2xhc3NUeXBlGAEgASgOMh8uc3RhdGVseS5nZW5lcmF0ZWQuRGVzdGlueUNsYXNzQgBSCWNsYXNzVHlwZRIiCgtjdXN0b21TdGF0cxgCIAMoDUIAUgtjdXN0b21TdGF0cyJQChZDdXN0b21TdGF0V2VpZ2h0c0VudHJ5EhwKCHN0YXRIYXNoGAEgASgNQgBSCHN0YXRIYXNoEhgKBndlaWdodBgCIAEoAUIAUgZ3ZWlnaHQi7gMKDkdsb2JhbFNldHRpbmdzEhYKBXN0YWdlGAEgASgJQgBSBXN0YWdlEiYKDWRpbUFwaUVuYWJsZWQYAiABKAhCAFINZGltQXBpRW5hYmxlZBJUCiRkZXN0aW55UHJvZmlsZU1pbmltdW1SZWZyZXNoSW50ZXJ2YWwYAyABKBJCAFIkZGVzdGlueVByb2ZpbGVNaW5pbXVtUmVmcmVzaEludGVydmFsEkYKHWRlc3RpbnlQcm9maWxlUmVmcmVzaEludGVydmFsGAQgASgSQgBSHWRlc3RpbnlQcm9maWxlUmVmcmVzaEludGVydmFsEiIKC2F1dG9SZWZyZXNoGAUgASgIQgBSC2F1dG9SZWZyZXNoEjoKF3JlZnJlc2hQcm9maWxlT25WaXNpYmxlGAYgASgIQgBSF3JlZnJlc2hQcm9maWxlT25WaXNpYmxlEkwKIGRpbVByb2ZpbGVNaW5pbXVtUmVmcmVzaEludGVydmFsGAcgASgSQgBSIGRpbVByb2ZpbGVNaW5pbXVtUmVmcmVzaEludGVydmFsEioKD3Nob3dJc3N1ZUJhbm5lchgIIAEoCEIAUg9zaG93SXNzdWVCYW5uZXISIgoLbGFzdFVwZGF0ZWQYCSABKBJCAFILbGFzdFVwZGF0ZWQ6ACJ2ChhJbkdhbWVMb2Fkb3V0SWRlbnRpZmllcnMSHgoJY29sb3JIYXNoGAEgASgNQgBSCWNvbG9ySGFzaBIcCghpY29uSGFzaBgCIAEoDUIAUghpY29uSGFzaBIcCghuYW1lSGFzaBgDIAEoDUIAUghuYW1lSGFzaCLbAQoOSXRlbUFubm90YXRpb24SHgoJcHJvZmlsZUlkGAEgASgEQgBSCXByb2ZpbGVJZBIoCg5kZXN0aW55VmVyc2lvbhgCIAEoDUIAUg5kZXN0aW55VmVyc2lvbhIvCgN0YWcYAyABKA4yGy5zdGF0ZWx5LmdlbmVyYXRlZC5UYWdWYWx1ZUIAUgN0YWcSFgoFbm90ZXMYBCABKAlCAFIFbm90ZXMSEAoCaWQYBSABKARCAFICaWQSIgoLY3JhZnRlZERhdGUYBiABKBJCAFILY3JhZnRlZERhdGU6ACK4AQoLSXRlbUhhc2hUYWcSHgoJcHJvZmlsZUlkGAEgASgEQgBSCXByb2ZpbGVJZBIoCg5kZXN0aW55VmVyc2lvbhgCIAEoDUIAUg5kZXN0aW55VmVyc2lvbhIvCgN0YWcYAyABKA4yGy5zdGF0ZWx5LmdlbmVyYXRlZC5UYWdWYWx1ZUIAUgN0YWcSFgoFbm90ZXMYBCABKAlCAFIFbm90ZXMSFAoEaGFzaBgFIAEoDUIAUgRoYXNoOgAi5gMKB0xvYWRvdXQSEAoCaWQYASABKAxCAFICaWQSFAoEbmFtZRgCIAEoCUIAUgRuYW1lEhYKBW5vdGVzGAMgASgJQgBSBW5vdGVzEj8KCWNsYXNzVHlwZRgEIAEoDjIfLnN0YXRlbHkuZ2VuZXJhdGVkLkRlc3RpbnlDbGFzc0IAUgljbGFzc1R5cGUSPAoIZXF1aXBwZWQYBSADKAsyHi5zdGF0ZWx5LmdlbmVyYXRlZC5Mb2Fkb3V0SXRlbUIAUghlcXVpcHBlZBJACgp1bmVxdWlwcGVkGAYgAygLMh4uc3RhdGVseS5nZW5lcmF0ZWQuTG9hZG91dEl0ZW1CAFIKdW5lcXVpcHBlZBJGCgpwYXJhbWV0ZXJzGAcgASgLMiQuc3RhdGVseS5nZW5lcmF0ZWQuTG9hZG91dFBhcmFtZXRlcnNCAFIKcGFyYW1ldGVycxIeCgljcmVhdGVkQXQYCCABKBJCAFIJY3JlYXRlZEF0EiYKDWxhc3RVcGRhdGVkQXQYCSABKBJCAFINbGFzdFVwZGF0ZWRBdBIoCg5kZXN0aW55VmVyc2lvbhgKIAEoDUIAUg5kZXN0aW55VmVyc2lvbhIeCglwcm9maWxlSWQYCyABKARCAFIJcHJvZmlsZUlkOgAiwgEKC0xvYWRvdXRJdGVtEhAKAmlkGAEgASgEQgBSAmlkEhQKBGhhc2gYAiABKA1CAFIEaGFzaBIYCgZhbW91bnQYAyABKA1CAFIGYW1vdW50Ek0KD3NvY2tldE92ZXJyaWRlcxgEIAMoCzIhLnN0YXRlbHkuZ2VuZXJhdGVkLlNvY2tldE92ZXJyaWRlQgBSD3NvY2tldE92ZXJyaWRlcxIiCgtjcmFmdGVkRGF0ZRgFIAEoEkIAUgtjcmFmdGVkRGF0ZSLDBgoRTG9hZG91dFBhcmFtZXRlcnMSTQoPc3RhdENvbnN0cmFpbnRzGAEgAygLMiEuc3RhdGVseS5nZW5lcmF0ZWQuU3RhdENvbnN0cmFpbnRCAFIPc3RhdENvbnN0cmFpbnRzEhQKBG1vZHMYAiADKA1CAFIEbW9kcxIeCgljbGVhck1vZHMYAyABKAhCAFIJY2xlYXJNb2RzEiQKDGNsZWFyV2VhcG9ucxgEIAEoCEIAUgxjbGVhcldlYXBvbnMSIAoKY2xlYXJBcm1vchgFIAEoCEIAUgpjbGVhckFybW9yEkoKDG1vZHNCeUJ1Y2tldBgGIAMoCzIkLnN0YXRlbHkuZ2VuZXJhdGVkLk1vZHNCeUJ1Y2tldEVudHJ5QgBSDG1vZHNCeUJ1Y2tldBJOCg9hcnRpZmFjdFVubG9ja3MYByABKAsyIi5zdGF0ZWx5LmdlbmVyYXRlZC5BcnRpZmFjdFVubG9ja3NCAFIPYXJ0aWZhY3RVbmxvY2tzEiQKDGF1dG9TdGF0TW9kcxgIIAEoCEIAUgxhdXRvU3RhdE1vZHMSFgoFcXVlcnkYCSABKAlCAFIFcXVlcnkSYAoVYXNzdW1lQXJtb3JNYXN0ZXJ3b3JrGAogASgOMiguc3RhdGVseS5nZW5lcmF0ZWQuQXNzdW1lQXJtb3JNYXN0ZXJ3b3JrQgBSFWFzc3VtZUFybW9yTWFzdGVyd29yaxIqCg9leG90aWNBcm1vckhhc2gYCyABKANCAFIPZXhvdGljQXJtb3JIYXNoElsKEWluR2FtZUlkZW50aWZpZXJzGAwgASgLMisuc3RhdGVseS5nZW5lcmF0ZWQuSW5HYW1lTG9hZG91dElkZW50aWZpZXJzQgBSEWluR2FtZUlkZW50aWZpZXJzEkAKGmluY2x1ZGVSdW50aW1lU3RhdEJlbmVmaXRzGA0gASgIQgBSGmluY2x1ZGVSdW50aW1lU3RhdEJlbmVmaXRzEhYKBXBlcmtzGA4gAygNQgBSBXBlcmtzEkIKCnNldEJvbnVzZXMYDyADKAsyIC5zdGF0ZWx5LmdlbmVyYXRlZC5TZXRCb251c0NvdW50QgBSCnNldEJvbnVzZXMiiwQKDExvYWRvdXRTaGFyZRIQCgJpZBgBIAEoCUIAUgJpZBIUCgRuYW1lGAIgASgJQgBSBG5hbWUSFgoFbm90ZXMYAyABKAlCAFIFbm90ZXMSPwoJY2xhc3NUeXBlGAQgASgOMh8uc3RhdGVseS5nZW5lcmF0ZWQuRGVzdGlueUNsYXNzQgBSCWNsYXNzVHlwZRI8CghlcXVpcHBlZBgFIAMoCzIeLnN0YXRlbHkuZ2VuZXJhdGVkLkxvYWRvdXRJdGVtQgBSCGVxdWlwcGVkEkAKCnVuZXF1aXBwZWQYBiADKAsyHi5zdGF0ZWx5LmdlbmVyYXRlZC5Mb2Fkb3V0SXRlbUIAUgp1bmVxdWlwcGVkEkYKCnBhcmFtZXRlcnMYByABKAsyJC5zdGF0ZWx5LmdlbmVyYXRlZC5Mb2Fkb3V0UGFyYW1ldGVyc0IAUgpwYXJhbWV0ZXJzEh4KCWNyZWF0ZWRBdBgIIAEoEkIAUgljcmVhdGVkQXQSJgoNbGFzdFVwZGF0ZWRBdBgJIAEoEkIAUg1sYXN0VXBkYXRlZEF0EigKDmRlc3RpbnlWZXJzaW9uGAogASgNQgBSDmRlc3RpbnlWZXJzaW9uEh4KCXByb2ZpbGVJZBgLIAEoBEIAUglwcm9maWxlSWQSHgoJdmlld0NvdW50GA8gASgNQgBSCXZpZXdDb3VudDoAIlUKEU1vZHNCeUJ1Y2tldEVudHJ5EiAKCmJ1Y2tldEhhc2gYASABKA1CAFIKYnVja2V0SGFzaBIeCgltb2RIYXNoZXMYAiADKA1CAFIJbW9kSGFzaGVzIpMCCgZTZWFyY2gSFgoFcXVlcnkYASABKAlCAFIFcXVlcnkSIAoKdXNhZ2VDb3VudBgCIAEoDUIAUgp1c2FnZUNvdW50EhYKBXNhdmVkGAMgASgIQgBSBXNhdmVkEh4KCWxhc3RVc2FnZRgEIAEoEkIAUglsYXN0VXNhZ2USMwoEdHlwZRgFIAEoDjIdLnN0YXRlbHkuZ2VuZXJhdGVkLlNlYXJjaFR5cGVCAFIEdHlwZRIWCgVxaGFzaBgGIAEoDEIAUgVxaGFzaBIeCglwcm9maWxlSWQYByABKARCAFIJcHJvZmlsZUlkEigKDmRlc3RpbnlWZXJzaW9uGAggASgNQgBSDmRlc3RpbnlWZXJzaW9uOgAiTQoNU2V0Qm9udXNDb3VudBIkCgxzZXRCb251c0hhc2gYASABKA1CAFIMc2V0Qm9udXNIYXNoEhYKBWNvdW50GAIgASgNQgBSBWNvdW50IrkWCghTZXR0aW5ncxIcCghtZW1iZXJJZBgBIAEoBEIAUghtZW1iZXJJZBIiCgtpdGVtUXVhbGl0eRgCIAEoCEIAUgtpdGVtUXVhbGl0eRIkCgxzaG93TmV3SXRlbXMYAyABKAhCAFIMc2hvd05ld0l0ZW1zEksKDmNoYXJhY3Rlck9yZGVyGAQgASgOMiEuc3RhdGVseS5nZW5lcmF0ZWQuQ2hhcmFjdGVyT3JkZXJCAFIOY2hhcmFjdGVyT3JkZXISMgoTaXRlbVNvcnRPcmRlckN1c3RvbRgFIAMoCUIAUhNpdGVtU29ydE9yZGVyQ3VzdG9tEi4KEWl0ZW1Tb3J0UmV2ZXJzYWxzGAYgAygJQgBSEWl0ZW1Tb3J0UmV2ZXJzYWxzEhoKB2NoYXJDb2wYByABKA1CAFIHY2hhckNvbBImCg1jaGFyQ29sTW9iaWxlGAggASgNQgBSDWNoYXJDb2xNb2JpbGUSHAoIaXRlbVNpemUYCSABKA1CAFIIaXRlbVNpemUSUwoRY29sbGFwc2VkU2VjdGlvbnMYCiADKAsyIy5zdGF0ZWx5LmdlbmVyYXRlZC5Db2xsYXBzZWRTZWN0aW9uQgBSEWNvbGxhcHNlZFNlY3Rpb25zEjgKFmNvbXBsZXRlZFJlY29yZHNIaWRkZW4YCyABKAhCAFIWY29tcGxldGVkUmVjb3Jkc0hpZGRlbhI6ChdyZWRhY3RlZFJlY29yZHNSZXZlYWxlZBgMIAEoCEIAUhdyZWRhY3RlZFJlY29yZHNSZXZlYWxlZBI6ChdmYXJtaW5nTWFrZVJvb21Gb3JJdGVtcxgNIAEoCEIAUhdmYXJtaW5nTWFrZVJvb21Gb3JJdGVtcxI0ChRpbnZlbnRvcnlDbGVhclNwYWNlcxgOIAEoDUIAUhRpbnZlbnRvcnlDbGVhclNwYWNlcxI0ChRoaWRlQ29tcGxldGVkUmVjb3JkcxgPIAEoCEIAUhRoaWRlQ29tcGxldGVkUmVjb3JkcxIyChNjdXN0b21DaGFyYWN0ZXJTb3J0GBAgAygJQgBSE2N1c3RvbUNoYXJhY3RlclNvcnQSUgoRaW5mdXNpb25EaXJlY3Rpb24YESABKA4yIi5zdGF0ZWx5LmdlbmVyYXRlZC5JbmZ1c2VEaXJlY3Rpb25CAFIRaW5mdXNpb25EaXJlY3Rpb24SHAoIbGFuZ3VhZ2UYEiABKAlCAFIIbGFuZ3VhZ2USKgoPd2lzaExpc3RTb3VyY2VzGBMgAygJQgBSD3dpc2hMaXN0U291cmNlcxJKCgxsb1BhcmFtZXRlcnMYFCABKAsyJC5zdGF0ZWx5LmdlbmVyYXRlZC5Mb2Fkb3V0UGFyYW1ldGVyc0IAUgxsb1BhcmFtZXRlcnMSZQoYbG9TdGF0Q29uc3RyYWludHNCeUNsYXNzGBUgAygLMicuc3RhdGVseS5nZW5lcmF0ZWQuU3RhdENvbnN0cmFpbnRzRW50cnlCAFIYbG9TdGF0Q29uc3RyYWludHNCeUNsYXNzEl8KF2N1c3RvbVRvdGFsU3RhdHNCeUNsYXNzGBYgAygLMiMuc3RhdGVseS5nZW5lcmF0ZWQuQ3VzdG9tU3RhdHNFbnRyeUIAUhdjdXN0b21Ub3RhbFN0YXRzQnlDbGFzcxI6Chdvcmdhbml6ZXJDb2x1bW5zV2VhcG9ucxgXIAMoCUIAUhdvcmdhbml6ZXJDb2x1bW5zV2VhcG9ucxI2ChVvcmdhbml6ZXJDb2x1bW5zQXJtb3IYGCADKAlCAFIVb3JnYW5pemVyQ29sdW1uc0FybW9yEjYKFW9yZ2FuaXplckNvbHVtbnNHaG9zdBgZIAMoCUIAUhVvcmdhbml6ZXJDb2x1bW5zR2hvc3QSLAoQY29tcGFyZUJhc2VTdGF0cxgaIAEoCEIAUhBjb21wYXJlQmFzZVN0YXRzEiwKEHNpZGVjYXJDb2xsYXBzZWQYGyABKAhCAFIQc2lkZWNhckNvbGxhcHNlZBIqCg9zaW5nbGVDaGFyYWN0ZXIYHCABKAhCAFIPc2luZ2xlQ2hhcmFjdGVyEioKD2JhZGdlUG9zdG1hc3RlchgdIAEoCEIAUg9iYWRnZVBvc3RtYXN0ZXISHAoIcGVya0xpc3QYHiABKAhCAFIIcGVya0xpc3QSQgoLbG9hZG91dFNvcnQYHyABKA4yHi5zdGF0ZWx5LmdlbmVyYXRlZC5Mb2Fkb3V0U29ydEIAUgtsb2Fkb3V0U29ydBIwChJpdGVtRmVlZEhpZGVUYWdnZWQYICABKAhCAFISaXRlbUZlZWRIaWRlVGFnZ2VkEiwKEGl0ZW1GZWVkRXhwYW5kZWQYISABKAhCAFIQaXRlbUZlZWRFeHBhbmRlZBI4ChZoaWRlUHVsbEZyb21Qb3N0bWFzdGVyGCIgASgIQgBSFmhpZGVQdWxsRnJvbVBvc3RtYXN0ZXISXQoVZGVzY3JpcHRpb25zVG9EaXNwbGF5GCMgASgOMiUuc3RhdGVseS5nZW5lcmF0ZWQuRGVzY3JpcHRpb25PcHRpb25zQgBSFWRlc2NyaXB0aW9uc1RvRGlzcGxheRI6Chdjb21wYXJlV2VhcG9uTWFzdGVyd29yaxgkIAEoCEIAUhdjb21wYXJlV2VhcG9uTWFzdGVyd29yaxIuChFpdGVtRmVlZFdhdGVybWFyaxglIAEoBEIAUhFpdGVtRmVlZFdhdGVybWFyaxJECgtjdXN0b21TdGF0cxgmIAMoCzIgLnN0YXRlbHkuZ2VuZXJhdGVkLkN1c3RvbVN0YXREZWZCAFILY3VzdG9tU3RhdHMSKAoOYXV0b0xvY2tUYWdnZWQYJyABKAhCAFIOYXV0b0xvY2tUYWdnZWQSFgoFdGhlbWUYKCABKAlCAFIFdGhlbWUSNgoVc29ydFJlY29yZFByb2dyZXNzaW9uGCkgASgIQgBSFXNvcnRSZWNvcmRQcm9ncmVzc2lvbhI4ChZ2ZW5kb3JzSGlkZVNpbHZlckl0ZW1zGCogASgIQgBSFnZlbmRvcnNIaWRlU2lsdmVySXRlbXMSMgoTdmF1bHRXZWFwb25Hcm91cGluZxgrIAEoCUIAUhN2YXVsdFdlYXBvbkdyb3VwaW5nEmkKGHZhdWx0V2VhcG9uR3JvdXBpbmdTdHlsZRgsIAEoDjIrLnN0YXRlbHkuZ2VuZXJhdGVkLlZhdWx0V2VhcG9uR3JvdXBpbmdTdHlsZUIAUhh2YXVsdFdlYXBvbkdyb3VwaW5nU3R5bGUSRQoMaXRlbVBvcHVwVGFiGC0gASgOMh8uc3RhdGVseS5nZW5lcmF0ZWQuSXRlbVBvcHVwVGFiQgBSDGl0ZW1Qb3B1cFRhYhJnChd2YXVsdEFybW9yR3JvdXBpbmdTdHlsZRguIAEoDjIrLnN0YXRlbHkuZ2VuZXJhdGVkLlZhdWx0V2VhcG9uR3JvdXBpbmdTdHlsZUIAUhd2YXVsdEFybW9yR3JvdXBpbmdTdHlsZRIqCg9wZXJrTGlzdERlc2t0b3AYLyABKAhCAFIPcGVya0xpc3REZXNrdG9wEiAKCnZhdWx0QmVsb3cYMCABKAhCAFIKdmF1bHRCZWxvdxJJCgxhcm1vckNvbXBhcmUYMSABKA4yIy5zdGF0ZWx5LmdlbmVyYXRlZC5Bcm1vclN0YXRDb21wYXJlQgBSDGFybW9yQ29tcGFyZRJOCg9vcm5hbWVudERpc3BsYXkYMiABKA4yIi5zdGF0ZWx5LmdlbmVyYXRlZC5Pcm5hbWVudERpc3BsYXlCAFIPb3JuYW1lbnREaXNwbGF5OgAiUgoOU29ja2V0T3ZlcnJpZGUSIgoLc29ja2V0SW5kZXgYASABKA1CAFILc29ja2V0SW5kZXgSHAoIaXRlbUhhc2gYAiABKA1CAFIIaXRlbUhhc2gingEKDlN0YXRDb25zdHJhaW50EhwKCHN0YXRIYXNoGAEgASgNQgBSCHN0YXRIYXNoEhoKB21pblRpZXIYAiABKA1CAFIHbWluVGllchIaCgdtYXhUaWVyGAMgASgNQgBSB21heFRpZXISGgoHbWluU3RhdBgEIAEoDUIAUgdtaW5TdGF0EhoKB21heFN0YXQYBSABKA1CAFIHbWF4U3RhdCKeAQoUU3RhdENvbnN0cmFpbnRzRW50cnkSPwoJY2xhc3NUeXBlGAEgASgOMh8uc3RhdGVseS5nZW5lcmF0ZWQuRGVzdGlueUNsYXNzQgBSCWNsYXNzVHlwZRJFCgtjb25zdHJhaW50cxgCIAMoCzIhLnN0YXRlbHkuZ2VuZXJhdGVkLlN0YXRDb25zdHJhaW50QgBSC2NvbnN0cmFpbnRzIncKB1RyaXVtcGgSIAoKcmVjb3JkSGFzaBgBIAEoDUIAUgpyZWNvcmRIYXNoEh4KCXByb2ZpbGVJZBgCIAEoBEIAUglwcm9maWxlSWQSKAoOZGVzdGlueVZlcnNpb24YByABKA1CAFIOZGVzdGlueVZlcnNpb246ACpwChBBcm1vclN0YXRDb21wYXJlEhwKGEFybW9yU3RhdENvbXBhcmVfQ3VycmVudBAAEhkKFUFybW9yU3RhdENvbXBhcmVfQmFzZRABEiMKH0FybW9yU3RhdENvbXBhcmVfQmFzZU1hc3RlcndvcmsQAiqlAQoVQXNzdW1lQXJtb3JNYXN0ZXJ3b3JrEh4KGkFzc3VtZUFybW9yTWFzdGVyd29ya19Ob25lEAASIwofQXNzdW1lQXJtb3JNYXN0ZXJ3b3JrX0xlZ2VuZGFyeRABEh0KGUFzc3VtZUFybW9yTWFzdGVyd29ya19BbGwQAhIoCiRBc3N1bWVBcm1vck1hc3RlcndvcmtfQXJ0aWZpY2VFeG90aWMQAyqqAQoOQ2hhcmFjdGVyT3JkZXISHgoaQ2hhcmFjdGVyT3JkZXJfVU5TUEVDSUZJRUQQABIdChlDaGFyYWN0ZXJPcmRlcl9tb3N0UmVjZW50EAESJAogQ2hhcmFjdGVyT3JkZXJfbW9zdFJlY2VudFJldmVyc2UQAhIYChRDaGFyYWN0ZXJPcmRlcl9maXhlZBADEhkKFUNoYXJhY3Rlck9yZGVyX2N1c3RvbRAEKpYBChJEZXNjcmlwdGlvbk9wdGlvbnMSIgoeRGVzY3JpcHRpb25PcHRpb25zX1VOU1BFQ0lGSUVEEAASHQoZRGVzY3JpcHRpb25PcHRpb25zX2J1bmdpZRABEiAKHERlc2NyaXB0aW9uT3B0aW9uc19jb21tdW5pdHkQAhIbChdEZXNjcmlwdGlvbk9wdGlvbnNfYm90aBADKnMKDERlc3RpbnlDbGFzcxIWChJEZXN0aW55Q2xhc3NfVGl0YW4QABIXChNEZXN0aW55Q2xhc3NfSHVudGVyEAESGAoURGVzdGlueUNsYXNzX1dhcmxvY2sQAhIYChREZXN0aW55Q2xhc3NfVW5rbm93bhADKmgKD0luZnVzZURpcmVjdGlvbhIfChtJbmZ1c2VEaXJlY3Rpb25fVU5TUEVDSUZJRUQQABIaChZJbmZ1c2VEaXJlY3Rpb25fSW5mdXNlEAESGAoUSW5mdXNlRGlyZWN0aW9uX0Z1ZWwQAipCCgxJdGVtUG9wdXBUYWISGQoVSXRlbVBvcHVwVGFiX092ZXJ2aWV3EAASFwoTSXRlbVBvcHVwVGFiX1RyaWFnZRABKkEKC0xvYWRvdXRTb3J0EhoKFkxvYWRvdXRTb3J0X0J5RWRpdFRpbWUQABIWChJMb2Fkb3V0U29ydF9CeU5hbWUQASpECg9Pcm5hbWVudERpc3BsYXkSFwoTT3JuYW1lbnREaXNwbGF5X0FsbBAAEhgKFE9ybmFtZW50RGlzcGxheV9Ob25lEAEqOQoKU2VhcmNoVHlwZRITCg9TZWFyY2hUeXBlX0l0ZW0QABIWChJTZWFyY2hUeXBlX0xvYWRvdXQQASqMAQoIVGFnVmFsdWUSGAoUVGFnVmFsdWVfVU5TUEVDSUZJRUQQABIVChFUYWdWYWx1ZV9mYXZvcml0ZRABEhEKDVRhZ1ZhbHVlX2tlZXAQAhITCg9UYWdWYWx1ZV9pbmZ1c2UQAxIRCg1UYWdWYWx1ZV9qdW5rEAQSFAoQVGFnVmFsdWVfYXJjaGl2ZRAFKmMKGFZhdWx0V2VhcG9uR3JvdXBpbmdTdHlsZRIiCh5WYXVsdFdlYXBvbkdyb3VwaW5nU3R5bGVfTGluZXMQABIjCh9WYXVsdFdlYXBvbkdyb3VwaW5nU3R5bGVfSW5saW5lEAFiBnByb3RvMw==', - ); - -/** - * Describes the message stately.generated.ApiApp. - * Use `create(ApiAppSchema)` to create a new message. - */ -export const ApiAppSchema = /*@__PURE__*/ messageDesc(file_stately, 0); - -/** - * Describes the message stately.generated.ArtifactUnlocks. - * Use `create(ArtifactUnlocksSchema)` to create a new message. - */ -export const ArtifactUnlocksSchema = /*@__PURE__*/ messageDesc(file_stately, 1); - -/** - * Describes the message stately.generated.CollapsedSection. - * Use `create(CollapsedSectionSchema)` to create a new message. - */ -export const CollapsedSectionSchema = /*@__PURE__*/ messageDesc(file_stately, 2); - -/** - * Describes the message stately.generated.CustomStatDef. - * Use `create(CustomStatDefSchema)` to create a new message. - */ -export const CustomStatDefSchema = /*@__PURE__*/ messageDesc(file_stately, 3); - -/** - * Describes the message stately.generated.CustomStatsEntry. - * Use `create(CustomStatsEntrySchema)` to create a new message. - */ -export const CustomStatsEntrySchema = /*@__PURE__*/ messageDesc(file_stately, 4); - -/** - * Describes the message stately.generated.CustomStatWeightsEntry. - * Use `create(CustomStatWeightsEntrySchema)` to create a new message. - */ -export const CustomStatWeightsEntrySchema = /*@__PURE__*/ messageDesc(file_stately, 5); - -/** - * Describes the message stately.generated.GlobalSettings. - * Use `create(GlobalSettingsSchema)` to create a new message. - */ -export const GlobalSettingsSchema = /*@__PURE__*/ messageDesc(file_stately, 6); - -/** - * Describes the message stately.generated.InGameLoadoutIdentifiers. - * Use `create(InGameLoadoutIdentifiersSchema)` to create a new message. - */ -export const InGameLoadoutIdentifiersSchema = /*@__PURE__*/ messageDesc(file_stately, 7); - -/** - * Describes the message stately.generated.ItemAnnotation. - * Use `create(ItemAnnotationSchema)` to create a new message. - */ -export const ItemAnnotationSchema = /*@__PURE__*/ messageDesc(file_stately, 8); - -/** - * Describes the message stately.generated.ItemHashTag. - * Use `create(ItemHashTagSchema)` to create a new message. - */ -export const ItemHashTagSchema = /*@__PURE__*/ messageDesc(file_stately, 9); - -/** - * Describes the message stately.generated.Loadout. - * Use `create(LoadoutSchema)` to create a new message. - */ -export const LoadoutSchema = /*@__PURE__*/ messageDesc(file_stately, 10); - -/** - * Describes the message stately.generated.LoadoutItem. - * Use `create(LoadoutItemSchema)` to create a new message. - */ -export const LoadoutItemSchema = /*@__PURE__*/ messageDesc(file_stately, 11); - -/** - * Describes the message stately.generated.LoadoutParameters. - * Use `create(LoadoutParametersSchema)` to create a new message. - */ -export const LoadoutParametersSchema = /*@__PURE__*/ messageDesc(file_stately, 12); - -/** - * Describes the message stately.generated.LoadoutShare. - * Use `create(LoadoutShareSchema)` to create a new message. - */ -export const LoadoutShareSchema = /*@__PURE__*/ messageDesc(file_stately, 13); - -/** - * Describes the message stately.generated.ModsByBucketEntry. - * Use `create(ModsByBucketEntrySchema)` to create a new message. - */ -export const ModsByBucketEntrySchema = /*@__PURE__*/ messageDesc(file_stately, 14); - -/** - * Describes the message stately.generated.Search. - * Use `create(SearchSchema)` to create a new message. - */ -export const SearchSchema = /*@__PURE__*/ messageDesc(file_stately, 15); - -/** - * Describes the message stately.generated.SetBonusCount. - * Use `create(SetBonusCountSchema)` to create a new message. - */ -export const SetBonusCountSchema = /*@__PURE__*/ messageDesc(file_stately, 16); - -/** - * Describes the message stately.generated.Settings. - * Use `create(SettingsSchema)` to create a new message. - */ -export const SettingsSchema = /*@__PURE__*/ messageDesc(file_stately, 17); - -/** - * Describes the message stately.generated.SocketOverride. - * Use `create(SocketOverrideSchema)` to create a new message. - */ -export const SocketOverrideSchema = /*@__PURE__*/ messageDesc(file_stately, 18); - -/** - * Describes the message stately.generated.StatConstraint. - * Use `create(StatConstraintSchema)` to create a new message. - */ -export const StatConstraintSchema = /*@__PURE__*/ messageDesc(file_stately, 19); - -/** - * Describes the message stately.generated.StatConstraintsEntry. - * Use `create(StatConstraintsEntrySchema)` to create a new message. - */ -export const StatConstraintsEntrySchema = /*@__PURE__*/ messageDesc(file_stately, 20); - -/** - * Describes the message stately.generated.Triumph. - * Use `create(TriumphSchema)` to create a new message. - */ -export const TriumphSchema = /*@__PURE__*/ messageDesc(file_stately, 21); - -/** - * Describes the enum stately.generated.ArmorStatCompare. - */ -export const ArmorStatCompareSchema = /*@__PURE__*/ enumDesc(file_stately, 0); - -export const ArmorStatCompare = /*@__PURE__*/ tsEnum(ArmorStatCompareSchema); - -/** - * Describes the enum stately.generated.AssumeArmorMasterwork. - */ -export const AssumeArmorMasterworkSchema = /*@__PURE__*/ enumDesc(file_stately, 1); - -export const AssumeArmorMasterwork = /*@__PURE__*/ tsEnum(AssumeArmorMasterworkSchema); - -/** - * Describes the enum stately.generated.CharacterOrder. - */ -export const CharacterOrderSchema = /*@__PURE__*/ enumDesc(file_stately, 2); - -export const CharacterOrder = /*@__PURE__*/ tsEnum(CharacterOrderSchema); - -/** - * Describes the enum stately.generated.DescriptionOptions. - */ -export const DescriptionOptionsSchema = /*@__PURE__*/ enumDesc(file_stately, 3); - -export const DescriptionOptions = /*@__PURE__*/ tsEnum(DescriptionOptionsSchema); - -/** - * Describes the enum stately.generated.DestinyClass. - */ -export const DestinyClassSchema = /*@__PURE__*/ enumDesc(file_stately, 4); - -export const DestinyClass = /*@__PURE__*/ tsEnum(DestinyClassSchema); - -/** - * Describes the enum stately.generated.InfuseDirection. - */ -export const InfuseDirectionSchema = /*@__PURE__*/ enumDesc(file_stately, 5); - -export const InfuseDirection = /*@__PURE__*/ tsEnum(InfuseDirectionSchema); - -/** - * Describes the enum stately.generated.ItemPopupTab. - */ -export const ItemPopupTabSchema = /*@__PURE__*/ enumDesc(file_stately, 6); - -export const ItemPopupTab = /*@__PURE__*/ tsEnum(ItemPopupTabSchema); - -/** - * Describes the enum stately.generated.LoadoutSort. - */ -export const LoadoutSortSchema = /*@__PURE__*/ enumDesc(file_stately, 7); - -export const LoadoutSort = /*@__PURE__*/ tsEnum(LoadoutSortSchema); - -/** - * Describes the enum stately.generated.OrnamentDisplay. - */ -export const OrnamentDisplaySchema = /*@__PURE__*/ enumDesc(file_stately, 8); - -export const OrnamentDisplay = /*@__PURE__*/ tsEnum(OrnamentDisplaySchema); - -/** - * Describes the enum stately.generated.SearchType. - */ -export const SearchTypeSchema = /*@__PURE__*/ enumDesc(file_stately, 9); - -export const SearchType = /*@__PURE__*/ tsEnum(SearchTypeSchema); - -/** - * Describes the enum stately.generated.TagValue. - */ -export const TagValueSchema = /*@__PURE__*/ enumDesc(file_stately, 10); - -export const TagValue = /*@__PURE__*/ tsEnum(TagValueSchema); - -/** - * Describes the enum stately.generated.VaultWeaponGroupingStyle. - */ -export const VaultWeaponGroupingStyleSchema = /*@__PURE__*/ enumDesc(file_stately, 11); - -export const VaultWeaponGroupingStyle = /*@__PURE__*/ tsEnum(VaultWeaponGroupingStyleSchema); diff --git a/api/stately/init/global-settings.ts b/api/stately/init/global-settings.ts deleted file mode 100644 index faf3ae2c..00000000 --- a/api/stately/init/global-settings.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { client } from '../client.js'; - -const devSettings = client.create('GlobalSettings', { - stage: 'dev', - dimApiEnabled: true, - destinyProfileMinimumRefreshInterval: 15n, - destinyProfileRefreshInterval: 120n, - autoRefresh: true, - refreshProfileOnVisible: true, - dimProfileMinimumRefreshInterval: 1n, - showIssueBanner: false, -}); - -const betaSettings = client.create('GlobalSettings', { - stage: 'beta', - dimApiEnabled: true, - destinyProfileMinimumRefreshInterval: 15n, - destinyProfileRefreshInterval: 120n, - autoRefresh: true, - refreshProfileOnVisible: true, - dimProfileMinimumRefreshInterval: 1n, - showIssueBanner: false, -}); - -const prodSettings = client.create('GlobalSettings', { - stage: 'app', - dimApiEnabled: true, - destinyProfileMinimumRefreshInterval: 15n, - destinyProfileRefreshInterval: 120n, - autoRefresh: true, - refreshProfileOnVisible: true, - dimProfileMinimumRefreshInterval: 1n, - showIssueBanner: false, -}); - -await client.putBatch(devSettings, betaSettings, prodSettings); - -console.log('Global settings initialized'); -// This is due to a bug in Connect! -process.exit(0); diff --git a/api/stately/init/migrate-loadout-shares.ts b/api/stately/init/migrate-loadout-shares.ts deleted file mode 100644 index a5c81084..00000000 --- a/api/stately/init/migrate-loadout-shares.ts +++ /dev/null @@ -1,6 +0,0 @@ -// import { migrateLoadoutShareChunk } from '../migrator/loadout-shares.js'; - -// while (true) { -// await migrateLoadoutShareChunk(); -// console.log('Migrated loadout shares'); -// } diff --git a/api/stately/init/migrate-stately-to-postgres.ts b/api/stately/init/migrate-stately-to-postgres.ts deleted file mode 100644 index ec3834cb..00000000 --- a/api/stately/init/migrate-stately-to-postgres.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { StatelyError } from '@stately-cloud/client'; -import { ClientBase } from 'pg'; -import { DatabaseError } from 'pg-protocol'; -import { closeDbPool, transaction } from '../../db/index.js'; -import { updateItemAnnotation } from '../../db/item-annotations-queries.js'; -import { updateItemHashTag } from '../../db/item-hash-tags-queries.js'; -import { updateLoadout } from '../../db/loadouts-queries.js'; -import { - abortMigrationToPostgres, - claimMigrationWork, - finishMigrationToPostgres, -} from '../../db/migration-state-queries.js'; -import { importSearch } from '../../db/searches-queries.js'; -import { trackTriumph } from '../../db/triumphs-queries.js'; -import { extractImportData } from '../../routes/import.js'; -import { delay } from '../../utils.js'; -import { exportDataForProfile } from '../bulk-queries.js'; - -const workerBatchSize = parseNumberEnv('MIGRATION_WORKER_BATCH_SIZE', 25); -const idleDelayMs = parseNumberEnv('MIGRATION_WORKER_IDLE_DELAY_MS', 5000); -const retryMaxAttempts = parseNumberEnv('MIGRATION_RETRY_MAX_ATTEMPTS', 12); -const retryBaseDelayMs = parseNumberEnv('MIGRATION_RETRY_BASE_DELAY_MS', 1000); -const retryMaxDelayMs = parseNumberEnv('MIGRATION_RETRY_MAX_DELAY_MS', 30000); -const statelyThrottleMinDelayMs = parseNumberEnv('MIGRATION_STATELY_THROTTLE_DELAY_MS', 15000); -const retryThrottlingForever = - (process.env.MIGRATION_RETRY_THROTTLING_FOREVER ?? 'true') !== 'false'; -const runOnce = (process.env.MIGRATION_WORKER_RUN_ONCE ?? 'false') === 'true'; - -interface RetryableError { - code?: string; - statelyCode?: string; - status?: number; - statusCode?: number; - headers?: Record; - response?: { - status?: number; - headers?: Record; - }; - cause?: unknown; - message?: string; -} - -function isStatelyThroughputError(error: unknown): boolean { - if (error instanceof StatelyError) { - return ( - error.statelyCode === 'StoreThroughputExceeded' || - error.statelyCode === 'StoreRequestLimitExceeded' - ); - } - - if (!error || typeof error !== 'object') { - return false; - } - - const retryable = error as RetryableError; - if ( - retryable.statelyCode === 'StoreThroughputExceeded' || - retryable.statelyCode === 'StoreRequestLimitExceeded' - ) { - return true; - } - - return retryable.cause ? isStatelyThroughputError(retryable.cause) : false; -} - -function getNestedStatus(error: RetryableError): number | undefined { - return error.status ?? error.statusCode ?? error.response?.status; -} - -function getHeaders( - error: RetryableError, -): Record | undefined { - return error.headers ?? error.response?.headers; -} - -function getRetryAfterMs(error: RetryableError): number | undefined { - const headers = getHeaders(error); - const retryAfter = headers?.['retry-after'] ?? headers?.['Retry-After']; - if (retryAfter === undefined) { - return undefined; - } - - const retryAfterSeconds = Number(retryAfter); - if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { - return retryAfterSeconds * 1000; - } - - if (typeof retryAfter === 'string') { - const retryAt = Date.parse(retryAfter); - if (!Number.isNaN(retryAt)) { - return Math.max(0, retryAt - Date.now()); - } - } - - return undefined; -} - -function isRetryableError(error: unknown): boolean { - if (error instanceof StatelyError) { - return [ - 'StoreThroughputExceeded', - 'StoreRequestLimitExceeded', - 'StoreInUse', - 'ConcurrentModification', - 'CachedSchemaTooOld', - 'BackupsUnavailable', - ].includes(error.statelyCode); - } - - if (error instanceof DatabaseError) { - return error.code ? ['40001', '40P01', '53300', '57P03'].includes(error.code) : false; - } - - if (!error || typeof error !== 'object') { - return false; - } - - const retryable = error as RetryableError; - const status = getNestedStatus(retryable); - if (status === 429 || (status !== undefined && status >= 500)) { - return true; - } - - if ( - retryable.code && - ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ENOTFOUND'].includes(retryable.code) - ) { - return true; - } - - if (typeof retryable.message === 'string') { - const msg = retryable.message.toLowerCase(); - if ( - msg.includes('throttl') || - msg.includes('too many requests') || - msg.includes('rate limit') || - msg.includes('temporar') || - msg.includes('timeout') - ) { - return true; - } - } - - return retryable.cause ? isRetryableError(retryable.cause) : false; -} - -function retryDelayMs(error: unknown, attempt: number): number { - const forcedDelay = - error && typeof error === 'object' ? getRetryAfterMs(error as RetryableError) : undefined; - if (forcedDelay !== undefined) { - return Math.min(retryMaxDelayMs, Math.max(retryBaseDelayMs, forcedDelay)); - } - - const exponential = retryBaseDelayMs * 2 ** (attempt - 1); - const jitter = Math.floor(Math.random() * retryBaseDelayMs); - const retryDelay = exponential + jitter; - - if (isStatelyThroughputError(error)) { - return Math.min(retryMaxDelayMs, Math.max(statelyThrottleMinDelayMs, retryDelay)); - } - - return Math.min(retryMaxDelayMs, retryDelay); -} - -async function withRetry(name: string, fn: () => T | Promise): Promise { - let attempt = 0; - while (true) { - attempt += 1; - try { - return await fn(); - } catch (error) { - const retryable = isRetryableError(error); - const throttleRetry = retryThrottlingForever && isStatelyThroughputError(error); - if (!retryable || (!throttleRetry && attempt >= retryMaxAttempts)) { - throw error; - } - - const waitMs = retryDelayMs(error, attempt); - const message = error instanceof Error ? error.message : String(error); - const attemptLabel = throttleRetry - ? `${attempt}/unbounded` - : `${attempt}/${retryMaxAttempts}`; - console.log( - `${name} failed with retryable error (attempt ${attemptLabel}). Waiting ${waitMs}ms before retry.`, - message, - ); - await delay(waitMs); - } - } -} - -function parseNumberEnv(envName: string, defaultValue: number): number { - const raw = process.env[envName]; - if (!raw) { - return defaultValue; - } - - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error(`${envName} must be a positive number`); - } - - return parsed; -} - -function toErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message.slice(0, 500); - } - return String(error).slice(0, 500); -} - -function hasUnpairedSurrogates(value: string): boolean { - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - if (code >= 0xd800 && code <= 0xdbff) { - const next = value.charCodeAt(i + 1); - if (!(next >= 0xdc00 && next <= 0xdfff)) { - return true; - } - i += 1; - continue; - } - - if (code >= 0xdc00 && code <= 0xdfff) { - return true; - } - } - - return false; -} - -function isWellFormedUnicode(value: string): boolean { - const candidate = value as string & { isWellFormed?: () => boolean }; - if (typeof candidate.isWellFormed === 'function') { - return candidate.isWellFormed(); - } - - return !hasUnpairedSurrogates(value); -} - -function getUnsafePostgresTextReason(value: string): string | undefined { - if (!isWellFormedUnicode(value)) { - return 'string contains unpaired UTF-16 surrogate code units'; - } - - if (value.includes('\u0000')) { - return 'string contains NUL (\\u0000), which Postgres text cannot store'; - } - - return undefined; -} - -async function migrateOneClaimedUser( - pgClient: ClientBase, - bungieMembershipId: number | undefined, - platformMembershipId: string, -): Promise { - const exportResponse = await withRetry(`exportDataForProfile:${platformMembershipId}`, () => - exportDataForProfile(platformMembershipId), - ); - const { loadouts, itemAnnotations, triumphs, searches, itemHashTags } = - extractImportData(exportResponse); - - for (const loadoutData of loadouts) { - await updateLoadout( - pgClient, - bungieMembershipId, - loadoutData.platformMembershipId, - loadoutData.destinyVersion, - loadoutData, - ); - } - - for (const tagData of itemAnnotations) { - await updateItemAnnotation( - pgClient, - bungieMembershipId, - tagData.platformMembershipId, - tagData.destinyVersion, - tagData, - ); - } - - for (const hashTag of itemHashTags) { - await updateItemHashTag(pgClient, bungieMembershipId, platformMembershipId, hashTag); - } - - for (const triumphSet of triumphs) { - for (const recordHash of triumphSet.triumphs) { - await trackTriumph(pgClient, bungieMembershipId, triumphSet.platformMembershipId, recordHash); - } - } - - for (const searchData of searches) { - try { - // if the query isn't valid UTF-8, the importSearch function will throw. In that case we want to skip it and continue with the rest of the migration instead of failing the whole migration. - const invalidReason = getUnsafePostgresTextReason(searchData.search.query); - if (invalidReason) { - console.warn( - `Skipping search with invalid query for ${platformMembershipId} (${invalidReason}):`, - searchData.search.query, - ); - continue; - } - - await importSearch( - pgClient, - bungieMembershipId, - platformMembershipId, - searchData.destinyVersion, - searchData.search.query, - searchData.search.saved, - searchData.search.lastUsage, - searchData.search.usageCount, - searchData.search.type, - ); - } catch (error) { - console.error(`Failed to import search for ${platformMembershipId}`, searchData, error); - throw error; - } - } -} - -let loopsWithoutWork = 0; - -try { - while (true) { - const claimed = await withRetry('claimMigrationWork', () => - transaction((client) => claimMigrationWork(client, workerBatchSize)), - ); - - if (claimed.length === 0) { - loopsWithoutWork += 1; - if (loopsWithoutWork % 12 === 1) { - console.log('No migration work available, waiting...'); - } - if (runOnce) { - break; - } - await delay(idleDelayMs); - continue; - } - - loopsWithoutWork = 0; - console.log(`Claimed ${claimed.length} migration record(s)`); - - for (const workItem of claimed) { - const { platformMembershipId, bungieMembershipId, attemptCount } = workItem; - console.log( - `Migrating ${platformMembershipId} (bungie=${String(bungieMembershipId)}, attempt=${attemptCount})`, - ); - - try { - await withRetry(`migrateOne:${platformMembershipId}`, () => - transaction(async (pgClient) => { - await migrateOneClaimedUser(pgClient, bungieMembershipId, platformMembershipId); - }), - ); - - await withRetry(`finishMigration:${platformMembershipId}`, () => - transaction(async (pgClient) => { - await finishMigrationToPostgres(pgClient, bungieMembershipId, platformMembershipId); - }), - ); - - console.log(`Migration finished for ${platformMembershipId}`); - } catch (error) { - const errorMessage = toErrorMessage(error); - console.error(`Migration failed for ${platformMembershipId}:`, error); - await withRetry(`abortMigration:${platformMembershipId}`, () => - transaction(async (pgClient) => { - await abortMigrationToPostgres( - pgClient, - bungieMembershipId, - platformMembershipId, - errorMessage, - ); - }), - ); - } - } - - if (runOnce) { - break; - } - } -} finally { - await closeDbPool(); -} diff --git a/api/stately/init/migrate-users.ts b/api/stately/init/migrate-users.ts deleted file mode 100644 index 41fcd20a..00000000 --- a/api/stately/init/migrate-users.ts +++ /dev/null @@ -1,34 +0,0 @@ -// import { chunk } from 'es-toolkit'; -// import { readTransaction } from '../../db/index.js'; -// import { getUsersToMigrate } from '../../db/migration-state-queries.js'; -// import { delay } from '../../utils.js'; -// import { migrateUser } from '../migrator/user.js'; - -// while (true) { -// try { -// const bungieMembershipIds = await readTransaction(async (client) => getUsersToMigrate(client)); -// if (bungieMembershipIds.length === 0) { -// console.log('No users to migrate'); -// break; -// } -// for (const idChunk of chunk(bungieMembershipIds, 10)) { -// await Promise.all( -// idChunk.map(async (bungieMembershipId) => { -// try { -// await migrateUser(bungieMembershipId); -// console.log(`Migrated user ${bungieMembershipId}`); -// } catch (e) { -// if (e instanceof Error) { -// console.error(`Error migrating user ${bungieMembershipId}: ${e}`); -// } -// } -// }), -// ); -// } -// } catch (e) { -// if (e instanceof Error) { -// console.error(`Error getting users to migrate: ${e}`); -// } -// await delay(1000); -// } -// } diff --git a/api/stately/init/stately-backfill.ts b/api/stately/init/stately-backfill.ts deleted file mode 100644 index 410ce660..00000000 --- a/api/stately/init/stately-backfill.ts +++ /dev/null @@ -1,520 +0,0 @@ -import { keyPath, StatelyError } from '@stately-cloud/client'; -import fs from 'node:fs/promises'; -import { DatabaseError } from 'pg-protocol'; -import { closeDbPool, transaction } from '../../db/index.js'; -import { addLoadoutShareIgnoring } from '../../db/loadout-share-queries.js'; -import { backfillMigrationState, MigrationState } from '../../db/migration-state-queries.js'; -import { getSettings, replaceSettings } from '../../db/settings-queries.js'; -import { Loadout } from '../../shapes/loadouts.js'; -import { defaultSettings } from '../../shapes/settings.js'; -import { delay, subtractObject } from '../../utils.js'; -import { client } from '../client.js'; -import { Settings } from '../generated/stately_pb.js'; -import { convertLoadoutFromStately } from '../loadouts-queries.js'; -import { convertToDimSettings, keyFor as settingsKey } from '../settings-queries.js'; - -function keyForLoadoutShare(shareId: string) { - return keyPath`/loadoutShare-${shareId}`; -} - -async function replaceSettingsIfNotPresent( - pgClient: Parameters[0], - bungieMembershipId: number, - settings: Partial, -) { - const existing = await getSettings(pgClient, bungieMembershipId); - if (!existing || existing.deleted) { - await replaceSettings(pgClient, bungieMembershipId, settings); - } -} - -const configuredTokenPath = process.env.BACKFILL_TOKEN_PATH ?? 'backfill-token.bin'; -const profileBatchSize = parseNumberEnv('BACKFILL_PROFILE_BATCH_SIZE', 1000); -const settingsBatchSize = parseNumberEnv('BACKFILL_SETTINGS_BATCH_SIZE', 50); -const shareBatchSize = parseNumberEnv('BACKFILL_SHARE_BATCH_SIZE', 50); -const continuationDelayMs = parseNumberEnv('BACKFILL_CONTINUATION_DELAY_MS', 1000); -const retryMaxAttempts = parseNumberEnv('BACKFILL_RETRY_MAX_ATTEMPTS', 12); -const retryBaseDelayMs = parseNumberEnv('BACKFILL_RETRY_BASE_DELAY_MS', 1000); -const retryMaxDelayMs = parseNumberEnv('BACKFILL_RETRY_MAX_DELAY_MS', 30000); -const statelyThrottleMinDelayMs = parseNumberEnv('BACKFILL_STATELY_THROTTLE_DELAY_MS', 15000); -const retryThrottlingForever = - (process.env.BACKFILL_RETRY_THROTTLING_FOREVER ?? 'true') !== 'false'; -const configuredParallelSegments = parseNumberEnv('BACKFILL_PARALLEL_SEGMENTS', 1); -const configuredTotalSegments = process.env.BACKFILL_TOTAL_SEGMENTS - ? parseNumberEnv('BACKFILL_TOTAL_SEGMENTS', 1) - : undefined; -const configuredSegmentIndex = parseNonNegativeIntEnv('BACKFILL_SEGMENT_INDEX'); - -interface RetryableError { - code?: string; - statelyCode?: string; - status?: number; - statusCode?: number; - headers?: Record; - response?: { - status?: number; - headers?: Record; - }; - cause?: unknown; - message?: string; -} - -function isStatelyThroughputError(error: unknown): boolean { - if (error instanceof StatelyError) { - return ( - error.statelyCode === 'StoreThroughputExceeded' || - error.statelyCode === 'StoreRequestLimitExceeded' - ); - } - - if (!error || typeof error !== 'object') { - return false; - } - - const retryable = error as RetryableError; - if ( - retryable.statelyCode === 'StoreThroughputExceeded' || - retryable.statelyCode === 'StoreRequestLimitExceeded' - ) { - return true; - } - - if (retryable.cause) { - return isStatelyThroughputError(retryable.cause); - } - - return false; -} - -function parseNumberEnv(envName: string, defaultValue: number) { - const raw = process.env[envName]; - if (!raw) { - return defaultValue; - } - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error(`${envName} must be a positive number`); - } - return parsed; -} - -function parseNonNegativeIntEnv(envName: string): number | undefined { - const raw = process.env[envName]; - if (raw === undefined || raw === '') { - return undefined; - } - - const parsed = Number(raw); - if (!Number.isInteger(parsed) || parsed < 0) { - throw new Error(`${envName} must be a non-negative integer`); - } - - return parsed; -} - -function tokenPathForSegment( - basePath: string, - segmentIndex: number, - totalSegments: number, -): string { - if (totalSegments <= 1) { - return basePath; - } - - const marker = `.segment-${segmentIndex + 1}-of-${totalSegments}`; - const dotIndex = basePath.lastIndexOf('.'); - if (dotIndex <= 0) { - return `${basePath}${marker}`; - } - - return `${basePath.slice(0, dotIndex)}${marker}${basePath.slice(dotIndex)}`; -} - -function getNestedStatus(error: RetryableError): number | undefined { - return error.status ?? error.statusCode ?? error.response?.status; -} - -function getHeaders( - error: RetryableError, -): Record | undefined { - return error.headers ?? error.response?.headers; -} - -function getRetryAfterMs(error: RetryableError): number | undefined { - const headers = getHeaders(error); - const retryAfter = headers?.['retry-after'] ?? headers?.['Retry-After']; - if (retryAfter === undefined) { - return undefined; - } - - const retryAfterSeconds = Number(retryAfter); - if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) { - return retryAfterSeconds * 1000; - } - - if (typeof retryAfter === 'string') { - const retryAt = Date.parse(retryAfter); - if (!Number.isNaN(retryAt)) { - return Math.max(0, retryAt - Date.now()); - } - } - - return undefined; -} - -function isRetryableError(error: unknown): boolean { - if (error instanceof StatelyError) { - return [ - 'StoreThroughputExceeded', - 'StoreRequestLimitExceeded', - 'StoreInUse', - 'ConcurrentModification', - 'CachedSchemaTooOld', - 'BackupsUnavailable', - ].includes(error.statelyCode); - } - - if (error instanceof DatabaseError) { - return error.code ? ['40001', '40P01', '53300', '57P03'].includes(error.code) : false; - } - - if (!error || typeof error !== 'object') { - return false; - } - - const retryable = error as RetryableError; - const status = getNestedStatus(retryable); - - if (status === 429 || (status !== undefined && status >= 500)) { - return true; - } - - if ( - retryable.code && - ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ENOTFOUND'].includes(retryable.code) - ) { - return true; - } - - if (typeof retryable.message === 'string') { - const msg = retryable.message.toLowerCase(); - return ( - msg.includes('throttl') || - msg.includes('too many requests') || - msg.includes('rate limit') || - msg.includes('temporar') || - msg.includes('timeout') - ); - } - - if (retryable.cause) { - return isRetryableError(retryable.cause); - } - - return false; -} - -function retryDelayMs(error: unknown, attempt: number) { - const forcedDelay = - error && typeof error === 'object' ? getRetryAfterMs(error as RetryableError) : undefined; - if (forcedDelay !== undefined) { - return Math.min(retryMaxDelayMs, Math.max(retryBaseDelayMs, forcedDelay)); - } - - const exponential = retryBaseDelayMs * 2 ** (attempt - 1); - const jitter = Math.floor(Math.random() * retryBaseDelayMs); - const retryDelay = exponential + jitter; - - if (isStatelyThroughputError(error)) { - // Give Stately autoscaling enough time to react to hot partitions and traffic bursts. - return Math.min(retryMaxDelayMs, Math.max(statelyThrottleMinDelayMs, retryDelay)); - } - - return Math.min(retryMaxDelayMs, retryDelay); -} - -async function withRetry(name: string, fn: () => T | Promise): Promise { - let attempt = 0; - while (true) { - attempt += 1; - try { - return await fn(); - } catch (error) { - const retryable = isRetryableError(error); - const throttleRetry = retryThrottlingForever && isStatelyThroughputError(error); - if (!retryable || (!throttleRetry && attempt >= retryMaxAttempts)) { - throw error; - } - - const waitMs = retryDelayMs(error, attempt); - const message = error instanceof Error ? error.message : String(error); - const attemptLabel = throttleRetry - ? `${attempt}/unbounded` - : `${attempt}/${retryMaxAttempts}`; - console.log( - `${name} failed with retryable error (attempt ${attemptLabel}). Waiting ${waitMs}ms before retry.`, - message, - ); - await delay(waitMs); - } - } -} - -type ScanList = ReturnType; -const scanItemTypes: NonNullable[0]>['itemTypes'] = [ - 'LoadoutShare', - 'ItemAnnotation', - 'ItemHashTag', - 'Loadout', - 'Search', - 'Triumph', - 'Settings', -]; - -async function runSegment(segmentIndex: number, totalSegments: number, workerCount: number) { - const tokenPath = tokenPathForSegment(configuredTokenPath, segmentIndex, totalSegments); - const logPrefix = `[segment ${segmentIndex + 1}/${totalSegments}]`; - const tokenData = await fs.readFile(tokenPath).catch(() => null); - - console.log(logPrefix, 'Starting scan using token file', tokenPath); - - let list: ScanList = tokenData - ? await withRetry(`${logPrefix} Continue scan from token file`, () => - client.continueScan(tokenData), - ) - : await withRetry(`${logPrefix} Begin scan`, () => - client.beginScan({ - itemTypes: scanItemTypes, - totalSegments, - segmentIndex, - }), - ); - - const profileIds = new Set(); - const settingsQueue: Settings[] = []; - const shareQueue: { - loadout: Loadout; - viewCount: number; - platformMembershipId: string; - shareId: string; - }[] = []; - - async function flushProfileIds(force = false) { - if (!force && profileIds.size < profileBatchSize) { - return; - } - if (profileIds.size === 0) { - return; - } - - console.log(logPrefix, 'Backfilling migration states for', profileIds.size, 'profiles...'); - const items = [...profileIds]; - await withRetry(`${logPrefix} Backfill migration states`, async () => { - await transaction(async (pgClient) => { - for (const profileId of items) { - await backfillMigrationState( - pgClient, - profileId.toString(), - undefined, - MigrationState.Stately, - ); - } - }); - }); - profileIds.clear(); - console.log(logPrefix, 'Done'); - } - - async function flushSettingsQueue(force = false) { - if (!force && settingsQueue.length < settingsBatchSize) { - return; - } - if (settingsQueue.length === 0) { - return; - } - - console.log(logPrefix, 'Backfilling settings for', settingsQueue.length, 'users...'); - const batch = [...settingsQueue]; - await withRetry(`${logPrefix} Backfill settings`, async () => { - await transaction(async (pgClient) => { - for (const settings of batch) { - await replaceSettingsIfNotPresent( - pgClient, - Number(settings.memberId), - subtractObject(convertToDimSettings(settings), defaultSettings), - ); - } - }); - }); - - await withRetry(`${logPrefix} Delete migrated settings from Stately`, async () => { - await client.del(...batch.map((s) => settingsKey(Number(s.memberId)))); - }); - - settingsQueue.splice(0, batch.length); - console.log(logPrefix, 'Done'); - } - - async function flushShareQueue(force = false) { - if (!force && shareQueue.length < shareBatchSize) { - return; - } - if (shareQueue.length === 0) { - return; - } - - console.log(logPrefix, 'Backfilling', shareQueue.length, 'loadout shares...'); - const batch = [...shareQueue]; - - await withRetry(`${logPrefix} Backfill loadout shares`, async () => { - await transaction(async (pgClient) => { - for (const share of batch) { - const inserted = await addLoadoutShareIgnoring( - pgClient, - undefined, - share.platformMembershipId, - share.shareId, - share.loadout, - share.viewCount, - ); - if (!inserted) { - console.log(logPrefix, 'Loadout share collision ignoring', share.shareId); - } - } - }); - }); - - await withRetry(`${logPrefix} Delete migrated loadout shares from Stately`, async () => { - await client.del(...batch.map((s) => keyForLoadoutShare(s.shareId))); - }); - - shareQueue.splice(0, batch.length); - console.log(logPrefix, 'Done'); - } - - let scanComplete = false; - - while (true) { - try { - for await (const item of list) { - if (client.isType(item, 'LoadoutShare')) { - shareQueue.push({ - loadout: convertLoadoutFromStately(item), - viewCount: item.viewCount, - platformMembershipId: item.profileId.toString(), - shareId: item.id, - }); - } else if (client.isType(item, 'Settings')) { - settingsQueue.push(item); - } else if ('profileId' in item) { - profileIds.add(item.profileId); - } - - await flushProfileIds(); - await flushSettingsQueue(); - await flushShareQueue(); - } - } catch (error) { - if (!isRetryableError(error)) { - throw error; - } - - const waitMs = retryDelayMs(error, 1); - const message = error instanceof Error ? error.message : String(error); - const token = list.token; - if (token) { - await fs.writeFile(tokenPath, token.tokenData); - console.log( - `${logPrefix} Scan iteration failed with retryable error. Waiting ${waitMs}ms before continuing scan.`, - message, - ); - await delay(waitMs); - - list = await withRetry(`${logPrefix} Continue scan after retryable scan failure`, () => - client.continueScan(token), - ); - continue; - } - - const persistedTokenData = await fs.readFile(tokenPath).catch(() => null); - if (persistedTokenData) { - console.log( - `${logPrefix} Retryable scan failure without in-memory token. Waiting ${waitMs}ms before resuming from persisted token.`, - message, - ); - await delay(waitMs); - list = await withRetry( - `${logPrefix} Continue scan from persisted token after retryable scan failure`, - () => client.continueScan(persistedTokenData), - ); - continue; - } - - console.log( - `${logPrefix} Retryable scan failure without token. Waiting ${waitMs}ms before restarting segment scan.`, - message, - ); - await delay(waitMs); - list = await withRetry(`${logPrefix} Restart segment scan after retryable scan failure`, () => - client.beginScan({ - itemTypes: scanItemTypes, - totalSegments, - segmentIndex, - }), - ); - continue; - } - - const token = list.token; - if (!token) { - console.log(logPrefix, 'Scan token missing, ending scan loop.'); - break; - } - - await fs.writeFile(tokenPath, token.tokenData); - if (!token.canContinue) { - console.log(logPrefix, 'Scan complete.'); - scanComplete = true; - break; - } - - await delay(continuationDelayMs); - console.log(logPrefix, 'Continuing scan...'); - list = await withRetry(`${logPrefix} Continue scan`, () => client.continueScan(token)); - } - - await flushProfileIds(true); - await flushSettingsQueue(true); - await flushShareQueue(true); - - if (scanComplete) { - await fs.unlink(tokenPath).catch(() => undefined); - } - - if (workerCount > 1) { - console.log(logPrefix, 'Worker complete.'); - } -} - -try { - const totalSegments = configuredTotalSegments ?? configuredParallelSegments; - - if (configuredSegmentIndex !== undefined) { - if (configuredTotalSegments === undefined) { - throw new Error('BACKFILL_TOTAL_SEGMENTS must be set when BACKFILL_SEGMENT_INDEX is set'); - } - if (configuredSegmentIndex >= configuredTotalSegments) { - throw new Error('BACKFILL_SEGMENT_INDEX must be less than BACKFILL_TOTAL_SEGMENTS'); - } - await runSegment(configuredSegmentIndex, configuredTotalSegments, 1); - } else if (totalSegments <= 1) { - await runSegment(0, 1, 1); - } else { - const workers = Array.from({ length: totalSegments }, (_, segmentIndex) => - runSegment(segmentIndex, totalSegments, totalSegments), - ); - await Promise.all(workers); - } -} finally { - await closeDbPool(); -} diff --git a/api/stately/item-annotations-queries.test.ts b/api/stately/item-annotations-queries.test.ts deleted file mode 100644 index 16f14898..00000000 --- a/api/stately/item-annotations-queries.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { client } from './client.js'; -import { - deleteAllItemAnnotations, - deleteItemAnnotation, - getItemAnnotationsForProfile, - updateItemAnnotation, -} from './item-annotations-queries.js'; - -const platformMembershipId = '213512057'; - -beforeEach(async () => deleteAllItemAnnotations(platformMembershipId)); - -it('can insert tags where none exist before', async () => { - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: 'favorite', - notes: 'the best', - }, - ]); - }); - - const annotations = (await getItemAnnotationsForProfile(platformMembershipId, 2)).tags; - expect(annotations[0]).toEqual({ - id: '123456', - tag: 'favorite', - notes: 'the best', - }); -}); - -it('can update tags where none exist before', async () => { - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: 'favorite', - notes: 'the best', - }, - { - id: '123456', - tag: 'junk', - notes: 'the worst', - }, - ]); - }); - - const annotations = (await getItemAnnotationsForProfile(platformMembershipId, 2)).tags; - expect(annotations[0]).toEqual({ - id: '123456', - tag: 'junk', - notes: 'the worst', - }); -}); - -it('can update tags clearing value', async () => { - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: 'favorite', - notes: 'the best', - }, - ]); - }); - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: null, - }, - ]); - }); - - const annotations = (await getItemAnnotationsForProfile(platformMembershipId, 2)).tags; - expect(annotations[0]).toEqual({ - id: '123456', - notes: 'the best', - }); -}); - -it('can create tags while passing null notes', async () => { - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: 'favorite', - notes: null, - }, - ]); - }); - - const annotations = (await getItemAnnotationsForProfile(platformMembershipId, 2)).tags; - expect(annotations[0]).toEqual({ - id: '123456', - tag: 'favorite', - }); -}); - -it('can delete tags', async () => { - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: 'favorite', - notes: 'the best', - }, - ]); - }); - await client.transaction(async (txn) => { - await deleteItemAnnotation(txn, platformMembershipId, 2, ['123456']); - }); - - const annotations = (await getItemAnnotationsForProfile(platformMembershipId, 2)).tags; - expect(annotations).toEqual([]); -}); - -it('can delete tags by setting both values to null/empty', async () => { - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: 'favorite', - notes: 'the best', - }, - ]); - }); - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: null, - notes: '', - }, - ]); - }); - - const annotations = (await getItemAnnotationsForProfile(platformMembershipId, 2)).tags; - expect(annotations).toEqual([]); -}); - -it('can clear tags', async () => { - await client.transaction(async (txn) => { - await updateItemAnnotation(txn, platformMembershipId, 2, [ - { - id: '123456', - tag: 'favorite', - notes: 'the best', - }, - ]); - }); - await client.transaction(async (txn) => { - await deleteItemAnnotation(txn, platformMembershipId, 2, ['123456', '654321']); - }); - - const annotations = (await getItemAnnotationsForProfile(platformMembershipId, 2)).tags; - expect(annotations).toEqual([]); -}); diff --git a/api/stately/item-annotations-queries.ts b/api/stately/item-annotations-queries.ts deleted file mode 100644 index ae801999..00000000 --- a/api/stately/item-annotations-queries.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { keyPath, ListToken } from '@stately-cloud/client'; -import { DestinyVersion } from '../shapes/general.js'; -import { ItemAnnotation, TagValue } from '../shapes/item-annotations.js'; -import { getProfile } from './bulk-queries.js'; -import { client } from './client.js'; -import { - ItemAnnotation as StatelyItemAnnotation, - TagValue as StatelyTagValue, -} from './generated/index.js'; -import { batches, clearValue, enumToStringUnion, Transaction } from './stately-utils.js'; - -export function keyFor( - platformMembershipId: string | bigint, - destinyVersion: DestinyVersion, - inventoryItemId: string | bigint, -) { - return keyPath`/p-${BigInt(platformMembershipId)}/d-${destinyVersion}/ia-${BigInt(inventoryItemId)}`; -} - -/** - * Get all of the item annotations for a particular platform_membership_id and destiny_version. - */ -export async function getItemAnnotationsForProfile( - platformMembershipId: string, - destinyVersion: DestinyVersion, -): Promise<{ tags: ItemAnnotation[]; token: ListToken; deletedTagsIds?: string[] }> { - const { profile, token } = await getProfile(platformMembershipId, destinyVersion, '/ia'); - return { tags: profile.tags ?? [], token }; -} - -/** - * Get ALL of the item annotations for a particular platformMembershipId, across - * all Destiny versions. This is a bit different from the PG version which gets - * everything under a bungieMembershipId. - */ -async function getAllItemAnnotationsForUser(platformMembershipId: string): Promise< - { - platformMembershipId: string; - destinyVersion: DestinyVersion; - annotation: ItemAnnotation; - }[] -> { - // Rather than list ALL items under the profile and filter down to item - // annotations, just separately get the D1 and D2 tags. We probably won't use - // this - for export we *will* scrape a whole profile. - const d1Annotations = getItemAnnotationsForProfile(platformMembershipId, 1); - const d2Annotations = getItemAnnotationsForProfile(platformMembershipId, 2); - return (await d1Annotations).tags - .map((a) => ({ platformMembershipId, destinyVersion: 1 as DestinyVersion, annotation: a })) - .concat( - (await d2Annotations).tags.map((a) => ({ - platformMembershipId, - destinyVersion: 2 as DestinyVersion, - annotation: a, - })), - ); -} - -export function convertItemAnnotation(item: StatelyItemAnnotation): ItemAnnotation { - const result: ItemAnnotation = { - id: item.id.toString(), - }; - if (item.tag) { - result.tag = enumToStringUnion(StatelyTagValue, item.tag) as TagValue; - } - if (item.notes) { - result.notes = item.notes; - } - if (item.craftedDate) { - result.craftedDate = Number(item.craftedDate) / 1000; - } - // TODO: I took Variant back out... - return result; -} - -/** - * Insert or update (upsert) item annotations. - */ -export async function updateItemAnnotation( - txn: Transaction, - platformMembershipId: string, - destinyVersion: DestinyVersion, - itemAnnotations: ItemAnnotation[], -): Promise { - // We want to merge the incoming values with the existing values, so we need - // to read the existing values first. - const existingTags = ( - await txn.getBatch( - ...itemAnnotations.map((v) => keyFor(platformMembershipId, destinyVersion, v.id)), - ) - ).filter((i) => client.isType(i, 'ItemAnnotation')); - - const itemsToPut = []; - const itemsToDelete: string[] = []; - for (const itemAnnotation of itemAnnotations) { - const tagValue = clearValue(itemAnnotation.tag); - const notesValue = clearValue(itemAnnotation.notes); - - const idBigInt = BigInt(itemAnnotation.id); - const ia = - existingTags.find((t) => t.id === idBigInt) ?? - client.create('ItemAnnotation', { - id: idBigInt, - profileId: BigInt(platformMembershipId), - destinyVersion, - }); - - if (tagValue === 'clear') { - ia.tag = StatelyTagValue.TagValue_UNSPECIFIED; - } else if (tagValue !== null) { - ia.tag = StatelyTagValue[`TagValue_${tagValue}`]; - } - - if (notesValue === 'clear') { - ia.notes = ''; - } else if (notesValue !== null) { - ia.notes = notesValue; - } - - if (itemAnnotation.craftedDate) { - ia.craftedDate = BigInt(itemAnnotation.craftedDate * 1000); - } - - // If we're updating them both to nothing, delete the annotation - if (!ia.notes && !ia.tag) { - itemsToDelete.push(keyFor(platformMembershipId, destinyVersion, itemAnnotation.id)); - } else { - itemsToPut.push(ia); - } - } - - if (itemsToDelete.length) { - await txn.del(...itemsToDelete); - } - - if (itemsToPut.length) { - await txn.putBatch(...itemsToPut); - } -} - -export function importTags( - itemAnnotations: (ItemAnnotation & { - platformMembershipId: string; - destinyVersion: DestinyVersion; - })[], -) { - return itemAnnotations.map((v) => - client.create('ItemAnnotation', { - id: BigInt(v.id), - profileId: BigInt(v.platformMembershipId), - destinyVersion: v.destinyVersion, - tag: v.tag ? StatelyTagValue[`TagValue_${v.tag}`] : StatelyTagValue.TagValue_UNSPECIFIED, - notes: v.notes || '', - craftedDate: v.craftedDate ? BigInt(v.craftedDate * 1000) : undefined, - }), - ); -} - -/** - * Delete item annotations. - */ -export async function deleteItemAnnotation( - txn: Transaction, - platformMembershipId: string, - destinyVersion: DestinyVersion, - inventoryItemIds: string[], -): Promise { - await txn.del(...inventoryItemIds.map((id) => keyFor(platformMembershipId, destinyVersion, id))); -} - -/** - * Delete all item annotations for a user (on all platforms). - */ -export async function deleteAllItemAnnotations(platformMembershipId: string): Promise { - // TODO: this is inefficient, for delete-my-data we'll nuke all the items in the group at once - const allAnnotations = await getAllItemAnnotationsForUser(platformMembershipId); - if (!allAnnotations.length) { - return; - } - for (const batch of batches(allAnnotations)) { - await client.del( - ...batch.map((a) => keyFor(a.platformMembershipId, a.destinyVersion, a.annotation.id)), - ); - } -} diff --git a/api/stately/item-hash-tags-queries.test.ts b/api/stately/item-hash-tags-queries.test.ts deleted file mode 100644 index 47c23cf8..00000000 --- a/api/stately/item-hash-tags-queries.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { client } from './client.js'; -import { - deleteAllItemHashTags, - deleteItemHashTag, - getItemHashTagsForProfile, - updateItemHashTag, -} from './item-hash-tags-queries.js'; - -const platformMembershipId = '213512057'; - -beforeEach(async () => deleteAllItemHashTags(platformMembershipId)); - -it('can insert item hash tags where none exist before', async () => { - await client.transaction(async (txn) => { - await updateItemHashTag(txn, platformMembershipId, { - hash: 2926662838, - tag: 'favorite', - notes: 'the best', - }); - }); - - const annotations = (await getItemHashTagsForProfile(platformMembershipId)).hashTags; - expect(annotations[0]).toEqual({ - hash: 2926662838, - tag: 'favorite', - notes: 'the best', - }); -}); - -it('can update item hash tags where none exist before', async () => { - await client.transaction(async (txn) => { - await updateItemHashTag(txn, platformMembershipId, { - hash: 2926662838, - tag: 'favorite', - notes: 'the best', - }); - - await updateItemHashTag(txn, platformMembershipId, { - hash: 2926662838, - tag: 'junk', - notes: 'the worst', - }); - }); - - const annotations = (await getItemHashTagsForProfile(platformMembershipId)).hashTags; - expect(annotations[0]).toEqual({ - hash: 2926662838, - tag: 'junk', - notes: 'the worst', - }); -}); - -it('can update item hash tags clearing value', async () => { - await client.transaction(async (txn) => { - await updateItemHashTag(txn, platformMembershipId, { - hash: 2926662838, - tag: 'favorite', - notes: 'the best', - }); - }); - await client.transaction(async (txn) => { - await updateItemHashTag(txn, platformMembershipId, { - hash: 2926662838, - tag: null, - }); - }); - const annotations = (await getItemHashTagsForProfile(platformMembershipId)).hashTags; - expect(annotations[0]).toEqual({ - hash: 2926662838, - notes: 'the best', - }); -}); - -it('can delete item hash tags', async () => { - await client.transaction(async (txn) => { - await updateItemHashTag(txn, platformMembershipId, { - hash: 2926662838, - tag: 'favorite', - notes: 'the best', - }); - }); - - await client.transaction(async (txn) => { - await deleteItemHashTag(txn, platformMembershipId, 2926662838); - }); - - const annotations = (await getItemHashTagsForProfile(platformMembershipId)).hashTags; - expect(annotations).toEqual([]); -}); - -it('can delete item hash tags by setting both values to null/empty', async () => { - await client.transaction(async (txn) => { - await updateItemHashTag(txn, platformMembershipId, { - hash: 2926662838, - tag: 'favorite', - notes: 'the best', - }); - }); - - await client.transaction(async (txn) => { - await updateItemHashTag(txn, platformMembershipId, { - hash: 2926662838, - tag: null, - notes: '', - }); - }); - - const annotations = (await getItemHashTagsForProfile(platformMembershipId)).hashTags; - expect(annotations).toEqual([]); -}); diff --git a/api/stately/item-hash-tags-queries.ts b/api/stately/item-hash-tags-queries.ts deleted file mode 100644 index 9317d326..00000000 --- a/api/stately/item-hash-tags-queries.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { keyPath, ListToken } from '@stately-cloud/client'; -import { ItemHashTag, TagValue } from '../shapes/item-annotations.js'; -import { getProfile } from './bulk-queries.js'; -import { client } from './client.js'; -import { - ItemHashTag as StatelyItemHashTag, - TagValue as StatelyTagValue, -} from './generated/index.js'; -import { batches, clearValue, enumToStringUnion, Transaction } from './stately-utils.js'; - -export function keyFor(platformMembershipId: string | bigint, itemHash: number) { - // HashTags are D2-only - return keyPath`/p-${BigInt(platformMembershipId)}/d-2/iht-${itemHash}`; -} - -/** - * Get all of the hash tags for a particular platform_membership_id and destiny_version. - */ -export async function getItemHashTagsForProfile( - platformMembershipId: string, -): Promise<{ hashTags: ItemHashTag[]; token: ListToken }> { - const { profile, token } = await getProfile(platformMembershipId, 2, '/iht'); - return { hashTags: profile.itemHashTags ?? [], token }; -} - -export function convertItemHashTag(item: StatelyItemHashTag): ItemHashTag { - const result: ItemHashTag = { - hash: Number(item.hash), - }; - if (item.tag) { - result.tag = enumToStringUnion(StatelyTagValue, item.tag) as TagValue; - } - if (item.notes) { - result.notes = item.notes; - } - return result; -} - -/** - * Insert or update (upsert) a single item annotation. - */ -// TODO: This one should also be batched -export async function updateItemHashTag( - txn: Transaction, - platformMembershipId: string, - itemHashTag: ItemHashTag, -): Promise { - const tagValue = clearValue(itemHashTag.tag); - const notesValue = clearValue(itemHashTag.notes); - - if (tagValue === 'clear' && notesValue === 'clear') { - // Delete the annotation entirely - return txn.del(keyFor(platformMembershipId, itemHashTag.hash)); - } - - // We want to merge the incoming values with the existing values, so we need - // to read the existing values first in a transaction. - let existing = await txn.get('ItemHashTag', keyFor(platformMembershipId, itemHashTag.hash)); - if (!existing) { - existing = client.create('ItemHashTag', { - hash: itemHashTag.hash, - profileId: BigInt(platformMembershipId), - destinyVersion: 2, - }); - } - - if (tagValue === 'clear') { - existing.tag = StatelyTagValue.TagValue_UNSPECIFIED; - } else if (tagValue !== null) { - existing.tag = StatelyTagValue[`TagValue_${tagValue}`]; - } - - if (notesValue === 'clear') { - existing.notes = ''; - } else if (notesValue !== null) { - existing.notes = notesValue; - } - - await txn.put(existing); -} - -export function importHashTags(platformMembershipId: string, itemHashTags: ItemHashTag[]) { - return itemHashTags.map((v) => - client.create('ItemHashTag', { - hash: v.hash, - profileId: BigInt(platformMembershipId), - destinyVersion: 2, - tag: v.tag ? StatelyTagValue[`TagValue_${v.tag}`] : StatelyTagValue.TagValue_UNSPECIFIED, - notes: v.notes || '', - }), - ); -} - -/** - * Delete an item hash tags. - */ -export async function deleteItemHashTag( - txn: Transaction, - platformMembershipId: string, - ...inventoryItemHashes: number[] -): Promise { - return txn.del(...inventoryItemHashes.map((hash) => keyFor(platformMembershipId, hash))); -} - -/** - * Delete all item hash tags for a user. - */ -export async function deleteAllItemHashTags(platformMembershipId: string): Promise { - // TODO: this is inefficient, for delete-my-data we'll nuke all the items in the group at once - const allHashTags = (await getItemHashTagsForProfile(platformMembershipId)).hashTags; - if (!allHashTags.length) { - return; - } - - for (const batch of batches(allHashTags)) { - await client.del(...batch.map((a) => keyFor(platformMembershipId, a.hash))); - } -} diff --git a/api/stately/loadout-share-queries.test.ts b/api/stately/loadout-share-queries.test.ts deleted file mode 100644 index 4efe2ce2..00000000 --- a/api/stately/loadout-share-queries.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { v4 as uuid } from 'uuid'; -import { Loadout } from '../shapes/loadouts.js'; -import { - addLoadoutShare, - deleteLoadoutShare, - getLoadoutShare, - recordAccess, -} from './loadout-share-queries.js'; - -const platformMembershipId = '213512057'; - -const shareID = 'ABCDEFG'; - -beforeEach(async () => deleteLoadoutShare(shareID)); - -const loadout: Loadout = { - id: uuid(), - name: 'Test Loadout', - classType: 1, - equipped: [ - { - hash: 100, - id: '1234', - socketOverrides: { 7: 9 }, - }, - ], - unequipped: [ - { - hash: 200, - id: '5678', - amount: 10, - }, - ], -}; - -it('can record a shared loadout', async () => { - await addLoadoutShare(platformMembershipId, shareID, loadout); - - const sharedLoadout = await getLoadoutShare(shareID); - - expect(sharedLoadout?.loadout.name).toBe(loadout.name); -}); - -it('rejects multiple shares with the same ID', async () => { - await addLoadoutShare(platformMembershipId, shareID, loadout); - - try { - await addLoadoutShare(platformMembershipId, shareID, loadout); - fail('Expected this to throw an error'); - } catch {} -}); - -it('can record visits', async () => { - await addLoadoutShare(platformMembershipId, shareID, loadout); - - await recordAccess(shareID); -}); diff --git a/api/stately/loadout-share-queries.ts b/api/stately/loadout-share-queries.ts deleted file mode 100644 index 5f20bf77..00000000 --- a/api/stately/loadout-share-queries.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { keyPath, StatelyError } from '@stately-cloud/client'; -import { Loadout } from '../shapes/loadouts.js'; -import { delay } from '../utils.js'; -import { client } from './client.js'; -import { LoadoutShare as StatelyLoadoutShare } from './generated/index.js'; -import { - convertLoadoutCommonFieldsToStately, - convertLoadoutFromStately, -} from './loadouts-queries.js'; - -export function keyFor(shareId: string) { - return keyPath`/loadoutShare-${shareId}`; -} - -/** - * Get a specific loadout share by its share ID. - */ -export async function getLoadoutShare( - shareId: string, -): Promise<{ loadout: Loadout; viewCount: number; platformMembershipId: string } | undefined> { - const result = await client.get('LoadoutShare', keyFor(shareId)); - return result - ? { - loadout: convertLoadoutFromStately(result), - viewCount: result.viewCount, - platformMembershipId: result.profileId.toString(), - } - : undefined; -} - -function convertLoadoutShareToStately( - loadout: Loadout, - platformMembershipId: string, - shareId: string, -): StatelyLoadoutShare { - return client.create('LoadoutShare', { - ...convertLoadoutCommonFieldsToStately(loadout, platformMembershipId, 2), - id: shareId, - }); -} - -export class LoadoutShareCollision extends Error { - static name = 'LoadoutShareCollision'; - constructor() { - super('Loadout share already exists'); - } -} - -/** - * Create a new loadout share. These are intended to be immutable. Loadout - * Shares are only supported for D2. - */ -export async function addLoadoutShare( - platformMembershipId: string, - shareId: string, - loadout: Loadout, -): Promise { - const loadoutShare = convertLoadoutShareToStately(loadout, platformMembershipId, shareId); - try { - await client.put(loadoutShare, { mustNotExist: true }); - } catch (e) { - if (e instanceof StatelyError && e.statelyCode === 'ConditionalCheckFailed') { - throw new LoadoutShareCollision(); - } - } -} - -/** - * Touch the last_accessed_at and visits fields to keep track of access. - */ -export async function recordAccess(shareId: string): Promise { - for (let attempts = 0; attempts < 3; attempts++) { - try { - // Hmm this is probably pretty expensive. Should I store the view count in a - // separate item? It'd also be nice to have an Update API. - await client.transaction(async (txn) => { - const loadoutShare = await txn.get('LoadoutShare', keyFor(shareId)); - if (!loadoutShare) { - throw new Error("somehow this loadout share doesn't exist"); - } - - loadoutShare.viewCount++; - - await txn.put(loadoutShare); - }); - return; - } catch (e) { - if (e instanceof StatelyError && e.statelyCode === 'ConcurrentModification') { - // try again after a delay - await delay(100 * Math.random() + 100); - } else { - throw e; - } - } - } -} - -export async function getLoadoutShareByShareId(shareId: string): Promise { - const result = await client.get('LoadoutShare', keyFor(shareId)); - return result ? convertLoadoutFromStately(result) : undefined; -} - -// This is here for tests -export async function deleteLoadoutShare(shareId: string): Promise { - return client.del(keyFor(shareId)); -} diff --git a/api/stately/loadouts-queries.test.ts b/api/stately/loadouts-queries.test.ts deleted file mode 100644 index d7cd524a..00000000 --- a/api/stately/loadouts-queries.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { toBinary } from '@bufbuild/protobuf'; -import { omit } from 'es-toolkit'; -import { v4 as uuid } from 'uuid'; -import { defaultLoadoutParameters, Loadout, LoadoutParameters } from '../shapes/loadouts.js'; -import { client } from './client.js'; -import { LoadoutParametersSchema, LoadoutSchema } from './generated/index.js'; -import { - convertLoadoutFromStately, - convertLoadoutParametersFromStately, - convertLoadoutParametersToStately, - convertLoadoutToStately, - deleteAllLoadouts, - deleteLoadout, - getLoadoutsForProfile, - updateLoadout, -} from './loadouts-queries.js'; - -const platformMembershipId = '213512057'; - -beforeEach(async () => deleteAllLoadouts(platformMembershipId)); - -export const loadout: Loadout = { - id: uuid(), - name: 'Test Loadout', - classType: 1, - equipped: [ - { - hash: 100, - id: '1234', - socketOverrides: { 7: 9 }, - }, - { - hash: 200, - id: '4567', - craftedDate: 1000, - }, - ], - unequipped: [ - { - hash: 200, - id: '5678', - amount: 10, - }, - ], -}; - -it('can roundtrip between DIM loadout and Stately loadout', () => { - const statelyLoadout = convertLoadoutToStately(loadout, platformMembershipId, 2); - expect(() => toBinary(LoadoutSchema, statelyLoadout)).not.toThrow(); - const loadout2 = convertLoadoutFromStately(statelyLoadout); - expect( - omit(loadout2, [ - 'profileId' as keyof Loadout, - 'destinyVersion' as keyof Loadout, - 'createdAt', - 'lastUpdatedAt', - ]), - ).toEqual(loadout); -}); - -it('can roundtrip loadout parameters', () => { - const loParams: LoadoutParameters = { - ...defaultLoadoutParameters, - exoticArmorHash: 3045642045, - autoStatMods: false, - clearArmor: true, - clearMods: true, - clearWeapons: true, - }; - - const statelyLoParams = client.create( - 'LoadoutParameters', - convertLoadoutParametersToStately(loParams), - ); - expect(() => toBinary(LoadoutParametersSchema, statelyLoParams)).not.toThrow(); - const loParams2 = convertLoadoutParametersFromStately(statelyLoParams); - expect(loParams2).toEqual(loParams); -}); - -it('can roundtrip loadout parameters w/ a negative armor hash', () => { - const loParams: LoadoutParameters = { - ...defaultLoadoutParameters, - exoticArmorHash: -1, - clearArmor: true, - clearMods: true, - clearWeapons: true, - }; - - const statelyLoParams = client.create( - 'LoadoutParameters', - convertLoadoutParametersToStately(loParams), - ); - expect(() => toBinary(LoadoutParametersSchema, statelyLoParams)).not.toThrow(); - const loParams2 = convertLoadoutParametersFromStately(statelyLoParams); - expect(loParams2).toEqual(loParams); -}); - -it('filters out set bonuses with zero count when converting to Stately', () => { - const loParams: LoadoutParameters = { - ...defaultLoadoutParameters, - setBonuses: { - 123456789: 2, // Valid set bonus with 2 pieces - 987654321: 0, // Should be filtered out - zero count - 555666777: 4, // Valid set bonus with 4 pieces - }, - }; - - const statelyLoParams = client.create( - 'LoadoutParameters', - convertLoadoutParametersToStately(loParams), - ); - - // The Stately version should only have the non-zero entries - expect(statelyLoParams.setBonuses).toHaveLength(2); - expect(statelyLoParams.setBonuses.find((b) => b.setBonusHash === 123456789)?.count).toBe(2); - expect(statelyLoParams.setBonuses.find((b) => b.setBonusHash === 555666777)?.count).toBe(4); - expect(statelyLoParams.setBonuses.find((b) => b.setBonusHash === 987654321)).toBeUndefined(); - - // When converting back, the zero entry should not be present - const loParams2 = convertLoadoutParametersFromStately(statelyLoParams); - expect(loParams2.setBonuses).toEqual({ - 123456789: 2, - 555666777: 4, - }); -}); - -it('can record a loadout', async () => { - await client.transaction(async (txn) => { - await updateLoadout(txn, platformMembershipId, 2, [loadout]); - }); - - const loadouts = (await getLoadoutsForProfile(platformMembershipId, 2)).loadouts; - - expect(loadouts.length).toBe(1); - - const firstLoadout = loadouts[0]; - expect(firstLoadout.createdAt).toBeDefined(); - delete firstLoadout.createdAt; - expect(firstLoadout.lastUpdatedAt).toBeDefined(); - delete firstLoadout.lastUpdatedAt; - expect(firstLoadout.unequipped.length).toBe(1); - expect(firstLoadout).toEqual(loadout); -}); - -it('can update a loadout', async () => { - await client.transaction(async (txn) => { - await updateLoadout(txn, platformMembershipId, 2, [loadout]); - }); - - await client.transaction(async (txn) => { - await updateLoadout(txn, platformMembershipId, 2, [ - { - ...loadout, - name: 'Updated', - unequipped: [], - }, - ]); - }); - - const loadouts = (await getLoadoutsForProfile(platformMembershipId, 2)).loadouts; - - expect(loadouts.length).toBe(1); - expect(loadouts[0].name).toEqual('Updated'); - expect(loadouts[0].unequipped.length).toBe(0); - expect(loadouts[0].equipped).toEqual(loadout.equipped); -}); - -it('can delete a loadout', async () => { - await client.transaction(async (txn) => { - await updateLoadout(txn, platformMembershipId, 2, [loadout]); - }); - let loadouts = (await getLoadoutsForProfile(platformMembershipId, 2)).loadouts; - expect(loadouts.length).toBe(1); - - await client.transaction(async (txn) => { - await deleteLoadout(txn, platformMembershipId, 2, [loadout.id]); - }); - loadouts = (await getLoadoutsForProfile(platformMembershipId, 2)).loadouts; - expect(loadouts.length).toBe(0); -}); diff --git a/api/stately/loadouts-queries.ts b/api/stately/loadouts-queries.ts deleted file mode 100644 index 0a1daab0..00000000 --- a/api/stately/loadouts-queries.ts +++ /dev/null @@ -1,414 +0,0 @@ -import { MessageInitShape } from '@bufbuild/protobuf'; -import { keyPath, ListToken } from '@stately-cloud/client'; -import { DestinyClass } from 'bungie-api-ts/destiny2'; -import { isEmpty } from 'es-toolkit/compat'; -import { DestinyVersion } from '../shapes/general.js'; -import { - AssumeArmorMasterwork, - Loadout, - LoadoutItem, - LoadoutParameters, - SetBonusCounts, - StatConstraint, -} from '../shapes/loadouts.js'; -import { isValidItemId } from '../utils.js'; -import { getProfile } from './bulk-queries.js'; -import { client } from './client.js'; -import { - LoadoutParametersSchema, - LoadoutSchema, - LoadoutShareSchema, - SetBonusCount, - Loadout as StatelyLoadout, - LoadoutItem as StatelyLoadoutItem, - LoadoutParameters as StatelyLoadoutParameters, - LoadoutShare as StatelyLoadoutShare, - StatConstraint as StatelyStatConstraint, -} from './generated/index.js'; -import { - batches, - listToMap, - parseUUID, - stringifyUUID, - stripDefaults, - stripTypeName, - Transaction, -} from './stately-utils.js'; - -export function keyFor( - platformMembershipId: string | bigint, - destinyVersion: DestinyVersion, - loadoutId: Uint8Array | string, -) { - if (typeof loadoutId === 'string') { - loadoutId = parseUUID(loadoutId); - } - return keyPath`/p-${BigInt(platformMembershipId)}/d-${destinyVersion}/loadout-${loadoutId}`; -} - -/** - * Get all of the loadouts for a particular platform_membership_id and destiny_version. - */ -export async function getLoadoutsForProfile( - platformMembershipId: string, - destinyVersion: DestinyVersion, -): Promise<{ loadouts: Loadout[]; token: ListToken }> { - const { profile, token } = await getProfile(platformMembershipId, destinyVersion, '/loadout'); - return { loadouts: profile.loadouts ?? [], token }; -} - -/** - * Get ALL of loadouts for a particular user across all platforms. - */ -async function getAllLoadoutsForUser(platformMembershipId: string): Promise< - { - platformMembershipId: string; - destinyVersion: DestinyVersion; - loadout: Loadout; - }[] -> { - // Rather than list ALL items under the profile and filter down to loadouts, - // just separately get the D1 and D2 tags. We probably won't use this - for - // export we *will* scrape a whole profile. - const d1Loadouts = getLoadoutsForProfile(platformMembershipId, 1); - const d2Loadouts = getLoadoutsForProfile(platformMembershipId, 2); - return (await d1Loadouts).loadouts - .map((a) => ({ platformMembershipId, destinyVersion: 1 as DestinyVersion, loadout: a })) - .concat( - (await d2Loadouts).loadouts.map((a) => ({ - platformMembershipId, - destinyVersion: 2 as DestinyVersion, - loadout: a, - })), - ); -} - -export function convertLoadoutFromStately(item: StatelyLoadout | StatelyLoadoutShare): Loadout { - const loadout: Loadout = { - // TODO: it's a bit weird to use the share ID for loadout shares. We should probably just mint a new UUID. - id: typeof item.id === 'string' ? item.id : stringifyUUID(item.id), - name: item.name, - classType: item.classType as number as DestinyClass, - equipped: (item.equipped || []).map(convertLoadoutItemFromStately), - unequipped: (item.unequipped || []).map(convertLoadoutItemFromStately), - createdAt: Number(item.createdAt), - lastUpdatedAt: Number(item.lastUpdatedAt), - }; - if (item.notes) { - loadout.notes = item.notes; - } - if (item.parameters) { - loadout.parameters = convertLoadoutParametersFromStately(item.parameters); - } - return loadout; -} - -export function convertLoadoutParametersFromStately( - loParameters: StatelyLoadoutParameters, -): LoadoutParameters { - const { - assumeArmorMasterwork, - statConstraints, - modsByBucket, - artifactUnlocks, - inGameIdentifiers, - setBonuses, - ...loParametersDefaulted - } = stripTypeName(loParameters); - return { - ...stripDefaults(loParametersDefaulted), - exoticArmorHash: exoticArmorHashFromStately(loParameters.exoticArmorHash), - // DIM's AssumArmorMasterwork enum starts at 1 - assumeArmorMasterwork: (assumeArmorMasterwork ?? 0) + 1, - statConstraints: statConstraintsFromStately(statConstraints), - modsByBucket: isEmpty(modsByBucket) - ? undefined - : listToMap('bucketHash', 'modHashes', modsByBucket), - artifactUnlocks: artifactUnlocks ? stripTypeName(artifactUnlocks) : undefined, - inGameIdentifiers: inGameIdentifiers ? stripTypeName(inGameIdentifiers) : undefined, - setBonuses: setBonusesFromStately(setBonuses), - }; -} - -function setBonusesFromStately( - setBonuses: SetBonusCount[] | undefined, -): SetBonusCounts | undefined { - if (!setBonuses || setBonuses.length === 0) { - return undefined; - } - return setBonuses.reduce((m: SetBonusCounts, b) => { - m[b.setBonusHash] = b.count; - return m; - }, {}); -} - -function setBonusCountsToStately(setBonuses: SetBonusCounts | undefined): SetBonusCount[] { - if (!setBonuses || isEmpty(setBonuses)) { - return []; - } - return Object.entries(setBonuses) - .filter(([, count]) => count !== undefined && count > 0) - .map(([setBonusHash, count]) => - client.create('SetBonusCount', { - setBonusHash: Number(setBonusHash), - count: Number(count), - }), - ); -} - -function exoticArmorHashFromStately(hash: bigint) { - if (hash === 0n) { - return undefined; - } - // Some hashes got sign-flipped when I was changing data types from uint32 to - // int32 to int64 - the signed versions interpreted larger numbers (> 2^31) as - // signed, and everything got messed up. -1 and -2 are still valid special - // cases. - if (hash < -2) { - hash = 4294967296n + hash; // The constant is 32 set bits, plus one - } - return Number(hash); -} - -export function statConstraintsFromStately(statConstraints: StatelyStatConstraint[]) { - if (statConstraints.length === 0) { - return undefined; - } - const constraints = statConstraints.map((c) => { - const constraint: StatConstraint = { - statHash: c.statHash, - }; - if (c.minTier !== 0) { - constraint.minTier = c.minTier; - } - // This is the tricky one - an undefined value means max tier 10 - if (c.maxTier !== 10) { - constraint.maxTier = c.maxTier; - } - if (c.minStat !== 0) { - constraint.minStat = c.minStat; - } - // This is the tricky one - an undefined value means max stat 200 - if (c.maxStat !== 200 && c.maxStat !== 0) { - constraint.maxStat = c.maxStat; - } - return constraint; - }); - - // I screwed up the max constraints for some stored items, so we'll fix them here - if (constraints.every((c) => c.maxTier === 0)) { - for (const c of constraints) { - delete c.maxTier; - } - } - return constraints; -} - -function convertLoadoutItemFromStately(item: StatelyLoadoutItem): LoadoutItem { - const result: LoadoutItem = { - hash: item.hash, - }; - if (item.amount) { - result.amount = item.amount; - } - if (item.id) { - result.id = item.id.toString(); - } - if (!isEmpty(item.socketOverrides)) { - result.socketOverrides = listToMap('socketIndex', 'itemHash', item.socketOverrides); - } - if (item.craftedDate) { - result.craftedDate = Number(item.craftedDate); - } - return result; -} - -export function convertLoadoutToStately( - loadout: Loadout, - platformMembershipId: string, - destinyVersion: DestinyVersion, -): StatelyLoadout { - return client.create('Loadout', { - ...convertLoadoutCommonFieldsToStately(loadout, platformMembershipId, destinyVersion), - id: parseUUID(loadout.id), - }); -} - -export function convertLoadoutCommonFieldsToStately( - loadout: Loadout, - platformMembershipId: string, - destinyVersion: DestinyVersion, -): Omit< - MessageInitShape | MessageInitShape, - 'id' | '$typeName' -> { - const out = { - destinyVersion, - profileId: BigInt(platformMembershipId), - name: loadout.name || 'Unnamed', - classType: loadout.classType as number, - equipped: (loadout.equipped || []).map(convertLoadoutItemToStately), - unequipped: (loadout.unequipped || []).map(convertLoadoutItemToStately), - notes: loadout.notes, - parameters: convertLoadoutParametersToStately(loadout.parameters), - createdAt: BigInt(loadout.createdAt ? new Date(loadout.createdAt).getTime() : 0n), - lastUpdatedAt: BigInt(loadout.lastUpdatedAt ? new Date(loadout.lastUpdatedAt).getTime() : 0n), - }; - if (out.lastUpdatedAt < out.createdAt) { - out.lastUpdatedAt = out.createdAt; - } - return out; -} - -function convertLoadoutItemToStately(item: LoadoutItem): StatelyLoadoutItem { - item = cleanItem(item); - - return client.create('LoadoutItem', { - hash: item.hash, - amount: item.amount, - id: item.id ? BigInt(item.id) : undefined, - socketOverrides: item.socketOverrides - ? Object.entries(item.socketOverrides).map(([socketIndex, itemHash]) => ({ - socketIndex: Number(socketIndex), - itemHash: Number(itemHash), - })) - : undefined, - craftedDate: item.craftedDate ? BigInt(item.craftedDate) : undefined, - }); -} - -export function convertLoadoutParametersToStately( - loParameters: LoadoutParameters | undefined, -): MessageInitShape | undefined { - let loParametersFixed: MessageInitShape | undefined; - if (!isEmpty(loParameters)) { - const { - assumeArmorMasterwork, - exoticArmorHash, - statConstraints, - modsByBucket, - setBonuses, - ...loParametersDefaulted - } = loParameters; - loParametersFixed = { - ...loParametersDefaulted, - exoticArmorHash: BigInt(exoticArmorHash ?? 0), - statConstraints: statConstraintsToStately(statConstraints), - // DIM's AssumArmorMasterwork enum starts at 1 - assumeArmorMasterwork: Number(assumeArmorMasterwork ?? AssumeArmorMasterwork.None) - 1, - modsByBucket: modsByBucket - ? Object.entries(modsByBucket).map(([bucketHash, modHashes]) => ({ - bucketHash: Number(bucketHash), - modHashes: modHashes.filter((h) => Number.isInteger(h)), - })) - : undefined, - setBonuses: setBonusCountsToStately(setBonuses), - }; - } - return loParametersFixed; -} - -export function statConstraintsToStately(statConstraints: StatConstraint[] | undefined) { - return statConstraints && statConstraints.length > 0 - ? statConstraints.map((c) => ({ - statHash: c.statHash, - minTier: Math.max(0, Math.floor(c.minTier ?? 0)), - maxTier: Math.min(Math.ceil(c.maxTier ?? 10), 10), - minStat: Math.max(0, Math.min(Math.floor(c.minStat ?? 0), 200)), - maxStat: Math.max(0, Math.min(Math.ceil(c.maxStat ?? 200), 200)), - })) - : []; -} - -/** - * Insert or update (upsert) loadouts. Loadouts are totally replaced when updated. - */ -export async function updateLoadout( - txn: Transaction, - platformMembershipId: string, - destinyVersion: DestinyVersion, - loadouts: Loadout[], -): Promise { - const items = loadouts.map((loadout) => - convertLoadoutToStately(loadout, platformMembershipId, destinyVersion), - ); - await txn.putBatch(...items); -} - -export function importLoadouts( - loadouts: (Loadout & { - platformMembershipId: string; - destinyVersion: DestinyVersion; - })[], -) { - return loadouts - .filter((v) => v.platformMembershipId && v.destinyVersion) - .map((v) => convertLoadoutToStately(v, v.platformMembershipId, v.destinyVersion)); -} - -/** - * Make sure items are stored minimally and extra properties don't sneak in - */ -export function cleanItem(item: LoadoutItem): LoadoutItem { - const hash = item.hash; - if (!Number.isFinite(hash)) { - throw new Error('hash must be a number'); - } - - const result: LoadoutItem = { - hash, - }; - - if (item.amount && Number.isFinite(item.amount)) { - result.amount = item.amount; - } - - if (item.id) { - if (!isValidItemId(item.id)) { - throw new Error(`item ID ${item.id} is not in the right format`); - } - result.id = item.id; - } - - if (item.socketOverrides) { - result.socketOverrides = item.socketOverrides; - } - - if (item.craftedDate && Number.isFinite(item.craftedDate)) { - result.craftedDate = item.craftedDate; - } - - return result; -} - -/** - * Delete one or more loadouts. - */ -export async function deleteLoadout( - txn: Transaction, - platformMembershipId: string, - destinyVersion: DestinyVersion, - loadoutIds: string[], -): Promise { - if (loadoutIds.length === 0) { - return; - } - await txn.del(...loadoutIds.map((id) => keyFor(platformMembershipId, destinyVersion, id))); -} - -/** - * Delete all loadouts for a user (on all platforms). - */ -export async function deleteAllLoadouts(platformMembershipId: string): Promise { - // TODO: this is inefficient, for delete-my-data we'll nuke all the items in the group at once - const allLoadouts = await getAllLoadoutsForUser(platformMembershipId); - if (!allLoadouts.length) { - return; - } - - for (const batch of batches(allLoadouts)) { - await client.del( - ...batch.map((a) => keyFor(a.platformMembershipId, a.destinyVersion, a.loadout.id)), - ); - } -} diff --git a/api/stately/migrator/user.ts b/api/stately/migrator/user.ts deleted file mode 100644 index 2b725f49..00000000 --- a/api/stately/migrator/user.ts +++ /dev/null @@ -1,44 +0,0 @@ -// import { isEmpty } from 'es-toolkit/compat'; -// import { doMigration } from '../../db/migration-state-queries.js'; -// import { pgExport } from '../../routes/export.js'; -// import { extractImportData, statelyImport } from '../../routes/import.js'; - -// export async function migrateUser(bungieMembershipId: number): Promise { -// const importToStately = async () => { -// // Export from Postgres -// const exportResponse = await pgExport(bungieMembershipId); - -// const { settings, loadouts, itemAnnotations, triumphs, searches, itemHashTags } = -// extractImportData(exportResponse); - -// const profileIds = new Set(); -// exportResponse.loadouts.forEach((l) => profileIds.add(l.platformMembershipId)); -// exportResponse.tags.forEach((t) => profileIds.add(t.platformMembershipId)); -// exportResponse.triumphs.forEach((t) => profileIds.add(t.platformMembershipId)); - -// if ( -// isEmpty(settings) && -// loadouts.length === 0 && -// itemAnnotations.length === 0 && -// triumphs.length === 0 && -// searches.length === 0 -// ) { -// // Nothing to import! -// return; -// } -// await statelyImport( -// bungieMembershipId, -// [...profileIds], -// settings, -// loadouts, -// itemAnnotations, -// triumphs, -// searches, -// itemHashTags, -// false, -// ); -// }; - -// // For now let's leave the old data in Postgres as a backup -// await doMigration(bungieMembershipId, importToStately); -// } diff --git a/api/stately/schema/README.md b/api/stately/schema/README.md deleted file mode 100644 index 3d485173..00000000 --- a/api/stately/schema/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# DIM (Destiny Item Manager) Schema - -These are the objects that DIM stores in its own service - most data comes from Bungie's Destiny API, but certain amendments (custom tags, loadouts, etc.) are provided directly by DIM because they're not part of the game. - -Key paths are laid out like this: - -- `/apps/app-:id`: `ApiApp` -- `/gs-:stage`: `GlobalSettings` -- `/loadoutShare-:id`: `LoadoutShare` -- `/member-:memberId/settings`: `Settings` -- `/p-:profileId` - - `/d-:destinyVersion` - - `/ia-:id`: `ItemAnnotation` - - `/iht-:hash`: `ItemHashTag` - - `/loadout-:id`: `Loadout` - - `/search-:qhash`: `Search` - - `/triumph-:recordHash`: `Triumph` - - `/wl-:wishlistId`: `WishListInfo` -- `/wl-:wishlistId`: `WishListInfo` (uuid? rand?) - - `/e-:wishlistEntry`: `WishListEntry` - -The goal with this modeling is to allow for syncing all of a user's info in two StatelyDB operations: - -- `List("/p-:profileId/d-:destinyVersion")` - get all saved data for a particular game profile + destiny version (each profile can be associated with Destiny 1 and Destiny 2) -- `List("/member-:memberId")` - get settings for a whole Bungie.net account. It's a List instead of a Get so we can sync it! - -Plus some fun stuff such as: - -- `List("/apps/")` to get all registered apps, and `SyncList()` to keep them up to date. diff --git a/api/stately/schema/app.ts b/api/stately/schema/app.ts deleted file mode 100644 index 94acb42a..00000000 --- a/api/stately/schema/app.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { itemType, string, type, uint } from '@stately-cloud/schema'; - -// A UUID stored as a string. This is inefficient, but we always use them as -// strings in this API. -const uuidString = type('uuidString', string, { - // Copied from protovalidate - valid: - "this.matches('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')", -}); - -// "apps" aren't exposed to users - they're periodically synced by server instances. -export const ApiApp = itemType('ApiApp', { - keyPath: '/apps-:partition/app-:id', - fields: { - /** A short ID that uniquely identifies the app. */ - id: { type: string }, - /** Apps must share their Bungie.net API key with us. */ - bungieApiKey: { type: string }, - /** Apps also get a generated API key for accessing DIM APIs that don't involve user data. */ - dimApiKey: { - type: uuidString, - /* initialValue: 'uuid' Sad, I actually wanted this to be a new random UUID on insert */ - }, - /** The origin used to allow CORS for this app. Only requests from this origin are allowed. */ - origin: { type: string }, - /** This isn't a "real" field but StatelyDB won't allow a group without an ID. */ - partition: { type: uint }, - }, -}); diff --git a/api/stately/schema/global-settings.ts b/api/stately/schema/global-settings.ts deleted file mode 100644 index d634c009..00000000 --- a/api/stately/schema/global-settings.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { - bool, - durationSeconds, - itemType, - string, - timestampMilliseconds, -} from '@stately-cloud/schema'; - -// Clients load these settings from the server on startup. They change infrequently and are super cache-able. -export const GlobalSettings = itemType('GlobalSettings', { - keyPath: '/gs-:stage', - fields: { - stage: { type: string }, - /** Whether the API is enabled or not. */ - dimApiEnabled: { type: bool }, - /** Don't allow refresh more often than this many seconds. */ - destinyProfileMinimumRefreshInterval: { type: durationSeconds }, - /** Time in seconds to refresh the profile when autoRefresh is true. */ - destinyProfileRefreshInterval: { type: durationSeconds }, - /** Whether to refresh profile automatically. */ - autoRefresh: { type: bool }, - /** Whether to refresh profile when the page becomes visible after being in the background. */ - refreshProfileOnVisible: { type: bool }, - /** Don't automatically refresh DIM profile info more often than this many seconds. */ - dimProfileMinimumRefreshInterval: { type: durationSeconds }, - /** Display an issue banner, if there is one. */ - showIssueBanner: { type: bool }, - - lastUpdated: { type: timestampMilliseconds, fromMetadata: 'lastModifiedAtTime' }, - }, -}); diff --git a/api/stately/schema/index.ts b/api/stately/schema/index.ts deleted file mode 100644 index 744f9cb8..00000000 --- a/api/stately/schema/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './app.js'; -export * from './global-settings.js'; -export * from './loadout-share.js'; -export * from './loadouts.js'; -export * from './search.js'; -export * from './settings.js'; -export * from './tags.js'; -export * from './triumphs.js'; -export * from './types.js'; diff --git a/api/stately/schema/loadout-share.ts b/api/stately/schema/loadout-share.ts deleted file mode 100644 index 60c43ff2..00000000 --- a/api/stately/schema/loadout-share.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { itemType, string, uint32 } from '@stately-cloud/schema'; -import { loadoutFields } from './loadouts.js'; - -// TODO: I'd love a way to specify that this item is immutable (save for viewCount). -export const LoadoutShare = itemType('LoadoutShare', { - // We put this under a profile and a destiny version so we can get all - // loadouts for a particular destiny version in one query - keyPath: [ - '/loadoutShare-:id', - // TODO: It'd be neat to store a compact pointer back to this under the - // user's profile, so we could list out shares. Maybe just an ID pointer, - // maybe project in the name as well... - // {path: "/p-:profileId/d-:destinyVersion/loadoutShare-:id", type: 'pointer' }, - ], - // TODO: I want a retention policy on these, that'll delete them if they have - // zero views for a month. That can't be done with just a TTL. - fields: { - /** - * A globally unique short random string to be used when sharing the loadout, but which is hard to guess. - * This is essentially 35 random bits encoded via base32 into a 7-character string. It'd be neat if we could - * support that, with a parameterizable string length. - */ - id: { type: string /* initialValue: 'rand35str' */ }, - ...loadoutFields, - // destinyVersion is always 2 - - /** - * A count that increases on each view. Not the most efficient way to deal - * with this - maybe that should be a child document. - */ - viewCount: { type: uint32, required: false }, - }, -}); diff --git a/api/stately/schema/loadouts.ts b/api/stately/schema/loadouts.ts deleted file mode 100644 index e59c3940..00000000 --- a/api/stately/schema/loadouts.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { - arrayOf, - bool, - enumType, - Fields, - itemType, - migrate, - objectType, - string, - timestampMilliseconds, - timestampSeconds, - uint32, - uuid, -} from '@stately-cloud/schema'; -import { - DestinyClass, - DestinyVersion, - HashID, - ItemID, - LockedExoticHash, - ProfileID, -} from './types.js'; - -export const SocketOverride = objectType('SocketOverride', { - fields: { - /** The index of the socket in the item */ - socketIndex: { type: uint32, required: false }, - /** The hash of the item that should be in this socket */ - itemHash: { type: HashID }, - }, -}); - -export const LoadoutItem = objectType('LoadoutItem', { - fields: { - /** itemInstanceId of the item (if it's instanced). Default to zero for an uninstanced item or unknown ID. */ - id: { type: ItemID, required: false }, - /** DestinyInventoryItemDefinition hash of the item */ - hash: { type: HashID }, - /** Optional amount (for consumables), default to zero */ - amount: { type: uint32, required: false }, - /** - * The socket overrides for the item. These signal what DestinyInventoryItemDefinition - * (by it's hash) is supposed to be socketed into the given socket index. - */ - socketOverrides: { type: arrayOf(SocketOverride), required: false }, - /** - * UTC epoch seconds timestamp of when the item was crafted. Used to - * match up items that have changed instance ID from being reshaped since they - * were added to the loadout. - */ - craftedDate: { type: timestampSeconds, required: false }, - }, -}); - -/** normally found inside DestinyLoadoutComponent, mapped to respective definition tables */ -export const InGameLoadoutIdentifiers = objectType('InGameLoadoutIdentifiers', { - fields: { - colorHash: { type: HashID }, - iconHash: { type: HashID }, - nameHash: { type: HashID }, - }, -}); - -// These are reused in LoadoutShare -export const loadoutFields: Fields = { - /** Name/title for the loadout. */ - name: { type: string }, - /** Optional longform notes about the loadout. */ - notes: { type: string, required: false }, - /** - * DestinyClass enum value for the class this loadout is restricted - * to. This is optional (set to Unknown for loadouts that can be used anywhere). - */ - classType: { type: DestinyClass, required: false }, - /** List of equipped items in the loadout */ - equipped: { type: arrayOf(LoadoutItem), required: false }, - /** List of unequipped items in the loadout */ - unequipped: { type: arrayOf(LoadoutItem), required: false }, - /** Information about the desired properties of this loadout - used to drive the Loadout Optimizer or apply Mod Loadouts */ - parameters: { type: LoadoutParameters, required: false }, - - /** When was this Loadout initially created? Tracked automatically by the API - when saving a loadout this field is ignored. */ - createdAt: { type: timestampMilliseconds, fromMetadata: 'createdAtTime' }, - /** When was this Loadout last changed? Tracked automatically by the API - when saving a loadout this field is ignored. */ - lastUpdatedAt: { type: timestampMilliseconds, fromMetadata: 'lastModifiedAtTime' }, - - destinyVersion: { type: DestinyVersion }, - profileId: { type: ProfileID }, -}; - -export const Loadout = itemType('Loadout', { - keyPath: [ - // We put this under a profile and a destiny version so we can get all - // loadouts for a particular destiny version in one query. - '/p-:profileId/d-:destinyVersion/loadout-:id', - // Technically loadouts are meant to be globally unique by ID, and we could - // add this alias to enforce that. But it doesn't seem too important since - // we never actually need to operate on them by ID. - // '/loadout-:id' - ], - fields: { - /** - * A globally unique (UUID) identifier for the loadout. Chosen by the client, not autogenerated by the DB. - */ - id: { type: uuid }, - ...loadoutFields, - }, -}); - -/** Whether armor of this type will have assumed masterworked stats in the Loadout Optimizer. */ -export function AssumeArmorMasterwork() { - return enumType('AssumeArmorMasterwork', { - /** No armor will have assumed masterworked stats. */ - None: 0, - /** Only legendary armor will have assumed masterworked stats. */ - Legendary: 1, - /** All armor (legendary & exotic) will have assumed masterworked stats. */ - All: 2, - /** All armor (legendary & exotic) will have assumed masterworked stats, and Exotic Armor will be upgraded to have an artifice mod slot. */ - ArtificeExotic: 3, - }); -} - -/** How the loadouts menu and page should be sorted */ -export const LoadoutSort = enumType('LoadoutSort', { - ByEditTime: 0, - ByName: 1, -}); - -export function ModsByBucketEntry() { - return objectType('ModsByBucketEntry', { - fields: { - bucketHash: { type: HashID }, - modHashes: { type: arrayOf(HashID), required: false }, - }, - }); -} - -export function ArtifactUnlocks() { - return objectType('ArtifactUnlocks', { - fields: { - /** The item hashes of the unlocked artifact perk items. */ - unlockedItemHashes: { type: arrayOf(HashID), required: false }, - /** The season this set of artifact unlocks was chosen from. */ - seasonNumber: { type: uint32 }, - }, - }); -} - -const SetBonusCount = objectType('SetBonusCount', { - fields: { - /** The DestinyEquipableItemSetDefinition hash of the set bonus */ - setBonusHash: { type: HashID }, - /** The number of pieces we require that provide that setBonus */ - count: { type: uint32, valid: 'this >= 0 && this <= 5' }, - }, -}); - -/** - * Parameters that explain how this loadout was chosen (in Loadout Optimizer) - * and at the same time, how this loadout should be configured when equipped. - * This can be used to re-load a loadout into Loadout Optimizer with its - * settings intact, or to equip the right mods when applying a loadout if AWA is - * ever released. - * - * Originally this was meant to model parameters independent of specific items, - * as a means of sharing Loadout Optimizer settings between users, but now we - * just share whole loadouts, so this can be used for any sort of parameter we - * want to add to loadouts. - * - * All properties are optional, but most have defaults specified in - * defaultLoadoutParameters that should be used if they are undefined. - */ -export function LoadoutParameters() { - return objectType('LoadoutParameters', { - fields: { - /** - * The stats the user cared about for this loadout, in the order they cared about them and - * with optional range by tier. If a stat is "ignored" it should just be missing from this - * list. - */ - statConstraints: { type: arrayOf(StatConstraint), required: false }, - - /** - * The mods that will be used with this loadout. Each entry is an inventory - * item hash representing the mod item. Hashes may appear multiple times. - * These are not associated with any specific item in the loadout - when - * applying the loadout we should automatically determine the minimum of - * changes required to match the desired mods, and apply these mods to the - * equipped items. - */ - mods: { type: arrayOf(HashID), required: false }, - - /** - * A list of armor perks that should be included in this loadout. This - * expresses a desire in the Loadout Optimizer to generate sets that have - * these perks. - * - * For regular perks each occurrence of the perk in this list represents one - * instance of the perk that should appear on an item in the loadout. For - * armor set bonuses, use setBonuses instead. - * - * For example, this can be used to: - * - Specify what exotic class item perks you want - * - Specify that you want some seasonal armor perks to be used (e.g. 3 - * instances of Iron Lord's Pride) - * - * For picking specific perks on weapons, use modsByBucket instead. - */ - perks: { type: arrayOf(HashID), required: false }, - - /** - * The set bonuses that we want to activate with this loadout. This is a - * mapping of one or more DestinyEquipableItemSetDefinition hashes to the - * number of pieces we require that provide that setBonus. - */ - setBonuses: { type: arrayOf(SetBonusCount), required: false }, - - /** - * If set, after applying the mods above, all other mods will be removed from armor. - */ - clearMods: { type: bool }, - - /** Whether to clear out other weapons when applying this loadout */ - clearWeapons: { type: bool }, - - /** Whether to clear out other weapons when applying this loadout */ - clearArmor: { type: bool }, - - /** - * Mods that must be applied to a specific bucket hash. In general, prefer to - * use the flat mods list above, and rely on the loadout function to assign - * mods automatically. However there are some mods like shaders which can't - * be automatically assigned to the right piece. These only apply to the equipped - * item. - */ - modsByBucket: { type: arrayOf(ModsByBucketEntry), required: false }, - - /** The artifact unlocks relevant to this build. */ - artifactUnlocks: { type: ArtifactUnlocks, required: false }, - - /** Whether to automatically add stat mods. */ - autoStatMods: { type: bool }, - - /** - * A search filter applied while editing the loadout in Loadout Optimizer, - * which constrains the items that can be in the loadout. - */ - query: { type: string, required: false }, - - /** - * Whether armor of this type will have assumed masterwork stats in the Loadout Optimizer. - */ - assumeArmorMasterwork: { type: AssumeArmorMasterwork, required: false }, - - /** - * The InventoryItemHash of the pinned exotic, if any was chosen. - */ - exoticArmorHash: { type: LockedExoticHash, required: false }, - - /** - * a user may optionally specify which icon/color/name will be used, - * if this DIM loadout is saved to an in-game slot. - */ - inGameIdentifiers: { type: InGameLoadoutIdentifiers, required: false }, - - /** - * When calculating loadout stats, should "Font of ..." mods be assumed active - * and their runtime bonus stats be included? - */ - includeRuntimeStatBenefits: { type: bool }, - }, - }); -} - -/** A constraint on the values an armor stat can take */ -export function StatConstraint() { - return objectType('StatConstraint', { - fields: { - /** The stat definition hash of the stat */ - statHash: { type: HashID }, - /** The minimum tier value for the stat. 0 if unset. */ - minTier: { type: uint32, required: false, valid: 'this <= 10 && this >= 0' }, - /** The maximum tier value for the stat. 10 if unset. */ - maxTier: { type: uint32, required: false, valid: 'this <= 10 && this >= 0' }, - /** Minimum absolute value for the stat. 0 if unset. Replaces minTier in Edge of Fate. */ - minStat: { type: uint32, required: false, valid: 'this <= 200 && this >= 0' }, - /** Maximum absolute value for the stat. Max Possible Stat Value if unset. Replaces maxTier in Edge of Fate. */ - maxStat: { type: uint32, required: false, valid: 'this <= 200 && this >= 0' }, - }, - }); -} - -migrate(3, 'Add new Loadout fields', (t) => { - t.changeType(StatConstraint, (m) => { - m.addField('maxStat'); - m.addField('minStat'); - }); - t.changeType(LoadoutParameters, (m) => { - m.addField('perks'); - }); -}); - -migrate(6, 'Add setBonus', (t) => { - t.addType(SetBonusCount); - t.changeType(LoadoutParameters, (m) => { - m.addField('setBonuses'); - }); -}); diff --git a/api/stately/schema/search.ts b/api/stately/schema/search.ts deleted file mode 100644 index eef8bf17..00000000 --- a/api/stately/schema/search.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - bool, - bytes, - enumType, - itemType, - string, - timestampMilliseconds, - uint32, -} from '@stately-cloud/schema'; -import { DestinyVersion, ProfileID } from './types.js'; - -export const SearchType = enumType('SearchType', { Item: 0, Loadout: 1 }); - -/** - * A search query. This can either be from history (recent searches), pinned (saved searches), or suggested. - */ -export const Search = itemType('Search', { - // We put this under a profile and a destiny version so we can get all search - // history for a particular destiny version in one query (along with other - // stuff in the profile). Technically the searches could be shared between - // profiles, but if we did that we'd need more queries. - keyPath: '/p-:profileId/d-:destinyVersion/search-:qhash', - // TODO: enable last usage index - // indexes: [ - // { - // groupLocalIndex: 1, - // field: 'lastUsage', - // }, - // ], - fields: { - /** - * The full search query. These are - */ - query: { type: string }, - /** A zero usage count means this is a suggested/preloaded search. */ - usageCount: { type: uint32, required: false }, - /** Has this search been saved/favorite'd/pinned by the user? */ - saved: { type: bool }, - /** - * The last time this was used, as a unix millisecond timestamp. We don't - * use fromMetadata: 'lastModifiedAtTime' because on import we want to set this - * to whatever value it was, not the insert time. - */ - lastUsage: { type: timestampMilliseconds }, - /** - * Which kind of thing is this search for? Searches of different types are - * stored together and need to be filtered to the specific type. - */ - type: { type: SearchType, required: false }, - - /** - * MD5 hash of the query string. This is used to enforce uniqueness of - * queries without using the whole long query string as a key. - */ - // TODO: in the current postgres schema, queries are stored as a hash of the - // query string which is automatically generated by the DB. We'd have to do - // it manually. - qhash: { type: bytes /* expression: 'md5(this.query)' */ }, - - /** The profile ID this search is associated with. */ - profileId: { type: ProfileID }, - - /** The Destiny version this search is associated with. */ - destinyVersion: { type: DestinyVersion }, - }, -}); diff --git a/api/stately/schema/settings.ts b/api/stately/schema/settings.ts deleted file mode 100644 index b5527609..00000000 --- a/api/stately/schema/settings.ts +++ /dev/null @@ -1,297 +0,0 @@ -// Synced with the definitions in DIM/src/app/settings/reducer.ts - -import { - arrayOf, - bool, - double, - enumType, - itemType, - migrate, - objectType, - string, - type, - uint32, -} from '@stately-cloud/schema'; -import { LoadoutParameters, LoadoutSort, StatConstraint } from './loadouts.js'; -import { DestinyClass, HashID, ItemID, MembershipID } from './types.js'; - -export const CharacterOrder = enumType('CharacterOrder', { - mostRecent: 1, - mostRecentReverse: 2, - fixed: 3, - custom: 4, -}); - -export const InfuseDirection = enumType('InfuseDirection', { - /** infuse something into the query (query = target) */ - Infuse: 1, - /** infuse the query into the target (query = source) */ - Fuel: 2, -}); - -export const ItemPopupTab = enumType('ItemPopupTab', { - Overview: 0, - Triage: 1, -}); - -export const ArmorStatCompare = enumType('ArmorStatCompare', { - Current: 0, - Base: 1, - BaseMasterwork: 2, -}); - -export const OrnamentDisplay = enumType('OrnamentDisplay', { - /** Always display the ornament's image. */ - All: 0, - /** Always display the base image. */ - None: 1, -}); - -export const VaultWeaponGroupingStyle = enumType('VaultWeaponGroupingStyle', { - Lines: 0, - Inline: 1, -}); - -const Columns = type('columns', uint32, { valid: 'this <= 5 && this >= 2' }); - -export const CollapsedSection = objectType('CollapsedSection', { - fields: { - key: { type: string }, - /** Whether this section is collapsed */ - collapsed: { type: bool }, - }, -}); - -export const StatConstraintsEntry = objectType('StatConstraintsEntry', { - fields: { - classType: { type: DestinyClass, required: false }, - constraints: { type: arrayOf(StatConstraint), required: false }, - }, -}); -export const CustomStatsEntry = objectType('CustomStatsEntry', { - fields: { - classType: { type: DestinyClass, required: false }, - customStats: { type: arrayOf(HashID) }, - }, -}); - -export const DescriptionOptions = enumType('DescriptionOptions', { - bungie: 1, - community: 2, - both: 3, -}); - -// we go generous here on key options, because sometimes a statHash -// is a string, because it was a dictionary key -/** traditional custom stats use a binary 1 or 0 for all 6 armor stats, but this could support more complex weights */ -export const CustomStatWeightsEntry = objectType('CustomStatWeightsEntry', { - fields: { - statHash: { type: HashID }, - weight: { type: double, required: false }, - }, -}); - -export const CustomStatDef = objectType('CustomStatDef', { - fields: { - /** a unique-per-user fake statHash used to look this stat up */ - statHash: { type: HashID }, - /** a unique-per-class name for this stat */ - label: { type: string }, - /** an abbreviated/crunched form of the stat label, for use in search filters */ - shortLabel: { type: string }, - /** which guardian class this stat should be used for. DestinyClass.Unknown makes a global (all 3 classes) stat */ - class: { type: DestinyClass, required: false }, - /** info about how to calculate the stat total */ - weights: { type: arrayOf(CustomStatWeightsEntry) }, - }, -}); - -export const Settings = itemType('Settings', { - // Settings are stored per-Bungie-membership, not per-profile or per-destiny-version - keyPath: '/member-:memberId/settings', - fields: { - memberId: { type: MembershipID }, - - /** Show item quality percentages */ - itemQuality: { type: bool }, - /** Show new items with an overlay */ - showNewItems: { type: bool }, - /** Sort characters (mostRecent, mostRecentReverse, fixed) */ - characterOrder: { type: CharacterOrder }, - /** Custom sorting properties, in order of application */ - // TODO: Default should be ["primStat", "name"] but we don't support list defaults - itemSortOrderCustom: { type: arrayOf(string), required: false }, - /** supplements itemSortOrderCustom by allowing each sort to be reversed */ - itemSortReversals: { type: arrayOf(string), required: false }, - /** How many columns to display character buckets */ - charCol: { type: Columns, required: false }, - /** How many columns to display character buckets on Mobile */ - charColMobile: { type: Columns, required: false }, - /** How big in pixels to draw items - start smaller for iPad */ - itemSize: { type: uint32, valid: 'this <= 66', required: false }, - /** Which categories or buckets should be collapsed? */ - // TODO: Some support for maps would be great - collapsedSections: { type: arrayOf(CollapsedSection), required: false }, - /** Hide triumphs once they're completed */ - completedRecordsHidden: { type: bool }, - /** Hide show triumphs the manifest recommends be redacted */ - redactedRecordsRevealed: { type: bool }, - /** Whether to keep one slot per item type open */ - farmingMakeRoomForItems: { type: bool }, - /** How many spaces to clear when using Farming Mode (make space). */ - inventoryClearSpaces: { type: uint32, required: false, valid: 'this <= 9' }, - - /** Hide completed triumphs/collections */ - hideCompletedRecords: { type: bool }, - - /** Custom character sort - across all accounts and characters! The values are character IDs. */ - customCharacterSort: { type: arrayOf(string), required: false }, - - /** The last direction the infusion fuel finder was set to. */ - infusionDirection: { type: InfuseDirection, required: false }, - - /** The user's preferred language code. */ - language: { type: string, required: false }, - - /** - * External sources for wish lists. - * Expected to be a valid URL. - * initialState should hold the current location of a reasonably-useful collection of rolls. - * Set to empty string to not use wishListSource. - */ - // TODO: default should be ['https://raw.githubusercontent.com/48klocs/dim-wish-list-sources/master/voltron.txt'] - // TODO: this should be "url" but it's a string for now since interpretAs doesn't yet work - wishListSources: { type: arrayOf(string), required: false }, - - /** - * The last used settings for the Loadout Optimizer. These apply to all classes. - */ - // TODO: originally this was Exclude; - loParameters: { type: LoadoutParameters, required: false }, - - /** - * Stat order, enablement, etc. Stored per class. - */ - // TODO: maps, again - loStatConstraintsByClass: { - type: arrayOf(StatConstraintsEntry), - - required: false, - }, - - /** list of stat hashes of interest, keyed by class enum */ - customTotalStatsByClass: { type: arrayOf(CustomStatsEntry), required: false }, - - /** Selected columns for the Vault Organizer */ - // TODO: Default should be ["icon", "name", "dmg", "power", "locked", "tag", "wishList", "archetype", "perks", "notes"] - organizerColumnsWeapons: { type: arrayOf(string) }, - // TODO: Default should be ["icon", "name", "power", "dmg", "energy", "locked", "tag", "ghost", "modslot", "perks", "stats", "customstat", "notes"] - organizerColumnsArmor: { type: arrayOf(string) }, - // TODO: Default should be ["icon", "name", "locked", "tag", "perks", "notes"] - organizerColumnsGhost: { type: arrayOf(string) }, - - /** Compare base stats or actual stats in Compare */ - compareBaseStats: { type: bool }, - /** Item popup sidecar collapsed just shows icon and no character locations */ - sidecarCollapsed: { type: bool }, - - /** In "Single Character Mode" DIM pretends you only have one (active) character and all the other characters' items are in the vault. */ - singleCharacter: { type: bool }, - - /** Badge the app icon with the number of postmaster items on the current character */ - badgePostmaster: { type: bool }, - - /** Display perks as a list instead of a grid (mobile). */ - perkList: { type: bool }, - - /** Display perks as a list instead of a grid (desktop). */ - perkListDesktop: { type: bool }, - - /** How the loadouts menu and page should be sorted */ - loadoutSort: { type: LoadoutSort, required: false }, - - /** Hide tagged items in the Item Feed */ - itemFeedHideTagged: { type: bool }, - - /** Show the Item Feed */ - itemFeedExpanded: { type: bool }, - - /** Pull from postmaster is an irreversible action and some people don't want to accidentally hit it. */ - hidePullFromPostmaster: { type: bool }, - - /** Select descriptions to display */ - descriptionsToDisplay: { type: DescriptionOptions, required: false }, - - /** Plug the T10 masterwork into D2Y2+ random roll weapons for comparison purposes. */ - compareWeaponMasterwork: { type: bool }, - - /** - * Cutoff point; the instance ID of the newest item that isn't shown in - * the item feed anymore after the user presses the "clear" button. - */ - itemFeedWatermark: { type: ItemID, required: false }, - - /** - * a set of user-defined custom stat totals. - * this will supersede customTotalStatsByClass. - * it defaults below to empty, which in DIM, initiates fallback to customTotalStatsByClass - */ - customStats: { type: arrayOf(CustomStatDef), required: false }, - - /** Automatically sync lock status with tag */ - autoLockTagged: { type: bool }, - - /** The currently chosen theme. */ - theme: { type: string, required: false }, - - /** Whether to sort triumphs on the records tab by their progression percentage. */ - sortRecordProgression: { type: bool }, - - /** Whether to hide items that cost silver from the Vendors screen. */ - vendorsHideSilverItems: { type: bool }, - - /** An additional layer of grouping for weapons in the vault. */ - vaultWeaponGrouping: { type: string, required: false }, - - /** How grouped weapons in the vault should be displayed. */ - vaultWeaponGroupingStyle: { type: VaultWeaponGroupingStyle, required: false }, - - /** How grouped armor in the vault should be displayed. */ - vaultArmorGroupingStyle: { type: VaultWeaponGroupingStyle, required: false }, - - /** The currently selected item popup tab. */ - itemPopupTab: { type: ItemPopupTab, required: false }, - - /** Whether to show vaulted items underneath equipped items in Desktop view. */ - vaultBelow: { type: bool }, - - /** Different modes for how armor stats can be compared. */ - armorCompare: { type: ArmorStatCompare, required: false }, - - /** Whether to show the ornamented state of items. */ - ornamentDisplay: { type: OrnamentDisplay, required: false }, - }, -}); - -migrate(4, 'Add new Settings fields', (t) => { - t.changeType(Settings, (m) => { - m.addField('vaultArmorGroupingStyle'); - }); -}); - -migrate(7, 'Add perkListDesktop setting', (t) => { - t.changeType(Settings, (m) => { - m.addField('perkListDesktop'); - }); -}); - -migrate(8, 'Add settings', (t) => { - t.changeType(Settings, (m) => { - m.addField('vaultBelow'); - m.addField('armorCompare'); - m.addField('ornamentDisplay'); - }); - - t.addType(ArmorStatCompare); - t.addType(OrnamentDisplay); -}); diff --git a/api/stately/schema/tags.ts b/api/stately/schema/tags.ts deleted file mode 100644 index 8938846e..00000000 --- a/api/stately/schema/tags.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Fields, enumType, itemType, string, timestampMilliseconds } from '@stately-cloud/schema'; -import { DestinyVersion, HashID, ItemID, ProfileID } from './types.js'; - -export const TagValue = enumType('TagValue', { - favorite: 1, - keep: 2, - infuse: 3, - junk: 4, - archive: 5, -}); - -// Both ItemAnnotation and ItemHashTag share these fields. -const sharedFields: Fields = { - /** The profile ID for a Destiny profile. */ - profileId: { type: ProfileID }, - destinyVersion: { type: DestinyVersion }, - /** Optional tag for the item. */ - tag: { type: TagValue, required: false }, - /** Optional text notes on the item. */ - notes: { type: string, required: false }, -}; - -/** Any extra info added by the user to individual items - tags, notes, etc. */ -export const ItemAnnotation = itemType('ItemAnnotation', { - keyPath: '/p-:profileId/d-:destinyVersion/ia-:id', - fields: { - ...sharedFields, - - /** The item instance ID for an individual item */ - id: { - type: ItemID, - // We still need to make sure these don't collide with the IDs in sharedFields. - }, - - /** - * UTC epoch seconds timestamp of when the item was crafted. Used to - * match up items that have changed instance ID from being reshaped since they - * were tagged. - */ - craftedDate: { - type: timestampMilliseconds, - // We still need to make sure these don't collide with the IDs in sharedFields. - required: false, - }, - }, -}); - -/** Any extra info added by the user to item hashes (shaders and mods) */ -export const ItemHashTag = itemType('ItemHashTag', { - keyPath: '/p-:profileId/d-:destinyVersion/iht-:hash', - fields: { - ...sharedFields, - // destinyVersion is always 2 - - /** The inventory item hash for an item */ - hash: { - type: HashID, - }, - }, -}); diff --git a/api/stately/schema/triumphs.ts b/api/stately/schema/triumphs.ts deleted file mode 100644 index c9320597..00000000 --- a/api/stately/schema/triumphs.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { itemType } from '@stately-cloud/schema'; -import { DestinyVersion, HashID, ProfileID } from './types.js'; - -/** - * Triumph stores a single record hash for a tracked triumph. Users can have any - * number of tracked triumphs, with one item per triumph. - */ -export const Triumph = itemType('Triumph', { - keyPath: '/p-:profileId/d-:destinyVersion/triumph-:recordHash', - fields: { - recordHash: { type: HashID }, - profileId: { type: ProfileID }, - - // This is always "2" but we can't have constants in key paths - destinyVersion: { type: DestinyVersion }, - }, -}); diff --git a/api/stately/schema/types.ts b/api/stately/schema/types.ts deleted file mode 100644 index 0439ac18..00000000 --- a/api/stately/schema/types.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { enumType, ProtoScalarType, type, uint, uint32 } from '@stately-cloud/schema'; - -// Normally we'd use sint64, but this field used to be uint32 before I realized -// it included some signed special values. -export const LockedExoticHash = type('LockedExoticHash', ProtoScalarType.INT64); - -/** The unique ID of an inventory item */ -export const ItemID = type('ItemID', uint); - -/** The hash ID of a definition */ -// Manifest hashes are actually a uint32 -export const HashID = type('HashID', uint32); - -/** The unique ID of a Bungie.net membership */ -export const MembershipID = type('MembershipID', uint); -/** The unique ID of a Destiny profile. These can be moved between different Bungie.net memberships. */ -export const ProfileID = type('ProfileID', uint); - -// This could be an enum, but it's easy enough as a constrained number. -export const DestinyVersion = type('DestinyVersion', uint32, { - valid: 'this == uint(1) || this == uint(2)', -}); - -export const DestinyClass = enumType('DestinyClass', { - // Normally we wouldn't have a zero-value default, but we're trying to match the Destiny enum - Titan: 0, - Hunter: 1, - Warlock: 2, - Unknown: 3, -}); diff --git a/api/stately/searches-queries.test.ts b/api/stately/searches-queries.test.ts deleted file mode 100644 index 2c1f2c9a..00000000 --- a/api/stately/searches-queries.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { cannedSearches } from '../routes/profile.js'; -import { DestinyVersion } from '../shapes/general.js'; -import { SearchType } from '../shapes/search.js'; -import { client } from './client.js'; -import { - deleteAllSearches, - deleteSearch, - getSearchesForProfile, - getSearchesForUser, - updateSearches, -} from './searches-queries.js'; - -const platformMembershipId = '213512057'; - -beforeEach(async () => deleteAllSearches(platformMembershipId)); - -async function updateUsedSearch( - platformMembershipId: string, - destinyVersion: DestinyVersion, - query: string, - type: SearchType, -) { - return client.transaction(async (txn) => { - await updateSearches(txn, platformMembershipId, destinyVersion, [ - { - query, - type, - saved: false, - incrementUsed: 1, - deleted: false, - }, - ]); - }); -} - -async function saveSearch( - platformMembershipId: string, - destinyVersion: DestinyVersion, - query: string, - type: SearchType, - saved: boolean, -) { - return client.transaction(async (txn) => { - await updateSearches(txn, platformMembershipId, destinyVersion, [ - { - query, - type, - saved, - incrementUsed: 0, - deleted: false, - }, - ]); - }); -} - -it('can record a used search where none was recorded before', async () => { - await updateUsedSearch(platformMembershipId, 2, 'tag:junk', SearchType.Item); - - const searches = (await getSearchesForProfile(platformMembershipId, 2)).searches.filter( - (s) => s.usageCount > 0, - ); - expect(searches[0].query).toBe('tag:junk'); - expect(searches[0].saved).toBe(false); - expect(searches[0].usageCount).toBe(1); -}); - -it('can track search multiple times', async () => { - await updateUsedSearch(platformMembershipId, 2, 'tag:junk', SearchType.Item); - await updateUsedSearch(platformMembershipId, 2, 'tag:junk', SearchType.Item); - - const searches = (await getSearchesForProfile(platformMembershipId, 2)).searches.filter( - (s) => s.usageCount > 0, - ); - expect(searches[0].query).toBe('tag:junk'); - expect(searches[0].saved).toBe(false); - expect(searches[0].usageCount).toBe(2); -}); - -it('can mark a search as favorite', async () => { - await updateUsedSearch(platformMembershipId, 2, 'tag:junk', SearchType.Item); - await saveSearch(platformMembershipId, 2, 'tag:junk', SearchType.Item, true); - - const searches = (await getSearchesForProfile(platformMembershipId, 2)).searches.filter( - (s) => s.usageCount > 0, - ); - expect(searches[0].query).toBe('tag:junk'); - expect(searches[0].saved).toBe(true); - expect(searches[0].usageCount).toBe(1); - - await saveSearch(platformMembershipId, 2, 'tag:junk', SearchType.Item, false); - - const searches2 = (await getSearchesForProfile(platformMembershipId, 2)).searches; - expect(searches2[0].query).toBe('tag:junk'); - expect(searches2[0].saved).toBe(false); - // Save/unsave doesn't modify usage count - expect(searches2[0].usageCount).toBe(1); - expect(searches2[0].lastUsage).toBe(searches2[0].lastUsage); -}); -it('can mark a search as favorite even when it hasnt been used', async () => { - await saveSearch(platformMembershipId, 2, 'tag:junk', SearchType.Item, true); - - const searches = (await getSearchesForProfile(platformMembershipId, 2)).searches; - expect(searches[0].query).toBe('tag:junk'); - expect(searches[0].saved).toBe(true); - expect(searches[0].usageCount).toBe(0); -}); - -it('can get all searches across profiles', async () => { - await updateUsedSearch(platformMembershipId, 2, 'tag:junk', SearchType.Item); - await updateUsedSearch(platformMembershipId, 1, 'is:tagged', SearchType.Item); - - const searches = (await getSearchesForUser(platformMembershipId)).filter( - (s) => s.search.usageCount > 0, - ); - expect(searches.length).toEqual(2); -}); - -it('can increment usage for one of the built-in searches', async () => { - const query = cannedSearches(2)[0].query; - - await updateUsedSearch(platformMembershipId, 2, query, SearchType.Item); - - const searches2 = (await getSearchesForProfile(platformMembershipId, 2)).searches; - const search = searches2.find((s) => s.query === query); - expect(search?.usageCount).toBe(1); - expect(searches2.length).toBe(1); -}); - -it('can delete a search', async () => { - await updateUsedSearch(platformMembershipId, 2, 'tag:junk', SearchType.Item); - await client.transaction(async (txn) => { - await deleteSearch(txn, platformMembershipId, 2, ['tag:junk']); - }); - - const searches = (await getSearchesForProfile(platformMembershipId, 2)).searches.filter( - (s) => s.usageCount > 0, - ); - expect(searches.length).toBe(0); -}); - -it('can record searches for loadouts', async () => { - await updateUsedSearch(platformMembershipId, 2, 'subclass:void', SearchType.Loadout); - - const searches = (await getSearchesForProfile(platformMembershipId, 2)).searches.filter( - (s) => s.usageCount > 0, - ); - expect(searches[0].query).toBe('subclass:void'); - expect(searches[0].saved).toBe(false); - expect(searches[0].usageCount).toBe(1); - expect(searches[0].type).toBe(SearchType.Loadout); -}); diff --git a/api/stately/searches-queries.ts b/api/stately/searches-queries.ts deleted file mode 100644 index a839a0b8..00000000 --- a/api/stately/searches-queries.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { keyPath, ListToken } from '@stately-cloud/client'; -import crypto from 'node:crypto'; -import { ExportResponse } from '../shapes/export.js'; -import { DestinyVersion } from '../shapes/general.js'; -import { Search, SearchType } from '../shapes/search.js'; -import { getProfile } from './bulk-queries.js'; -import { client } from './client.js'; -import { Search as StatelySearch, SearchType as StatelySearchType } from './generated/index.js'; -import { batches, Transaction } from './stately-utils.js'; - -function queryHash(query: string) { - return crypto.createHash('md5').update(query).digest(); -} - -export function keyFor( - platformMembershipId: string | bigint, - destinyVersion: DestinyVersion, - query: string, -) { - return keyPath`/p-${BigInt(platformMembershipId)}/d-${destinyVersion}/search-${queryHash(query)}`; -} - -/* - * Searches are stored in a single table, scoped by Bungie.net account and destiny version (D1 searches are separate from D2 searches). - * Favorites and recent searches are stored the same - there's just a favorite flag for saved searches. There is also a usage count - * and a last_updated_at time, so we can order by both frequency and recency (or a combination of both) and we can age out less-used - * searches. For the best results, searches should be normalized so they match up more often. - * - * We can merge this with a list of global suggested searches to avoid an empty menu. - */ - -/** - * Get all of the searches for a particular destiny_version. - */ -export async function getSearchesForProfile( - platformMembershipId: string, - destinyVersion: DestinyVersion, -): Promise<{ searches: Search[]; token: ListToken }> { - const { profile, token } = await getProfile(platformMembershipId, destinyVersion, '/search'); - return { searches: profile.searches ?? [], token }; -} - -export function convertSearchFromStately(item: StatelySearch): Search { - return { - query: item.query, - usageCount: item.usageCount, - saved: item.saved, - lastUsage: Number(item.lastUsage), - type: item.type === StatelySearchType.SearchType_Item ? SearchType.Item : SearchType.Loadout, - }; -} - -/** - * Get ALL of the searches for a particular user across all destiny versions. - */ -export async function getSearchesForUser( - platformMembershipId: string, -): Promise { - // Rather than list ALL items under the profile and filter down to searches, - // just separately get the D1 and D2 searches. We probably won't use this - - // for export we *will* scrape a whole profile. - const d1Searches = getSearchesForProfile(platformMembershipId, 1); - const d2Searches = getSearchesForProfile(platformMembershipId, 2); - return (await d1Searches).searches - .map((a) => ({ destinyVersion: 1 as DestinyVersion, search: a })) - .concat( - (await d2Searches).searches.map((a) => ({ - destinyVersion: 2 as DestinyVersion, - search: a, - })), - ); -} - -export interface UpdateSearch { - query: string; - type: SearchType; - /** - * Whether the search should be saved. If undefined, the saved status is not - * changed. - */ - saved?: boolean; - /** How much to increment the used count by. */ - incrementUsed: number; - deleted: boolean; -} - -/** - * Update multiple searches. This can both save/unsave them and increase their usage count. - */ -export async function updateSearches( - txn: Transaction, - platformMembershipId: string, - destinyVersion: DestinyVersion, - updates: UpdateSearch[], -): Promise { - const existingSearches = ( - await txn.getBatch(...updates.map((v) => keyFor(platformMembershipId, destinyVersion, v.query))) - ).filter((i) => client.isType(i, 'Search')); - const updated = updates.map(({ query, type, saved, incrementUsed }) => { - const search = - existingSearches.find((s) => s.query === query) ?? - newSearch(platformMembershipId, destinyVersion, type, query); - search.usageCount += incrementUsed; - if (saved !== undefined) { - search.saved = saved; - } - search.lastUsage = BigInt(Date.now()); - return search; - }); - await txn.putBatch(...updated); -} - -function newSearch( - platformMembershipId: string, - destinyVersion: DestinyVersion, - type: SearchType, - query: string, -) { - return client.create('Search', { - query, - qhash: queryHash(query), - saved: false, - type: - type === SearchType.Item - ? StatelySearchType.SearchType_Item - : StatelySearchType.SearchType_Loadout, - profileId: BigInt(platformMembershipId), - destinyVersion, - }); -} - -export function importSearches( - platformMembershipId: string, - searches: { - destinyVersion: DestinyVersion; - search: Search; - }[], -) { - return searches - .filter(({ search }) => search.query) - .map(({ destinyVersion, search }) => - client.create('Search', { - query: search.query, - qhash: queryHash(search.query), - saved: search.saved, - usageCount: search.usageCount, - lastUsage: BigInt(search.lastUsage), - type: - search.type === SearchType.Item - ? StatelySearchType.SearchType_Item - : StatelySearchType.SearchType_Loadout, - profileId: BigInt(platformMembershipId), - destinyVersion, - }), - ); -} - -/** - * Delete a single search - */ -export async function deleteSearch( - txn: Transaction, - platformMembershipId: string, - destinyVersion: DestinyVersion, - queries: string[], -): Promise { - // TODO: We really should check that it's the right type of query, but realistically they're unique by query text. - await txn.del(...queries.map((q) => keyFor(platformMembershipId, destinyVersion, q))); -} - -/** - * Delete all searches for a user (for all destiny versions). - */ -export async function deleteAllSearches(platformMembershipId: string): Promise { - const searches = (await getSearchesForUser(platformMembershipId)).filter( - (s) => s.search.usageCount > 0, - ); - if (!searches.length) { - return; - } - for (const batch of batches(searches)) { - await client.del( - ...batch.map((search) => - keyFor(platformMembershipId, search.destinyVersion, search.search.query), - ), - ); - } -} diff --git a/api/stately/settings-queries.test.ts b/api/stately/settings-queries.test.ts deleted file mode 100644 index 5c3a109f..00000000 --- a/api/stately/settings-queries.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { omit } from 'es-toolkit'; -import { defaultLoadoutParameters } from '../shapes/loadouts.js'; -import { defaultSettings, Settings } from '../shapes/settings.js'; -import { client } from './client.js'; -import { - convertToDimSettings, - convertToStatelyItem, - getSettings, - setSetting, -} from './settings-queries.js'; - -const bungieMembershipId = 4321; - -it('can roundtrip between DIM settings and Stately settings', () => { - const settings: Settings = defaultSettings; - const statelySettings = convertToStatelyItem(settings, 1234); - const settings2 = convertToDimSettings(statelySettings); - expect(omit(settings2, ['memberId' as keyof Settings])).toEqual(settings); -}); - -it('can roundtrip between DIM settings and Stately settings with loadout parameters', () => { - const settings: Settings = { ...defaultSettings, loParameters: defaultLoadoutParameters }; - const statelySettings = convertToStatelyItem(settings, 1234); - const settings2 = convertToDimSettings(statelySettings); - expect(omit(settings2, ['memberId' as keyof Settings])).toEqual(settings); -}); - -it('can insert settings where none exist before', async () => { - await client.transaction(async (txn) => { - await setSetting(txn, bungieMembershipId, { - showNewItems: true, - }); - }); - - const settings = await getSettings(bungieMembershipId); - expect(settings?.showNewItems).toBe(true); -}); - -it('can update settings', async () => { - await client.transaction(async (txn) => { - await setSetting(txn, bungieMembershipId, { - showNewItems: true, - }); - }); - - const settings = await getSettings(bungieMembershipId); - expect(settings?.showNewItems).toBe(true); - - await client.transaction(async (txn) => { - await setSetting(txn, bungieMembershipId, { - showNewItems: false, - }); - }); - - const settings2 = await getSettings(bungieMembershipId); - expect(settings2?.showNewItems).toBe(false); -}); - -it('can partially update settings', async () => { - await client.transaction(async (txn) => { - await setSetting(txn, bungieMembershipId, { - showNewItems: true, - }); - }); - - const settings = await getSettings(bungieMembershipId); - expect(settings?.showNewItems).toBe(true); - - await client.transaction(async (txn) => { - await setSetting(txn, bungieMembershipId, { - singleCharacter: true, - }); - }); - - const settings2 = await getSettings(bungieMembershipId); - expect(settings2?.showNewItems).toBe(true); -}); diff --git a/api/stately/settings-queries.ts b/api/stately/settings-queries.ts deleted file mode 100644 index 287ef38e..00000000 --- a/api/stately/settings-queries.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { keyPath, ListToken } from '@stately-cloud/client'; -import { DestinyClass } from 'bungie-api-ts/destiny2'; -import { CustomStatDef } from '../shapes/custom-stats.js'; -import { LoadoutSort } from '../shapes/loadouts.js'; -import { - defaultSettings, - InfuseDirection, - ItemPopupTab, - OrnamentDisplay, - Settings, - VaultWeaponGroupingStyle, -} from '../shapes/settings.js'; -import { client } from './client.js'; -import { - ArmorStatCompare, - CharacterOrder, - DescriptionOptions, - InfuseDirection as StatelyInfuseDirection, - ItemPopupTab as StatelyItemPopupTab, - LoadoutSort as StatelyLoadoutSort, - OrnamentDisplay as StatelyOrnamentDisplay, - Settings as StatelySettings, - VaultWeaponGroupingStyle as StatelyVaultWeaponGroupingStyle, -} from './generated/index.js'; -import { - convertLoadoutParametersFromStately, - convertLoadoutParametersToStately, - statConstraintsFromStately, - statConstraintsToStately, -} from './loadouts-queries.js'; -import { - bigIntToNumber, - enumToStringUnion, - listToMap, - stripTypeName, - Transaction, -} from './stately-utils.js'; - -export function keyFor(bungieMembershipId: number) { - return keyPath`/member-${BigInt(bungieMembershipId)}/settings`; -} - -/** - * Get settings for a particular account. - */ -export async function getSettings(bungieMembershipId: number): Promise { - const results = await client.get('Settings', keyFor(bungieMembershipId)); - return results ? convertToDimSettings(results) : undefined; -} - -/** - * Get settings for a particular account in a syncable way. - */ -export async function querySettings( - bungieMembershipId: number, -): Promise<{ settings: Settings | undefined; token: ListToken }> { - const iter = client.beginList(keyFor(bungieMembershipId)); - let settingsItem: StatelySettings | undefined; - for await (const item of iter) { - if (client.isType(item, 'Settings')) { - settingsItem = item; - } - } - const token = iter.token!; - return { - settings: settingsItem ? convertToDimSettings(settingsItem) : undefined, - token, - }; -} - -/** - * Get new settings for a particular account, if they've changed since the - * token. Returns a new token. - */ -export async function syncSettings( - token: Buffer, -): Promise<{ settings: Settings | undefined; token: ListToken }> { - const sync = client.syncList(token); - let settings: Settings | undefined; - for await (const change of sync) { - switch (change.type) { - case 'changed': { - const item = change.item; - if (client.isType(item, 'Settings')) { - settings = convertToDimSettings(item); - } - break; - } - case 'reset': - case 'deleted': { - // Reset the settings - settings = defaultSettings; - break; - } - case 'updatedOutsideWindow': { - // This is a weird one, it really shouldn't happen - break; - } - } - } - return { settings, token: sync.token! }; -} - -/** - * Convert a Stately Settings item to a DIM Settings object. - */ -export function convertToDimSettings(settings: StatelySettings): Settings { - const { - wishListSources, - characterOrder, - collapsedSections, - infusionDirection, - loParameters, - loStatConstraintsByClass, - customTotalStatsByClass, - loadoutSort, - descriptionsToDisplay, - itemFeedWatermark, - customStats, - vaultWeaponGroupingStyle, - vaultArmorGroupingStyle, - itemPopupTab, - armorCompare, - ornamentDisplay, - ...rest - } = settings; - - const collapsedSectionsMap = Object.fromEntries( - collapsedSections.map((s) => [s.key, s.collapsed]), - ); - const loParametersFixed = loParameters - ? convertLoadoutParametersFromStately(loParameters) - : undefined; - const loStatConstraintsByClassMap = Object.fromEntries( - loStatConstraintsByClass.map(({ classType, constraints }) => [ - classType, - statConstraintsFromStately(constraints) ?? [], - ]), - ); - - const customStatsFixed: CustomStatDef[] = customStats.map((c) => { - const { class: klass, statHash, weights, ...rest } = stripTypeName(bigIntToNumber(c)); - return { - statHash: -statHash, // we stored it negated, because it's always negative - class: klass as number as DestinyClass, - weights: listToMap('statHash', 'weight', weights), - ...rest, - }; - }); - - return { - ...stripTypeName(bigIntToNumber(rest)), - wishListSource: wishListSources.join('|'), - characterOrder: enumToStringUnion(CharacterOrder, characterOrder) as Settings['characterOrder'], - collapsedSections: collapsedSectionsMap, - infusionDirection: - infusionDirection === StatelyInfuseDirection.InfuseDirection_Fuel - ? InfuseDirection.FUEL - : InfuseDirection.INFUSE, - loParameters: loParametersFixed ?? defaultSettings.loParameters, - loStatConstraintsByClass: loStatConstraintsByClassMap, - customTotalStatsByClass: listToMap( - 'classType', - 'customStats', - bigIntToNumber(customTotalStatsByClass), - ), - loadoutSort: - loadoutSort === StatelyLoadoutSort.LoadoutSort_ByEditTime - ? LoadoutSort.ByEditTime - : LoadoutSort.ByName, - descriptionsToDisplay: enumToStringUnion( - DescriptionOptions, - descriptionsToDisplay, - ) as Settings['descriptionsToDisplay'], - itemFeedWatermark: itemFeedWatermark.toString(), - customStats: customStatsFixed, - vaultWeaponGroupingStyle: - vaultWeaponGroupingStyle === StatelyVaultWeaponGroupingStyle.VaultWeaponGroupingStyle_Inline - ? VaultWeaponGroupingStyle.Inline - : VaultWeaponGroupingStyle.Lines, - vaultArmorGroupingStyle: - vaultArmorGroupingStyle === StatelyVaultWeaponGroupingStyle.VaultWeaponGroupingStyle_Inline - ? VaultWeaponGroupingStyle.Inline - : VaultWeaponGroupingStyle.Lines, - itemPopupTab: - itemPopupTab === StatelyItemPopupTab.ItemPopupTab_Overview - ? ItemPopupTab.Overview - : ItemPopupTab.Triage, - armorCompare: - armorCompare === ArmorStatCompare.ArmorStatCompare_BaseMasterwork - ? 'baseMasterwork' - : armorCompare === ArmorStatCompare.ArmorStatCompare_Current - ? 'current' - : 'base', - ornamentDisplay: - ornamentDisplay === StatelyOrnamentDisplay.OrnamentDisplay_All - ? OrnamentDisplay.All - : OrnamentDisplay.None, - }; -} - -/** - * Convert a DIM Settings object to a Stately Settings item. - */ -export function convertToStatelyItem( - settings: Settings, - bungieMembershipId: number, -): StatelySettings { - const { - wishListSource, - characterOrder, - collapsedSections, - infusionDirection, - loParameters, - loStatConstraintsByClass, - customTotalStatsByClass, - loadoutSort, - descriptionsToDisplay, - itemFeedWatermark, - customStats, - vaultWeaponGroupingStyle, - vaultArmorGroupingStyle, - itemPopupTab, - itemSize, - charCol, - armorCompare, - ornamentDisplay, - ...rest - } = settings; - - const collapsedSectionsList = Object.entries(collapsedSections).map(([key, collapsed]) => ({ - key, - collapsed, - })); - - // TODO: In Postgres, because we store settings as JSON, I do a clever thing - // where I only store the diff vs. the default settings. That's harder to do - // in StatelyDB because the settings are protobufs. - const loParametersFixed = convertLoadoutParametersToStately(loParameters); - - const loStatConstraintsByClassList = Object.entries(loStatConstraintsByClass).map( - ([classType, constraints]) => ({ - classType: Number(classType), - constraints: statConstraintsToStately(constraints), - }), - ); - - const customStatsFixed = customStats.map((c) => { - const { class: klass, statHash, weights, ...rest } = c; - if (statHash >= 0 || !Number.isInteger(statHash)) { - throw new Error(`Expected fake custom stat hash to be negative integer, was ${statHash}`); - } - return client.create('CustomStatDef', { - class: klass as number, - statHash: -statHash, - weights: Object.entries(weights).map(([statHash, weight]) => ({ - statHash: Number(statHash), - weight: weight ?? 0, - })), - ...rest, - }); - }); - - const customTotalStatsList = Object.entries(customTotalStatsByClass) - .map(([classType, customStats]) => ({ - classType: Number(classType), - customStats, - })) - .filter((c) => c.customStats.length > 0); - - return client.create('Settings', { - ...rest, - memberId: BigInt(bungieMembershipId), - wishListSources: wishListSource.split('|'), - characterOrder: CharacterOrder[`CharacterOrder_${characterOrder}`], - collapsedSections: collapsedSectionsList, - itemSize: Math.min(Math.max(0, itemSize), 66), - charCol: Math.min(Math.max(2, charCol), 5), - infusionDirection: - infusionDirection === InfuseDirection.FUEL - ? StatelyInfuseDirection.InfuseDirection_Fuel - : StatelyInfuseDirection.InfuseDirection_Infuse, - loParameters: loParametersFixed, - loStatConstraintsByClass: loStatConstraintsByClassList, - customTotalStatsByClass: customTotalStatsList, - loadoutSort: - loadoutSort === LoadoutSort.ByEditTime - ? StatelyLoadoutSort.LoadoutSort_ByEditTime - : StatelyLoadoutSort.LoadoutSort_ByName, - descriptionsToDisplay: DescriptionOptions[`DescriptionOptions_${descriptionsToDisplay}`], - itemFeedWatermark: BigInt(itemFeedWatermark || '0'), - customStats: customStatsFixed, - vaultWeaponGroupingStyle: - vaultWeaponGroupingStyle === VaultWeaponGroupingStyle.Inline - ? StatelyVaultWeaponGroupingStyle.VaultWeaponGroupingStyle_Inline - : StatelyVaultWeaponGroupingStyle.VaultWeaponGroupingStyle_Lines, - vaultArmorGroupingStyle: - vaultArmorGroupingStyle === VaultWeaponGroupingStyle.Inline - ? StatelyVaultWeaponGroupingStyle.VaultWeaponGroupingStyle_Inline - : StatelyVaultWeaponGroupingStyle.VaultWeaponGroupingStyle_Lines, - itemPopupTab: - itemPopupTab === ItemPopupTab.Overview - ? StatelyItemPopupTab.ItemPopupTab_Overview - : StatelyItemPopupTab.ItemPopupTab_Triage, - ornamentDisplay: - ornamentDisplay === OrnamentDisplay.All - ? StatelyOrnamentDisplay.OrnamentDisplay_All - : StatelyOrnamentDisplay.OrnamentDisplay_None, - armorCompare: - armorCompare === 'baseMasterwork' - ? ArmorStatCompare.ArmorStatCompare_BaseMasterwork - : armorCompare === 'current' - ? ArmorStatCompare.ArmorStatCompare_Current - : ArmorStatCompare.ArmorStatCompare_Base, - }); -} - -/** - * Get existing settings for update purposes. - */ -export async function getSettingsForUpdate( - txn: Transaction, - bungieMembershipId: number, -): Promise { - const storedSettings = await txn.get('Settings', keyFor(bungieMembershipId)); - return storedSettings ? convertToDimSettings(storedSettings) : undefined; -} - -/** - * Update settings by putting the full settings object. - */ -export async function updateSettings( - txn: Transaction, - bungieMembershipId: number, - settings: Settings, -): Promise { - await txn.put(convertToStatelyItem(settings, bungieMembershipId)); -} - -/** - * Update specific key/value pairs within settings, leaving the rest alone. Creates the settings row if it doesn't exist. - */ -export async function setSetting( - txn: Transaction, - bungieMembershipId: number, - settings: Partial, -): Promise { - const existingSettings = await getSettingsForUpdate(txn, bungieMembershipId); - await updateSettings(txn, bungieMembershipId, { - ...defaultSettings, - ...existingSettings, - ...settings, - }); -} - -/** - * Delete the settings row for a particular user. - */ -export async function deleteSettings(bungieMembershipId: number): Promise { - return client.del(keyFor(bungieMembershipId)); -} diff --git a/api/stately/stately-utils.test.ts b/api/stately/stately-utils.test.ts deleted file mode 100644 index 7e500036..00000000 --- a/api/stately/stately-utils.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { - batches, - BigIntToNumber, - bigIntToNumber, - clearValue, - fromStatelyUUID, - listToMap, - NumberToBigInt, - numberToBigInt, - stripDefaults, -} from './stately-utils.js'; - -describe('bigIntToNumber', () => { - it('converts bigints to numbers', () => { - const input = { - foo: 1n, - bar: { - baz: '3', - qux: 4n, - }, - } as const; - - const output: BigIntToNumber = { - foo: 1, - bar: { - baz: '3', - qux: 4, - }, - }; - - expect(bigIntToNumber(input)).toEqual(output); - }); - - it('converts an empty list to an empty list', () => { - const input: { key: string; value: string }[] = []; - const output = bigIntToNumber(input); - const expected: { key: string; value: string }[] = []; - - expect(output).toEqual(expected); - }); -}); - -describe('numberToBigInt', () => { - it('converts numbers to bigints', () => { - const input = { - foo: 1, - bar: { - baz: '3', - qux: 4, - }, - } as const; - - const output: NumberToBigInt = { - foo: 1n, - bar: { - baz: '3', - qux: 4n, - }, - }; - - expect(numberToBigInt(input)).toEqual(output); - }); - - it('converts an empty list to an empty list', () => { - const input: { key: string; value: string }[] = []; - const output = numberToBigInt(input); - const expected: { key: string; value: string }[] = []; - - expect(output).toEqual(expected); - }); -}); - -describe('listToMap', () => { - it('converts a list to a map', () => { - const input = [ - { key: 'foo', value: 1 }, - { key: 'bar', value: 2 }, - ]; - const output = listToMap('key', 'value', input); - const expected = { - foo: 1, - bar: 2, - }; - expect(output).toEqual(expected); - }); - - it('converts a list with non string keys to a map', () => { - const input = [ - { key: 3, value: 'bar' }, - { key: 4, value: 'baz' }, - ]; - const output = listToMap('key', 'value', input); - const expected = { - '3': 'bar', - '4': 'baz', - }; - expect(output).toEqual(expected); - }); - - it('converts an emptry list to an empty map', () => { - const input: { key: string; value: string }[] = []; - const output = listToMap('key', 'value', input); - const expected: { [key: string]: string } = {}; - expect(output).toEqual(expected); - }); -}); - -describe('stripDefaults', () => { - it('strips falsy values out of objects', () => { - const input = { - foo: 1, - bar: undefined, - baz: null, - qux: 0, - qup: '', - quint: 'hey', - quop: true, - } as const; - - const output = { - foo: 1, - quint: 'hey', - quop: true, - }; - - expect(stripDefaults(input)).toEqual(output); - }); -}); - -describe('clearValue', () => { - it('returns "clear" for null or empty strings', () => { - expect(clearValue(null)).toBe('clear'); - expect(clearValue('')).toBe('clear'); - }); - - it('returns null for undefined', () => { - expect(clearValue(undefined)).toBe(null); - }); - - it('returns the input for other values', () => { - expect(clearValue('foo')).toBe('foo'); - }); -}); - -describe('batches', () => { - for (const size of [0, 1, 25, 35, 77]) { - it(`batches an array of length ${size}`, () => { - const input = Array.from(new Array(size).fill(1)); - let num = 0; - for (const batch of batches(input)) { - num += batch.length; - expect(batch.length).toBeLessThanOrEqual(50); - } - expect(num).toBe(size); - }); - } -}); - -describe('fromStatelyUUID', () => { - it('converts a Stately UUID to a string', () => { - expect(fromStatelyUUID('_XBlcIuJTLCITKFKCQEJPQ')).toBe('fd706570-8b89-4cb0-884c-a14a0901093d'); - }); - it('passes through a string UUID', () => { - expect(fromStatelyUUID('fd706570-8b89-4cb0-884c-a14a0901093d')).toBe( - 'fd706570-8b89-4cb0-884c-a14a0901093d', - ); - }); -}); diff --git a/api/stately/stately-utils.ts b/api/stately/stately-utils.ts deleted file mode 100644 index 70a8c367..00000000 --- a/api/stately/stately-utils.ts +++ /dev/null @@ -1,228 +0,0 @@ -// Utilities for dealing with Stately Items (protobufs) and other Stately-specific utilities. - -import { Transaction as TXN } from '@stately-cloud/client'; -import { mapValues } from 'es-toolkit'; -import { AllItemTypes, itemTypeToSchema } from './generated/index.js'; - -export type Transaction = TXN; - -/** Recursively convert bigints to regular numbers in an object. */ -type ObjectBigIntToNumber = { - [K in keyof T]: BigIntToNumber; -}; - -/** Recursively convert bigints to regular numbers in an object. */ -export type BigIntToNumber = T extends bigint - ? number - : T extends object - ? ObjectBigIntToNumber - : T extends (infer K)[] - ? BigIntToNumber[] - : T; - -/** Recursively convert bigints to regular numbers in an object. */ -export function bigIntToNumber(value: T): BigIntToNumber { - if (typeof value === 'bigint') { - if (value > Number.MAX_SAFE_INTEGER) { - throw new Error(`BigInt value ${value} is too large to convert to a number`); - } - return Number(value) as BigIntToNumber; - } else if (Array.isArray(value)) { - return value.map(bigIntToNumber) as BigIntToNumber; - } else if (typeof value === 'object' && value !== null) { - return mapValues(value, bigIntToNumber) as BigIntToNumber; - } - return value as BigIntToNumber; -} - -/** Recursively convert bigints to regular numbers in an object. */ -type ObjectNumberToBigInt = { - [K in keyof T]: NumberToBigInt; -}; - -/** Recursively convert bigints to regular numbers in an object. */ -export type NumberToBigInt = T extends number - ? bigint - : T extends object - ? ObjectNumberToBigInt - : T extends (infer K)[] - ? NumberToBigInt[] - : T; - -/** Recursively convert numbers to bigints in an object. */ -export function numberToBigInt(value: T): NumberToBigInt { - if (typeof value === 'number') { - return BigInt(value) as NumberToBigInt; - } else if (Array.isArray(value)) { - return value.map(numberToBigInt) as NumberToBigInt; - } else if (typeof value === 'object' && value !== null) { - return mapValues(value, numberToBigInt) as NumberToBigInt; - } - return value as NumberToBigInt; -} - -/** Strip the protobuf-es $typeName field from a top-level object. */ -export function stripTypeName>(data: T): Omit { - const { $typeName, ...rest } = data; - return rest; -} - -/** Stately doesn't have maps yet, so we have represented them as lists. */ -export function listToMap< - Obj extends object, - KeyProp extends keyof Obj, - ValueProp extends keyof Obj, ->( - keyProp: KeyProp, - valProp: ValueProp, - list: Obj[], -): { - [key: string]: Obj[ValueProp]; -} { - return Object.fromEntries(list.map((s) => [s[keyProp], s[valProp]])) as { - [key: string]: Obj[ValueProp]; - }; -} - -/** - * Extract the string name (without the prefix) from a Stately enum. - */ -export function enumToStringUnion(e: Record, v: keyof typeof e): string { - return e[v].replace(/.*_/, ''); -} - -/** - * Convert a Stately enum to a DIM enum by stripping the prefix off it and then - * looking up the name in the output enum. - */ -export function convertEnum( - e: Record, - v: keyof typeof e, - outputEnum: Record, -): number { - return outputEnum[e[v].replace(/.*_/, '')]; -} - -/** - * Strips falsy values out of objects. - */ -export function stripDefaults>(data: T): Partial { - return Object.entries(data).reduce>((result, [key, value]) => { - if (value || value === false) { - result[key] = value; - } - return result; - }, {}) as unknown as Partial; -} - -/** - * If the value is explicitly set to null or empty string, we return "clear" which will remove the value from the database. - * If it's undefined we return null, which will preserve the existing value. - * If it's set, we'll return the input which will update the existing value. - */ -export function clearValue(val: T | null | undefined): T | 'clear' | null { - if (val === null || val?.length === 0) { - return 'clear'; - } else if (!val) { - return null; - } else { - return val; - } -} - -const STATELY_MAX_BATCH_SIZE = 50; - -/** - * Yield batches of no more than STATELY_MAX_BATCH_SIZE items from an array. - * Otherwise you'll get an error from Stately batch APIs. - */ -export function* batches(input: T[]): Generator { - const numBatches = Math.ceil(input.length / STATELY_MAX_BATCH_SIZE); - for (let i = 0; i < numBatches; i += 1) { - yield input.slice(i * STATELY_MAX_BATCH_SIZE, (i + 1) * STATELY_MAX_BATCH_SIZE); - } -} - -export function parseKeyPath(keyPath: string): { ns: string; id: string }[] { - if (!keyPath.startsWith('/')) { - throw new Error(`Invalid keyPath ${keyPath}`); - } - return keyPath - .slice(1) - .split('/') - .map((p) => { - const splitIndex = p.indexOf('-'); - const ns = p.slice(0, splitIndex); - const id = p.slice(splitIndex + 1); - return { ns, id }; - }); -} - -/** Convert a UUID from a Stately key path into a string-form UUID. */ -export function fromStatelyUUID(id: string): string { - if (id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) { - // It's already in that format - return id; - } - const bytes = Buffer.from(id, 'base64'); - return stringifyUUID(new Uint8Array(bytes)); -} - -/** - * Convert array of 16 byte values to UUID string format of the form: - * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX - */ -const byteToHex: string[] = []; - -for (let i = 0; i < 256; ++i) { - byteToHex.push((i + 0x100).toString(16).slice(1)); -} - -// Copied from uuid package but without validation -export function stringifyUUID(arr: Uint8Array, offset = 0) { - // Note: Be careful editing this code! It's been tuned for performance - // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434 - return `${ - byteToHex[arr[offset + 0]] + - byteToHex[arr[offset + 1]] + - byteToHex[arr[offset + 2]] + - byteToHex[arr[offset + 3]] - }-${byteToHex[arr[offset + 4]]}${byteToHex[arr[offset + 5]]}-${byteToHex[arr[offset + 6]]}${ - byteToHex[arr[offset + 7]] - }-${byteToHex[arr[offset + 8]]}${byteToHex[arr[offset + 9]]}-${byteToHex[arr[offset + 10]]}${ - byteToHex[arr[offset + 11]] - }${byteToHex[arr[offset + 12]]}${byteToHex[arr[offset + 13]]}${ - byteToHex[arr[offset + 14]] - }${byteToHex[arr[offset + 15]]}`; -} - -// This is a copy of the UUID parsing code from the uuid package, but without -// the validation - I don't really care whether it's a perfectly valid UUID, -// just that it's 16 bytes. -export function parseUUID(uuid: string): Uint8Array { - let v; - const arr = new Uint8Array(16); // Parse ########-....-....-....-............ - - arr[0] = (v = parseInt(uuid.slice(0, 8), 16)) >>> 24; - arr[1] = (v >>> 16) & 0xff; - arr[2] = (v >>> 8) & 0xff; - arr[3] = v & 0xff; // Parse ........-####-....-....-............ - - arr[4] = (v = parseInt(uuid.slice(9, 13), 16)) >>> 8; - arr[5] = v & 0xff; // Parse ........-....-####-....-............ - - arr[6] = (v = parseInt(uuid.slice(14, 18), 16)) >>> 8; - arr[7] = v & 0xff; // Parse ........-....-....-####-............ - - arr[8] = (v = parseInt(uuid.slice(19, 23), 16)) >>> 8; - arr[9] = v & 0xff; // Parse ........-....-....-....-############ - // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) - - arr[10] = ((v = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff; - arr[11] = (v / 0x100000000) & 0xff; - arr[12] = (v >>> 24) & 0xff; - arr[13] = (v >>> 16) & 0xff; - arr[14] = (v >>> 8) & 0xff; - arr[15] = v & 0xff; - return arr; -} diff --git a/api/stately/triumphs-queries.test.ts b/api/stately/triumphs-queries.test.ts deleted file mode 100644 index ad8a4939..00000000 --- a/api/stately/triumphs-queries.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { client } from './client.js'; -import { - deleteAllTrackedTriumphs, - getTrackedTriumphsForProfile, - trackUntrackTriumphs, -} from './triumphs-queries.js'; - -const platformMembershipId = '213512057'; - -beforeEach(async () => deleteAllTrackedTriumphs(platformMembershipId)); - -async function trackTriumph(platformMembershipId: string, triumphHash: number) { - return client.transaction(async (txn) => { - await trackUntrackTriumphs(txn, platformMembershipId, [ - { recordHash: triumphHash, tracked: true }, - ]); - }); -} -async function unTrackTriumph(platformMembershipId: string, triumphHash: number) { - return client.transaction(async (txn) => { - await trackUntrackTriumphs(txn, platformMembershipId, [ - { recordHash: triumphHash, tracked: false }, - ]); - }); -} - -it('can track a triumph where none was tracked before', async () => { - await trackTriumph(platformMembershipId, 3851137658); - - const triumphs = (await getTrackedTriumphsForProfile(platformMembershipId)).triumphs; - expect(triumphs[0]).toEqual(3851137658); -}); - -it('can track a triumph that was already tracked', async () => { - await trackTriumph(platformMembershipId, 3851137658); - - await trackTriumph(platformMembershipId, 3851137658); - - const triumphs = (await getTrackedTriumphsForProfile(platformMembershipId)).triumphs; - expect(triumphs[0]).toEqual(3851137658); -}); - -it('can untrack a triumph', async () => { - await trackTriumph(platformMembershipId, 3851137658); - - await unTrackTriumph(platformMembershipId, 3851137658); - - const triumphs = (await getTrackedTriumphsForProfile(platformMembershipId)).triumphs; - expect(triumphs.length).toEqual(0); -}); diff --git a/api/stately/triumphs-queries.ts b/api/stately/triumphs-queries.ts deleted file mode 100644 index 3d215e38..00000000 --- a/api/stately/triumphs-queries.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { keyPath, ListToken } from '@stately-cloud/client'; -import { partition } from 'es-toolkit'; -import { getProfile } from './bulk-queries.js'; -import { client } from './client.js'; -import { batches, Transaction } from './stately-utils.js'; - -export function keyFor(platformMembershipId: string | bigint, triumphHash: number) { - return keyPath`/p-${BigInt(platformMembershipId)}/d-2/triumph-${triumphHash}`; -} - -/** - * Get all of the tracked triumphs for a particular platformMembershipId. - */ -export async function getTrackedTriumphsForProfile( - platformMembershipId: string, -): Promise<{ triumphs: number[]; token: ListToken }> { - const { profile, token } = await getProfile(platformMembershipId, 2, '/triumph'); - return { triumphs: profile.triumphs ?? [], token }; -} - -export async function trackUntrackTriumphs( - txn: Transaction, - platformMembershipId: string, - triumphs: { - recordHash: number; - tracked: boolean; - }[], -): Promise { - const [trackedTriumphs, untrackedTriumphs] = partition(triumphs, (t) => t.tracked); - if (untrackedTriumphs.length) { - await txn.del( - ...untrackedTriumphs.map(({ recordHash }) => keyFor(platformMembershipId, recordHash)), - ); - } - if (trackedTriumphs.length) { - await txn.putBatch( - ...trackedTriumphs.map(({ recordHash }) => - client.create('Triumph', { - recordHash, - profileId: BigInt(platformMembershipId), - destinyVersion: 2, - }), - ), - ); - } -} - -export function importTriumphs(platformMembershipId: string, recordHashes: number[]) { - return recordHashes.map((recordHash) => - client.create('Triumph', { - recordHash, - profileId: BigInt(platformMembershipId), - destinyVersion: 2, - }), - ); -} - -/** - * Delete all item annotations for a user (on all platforms). - */ -export async function deleteAllTrackedTriumphs(platformMembershipId: string): Promise { - const triumphs = (await getTrackedTriumphsForProfile(platformMembershipId)).triumphs; - if (!triumphs.length) { - return; - } - for (const batch of batches(triumphs)) { - await client.del(...batch.map((recordHash) => keyFor(platformMembershipId, recordHash))); - } -} diff --git a/eslint.config.js b/eslint.config.js index b6c65884..1a8d4f1a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,12 +22,7 @@ export default tseslint.config( { name: 'sonarjs/recommended', ...sonarjs.configs.recommended }, { name: 'global ignores', - ignores: [ - '*.test.ts', - 'api/migrations/*', - 'api/stately/generated/*.js', - 'api/test/postgres.*.mjs', - ], + ignores: ['*.test.ts', 'api/migrations/*', 'api/test/postgres.*.mjs'], }, { name: 'dim-api-custom', diff --git a/kubernetes/README.md b/kubernetes/README.md index 65c4f955..fbf93e76 100644 --- a/kubernetes/README.md +++ b/kubernetes/README.md @@ -19,93 +19,3 @@ An Ingress Controller that uses NGINX. Deployed only on DigitalOcean, by followi ## DIM API Our NodeJS service. Deployed via dim-api-\*.yaml. - -## Stately Backfill Job - -Run a one-time migration scan from StatelyDB into Postgres with -`dim-api-stately-backfill-job.yaml`. - -The job is resumable across pod restarts by storing scan token state in a PVC. - -One-pod parallelization options: - -- `BACKFILL_PARALLEL_SEGMENTS`: Number of segment workers to run concurrently in one pod. -- `BACKFILL_TOTAL_SEGMENTS`: Optional explicit total segment count (defaults to `BACKFILL_PARALLEL_SEGMENTS`). -- `BACKFILL_SEGMENT_INDEX`: Optional explicit segment index for single-segment worker mode. - -For one-pod parallel mode, set only `BACKFILL_PARALLEL_SEGMENTS` to the desired worker count and leave -`BACKFILL_SEGMENT_INDEX` unset. - -Segment workers automatically use segment-specific token files derived from `BACKFILL_TOKEN_PATH`. - -Required environment inputs: - -- `dim-api-config` ConfigMap (same values used by API deployment) -- `dim-api-secret` keys: - - `pg_password` - - `stately_access_key` - - `stately_store_id` - -Build and push image: - -```sh -COMMITHASH=$(git rev-parse HEAD) -docker buildx build --platform linux/amd64 --push -t destinyitemmanager/dim-api:$COMMITHASH . -``` - -Apply the job manifest: - -```sh -mkdir -p deployment -cp kubernetes/dim-api-stately-backfill-job.yaml deployment/ -sed -i'' -e "s/\$COMMITHASH/$COMMITHASH/" deployment/dim-api-stately-backfill-job.yaml -kubectl apply -f deployment/dim-api-stately-backfill-job.yaml -``` - -Inspect progress: - -```sh -kubectl logs -f job/dim-api-stately-backfill -``` - -If re-running after completion, delete and recreate the job: - -```sh -kubectl delete job dim-api-stately-backfill -kubectl apply -f deployment/dim-api-stately-backfill-job.yaml -``` - -## Stately to Postgres Migration Worker - -Run continuous workers that claim `migration_state` rows in `Stately` state and -migrate each claimed profile to Postgres. - -Use `dim-api-migration-worker-deployment.yaml`. - -This is a Deployment (not a Job) so you can scale replicas for parallelism. Each -replica safely claims work via optimistic state transitions in `migration_state` -(`Stately` -> `MigratingToPostgres`). - -Worker environment knobs: - -- `MIGRATION_WORKER_BATCH_SIZE`: Number of rows to claim per poll (default `25`). -- `MIGRATION_WORKER_IDLE_DELAY_MS`: Sleep delay when no work is available (default `5000`). -- `MIGRATION_WORKER_BETWEEN_USERS_DELAY_MS`: Delay between processing users in a claimed batch (default `100`). - -Apply the worker deployment: - -```sh -pnpm run deploy:migration-worker -``` - -Scale workers: - -```sh -kubectl scale deployment dim-api-migration-worker --replicas=4 -``` - -Inspect worker logs: - -```sh -kubectl logs -f deployment/dim-api-migration-worker -``` diff --git a/kubernetes/deploy-migration-worker.sh b/kubernetes/deploy-migration-worker.sh deleted file mode 100755 index 47be273c..00000000 --- a/kubernetes/deploy-migration-worker.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -ex - -ROOT=$(git rev-parse --show-toplevel) -COMMITHASH=${GITHUB_SHA:-$(git rev-parse HEAD)} -IMAGE="destinyitemmanager/dim-api:$COMMITHASH" - -rm -rf dist && pnpm build:api && docker buildx build --platform linux/amd64 --push -t "$IMAGE" "$ROOT" - -mkdir -p "$ROOT/deployment" -cp "$ROOT/kubernetes/dim-api-migration-worker-deployment.yaml" "$ROOT/deployment" - -sed -i'' -e "s/\$COMMITHASH/$COMMITHASH/" "$ROOT/deployment/dim-api-migration-worker-deployment.yaml" - -kubectl apply -f "$ROOT/deployment/dim-api-migration-worker-deployment.yaml" -kubectl rollout status deployment/dim-api-migration-worker - -rm -rf "$ROOT/deployment" diff --git a/kubernetes/deploy-stately-backfill-job.sh b/kubernetes/deploy-stately-backfill-job.sh deleted file mode 100755 index b6ad9edf..00000000 --- a/kubernetes/deploy-stately-backfill-job.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -ex - -ROOT=$(git rev-parse --show-toplevel) -COMMITHASH=${GITHUB_SHA:-$(git rev-parse HEAD)} -IMAGE="destinyitemmanager/dim-api:$COMMITHASH" - -rm -rf dist && pnpm build:api && docker buildx build --platform linux/amd64 --push -t "$IMAGE" "$ROOT" - -mkdir -p "$ROOT/deployment" -cp "$ROOT/kubernetes/dim-api-stately-backfill-job.yaml" "$ROOT/deployment" - -sed -i'' -e "s/\$COMMITHASH/$COMMITHASH/" "$ROOT/deployment/dim-api-stately-backfill-job.yaml" - -# Job specs are immutable in Kubernetes; delete the old Job before applying updates. -kubectl delete job dim-api-stately-backfill --ignore-not-found=true -kubectl apply -f "$ROOT/deployment/dim-api-stately-backfill-job.yaml" - -rm -rf "$ROOT/deployment" diff --git a/kubernetes/dim-api-deployment.yaml b/kubernetes/dim-api-deployment.yaml index 098ee9e3..e24868ed 100644 --- a/kubernetes/dim-api-deployment.yaml +++ b/kubernetes/dim-api-deployment.yaml @@ -59,11 +59,6 @@ spec: secretKeyRef: name: dim-api-secret key: jwt_secret - - name: STATELY_ACCESS_KEY - valueFrom: - secretKeyRef: - name: dim-api-secret - key: stately_access_key - name: GITHUB_CLIENT_SECRET valueFrom: secretKeyRef: diff --git a/kubernetes/dim-api-migration-worker-deployment.yaml b/kubernetes/dim-api-migration-worker-deployment.yaml deleted file mode 100644 index 8c900aa4..00000000 --- a/kubernetes/dim-api-migration-worker-deployment.yaml +++ /dev/null @@ -1,56 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: dim-api-migration-worker - labels: - app: dim-api-migration-worker -spec: - replicas: 1 - selector: - matchLabels: - app: dim-api-migration-worker - template: - metadata: - labels: - app: dim-api-migration-worker - annotations: - cluster-autoscaler.kubernetes.io/safe-to-evict: 'true' - spec: - restartPolicy: Always - containers: - - name: dim-api-migration-worker - image: destinyitemmanager/dim-api:$COMMITHASH - imagePullPolicy: Always - command: ['node'] - args: - - --enable-source-maps - - api/stately/init/migrate-stately-to-postgres.js - env: - - name: NODE_ENV - value: production - - name: MIGRATION_WORKER_BATCH_SIZE - value: '25' - - name: MIGRATION_WORKER_IDLE_DELAY_MS - value: '5000' - - name: MIGRATION_WORKER_BETWEEN_USERS_DELAY_MS - value: '100' - - name: PGPASSWORD - valueFrom: - secretKeyRef: - name: dim-api-secret - key: pg_password - - name: STATELY_ACCESS_KEY - valueFrom: - secretKeyRef: - name: dim-api-secret - key: stately_access_key - envFrom: - - configMapRef: - name: dim-api-config - resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: '1' - memory: 1Gi diff --git a/kubernetes/dim-api-stately-backfill-job.yaml b/kubernetes/dim-api-stately-backfill-job.yaml deleted file mode 100644 index 89b6ce9d..00000000 --- a/kubernetes/dim-api-stately-backfill-job.yaml +++ /dev/null @@ -1,92 +0,0 @@ -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: dim-api-stately-backfill-token - labels: - app: dim-api-stately-backfill -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: dim-api-stately-backfill - labels: - app: dim-api-stately-backfill -spec: - completions: 1 - parallelism: 1 - backoffLimit: 12 - template: - metadata: - labels: - app: dim-api-stately-backfill - annotations: - cluster-autoscaler.kubernetes.io/safe-to-evict: 'false' - spec: - securityContext: - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - fsGroupChangePolicy: OnRootMismatch - restartPolicy: OnFailure - containers: - - name: stately-backfill - image: destinyitemmanager/dim-api:$COMMITHASH - imagePullPolicy: IfNotPresent - command: ['node'] - args: - - --enable-source-maps - - api/stately/init/stately-backfill.js - env: - - name: NODE_ENV - value: production - - name: BACKFILL_PARALLEL_SEGMENTS - value: '4' - - name: BACKFILL_TOKEN_PATH - value: /var/lib/dim-api-backfill/backfill-token2.bin - - name: BACKFILL_RETRY_MAX_ATTEMPTS - value: '12' - - name: BACKFILL_RETRY_BASE_DELAY_MS - value: '1000' - - name: BACKFILL_RETRY_MAX_DELAY_MS - value: '30000' - - name: BACKFILL_PROFILE_BATCH_SIZE - value: '1000' - - name: BACKFILL_SETTINGS_BATCH_SIZE - value: '50' - - name: BACKFILL_SHARE_BATCH_SIZE - value: '50' - - name: BACKFILL_CONTINUATION_DELAY_MS - value: '1000' - - name: PGPASSWORD - valueFrom: - secretKeyRef: - name: dim-api-secret - key: pg_password - - name: STATELY_ACCESS_KEY - valueFrom: - secretKeyRef: - name: dim-api-secret - key: stately_access_key - envFrom: - - configMapRef: - name: dim-api-config - resources: - requests: - cpu: 200m - memory: 256Mi - limits: - cpu: '1' - memory: 1Gi - volumeMounts: - - name: token-state - mountPath: /var/lib/dim-api-backfill - volumes: - - name: token-state - persistentVolumeClaim: - claimName: dim-api-stately-backfill-token diff --git a/package.json b/package.json index 71e8cf62..5bee8ba1 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,8 @@ "type": "module", "scripts": { "start": "rm -rf dist && pnpm build:api && node -r dotenv/config dist/api/index.js", - "build:api": "mkdir -p ./dist/api/stately/generated && cp -r ./api/stately/generated/*.js ./dist/api/stately/generated && tsc -p ./api/tsconfig.json", + "build:api": "tsc -p ./api/tsconfig.json", "deploy": "kubernetes/deploy.sh", - "deploy:stately-backfill": "kubernetes/deploy-stately-backfill-job.sh", - "deploy:migration-worker": "kubernetes/deploy-migration-worker.sh", "docker:build": "rm -rf dist && pnpm build:api && docker build -t destinyitemmanager/dim-api .", "docker:run": "docker run -p 3000:3000 destinyitemmanager/dim-api:latest", "docker:push": "docker push destinyitemmanager/dim-api:latest", @@ -25,9 +23,6 @@ "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --detectOpenHandles --verbose --coverage --forceExit", "test:watch": "jest --watch", "eslint-inspect": "pnpm dlx @eslint/config-inspector", - "generate": "stately schema generate -l js --schema-id 8030842688320564 api/stately/generated && prettier \"api/stately/**/*.{js,ts}\" --write", - "generate-preview": "stately schema generate -l js --preview api/stately/schema/index.ts api/stately/generated && prettier \"api/stately/**/*.{js,ts}\" --write", - "publish-schema": "stately schema put --allow-backwards-incompatible --schema-id 8030842688320564 api/stately/schema/index.ts && pnpm run generate", "dim-api-types:build": "./build-dim-api-types.sh" }, "devDependencies": { @@ -41,8 +36,6 @@ "@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.1", "@sentry/cli": "^2.58.2", - "@stately-cloud/cli": "^0.97.0", - "@stately-cloud/schema": "^0.34.4", "@types/connect-pg-simple": "^7.0.3", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", @@ -80,7 +73,6 @@ "@octokit/rest": "^21.0.2", "@sentry/node": "^7.120.4", "@sentry/tracing": "^7.120.4", - "@stately-cloud/client": "^0.37.0", "bungie-api-ts": "^5.10.0", "connect-pg-simple": "^9.0.1", "cors": "^2.8.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d98873e6..7c8e0123 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ dependencies: '@sentry/tracing': specifier: ^7.120.4 version: 7.120.4 - '@stately-cloud/client': - specifier: ^0.37.0 - version: 0.37.0 bungie-api-ts: specifier: ^5.10.0 version: 5.10.0 @@ -118,12 +115,6 @@ devDependencies: '@sentry/cli': specifier: ^2.58.2 version: 2.58.2 - '@stately-cloud/cli': - specifier: ^0.97.0 - version: 0.97.0 - '@stately-cloud/schema': - specifier: ^0.34.4 - version: 0.34.4 '@types/connect-pg-simple': specifier: ^7.0.3 version: 7.0.3 @@ -1455,41 +1446,13 @@ packages: /@bufbuild/protobuf@2.10.1: resolution: {integrity: sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==} + dev: false /@colors/colors@1.5.0: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} dev: true - /@connectrpc/connect-node@2.1.1(@bufbuild/protobuf@2.10.1)(@connectrpc/connect@2.1.1): - resolution: {integrity: sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg==} - engines: {node: '>=20'} - peerDependencies: - '@bufbuild/protobuf': ^2.7.0 - '@connectrpc/connect': 2.1.1 - dependencies: - '@bufbuild/protobuf': 2.10.1 - '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.10.1) - dev: false - - /@connectrpc/connect-web@2.1.1(@bufbuild/protobuf@2.10.1)(@connectrpc/connect@2.1.1): - resolution: {integrity: sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==} - peerDependencies: - '@bufbuild/protobuf': ^2.7.0 - '@connectrpc/connect': 2.1.1 - dependencies: - '@bufbuild/protobuf': 2.10.1 - '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.10.1) - dev: false - - /@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.10.1): - resolution: {integrity: sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==} - peerDependencies: - '@bufbuild/protobuf': ^2.7.0 - dependencies: - '@bufbuild/protobuf': 2.10.1 - dev: false - /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -2694,34 +2657,6 @@ packages: '@sinonjs/commons': 3.0.1 dev: true - /@stately-cloud/cli@0.97.0: - resolution: {integrity: sha512-9tyIKiArCRQJhIPRwXlXnBeAZftCrtvP/fKf2zGHT4cj7CDGvVDgGLUdwCdnoUJiUN+hykOF+MNO8RrmosjY2w==} - engines: {node: '>=20'} - hasBin: true - requiresBuild: true - dev: true - - /@stately-cloud/client@0.37.0: - resolution: {integrity: sha512-ZSQoyF7UyhjSmIou7B0Keh8r2Wg0LtUYaw7Oojde8iTI2Bki/j5RYPSbZ7f9aCQogzZDo8kzkN6p/4F5QNpjJw==} - engines: {node: '>=20'} - dependencies: - '@bufbuild/protobuf': 2.10.1 - '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.10.1) - '@connectrpc/connect-node': 2.1.1(@bufbuild/protobuf@2.10.1)(@connectrpc/connect@2.1.1) - '@connectrpc/connect-web': 2.1.1(@bufbuild/protobuf@2.10.1)(@connectrpc/connect@2.1.1) - dev: false - - /@stately-cloud/schema@0.34.4: - resolution: {integrity: sha512-W2stQ3h49z3QxgRoouIX8J6XPkGWdxZR9LkKK6vLkMgfjJMAiwB1FoqzwYQ82kPiB4IyU6m66mvIeNrRC3NDYw==} - engines: {node: '>=20'} - hasBin: true - dependencies: - '@bufbuild/protobuf': 2.10.1 - fast-equals: 5.3.3 - tsx: 4.21.0 - typescript: 5.9.3 - dev: true - /@tsconfig/node10@1.0.12: resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} dev: true @@ -4198,11 +4133,6 @@ packages: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true - /fast-equals@5.3.3: - resolution: {integrity: sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==} - engines: {node: '>=6.0.0'} - dev: true - /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true