@atproto/bsky 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/api/app/bsky/actor/getProfile.js +1 -1
  3. package/dist/api/app/bsky/actor/getProfile.js.map +1 -1
  4. package/dist/api/app/bsky/actor/getProfiles.js +1 -1
  5. package/dist/api/app/bsky/actor/getProfiles.js.map +1 -1
  6. package/dist/api/app/bsky/actor/getSuggestions.js +2 -2
  7. package/dist/api/app/bsky/actor/getSuggestions.js.map +1 -1
  8. package/dist/api/app/bsky/embed/getEmbedExternalView.d.ts.map +1 -1
  9. package/dist/api/app/bsky/embed/getEmbedExternalView.js +132 -53
  10. package/dist/api/app/bsky/embed/getEmbedExternalView.js.map +1 -1
  11. package/dist/api/app/bsky/feed/getAuthorFeed.d.ts.map +1 -1
  12. package/dist/api/app/bsky/feed/getAuthorFeed.js +4 -2
  13. package/dist/api/app/bsky/feed/getAuthorFeed.js.map +1 -1
  14. package/dist/api/app/bsky/graph/getSuggestedFollowsByActor.js +2 -2
  15. package/dist/api/app/bsky/graph/getSuggestedFollowsByActor.js.map +1 -1
  16. package/dist/api/app/bsky/notification/constants.d.ts +4 -1
  17. package/dist/api/app/bsky/notification/constants.d.ts.map +1 -1
  18. package/dist/api/app/bsky/notification/constants.js +3 -0
  19. package/dist/api/app/bsky/notification/constants.js.map +1 -1
  20. package/dist/api/app/bsky/notification/getGroupedNotifications.js +19 -7
  21. package/dist/api/app/bsky/notification/getGroupedNotifications.js.map +1 -1
  22. package/dist/api/app/bsky/notification/grouping/spotlight.d.ts +1 -1
  23. package/dist/api/app/bsky/notification/grouping/spotlight.d.ts.map +1 -1
  24. package/dist/api/app/bsky/notification/grouping/spotlight.js +114 -80
  25. package/dist/api/app/bsky/notification/grouping/spotlight.js.map +1 -1
  26. package/dist/api/app/bsky/notification/listActivitySubscriptions.js +3 -2
  27. package/dist/api/app/bsky/notification/listActivitySubscriptions.js.map +1 -1
  28. package/dist/api/app/bsky/unspecced/getAtmosphereExploreTab.d.ts.map +1 -1
  29. package/dist/api/app/bsky/unspecced/getAtmosphereExploreTab.js +16 -148
  30. package/dist/api/app/bsky/unspecced/getAtmosphereExploreTab.js.map +1 -1
  31. package/dist/api/internal/bsky/actor/getProfiles.js +4 -1
  32. package/dist/api/internal/bsky/actor/getProfiles.js.map +1 -1
  33. package/dist/config.d.ts +2 -0
  34. package/dist/config.d.ts.map +1 -1
  35. package/dist/config.js +7 -0
  36. package/dist/config.js.map +1 -1
  37. package/dist/data-plane/server/indexing/index.d.ts +3 -1
  38. package/dist/data-plane/server/indexing/index.d.ts.map +1 -1
  39. package/dist/data-plane/server/indexing/index.js +2 -0
  40. package/dist/data-plane/server/indexing/index.js.map +1 -1
  41. package/dist/data-plane/server/indexing/plugins/profile-link.d.ts +8 -0
  42. package/dist/data-plane/server/indexing/plugins/profile-link.d.ts.map +1 -0
  43. package/dist/data-plane/server/indexing/plugins/profile-link.js +30 -0
  44. package/dist/data-plane/server/indexing/plugins/profile-link.js.map +1 -0
  45. package/dist/data-plane/server/routes/records.d.ts.map +1 -1
  46. package/dist/data-plane/server/routes/records.js +1 -0
  47. package/dist/data-plane/server/routes/records.js.map +1 -1
  48. package/dist/hydration/actor.d.ts +4 -1
  49. package/dist/hydration/actor.d.ts.map +1 -1
  50. package/dist/hydration/actor.js +11 -0
  51. package/dist/hydration/actor.js.map +1 -1
  52. package/dist/hydration/external.d.ts +67 -64
  53. package/dist/hydration/external.d.ts.map +1 -1
  54. package/dist/hydration/external.js +79 -133
  55. package/dist/hydration/external.js.map +1 -1
  56. package/dist/hydration/hydrator.d.ts +53 -14
  57. package/dist/hydration/hydrator.d.ts.map +1 -1
  58. package/dist/hydration/hydrator.js +414 -105
  59. package/dist/hydration/hydrator.js.map +1 -1
  60. package/dist/lexicons/app/bsky/actor/defs.defs.d.ts +24 -0
  61. package/dist/lexicons/app/bsky/actor/defs.defs.d.ts.map +1 -1
  62. package/dist/lexicons/app/bsky/actor/defs.defs.js +13 -0
  63. package/dist/lexicons/app/bsky/actor/defs.defs.js.map +1 -1
  64. package/dist/lexicons/app/bsky/actor/link.d.ts +3 -0
  65. package/dist/lexicons/app/bsky/actor/link.d.ts.map +1 -0
  66. package/dist/lexicons/app/bsky/actor/link.defs.d.ts +105 -0
  67. package/dist/lexicons/app/bsky/actor/link.defs.d.ts.map +1 -0
  68. package/dist/lexicons/app/bsky/actor/link.defs.js +34 -0
  69. package/dist/lexicons/app/bsky/actor/link.defs.js.map +1 -0
  70. package/dist/lexicons/app/bsky/actor/link.js +6 -0
  71. package/dist/lexicons/app/bsky/actor/link.js.map +1 -0
  72. package/dist/lexicons/app/bsky/actor/profile.defs.d.ts +14 -0
  73. package/dist/lexicons/app/bsky/actor/profile.defs.d.ts.map +1 -1
  74. package/dist/lexicons/app/bsky/actor/profile.defs.js +3 -0
  75. package/dist/lexicons/app/bsky/actor/profile.defs.js.map +1 -1
  76. package/dist/lexicons/app/bsky/actor.d.ts +1 -0
  77. package/dist/lexicons/app/bsky/actor.d.ts.map +1 -1
  78. package/dist/lexicons/app/bsky/actor.js +1 -0
  79. package/dist/lexicons/app/bsky/actor.js.map +1 -1
  80. package/dist/lexicons/app/bsky/authFullApp.defs.d.ts.map +1 -1
  81. package/dist/lexicons/app/bsky/authFullApp.defs.js +1 -0
  82. package/dist/lexicons/app/bsky/authFullApp.defs.js.map +1 -1
  83. package/dist/lexicons/app/bsky/authManageProfile.defs.d.ts.map +1 -1
  84. package/dist/lexicons/app/bsky/authManageProfile.defs.js +1 -0
  85. package/dist/lexicons/app/bsky/authManageProfile.defs.js.map +1 -1
  86. package/dist/lexicons/app/bsky/embed/getEmbedExternalView.defs.d.ts +13 -3
  87. package/dist/lexicons/app/bsky/embed/getEmbedExternalView.defs.d.ts.map +1 -1
  88. package/dist/lexicons/app/bsky/embed/getEmbedExternalView.defs.js +11 -3
  89. package/dist/lexicons/app/bsky/embed/getEmbedExternalView.defs.js.map +1 -1
  90. package/dist/lexicons/app/bsky/notification/getGroupedNotifications.defs.d.ts +62 -3
  91. package/dist/lexicons/app/bsky/notification/getGroupedNotifications.defs.d.ts.map +1 -1
  92. package/dist/lexicons/app/bsky/notification/getGroupedNotifications.defs.js +53 -2
  93. package/dist/lexicons/app/bsky/notification/getGroupedNotifications.defs.js.map +1 -1
  94. package/dist/proto/bsky_connect.d.ts +10 -1
  95. package/dist/proto/bsky_connect.d.ts.map +1 -1
  96. package/dist/proto/bsky_connect.js +10 -1
  97. package/dist/proto/bsky_connect.js.map +1 -1
  98. package/dist/proto/bsky_pb.d.ts +34 -0
  99. package/dist/proto/bsky_pb.d.ts.map +1 -1
  100. package/dist/proto/bsky_pb.js +60 -0
  101. package/dist/proto/bsky_pb.js.map +1 -1
  102. package/dist/telemetry/events.d.ts +30 -0
  103. package/dist/telemetry/events.d.ts.map +1 -1
  104. package/dist/telemetry/events.js +37 -0
  105. package/dist/telemetry/events.js.map +1 -1
  106. package/dist/util/external/index.d.ts +8 -0
  107. package/dist/util/external/index.d.ts.map +1 -0
  108. package/dist/util/external/index.js +50 -0
  109. package/dist/util/external/index.js.map +1 -0
  110. package/dist/util/{standard-site.d.ts → external/standard-site.d.ts} +30 -4
  111. package/dist/util/external/standard-site.d.ts.map +1 -0
  112. package/dist/util/{standard-site.js → external/standard-site.js} +73 -4
  113. package/dist/util/external/standard-site.js.map +1 -0
  114. package/dist/views/external.js +1 -1
  115. package/dist/views/external.js.map +1 -1
  116. package/dist/views/index.d.ts +10 -6
  117. package/dist/views/index.d.ts.map +1 -1
  118. package/dist/views/index.js +84 -42
  119. package/dist/views/index.js.map +1 -1
  120. package/dist/views/profile-link-rules.d.ts +10 -0
  121. package/dist/views/profile-link-rules.d.ts.map +1 -0
  122. package/dist/views/profile-link-rules.js +48 -0
  123. package/dist/views/profile-link-rules.js.map +1 -0
  124. package/dist/views/types.d.ts +2 -0
  125. package/dist/views/types.d.ts.map +1 -1
  126. package/dist/views/types.js.map +1 -1
  127. package/package.json +4 -4
  128. package/dist/util/standard-site.d.ts.map +0 -1
  129. package/dist/util/standard-site.js.map +0 -1
@@ -1,16 +1,17 @@
1
1
  import assert from 'node:assert';
2
- import { dedupeStrs, mapDefined } from '@atproto/common';
3
- import { atUri } from '@atproto/lex';
4
- import { AtUri, } from '@atproto/syntax';
2
+ import { chunkArray, dedupeStrs, mapDefined } from '@atproto/common';
3
+ import { atUri, isDidString } from '@atproto/lex';
4
+ import { AtUri, parseAtUriString, } from '@atproto/syntax';
5
5
  import { NOTIFICATION_REASON } from '../api/app/bsky/notification/constants.js';
6
- import { app, chat, com } from '../lexicons/index.js';
6
+ import { app, chat, com, place, site, social } from '../lexicons/index.js';
7
7
  import { hydrationLogger } from '../logger.js';
8
8
  import { events } from '../telemetry/events.js';
9
- import { SITE_STANDARD_NSID_PREFIX } from '../util/standard-site.js';
9
+ import { SITE_STANDARD_NSID_PREFIX } from '../util/external/standard-site.js';
10
10
  import { uriToDid, uriToDid as didFromUri } from '../util/uris.js';
11
+ import { MAX_PROFILE_LINKS } from '../views/profile-link-rules.js';
11
12
  import { isExternalEmbedType, isListRuleType, isRecordEmbedType, isRecordWithMediaType, } from '../views/types.js';
12
13
  import { ActorHydrator, } from './actor.js';
13
- import { ExternalHydrator, parseGenericRecordKey, } from './external.js';
14
+ import { ExternalHydrator, actionExternalRecordTakedowns, genericRecordKey, parseGenericRecordKey, trackExternalHydration, } from './external.js';
14
15
  import { FeedHydrator, } from './feed.js';
15
16
  import { GraphHydrator, } from './graph.js';
16
17
  import { LabelHydrator, Labels, } from './label.js';
@@ -74,7 +75,122 @@ export class Hydrator {
74
75
  // app.bsky.actor.defs#profileView
75
76
  // - profile
76
77
  // - list basic
77
- async hydrateProfiles(dids, ctx) {
78
+ async hydrateProfiles(dids, ctx, opts) {
79
+ return this.hydrateProfilesWorker(dids, ctx, 'profile', opts);
80
+ }
81
+ // app.bsky.actor.defs#profileViewBasic
82
+ // - profile basic
83
+ // - profile
84
+ // - list basic
85
+ async hydrateProfilesBasic(dids, ctx, opts) {
86
+ return this.hydrateProfilesWorker(dids, ctx, 'basic', opts);
87
+ }
88
+ // app.bsky.actor.defs#profileViewDetailed
89
+ // - profile detailed
90
+ // - profile
91
+ // - list basic
92
+ // - starterpack
93
+ // - profile
94
+ // - list basic
95
+ // - labels
96
+ async hydrateProfilesDetailed(dids, ctx, opts) {
97
+ return this.hydrateProfilesWorker(dids, ctx, 'detailed', opts);
98
+ }
99
+ async hydrateProfilesWorker(dids, ctx, level, opts) {
100
+ const detailed = level === 'detailed';
101
+ const knownFollowersDids = opts?.knownFollowersDids;
102
+ const includeActivitySubscriptions = opts?.activitySubscriptions ?? false;
103
+ // Batch 1: known followers, profile counts, activity subscriptions.
104
+ const knownFollowersPromise = knownFollowersDids?.length
105
+ ? this.actor
106
+ .getKnownFollowers(knownFollowersDids, ctx.viewer)
107
+ .catch((err) => {
108
+ hydrationLogger.error({ err }, 'Failed to get known followers for profiles');
109
+ return new HydrationMap();
110
+ })
111
+ : Promise.resolve(undefined);
112
+ const profileAggsPromise = detailed
113
+ ? this.actor.getProfileAggregates(dids)
114
+ : Promise.resolve(undefined);
115
+ const activitySubscriptionsPromise = includeActivitySubscriptions
116
+ ? this.actor
117
+ .getActivitySubscriptions(dids, ctx.viewer)
118
+ .catch((err) => {
119
+ if (!detailed && opts?.activitySubscriptions)
120
+ throw err;
121
+ hydrationLogger.error({ err }, 'Failed to get activity subscriptions state for profiles');
122
+ return new HydrationMap();
123
+ })
124
+ : Promise.resolve(undefined);
125
+ const profilesPromise = (async () => {
126
+ // Wait for known followers to collect their DIDs for batch 2.
127
+ const knownFollowers = await knownFollowersPromise;
128
+ const subjectsToKnownFollowersMap = new Map();
129
+ knownFollowers?.forEach((known, did) => {
130
+ if (known)
131
+ subjectsToKnownFollowersMap.set(did, known.followers);
132
+ });
133
+ const allKnownFollowerDids = knownFollowers
134
+ ? Array.from(knownFollowers.values())
135
+ .filter(Boolean)
136
+ .flatMap((f) => f.followers)
137
+ : [];
138
+ const allDids = Array.from(new Set(dids.concat(allKnownFollowerDids)));
139
+ // Batch 2: profiles, bidirectional blocks between requested DIDs and their known followers.
140
+ const profileStatePromise = this.hydrateProfileState(allDids, ctx);
141
+ const bidirectionalBlocksPromise = knownFollowers
142
+ ? this.hydrateBidirectionalBlocks(subjectsToKnownFollowersMap, ctx)
143
+ : undefined;
144
+ // Batch 3: joined-via starter packs, profile links.
145
+ const starterPackStatePromise = detailed
146
+ ? (async () => {
147
+ const state = await profileStatePromise;
148
+ const starterPackUriSet = new Set();
149
+ state.actors?.forEach((actor) => {
150
+ if (actor?.profile?.joinedViaStarterPack) {
151
+ starterPackUriSet.add(actor.profile.joinedViaStarterPack.uri);
152
+ }
153
+ });
154
+ return this.hydrateStarterPacksBasic([...starterPackUriSet], ctx);
155
+ })()
156
+ : undefined;
157
+ const profileLinksPromise = detailed
158
+ ? (async () => {
159
+ const state = await profileStatePromise;
160
+ return this.actor
161
+ .getProfileLinks(profileLinkUris(dids, state.actors), ctx.includeTakedowns)
162
+ .catch((err) => {
163
+ hydrationLogger.error({ err }, 'Failed to get profile links');
164
+ return new HydrationMap();
165
+ });
166
+ })()
167
+ : undefined;
168
+ const [profileState, bidirectionalBlocks, starterPackState, profileLinks,] = await Promise.all([
169
+ profileStatePromise,
170
+ bidirectionalBlocksPromise,
171
+ starterPackStatePromise,
172
+ profileLinksPromise,
173
+ ]);
174
+ return mergeManyStates(profileState, starterPackState ?? {}, {
175
+ knownFollowers,
176
+ bidirectionalBlocks,
177
+ profileLinks,
178
+ ctx,
179
+ });
180
+ })();
181
+ // Also await counts and subscriptions from batch 1.
182
+ const [state, profileAggs, activitySubscriptions] = await Promise.all([
183
+ profilesPromise,
184
+ profileAggsPromise,
185
+ activitySubscriptionsPromise,
186
+ ]);
187
+ return mergeStates(state, {
188
+ profileAggs,
189
+ activitySubscriptions,
190
+ ctx,
191
+ });
192
+ }
193
+ async hydrateProfileState(dids, ctx) {
78
194
  /**
79
195
  * Special case here, we want to include takedowns in special cases, like
80
196
  * `getProfile`, since we throw client-facing errors later in the pipeline.
@@ -124,66 +240,6 @@ export class Hydrator {
124
240
  ctx,
125
241
  });
126
242
  }
127
- // app.bsky.actor.defs#profileViewBasic
128
- // - profile basic
129
- // - profile
130
- // - list basic
131
- async hydrateProfilesBasic(dids, ctx) {
132
- return this.hydrateProfiles(dids, ctx);
133
- }
134
- // app.bsky.actor.defs#profileViewDetailed
135
- // - profile detailed
136
- // - profile
137
- // - list basic
138
- // - starterpack
139
- // - profile
140
- // - list basic
141
- // - labels
142
- async hydrateProfilesDetailed(dids, ctx, opts) {
143
- const [knownFollowers, activitySubscriptions] = await Promise.all([
144
- this.actor
145
- .getKnownFollowers(opts?.knownFollowersDids ?? dids, ctx.viewer)
146
- .catch((err) => {
147
- hydrationLogger.error({ err }, 'Failed to get known followers for profiles');
148
- return new HydrationMap();
149
- }),
150
- this.actor
151
- .getActivitySubscriptions(dids, ctx.viewer)
152
- .catch((err) => {
153
- hydrationLogger.error({ err }, 'Failed to get activity subscriptions state for profiles');
154
- return new HydrationMap();
155
- }),
156
- ]);
157
- const subjectsToKnownFollowersMap = new Map();
158
- for (const did of knownFollowers.keys()) {
159
- const known = knownFollowers.get(did);
160
- if (known)
161
- subjectsToKnownFollowersMap.set(did, known.followers);
162
- }
163
- const allKnownFollowerDids = Array.from(knownFollowers.values())
164
- .filter(Boolean)
165
- .flatMap((f) => f.followers);
166
- const allDids = Array.from(new Set(dids.concat(allKnownFollowerDids)));
167
- const [state, profileAggs, bidirectionalBlocks] = await Promise.all([
168
- this.hydrateProfiles(allDids, ctx),
169
- this.actor.getProfileAggregates(dids),
170
- this.hydrateBidirectionalBlocks(subjectsToKnownFollowersMap, ctx),
171
- ]);
172
- const starterPackUriSet = new Set();
173
- state.actors?.forEach((actor) => {
174
- if (actor?.profile?.joinedViaStarterPack) {
175
- starterPackUriSet.add(actor?.profile?.joinedViaStarterPack?.uri);
176
- }
177
- });
178
- const starterPackState = await this.hydrateStarterPacksBasic([...starterPackUriSet], ctx);
179
- return mergeManyStates(state, starterPackState, {
180
- profileAggs,
181
- knownFollowers,
182
- activitySubscriptions,
183
- ctx,
184
- bidirectionalBlocks,
185
- });
186
- }
187
243
  // app.bsky.graph.defs#listView
188
244
  // - list
189
245
  // - profile basic
@@ -556,47 +612,244 @@ export class Hydrator {
556
612
  });
557
613
  }
558
614
  /**
559
- * Hydrate the state needed to build an `app.bsky.embed.external#view` for
560
- * a set of `site.standard.*` AT-URIs at compose-time. Filters input URIs to
561
- * SS collections, fetches latest indexed versions plus labels, and gates
562
- * taken-down records.
615
+ * Hydrate the latest versions of external records, plus labels, profiles, and
616
+ * their view dependencies. `hydrationPass` is the 1-based number of this
617
+ * generic batch within the traversal (the root batch is pass 1); nested
618
+ * batches continue the count. Nested passes share the caller's
619
+ * `hydrationMetrics` tracker; without one, this call owns the traversal and
620
+ * reports its totals.
621
+ */
622
+ async hydrateEmbedExternalViewFromUris(uris, ctx, seenRecordKeys = new Set(), hydrationPass = 1, hydrationMetrics) {
623
+ return trackExternalHydration('uris', hydrationMetrics, (hydrationMetrics) => this.hydrateEmbedExternalView(uris.map(uriToRef), ctx, seenRecordKeys, hydrationPass, hydrationMetrics));
624
+ }
625
+ /**
626
+ * Like `hydrateEmbedExternalViewFromUris`, but for exact record versions
627
+ * pinned by strong refs. Roots land only in `externalRecordsByRef`; their
628
+ * URI dependencies are hydrated as latest versions by the URI path.
563
629
  */
564
- async hydrateEmbedExternalViewFromUris(uris, ctx) {
565
- const ssUris = dedupeStrs(uris.filter((u) => new AtUri(u).collection.startsWith(SITE_STANDARD_NSID_PREFIX)));
566
- if (!ssUris.length)
630
+ async hydrateEmbedExternalViewFromRefs(refs, ctx, seenRecordKeys = new Set(), hydrationPass = 1, hydrationMetrics) {
631
+ return trackExternalHydration('refs', hydrationMetrics, (hydrationMetrics) => this.hydrateEmbedExternalView(refs, ctx, seenRecordKeys, hydrationPass, hydrationMetrics));
632
+ }
633
+ async hydrateEmbedExternalView(refs, ctx, seenRecordKeys, hydrationPass, hydrationMetrics) {
634
+ // Filter at the entry point so each lookup runs at most once per shared
635
+ // traversal, even for callers that did not prefilter their input. Null or
636
+ // unavailable results stay seen as completed lookups. Nothing unseen means
637
+ // no hydration work, and no pass is consumed.
638
+ const items = takeUnseenRefs(refs, seenRecordKeys);
639
+ if (!items.length)
567
640
  return { ctx };
568
- const dids = dedupeStrs(ssUris.map((uri) => uriToDid(uri)));
569
- const [{ documents, publications }, labels, profiles] = await Promise.all([
570
- this.external.getSiteStandardRecordsByURI(ssUris, ctx.includeTakedowns),
571
- this.label.getLabelsForSubjects(ssUris, ctx.labelers),
641
+ // Get any bare URIs to fetch
642
+ const byUriValues = items
643
+ .filter(({ cid }) => cid === undefined)
644
+ .map(({ uri }) => uri);
645
+ // Get any exact refs to fetch
646
+ const byRefValues = items.filter(isExactRef);
647
+ // Pull out all URIs for label/profile hydration
648
+ const allUris = dedupeStrs(items.map(({ uri }) => uri));
649
+ // Get all the actors that are part of this hydration
650
+ const dids = dedupeStrs(allUris.map(uriToDid));
651
+ const fetchRecords = async () => {
652
+ const [externalRecords, exactRecords] = await Promise.all([
653
+ byUriValues.length
654
+ ? this.external.getRecordsByURI(byUriValues, ctx.includeTakedowns)
655
+ : undefined,
656
+ byRefValues.length
657
+ ? this.external.getRecordsByRef(byRefValues, ctx.includeTakedowns)
658
+ : undefined,
659
+ ]);
660
+ // Build the by-ref map used by some endpoints. Latest versions were not
661
+ // requested exactly, so they are not marked as completed exact lookups,
662
+ // and requested exact entries take precedence over them.
663
+ const externalRecordsByRef = new HydrationMap();
664
+ for (const [uri, info] of externalRecords ?? []) {
665
+ if (!info)
666
+ continue;
667
+ externalRecordsByRef.set(genericRecordKey({ uri, cid: info.cid }), info);
668
+ }
669
+ if (exactRecords)
670
+ externalRecordsByRef.merge(exactRecords);
671
+ return { externalRecords, externalRecordsByRef };
672
+ };
673
+ // Count this batch where its generic fetches are scheduled; a failed batch
674
+ // was still attempted.
675
+ hydrationMetrics.recordLookups += items.length;
676
+ hydrationMetrics.batches += 1;
677
+ hydrationMetrics.maxPass = Math.max(hydrationMetrics.maxPass, hydrationPass);
678
+ const [{ externalRecords, externalRecordsByRef }, labels, profiles] = await Promise.all([
679
+ fetchRecords(),
680
+ this.label.getLabelsForSubjects(allUris, ctx.labelers),
572
681
  this.hydrateProfilesBasic(dids, ctx),
573
682
  ]);
574
- if (!ctx.includeTakedowns) {
575
- actionSiteStandardTakedownLabels(documents, publications, labels);
576
- }
577
- // Edge case: a document's `site` may resolve to a publication owned by a
578
- // different repo than any of the input URIs (the dataplane returns it
579
- // even though it wasn't requested directly). Top up profile coverage for
580
- // any such DIDs with a serial second hydration so `associatedProfiles`
581
- // is complete.
582
- const knownDids = new Set(dids);
583
- const extraDids = [];
584
- for (const key of publications.keys()) {
585
- const did = uriToDid(parseGenericRecordKey(key).uri);
586
- if (!knownDids.has(did)) {
587
- knownDids.add(did);
588
- extraDids.push(did);
589
- }
590
- }
591
- const profilesState = extraDids.length
592
- ? mergeStates(profiles, await this.hydrateProfilesBasic(extraDids, ctx))
593
- : profiles;
594
- return mergeStates(profilesState, {
683
+ // Merge record and profile state, then hide moderated records so they
684
+ // trigger no dependency expansion.
685
+ const baseState = mergeStates(profiles, {
595
686
  ctx,
596
687
  labels,
597
- siteStandardDocuments: documents,
598
- siteStandardPublications: publications,
688
+ externalRecords,
689
+ externalRecordsByRef,
599
690
  });
691
+ actionExternalRecordTakedowns(baseState, ctx.includeTakedowns);
692
+ // Discover and hydrate any additional view dependencies
693
+ const depsState = await this.hydrateExternalViewDependencies(baseState, items, ctx, seenRecordKeys, hydrationPass, hydrationMetrics);
694
+ // A supplemental latest lookup returning the same URI and CID is the same
695
+ // version, so it may replace an unavailable exact entry; other CIDs have
696
+ // other keys and never satisfy a pin.
697
+ return mergeStates(baseState, depsState);
698
+ }
699
+ /**
700
+ * Discover view dependencies by URI collection and hydrate new URIs in a batch.
701
+ * Returns additional state. Each input is inspected in the map matching its
702
+ * identity: `(uri, cid)` refs in `externalRecordsByRef`, bare URIs in
703
+ * `externalRecords`. Every pinned version is inspected, since versions of
704
+ * one URI can reference different dependencies.
705
+ *
706
+ * `seenRecordKeys` prevents repeated lookups and cycles across nested passes,
707
+ * but not a chain of distinct URIs; `MAX_EXTERNAL_HYDRATION_PASSES` bounds
708
+ * that, as a guard against future association rules or unexpected expansion.
709
+ * `hydrationPass` is the number of the generic batch that produced `state`
710
+ * (1-based, default 1 for prehydrated state, which is treated as the root
711
+ * batch); the next nested batch is `hydrationPass + 1`. `hydrationMetrics`
712
+ * is shared as in `hydrateEmbedExternalViewFromUris`.
713
+ */
714
+ async hydrateExternalViewDependencies(state, refs, ctx, seenRecordKeys = new Set(), hydrationPass = 1, hydrationMetrics) {
715
+ return trackExternalHydration('dependencies', hydrationMetrics, (hydrationMetrics) => this.discoverExternalViewDependencies(state, refs, ctx, seenRecordKeys, hydrationPass, hydrationMetrics));
716
+ }
717
+ async discoverExternalViewDependencies(state, refs, ctx, seenRecordKeys, hydrationPass, hydrationMetrics) {
718
+ // Backlink sources to sample per target, each with its own bound.
719
+ const targets = new Map();
720
+ const discoveredUris = new Set();
721
+ for (const ref of refs) {
722
+ /*
723
+ * Additional safety in case this method is ever called directly.
724
+ * `takeUnseenRefs` already populates this map, so this is a no-op under
725
+ * normal circumstances.
726
+ */
727
+ seenRecordKeys.add(genericRecordKey(ref));
728
+ const { uri, cid } = ref;
729
+ const record = (cid === undefined
730
+ ? state.externalRecords?.get(uri)
731
+ : state.externalRecordsByRef?.get(genericRecordKey({ uri, cid })))?.record;
732
+ if (!record)
733
+ continue;
734
+ const parsed = parseAtUriString(uri);
735
+ if (!parsed.success ||
736
+ !isDidString(parsed.value.authority) ||
737
+ !parsed.value.rkey ||
738
+ parsed.value.hash) {
739
+ continue;
740
+ }
741
+ /*
742
+ * Collection-specific rules for discovering additional view dependencies.
743
+ */
744
+ const { collection } = parsed.value;
745
+ if (collection === site.standard.document.$type &&
746
+ site.standard.document.$matches(record, { strict: false })) {
747
+ targets.set(uri, [
748
+ {
749
+ collection: site.standard.graph.recommend.$type,
750
+ limit: ExternalHydrator.MAX_BACKLINK_PREVIEWS,
751
+ },
752
+ ]);
753
+ const publicationUri = recordUriOf(record.site, site.standard.publication.$type);
754
+ if (publicationUri)
755
+ discoveredUris.add(publicationUri);
756
+ }
757
+ else if (collection === site.standard.publication.$type &&
758
+ site.standard.publication.$matches(record, { strict: false })) {
759
+ targets.set(uri, [
760
+ {
761
+ collection: site.standard.graph.subscription.$type,
762
+ limit: ExternalHydrator.MAX_BACKLINK_PREVIEWS,
763
+ },
764
+ ]);
765
+ }
766
+ else if (collection === place.stream.livestream.$type &&
767
+ place.stream.livestream.$matches(record, { strict: false })) {
768
+ // Nothing to do atm
769
+ }
770
+ else if (collection === social.grain.gallery.$type &&
771
+ social.grain.gallery.$matches(record, { strict: false })) {
772
+ targets.set(uri, [
773
+ {
774
+ collection: social.grain.favorite.$type,
775
+ limit: ExternalHydrator.MAX_BACKLINK_PREVIEWS,
776
+ },
777
+ {
778
+ collection: social.grain.gallery.item.$type,
779
+ limit: ExternalHydrator.MAX_BACKLINK_GALLERY_ITEMS,
780
+ },
781
+ ]);
782
+ }
783
+ else if (collection === social.grain.gallery.item.$type &&
784
+ social.grain.gallery.item.$matches(record, { strict: false })) {
785
+ // Items have no backlinks; they only point at their photo.
786
+ const photoUri = recordUriOf(record.item, social.grain.photo.$type);
787
+ if (photoUri)
788
+ discoveredUris.add(photoUri);
789
+ }
790
+ }
791
+ let externalRecordBacklinks;
792
+ let externalRecordBacklinkCounts;
793
+ if (targets.size) {
794
+ const backlinks = new HydrationMap();
795
+ const queries = [...targets].flatMap(([uri, sources]) => sources.map((source) => ({ uri, ...source })));
796
+ // @NOTE Backlink data is best effort: a failed lookup leaves its state
797
+ // absent and builders render what remains.
798
+ const [counts] = await Promise.all([
799
+ this.external
800
+ .getAtmosphereBacklinkCounts([...targets.keys()])
801
+ .catch((err) => {
802
+ hydrationLogger.warn({ err }, 'failed to hydrate external record backlink counts');
803
+ return undefined;
804
+ }),
805
+ (async () => {
806
+ // @NOTE Bound fan-out as well as each sample; counts are independent.
807
+ for (const batch of chunkArray(queries, ExternalHydrator.MAX_BACKLINK_FANOUT)) {
808
+ await Promise.all(batch.map(async ({ uri, collection, limit }) => {
809
+ let page;
810
+ try {
811
+ page = await this.external.getAtmosphereBacklinks(uri, collection, { limit });
812
+ }
813
+ catch (err) {
814
+ hydrationLogger.warn({ err, uri, collection }, 'failed to hydrate external record backlinks');
815
+ return;
816
+ }
817
+ const links = page.backlinks
818
+ .filter((b) => parseAtUriString(b.uri).success)
819
+ .slice(0, limit)
820
+ .map((b) => b.uri);
821
+ backlinks.set(uri, dedupeStrs([...(backlinks.get(uri) ?? []), ...links]));
822
+ links.forEach((link) => discoveredUris.add(link));
823
+ }));
824
+ }
825
+ })(),
826
+ ]);
827
+ externalRecordBacklinks = backlinks;
828
+ externalRecordBacklinkCounts = counts;
829
+ }
830
+ const newUris = [...discoveredUris].filter((uri) => !seenRecordKeys.has(genericRecordKey({ uri })));
831
+ let nested = {};
832
+ if (newUris.length &&
833
+ hydrationPass >= ExternalHydrator.MAX_EXTERNAL_HYDRATION_PASSES) {
834
+ // Keep the partial state. Skipped URIs are not marked seen: they were
835
+ // never fetched.
836
+ hydrationMetrics.capped = true;
837
+ hydrationLogger.warn({
838
+ hydrationPass,
839
+ maxPasses: ExternalHydrator.MAX_EXTERNAL_HYDRATION_PASSES,
840
+ skipped: newUris.length,
841
+ }, 'external hydration pass limit reached, skipping dependencies');
842
+ }
843
+ else if (newUris.length) {
844
+ try {
845
+ nested = await this.hydrateEmbedExternalViewFromUris(newUris, ctx, seenRecordKeys, hydrationPass + 1, hydrationMetrics);
846
+ }
847
+ catch (err) {
848
+ // Skip the whole batch; partial child state could miss moderation.
849
+ hydrationLogger.warn({ err, hydrationPass, skipped: newUris.length }, 'failed to hydrate external view dependencies');
850
+ }
851
+ }
852
+ return mergeStates({ ctx, externalRecordBacklinks, externalRecordBacklinkCounts }, nested);
600
853
  }
601
854
  // app.bsky.feed.defs#threadViewPost
602
855
  // - post
@@ -892,10 +1145,11 @@ export class Hydrator {
892
1145
  ctx,
893
1146
  });
894
1147
  }
895
- async hydrateGroupedNotifications(notifs, ctx) {
1148
+ async hydrateGroupedNotifications(notifs, ctx, opts) {
896
1149
  if (!notifs.length)
897
1150
  return { ctx };
898
1151
  const notificationUris = dedupeStrs(notifs.map((notif) => notif.uri));
1152
+ const authorDids = dedupeStrs(notificationUris.map(didFromUri));
899
1153
  const collections = urisByCollection(notificationUris);
900
1154
  const notificationPostUris = collections.get(app.bsky.feed.post.$type) ?? [];
901
1155
  const likeUris = collections.get(app.bsky.feed.like.$type) ?? [];
@@ -934,9 +1188,12 @@ export class Hydrator {
934
1188
  this.feed.getReposts([...repostUris], ctx.includeTakedowns),
935
1189
  this.graph.getFollows(followUris, ctx.includeTakedowns),
936
1190
  this.label.getLabelsForSubjects(
937
- // Fetch labels for likes and follows here; hydrateProfilesDetailed fetches profile labels, and hydratePosts fetches post labels later.
1191
+ // Fetch labels for likes and follows here; profile and post hydration fetch their own labels.
938
1192
  [...likeUris, ...followUris], ctx.labelers),
939
- this.hydrateProfilesDetailed(dedupeStrs(notificationUris.map(didFromUri)), ctx),
1193
+ // Hydrate known followers only for the subset selected by the endpoint.
1194
+ this.hydrateProfilesBasic(authorDids, ctx, {
1195
+ knownFollowersDids: opts?.knownFollowersDids,
1196
+ }),
940
1197
  ]);
941
1198
  reposts.forEach((repost) => {
942
1199
  if (repost)
@@ -955,7 +1212,7 @@ export class Hydrator {
955
1212
  const [postState, feedGenState, starterPackState] = await Promise.all([
956
1213
  this.hydratePosts([...postUris].map((uri) => ({ uri })), ctx, { posts }),
957
1214
  this.hydrateFeedGens([...feedGenUris], ctx),
958
- this.hydrateStarterPacks([...starterPackUris], ctx),
1215
+ this.hydrateStarterPacksBasic([...starterPackUris], ctx),
959
1216
  ]);
960
1217
  return mergeManyStates(profileState, postState, feedGenState, starterPackState, { likes, reposts, follows, labels, ctx });
961
1218
  }
@@ -1144,6 +1401,10 @@ export class Hydrator {
1144
1401
  return;
1145
1402
  return ((await this.actor.getGermDeclarations([uri], includeTakedowns)).get(uri) ?? undefined);
1146
1403
  }
1404
+ else if (collection === app.bsky.actor.link.$type) {
1405
+ return ((await this.actor.getProfileLinks([uri], includeTakedowns)).get(uri) ??
1406
+ undefined);
1407
+ }
1147
1408
  else if (collection === app.bsky.notification.declaration.$type) {
1148
1409
  if (parsed.rkey !== 'self')
1149
1410
  return;
@@ -1352,6 +1613,24 @@ const pairsToMap = (pairs) => {
1352
1613
  }
1353
1614
  return map;
1354
1615
  };
1616
+ /**
1617
+ * The link records a profile points to, in order. Only the first
1618
+ * MAX_PROFILE_LINKS are shown, and only links in the profile's own repo count.
1619
+ */
1620
+ export const profileLinkUris = (dids, actors) => {
1621
+ const uris = [];
1622
+ for (const did of dids) {
1623
+ const refs = actors?.get(did)?.profile?.links ?? [];
1624
+ for (const ref of refs.slice(0, MAX_PROFILE_LINKS)) {
1625
+ const parsed = new AtUri(ref.uri);
1626
+ if (parsed.host === did &&
1627
+ parsed.collection === app.bsky.actor.link.$type) {
1628
+ uris.push(ref.uri);
1629
+ }
1630
+ }
1631
+ }
1632
+ return dedupeStrs(uris);
1633
+ };
1355
1634
  export const mergeStates = (stateA, stateB) => {
1356
1635
  assert(!stateA.ctx?.viewer ||
1357
1636
  !stateB.ctx?.viewer ||
@@ -1385,6 +1664,7 @@ export const mergeStates = (stateA, stateB) => {
1385
1664
  feedgenViewers: mergeMaps(stateA.feedgenViewers, stateB.feedgenViewers),
1386
1665
  starterPacks: mergeMaps(stateA.starterPacks, stateB.starterPacks),
1387
1666
  starterPackAggs: mergeMaps(stateA.starterPackAggs, stateB.starterPackAggs),
1667
+ profileLinks: mergeMaps(stateA.profileLinks, stateB.profileLinks),
1388
1668
  labelers: mergeMaps(stateA.labelers, stateB.labelers),
1389
1669
  labelerAggs: mergeMaps(stateA.labelerAggs, stateB.labelerAggs),
1390
1670
  labelerViewers: mergeMaps(stateA.labelerViewers, stateB.labelerViewers),
@@ -1395,6 +1675,7 @@ export const mergeStates = (stateA, stateB) => {
1395
1675
  verifications: mergeMaps(stateA.verifications, stateB.verifications),
1396
1676
  bookmarks: mergeNestedMaps(stateA.bookmarks, stateB.bookmarks),
1397
1677
  externalRecords: mergeMaps(stateA.externalRecords, stateB.externalRecords),
1678
+ externalRecordsByRef: mergeMaps(stateA.externalRecordsByRef, stateB.externalRecordsByRef),
1398
1679
  externalRecordBacklinks: mergeMaps(stateA.externalRecordBacklinks, stateB.externalRecordBacklinks),
1399
1680
  externalRecordBacklinkCounts: mergeMaps(stateA.externalRecordBacklinkCounts, stateB.externalRecordBacklinkCounts),
1400
1681
  siteStandardDocuments: mergeMaps(stateA.siteStandardDocuments, stateB.siteStandardDocuments),
@@ -1411,6 +1692,20 @@ const actionTakedownLabels = (keys, hydrationMap, labels) => {
1411
1692
  }
1412
1693
  }
1413
1694
  };
1695
+ /**
1696
+ * The URI when an externally authored reference is a complete DID-based
1697
+ * record URI in `collection`, with a record key and no fragment.
1698
+ */
1699
+ const recordUriOf = (value, collection) => {
1700
+ const parsed = parseAtUriString(value);
1701
+ return parsed.success &&
1702
+ isDidString(parsed.value.authority) &&
1703
+ parsed.value.collection === collection &&
1704
+ parsed.value.rkey &&
1705
+ !parsed.value.hash
1706
+ ? value
1707
+ : undefined;
1708
+ };
1414
1709
  /**
1415
1710
  * Apply takedown labels to the site.standard hydration maps. Per-record
1416
1711
  * takedowns null any entry whose subject URI is taken down; pair takedowns
@@ -1465,4 +1760,18 @@ const actionSiteStandardTakedownLabels = (documents, publications, labels) => {
1465
1760
  const uriToRef = (uri) => {
1466
1761
  return { uri };
1467
1762
  };
1763
+ /**
1764
+ * Drop refs whose lookup key is already in `seenRecordKeys`, and mark the rest
1765
+ * synchronously so concurrent work in the traversal cannot schedule them again.
1766
+ * Latest (bare URI) and exact (`uri@cid`) keys are distinct identities in the
1767
+ * shared set: neither implies nor suppresses the other, or another version.
1768
+ */
1769
+ const takeUnseenRefs = (refs, seenRecordKeys) => refs.filter((ref) => {
1770
+ const key = genericRecordKey(ref);
1771
+ if (seenRecordKeys.has(key))
1772
+ return false;
1773
+ seenRecordKeys.add(key);
1774
+ return true;
1775
+ });
1776
+ const isExactRef = (ref) => ref.cid !== undefined;
1468
1777
  //# sourceMappingURL=hydrator.js.map