Skip to content

Commit e26076a

Browse files
authored
Merge pull request #264 from DestinyItemManager/sync-use
Use sync tokens if they're passed in
2 parents f3f2a83 + c39de6e commit e26076a

File tree

9 files changed

+386
-69
lines changed

9 files changed

+386
-69
lines changed

api/routes/profile.ts

Lines changed: 131 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as Sentry from '@sentry/node';
12
import { ListToken } from '@stately-cloud/client';
23
import express from 'express';
34
import asyncHandler from 'express-async-handler';
@@ -6,13 +7,28 @@ import { ApiApp } from '../shapes/app.js';
67
import { DestinyVersion } from '../shapes/general.js';
78
import { ProfileResponse } from '../shapes/profile.js';
89
import { UserInfo } from '../shapes/user.js';
9-
import { getProfile } from '../stately/bulk-queries.js';
10-
import { getItemAnnotationsForProfile as getItemAnnotationsForProfileStately } from '../stately/item-annotations-queries.js';
11-
import { getItemHashTagsForProfile as getItemHashTagsForProfileStately } from '../stately/item-hash-tags-queries.js';
12-
import { getLoadoutsForProfile as getLoadoutsForProfileStately } from '../stately/loadouts-queries.js';
13-
import { getSearchesForProfile as getSearchesForProfileStately } from '../stately/searches-queries.js';
14-
import { querySettings } from '../stately/settings-queries.js';
15-
import { getTrackedTriumphsForProfile as getTrackedTriumphsForProfileStately } from '../stately/triumphs-queries.js';
10+
import { getProfile, syncProfile } from '../stately/bulk-queries.js';
11+
import {
12+
getItemAnnotationsForProfile as getItemAnnotationsForProfileStately,
13+
syncItemAnnotations,
14+
} from '../stately/item-annotations-queries.js';
15+
import {
16+
getItemHashTagsForProfile as getItemHashTagsForProfileStately,
17+
syncItemHashTags,
18+
} from '../stately/item-hash-tags-queries.js';
19+
import {
20+
getLoadoutsForProfile as getLoadoutsForProfileStately,
21+
syncLoadouts,
22+
} from '../stately/loadouts-queries.js';
23+
import {
24+
getSearchesForProfile as getSearchesForProfileStately,
25+
syncSearches,
26+
} from '../stately/searches-queries.js';
27+
import { querySettings, syncSettings } from '../stately/settings-queries.js';
28+
import {
29+
getTrackedTriumphsForProfile as getTrackedTriumphsForProfileStately,
30+
syncTrackedTriumphs,
31+
} from '../stately/triumphs-queries.js';
1632
import { badRequest, checkPlatformMembershipId, isValidPlatformMembershipId } from '../utils.js';
1733

1834
type ProfileComponent = 'settings' | 'loadouts' | 'tags' | 'hashtags' | 'triumphs' | 'searches';
@@ -74,13 +90,31 @@ export const profileHandler = asyncHandler(async (req, res) => {
7490
return;
7591
}
7692

77-
const response = await statelyProfile(
78-
res,
79-
components,
80-
bungieMembershipId,
81-
platformMembershipId,
82-
destinyVersion,
83-
);
93+
const syncTokens = extractSyncToken(req.query.sync?.toString());
94+
95+
let response: ProfileResponse | undefined;
96+
try {
97+
response = await statelyProfile(
98+
res,
99+
components,
100+
bungieMembershipId,
101+
platformMembershipId,
102+
destinyVersion,
103+
syncTokens,
104+
);
105+
} catch (e) {
106+
Sentry.captureException(e, { extra: { syncTokens, components, platformMembershipId } });
107+
if (syncTokens) {
108+
// Start over without sync tokens
109+
response = await statelyProfile(
110+
res,
111+
components,
112+
bungieMembershipId,
113+
platformMembershipId,
114+
destinyVersion,
115+
);
116+
}
117+
}
84118

85119
if (!response) {
86120
return; // we've already responded
@@ -92,22 +126,50 @@ export const profileHandler = asyncHandler(async (req, res) => {
92126
res.send(response);
93127
});
94128

95-
// TODO: Probably could enable allowStale, since profiles are cached anyway
129+
function extractSyncToken(syncTokenParam: string | undefined) {
130+
if (syncTokenParam) {
131+
try {
132+
const tokenMap = JSON.parse(syncTokenParam) as { [component: string]: string };
133+
return Object.entries(tokenMap).reduce<{ [component: string]: Buffer }>(
134+
(acc, [component, token]) => {
135+
acc[component] = Buffer.from(token, 'base64');
136+
return acc;
137+
},
138+
{},
139+
);
140+
} catch (e) {
141+
Sentry.captureException(e, { extra: { syncTokenParam } });
142+
}
143+
}
144+
}
145+
96146
// TODO: It'd be nice to pass a signal in so we can abort all the parallel fetches
97147
async function statelyProfile(
98148
res: express.Response,
99149
components: ProfileComponent[],
100150
bungieMembershipId: number,
101151
platformMembershipId: string | undefined,
102152
destinyVersion: DestinyVersion,
153+
incomingSyncTokens?: { [component: string]: Buffer },
103154
) {
104-
const response: ProfileResponse = {};
155+
const response: ProfileResponse = {
156+
sync: Boolean(incomingSyncTokens),
157+
};
158+
const timerPrefix = response.sync ? 'profileSync' : 'profileStately';
159+
const counterPrefix = response.sync ? 'sync' : 'stately';
105160
const syncTokens: { [component: string]: string } = {};
106161
const addSyncToken = (name: string, token: ListToken) => {
107162
if (token.canSync) {
108163
syncTokens[name] = Buffer.from(token.tokenData).toString('base64');
109164
}
110165
};
166+
const getSyncToken = (name: string) => {
167+
const tokenData = incomingSyncTokens?.settings;
168+
if (incomingSyncTokens && !tokenData) {
169+
throw new Error(`Missing sync token: ${name}`);
170+
}
171+
return tokenData;
172+
};
111173

112174
// We'll accumulate promises and await them all at the end
113175
const promises: Promise<void>[] = [];
@@ -116,11 +178,13 @@ async function statelyProfile(
116178
promises.push(
117179
(async () => {
118180
const start = new Date();
119-
const { settings: storedSettings, token: settingsToken } =
120-
await querySettings(bungieMembershipId);
181+
const tokenData = getSyncToken('settings');
182+
const { settings: storedSettings, token: settingsToken } = tokenData
183+
? await syncSettings(tokenData)
184+
: await querySettings(bungieMembershipId);
121185
response.settings = storedSettings;
122186
addSyncToken('settings', settingsToken);
123-
metrics.timing('profileStately.settings', start);
187+
metrics.timing(`${timerPrefix}.settings`, start);
124188
})(),
125189
);
126190
}
@@ -133,17 +197,20 @@ async function statelyProfile(
133197
)
134198
) {
135199
const start = new Date();
136-
const { profile: profileResponse, token: profileToken } = await getProfile(
137-
platformMembershipId,
138-
destinyVersion,
139-
);
140-
metrics.timing('profileStately.allComponents', start);
200+
const tokenData = getSyncToken('profile');
201+
const { profile: profileResponse, token: profileToken } = tokenData
202+
? await syncProfile(tokenData)
203+
: await getProfile(platformMembershipId, destinyVersion);
204+
metrics.timing(`${timerPrefix}.allComponents`, start);
141205
await Promise.all(promises); // wait for settings
142-
metrics.timing('profile.loadouts.numReturned', profileResponse.loadouts?.length ?? 0);
143-
metrics.timing('profile.tags.numReturned', profileResponse.tags?.length ?? 0);
144-
metrics.timing('profile.hashtags.numReturned', profileResponse.itemHashTags?.length ?? 0);
145-
metrics.timing('profile.triumphs.numReturned', profileResponse.triumphs?.length ?? 0);
146-
metrics.timing('profile.searches.numReturned', profileResponse.searches?.length ?? 0);
206+
metrics.timing(`${counterPrefix}.loadouts.numReturned`, profileResponse.loadouts?.length ?? 0);
207+
metrics.timing(`${counterPrefix}.tags.numReturned`, profileResponse.tags?.length ?? 0);
208+
metrics.timing(
209+
`${counterPrefix}.hashtags.numReturned`,
210+
profileResponse.itemHashTags?.length ?? 0,
211+
);
212+
metrics.timing(`${counterPrefix}.triumphs.numReturned`, profileResponse.triumphs?.length ?? 0);
213+
metrics.timing(`${counterPrefix}.searches.numReturned`, profileResponse.searches?.length ?? 0);
147214
addSyncToken('profile', profileToken);
148215
response.syncToken = serializeSyncToken(syncTokens);
149216
return { ...response, ...profileResponse };
@@ -157,14 +224,15 @@ async function statelyProfile(
157224
promises.push(
158225
(async () => {
159226
const start = new Date();
160-
const { loadouts, token } = await getLoadoutsForProfileStately(
161-
platformMembershipId,
162-
destinyVersion,
163-
);
227+
const tokenData = getSyncToken('loadouts');
228+
const { loadouts, token, deletedLoadoutIds } = tokenData
229+
? await syncLoadouts(tokenData)
230+
: await getLoadoutsForProfileStately(platformMembershipId, destinyVersion);
164231
response.loadouts = loadouts;
232+
response.deletedLoadoutIds = deletedLoadoutIds;
165233
addSyncToken('loadouts', token);
166-
metrics.timing('profile.loadouts.numReturned', response.loadouts.length);
167-
metrics.timing('profileStately.loadouts', start);
234+
metrics.timing(`${counterPrefix}.loadouts.numReturned`, response.loadouts.length);
235+
metrics.timing(`${timerPrefix}.loadouts`, start);
168236
})(),
169237
);
170238
}
@@ -177,14 +245,15 @@ async function statelyProfile(
177245
promises.push(
178246
(async () => {
179247
const start = new Date();
180-
const { tags, token } = await getItemAnnotationsForProfileStately(
181-
platformMembershipId,
182-
destinyVersion,
183-
);
248+
const tokenData = getSyncToken('tags');
249+
const { tags, token, deletedTagsIds } = tokenData
250+
? await syncItemAnnotations(tokenData)
251+
: await getItemAnnotationsForProfileStately(platformMembershipId, destinyVersion);
184252
response.tags = tags;
253+
response.deletedTagsIds = deletedTagsIds;
185254
addSyncToken('tags', token);
186-
metrics.timing('profile.tags.numReturned', response.tags.length);
187-
metrics.timing('profileStately.tags', start);
255+
metrics.timing(`${counterPrefix}.tags.numReturned`, response.tags.length);
256+
metrics.timing(`${timerPrefix}.tags`, start);
188257
})(),
189258
);
190259
}
@@ -197,11 +266,15 @@ async function statelyProfile(
197266
promises.push(
198267
(async () => {
199268
const start = new Date();
200-
const { hashTags, token } = await getItemHashTagsForProfileStately(platformMembershipId);
269+
const tokenData = getSyncToken('hashtags');
270+
const { hashTags, token, deletedItemHashTagHashes } = tokenData
271+
? await syncItemHashTags(tokenData)
272+
: await getItemHashTagsForProfileStately(platformMembershipId);
201273
response.itemHashTags = hashTags;
274+
response.deletedItemHashTagHashes = deletedItemHashTagHashes;
202275
addSyncToken('hashtags', token);
203-
metrics.timing('profile.hashtags.numReturned', response.itemHashTags.length);
204-
metrics.timing('profileStately.hashtags', start);
276+
metrics.timing(`${counterPrefix}.hashtags.numReturned`, response.itemHashTags.length);
277+
metrics.timing(`${timerPrefix}.hashtags`, start);
205278
})(),
206279
);
207280
}
@@ -214,11 +287,15 @@ async function statelyProfile(
214287
promises.push(
215288
(async () => {
216289
const start = new Date();
217-
const { triumphs, token } = await getTrackedTriumphsForProfileStately(platformMembershipId);
290+
const tokenData = getSyncToken('triumphs');
291+
const { triumphs, token, deletedTriumphs } = tokenData
292+
? await syncTrackedTriumphs(tokenData)
293+
: await getTrackedTriumphsForProfileStately(platformMembershipId);
218294
response.triumphs = triumphs;
295+
response.deletedTriumphs = deletedTriumphs;
219296
addSyncToken('triumphs', token);
220-
metrics.timing('profile.triumphs.numReturned', response.triumphs.length);
221-
metrics.timing('profileStately.triumphs', start);
297+
metrics.timing(`${counterPrefix}.triumphs.numReturned`, response.triumphs.length);
298+
metrics.timing(`${timerPrefix}.triumphs`, start);
222299
})(),
223300
);
224301
}
@@ -231,14 +308,15 @@ async function statelyProfile(
231308
promises.push(
232309
(async () => {
233310
const start = new Date();
234-
const { searches, token } = await getSearchesForProfileStately(
235-
platformMembershipId,
236-
destinyVersion,
237-
);
311+
const tokenData = getSyncToken('searches');
312+
const { searches, token, deletedSearchHashes } = tokenData
313+
? await syncSearches(tokenData)
314+
: await getSearchesForProfileStately(platformMembershipId, destinyVersion);
238315
response.searches = searches;
316+
response.deletedSearchHashes = deletedSearchHashes;
239317
addSyncToken('searches', token);
240-
metrics.timing('profile.searches.numReturned', response.searches.length);
241-
metrics.timing('profileStately.searches', start);
318+
metrics.timing(`${counterPrefix}.searches.numReturned`, response.searches.length);
319+
metrics.timing(`${timerPrefix}.searches`, start);
242320
})(),
243321
);
244322
}

api/stately/bulk-queries.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { convertItemHashTag, keyFor as hashTagKeyFor } from './item-hash-tags-qu
1212
import { convertLoadoutFromStately, keyFor as loadoutKeyFor } from './loadouts-queries.js';
1313
import { convertSearchFromStately, keyFor as searchKeyFor } from './searches-queries.js';
1414
import { deleteSettings, getSettings } from './settings-queries.js';
15-
import { batches } from './stately-utils.js';
15+
import { batches, parseKeyPath } from './stately-utils.js';
1616
import { keyFor as triumphKeyFor } from './triumphs-queries.js';
1717

1818
/**
@@ -212,3 +212,69 @@ export async function getProfile(
212212

213213
return { profile: response, token: iter.token! };
214214
}
215+
216+
export async function syncProfile(
217+
tokenData: Buffer,
218+
): Promise<{ profile: ProfileResponse; token: ListToken }> {
219+
const response: ProfileResponse = {
220+
sync: true,
221+
};
222+
223+
// Now get all the data under the profile in one listing.
224+
const iter = client.syncList(tokenData);
225+
for await (const change of iter) {
226+
switch (change.type) {
227+
case 'reset': {
228+
response.sync = false;
229+
break;
230+
}
231+
case 'changed': {
232+
const item = change.item;
233+
if (client.isType(item, 'Triumph')) {
234+
(response.triumphs ??= []).push(item.recordHash);
235+
} else if (client.isType(item, 'ItemAnnotation')) {
236+
(response.tags ??= []).push(convertItemAnnotation(item));
237+
} else if (client.isType(item, 'ItemHashTag')) {
238+
(response.itemHashTags ??= []).push(convertItemHashTag(item));
239+
} else if (client.isType(item, 'Loadout')) {
240+
(response.loadouts ??= []).push(convertLoadoutFromStately(item));
241+
} else if (client.isType(item, 'Search')) {
242+
(response.searches ??= []).push(convertSearchFromStately(item));
243+
}
244+
break;
245+
}
246+
case 'deleted': {
247+
const keyPath = parseKeyPath(change.keyPath);
248+
if (keyPath[0].ns === 'p') {
249+
const lastPart = keyPath.at(-1)!;
250+
const idStr = lastPart.id;
251+
const type = lastPart.ns;
252+
switch (type) {
253+
case 'triumph': {
254+
(response.deletedTriumphs ??= []).push(Number(idStr));
255+
break;
256+
}
257+
case 'ia': {
258+
(response.deletedTagsIds ??= []).push(idStr);
259+
break;
260+
}
261+
case 'ia-hash': {
262+
(response.deletedItemHashTagHashes ??= []).push(Number(idStr));
263+
break;
264+
}
265+
case 'loadout': {
266+
(response.deletedLoadoutIds ??= []).push(idStr);
267+
break;
268+
}
269+
case 'search': {
270+
(response.deletedSearchHashes ??= []).push(idStr);
271+
break;
272+
}
273+
}
274+
}
275+
}
276+
}
277+
}
278+
279+
return { profile: response, token: iter.token! };
280+
}

0 commit comments

Comments
 (0)