@teambit/lanes 1.0.1100 → 1.0.1102

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.
@@ -17,16 +17,16 @@ function _cli() {
17
17
  };
18
18
  return data;
19
19
  }
20
- function _pMapSeries() {
21
- const data = _interopRequireDefault(require("p-map-series"));
22
- _pMapSeries = function () {
20
+ function _pMap() {
21
+ const data = _interopRequireDefault(require("p-map"));
22
+ _pMap = function () {
23
23
  return data;
24
24
  };
25
25
  return data;
26
26
  }
27
- function _pMap() {
28
- const data = _interopRequireDefault(require("p-map"));
29
- _pMap = function () {
27
+ function _pLimit() {
28
+ const data = _interopRequireDefault(require("p-limit"));
29
+ _pLimit = function () {
30
30
  return data;
31
31
  };
32
32
  return data;
@@ -316,7 +316,9 @@ function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbol
316
316
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
317
317
  function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; }
318
318
  function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; }
319
- function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); }
319
+ function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } /* eslint-disable max-lines */
320
+ /** where the base was resolved from: `workspace` (already local) or `scope` (fetched from remote). */
321
+
320
322
  class LanesMain {
321
323
  constructor(workspace, scope, merging, componentAspect, logger, importer, exporter, componentCompare, componentWriter, remove, checkout, install) {
322
324
  this.workspace = workspace;
@@ -331,8 +333,97 @@ class LanesMain {
331
333
  this.remove = remove;
332
334
  this.checkout = checkout;
333
335
  this.install = install;
336
+ /**
337
+ * disk-persisted memo layer for the lane-diff computation (snaps-distance, change-types and the
338
+ * top-level result). extracted to its own class — see `LaneDiffCache` in lanes/diff.
339
+ */
340
+ _defineProperty(this, "_laneDiffCache", void 0);
341
+ /**
342
+ * Bound the parallelism of `deriveChangeTypes` across all in-flight requests. graphql-js resolves
343
+ * list fields in parallel via `Promise.all`, so an uncapped derivation fires one `compare()` per
344
+ * component at once. The old cap of 4 existed because derivation also ran `getAPIDiff` (serial
345
+ * tsserver schema extraction) — that's now deferred to the API view, so derivation only does the
346
+ * I/O+CPU-bound `compare()`, which parallelizes well. Cap to the component concurrency limit.
347
+ */
348
+ _defineProperty(this, "deriveChangesLimit", (0, _pLimit().default)((0, _harmonyModules().concurrentComponentsLimit)()));
349
+ /** single-flight for concurrent `deriveComponentChanges` calls on the same memo key. */
350
+ _defineProperty(this, "changeTypesInflight", new Map());
351
+ }
352
+ get laneDiffCache() {
353
+ if (!this._laneDiffCache) this._laneDiffCache = new (_lanesModules().LaneDiffCache)(this.logger);
354
+ return this._laneDiffCache;
355
+ }
356
+ tryDiffStatusResultMemo(resultMemoKey, sourceLaneId, targetLaneId) {
357
+ const cached = this.laneDiffCache.getDiffStatus(resultMemoKey);
358
+ if (!cached) return undefined;
359
+ return {
360
+ source: sourceLaneId,
361
+ target: targetLaneId || this.getDefaultLaneId(),
362
+ componentsStatus: cached
363
+ };
334
364
  }
335
365
 
366
+ /**
367
+ * Pre-warm the downstream caches the lane-compare UI hits right after `LaneDiffStatus`:
368
+ * - the scope's `ScopeComponentLoader.componentsCache` via `host.getMany([…])`
369
+ * - `componentCompare`'s disk-persisted result memo via `compareComponents(pairs)`
370
+ *
371
+ * Fire-and-forget. The UI's follow-up `Component` and `CompareComponents` queries arrive ~50–200 ms
372
+ * after we return; this kickoff happens *before* we return, so the work overlaps the UI's render
373
+ * tick and the response serialization. By the time the UI's queries land on the server, the caches
374
+ * are populated (or, worst case, the pre-warm work is in-flight and the resolver-level single-flight
375
+ * in `componentCompare` dedupes the load).
376
+ */
377
+ prewarmCompareCaches(host, visibleDiffProps) {
378
+ const allVersionedIds = [];
379
+ const comparePairs = [];
380
+ for (const {
381
+ componentId,
382
+ sourceHead,
383
+ targetHead
384
+ } of visibleDiffProps) {
385
+ const compareId = componentId.changeVersion(sourceHead);
386
+ allVersionedIds.push(compareId);
387
+ if (targetHead) {
388
+ const baseId = componentId.changeVersion(targetHead);
389
+ allVersionedIds.push(baseId);
390
+ comparePairs.push({
391
+ baseId: baseId.toString(),
392
+ compareId: compareId.toString()
393
+ });
394
+ }
395
+ }
396
+ if (allVersionedIds.length === 0) return;
397
+ // NOTE: `host.getMany` uses mapSeries internally (sequential). For cold pre-warm to actually
398
+ // beat the UI's concurrent N-op `Component` batch we have to drive parallelism ourselves. pMap
399
+ // here uses the same `concurrentComponentsLimit()` cap as the lane diff body, matching what
400
+ // downstream loaders are tuned for.
401
+ Promise.all([
402
+ // wrap in try/catch since `host.get` could be missing or throw synchronously; the previous
403
+ // `host.get?.(id).catch(...)` chained .catch on `undefined` when get was absent.
404
+ (0, _pMap().default)(allVersionedIds, async id => {
405
+ try {
406
+ return await host.get?.(id);
407
+ } catch {
408
+ return undefined;
409
+ }
410
+ }, {
411
+ concurrency: (0, _harmonyModules().concurrentComponentsLimit)()
412
+ }).catch(() => undefined), comparePairs.length > 0 ? this.componentCompare.compareComponents(comparePairs).catch(() => undefined) : Promise.resolve()]).catch(() => {});
413
+ }
414
+ populateDiffStatusMemoAsync(resultMemoKey, results) {
415
+ Promise.all(results.map(async status => {
416
+ if (status.changes) return;
417
+ status.changes = await this.deriveComponentChanges(status);
418
+ })).then(() => {
419
+ // don't persist a result set where a component's change types couldn't be derived: `changes`
420
+ // came back undefined for a reason OTHER than being explicitly skipped (i.e. a transient
421
+ // Version-load failure). persisting it under the immutable lane-pair key would serve that gap
422
+ // forever; it recomputes and persists on a later request once the objects are available.
423
+ const anyFailed = results.some(s => s.changes === undefined && !s.changesContext?.skipped);
424
+ if (!anyFailed) this.laneDiffCache.storeDiffStatus(resultMemoKey, results);
425
+ }).catch(() => {});
426
+ }
336
427
  /**
337
428
  * return the lane data without the deleted components.
338
429
  * the deleted components are filtered out in legacyScope.lanes.getLanesData()
@@ -698,12 +789,9 @@ please create a new lane instead, which will include all components of this lane
698
789
  return deletedComps.map(c => c.id);
699
790
  }
700
791
 
701
- /**
702
- * get the head hash (snap) of main. return undefined if the component exists only on a lane and was never merged to main
703
- */
792
+ /** head hash of main, with remote-scope fallback. undefined only for a genuinely-new component. */
704
793
  async getHeadOnMain(componentId) {
705
- const modelComponent = await this.scope.legacyScope.getModelComponent(componentId);
706
- return modelComponent.head?.toString();
794
+ return (0, _lanesModules().getHeadOnMain)(this.scope, componentId);
707
795
  }
708
796
 
709
797
  /**
@@ -861,8 +949,13 @@ please create a new lane instead, which will include all components of this lane
861
949
  if (!lane) return [];
862
950
  const laneComponents = lane.components;
863
951
  const workspace = this.workspace;
864
- const bitIdsFromBitmap = workspace ? workspace.consumer.bitMap.getAllBitIdsFromAllLanes() : [];
865
- const filteredComponentIds = workspace ? laneComponents.filter(laneComponent => bitIdsFromBitmap.some(bitmapComponentId => bitmapComponentId.isEqualWithoutVersion(laneComponent.id))) : laneComponents;
952
+ // the bitmap filter only makes sense for the lane currently checked out — it keeps that view
953
+ // aligned with what's actually in the workspace. any OTHER lane lives only in the local scope:
954
+ // its components are never in the bitmap, so filtering by it empties the lane (a fully-fetched
955
+ // lane renders as "no components" in the overview) even though every object is available.
956
+ const isCurrentLane = Boolean(workspace && this.getCurrentLaneId()?.isEqual(lane.id));
957
+ const bitIdsFromBitmap = workspace && isCurrentLane ? workspace.consumer.bitMap.getAllBitIdsFromAllLanes() : [];
958
+ const filteredComponentIds = isCurrentLane ? laneComponents.filter(laneComponent => bitIdsFromBitmap.some(bitmapComponentId => bitmapComponentId.isEqualWithoutVersion(laneComponent.id))) : laneComponents;
866
959
  return filteredComponentIds.map(laneComponent => laneComponent.id.changeVersion(laneComponent.head));
867
960
  }
868
961
 
@@ -927,6 +1020,7 @@ please create a new lane instead, which will include all components of this lane
927
1020
  };
928
1021
  }
929
1022
  async diffStatus(sourceLaneId, targetLaneId, options) {
1023
+ await this.laneDiffCache.ensureLoaded();
930
1024
  this.logger.profile(`diff status for source lane: ${sourceLaneId.name} and target lane: ${targetLaneId?.name}`);
931
1025
  const sourceLane = sourceLaneId.isDefault() ? await this.getLaneDataOfDefaultLane() : await this.loadLane(sourceLaneId);
932
1026
  const sourceLaneComponents = sourceLaneId.isDefault() ? sourceLane?.components.map(main => ({
@@ -936,13 +1030,39 @@ please create a new lane instead, which will include all components of this lane
936
1030
  const targetLane = targetLaneId ? await this.loadLane(targetLaneId) : undefined;
937
1031
  const targetLaneIds = targetLane?.toBitIds();
938
1032
  const host = this.componentAspect.getHost();
939
- const targetMainHeads = !targetLaneId || targetLaneId?.isDefault() ? (0, _lodash().compact)(await Promise.all((sourceLaneComponents || []).map(async ({
1033
+
1034
+ // Resolve the main-side heads up-front: they feed BOTH the memo key (so the cache invalidates
1035
+ // when main advances) and the diff computation below. The target=main case has no single lane
1036
+ // hash, so without this the key was constant ('default') and stale results were served when main
1037
+ // moved ahead (lanes.spec "not up to date when main is ahead").
1038
+ const targetIsMain = !targetLaneId || Boolean(targetLaneId?.isDefault());
1039
+ // whether each base on main was already local (snapshot *before* the remote fetch) - drives the
1040
+ // UI "workspace vs remote scope" source indicator.
1041
+ const baseWasLocalByComp = new Map();
1042
+ if (targetIsMain) {
1043
+ const sourceComponentIds = await Promise.all((sourceLaneComponents || []).map(({
1044
+ id
1045
+ }) => host.resolveComponentId(id)));
1046
+ await Promise.all(sourceComponentIds.map(async cid => {
1047
+ const modelComp = await this.scope.legacyScope.getModelComponentIfExist(cid);
1048
+ baseWasLocalByComp.set(cid.toString(), Boolean(modelComp?.head));
1049
+ }));
1050
+ // the base on main may live only on the remote scope; pull it so the diff is real, not a spurious NEW.
1051
+ await (0, _lanesModules().importMainHeads)(this.scope, sourceComponentIds);
1052
+ }
1053
+ const targetMainHeads = targetIsMain ? (0, _lodash().compact)(await Promise.all((sourceLaneComponents || []).map(async ({
940
1054
  id
941
1055
  }) => {
942
1056
  const componentId = await host.resolveComponentId(id);
943
1057
  const headOnMain = await this.getHeadOnMain(componentId);
944
1058
  return headOnMain ? id.changeVersion(headOnMain) : undefined;
945
1059
  }))) : [];
1060
+ const resultMemoKey = this.laneDiffCache.diffStatusKey(sourceLaneId, sourceLane ?? undefined, targetLaneId, targetLane ?? undefined, options, targetMainHeads);
1061
+ const earlyReturn = this.tryDiffStatusResultMemo(resultMemoKey, sourceLaneId, targetLaneId);
1062
+ if (earlyReturn) {
1063
+ this.logger.profile(`diff status for source lane: ${sourceLaneId.name} and target lane: ${targetLaneId?.name}`);
1064
+ return earlyReturn;
1065
+ }
946
1066
  await this.importer.importObjectsFromMainIfExist(targetMainHeads, {
947
1067
  cache: true
948
1068
  });
@@ -972,12 +1092,28 @@ please create a new lane instead, which will include all components of this lane
972
1092
  })));
973
1093
  const snapDistancesByComponentId = new Map();
974
1094
  this.logger.profile(`get snaps distance for source lane: ${sourceLane?.id.name} and target lane: ${targetLane?.id.name} with ${diffProps.length} components`);
1095
+ let memoHits = 0;
975
1096
  await (0, _pMap().default)(diffProps, async ({
976
1097
  componentId,
977
1098
  sourceHead,
978
1099
  targetHead
979
1100
  }) => {
980
- const snapsDistance = await this.scope.getSnapsDistanceBetweenTwoSnaps(componentId, sourceHead, targetHead, false);
1101
+ const memoKey = this.laneDiffCache.snapsDistanceKey(componentId, sourceHead, targetHead);
1102
+ const cached = this.laneDiffCache.getSnapsDistance(memoKey, componentId.toString());
1103
+ let snapsDistance;
1104
+ if (cached) {
1105
+ memoHits += 1;
1106
+ snapsDistance = cached;
1107
+ } else {
1108
+ const computed = await this.scope.getSnapsDistanceBetweenTwoSnaps(componentId, sourceHead, targetHead, false);
1109
+ if (computed) {
1110
+ // cache success + the deterministic "unrelated" outcome. transient errors stay uncached.
1111
+ if (!computed.err || computed.err instanceof _legacy().NoCommonSnap) {
1112
+ this.laneDiffCache.storeSnapsDistance(memoKey, computed);
1113
+ }
1114
+ snapsDistance = computed;
1115
+ }
1116
+ }
981
1117
  if (snapsDistance) {
982
1118
  snapDistancesByComponentId.set(componentId.toString(), {
983
1119
  snapsDistance,
@@ -989,21 +1125,64 @@ please create a new lane instead, which will include all components of this lane
989
1125
  }, {
990
1126
  concurrency: (0, _harmonyModules().concurrentComponentsLimit)()
991
1127
  });
992
- this.logger.profile(`get snaps distance for source lane: ${sourceLane?.id.name} and target lane: ${targetLane?.id.name} with ${diffProps.length} components`);
993
- const commonSnapsToImport = (0, _lodash().compact)([...snapDistancesByComponentId.values()].map(s => s.snapsDistance.commonSnapBeforeDiverge ? s.componentId.changeVersion(s.snapsDistance.commonSnapBeforeDiverge.hash) : null));
1128
+ this.logger.profile(`get snaps distance for source lane: ${sourceLane?.id.name} and target lane: ${targetLane?.id.name} with ${diffProps.length} components (memo hits: ${memoHits}/${diffProps.length})`);
1129
+
1130
+ // when `skipUpToDate` is set, drop only components with NO difference vs the target. keep any
1131
+ // divergence — crucially `isSourceAhead()` (the lane's own changes): a lane forked from main has no
1132
+ // target-only snaps, so `isUpToDate()` (=!isTargetAhead) alone would wrongly hide the whole diff.
1133
+ const visibleDiffProps = options?.skipUpToDate ? diffProps.filter(({
1134
+ componentId
1135
+ }) => {
1136
+ // `d` may be a memo reconstruction exposing only data + `isUpToDate()`; test source-ahead via
1137
+ // `snapsOnSourceOnly.length`, not the `isSourceAhead()` method (absent on the reconstruction).
1138
+ const d = snapDistancesByComponentId.get(componentId.toString())?.snapsDistance;
1139
+ return !d || (d.snapsOnSourceOnly?.length ?? 0) > 0 || !d.isUpToDate();
1140
+ }) : diffProps;
1141
+ const commonSnapsToImport = (0, _lodash().compact)(visibleDiffProps.map(({
1142
+ componentId
1143
+ }) => {
1144
+ const s = snapDistancesByComponentId.get(componentId.toString());
1145
+ return s?.snapsDistance.commonSnapBeforeDiverge ? s.componentId.changeVersion(s.snapsDistance.commonSnapBeforeDiverge.hash) : null;
1146
+ }));
994
1147
  const sourceOrTargetLane = ((sourceLaneId.isDefault() ? null : sourceLane) || (targetLaneId?.isDefault() ? null : targetLane)) ?? undefined;
995
- if (commonSnapsToImport.length > 0 && !options?.skipChanges) {
1148
+ if (commonSnapsToImport.length > 0 && !options?.skipChanges && !options?.deferChanges) {
1149
+ this.logger.profile(`import common snaps for lane diff (${commonSnapsToImport.length} snaps)`);
996
1150
  await this.scope.legacyScope.scopeImporter.importWithoutDeps(_componentId().ComponentIdList.fromArray(commonSnapsToImport), {
997
1151
  cache: true,
998
1152
  reason: `get the common snap for lane diff`,
999
1153
  lane: sourceOrTargetLane
1000
1154
  });
1155
+ this.logger.profile(`import common snaps for lane diff (${commonSnapsToImport.length} snaps)`);
1001
1156
  }
1002
- const results = await (0, _pMapSeries().default)(diffProps, async ({
1157
+ this.logger.profile(`componentDiffStatus pMap (${visibleDiffProps.length} components)`);
1158
+ // run in parallel — bounded by `concurrentComponentsLimit()`. previously this was pMapSeries which
1159
+ // serialized 30 schema extractions and dominated the cold call (minutes).
1160
+ const results = await (0, _pMap().default)(visibleDiffProps, async ({
1003
1161
  componentId,
1004
1162
  sourceHead,
1005
1163
  targetHead
1006
- }) => this.componentDiffStatus(componentId, sourceHead, targetHead, snapDistancesByComponentId.get(componentId.toString())?.snapsDistance, options));
1164
+ }) => {
1165
+ // report whether an existing main base was already local (`workspace`) or pulled from remote (`scope`).
1166
+ const baseSource = targetIsMain && targetHead ? baseWasLocalByComp.get(componentId.toString()) ? 'workspace' : 'scope' : undefined;
1167
+ return this.componentDiffStatus(componentId, sourceHead, targetHead, snapDistancesByComponentId.get(componentId.toString())?.snapsDistance, options, baseSource);
1168
+ }, {
1169
+ concurrency: (0, _harmonyModules().concurrentComponentsLimit)()
1170
+ });
1171
+ this.logger.profile(`componentDiffStatus pMap (${visibleDiffProps.length} components)`);
1172
+
1173
+ // best-effort populate the top-level result memo. eagerly resolve `changes` for any deferred
1174
+ // components so the next cold call can return immediately — does not block this response.
1175
+ if (resultMemoKey) this.populateDiffStatusMemoAsync(resultMemoKey, results);
1176
+
1177
+ // Pre-warm caches the lane-compare UI will hit right after this query returns:
1178
+ // - `host.getMany([base+compare versioned ids])` populates `ScopeComponentLoader.componentsCache`
1179
+ // so the UI's 20 per-component `getHost.get` queries (componentFields + componentFieldWithLogs
1180
+ // for each side of each visible pair) all hit cache instead of racing through cold loads.
1181
+ // - `componentCompare.compareComponents(pairs)` populates the new compare-result memo so the UI's
1182
+ // `CompareComponents` query is answered from cache.
1183
+ // Fired *after* we have the answer ready — never blocks the response, catch() hides any failure.
1184
+ // Gated to `prewarmCaches` (the UI caller) so CLI/programmatic consumers don't pay & multiply it.
1185
+ if (options?.prewarmCaches && visibleDiffProps.length > 0) this.prewarmCompareCaches(host, visibleDiffProps);
1007
1186
  this.logger.profile(`diff status for source lane: ${sourceLaneId.name} and target lane: ${targetLaneId?.name}`);
1008
1187
  return {
1009
1188
  source: sourceLaneId,
@@ -1011,20 +1190,26 @@ please create a new lane instead, which will include all components of this lane
1011
1190
  componentsStatus: results
1012
1191
  };
1013
1192
  }
1014
- async componentDiffStatus(componentId, sourceHead, targetHead, snapsDistance, options) {
1193
+ async componentDiffStatus(componentId, sourceHead, targetHead, snapsDistance, options, baseSource) {
1015
1194
  if (snapsDistance?.err) {
1016
1195
  const noCommonSnap = snapsDistance.err instanceof _legacy().NoCommonSnap;
1017
1196
  return {
1018
1197
  componentId,
1019
1198
  sourceHead,
1020
1199
  targetHead,
1200
+ baseSource,
1021
1201
  upToDate: snapsDistance?.isUpToDate(),
1022
1202
  unrelated: noCommonSnap || undefined,
1023
1203
  changes: []
1024
1204
  };
1025
1205
  }
1026
1206
  const commonSnap = snapsDistance?.commonSnapBeforeDiverge;
1027
- const changes = !options?.skipChanges ? await this.deriveChangeTypes(commonSnap, componentId, sourceHead) : undefined;
1207
+
1208
+ // when changes are skipped or deferred, return the snap-distance metadata immediately and leave
1209
+ // `changes` unset. callers derive them lazily through `deriveComponentChanges` (used by the GraphQL
1210
+ // `changes` field resolver, which runs the work per-component in parallel only when the client
1211
+ // selects the field — keeps cold p50 under 200 ms even for many components).
1212
+ const changes = options?.skipChanges || options?.deferChanges ? undefined : await this.deriveChangeTypes(commonSnap, componentId, sourceHead);
1028
1213
  const changeType = changes ? changes[0] : undefined;
1029
1214
  return {
1030
1215
  componentId,
@@ -1032,70 +1217,103 @@ please create a new lane instead, which will include all components of this lane
1032
1217
  changes,
1033
1218
  sourceHead,
1034
1219
  targetHead: commonSnap?.hash,
1220
+ baseSource,
1035
1221
  upToDate: snapsDistance?.isUpToDate(),
1036
1222
  snapsDistance: {
1037
1223
  onSource: snapsDistance?.snapsOnSourceOnly.map(s => s.hash) ?? [],
1038
1224
  onTarget: snapsDistance?.snapsOnTargetOnly.map(s => s.hash) ?? [],
1039
1225
  common: snapsDistance?.commonSnapBeforeDiverge?.hash
1226
+ },
1227
+ changesContext: {
1228
+ commonSnap,
1229
+ skipped: options?.skipChanges
1040
1230
  }
1041
1231
  };
1042
1232
  }
1043
- async componentDiffStatusOld(componentId, sourceHead, targetHead, options) {
1044
- const snapsDistance = await this.scope.getSnapsDistanceBetweenTwoSnaps(componentId, sourceHead, targetHead, false);
1045
- if (snapsDistance?.err) {
1046
- const noCommonSnap = snapsDistance.err instanceof _legacy().NoCommonSnap;
1047
- return {
1048
- componentId,
1049
- sourceHead,
1050
- targetHead,
1051
- upToDate: snapsDistance?.isUpToDate(),
1052
- unrelated: noCommonSnap || undefined,
1053
- changes: []
1054
- };
1233
+
1234
+ /**
1235
+ * Lazily derive the change types for a single component diff status. Used by the GraphQL `changes`
1236
+ * field resolver so the (expensive) derivation only runs when the field is selected. Memoized per
1237
+ * status object via `changesContext.pending`, so selecting both `changes` and `changeType` computes
1238
+ * the value once.
1239
+ */
1240
+ async deriveComponentChanges(status) {
1241
+ if (status.changes) return status.changes;
1242
+ const context = status.changesContext;
1243
+ if (!context || context.skipped) return undefined;
1244
+ await this.laneDiffCache.ensureLoaded();
1245
+
1246
+ // top-level memo on the final ChangeType[] — short-circuits BEFORE compare()/getAPIDiff() run.
1247
+ // Persisted to disk, keyed on immutable hashes, so a cold server start with a populated cache
1248
+ // answers the entire derivation from a Map.get().
1249
+ const memoKey = this.laneDiffCache.changeTypesKey(status.componentId, status.sourceHead, context.commonSnap?.hash ?? null);
1250
+ const cached = this.laneDiffCache.getChangeTypes(memoKey);
1251
+ if (cached) return cached;
1252
+
1253
+ // single-flight concurrent requests for the same (componentId, sourceHead, commonSnap) tuple.
1254
+ let pending = this.changeTypesInflight.get(memoKey);
1255
+ if (!pending) {
1256
+ pending = this.deriveChangesLimit(() => this.deriveChangeTypes(context.commonSnap, status.componentId, status.sourceHead)).then(result => {
1257
+ // `undefined` means the derivation couldn't load a Version object (transient) — don't persist
1258
+ // it under the immutable-hash key; let it recompute on a later request once the object exists.
1259
+ if (result) this.laneDiffCache.storeChangeTypes(memoKey, result);
1260
+ return result;
1261
+ }).finally(() => {
1262
+ this.changeTypesInflight.delete(memoKey);
1263
+ });
1264
+ this.changeTypesInflight.set(memoKey, pending);
1055
1265
  }
1056
- const commonSnap = snapsDistance?.commonSnapBeforeDiverge;
1057
- const changes = !options?.skipChanges ? await this.deriveChangeTypes(commonSnap, componentId, sourceHead) : undefined;
1058
- const changeType = changes ? changes[0] : undefined;
1059
- return {
1060
- componentId,
1061
- changeType,
1062
- changes,
1063
- sourceHead,
1064
- targetHead: commonSnap?.hash,
1065
- upToDate: snapsDistance?.isUpToDate(),
1066
- snapsDistance: {
1067
- onSource: snapsDistance?.snapsOnSourceOnly.map(s => s.hash) ?? [],
1068
- onTarget: snapsDistance?.snapsOnTargetOnly.map(s => s.hash) ?? [],
1069
- common: snapsDistance?.commonSnapBeforeDiverge?.hash
1070
- }
1071
- };
1266
+ context.pending = pending;
1267
+ return pending;
1072
1268
  }
1073
1269
  async deriveChangeTypes(commonSnap, componentId, sourceHead) {
1074
1270
  if (!commonSnap) return [_lanesEntities().ChangeType.NEW];
1075
- const baseIdStr = componentId.changeVersion(commonSnap.hash).toString();
1076
- const compareIdStr = componentId.changeVersion(sourceHead).toString();
1077
- const [compare, apiDiff] = await Promise.all([this.componentCompare.compare(baseIdStr, compareIdStr), this.componentCompare.getAPIDiff(baseIdStr, compareIdStr)]);
1078
- const hasCodeChanges = compare.code.some(c => c.status !== 'UNCHANGED');
1079
- const hasFieldChanges = compare.fields.length > 0;
1080
- const hasApiChanges = apiDiff?.hasChanges ?? false;
1081
- if (!hasFieldChanges && !hasCodeChanges && !hasApiChanges) {
1082
- return [_lanesEntities().ChangeType.NONE];
1083
- }
1084
- const changed = [];
1085
- if (hasCodeChanges) {
1086
- changed.push(_lanesEntities().ChangeType.SOURCE_CODE);
1087
- }
1088
- if (hasFieldChanges) {
1089
- changed.push(_lanesEntities().ChangeType.ASPECTS);
1090
- }
1091
- const depsFields = ['dependencies', 'devDependencies', 'extensionDependencies'];
1092
- if (compare.fields.some(field => depsFields.includes(field.fieldName))) {
1093
- changed.push(_lanesEntities().ChangeType.DEPENDENCY);
1094
- }
1095
- if (hasApiChanges) {
1096
- changed.push(_lanesEntities().ChangeType.API);
1271
+
1272
+ // Classify changes by comparing the two raw `Version` objects directly (files-by-hash, deps,
1273
+ // extensions) instead of `componentCompare.compare()`. compare() loads full consumer components
1274
+ // (aspect calculation, capsules) for both sides just to extract two booleans — dominating the cold
1275
+ // derivation for a large lane. Loading the raw Version objects is a cheap object read.
1276
+ // The API change type is intentionally not computed here: it needs serial tsserver schema
1277
+ // extraction and is resolved lazily by the API view, gated on the SOURCE_CODE/DEPENDENCY evidence.
1278
+ const repo = this.scope.legacyScope.objects;
1279
+ const loadPair = () => Promise.all([repo.load(_objects().Ref.from(commonSnap.hash), false), repo.load(_objects().Ref.from(sourceHead), false)]);
1280
+ let [baseVersion, compareVersion] = await loadPair();
1281
+
1282
+ // the deferChanges path (the default for UI lane compares) skips the eager common-snap import, so
1283
+ // the merge-base Version can be absent locally here. rather than give up — which classifies a
1284
+ // genuinely-changed component as "couldn't classify" and drops it from the diff views — import the
1285
+ // missing snap(s) on demand and retry once. this fires only on a miss and is per-component, so the
1286
+ // fast deferred path stays fast while staying correct; the derived result is memoized afterwards.
1287
+ if (!baseVersion || !compareVersion) {
1288
+ const missing = [];
1289
+ if (!baseVersion) missing.push(componentId.changeVersion(commonSnap.hash));
1290
+ if (!compareVersion) missing.push(componentId.changeVersion(sourceHead));
1291
+ try {
1292
+ await this.scope.legacyScope.scopeImporter.importWithoutDeps(_componentId().ComponentIdList.fromArray(missing), {
1293
+ cache: true,
1294
+ ignoreMissingHead: true,
1295
+ reason: `derive lane diff change types (snap missing locally)`
1296
+ });
1297
+ [baseVersion, compareVersion] = await loadPair();
1298
+ } catch {
1299
+ // best-effort: if the import fails (e.g. the snap only exists on a lane we lack context for here),
1300
+ // fall through to the undefined return below rather than throwing out of the field resolver.
1301
+ }
1097
1302
  }
1098
- return changed;
1303
+
1304
+ // still couldn't load a Version. this is a transient "couldn't classify", NOT "no changes"
1305
+ // ([ChangeType.NONE]) — return undefined so it's distinguishable and never persisted to the
1306
+ // immutable-hash-keyed memo, or the component would be reported with no change types forever.
1307
+ if (!baseVersion || !compareVersion) return undefined;
1308
+
1309
+ // pure classification of the two raw Version shapes — extracted & unit-tested in lanes/diff.
1310
+ return (0, _lanesModules().classifyVersionChanges)({
1311
+ obj: baseVersion.toObject(),
1312
+ extensionDependencies: baseVersion.extensionDependencies.cloneAsString()
1313
+ }, {
1314
+ obj: compareVersion.toObject(),
1315
+ extensionDependencies: compareVersion.extensionDependencies.cloneAsString()
1316
+ });
1099
1317
  }
1100
1318
  async recreateNewLaneIfDeleted() {
1101
1319
  if (!this.workspace) return;